diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py
index 4ee4eb95..9689e2ff 100644
--- a/.github/scripts/run_offline_qualified.py
+++ b/.github/scripts/run_offline_qualified.py
@@ -13,6 +13,7 @@ import sys
import tempfile
PYTHON_FILES = (
+ "AppImage/scripts/tests/test_pbs_i18n.py",
"AppImage/scripts/tests/test_monitor_links_i18n.py",
"AppImage/scripts/tests/test_monitor_links.py",
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
@@ -30,6 +31,7 @@ PYTHON_FILES = (
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
)
NODE_FILES = (
+ "tests/test_pbs_i18n.cjs",
"tests/test_monitor_links_i18n.cjs",
"tests/lxc_updates/test_local_app_icons_ui.cjs",
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml
index 1993c320..53880af5 100644
--- a/.github/workflows/test-offline-qualified.yml
+++ b/.github/workflows/test-offline-qualified.yml
@@ -16,6 +16,7 @@ on:
- 'AppImage/scripts/tests/test_local_app_icons.py'
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
+ - 'AppImage/scripts/tests/test_pbs_i18n.py'
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
- 'AppImage/app/**'
- 'AppImage/components/**'
@@ -41,6 +42,7 @@ on:
- 'AppImage/scripts/tests/test_local_app_icons.py'
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
+ - 'AppImage/scripts/tests/test_pbs_i18n.py'
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
- 'AppImage/app/**'
- 'AppImage/components/**'
diff --git a/.github/workflows/test-pbs.yml b/.github/workflows/test-pbs.yml
index 2dbc71b0..84a23736 100644
--- a/.github/workflows/test-pbs.yml
+++ b/.github/workflows/test-pbs.yml
@@ -6,6 +6,7 @@ on:
- '.github/workflows/test-pbs.yml'
- 'AppImage/scripts/*pbs*.py'
- 'AppImage/scripts/tests/test_pbs.py'
+ - 'AppImage/scripts/tests/test_pbs_i18n.py'
- 'AppImage/scripts/health_monitor.py'
- 'AppImage/scripts/auth_manager.py'
- 'AppImage/scripts/build_appimage.sh'
@@ -51,6 +52,7 @@ jobs:
- name: Platform, real TLS, APIs, health and AppImage packaging
run: |
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
+ python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
bash -n "$script"
diff --git a/AppImage/components/pbs-datastore-usage.tsx b/AppImage/components/pbs-datastore-usage.tsx
index 944ba340..c43682d3 100644
--- a/AppImage/components/pbs-datastore-usage.tsx
+++ b/AppImage/components/pbs-datastore-usage.tsx
@@ -4,7 +4,8 @@ import { CalendarClock, Database } from "lucide-react"
import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress"
import { formatStorage } from "../lib/utils"
-import { useT } from "../lib/i18n/provider"
+import { useT, useI18n } from "../lib/i18n/provider"
+import { PBSProblemMessage } from "./pbs-problem-message"
export interface PBSDatastore {
name: string
@@ -13,6 +14,7 @@ export interface PBSDatastore {
used?: number | null
avail?: number | null
error?: string
+ error_code?: string
"backend-type"?: string
"estimated-full-date"?: number
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
@@ -42,7 +44,7 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
{store.name}
—
-
{store.error || t("pbs.capacityUnavailable")}
+
}
const tone = usageTone(percent)
@@ -62,10 +64,11 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
const t = useT()
+ const { language } = useI18n()
const percent = datastorePercent(store)
const tone = percent == null ? null : usageTone(percent)
const fullDate = store["estimated-full-date"]
- const type = store["backend-type"] || "datastore"
+ const type = store["backend-type"] || t("pbs.datastoreType")
return
@@ -99,13 +102,13 @@ export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
) : (
-
{store.error || t("storage.capacityNotReported")}
+
)}
{(store.path || fullDate !== undefined) &&
{store.path && {store.path}}
{fullDate !== undefined &&
{t("pbs.estimatedFull")}:
- {fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")}
+ {fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString(language) : t("pbs.estimatedFullUnknown")}
}
}
diff --git a/AppImage/components/pbs-panel.tsx b/AppImage/components/pbs-panel.tsx
index 5ea6856d..5dff026e 100644
--- a/AppImage/components/pbs-panel.tsx
+++ b/AppImage/components/pbs-panel.tsx
@@ -14,8 +14,10 @@ import { Label } from "./ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
+import { PBSProblemMessage } from "./pbs-problem-message"
+import { pbsDiagnostic, pbsResultText, pbsServiceState, pbsTaskType, formatPBSDuration } from "../lib/pbs-messages"
import { fetchApi } from "../lib/api-config"
-import { useT } from "../lib/i18n/provider"
+import { useT, useI18n } from "../lib/i18n/provider"
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
interface Detected { name: string; url: string; fingerprint: string }
@@ -26,6 +28,7 @@ interface PBSStatus {
name: string; version: string | null; fetched_at: number; services: Record;
datastores: PBSDatastore[] | null; tasks: Task[] | null;
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record;
+ error_details?: Record }>;
}
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "", username: "", password: "" }
@@ -46,9 +49,10 @@ const TASK_ICONS: Record = {
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
}
-// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running.
+// PBS reports "unknown", "OK", "WARNINGS: n" or an error; no state is still running.
function taskState(task: Task): keyof typeof badgeTone {
if (!task.status) return task.endtime ? "muted" : "running"
+ if (task.status === "unknown") return "muted"
if (task.status === "OK") return "ok"
if (task.status.startsWith("WARNINGS")) return "warn"
return "error"
@@ -56,27 +60,19 @@ function taskState(task: Task): keyof typeof badgeTone {
// PBS reports "OK", "WARNINGS: n" or the error text of the last run.
function jobState(state?: string): keyof typeof badgeTone {
- if (!state) return "muted"
+ if (!state || state === "unknown") return "muted"
if (state.toLowerCase() === "ok") return "ok"
if (state.toLowerCase().startsWith("warn")) return "warn"
return "error"
}
-function formatDuration(seconds: number) {
- if (!Number.isFinite(seconds) || seconds < 0) return "—"
- if (seconds < 60) return `${Math.round(seconds)}s`
- const minutes = Math.floor(seconds / 60)
- if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`
- const hours = Math.floor(minutes / 60)
- return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
-}
-
// `localOnly` is the PBS host's own Monitor: no connection picker, the
// header Refresh already reloads the page data. Its tabs show one `view` each.
export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: boolean; view?: "all" | "datastores" | "tasks" } = {}) {
const showStores = view !== "tasks"
const showTasks = view !== "datastores"
const t = useT()
+ const { language } = useI18n()
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR(
"/api/pbs/servers", url => fetchApi(url), { revalidateOnFocus: false })
const [selected, setSelected] = useState("")
@@ -85,7 +81,8 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
const [method, setMethod] = useState("password")
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("")
- const [error, setError] = useState("")
+ const [error, setError] = useState(null)
+ const [errorFallback, setErrorFallback] = useState("pbs.saveFailed")
const { data, error: statusError, isLoading, mutate } = useSWR(
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
url => fetchApi(url), { refreshInterval: 30000, revalidateOnFocus: false })
@@ -102,7 +99,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
}
const save = async () => {
+ setErrorFallback("pbs.saveFailed")
setBusy(true); setError(""); setMessage("")
+ let saved = false
try {
// A password is sent once to create a read-only token; only the token is kept.
const { username, password, ...connection } = form
@@ -113,22 +112,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
: await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
...connection, id: form.id || undefined, token_secret: form.token_secret || undefined,
}) })
+ saved = true
await reloadConnections()
setSelected(result.id)
setForm(emptyForm)
setManage(false)
- } catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
+ } catch (err) { setError(saved ? { body: { code: "pbs_saved_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
finally { setBusy(false) }
}
const remove = async () => {
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
+ setErrorFallback("pbs.removeFailed")
setBusy(true); setError(""); setMessage("")
+ let removed = false
try {
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
+ removed = true
setForm(emptyForm)
await reloadConnections()
- } catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
+ } catch (err) { setError(removed ? { body: { code: "pbs_removed_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
finally { setBusy(false) }
}
const now = Date.now() / 1000
@@ -150,8 +153,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
{t("pbs.manage")}
}
- {(connectionError || statusError) &&
- {String((connectionError || statusError)?.message || t("pbs.loadFailed"))}
}
+ {(connectionError || statusError) && }
{isLoading && {t("pbs.loading")}
}
{!selected && !connectionError && connections && {t("pbs.noServer")}
}
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 &&
@@ -171,7 +175,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
}
{!localOnly &&