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 && { if (!busy) setManage(open) }}> - + {t("pbs.manage")} {method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")} @@ -201,7 +205,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool )} } -
+
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => { const secret = field === "token_secret" || field === "password" return
@@ -213,7 +217,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool })}

{t("pbs.tlsHelp")}

- {error &&

{error}

} + {!!error &&
+ +
} {message &&

{message}

}
{form.id &&
} {data && <> {!localOnly &&
- {data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })} + {data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString(language) })} {Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active") ? {t("pbs.servicesActive")} : Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) => - {service}: {state})} + {service}: {pbsServiceState(state, t)})}
} {Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks) - .map(([section, detail]) =>

{t(`pbs.${section}`)}: {detail}

)} + .map(([section, detail]) =>
+

{t(`pbs.${["datastores", "tasks", "sync", "verify", "prune"].includes(section) ? section : "title"}`)}

+ +
)} {showStores && {t("pbs.datastores")} @@ -268,14 +277,14 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool return - {row.worker_type} + {pbsTaskType(row.worker_type, t)} {row.worker_id && {row.worker_id}} - {row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"} - {row.starttime && end ? formatDuration(end - row.starttime) : "—"} - - {state === "running" ? t("pbs.running") : row.status || "—"} - + {row.starttime ? new Date(row.starttime * 1000).toLocaleString(language) : "—"} + {row.starttime && end ? formatPBSDuration(end - row.starttime, t) : "—"} + + + })} } @@ -299,7 +308,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool {t(`pbs.${kind}`)} · {row.id} {row.store || "—"} {row.schedule || "—"} - {row["last-run-state"] || "—"} + )} })()} @@ -309,3 +318,15 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool } } + +function PBSResultBadge({ status, running = false, tone }: { status?: string; running?: boolean; tone: string }) { + const t = useT() + const badge = + {running ? t("pbs.running") : pbsResultText(status, t)} + + if (running || !status || /^ok$/i.test(status)) return badge + return
+ {badge} +
{status}
+
+} diff --git a/AppImage/components/pbs-problem-message.tsx b/AppImage/components/pbs-problem-message.tsx new file mode 100644 index 00000000..459701c4 --- /dev/null +++ b/AppImage/components/pbs-problem-message.tsx @@ -0,0 +1,18 @@ +"use client" + +import { pbsErrorText } from "../lib/pbs-messages" +import { useT } from "../lib/i18n/provider" + +// Keep PBS's original diagnostic available without making it the UI message. +export function PBSProblemMessage({ problem, raw, fallback, className = "" }: { + problem?: unknown; raw?: string; fallback?: string; className?: string +}) { + const t = useT() + return
+

{pbsErrorText(problem, t, fallback)}

+ {raw &&
+ {t("pbs.technicalDetails")} +
{raw}
+
} +
+} diff --git a/AppImage/components/system-overview.tsx b/AppImage/components/system-overview.tsx index ecbeeef7..7458485b 100644 --- a/AppImage/components/system-overview.tsx +++ b/AppImage/components/system-overview.tsx @@ -7,6 +7,8 @@ import { Progress } from "./ui/progress" import { Badge } from "./ui/badge" import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react" import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage" +import { PBSProblemMessage } from "./pbs-problem-message" +import { pbsDiagnostic } from "../lib/pbs-messages" import { NodeMetricsCharts } from "./node-metrics-charts" import { NetworkTrafficChart } from "./network-traffic-chart" import { TemperatureDetailModal } from "./temperature-detail-modal" @@ -187,14 +189,20 @@ const getUnitsSettings = (): "Bytes" | "Bits" => { return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes" } +interface PBSOverviewStatus { + datastores: PBSDatastore[] | null + errors?: Record + error_details?: Record +} + export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) { const t = useT() const { language } = useI18n() const isPbs = product === "pbs" // On Proxmox Backup Server the datastores take the place of guests and PVE storage. - const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>( + const { data: pbsStatus, error: pbsError } = useSWR( isPbs ? "/api/pbs/status?server=local" : null, - (url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url), + (url: string) => fetchApi(url), { refreshInterval: 60000, revalidateOnFocus: false }, ) const datastores = pbsStatus?.datastores ?? null @@ -545,13 +553,15 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } - {!datastores && !pbsError ? ( + {!pbsStatus && !pbsError ? (
) : !datastores ? ( -

{t("pbs.loadFailed")}

+
+ +
) : datastores.length === 0 ? (

{t("pbs.noDatastores")}

) : ( @@ -762,8 +772,12 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } {isPbs ? (
{t("pbs.datastores")}
- {!datastores ? ( -
{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}
+ {!pbsStatus && !pbsError ? ( +
{t("pbs.loading")}
+ ) : !datastores ? ( +
+ +
) : datastores.length === 0 ? (
{t("pbs.noDatastores")}
) : datastores.map(store => )} diff --git a/AppImage/lib/pbs-messages.ts b/AppImage/lib/pbs-messages.ts new file mode 100644 index 00000000..6ce6faa3 --- /dev/null +++ b/AppImage/lib/pbs-messages.ts @@ -0,0 +1,77 @@ +type Translate = (key: string, params?: Record) => string + +const ERROR_KEYS: Record = { + pbs_config_read: "pbs.errors.configRead", pbs_invalid_config: "pbs.errors.invalidConfig", + pbs_invalid_field: "pbs.errors.invalidField", pbs_invalid_url: "pbs.errors.invalidUrl", + pbs_name_required: "pbs.errors.nameRequired", pbs_token_id_format: "pbs.errors.tokenIdFormat", + pbs_token_secret: "pbs.errors.tokenSecret", pbs_invalid_token_id: "pbs.errors.invalidTokenId", + pbs_invalid_fingerprint: "pbs.errors.invalidFingerprint", pbs_fingerprint_mismatch: "pbs.errors.fingerprintMismatch", + pbs_token_rejected: "pbs.errors.tokenRejected", pbs_invalid_response: "pbs.errors.invalidResponse", + pbs_invalid_json: "pbs.errors.invalidJson", pbs_connect_failed: "pbs.errors.connectFailed", + pbs_invalid_id: "pbs.errors.invalidId", pbs_invalid_datastores: "pbs.errors.invalidDatastores", + pbs_username: "pbs.errors.username", pbs_password: "pbs.errors.password", + pbs_login_rejected: "pbs.errors.loginRejected", pbs_invalid_login: "pbs.errors.invalidLogin", + pbs_token_create_denied: "pbs.errors.tokenCreateDenied", pbs_invalid_token: "pbs.errors.invalidToken", + pbs_audit_role_denied: "pbs.errors.auditRoleDenied", pbs_not_found: "pbs.errors.notFound", + pbs_read_failed: "pbs.errors.readFailed", pbs_invalid_list: "pbs.errors.invalidList", + pbs_local_unavailable: "pbs.errors.localUnavailable", pbs_auth_required: "pbs.errors.authRequired", + pbs_save_failed: "pbs.saveFailed", pbs_remove_failed: "pbs.removeFailed", + pbs_saved_refresh_failed: "pbs.savedRefreshFailed", pbs_removed_refresh_failed: "pbs.removedRefreshFailed", + pbs_datastore_offline: "pbs.errors.datastoreOffline", pbs_datastore_unavailable: "pbs.errors.datastoreUnavailable", +} + +// Accept structured codes from fetchApi, partial status or a datastore. Legacy +// English errors remain diagnostics; never interpret arbitrary text as a key. +export function pbsErrorText(error: unknown, t: Translate, fallback = "pbs.loadFailed"): string { + const body = error && typeof error === "object" && "body" in error ? error.body : error + if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return t(fallback) + if (body.code === "pbs_connection_limit") { + const params = "params" in body ? body.params : null + const count = params && typeof params === "object" && "count" in params ? params.count : null + return typeof count === "number" && Number.isSafeInteger(count) && count > 0 + ? t("pbs.errors.connectionLimit", { count }) : t(fallback) + } + return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code) ? t(ERROR_KEYS[body.code]) : t(fallback) +} + +export function pbsDiagnostic(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined + const body = "body" in error ? error.body : null + if (body && typeof body === "object" && "error" in body && typeof body.error === "string") return body.error + return "message" in error && typeof error.message === "string" ? error.message : undefined +} + +const TASK_TYPES: Record = { + backup: "backup", reader: "restore", verify: "verify", verificationjob: "verify", + verify_group: "verifyGroup", verify_snapshot: "verifySnapshot", prune: "prune", prunejob: "prune", + garbage_collection: "garbageCollection", sync: "sync", syncjob: "sync", aptupdate: "updateCheck", + termproxy: "terminal", dircreate: "createDatastore", "create-datastore": "createDatastore", +} +export function pbsTaskType(type: string | undefined, t: Translate): string { + return type && Object.prototype.hasOwnProperty.call(TASK_TYPES, type) + ? t(`pbs.taskTypes.${TASK_TYPES[type]}`) : t("pbs.taskTypes.other", { type: type || "—" }) +} + +export function pbsResultText(status: string | undefined, t: Translate): string { + if (!status || status === "unknown") return t("pbs.states.unknown") + if (status.toLowerCase() === "ok") return t("pbs.states.ok") + const warnings = /^WARNINGS:\s*(\d+)\s*$/i.exec(status) + if (warnings && Number.isSafeInteger(Number(warnings[1]))) return t("pbs.states.warnings", { count: Number(warnings[1]) }) + return /^warn/i.test(status) ? t("pbs.states.warning") : t("pbs.states.error") +} + +export function pbsServiceState(state: string, t: Translate): string { + const known = ["active", "inactive", "failed", "activating", "deactivating", "unknown"] + return t(`pbs.serviceStates.${known.includes(state) ? state : "unknown"}`) +} + +export function formatPBSDuration(seconds: number, t: Translate): string { + if (!Number.isFinite(seconds) || seconds < 0) return "—" + const unit = (key: string, count: number) => t(`pbs.durationUnits.${key}`, { count }) + if (seconds < 60) return unit("seconds", Math.round(seconds)) + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${unit("minutes", minutes)} ${unit("seconds", Math.round(seconds % 60))}` + const hours = Math.floor(minutes / 60) + return hours < 24 ? `${unit("hours", hours)} ${unit("minutes", minutes % 60)}` + : `${unit("days", Math.floor(hours / 24))} ${unit("hours", hours % 24)}` +} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index aa4a5c4a..f4a3b71c 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -72,7 +72,55 @@ "estimatedFull": "Estimated full", "estimatedFullUnknown": "Not enough data yet", "duration": "Duration", - "noJobs": "No sync, verification or prune schedules are configured." + "noJobs": "No sync, verification or prune schedules are configured.", + "removeFailed": "Could not remove the PBS connection.", + "savedRefreshFailed": "The PBS connection was saved, but the connection list could not be refreshed. Reload the page before trying another change.", + "removedRefreshFailed": "The PBS connection was removed, but the connection list could not be refreshed. Reload the page.", + "technicalDetails": "Technical details", + "datastoreType": "Datastore", + "errors": { + "configRead": "Could not read the saved PBS connections. Check the configuration file and permissions.", + "invalidConfig": "The PBS connection settings are not valid. Check the form and try again.", + "invalidField": "A connection field is not valid or is too long.", + "invalidUrl": "Use an HTTPS PBS address without embedded credentials, query parameters or a fragment.", + "nameRequired": "Enter a server name of at most 128 characters.", + "tokenIdFormat": "Enter the PBS API token ID in user@realm!token format.", + "tokenSecret": "Enter a valid API token secret without spaces or control characters.", + "invalidTokenId": "The API token ID must contain only ASCII characters.", + "invalidFingerprint": "Enter the certificate's SHA256 fingerprint: 64 hexadecimal characters, optionally separated by colons.", + "fingerprintMismatch": "The PBS certificate does not match the fingerprint. No credentials were sent.", + "tokenRejected": "PBS rejected the API token or its permissions. Check the ACLs of both the user and the token.", + "invalidResponse": "PBS returned an unexpected response. Redirects are not followed.", + "invalidJson": "PBS returned an invalid JSON document.", + "connectFailed": "Could not connect to PBS over verified HTTPS. Check the address, certificate and service.", + "invalidId": "The PBS connection ID is not valid. Reload the connection list.", + "invalidDatastores": "PBS returned an invalid datastore list.", + "connectionLimit": "At most {count} PBS connections may be configured.", + "username": "Enter the PBS user in user@realm format, for example root@pam.", + "password": "Enter the PBS user's password.", + "loginRejected": "PBS rejected the username or password.", + "invalidLogin": "PBS returned an invalid sign-in response.", + "tokenCreateDenied": "This PBS user cannot create API tokens.", + "invalidToken": "PBS returned an invalid API token.", + "auditRoleDenied": "This PBS user cannot grant the Audit role. Sign in as root@pam or another administrator.", + "notFound": "The PBS connection was not found. Reload the connection list.", + "readFailed": "Could not read PBS data. Check its service and permissions.", + "invalidList": "PBS returned an invalid data list.", + "localUnavailable": "PBS is not available on this host.", + "authRequired": "Enable local authentication in Monitor before managing remote PBS connections.", + "datastoreOffline": "The datastore is offline, in maintenance mode or its backing device is not mounted.", + "datastoreUnavailable": "The datastore filesystem is not available." + }, + "taskTypes": { + "backup": "Backup", "restore": "Restore", "verify": "Verification", + "verifyGroup": "Backup group verification", "verifySnapshot": "Snapshot verification", + "prune": "Prune", "garbageCollection": "Garbage collection", "sync": "Synchronization", + "updateCheck": "Check for updates", "terminal": "Terminal", "createDatastore": "Create datastore", + "other": "Other task ({type})" + }, + "states": { "ok": "OK", "unknown": "Unknown", "warnings": "Warnings: {count}", "warning": "Warning", "error": "Error" }, + "serviceStates": { "active": "Active", "inactive": "Inactive", "failed": "Failed", "activating": "Starting", "deactivating": "Stopping", "unknown": "Unknown" }, + "durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" } }, "app": { "title": "ProxMenux Monitor", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 29ea7bd2..801b456e 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -8,7 +8,7 @@ "notAvailable": "Nedostupné", "serverOffline": "Server je offline", "supportProject": "Podporte projekt alebo prispejte vlastnými úpravami", - "descriptionPbs": "Proxmox Backup Server Dashboard" + "descriptionPbs": "Prehľad systému Proxmox Backup Server" }, "common": { "notAvailable": "Nedostupné", @@ -7299,35 +7299,35 @@ "title": "Proxmox Backup Server", "server": "Server PBS", "chooseServer": "Vybrať server PBS", - "local": "Miestny PBS", + "local": "Lokálny PBS", "thisNode": "PBS na tomto uzle", "updatedAt": "Aktualizované o {time}", "servicesActive": "Služby sú aktívne", - "detectedTitle": "PBS zistené v úložisku Proxmox", - "detectedHelp": "Tieto Proxmox Backup Servery sú nastavené ako úložisko záloh. Pripojte každý z nich API tokenom len na čítanie, aby ste tu videli jeho datastory, úlohy a joby.", + "detectedTitle": "PBS zistené v nastaveniach úložísk Proxmoxu", + "detectedHelp": "Tieto servery Proxmox Backup Server sú nastavené ako úložiská záloh. Pripoj ich pomocou API tokenu iba na čítanie a zobrazia sa tu ich dátové úložiská, úlohy a plány údržby.", "connect": "Pripojiť", "tasksTab": "Úlohy", "signInWith": "Prihlásiť sa pomocou", "methodPassword": "Používateľ a heslo", "methodToken": "API token", - "username": "Používateľ (pouzivatel@realm)", + "username": "Používateľ (user@realm)", "password": "Heslo", - "passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu len na čítanie (rola Audit) pre tento Monitor. Uloží sa iba tento token, nikdy nie heslo. Najprv zapnite overovanie v Monitore.", + "passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu iba na čítanie s rolou Audit pre tento Monitor. Uloží sa iba token, heslo sa neukladá. Najprv zapni overovanie v Monitore.", "cancel": "Zrušiť", "refresh": "Obnoviť", "manage": "Spravovať pripojenia PBS", "loadFailed": "Nepodarilo sa načítať stav PBS.", "loading": "Načítavajú sa údaje PBS…", - "noServer": "Pridajte pripojenie PBS na zobrazenie dátových úložísk, úloh a plánov.", - "permissionsHelp": "Pred uložením prihlasovacích údajov povoľte overovanie v Monitore. Použite vyhradený API token PBS s rolou Audit na / v ACL používateľa aj tokenu, so zapnutým dedením. Tento panel údaje PBS iba číta.", + "noServer": "Pridaj pripojenie PBS a zobrazia sa jeho dátové úložiská, úlohy a plány údržby.", + "permissionsHelp": "Pred uložením prihlasovacích údajov zapni overovanie v Monitore. Použi vyhradený API token PBS. Používateľ aj token musia mať v zozname prístupových oprávnení (ACL) rolu Audit na ceste / so zapnutým dedením. Tento panel údaje PBS iba číta.", "editConnection": "Pripojenie na úpravu", "add": "Pridať nové pripojenie", "name": "Názov", - "url": "HTTPS URL (pre pôvodný port PBS uveďte :8007)", + "url": "Adresa HTTPS (pre štandardný port PBS uveď :8007)", "token_id": "ID API tokenu (user@realm!token)", - "token_secret": "Tajný kľúč API tokenu (ponechajte prázdne, ak chcete zachovať uložený kľúč)", + "token_secret": "Tajný kľúč API tokenu (prázdne pole zachová uložený kľúč)", "fingerprint": "Odtlačok SHA256 certifikátu (voliteľný)", - "tlsHelp": "Pre bežné overenie certifikačnej autority a názvu hostiteľa ponechajte odtlačok prázdny. Pri certifikáte s vlastným podpisom skopírujte jeho odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného kanála. Odtlačok sa kontroluje pred odoslaním prihlasovacích údajov.", + "tlsHelp": "Pri bežnom overovaní certifikačnej autority a názvu hostiteľa nechaj odtlačok prázdny. Pri certifikáte s vlastným podpisom skopíruj odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného zdroja. Kontroluje sa ešte pred odoslaním prihlasovacích údajov.", "saved": "Pripojenie PBS bolo overené a uložené.", "saveFailed": "Nepodarilo sa uložiť pripojenie PBS.", "testing": "Overuje sa pripojenie…", @@ -7339,9 +7339,9 @@ "total": "Kapacita súborového systému", "used": "Použité", "free": "Dostupné", - "noDatastores": "Žiadne dostupné dátové úložiská. Skontrolujte konfiguráciu úložísk a oprávnenia používateľa a tokenu.", + "noDatastores": "Nie sú dostupné žiadne dátové úložiská. Skontroluj ich nastavenia a oprávnenia používateľa aj tokenu.", "tasks": "Nedávne úlohy", - "noTasks": "Neboli vrátené žiadne nedávne úlohy.", + "noTasks": "PBS nevrátil žiadne nedávne úlohy.", "task": "Úloha", "started": "Spustené", "status": "Stav", @@ -7351,7 +7351,7 @@ "schedule": "Plán", "sync": "Synchronizácia", "verify": "Overenie", - "prune": "Prerezávanie", + "prune": "Prerezávanie záloh", "overview": "Prehľad", "logs": "Systémové záznamy", "terminal": "Terminál", @@ -7362,13 +7362,61 @@ "memory": "Pamäť", "uptime": "Čas prevádzky", "capacityUnavailable": "Kapacita nie je k dispozícii.", - "datastoresUnavailable": "{count} nie je k dispozícii", + "datastoresUnavailable": "Nedostupné úložiská: {count}", "datastoresOnline": "Všetky dostupné", "moreDatastores": "+{count} ďalších dátových úložísk", - "estimatedFull": "Odhadovaný plný", - "estimatedFullUnknown": "Zatiaľ nie je dostatok údajov", + "estimatedFull": "Odhadované zaplnenie", + "estimatedFullUnknown": "Zatiaľ sa nedá odhadnúť", "duration": "Trvanie", - "noJobs": "Nie sú nakonfigurované žiadne plány synchronizácie, overovania ani odstraňovania." + "noJobs": "Nie sú nastavené žiadne plány synchronizácie, overovania ani prerezávania záloh.", + "removeFailed": "Nepodarilo sa odstrániť pripojenie PBS.", + "savedRefreshFailed": "Pripojenie PBS sa uložilo, ale nepodarilo sa obnoviť zoznam pripojení. Pred ďalšou zmenou obnov stránku.", + "removedRefreshFailed": "Pripojenie PBS sa odstránilo, ale nepodarilo sa obnoviť zoznam pripojení. Obnov stránku.", + "technicalDetails": "Technické podrobnosti", + "datastoreType": "Dátové úložisko", + "errors": { + "configRead": "Nepodarilo sa načítať uložené pripojenia PBS. Skontroluj konfiguračný súbor a oprávnenia.", + "invalidConfig": "Nastavenia pripojenia PBS nie sú platné. Skontroluj formulár a skús to znova.", + "invalidField": "Niektoré pole pripojenia nie je platné alebo je príliš dlhé.", + "invalidUrl": "Použi adresu HTTPS bez vložených prihlasovacích údajov, parametrov za otáznikom a časti za znakom #.", + "nameRequired": "Zadaj názov servera s najviac 128 znakmi.", + "tokenIdFormat": "Zadaj ID API tokenu PBS vo formáte user@realm!token.", + "tokenSecret": "Zadaj platný tajný kľúč API tokenu bez medzier a riadiacich znakov.", + "invalidTokenId": "ID API tokenu môže obsahovať iba znaky ASCII.", + "invalidFingerprint": "Zadaj odtlačok SHA256 certifikátu: 64 šestnástkových znakov, prípadne oddelených dvojbodkami.", + "fingerprintMismatch": "Certifikát PBS sa nezhoduje s odtlačkom. Prihlasovacie údaje sa neodoslali.", + "tokenRejected": "PBS odmietol API token alebo jeho oprávnenia. Skontroluj zoznam prístupových oprávnení (ACL) používateľa aj tokenu.", + "invalidResponse": "PBS vrátil neočakávanú odpoveď. Presmerovania sa nenasledujú.", + "invalidJson": "PBS vrátil neplatný dokument JSON.", + "connectFailed": "Nepodarilo sa pripojiť k PBS cez overené HTTPS. Skontroluj adresu, certifikát a službu.", + "invalidId": "ID pripojenia PBS nie je platné. Obnov zoznam pripojení.", + "invalidDatastores": "PBS vrátil neplatný zoznam dátových úložísk.", + "connectionLimit": "Môžeš nastaviť najviac {count} pripojení PBS.", + "username": "Zadaj používateľa PBS vo formáte user@realm, napríklad root@pam.", + "password": "Zadaj heslo používateľa PBS.", + "loginRejected": "PBS odmietol používateľské meno alebo heslo.", + "invalidLogin": "PBS vrátil neplatnú odpoveď na prihlásenie.", + "tokenCreateDenied": "Tento používateľ PBS nemôže vytvárať API tokeny.", + "invalidToken": "PBS vrátil neplatný API token.", + "auditRoleDenied": "Tento používateľ PBS nemôže prideliť rolu Audit. Prihlás sa ako root@pam alebo iný správca.", + "notFound": "Pripojenie PBS sa nenašlo. Obnov zoznam pripojení.", + "readFailed": "Nepodarilo sa načítať údaje PBS. Skontroluj službu a oprávnenia.", + "invalidList": "PBS vrátil neplatný zoznam údajov.", + "localUnavailable": "PBS na tomto hostiteľovi nie je dostupný.", + "authRequired": "Pred správou vzdialených pripojení PBS zapni lokálne overovanie v Monitore.", + "datastoreOffline": "Dátové úložisko je odpojené, v režime údržby alebo jeho zariadenie nie je pripojené.", + "datastoreUnavailable": "Súborový systém dátového úložiska nie je dostupný." + }, + "taskTypes": { + "backup": "Zálohovanie", "restore": "Obnova", "verify": "Overovanie", + "verifyGroup": "Overovanie skupiny záloh", "verifySnapshot": "Overovanie snímky", + "prune": "Prerezávanie záloh", "garbageCollection": "Čistenie nepoužívaných dát", "sync": "Synchronizácia", + "updateCheck": "Kontrola aktualizácií", "terminal": "Terminál", "createDatastore": "Vytvorenie dátového úložiska", + "other": "Iná úloha ({type})" + }, + "states": { "ok": "V poriadku", "unknown": "Neznámy", "warnings": "Upozornenia: {count}", "warning": "Upozornenie", "error": "Chyba" }, + "serviceStates": { "active": "Aktívna", "inactive": "Neaktívna", "failed": "Zlyhala", "activating": "Spúšťa sa", "deactivating": "Zastavuje sa", "unknown": "Neznámy stav" }, + "durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" } }, "monitorLinks": { "groups": { diff --git a/AppImage/scripts/flask_pbs_routes.py b/AppImage/scripts/flask_pbs_routes.py index 2c18f8b8..1ecb148c 100644 --- a/AppImage/scripts/flask_pbs_routes.py +++ b/AppImage/scripts/flask_pbs_routes.py @@ -8,6 +8,10 @@ from jwt_middleware import require_auth, require_admin_scope pbs_bp = Blueprint("pbs", __name__) +def pbs_error(exc, status): + return jsonify(error=str(exc), **exc.problem()), status + + @pbs_bp.after_request def no_cache(response): response.headers["Cache-Control"] = "no-store" @@ -27,7 +31,7 @@ def servers(): servers = pbs.public_servers() return jsonify(servers=servers, local_available=pbs.local_available(), detected=_detected(servers)) except pbs.PBSError as exc: - return jsonify(error=str(exc)), 500 + return pbs_error(exc, 500) def _detected(servers): @@ -57,42 +61,42 @@ def _protected_configuration(): @require_admin_scope def save_server(): if not _protected_configuration(): - return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409 + return jsonify(error="Enable local authentication before saving remote PBS credentials.", code="pbs_auth_required"), 409 try: identifier = pbs.save_server(request.get_json(silent=True)) return jsonify(success=True, id=identifier, servers=pbs.public_servers()) except pbs.PBSError as exc: - return jsonify(error=str(exc)), 400 + return pbs_error(exc, 400) except OSError: - return jsonify(error="Could not save PBS connection."), 500 + return jsonify(error="Could not save PBS connection.", code="pbs_save_failed"), 500 @pbs_bp.route("/api/pbs/servers/login", methods=["POST"]) @require_admin_scope def connect_with_password(): if not _protected_configuration(): - return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409 + return jsonify(error="Enable local authentication before saving remote PBS credentials.", code="pbs_auth_required"), 409 try: identifier = pbs.connect_with_password(request.get_json(silent=True)) return jsonify(success=True, id=identifier, servers=pbs.public_servers()) except pbs.PBSError as exc: - return jsonify(error=str(exc)), 400 + return pbs_error(exc, 400) except OSError: - return jsonify(error="Could not save PBS connection."), 500 + return jsonify(error="Could not save PBS connection.", code="pbs_save_failed"), 500 @pbs_bp.route("/api/pbs/servers/", methods=["DELETE"]) @require_admin_scope def delete_server(identifier): if not _protected_configuration(): - return jsonify(error="Enable local authentication before changing PBS connections."), 409 + return jsonify(error="Enable local authentication before changing PBS connections.", code="pbs_auth_required"), 409 try: pbs.delete_server(identifier) return jsonify(success=True) except pbs.PBSError as exc: - return jsonify(error=str(exc)), 400 + return pbs_error(exc, 400) except OSError: - return jsonify(error="Could not remove PBS connection."), 500 + return jsonify(error="Could not remove PBS connection.", code="pbs_remove_failed"), 500 @pbs_bp.route("/api/pbs/status") @@ -101,4 +105,4 @@ def status(): try: return jsonify(pbs.get_status(request.args.get("server", "local"))) except pbs.PBSError as exc: - return jsonify(error=str(exc)), 502 + return pbs_error(exc, 502) diff --git a/AppImage/scripts/pbs_manager.py b/AppImage/scripts/pbs_manager.py index 7ec910d1..d4a12ad4 100644 --- a/AppImage/scripts/pbs_manager.py +++ b/AppImage/scripts/pbs_manager.py @@ -39,7 +39,13 @@ MAX_RESPONSE = 4 * 1024 * 1024 class PBSError(ValueError): - pass + def __init__(self, message, *, code="pbs_unknown", params=None): + super().__init__(message) + self.code = code + self.params = params + + def problem(self): + return {"code": self.code, **({"params": self.params} if self.params else {})} def get_platform(): @@ -87,7 +93,7 @@ def _load_servers(): except FileNotFoundError: return [] except (OSError, ValueError) as exc: - raise PBSError("Could not read the PBS server configuration.") from exc + raise PBSError("Could not read the PBS server configuration.", code="pbs_config_read") from exc def _save_servers(servers): @@ -113,11 +119,11 @@ def public_servers(): def _validate_server(data, previous=None, fields=("name", "url", "token_id", "token_secret", "fingerprint")): if not isinstance(data, dict): - raise PBSError("Invalid PBS server configuration.") + raise PBSError("Invalid PBS server configuration.", code="pbs_invalid_config") previous = previous or {} row = {field: data.get(field, previous.get(field, "")) for field in fields} if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()): - raise PBSError("Invalid PBS connection field.") + raise PBSError("Invalid PBS connection field.", code="pbs_invalid_field") row["name"] = row["name"].strip() row["url"] = row["url"].strip().rstrip("/") try: @@ -127,26 +133,26 @@ def _validate_server(data, previous=None, fields=("name", "url", "token_id", "to or parts.query or parts.fragment): raise ValueError() except ValueError as exc: - raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.") from exc + raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.", code="pbs_invalid_url") from exc if not row["name"] or len(row["name"]) > 128: - raise PBSError("A server name of at most 128 characters is required.") + raise PBSError("A server name of at most 128 characters is required.", code="pbs_name_required") if "token_id" not in row: return _validated_fingerprint(row) row["token_id"] = row["token_id"].strip() if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]): - raise PBSError("Enter a PBS API token ID in user@realm!token format.") + raise PBSError("Enter a PBS API token ID in user@realm!token format.", code="pbs_token_id_format") if (not row["token_secret"] or not row["token_secret"].isascii() or any(char.isspace() or ord(char) < 32 for char in row["token_secret"])): - raise PBSError("A valid API token secret is required.") + raise PBSError("A valid API token secret is required.", code="pbs_token_secret") if not row["token_id"].isascii(): - raise PBSError("Invalid API token ID.") + raise PBSError("Invalid API token ID.", code="pbs_invalid_token_id") return _validated_fingerprint(row) def _validated_fingerprint(row): fingerprint = row["fingerprint"].strip().lower().replace(":", "") if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint): - raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).") + raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).", code="pbs_invalid_fingerprint") row["fingerprint"] = fingerprint return row @@ -158,7 +164,8 @@ def _remote_get(server, endpoint): def _remote_request(server, method, endpoint, headers, form=None, - rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs."): + rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs.", + *, rejected_code="pbs_token_rejected"): """One request to PBS; credentials leave only after the certificate is checked.""" parts = urlsplit(server["url"]) fingerprint = server.get("fingerprint") @@ -175,7 +182,7 @@ def _remote_request(server, method, endpoint, headers, form=None, if fingerprint: actual = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).hexdigest() if not hmac.compare_digest(actual, fingerprint): - raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.") + raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.", code="pbs_fingerprint_mismatch") path = parts.path.rstrip("/") + "/api2/json" + endpoint body = urlencode(form) if form is not None else None request_headers = {"Accept": "application/json", **headers} @@ -185,17 +192,17 @@ def _remote_request(server, method, endpoint, headers, form=None, response = connection.getresponse() content = response.read(MAX_RESPONSE + 1) if response.status in (401, 403): - raise PBSError(rejected) + raise PBSError(rejected, code=rejected_code) if response.status != 200 or len(content) > MAX_RESPONSE: - raise PBSError("PBS returned an invalid response. Redirects are not followed.") + raise PBSError("PBS returned an invalid response. Redirects are not followed.", code="pbs_invalid_response") document = json.loads(content) if not isinstance(document, dict) or "data" not in document: - raise PBSError("PBS returned an invalid JSON document.") + raise PBSError("PBS returned an invalid JSON document.", code="pbs_invalid_json") return document["data"] except PBSError: raise except (OSError, http.client.HTTPException, ValueError) as exc: - raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.") from exc + raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.", code="pbs_connect_failed") from exc finally: connection.close() @@ -203,19 +210,19 @@ def _remote_request(server, method, endpoint, headers, form=None, def save_server(data): identifier = data.get("id") if isinstance(data, dict) else None if identifier is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(identifier)): - raise PBSError("Invalid PBS server ID.") + raise PBSError("Invalid PBS server ID.", code="pbs_invalid_id") with _lock: previous = next((row for row in _load_servers() if row["id"] == identifier), None) row = _validate_server(data, previous) # /version may be public; validate credentials on the protected status API. usage = _remote_get(row, "/status/datastore-usage") if not isinstance(usage, list): - raise PBSError("PBS returned an invalid datastore list.") + raise PBSError("PBS returned an invalid datastore list.", code="pbs_invalid_datastores") row.update(id=identifier or "pbs-" + secrets.token_hex(6), revision=secrets.token_hex(16)) with _lock: servers = _load_servers() if previous is None and len(servers) >= 32: - raise PBSError("At most 32 PBS connections may be configured.") + raise PBSError("At most 32 PBS connections may be configured.", code="pbs_connection_limit", params={"count": 32}) servers = [existing for existing in servers if existing["id"] != row["id"]] _save_servers([*servers, row]) return row["id"] @@ -228,27 +235,27 @@ def connect_with_password(data): username = data.get("username") if isinstance(data, dict) else None password = data.get("password") if isinstance(data, dict) else None if not isinstance(username, str) or not re.fullmatch(r"[^\s:!@]+@[^\s:!@]+", username.strip()): - raise PBSError("Enter the PBS user as user@realm, for example root@pam.") + raise PBSError("Enter the PBS user as user@realm, for example root@pam.", code="pbs_username") if not isinstance(password, str) or not password or len(password) > 4096: - raise PBSError("Enter the password of the PBS user.") + raise PBSError("Enter the password of the PBS user.", code="pbs_password") ticket = _remote_request(endpoint, "POST", "/access/ticket", {}, {"username": username.strip(), "password": password}, - rejected="PBS rejected the user name or password.") + rejected="PBS rejected the user name or password.", rejected_code="pbs_login_rejected") if not isinstance(ticket, dict) or not ticket.get("ticket") or not ticket.get("CSRFPreventionToken"): - raise PBSError("PBS returned an invalid sign-in response.") + raise PBSError("PBS returned an invalid sign-in response.", code="pbs_invalid_login") session = {"Cookie": "PBSAuthCookie=" + quote(ticket["ticket"], safe=""), "CSRFPreventionToken": ticket["CSRFPreventionToken"]} user = str(ticket.get("username") or username.strip()) name = "proxmenux-" + secrets.token_hex(4) token_path = f"/access/users/{quote(user, safe='')}/token/{name}" created = _remote_request(endpoint, "POST", token_path, session, {"comment": "ProxMenux Monitor, read-only"}, - rejected="This PBS user cannot create API tokens.") + rejected="This PBS user cannot create API tokens.", rejected_code="pbs_token_create_denied") if not isinstance(created, dict) or not created.get("tokenid") or not created.get("value"): - raise PBSError("PBS returned an invalid API token.") + raise PBSError("PBS returned an invalid API token.", code="pbs_invalid_token") try: _remote_request(endpoint, "PUT", "/access/acl", session, {"path": "/", "role": "Audit", "auth-id": created["tokenid"], "propagate": "1"}, - rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.") + rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.", rejected_code="pbs_audit_role_denied") return save_server({**endpoint, "token_id": created["tokenid"], "token_secret": created["value"]}) except PBSError: try: @@ -263,7 +270,7 @@ def delete_server(identifier): servers = _load_servers() remaining = [row for row in servers if row["id"] != identifier] if len(remaining) == len(servers): - raise PBSError("PBS server not found.") + raise PBSError("PBS server not found.", code="pbs_not_found") _save_servers(remaining) @@ -276,7 +283,7 @@ def _cli(*arguments): raise ValueError() return data except (OSError, subprocess.SubprocessError, ValueError) as exc: - raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.") from exc + raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.", code="pbs_read_failed") from exc def _local_api(path): @@ -290,7 +297,7 @@ def _local_api(path): raise ValueError() return data except (OSError, subprocess.SubprocessError, ValueError) as exc: - raise PBSError("Could not read PBS " + path + ". Check its service and permissions.") from exc + raise PBSError("Could not read PBS " + path + ". Check its service and permissions.", code="pbs_read_failed") from exc def _local_datastores(): @@ -305,12 +312,14 @@ def _local_datastores(): inactive = store.get("backing-device") and not (path and os.path.ismount(path)) if not path or inactive or store.get("maintenance-mode"): row["error"] = "Datastore is offline, in maintenance or has no mounted backing device." + row["error_code"] = "pbs_datastore_offline" else: try: capacity = shutil.disk_usage(path) row.update(total=capacity.total, used=capacity.used, avail=capacity.free) except OSError: row["error"] = "Datastore filesystem is not available." + row["error_code"] = "pbs_datastore_unavailable" result.append(row) return result @@ -331,10 +340,10 @@ def get_status(identifier): cached = _status_cache.get(identifier) if cached and cached[0] > time.monotonic(): return cached[1] - errors = {} + errors, error_details = {}, {} if identifier == "local": if not local_available(): - raise PBSError("The local host is not a PBS server.") + raise PBSError("The local host is not a PBS server.", code="pbs_local_unavailable") name, version = get_platform().get("hostname") or socket.gethostname(), _local_version() services = _local_services() operations = {"datastores": _local_datastores, @@ -346,7 +355,7 @@ def get_status(identifier): with _lock: server = next((row for row in _load_servers() if row["id"] == identifier), None) if not server: - raise PBSError("PBS server not found.") + raise PBSError("PBS server not found.", code="pbs_not_found") metadata = _remote_get(server, "/version") name, version = server["name"], metadata.get("version") if isinstance(metadata, dict) else None services = {} @@ -354,18 +363,19 @@ def get_status(identifier): "sync": "/admin/sync", "verify": "/admin/verify", "prune": "/admin/prune"} operations = {key: (lambda endpoint=path: _remote_get(server, endpoint)) for key, path in endpoints.items()} result = {"server": identifier, "name": name, "version": version, "services": services, - "fetched_at": int(time.time()), "errors": errors} + "fetched_at": int(time.time()), "errors": errors, "error_details": error_details} with ThreadPoolExecutor(max_workers=5) as executor: futures = {key: executor.submit(operation) for key, operation in operations.items()} for key, future in futures.items(): try: data = future.result() if not isinstance(data, list) or not all(isinstance(row, dict) for row in data): - raise PBSError("PBS returned an invalid list.") + raise PBSError("PBS returned an invalid list.", code="pbs_invalid_list") result[key] = [{**row, "name": row.get("store", row.get("name"))} for row in data] if key == "datastores" else data except PBSError as exc: result[key] = None # Unknown/forbidden is never presented as zero. errors[key] = str(exc) + error_details[key] = exc.problem() with _lock: if len(_status_cache) >= 33: _status_cache.clear() diff --git a/AppImage/scripts/tests/test_pbs.py b/AppImage/scripts/tests/test_pbs.py index cf932a8e..e86c38ac 100644 --- a/AppImage/scripts/tests/test_pbs.py +++ b/AppImage/scripts/tests/test_pbs.py @@ -205,7 +205,7 @@ class PBSManagerTests(unittest.TestCase): def test_password_sign_in_keeps_only_a_read_only_token(self): calls = [] - def request(server, method, endpoint, headers, form=None, rejected=""): + def request(server, method, endpoint, headers, form=None, rejected="", **kwargs): calls.append((method, endpoint, form)) if endpoint == "/access/ticket": return {"ticket": "PBS:root@pam:ticket", "CSRFPreventionToken": "csrf", "username": "root@pam"} @@ -227,7 +227,7 @@ class PBSManagerTests(unittest.TestCase): def test_password_sign_in_removes_its_token_when_the_role_cannot_be_granted(self): calls = [] - def request(server, method, endpoint, headers, form=None, rejected=""): + def request(server, method, endpoint, headers, form=None, rejected="", **kwargs): calls.append((method, endpoint)) if endpoint == "/access/ticket": return {"ticket": "t", "CSRFPreventionToken": "c"} @@ -351,15 +351,17 @@ class TLSTests(unittest.TestCase): def test_bad_pin_and_untrusted_ca_send_no_http_credentials(self): for fingerprint in ('0' * 64, ''): - with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError): + with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError) as caught: pbs._remote_get({**self.connection, "fingerprint": fingerprint}, '/status/datastore-usage') + self.assertEqual(caught.exception.code, "pbs_fingerprint_mismatch" if fingerprint else "pbs_connect_failed") self.assertEqual(self.requests, []) def test_bad_credentials_and_redirects_are_rejected(self): for status in (401, 403, 302): type(self).response_status = status - with self.subTest(status=status), self.assertRaises(pbs.PBSError): + with self.subTest(status=status), self.assertRaises(pbs.PBSError) as caught: pbs._remote_get(self.connection, '/status/datastore-usage') + self.assertEqual(caught.exception.code, "pbs_token_rejected" if status in (401, 403) else "pbs_invalid_response") if __name__ == '__main__': diff --git a/AppImage/scripts/tests/test_pbs_i18n.py b/AppImage/scripts/tests/test_pbs_i18n.py new file mode 100644 index 00000000..42a629c7 --- /dev/null +++ b/AppImage/scripts/tests/test_pbs_i18n.py @@ -0,0 +1,196 @@ +"""PBS localization metadata must preserve routes, security and persistence.""" +import importlib.util +import json +from pathlib import Path +import sys +import tempfile +import types +import unittest +from unittest import mock +from flask import Flask + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / "AppImage/scripts")) +import auth_manager +import pbs_manager as pbs +REAL_REMOTE_REQUEST = pbs._remote_request + + +class PBSI18nTests(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + for patch in [mock.patch.multiple(auth_manager, CONFIG_DIR=self.root, AUTH_CONFIG_FILE=self.root / "auth.json"), + mock.patch.object(pbs, "_remote_get", return_value=[]), + mock.patch.object(pbs, "_remote_request"), + mock.patch.object(pbs, "local_available", return_value=False)]: + patch.start(); self.addCleanup(patch.stop) + (self.root / "auth.json").write_text(json.dumps({"enabled": True, "password_hash": "fixture"})) + self.server = {"name": "Fixture", "url": "https://pbs.invalid:8007", "token_id": "audit@pbs!monitor", "token_secret": "fake-fixture-secret", "fingerprint": ""} + self.id = pbs.save_server(self.server) + pbs._remote_get.reset_mock(); pbs._status_cache.clear() + auth = types.SimpleNamespace(load_auth_config=auth_manager.load_auth_config, verify_token=lambda _: "fixture", + verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only")) + spec = importlib.util.spec_from_file_location("fixture_pbs_jwt", ROOT / "AppImage/scripts/jwt_middleware.py") + middleware = importlib.util.module_from_spec(spec) + with mock.patch.dict(sys.modules, {"auth_manager": auth}): spec.loader.exec_module(middleware) + spec = importlib.util.spec_from_file_location("fixture_pbs_routes", ROOT / "AppImage/scripts/flask_pbs_routes.py") + routes = importlib.util.module_from_spec(spec) + with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}): spec.loader.exec_module(routes) + app = Flask(__name__); app.register_blueprint(routes.pbs_bp); self.client = app.test_client() + + def send(self, method="POST", path="/api/pbs/servers", data=None, role="admin"): + return self.client.open(path, method=method, json=data, headers={"Authorization": "Bearer " + role} if role else {}) + + def reject(self, data, message, code, path="/api/pbs/servers"): + before = (self.root / "pbs_servers.json").read_bytes() + with mock.patch.object(pbs, "_save_servers") as save: + response = self.send(path=path, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json, {"error": message, "code": code}) + self.assertEqual(response.headers["Cache-Control"], "no-store") + save.assert_not_called(); pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called() + self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before) + + def test_invalid_input_is_localizable_before_any_remote_or_save_effect(self): + cases = [ + (None, "Invalid PBS server configuration.", "pbs_invalid_config"), + ({**self.server, "name": []}, "Invalid PBS connection field.", "pbs_invalid_field"), + ({**self.server, "url": "http://pbs.invalid"}, "Use an HTTPS PBS URL without embedded credentials, query or fragment.", "pbs_invalid_url"), + ({**self.server, "name": ""}, "A server name of at most 128 characters is required.", "pbs_name_required"), + ({**self.server, "token_id": "bad"}, "Enter a PBS API token ID in user@realm!token format.", "pbs_token_id_format"), + ({**self.server, "token_secret": "bad\nsecret"}, "A valid API token secret is required.", "pbs_token_secret"), + ({**self.server, "token_id": "človek@pbs!monitor"}, "Invalid API token ID.", "pbs_invalid_token_id"), + ({**self.server, "fingerprint": "invalid"}, "The certificate fingerprint must be SHA256 (64 hex characters).", "pbs_invalid_fingerprint"), + ({**self.server, "id": "../../bad"}, "Invalid PBS server ID.", "pbs_invalid_id"), + ] + for data, message, code in cases: + with self.subTest(code=code): self.reject(data, message, code) + for data, message, code in [ + ({**self.server, "username": "missing-realm", "password": "fake"}, "Enter the PBS user as user@realm, for example root@pam.", "pbs_username"), + ({**self.server, "username": "root@pam", "password": ""}, "Enter the password of the PBS user.", "pbs_password"), + ]: + with self.subTest(code=code): self.reject(data, message, code, "/api/pbs/servers/login") + + def test_authentication_and_scope_guards_precede_all_mutations(self): + before = (self.root / "pbs_servers.json").read_bytes() + save_patch = mock.patch.object(pbs, "_save_servers") + save = save_patch.start(); self.addCleanup(save_patch.stop) + for path, method in [("/api/pbs/servers", "POST"), ("/api/pbs/servers/login", "POST"), ("/api/pbs/servers/" + self.id, "DELETE")]: + for role, status in [(None, 401), ("reader", 403)]: + self.assertEqual(self.send(method, path, self.server, role).status_code, status) + (self.root / "auth.json").write_text(json.dumps({"enabled": False})) + for path in ("/api/pbs/servers", "/api/pbs/servers/login"): + response = self.send(path=path, data=self.server) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json, {"error": "Enable local authentication before saving remote PBS credentials.", "code": "pbs_auth_required"}) + response = self.send("DELETE", "/api/pbs/servers/" + self.id) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json["code"], "pbs_auth_required") + pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called() + save.assert_not_called() + self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before) + + def test_corrupt_configuration_missing_server_and_local_unavailable_keep_status(self): + response = self.send("DELETE", "/api/pbs/servers/absent") + self.assertEqual((response.status_code, response.json), (400, {"error": "PBS server not found.", "code": "pbs_not_found"})) + for identifier, code in [("absent", "pbs_not_found"), ("local", "pbs_local_unavailable")]: + response = self.send("GET", "/api/pbs/status?server=" + identifier) + self.assertEqual(response.status_code, 502); self.assertEqual(response.json["code"], code) + (self.root / "pbs_servers.json").write_text("invalid json") + response = self.send("GET", "/api/pbs/servers") + self.assertEqual((response.status_code, response.json), (500, {"error": "Could not read the PBS server configuration.", "code": "pbs_config_read"})) + pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called() + + def test_save_delete_failures_leave_previous_bytes_and_do_not_leak_details(self): + for path, method, data, code, message in [ + ("/api/pbs/servers", "POST", {**self.server, "id": self.id}, "pbs_save_failed", "Could not save PBS connection."), + ("/api/pbs/servers/" + self.id, "DELETE", None, "pbs_remove_failed", "Could not remove PBS connection."), + ]: + before = (self.root / "pbs_servers.json").read_bytes() + with self.subTest(method=method), mock.patch.object(pbs.os, "replace", side_effect=OSError("private filesystem detail")): + response = self.send(method, path, data) + self.assertEqual((response.status_code, response.json), (500, {"error": message, "code": code})) + self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before) + + def test_partial_status_preserves_legacy_error_null_and_cache_with_additive_metadata(self): + def remote(server, endpoint): + if endpoint == "/version": return {"version": "4.2.5"} + if endpoint == "/admin/verify": raise pbs.PBSError("Fixture access denied", code="pbs_token_rejected") + if endpoint == "/admin/prune": return {"invalid": "list"} + return [] + with mock.patch.object(pbs, "_remote_get", side_effect=remote) as remote_call: + result = self.send("GET", "/api/pbs/status?server=" + self.id) + count = remote_call.call_count + self.assertEqual(self.send("GET", "/api/pbs/status?server=" + self.id).json, result.json) + self.assertEqual(remote_call.call_count, count) + self.assertEqual(result.status_code, 200) + self.assertIsNone(result.json["verify"]); self.assertIsNone(result.json["prune"]) + self.assertEqual(result.json["sync"], []) + self.assertEqual(result.json["errors"]["verify"], "Fixture access denied") + self.assertEqual(result.json["error_details"]["verify"], {"code": "pbs_token_rejected"}) + self.assertEqual(result.json["error_details"]["prune"], {"code": "pbs_invalid_list"}) + + def test_local_datastore_errors_and_command_errors_keep_legacy_messages(self): + with mock.patch.object(pbs, "_cli", return_value=[{"name": "offline", "path": "/fixture", "backing-device": "fixture"}]), \ + mock.patch.object(pbs.os.path, "ismount", return_value=False), mock.patch.object(pbs.shutil, "disk_usage") as usage: + store = pbs._local_datastores()[0] + self.assertEqual(store["error"], "Datastore is offline, in maintenance or has no mounted backing device.") + self.assertEqual(store["error_code"], "pbs_datastore_offline"); usage.assert_not_called(); self.assertIsNone(store["total"]) + with mock.patch.object(pbs.subprocess, "run", side_effect=OSError("fixture")): + for fn, args in [(pbs._cli, ("task", "list")), (pbs._local_api, ("/admin/sync",))]: + with self.subTest(args=args), self.assertRaises(pbs.PBSError) as caught: fn(*args) + self.assertEqual(caught.exception.code, "pbs_read_failed") + self.assertIn("Check its service and permissions.", str(caught.exception)) + + def test_remote_failure_codes_follow_the_actual_request_and_keep_english(self): + cases = [ + (401, b'{}', "pbs_token_rejected", "PBS rejected the API token or its permissions. Check both the user and token ACLs."), + (302, b'{}', "pbs_invalid_response", "PBS returned an invalid response. Redirects are not followed."), + (200, b'{}', "pbs_invalid_json", "PBS returned an invalid JSON document."), + (200, b'invalid-json', "pbs_connect_failed", "Could not connect to PBS over verified HTTPS. Check the address, certificate and service."), + ] + for status, body, code, message in cases: + connection = mock.Mock(); connection.getresponse.return_value.status = status + connection.getresponse.return_value.read.return_value = body + with self.subTest(code=code), mock.patch.object(pbs.http.client, "HTTPSConnection", return_value=connection), \ + self.assertRaises(pbs.PBSError) as caught: + REAL_REMOTE_REQUEST(self.server, "GET", "/version", {}) + self.assertEqual((str(caught.exception), caught.exception.problem()), (message, {"code": code})) + connection.close.assert_called_once() + for code in ("pbs_login_rejected", "pbs_token_create_denied", "pbs_audit_role_denied"): + connection = mock.Mock(); connection.getresponse.return_value.status = 403 + connection.getresponse.return_value.read.return_value = b'{}' + with self.subTest(code=code), mock.patch.object(pbs.http.client, "HTTPSConnection", return_value=connection), self.assertRaises(pbs.PBSError) as caught: + REAL_REMOTE_REQUEST(self.server, "POST", "/access/ticket", {}, rejected="Original context", rejected_code=code) + self.assertEqual((str(caught.exception), caught.exception.code), ("Original context", code)) + + def test_connection_limit_and_invalid_datastore_response_do_not_save(self): + before = (self.root / "pbs_servers.json").read_bytes() + with mock.patch.object(pbs, "_save_servers") as save, mock.patch.object(pbs, "_load_servers", return_value=[{"id": str(i)} for i in range(32)]): + response = self.send(data=self.server) + self.assertEqual(response.json, {"error": "At most 32 PBS connections may be configured.", "code": "pbs_connection_limit", "params": {"count": 32}}) + self.assertEqual(response.status_code, 400); save.assert_not_called() + pbs._remote_get.return_value = {} + response = self.send(data=self.server) + self.assertEqual(response.json["code"], "pbs_invalid_datastores") + self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before) + + def test_login_failures_keep_saved_state_and_cleanup_a_created_token(self): + before = (self.root / "pbs_servers.json").read_bytes() + login = {"name": "Fixture", "url": "https://pbs.invalid", "username": "root@pam", "password": "fake"} + for stage, code in [("ticket", "pbs_invalid_login"), ("token", "pbs_invalid_token"), ("acl", "pbs_audit_role_denied")]: + calls = [] + def request(server, method, endpoint, headers, form=None, rejected="", rejected_code="pbs_token_rejected"): + calls.append((method, endpoint)) + if endpoint == "/access/ticket": return None if stage == "ticket" else {"ticket": "fake", "CSRFPreventionToken": "fake"} + if method == "POST": return None if stage == "token" else {"tokenid": "audit@pbs!fixture", "value": "fake"} + if method == "PUT": raise pbs.PBSError(rejected, code=rejected_code) + pbs._remote_request.side_effect = request + response = self.send(path="/api/pbs/servers/login", data=login) + self.assertEqual(response.status_code, 400); self.assertEqual(response.json["code"], code) + self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before) + self.assertEqual(sum(method == "DELETE" for method, _ in calls), 1 if stage == "acl" else 0) + + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_pbs_i18n.cjs b/tests/test_pbs_i18n.cjs new file mode 100644 index 00000000..1712b9d8 --- /dev/null +++ b/tests/test_pbs_i18n.cjs @@ -0,0 +1,134 @@ +// Shipped PBS code with only transport, hooks and UI primitives replaced. +const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm'); +const ts = require('typescript'); +const compile = source => ts.transpileModule(source, {compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX}}).outputText; +const catalogs = Object.fromEntries(['en','sk'].map(l => [l, JSON.parse(fs.readFileSync(`AppImage/messages/${l}/common.json`))])); +const translate = locale => (key, params = {}) => { + const value = key.split('.').reduce((tree, part) => tree?.[part], catalogs[locale]); + assert.equal(typeof value, 'string', key); + return value.replace(/\{(\w+)\}/g, (m,k) => params[k] ?? m); +}; +const library = {exports: {}}; +vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/pbs-messages.ts','utf8')), library); +const messages = library.exports; +const failure = code => Object.assign(new Error('RAW DIAGNOSTIC'), {body: {error:'RAW DIAGNOSTIC',code}}); +const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten) + : typeof n === 'object' ? n.type === 'Dialog' && !n.props.open ? [] : [n,...flatten(n.props?.children)] : [n]; +const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join(''); + +function fixture(locale='sk', options={}) { + let cursor=0, slots=[], effects=[], tree, apiError, reloadError; + const calls=[], confirmations=[], reloads=[]; + const servers={local_available:false,servers:[{id:'saved',name:'Saved PBS',url:'https://saved.invalid:8007',token_id:'audit@pbs!monitor',has_token:true,fingerprint:''}],detected:[]}; + const status={name:'Fixture',version:'4.2.5',fetched_at:1700000000,services:{'proxmox-backup':'inactive'},datastores:[],tasks:[],sync:[],verify:[],prune:[],errors:{}}; + const t=translate(locale), loaded={}; + const jsx=(type,props) => typeof type === 'function' ? type(props) : ({type,props}); + const react={useState(initial){const i=cursor++;if(!(i in slots))slots[i]=typeof initial==='function'?initial():initial;return [slots[i],value=>{slots[i]=typeof value==='function'?value(slots[i]):value;}];}, + useEffect(fn,deps){const i=cursor++, old=slots[i];if(!old||deps.some((d,j)=>d!==old[j]))effects.push(fn);slots[i]=deps;}}; + function requireModule(name) { + if(name==='react')return react; + if(name==='react/jsx-runtime')return {jsx,jsxs:jsx,Fragment:'Fragment'}; + if(name==='swr')return {default:key=>({data:key?.startsWith('/api/pbs/status')?status:key?servers:undefined,error:options.loadError, + isLoading:false,mutate:async()=>{reloads.push(key);if(reloadError)throw reloadError;}})}; + if(name.includes('i18n/provider'))return {useT:()=>t,useI18n:()=>({language:locale})}; + if(name.includes('pbs-messages'))return messages; + if(name.includes('api-config'))return {fetchApi:async(path,opts)=>{calls.push({path,...opts});if(apiError)throw apiError;return {id:'new',success:true};}}; + if(name.endsWith('pbs-problem-message'))return load('AppImage/components/pbs-problem-message.tsx'); + if(name.endsWith('pbs-datastore-usage'))return load('AppImage/components/pbs-datastore-usage.tsx'); + if(name.includes('lib/utils'))return {formatStorage:n=>String(n)+' GB'}; + return new Proxy({}, {get:(_,key)=>key}); + } + function load(path) {if(loaded[path])return loaded[path];const e={};loaded[path]=e; + vm.runInNewContext(compile(fs.readFileSync(path,'utf8')), {exports:e,require:requireModule,Date,window:{confirm:message=>{confirmations.push(message);return options.confirm===true;}}});return e;} + const panel=load('AppImage/components/pbs-panel.tsx').PBSPanel; + return {t,servers,status,calls,confirmations,reloads,jsx,requireModule, + render(props){cursor=0;effects=[];tree=panel(props);effects.forEach(fn=>fn());return tree;}, + nodes:()=>flatten(tree),text:()=>text(tree), + button(label){const n=flatten(tree).find(n=>n.type==='Button'&&text(n)===label);assert.ok(n,label);return n;}, + input(field){const n=flatten(tree).find(n=>n.type==='Input'&&n.props.id==='pbs-'+field);assert.ok(n,field);return n;}, + select(label){const n=flatten(tree).find(n=>n.type==='Select'&&flatten(n).some(n=>n.props?.['aria-label']===label));assert.ok(n,label);return n;}, + fail:e=>{apiError=e;},failReload:e=>{reloadError=e;},load, + }; +} + +function overview(locale, sourcePath='AppImage/components/system-overview.tsx') { + const source=fs.readFileSync(sourcePath,'utf8'), ast=ts.createSourceFile('overview.tsx',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX), branches=[]; + function visit(n){if(ts.isConditionalExpression(n)&&n.condition.getText(ast)==='isPbs'&&n.whenTrue.getText(ast).includes('pbs.datastores'))branches.push(n.whenTrue.getText(ast));ts.forEachChild(n,visit);}visit(ast); + assert.equal(branches.length,2,'overview summary and storage details both consume PBS'); + const f=fixture(locale); + for(const branch of branches) { + for(const state of ['partial','network','empty','loading']) { + const pbsStatus=state==='loading'||state==='network'?undefined:{datastores:state==='empty'?[]:null,errors:{datastores:'RAW DIAGNOSTIC'},error_details:{datastores:{code:'pbs_token_rejected'}}}; + const context={isPbs:true,pbsStatus,pbsError:state==='network'?failure('pbs_connect_failed'):undefined,datastores:pbsStatus?.datastores, + t:f.t,require:f.requireModule,pbsDiagnostic:messages.pbsDiagnostic,PBSProblemMessage:f.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage, + DatastoreUsageRow:f.load('AppImage/components/pbs-datastore-usage.tsx').DatastoreUsageRow}; + for(const name of ['Card','CardHeader','CardTitle','CardContent','Database','Badge'])context[name]=name; + vm.runInNewContext(compile('exports.result=('+branch+');'),{...context,exports:context}); + const rendered=text(context.result); + if(state==='partial') {assert.ok(rendered.includes(f.t('pbs.errors.tokenRejected')));assert.ok(!rendered.includes(f.t('pbs.loading')));assert.ok(!flatten(context.result).some(n=>n.props?.className?.includes('animate-pulse')));} + if(state==='network')assert.ok(rendered.includes(f.t('pbs.errors.connectFailed'))); + if(state==='empty')assert.ok(rendered.includes(f.t('pbs.noDatastores'))); + } + } +} + +async function main() { + const backend=fs.readFileSync('AppImage/scripts/pbs_manager.py','utf8')+fs.readFileSync('AppImage/scripts/flask_pbs_routes.py','utf8'); + const codes=new Set([...backend.matchAll(/(?:code|rejected_code)="(pbs_\w+)"/g)].map(m=>m[1])); + for(const locale of ['en','sk']) { + const t=translate(locale); + for(const code of codes) { + if(code==='pbs_unknown')continue; + const err=failure(code);err.body.params={count:32}; + assert.notEqual(messages.pbsErrorText(err,t),t('pbs.loadFailed'),code); + assert.ok(!messages.pbsErrorText(err,t).includes('RAW')); + } + for(const err of [null,undefined,'raw',new Error('raw'),{body:null},{code:'constructor'},{code:'__proto__'},{code:'future'}, + ...[null,0,-1,1.2,'32',Infinity,NaN,2**54].map(count=>({code:'pbs_connection_limit',params:{count}}))]) { + assert.equal(messages.pbsErrorText(err,t),t('pbs.loadFailed')); + } + assert.equal(messages.pbsResultText('OK',t),t('pbs.states.ok')); + assert.equal(messages.pbsResultText('WARNINGS: 2',t),t('pbs.states.warnings',{count:2})); + assert.equal(messages.pbsResultText(undefined,t),t('pbs.states.unknown')); + assert.equal(messages.pbsResultText('unknown',t),t('pbs.states.unknown')); + assert.equal(messages.pbsResultText('TASK ERROR: raw',t),t('pbs.states.error')); + for(const n of [-1,NaN,Infinity])assert.equal(messages.formatPBSDuration(n,t),'—'); + for(const [n,label] of [[0,'0 s'],[59,'59 s'],[60,'1 min 0 s'],[3599,'59 min 59 s'],[3600,'1 h 0 min'],[86399,'23 h 59 min'],[86400,'1 d 0 h']])assert.equal(messages.formatPBSDuration(n,t),label); + assert.equal(messages.pbsTaskType('garbage_collection',t),t('pbs.taskTypes.garbageCollection')); + assert.equal(messages.pbsTaskType('future',t),t('pbs.taskTypes.other',{type:'future'})); + assert.equal(messages.pbsServiceState('constructor',t),t('pbs.serviceStates.unknown')); + const unknown=fixture(locale);unknown.status.tasks=[{upid:'unknown',endtime:1700000000,status:'unknown'}];unknown.status.sync=[{id:'unknown','last-run-state':'unknown'}]; + unknown.render();unknown.render(); + const unknownBadges=unknown.nodes().filter(n=>n.type==='Badge'&&text(n)===t('pbs.states.unknown')); + assert.equal(unknownBadges.length,2);assert.ok(unknownBadges.every(n=>!n.props.className.includes('red-'))); + const f=fixture(locale);f.render();f.render();f.button(t('pbs.manage')).props.onClick();f.render(); + f.input('name').props.onChange({target:{value:'Draft'}});f.render(); + f.input('password').props.onChange({target:{value:'fake-one-time-password'}});f.render(); + f.button(t('pbs.cancel')).props.onClick();f.render();assert.equal(f.calls.length,0); + f.button(t('pbs.manage')).props.onClick();f.render();assert.equal(f.input('password').props.value,''); + f.input('name').props.onChange({target:{value:'Draft'}});f.render();f.input('url').props.onChange({target:{value:'http://bad.invalid'}});f.render(); + f.fail(failure('pbs_invalid_url'));await f.button(t('pbs.save')).props.onClick();f.render(); + assert.ok(f.text().includes(t('pbs.errors.invalidUrl')));assert.equal(f.input('url').props.value,'http://bad.invalid'); + assert.equal(f.calls[0].path,'/api/pbs/servers/login');assert.ok(!Object.hasOwn(JSON.parse(f.calls[0].body),'token_secret')); + f.button(t('pbs.methodToken')).props.onClick();f.render();await f.button(t('pbs.save')).props.onClick();f.render(); + assert.equal(f.calls[1].path,'/api/pbs/servers');assert.ok(!Object.hasOwn(JSON.parse(f.calls[1].body),'password')); + f.select(t('pbs.editConnection')).props.onValueChange('saved');f.render();await f.button(t('pbs.remove')).props.onClick();f.render(); + assert.deepEqual(f.confirmations,[t('pbs.removeConfirm')]);assert.equal(f.calls.length,2,'Declined remove has no DELETE'); + const remove=fixture(locale,{confirm:true});remove.render();remove.render();remove.button(t('pbs.manage')).props.onClick();remove.render();remove.select(t('pbs.editConnection')).props.onValueChange('saved');remove.render(); + await remove.button(t('pbs.remove')).props.onClick();remove.render();assert.equal(remove.calls.length,1);assert.equal(remove.calls[0].method,'DELETE'); + const failedRemove=fixture(locale,{confirm:true});failedRemove.render();failedRemove.render();failedRemove.button(t('pbs.manage')).props.onClick();failedRemove.render();failedRemove.select(t('pbs.editConnection')).props.onValueChange('saved');failedRemove.render();failedRemove.fail(new Error('network failure')); + await failedRemove.button(t('pbs.remove')).props.onClick();failedRemove.render();assert.ok(failedRemove.text().includes(t('pbs.removeFailed')));assert.equal(failedRemove.calls.length,1); + const failedReload=fixture(locale);failedReload.render();failedReload.render();failedReload.button(t('pbs.manage')).props.onClick();failedReload.render();failedReload.failReload(new Error('raw reload')); + for(const [field,value] of Object.entries({name:'Valid',url:'https://fixture.invalid',username:'root@pam',password:'fake'})){failedReload.input(field).props.onChange({target:{value}});failedReload.render();} + assert.equal(failedReload.button(t('pbs.save')).props.disabled,false); + await failedReload.button(t('pbs.save')).props.onClick();failedReload.render();assert.equal(failedReload.calls.length,1,'No automatic mutation retry after refresh fails');assert.ok(failedReload.text().includes(t('pbs.savedRefreshFailed'))); + const removeReload=fixture(locale,{confirm:true});removeReload.render();removeReload.render();removeReload.button(t('pbs.manage')).props.onClick();removeReload.render();removeReload.select(t('pbs.editConnection')).props.onValueChange('saved');removeReload.render();removeReload.failReload(new Error('network')); + await removeReload.button(t('pbs.remove')).props.onClick();removeReload.render();assert.equal(removeReload.calls.length,1);assert.ok(removeReload.text().includes(t('pbs.removedRefreshFailed'))); + const store=f.load('AppImage/components/pbs-datastore-usage.tsx').PBSDatastoreCard({store:{name:'Offline',total:null,used:null,error:'RAW DIAGNOSTIC',error_code:'pbs_datastore_offline'}}); + assert.ok(text(store).includes(t('pbs.errors.datastoreOffline')));assert.ok(!text(store).includes('0 GB')); + overview(locale); + } + console.log('PASS: all structured PBS error codes, EN/SK/unknown diagnostics, forms/login/token/remove/cancel, no automatic retry, task/service/duration states and both real overview branches'); +} +main().catch(error=>{console.error(error);process.exitCode=1}); +module.exports={fixture,overview};