From 9ffd154a2a5544de49cea1fcd3406b66f723a054 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:46:15 +0000 Subject: [PATCH 1/7] i18n: refine Slovak linked nodes and localize validation errors --- .github/scripts/run_offline_qualified.py | 3 + AppImage/components/monitor-switcher.tsx | 22 +-- AppImage/lib/monitor-link-errors.ts | 29 ++++ AppImage/messages/en/common.json | 13 +- AppImage/messages/sk/common.json | 35 +++-- .../scripts/flask_monitor_links_routes.py | 19 ++- AppImage/scripts/monitor_links.py | 27 ++-- .../scripts/tests/test_monitor_links_i18n.py | 138 ++++++++++++++++++ tests/test_monitor_links_i18n.cjs | 133 +++++++++++++++++ 9 files changed, 379 insertions(+), 40 deletions(-) create mode 100644 AppImage/lib/monitor-link-errors.ts create mode 100644 AppImage/scripts/tests/test_monitor_links_i18n.py create mode 100644 tests/test_monitor_links_i18n.cjs diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 8a9548cd..4ee4eb95 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -13,6 +13,8 @@ import sys import tempfile PYTHON_FILES = ( + "AppImage/scripts/tests/test_monitor_links_i18n.py", + "AppImage/scripts/tests/test_monitor_links.py", "AppImage/scripts/tests/test_local_app_icon_isolation.py", "AppImage/scripts/tests/test_local_app_icons.py", "AppImage/scripts/tests/test_docker_app_discovery.py", @@ -28,6 +30,7 @@ PYTHON_FILES = ( "AppImage/scripts/tests/test_health_persistence_recurrence.py", ) NODE_FILES = ( + "tests/test_monitor_links_i18n.cjs", "tests/lxc_updates/test_local_app_icons_ui.cjs", "tests/lxc_updates/test_docker_app_discovery_ui.cjs", "tests/lxc_updates/test_native_app_discovery_ui.cjs", diff --git a/AppImage/components/monitor-switcher.tsx b/AppImage/components/monitor-switcher.tsx index 89f8d249..7fb88eb9 100644 --- a/AppImage/components/monitor-switcher.tsx +++ b/AppImage/components/monitor-switcher.tsx @@ -14,6 +14,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./ui/dropdown-menu" import { fetchApi } from "../lib/api-config" +import { monitorLinkError } from "../lib/monitor-link-errors" import { useT } from "../lib/i18n/provider" interface MonitorLink { @@ -185,8 +186,9 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin await refreshCache("/api/monitor-links") setOfferOrigin(false) } catch (err) { - await refreshCache("/api/monitor-links") - setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) + setAddError(monitorLinkError(err, t)) + try { await refreshCache("/api/monitor-links") } + catch { /* SWR shows the load error; keep the localized mutation error. */ } } finally { setAdding(false) } } const notNow = () => { @@ -260,7 +262,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin {manageOpen && } {origin && { if (!open) notNow() }}> - event.stopPropagation()}> + event.stopPropagation()}> {t("monitorLinks.knownTitle")} {t("monitorLinks.knownBody", { name: origin.name })} @@ -290,7 +292,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { const t = useT() - const { data, mutate } = useSWR("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false }) + const { data, mutate, error: loadError, isLoading } = useSWR("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false }) const [form, setForm] = useState({ name: "", url: "", product: "pve" }) const [addresses, setAddresses] = useState>({}) const [editing, setEditing] = useState(null) @@ -304,14 +306,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange await mutate() await refreshCache("/api/monitor-links") } catch (err) { - setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) + setError(monitorLinkError(err, t)) } finally { setBusy(false) } } const send = (path: string, method: string, body?: unknown) => fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) return - event.stopPropagation()}> {t("monitorLinks.dialogTitle")} @@ -320,7 +322,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange

{t("monitorLinks.addTitle")}

-
+
setForm({ ...form, name: event.target.value })} /> @@ -350,6 +352,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange {error &&

{error}

}
+ {isLoading &&

{t("monitorLinks.checking")}

} + {loadError &&

{t("monitorLinks.loadFailed")}

} {data?.links.length === 0 &&

{t("monitorLinks.empty")}

} {data?.links.map(link => { const own = link.manual || link.custom_url @@ -378,8 +382,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange {t("monitorLinks.thisNode")}
: isEditing - ?
- + setAddresses({ ...addresses, [link.key]: event.target.value })} />
-

{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}; From f9e36f0b0aed236a2dd1fa204e1e26cd2823106d Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:13:34 +0000 Subject: [PATCH 3/7] fix(i18n): align PBS fields and omit duplicate English errors --- AppImage/components/pbs-panel.tsx | 11 ++++++++--- AppImage/components/pbs-problem-message.tsx | 7 ++++--- AppImage/lib/pbs-messages.ts | 20 ++++++++++++++------ AppImage/messages/en/common.json | 15 ++++++++++----- AppImage/messages/sk/common.json | 15 ++++++++++----- tests/test_pbs_i18n.cjs | 20 ++++++++++++++++++++ 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/AppImage/components/pbs-panel.tsx b/AppImage/components/pbs-panel.tsx index 5dff026e..b18a3e12 100644 --- a/AppImage/components/pbs-panel.tsx +++ b/AppImage/components/pbs-panel.tsx @@ -208,11 +208,16 @@ 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
- + const help = field === "url" ? "pbs.urlHelp" : field === "username" ? "pbs.usernameHelp" + : field === "token_id" ? "pbs.tokenIdHelp" : field === "token_secret" + ? form.id ? "pbs.tokenSecretKeepHelp" : "pbs.tokenSecretHelp" : undefined + return
+ setForm({ ...form, [field]: event.target.value })} /> + {help &&

{t(help)}

}
})}
@@ -324,7 +329,7 @@ function PBSResultBadge({ status, running = false, tone }: { status?: string; ru const badge = {running ? t("pbs.running") : pbsResultText(status, t)} - if (running || !status || /^ok$/i.test(status)) return badge + if (running || !status || status === "unknown" || /^ok$/i.test(status) || /^WARNINGS:\s*\d+\s*$/i.test(status)) return badge return
{badge}
{status}
diff --git a/AppImage/components/pbs-problem-message.tsx b/AppImage/components/pbs-problem-message.tsx index 459701c4..f8286da9 100644 --- a/AppImage/components/pbs-problem-message.tsx +++ b/AppImage/components/pbs-problem-message.tsx @@ -1,16 +1,17 @@ "use client" -import { pbsErrorText } from "../lib/pbs-messages" +import { pbsErrorText, pbsIsKnownError } from "../lib/pbs-messages" import { useT } from "../lib/i18n/provider" -// Keep PBS's original diagnostic available without making it the UI message. +// Known application messages are already explained in the active language. +// Preserve unrecognized external diagnostics as escaped technical details. export function PBSProblemMessage({ problem, raw, fallback, className = "" }: { problem?: unknown; raw?: string; fallback?: string; className?: string }) { const t = useT() return

{pbsErrorText(problem, t, fallback)}

- {raw &&
+ {raw && !pbsIsKnownError(problem) &&
{t("pbs.technicalDetails")}
{raw}
} diff --git a/AppImage/lib/pbs-messages.ts b/AppImage/lib/pbs-messages.ts index 6ce6faa3..6314d317 100644 --- a/AppImage/lib/pbs-messages.ts +++ b/AppImage/lib/pbs-messages.ts @@ -20,18 +20,26 @@ const ERROR_KEYS: Record = { 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 { +// Accept structured codes, never arbitrary error text as a translation key. +function pbsErrorTranslation(error: unknown): { key: string; params?: { count: number } } | null { 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 || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return null 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) + ? { key: "pbs.errors.connectionLimit", params: { count } } : null } - return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code) ? t(ERROR_KEYS[body.code]) : t(fallback) + return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code) ? { key: ERROR_KEYS[body.code] } : null +} + +export function pbsIsKnownError(error: unknown): boolean { + return pbsErrorTranslation(error) !== null +} + +export function pbsErrorText(error: unknown, t: Translate, fallback = "pbs.loadFailed"): string { + const translated = pbsErrorTranslation(error) + return translated ? t(translated.key, translated.params) : t(fallback) } export function pbsDiagnostic(error: unknown): string | undefined { diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index f4a3b71c..7bc7b31c 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -14,7 +14,7 @@ "signInWith": "Sign in with", "methodPassword": "User and password", "methodToken": "API token", - "username": "User (user@realm)", + "username": "User", "password": "Password", "passwordHelp": "The password is used once to create a read-only API token (Audit role) for this Monitor. Only that token is saved, never the password. Enable Monitor authentication first.", "cancel": "Cancel", @@ -27,10 +27,15 @@ "editConnection": "Connection to edit", "add": "Add a new connection", "name": "Name", - "url": "HTTPS URL (include :8007 for the native PBS port)", - "token_id": "API token ID (user@realm!token)", - "token_secret": "API token secret (leave empty to keep the stored secret)", - "fingerprint": "SHA256 certificate fingerprint (optional)", + "url": "HTTPS address", + "urlHelp": "Include :8007 for the default PBS port, for example https://pbs.example:8007.", + "usernameHelp": "Use user@realm, for example root@pam.", + "tokenIdHelp": "Use user@realm!token, for example monitor@pbs!proxmenux.", + "tokenSecretHelp": "Enter the secret of this API token.", + "tokenSecretKeepHelp": "Leave empty to keep the saved token secret.", + "token_id": "API token ID", + "token_secret": "API token secret", + "fingerprint": "SHA256 fingerprint (optional)", "tlsHelp": "Leave the fingerprint empty for normal CA and hostname verification. For a self-signed certificate, copy its SHA256 fingerprint from the PBS console or another trusted channel. The pin is checked before sending credentials.", "saved": "PBS connection verified and saved.", "saveFailed": "Could not save the PBS connection.", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 801b456e..418bf19b 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -7310,7 +7310,7 @@ "signInWith": "Prihlásiť sa pomocou", "methodPassword": "Používateľ a heslo", "methodToken": "API token", - "username": "Používateľ (user@realm)", + "username": "Používateľ", "password": "Heslo", "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ť", @@ -7323,10 +7323,15 @@ "editConnection": "Pripojenie na úpravu", "add": "Pridať nové pripojenie", "name": "Názov", - "url": "Adresa HTTPS (pre štandardný port PBS uveď :8007)", - "token_id": "ID API tokenu (user@realm!token)", - "token_secret": "Tajný kľúč API tokenu (prázdne pole zachová uložený kľúč)", - "fingerprint": "Odtlačok SHA256 certifikátu (voliteľný)", + "url": "Adresa HTTPS", + "urlHelp": "Štandardný port PBS je 8007, napríklad https://pbs.example:8007.", + "usernameHelp": "Použi meno a overovaciu oblasť oddelené znakom @, napríklad root@pam.", + "tokenIdHelp": "Použi formát meno@oblasť!token, napríklad monitor@pbs!proxmenux.", + "tokenSecretHelp": "Zadaj tajný kľúč k tomuto API tokenu.", + "tokenSecretKeepHelp": "Prázdne pole zachová uložený tajný kľúč tokenu.", + "token_id": "ID API tokenu", + "token_secret": "Tajný kľúč API tokenu", + "fingerprint": "Odtlačok SHA256 (voliteľný)", "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.", diff --git a/tests/test_pbs_i18n.cjs b/tests/test_pbs_i18n.cjs index 1712b9d8..93d92605 100644 --- a/tests/test_pbs_i18n.cjs +++ b/tests/test_pbs_i18n.cjs @@ -97,11 +97,28 @@ async function main() { 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')); + assert.equal(t('pbs.url'),locale==='sk'?'Adresa HTTPS':'HTTPS address'); + const diagnosticFixture=fixture(locale),problem=diagnosticFixture.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage; + for(const code of codes){ + if(code==='pbs_unknown')continue; + const err=failure(code);err.body.params={count:32}; + const rendered=problem({problem:err,raw:'RAW DIAGNOSTIC'}); + assert.ok(!text(rendered).includes('RAW DIAGNOSTIC'),'Known '+code+' must not repeat an English application message'); + assert.ok(!flatten(rendered).some(n=>n.type==='details')); + } + for(const code of ['future','__proto__','constructor']){ + const rendered=problem({problem:failure(code),raw:'RAW DIAGNOSTIC'}); + assert.ok(text(rendered).includes('RAW DIAGNOSTIC'));assert.ok(flatten(rendered).some(n=>n.type==='details')); + } 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-'))); + unknown.status.tasks=[{upid:'warn',endtime:1700000000,status:'WARNINGS: 3'},{upid:'raw',endtime:1700000000,status:'RAW EXTERNAL TASK ERROR'}];unknown.status.sync=[];unknown.render(); + const taskDetails=unknown.nodes().filter(n=>n.type==='details');assert.equal(taskDetails.length,1);assert.ok(text(taskDetails[0]).includes('RAW EXTERNAL TASK ERROR')); const f=fixture(locale);f.render();f.render();f.button(t('pbs.manage')).props.onClick();f.render(); + assert.equal(f.input('url').props['aria-describedby'],'pbs-url-help'); + assert.ok(f.text().includes(t('pbs.urlHelp')));assert.ok(f.text().includes(t('pbs.usernameHelp'))); 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); @@ -111,8 +128,11 @@ async function main() { 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.ok(f.text().includes(t('pbs.tokenSecretHelp')));assert.ok(!f.text().includes(t('pbs.tokenSecretKeepHelp'))); + assert.equal(f.input('token_secret').props['aria-describedby'],'pbs-token_secret-help'); 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.ok(f.text().includes(t('pbs.tokenSecretKeepHelp')));assert.ok(!f.text().includes(t('pbs.tokenSecretHelp'))); 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'); From 1dc9e92f74fc18b95ddf7b785d96b560ce9b90f7 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:35:33 +0000 Subject: [PATCH 4/7] i18n(sk): clarify removal of old backups --- AppImage/messages/sk/common.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 418bf19b..f7312318 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -4807,7 +4807,7 @@ "keepAll": "Ponechať všetky zálohy", "description": "Predvolená politika uchovávania záloh uložená v Proxmoxe. Zálohovacie úlohy s vlastnou politikou majú pred týmto predvoleným nastavením prednosť.", "refresh": "Obnoviť", - "saved": "Politika uchovávania záloh úložiska bola uložená. Prerezávanie sa nevykonalo.", + "saved": "Pravidlá uchovávania záloh na úložisku boli uložené. Žiadne zálohy sa teraz neodstránili.", "choosePolicy": "Vyberte Ponechať všetky alebo aspoň jeden počet uchovávaných záloh väčší ako nula.", "saveFailed": "Nepodarilo sa uložiť politiku uchovávania záloh úložiska. Ak sa konfigurácia zmenila, obnovte zoznam.", "loadFailed": "Nepodarilo sa načítať politiku uchovávania záloh úložiska.", @@ -5641,7 +5641,7 @@ "nextStep": { "missing": "Nastavte si držanie na týchto pracovných miestach, alebo na sklade, na ktorý píšu. Proxmox berie nastavenie úlohy ako prvý, potom úložisko je, potom uzol predvolený.", "notDeclared": "Tieto práce uchovávajú všetky kópie, ktoré robia. To je zámerná voľba pre niektoré destinácie, ale potvrďte, že cieľ má priestor na rast.", - "onServer": "Tieto úlohy píšu na záložný server, ktorý ich prerezáva podľa vlastných pravidiel. Z tohto uzla sa nedá čítať to, čo drží, takže skontrolujte, či tam nie je." + "onServer": "Tieto úlohy ukladajú zálohy na zálohovací server. Ten odstraňuje staré zálohy podľa vlastných pravidiel. Z tohto uzla sa nedá zistiť, ktoré zálohy uchováva; skontroluj ich priamo na zálohovacom serveri." }, "explanation": "O tom, koľko výtlačkov sa uchováva a ako dlho sa uchováva. Bez nej sa cieľ naplní, kým neprestane prijímať nové kópie, a záloha, ktorú nemožno napísať, je tá, o ktorej sa dozviete v deň, keď ju potrebujete." }, @@ -7356,7 +7356,7 @@ "schedule": "Plán", "sync": "Synchronizácia", "verify": "Overenie", - "prune": "Prerezávanie záloh", + "prune": "Odstraňovanie starých záloh", "overview": "Prehľad", "logs": "Systémové záznamy", "terminal": "Terminál", @@ -7373,7 +7373,7 @@ "estimatedFull": "Odhadované zaplnenie", "estimatedFullUnknown": "Zatiaľ sa nedá odhadnúť", "duration": "Trvanie", - "noJobs": "Nie sú nastavené žiadne plány synchronizácie, overovania ani prerezávania záloh.", + "noJobs": "Nie sú nastavené žiadne plány synchronizácie, overovania ani odstraňovania starých 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.", @@ -7415,7 +7415,7 @@ "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", + "prune": "Odstraňovanie starých 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})" }, From aed77ecefe67aa521dc53be799e62b9a6f215e4c Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:45:29 +0000 Subject: [PATCH 5/7] i18n: localize host backup application errors --- .github/scripts/run_offline_qualified.py | 1 + AppImage/components/host-backup.tsx | 89 +++++++------- AppImage/lib/host-backup-messages.ts | 141 +++++++++++++++++++++++ AppImage/messages/en/common.json | 118 +++++++++++++++++++ AppImage/messages/sk/common.json | 120 ++++++++++++++++++- tests/test_host_backup_messages.cjs | 103 +++++++++++++++++ 6 files changed, 527 insertions(+), 45 deletions(-) create mode 100644 AppImage/lib/host-backup-messages.ts create mode 100644 tests/test_host_backup_messages.cjs diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 9689e2ff..586ff3f5 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -31,6 +31,7 @@ PYTHON_FILES = ( "AppImage/scripts/tests/test_health_persistence_recurrence.py", ) NODE_FILES = ( + "tests/test_host_backup_messages.cjs", "tests/test_pbs_i18n.cjs", "tests/test_monitor_links_i18n.cjs", "tests/lxc_updates/test_local_app_icons_ui.cjs", diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 07e10b94..30434ff7 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -48,6 +48,7 @@ import { import { ScriptTerminalModal } from "./script-terminal-modal" import { RestoreProgressCard } from "./restore-progress-card" import { fetchApi, getApiUrl } from "../lib/api-config" +import { hostBackupErrorText } from "../lib/host-backup-messages" import { fetchTerminalTicket } from "../lib/terminal-ws" import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils" import { getStorageUsageColor } from "../lib/storage-usage-color" @@ -525,7 +526,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { await mutateInfo() closeUpload() } catch (e) { - setErr(e instanceof Error ? e.message : String(e)) + setErr(hostBackupErrorText(e, t)) setBusy(false) } } @@ -542,7 +543,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { await mutateInfo() closeDelete() } catch (e) { - setErr(e instanceof Error ? e.message : String(e)) + setErr(hostBackupErrorText(e, t)) setBusy(false) } } @@ -765,7 +766,7 @@ function KeyfileActionsBar({ setPass1("") setPass2("") } catch (e) { - setErr(e instanceof Error ? e.message : String(e)) + setErr(hostBackupErrorText(e, t)) } finally { setBusy(false) } @@ -956,7 +957,7 @@ export function HostBackup() { mutateJobs() setJobToDelete(null) } catch (e) { - setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` })) + setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${hostBackupErrorText(e, t)}` })) } finally { setBusyJobId(null) } @@ -1334,7 +1335,7 @@ export function HostBackup() {
{t("backup.archives.remoteQueryWarning")}
{remoteArchivesResp.errors.map((e, i) => (
- {e.backend}/{e.repo_name}: {e.error} + {e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
))}
@@ -1761,7 +1762,7 @@ function InspectModal({ setImportFile(null) setImportPath("") } catch (e) { - setImportError(e instanceof Error ? e.message : String(e)) + setImportError(hostBackupErrorText(e, t)) } finally { setImporting(false) } @@ -1812,7 +1813,7 @@ function InspectModal({ : undefined, }) } catch (e) { - setRestoreError(e instanceof Error ? e.message : String(e)) + setRestoreError(hostBackupErrorText(e, t)) } finally { setRestorePreparing(false) } @@ -1841,7 +1842,7 @@ function InspectModal({ a.click() document.body.removeChild(a) } catch (e) { - setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) })) + setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) })) } finally { // The download itself runs in the browser's network stack; // we just initiated it. Clear the spinner immediately. @@ -1914,7 +1915,7 @@ function InspectModal({ document.body.removeChild(a) setExportTask(null) } catch (e) { - setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) })) + setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) })) } finally { setDownloading(false) } @@ -1962,7 +1963,7 @@ function InspectModal({ setShowDeleteArchiveConfirm(false) if (onDeleted) onDeleted(); else onClose() } catch (e) { - setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) })) + setError(t("backup.errors.deleteFailed", { error: hostBackupErrorText(e, t) })) } finally { setDeletingArchive(false) } @@ -1989,7 +1990,7 @@ function InspectModal({ ) as NonNullable setInstructions(res) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setFetchingInstructions(false) } @@ -2021,7 +2022,7 @@ function InspectModal({ ) setReport(res) } catch (e: any) { - setError(e?.message || t("backup.errors.preflightFailed")) + setError(hostBackupErrorText(e, t)) } finally { setRunning(false) } @@ -2164,7 +2165,7 @@ function InspectModal({ — {exportTask.message}
{exportTask.state === "failed" && exportTask.error && ( -
{exportTask.error}
+
{hostBackupErrorText(exportTask.error, t)}
)} {exportTask.state === "completed" && exportTask.size_bytes > 0 && (
{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}
@@ -2706,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
{c.id} - {c.message} + {hostBackupErrorText(c.message, t)}
) @@ -3326,9 +3327,9 @@ function CreateJobDialog({ } catch (e) { const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } const detail = err.body?.tool_output - ? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}` - : err.message - setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) })) + ? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}` + : hostBackupErrorText(err, t) + setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) })) setPbsImportBusy(false) setSubmitting(false) return @@ -3391,7 +3392,7 @@ function CreateJobDialog({ }) onCreated() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setSubmitting(false) } @@ -3764,7 +3765,7 @@ function CreateJobDialog({ ) : (
- {t("backup.validation.invalidLabel")} {calendarPreview.error} + {t("backup.validation.invalidLabel")} {hostBackupErrorText(calendarPreview.error, t)}
) ) : ( @@ -4696,9 +4697,9 @@ function ManualBackupDialog({ } catch (e) { const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } const detail = err.body?.tool_output - ? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}` - : err.message - setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) })) + ? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}` + : hostBackupErrorText(err, t) + setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) })) setPbsImportBusy(false) setSubmitting(false) return @@ -4738,7 +4739,7 @@ function ManualBackupDialog({ ) onLaunched(resp.job_id) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setSubmitting(false) } @@ -5542,7 +5543,7 @@ function DestinationsSection({ await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" }) onChanged() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -5554,7 +5555,7 @@ function DestinationsSection({ await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" }) onChanged() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -5571,7 +5572,7 @@ function DestinationsSection({ ) onChanged() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -5599,7 +5600,7 @@ function DestinationsSection({ }) onChanged() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -6042,7 +6043,7 @@ function DestinationRow({ {capacity?.error && (

- {t("backup.destinations.capacityUnavailable", { error: capacity.error })} + {t("backup.destinations.capacityUnavailable", { error: hostBackupErrorText(capacity.error, t) })}

)} {!capacity && ( @@ -6341,7 +6342,7 @@ function AddDestinationDialog({ ) setGeneratedKey(resp) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setGeneratingKey(false) } @@ -6405,7 +6406,7 @@ function AddDestinationDialog({ } onSaved(savedRepo) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setSubmitting(false) } @@ -6756,7 +6757,7 @@ function UsbPicker({ mutate() if (resp?.mountpoint) onPick(resp.mountpoint) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -6784,7 +6785,7 @@ function UsbPicker({ setFormatTyped("") if (resp?.mountpoint) onPick(resp.mountpoint) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -7001,7 +7002,7 @@ function ExtraPathsSection() { setNewPath("") mutate() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setSubmitting(false) } @@ -7014,7 +7015,7 @@ function ExtraPathsSection() { await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" }) mutate() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyPath(null) } @@ -7180,7 +7181,7 @@ function UsbDrivesSection() { }) mutate() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -7198,7 +7199,7 @@ function UsbDrivesSection() { }) mutate() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -7222,7 +7223,7 @@ function UsbDrivesSection() { setFormatTarget(null) setFormatTyped("") } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -7508,7 +7509,7 @@ function JobDetailModal({ setRunning(false) setRunBaseline(null) setRunBaselineLogPath(null) - setActionError(t("backup.errors.runFailed", { error: e instanceof Error ? e.message : String(e) })) + setActionError(t("backup.errors.runFailed", { error: hostBackupErrorText(e, t) })) } } @@ -7522,7 +7523,7 @@ function JobDetailModal({ await refetch() onChanged() } catch (e) { - setActionError(t("backup.errors.toggleFailed", { error: e instanceof Error ? e.message : String(e) })) + setActionError(t("backup.errors.toggleFailed", { error: hostBackupErrorText(e, t) })) } finally { setBusy("") } @@ -8155,7 +8156,7 @@ function PbsKeyfileRecoveryDialog({ }) onRecovered() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusy(false) } @@ -8342,7 +8343,7 @@ function ArchiveContentsModal({ body: JSON.stringify(body), }) .then((r: any) => setData(r)) - .catch((e) => setError(e instanceof Error ? e.message : String(e))) + .catch((e) => setError(hostBackupErrorText(e, t))) .finally(() => setLoading(false)) }, [open, source, repo_name, snapshot, path]) @@ -8571,7 +8572,7 @@ function PlanSummary({ plan }: { plan: any }) {
{t("backup.plan.blockers")}
    {blockers.map((b, i) => ( -
  • {typeof b === "string" ? b : (b.message || JSON.stringify(b))}
  • +
  • {hostBackupErrorText(typeof b === "string" ? b : (b.message || JSON.stringify(b)), t)}
  • ))}
@@ -8581,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
{t("backup.plan.warnings")}
    {warnings.map((w, i) => ( -
  • {typeof w === "string" ? w : (w.message || JSON.stringify(w))}
  • +
  • {hostBackupErrorText(typeof w === "string" ? w : (w.message || JSON.stringify(w)), t)}
  • ))}
@@ -8611,7 +8612,7 @@ function StorageSection({ storage }: { storage: any }) {
{e.name || e.storage || `entry ${i}`} {e.type && {e.type}} - {e.message &&
{e.message}
} + {e.message &&
{hostBackupErrorText(e.message, t)}
}
) diff --git a/AppImage/lib/host-backup-messages.ts b/AppImage/lib/host-backup-messages.ts new file mode 100644 index 00000000..6129df23 --- /dev/null +++ b/AppImage/lib/host-backup-messages.ts @@ -0,0 +1,141 @@ +// Legacy HostBackup API messages are an English wire contract shared with CLI callers. +// Match complete, source-verified messages only; never derive a translation key from input. +// This presentation-only adapter keeps unknown tool diagnostics and parameter text intact. +type Translate = (key: string, params?: Record) => string + +const fixed = new Map([ + ["path must be absolute", "backup.apiErrors.absolutePath"], + ["this path is already configured", "backup.apiErrors.pathConfigured"], + ["path is required", "backup.apiErrors.pathRequired"], + ["path query parameter is required", "backup.apiErrors.pathQuery"], + ["path not found", "backup.apiErrors.pathNotFound"], + ["invalid job id", "backup.apiErrors.invalidJobId"], + ["job not found", "backup.apiErrors.jobNotFound"], + ["runner script not installed", "backup.apiErrors.runnerMissing"], + ["no restore state to dismiss", "backup.apiErrors.noRestore"], + ["invalid file name", "backup.apiErrors.invalidFile"], + ["not found", "backup.apiErrors.notFound"], + ["log path outside allowed directory", "backup.apiErrors.logOutside"], + ["targets must be a list", "backup.apiErrors.targetsList"], + ["empty expression", "backup.apiErrors.emptyExpression"], + ["invalid id (use letters, digits, _ or -)", "backup.apiErrors.invalidId"], + ["backend must be pbs, local or borg", "backup.apiErrors.backend"], + ["backend must be pbs or borg", "backup.apiErrors.remoteBackend"], + ["backend must be 'pbs' or 'borg'", "backup.apiErrors.remoteBackendQuoted"], + ["profile_mode must be default or custom", "backup.apiErrors.profileMode"], + ["borg cannot be attached (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttached"], + ["borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttachedLong"], + ["pve_parent_job_id and pve_storage are required for attached", "backup.apiErrors.parentRequired"], + ["paths must be a list of absolute paths", "backup.apiErrors.absolutePaths"], + ["custom profile needs at least one path", "backup.apiErrors.customPath"], + ["could not resolve default profile paths", "backup.apiErrors.defaultPaths"], + ["retention must be an object", "backup.apiErrors.retentionObject"], + ["pbs_repository is required", "backup.apiErrors.pbsRepository"], + ["pbs_encrypt_mode must be 'none', 'new' or 'existing'", "backup.apiErrors.pbsEncryption"], + ["failed to create PBS encryption keyfile", "backup.apiErrors.pbsKeyFailed"], + ["failed to create PBS encryption keyfile (is proxmox-backup-client installed?)", "backup.apiErrors.pbsClientMissing"], + ["borg_repo is required", "backup.apiErrors.borgRepository"], + ["borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.borgEncryption"], + ["encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.encryption"], + ["borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)", "backup.apiErrors.borgPassphrase"], + ["passphrase is required when encryption is enabled", "backup.apiErrors.passphrase"], + ["password is required", "backup.apiErrors.password"], + ["confirm_device must match device exactly (typo / safety check)", "backup.apiErrors.deviceConfirmation"], + ["invalid device path", "backup.apiErrors.invalidDevice"], + ["absolute mountpoint required", "backup.apiErrors.absoluteMountpoint"], + ["archive layout not recognized — no rootfs/ found", "backup.apiErrors.archiveLayout"], + ["archive not found", "backup.apiErrors.archiveMissing"], + ["archive path is not a regular file", "backup.apiErrors.archiveNotFile"], + ["invalid name", "backup.apiErrors.invalidName"], + ["invalid staging_path", "backup.apiErrors.invalidStaging"], + ["job name cannot be changed (delete + recreate to rename)", "backup.apiErrors.jobRename"], + ["key_path must be absolute", "backup.apiErrors.absoluteKey"], + ["mode must be \"local\" or \"ssh\"", "backup.apiErrors.mode"], + ["name is required (letters, digits, _ or -)", "backup.apiErrors.nameRequired"], + ["no log available for this job yet", "backup.apiErrors.noLog"], + ["no manual PBS configs file", "backup.apiErrors.noPbsConfig"], + ["output file missing", "backup.apiErrors.outputMissing"], + ["parser output was not valid JSON", "backup.apiErrors.parserJson"], + ["run_restore output was not valid JSON", "backup.apiErrors.restoreJson"], + ["repo is required", "backup.apiErrors.repoRequired"], + ["repo_name and snapshot are required", "backup.apiErrors.repoSnapshot"], + ["repo_name and snapshot are required for borg", "backup.apiErrors.borgRepoSnapshot"], + ["repo_name and snapshot are required for pbs", "backup.apiErrors.pbsRepoSnapshot"], + ["repo_name is required", "backup.apiErrors.repoName"], + ["restore tooling not installed on this host", "backup.apiErrors.restoreMissing"], + ["server and datastore are required", "backup.apiErrors.serverDatastore"], + ["snapshot is required", "backup.apiErrors.snapshotRequired"], + ["source must be pbs, borg, or local", "backup.apiErrors.source"], + ["ssh_port must be an integer between 1 and 65535", "backup.apiErrors.sshPort"], + ["ssh_user, ssh_host and ssh_remote_path are required for ssh mode", "backup.apiErrors.sshRequired"], + ["target has no repository path", "backup.apiErrors.repositoryPath"], + ["task not found or expired", "backup.apiErrors.taskExpired"], + ["invalid expression", "backup.apiErrors.invalidExpression"], + ["no report emitted", "backup.apiErrors.noReport"], + ["parse_manifest exited non-zero", "backup.apiErrors.parseManifestFailed"], +]) + +const parameterized: ReadonlyArray = [ + [new RegExp("^job already exists: ([\\s\\S]+)$"), "backup.apiErrors.jobExists"], + [new RegExp("^path does not exist on this host: ([\\s\\S]+)$"), "backup.apiErrors.pathMissing"], + [new RegExp("^device does not exist: ([\\s\\S]+)$"), "backup.apiErrors.deviceMissing"], + [new RegExp("^not a mountpoint: ([\\s\\S]+)$"), "backup.apiErrors.mountpoint"], + [new RegExp("^local archive not found: ([\\s\\S]+)$"), "backup.apiErrors.localArchiveMissing"], + [new RegExp("^Borg destination '([\\s\\S]+)' not found$"), "backup.apiErrors.borgDestination"], + [new RegExp("^PBS destination '([\\s\\S]+)' not found$"), "backup.apiErrors.pbsDestination"], + [new RegExp("^no password available for PBS repo '([\\s\\S]+)'$"), "backup.apiErrors.pbsPassword"], + [new RegExp("^Borg repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.borgRepoMissing"], + [new RegExp("^PBS repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.pbsRepoMissing"], + [new RegExp("^Borg target not found: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetMissing"], + [new RegExp("^PBS repo not found: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetMissing"], + [new RegExp("^PVE vzdump job not found: ([\\s\\S]+)$"), "backup.apiErrors.pveJobMissing"], + [new RegExp("^cannot persist state: ([\\s\\S]+)$"), "backup.apiErrors.statePersist"], + [new RegExp("^cannot prepare ssh dir: ([\\s\\S]+)$"), "backup.apiErrors.sshDirectory"], + [new RegExp("^cannot read log: ([\\s\\S]+)$"), "backup.apiErrors.logRead"], + [new RegExp("^could not update \\.env: ([\\s\\S]+)$"), "backup.apiErrors.envUpdate"], + [new RegExp("^failed to persist PBS config: ([\\s\\S]+)$"), "backup.apiErrors.pbsConfigWrite"], + [new RegExp("^failed to persist: ([\\s\\S]+)$"), "backup.apiErrors.persist"], + [new RegExp("^failed to remove archive: ([\\s\\S]+)$"), "backup.apiErrors.archiveRemove"], + [new RegExp("^failed to start: ([\\s\\S]+)$"), "backup.apiErrors.start"], + [new RegExp("^failed to update borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsUpdate"], + [new RegExp("^failed to update pbs-manual-configs\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetsUpdate"], + [new RegExp("^failed to update systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsUpdate"], + [new RegExp("^failed to write borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsWrite"], + [new RegExp("^failed to write job files: ([\\s\\S]+)$"), "backup.apiErrors.jobWrite"], + [new RegExp("^failed to write local-target\\.conf: ([\\s\\S]+)$"), "backup.apiErrors.localTargetWrite"], + [new RegExp("^failed to write systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsWrite"], + [new RegExp("^format failed: ([\\s\\S]+)$"), "backup.apiErrors.format"], + [new RegExp("^invalid on_calendar: ([\\s\\S]+)$"), "backup.apiErrors.calendar"], + [new RegExp("^mount failed: ([\\s\\S]+)$"), "backup.apiErrors.mount"], + [new RegExp("^parser invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.parserInvocation"], + [new RegExp("^preflight invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.preflightInvocation"], + [new RegExp("^public key not found after generation: ([\\s\\S]+)$"), "backup.apiErrors.publicKeyMissing"], + [new RegExp("^retention\\.([\\s\\S]+) must be an integer$"), "backup.apiErrors.retentionInteger"], + [new RegExp("^retention\\.([\\s\\S]+) must be non-negative$"), "backup.apiErrors.retentionNegative"], + [new RegExp("^ssh-keygen failed: ([\\s\\S]+)$"), "backup.apiErrors.keygen"], + [new RegExp("^systemctl failed: ([\\s\\S]+)$"), "backup.apiErrors.systemctl"], + [new RegExp("^umount failed: ([\\s\\S]+)$"), "backup.apiErrors.unmount"], + [new RegExp("^task is in state \"([\\s\\S]+)\", not completed$"), "backup.apiErrors.taskIncomplete"], + [new RegExp("^unknown kind: ([\\s\\S]+)$"), "backup.apiErrors.unknownKind"], + [new RegExp("^unknown mode \"([\\s\\S]+)\"$"), "backup.apiErrors.unknownMode"], + [new RegExp("^([\\s\\S]+) is the built-in default and cannot be removed$"), "backup.apiErrors.defaultRemove"], + [new RegExp("^([\\s\\S]+) is the built-in default; no need to add it$"), "backup.apiErrors.defaultAdd"], + [new RegExp("^pbs_encrypt_mode='existing' but no keyfile is installed at ([\\s\\S]+) — import one first with POST /api/host-backups/pbs-encryption/import$"), "backup.apiErrors.keyImport"], + [new RegExp("^PVE job storage mismatch \\(job uses ([\\s\\S]+), payload says ([\\s\\S]+)\\)$"), "backup.apiErrors.storageMismatch"], +] + +export function hostBackupErrorText(problem: unknown, t: Translate): string { + const message = typeof problem === "string" ? problem + : problem && typeof problem === "object" && "message" in problem && typeof problem.message === "string" + ? problem.message : "" + if (!message) return t("backup.apiErrors.requestFailed") + const key = fixed.get(message) + if (key) return t(key) + // Keep unusually large tool output verbatim, without running pattern matching over it. + if (message.length > 16384) return message + for (const [pattern, patternKey] of parameterized) { + const match = pattern.exec(message) + if (match) return t(patternKey, { value: match[1], other: match[2] ?? "" }) + } + return message +} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 7bc7b31c..cb13efab 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -4368,6 +4368,124 @@ "unrecoverable": "Without the key, encrypted backups cannot be restored.", "usesSharedKeyfileAt": "Uses shared keyfile at" }, + "apiErrors": { + "requestFailed": "The request failed.", + "absolutePath": "path must be absolute", + "pathConfigured": "this path is already configured", + "pathRequired": "path is required", + "pathQuery": "path query parameter is required", + "pathNotFound": "path not found", + "invalidJobId": "invalid job id", + "jobNotFound": "job not found", + "runnerMissing": "runner script not installed", + "noRestore": "no restore state to dismiss", + "invalidFile": "invalid file name", + "notFound": "not found", + "logOutside": "log path outside allowed directory", + "targetsList": "targets must be a list", + "emptyExpression": "empty expression", + "invalidId": "invalid id (use letters, digits, _ or -)", + "backend": "backend must be pbs, local or borg", + "remoteBackend": "backend must be pbs or borg", + "remoteBackendQuoted": "backend must be 'pbs' or 'borg'", + "profileMode": "profile_mode must be default or custom", + "borgAttached": "borg cannot be attached (PVE vzdump only writes to pbs/local storages)", + "borgAttachedLong": "borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)", + "parentRequired": "pve_parent_job_id and pve_storage are required for attached", + "absolutePaths": "paths must be a list of absolute paths", + "customPath": "custom profile needs at least one path", + "defaultPaths": "could not resolve default profile paths", + "retentionObject": "retention must be an object", + "pbsRepository": "pbs_repository is required", + "pbsEncryption": "pbs_encrypt_mode must be 'none', 'new' or 'existing'", + "pbsKeyFailed": "failed to create PBS encryption keyfile", + "pbsClientMissing": "failed to create PBS encryption keyfile (is proxmox-backup-client installed?)", + "borgRepository": "borg_repo is required", + "borgEncryption": "borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated", + "encryption": "encrypt_mode must be one of: none, repokey, keyfile, authenticated", + "borgPassphrase": "borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)", + "passphrase": "passphrase is required when encryption is enabled", + "password": "password is required", + "deviceConfirmation": "confirm_device must match device exactly (typo / safety check)", + "invalidDevice": "invalid device path", + "absoluteMountpoint": "absolute mountpoint required", + "archiveLayout": "archive layout not recognized — no rootfs/ found", + "archiveMissing": "archive not found", + "archiveNotFile": "archive path is not a regular file", + "invalidName": "invalid name", + "invalidStaging": "invalid staging_path", + "jobRename": "job name cannot be changed (delete + recreate to rename)", + "absoluteKey": "key_path must be absolute", + "mode": "mode must be \"local\" or \"ssh\"", + "nameRequired": "name is required (letters, digits, _ or -)", + "noLog": "no log available for this job yet", + "noPbsConfig": "no manual PBS configs file", + "outputMissing": "output file missing", + "parserJson": "parser output was not valid JSON", + "restoreJson": "run_restore output was not valid JSON", + "repoRequired": "repo is required", + "repoSnapshot": "repo_name and snapshot are required", + "borgRepoSnapshot": "repo_name and snapshot are required for borg", + "pbsRepoSnapshot": "repo_name and snapshot are required for pbs", + "repoName": "repo_name is required", + "restoreMissing": "restore tooling not installed on this host", + "serverDatastore": "server and datastore are required", + "snapshotRequired": "snapshot is required", + "source": "source must be pbs, borg, or local", + "sshPort": "ssh_port must be an integer between 1 and 65535", + "sshRequired": "ssh_user, ssh_host and ssh_remote_path are required for ssh mode", + "repositoryPath": "target has no repository path", + "taskExpired": "task not found or expired", + "invalidExpression": "invalid expression", + "noReport": "no report emitted", + "parseManifestFailed": "parse_manifest exited non-zero", + "jobExists": "job already exists: {value}", + "pathMissing": "path does not exist on this host: {value}", + "deviceMissing": "device does not exist: {value}", + "mountpoint": "not a mountpoint: {value}", + "localArchiveMissing": "local archive not found: {value}", + "borgDestination": "Borg destination '{value}' not found", + "pbsDestination": "PBS destination '{value}' not found", + "pbsPassword": "no password available for PBS repo '{value}'", + "borgRepoMissing": "Borg repo \"{value}\" not configured", + "pbsRepoMissing": "PBS repo \"{value}\" not configured", + "borgTargetMissing": "Borg target not found: {value}", + "pbsTargetMissing": "PBS repo not found: {value}", + "pveJobMissing": "PVE vzdump job not found: {value}", + "statePersist": "cannot persist state: {value}", + "sshDirectory": "cannot prepare ssh dir: {value}", + "logRead": "cannot read log: {value}", + "envUpdate": "could not update .env: {value}", + "pbsConfigWrite": "failed to persist PBS config: {value}", + "persist": "failed to persist: {value}", + "archiveRemove": "failed to remove archive: {value}", + "start": "failed to start: {value}", + "borgTargetsUpdate": "failed to update borg-targets.txt: {value}", + "pbsTargetsUpdate": "failed to update pbs-manual-configs.txt: {value}", + "unitsUpdate": "failed to update systemd units: {value}", + "borgTargetsWrite": "failed to write borg-targets.txt: {value}", + "jobWrite": "failed to write job files: {value}", + "localTargetWrite": "failed to write local-target.conf: {value}", + "unitsWrite": "failed to write systemd units: {value}", + "format": "format failed: {value}", + "calendar": "invalid on_calendar: {value}", + "mount": "mount failed: {value}", + "parserInvocation": "parser invocation failed: {value}", + "preflightInvocation": "preflight invocation failed: {value}", + "publicKeyMissing": "public key not found after generation: {value}", + "retentionInteger": "retention.{value} must be an integer", + "retentionNegative": "retention.{value} must be non-negative", + "keygen": "ssh-keygen failed: {value}", + "systemctl": "systemctl failed: {value}", + "unmount": "umount failed: {value}", + "taskIncomplete": "task is in state \"{value}\", not completed", + "unknownKind": "unknown kind: {value}", + "unknownMode": "unknown mode \"{value}\"", + "defaultRemove": "{value} is the built-in default and cannot be removed", + "defaultAdd": "{value} is the built-in default; no need to add it", + "keyImport": "No PBS keyfile is installed at {value}. Import the keyfile first.", + "storageMismatch": "PVE job storage mismatch (job uses {value}, payload says {other})" + }, "errors": { "backendNoStagingPath": "Backend did not return a staging path.", "deleteFailed": "Delete failed: {error}", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index f7312318..86302c98 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -4178,7 +4178,7 @@ "unmountTitle": "Odpojiť tento cieľ", "whereIsBorgRepo": "Kde je Borg repozitár?", "backupsKeptMessage": "Existujúce zálohy sa neodstránia.", - "localAttachHelpMessage": "Najprv pripojte jednotky USB.Ak chcete použiť cestu k úložisku PVE + /dump, nechajte pole prázdne (záložný: /var/lib/vz/dump).", + "localAttachHelpMessage": "Najprv pripoj USB disk. Ak pole necháš prázdne, použije sa cesta úložiska PVE doplnená o /dump; náhradná cesta je /var/lib/vz/dump.", "sshAuthorizedKeyHelp": "Pred pridaním do ~/.ssh/authorized_keys pre {user} na vzdialenom hostiteľovi skontrolujte riadok nižšie.", "sshKeyHelpMessage": "Cesta súkromného kľúča na tomto hostiteľovi.Použite {action}, potom skontrolujte a pridajte zobrazený riadok verejného kľúča do ~/.ssh/authorized_keys vzdialeného používateľa.", "sshKeySetupHelp": "Vytvorí kľúč na zadanej lokálnej ceste, ak žiadny neexistuje;inak prečíta svoj verejný kľúč.Na vzdialenom hostiteľovi nie je nainštalovaný žiadny kľúč.", @@ -4222,6 +4222,124 @@ "usesSharedKeyfileAt": "Používa spoločný súbor s kľúčom v", "pbsHelp": "Monitor používa {keyfile} s {option} na šifrované zálohy a obnovy PBS.Súbor kľúča je uložený v režime {mode}.Uschovajte si bezpečnú kópiu mimo tohto hostiteľa." }, + "apiErrors": { + "requestFailed": "Požiadavka zlyhala.", + "absolutePath": "Cesta musí začínať znakom /.", + "pathConfigured": "Táto cesta už je nastavená.", + "pathRequired": "Zadaj cestu.", + "pathQuery": "V požiadavke chýba cesta.", + "pathNotFound": "Cesta sa nenašla.", + "invalidJobId": "Neplatný identifikátor úlohy.", + "jobNotFound": "Úloha sa nenašla.", + "runnerMissing": "Na serveri chýba skript na spustenie zálohy.", + "noRestore": "Nie je tu žiadny záznam obnovy na zatvorenie.", + "invalidFile": "Neplatný názov súboru.", + "notFound": "Požadovaný záznam sa nenašiel.", + "logOutside": "Cesta k záznamu je mimo povoleného priečinka.", + "targetsList": "Ciele musia byť zadané ako zoznam.", + "emptyExpression": "Zadaj časový plán.", + "invalidId": "V identifikátore použi iba písmená, číslice, _ alebo -.", + "backend": "Vyber cieľ PBS, miestny archív alebo Borg.", + "remoteBackend": "Vyber cieľ PBS alebo Borg.", + "remoteBackendQuoted": "Vyber cieľ PBS alebo Borg.", + "profileMode": "Vyber predvolený alebo vlastný profil.", + "borgAttached": "Borg sa nedá pripojiť k úlohe PVE; vzdump zapisuje iba na PBS alebo miestne úložisko.", + "borgAttachedLong": "Borg sa nedá pripojiť k úlohe PVE; vzdump zapisuje iba na PBS alebo miestne úložisko.", + "parentRequired": "Vyber nadradenú úlohu PVE aj jej úložisko.", + "absolutePaths": "Zadaj zoznam ciest, ktoré začínajú znakom /.", + "customPath": "Do vlastného profilu pridaj aspoň jednu cestu.", + "defaultPaths": "Cesty predvoleného profilu sa nepodarilo zistiť.", + "retentionObject": "Pravidlá uchovávania záloh majú neplatný formát.", + "pbsRepository": "Zadaj repozitár PBS.", + "pbsEncryption": "Vyber režim šifrovania PBS: bez šifrovania, nový kľúč alebo existujúci kľúč.", + "pbsKeyFailed": "Súbor so šifrovacím kľúčom PBS sa nepodarilo vytvoriť.", + "pbsClientMissing": "Súbor so šifrovacím kľúčom PBS sa nepodarilo vytvoriť. Skontroluj, či je nainštalovaný proxmox-backup-client.", + "borgRepository": "Zadaj repozitár Borg.", + "borgEncryption": "Vyber podporovaný režim šifrovania Borg: none, repokey, keyfile alebo authenticated.", + "encryption": "Vyber podporovaný režim šifrovania: none, repokey, keyfile alebo authenticated.", + "borgPassphrase": "Šifrovaný repozitár Borg potrebuje heslo. Ulož ho pri cieli alebo ho zadaj v požiadavke.", + "passphrase": "Pri zapnutom šifrovaní zadaj heslo.", + "password": "Zadaj heslo.", + "deviceConfirmation": "Na potvrdenie zadaj presný názov zariadenia.", + "invalidDevice": "Neplatná cesta k zariadeniu.", + "absoluteMountpoint": "Prípojný bod musí byť cesta začínajúca znakom /.", + "archiveLayout": "Archív nemá očakávanú štruktúru: chýba priečinok rootfs/.", + "archiveMissing": "Archív sa nenašiel.", + "archiveNotFile": "Cesta k archívu neoznačuje bežný súbor.", + "invalidName": "Neplatný názov.", + "invalidStaging": "Neplatná cesta k dočasným súborom.", + "jobRename": "Názov existujúcej úlohy sa nedá zmeniť. Ak ju chceš premenovať, odstráň ju a vytvor novú.", + "absoluteKey": "Cesta k súboru s kľúčom musí začínať znakom /.", + "mode": "Vyber miestny cieľ alebo pripojenie cez SSH.", + "nameRequired": "Zadaj názov; použi iba písmená, číslice, _ alebo -.", + "noLog": "Pre túto úlohu zatiaľ nie je dostupný záznam.", + "noPbsConfig": "Súbor s ručne nastavenými cieľmi PBS sa nenašiel.", + "outputMissing": "Chýba výstupný súbor.", + "parserJson": "Výstup spracovania archívu nemá platný formát JSON.", + "restoreJson": "Výstup prípravy obnovy nemá platný formát JSON.", + "repoRequired": "Zadaj repozitár.", + "repoSnapshot": "Vyber repozitár aj zálohu.", + "borgRepoSnapshot": "Pre Borg vyber repozitár aj zálohu.", + "pbsRepoSnapshot": "Pre PBS vyber repozitár aj zálohu.", + "repoName": "Zadaj názov repozitára.", + "restoreMissing": "Na tomto serveri chýbajú nástroje na obnovu.", + "serverDatastore": "Zadaj server aj dátové úložisko.", + "snapshotRequired": "Vyber zálohu.", + "source": "Vyber zdroj PBS, Borg alebo miestny archív.", + "sshPort": "Port SSH musí byť celé číslo od 1 do 65535.", + "sshRequired": "Pre SSH zadaj používateľa, hostiteľa a vzdialenú cestu.", + "repositoryPath": "Cieľ nemá nastavenú cestu k repozitáru.", + "taskExpired": "Úloha sa nenašla alebo už vypršala.", + "invalidExpression": "Neplatný časový plán.", + "noReport": "Kontrola nevrátila žiadnu správu.", + "parseManifestFailed": "Spracovanie manifestu skončilo chybou.", + "jobExists": "Úloha s názvom {value} už existuje.", + "pathMissing": "Na tomto serveri sa nenašla cesta: {value}", + "deviceMissing": "Zariadenie neexistuje: {value}", + "mountpoint": "Táto cesta nie je prípojný bod: {value}", + "localArchiveMissing": "Miestny archív sa nenašiel: {value}", + "borgDestination": "Cieľ Borg „{value}“ sa nenašiel.", + "pbsDestination": "Cieľ PBS „{value}“ sa nenašiel.", + "pbsPassword": "Pre repozitár PBS „{value}“ nie je dostupné heslo.", + "borgRepoMissing": "Repozitár Borg „{value}“ nie je nastavený.", + "pbsRepoMissing": "Repozitár PBS „{value}“ nie je nastavený.", + "borgTargetMissing": "Cieľ Borg sa nenašiel: {value}", + "pbsTargetMissing": "Repozitár PBS sa nenašiel: {value}", + "pveJobMissing": "Úloha zálohovania PVE sa nenašla: {value}", + "statePersist": "Stav sa nepodarilo uložiť: {value}", + "sshDirectory": "Priečinok SSH sa nepodarilo pripraviť: {value}", + "logRead": "Záznam sa nepodarilo prečítať: {value}", + "envUpdate": "Súbor .env sa nepodarilo aktualizovať: {value}", + "pbsConfigWrite": "Nastavenia PBS sa nepodarilo uložiť: {value}", + "persist": "Údaje sa nepodarilo uložiť: {value}", + "archiveRemove": "Archív sa nepodarilo odstrániť: {value}", + "start": "Úlohu sa nepodarilo spustiť: {value}", + "borgTargetsUpdate": "Súbor borg-targets.txt sa nepodarilo aktualizovať: {value}", + "pbsTargetsUpdate": "Súbor pbs-manual-configs.txt sa nepodarilo aktualizovať: {value}", + "unitsUpdate": "Jednotky systemd sa nepodarilo aktualizovať: {value}", + "borgTargetsWrite": "Súbor borg-targets.txt sa nepodarilo uložiť: {value}", + "jobWrite": "Súbory úlohy sa nepodarilo uložiť: {value}", + "localTargetWrite": "Súbor local-target.conf sa nepodarilo uložiť: {value}", + "unitsWrite": "Jednotky systemd sa nepodarilo uložiť: {value}", + "format": "Formátovanie zlyhalo: {value}", + "calendar": "Neplatný časový plán: {value}", + "mount": "Disk sa nepodarilo pripojiť: {value}", + "parserInvocation": "Spracovanie archívu sa nepodarilo spustiť: {value}", + "preflightInvocation": "Kontrolu pred obnovou sa nepodarilo spustiť: {value}", + "publicKeyMissing": "Po vytvorení kľúča sa nenašiel verejný kľúč: {value}", + "retentionInteger": "Pravidlo uchovávania „{value}“ musí byť celé číslo.", + "retentionNegative": "Pravidlo uchovávania „{value}“ nesmie byť záporné.", + "keygen": "Vytvorenie kľúča SSH zlyhalo: {value}", + "systemctl": "Správa služby cez systemctl zlyhala: {value}", + "unmount": "Disk sa nepodarilo odpojiť: {value}", + "taskIncomplete": "Úloha ešte nie je dokončená. Jej stav: {value}", + "unknownKind": "Neznámy typ: {value}", + "unknownMode": "Neznámy režim: {value}", + "defaultRemove": "Cesta {value} je predvolená a nedá sa odstrániť.", + "defaultAdd": "Cesta {value} je predvolená; nemusíš ju pridávať.", + "keyImport": "V {value} nie je uložený súbor s kľúčom PBS. Najprv ho importuj.", + "storageMismatch": "Úložisko sa nezhoduje: úloha PVE používa {value}, požiadavka uvádza {other}." + }, "errors": { "backendNoStagingPath": "Cieľ nevrátil cestu k dočasným súborom.", "deleteFailed": "Mazanie zlyhalo: {error}", diff --git a/tests/test_host_backup_messages.cjs b/tests/test_host_backup_messages.cjs new file mode 100644 index 00000000..852979e0 --- /dev/null +++ b/tests/test_host_backup_messages.cjs @@ -0,0 +1,103 @@ +// Exercise the shipped error adapter and actual HostBackup handlers with a fake transport. +const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm'); +const { ts, read, compile, translator } = require('./backup_i18n_seam.cjs'); +const library = { exports: {} }; +vm.runInNewContext(compile(read('lib/host-backup-messages.ts')), library); +const { hostBackupErrorText } = library.exports; +// Independently read the real API's literal/f-string contracts without importing +// Flask or running any route, host probe, script or persistence operation. +const contracts = JSON.parse(require('node:child_process').execFileSync('python3', ['-I', '-B', '-c', ` +import ast,json,sys +tree=ast.parse(open(sys.argv[1]).read()) +def messages(node): + if isinstance(node,ast.Constant) and isinstance(node.value,str): return [node.value] + if isinstance(node,ast.JoinedStr): return [''.join(part.value if isinstance(part,ast.Constant) else 'PARAMETER' for part in node.values)] + if isinstance(node,ast.BoolOp): return [text for child in node.values for text in messages(child)] + return [] +result=set() +for function in tree.body: + if isinstance(function,ast.FunctionDef) and function.name.startswith(('api_host_backups_','api_host_restore')): + for node in ast.walk(function): + if isinstance(node,ast.Dict): + for key,value in zip(node.keys,node.values): + if isinstance(key,ast.Constant) and key.value=='error': result.update(messages(value)) +print(json.dumps(sorted(result))) +`, 'AppImage/scripts/flask_server.py'], { encoding: 'utf8' })); +const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten) + : typeof n === 'object' ? [n, ...flatten(n.props?.children)] : [n]; +const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join(''); + +function fixture(locale) { + let cursor = 0, slots = [], tree, failure = 'path must be absolute', mutations = 0; + const calls = [], t = translator(locale); + const jsx = (type, props) => typeof type === 'function' ? type(props) : { type, props }; + const hooks = { useState(initial) { const i = cursor++; if (!(i in slots)) slots[i] = initial; return [slots[i], value => { slots[i] = value; }]; } }; + const requireModule = name => { + if (name === 'react') return hooks; + if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx }; + if (name === 'swr') return { default: () => ({ data: { paths: [{ path: '/kept' }] }, mutate: () => { mutations++; } }) }; + if (name.includes('i18n/provider')) return { useT: () => t }; + if (name.endsWith('host-backup-messages')) return library.exports; + if (name.endsWith('api-config')) return { fetchApi: async (url, options) => { calls.push({ url, ...options }); if (failure) throw new Error(failure); return { status: 'ok' }; } }; + return new Proxy({}, { get: (_, key) => key }); + }; + const source = read('components/host-backup.tsx') + '\nexports.ExtraPathsSection = ExtraPathsSection;'; + const module = { exports: {} }; + vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX } }).outputText, + { exports: module.exports, require: requireModule, Error, console }); + return { calls, t, render() { cursor = 0; tree = module.exports.ExtraPathsSection(); return tree; }, text: () => text(tree), + input: () => flatten(tree).find(n => n.type === 'Input'), + add: () => flatten(tree).find(n => n.type === 'Button' && text(n).includes(t('backup.actions.addPath'))), + fail: message => { failure = message; }, mutations: () => mutations }; +} + +(async () => { + assert.ok(contracts.length > 100, 'real HostBackup error contracts discovered'); + for (const message of contracts) { + const translated = hostBackupErrorText(message, translator('sk')); + assert.notEqual(translated, message, 'application-owned message must be localized: ' + message); + assert.ok(!translated.includes('backup.apiErrors.'), 'every mapped key has a real Slovak value'); + assert.equal(hostBackupErrorText(message, translator('missing')), hostBackupErrorText(message, translator('en')), 'missing locale uses English'); + } + for (const locale of ['en', 'sk', 'missing']) { + const t = translator(locale); + assert.equal(hostBackupErrorText(new Error('path must be absolute'), t), t('backup.apiErrors.absolutePath')); + assert.equal(hostBackupErrorText({ message: 'this path is already configured' }, t), t('backup.apiErrors.pathConfigured')); + assert.equal(hostBackupErrorText('job already exists: test_1', t), t('backup.apiErrors.jobExists', { value: 'test_1' })); + const raw = 'tool failed: path must be absolute'; + assert.equal(hostBackupErrorText(raw, t), raw, 'external diagnostics must not be partially matched or discarded'); + const long = 'path does not exist on this host: ' + 'x'.repeat(20000); + assert.equal(hostBackupErrorText(long, t), long, 'large diagnostics preserved without pattern matching'); + for (const unknown of ['constructor', '__proto__', 'toString', 'future backend message']) assert.equal(hostBackupErrorText(unknown, t), unknown); + for (const invalid of [null, undefined, {}, 23]) assert.equal(hostBackupErrorText(invalid, t), t('backup.apiErrors.requestFailed')); + const unsafe = '{backup.actions.run}'; + assert.equal(hostBackupErrorText('path does not exist on this host: ' + unsafe, t), t('backup.apiErrors.pathMissing', { value: unsafe }), 'captured paths remain literal data'); + const f = fixture(locale); f.render(); + assert.equal(f.add().props.disabled, true); + await f.add().props.onClick(); assert.equal(f.calls.length, 0, 'blank path causes no request'); + f.input().props.onChange({ target: { value: 'relative-path' } }); f.render(); await f.add().props.onClick(); f.render(); + assert.equal(f.calls.length, 1); assert.equal(f.calls[0].url, '/api/host-backups/extra-paths'); + assert.equal(f.calls[0].method, 'POST'); assert.deepEqual(JSON.parse(f.calls[0].body), { path: 'relative-path' }); + assert.equal(f.input().props.value, 'relative-path'); assert.equal(f.mutations(), 0, 'rejected input preserves state and does not refresh as success'); + assert.ok(f.text().includes(t('backup.apiErrors.absolutePath'))); assert.ok(f.text().includes('/kept')); + f.fail('future backend message'); await f.add().props.onClick(); f.render(); assert.ok(f.text().includes('future backend message')); + f.fail(null); await f.add().props.onClick(); f.render(); assert.equal(f.input().props.value, ''); assert.equal(f.mutations(), 1); + } + // Adapter has no effect on the server's CLI/API/persistence contracts. + const host = read('components/host-backup.tsx'); + const ast = ts.createSourceFile('host-backup.tsx', host, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX), encryptionCatches = []; + function visit(node) { if (ts.isCatchClause(node) && node.block.getText(ast).includes('err.body?.tool_output')) encryptionCatches.push(node.block); ts.forEachChild(node, visit); } + visit(ast); assert.equal(encryptionCatches.length, 2, 'scheduled and manual backup encryption paths both checked'); + for (const locale of ['en', 'sk', 'missing']) for (const block of encryptionCatches) { + let displayed, effects = 0; + const code = ts.transpileModule('function run(e) '+block.getText(ast)+'; run(error);', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; + const t = translator(locale), error = Object.assign(new Error('failed to create PBS encryption keyfile'), { body: { tool_output: 'EXTERNAL TOOL DIAGNOSTIC' } }); + vm.runInNewContext(code, { error, t, hostBackupErrorText, setError: value => { displayed = value; }, setPbsImportBusy: () => {}, setSubmitting: () => {}, onCreated: () => effects++ }); + assert.ok(displayed.includes(hostBackupErrorText(error, t)) && displayed.includes('EXTERNAL TOOL DIAGNOSTIC')); + assert.ok(!displayed.includes('{error}') && !displayed.includes('{detail}'), 'error interpolation must not leave an unresolved placeholder'); + assert.equal(effects, 0); + } + assert.ok(!host.includes('e instanceof Error ? e.message : String(e)'), 'all HostBackup catch consumers use the adapter'); + assert.ok(!host.includes('{e.error}') && !host.includes('{calendarPreview.error}'), 'structured error consumers use the adapter too'); + console.log('PASS: '+contracts.length+' real API contracts, HostBackup actual extra-path handlers, EN/SK/fallback, 0 calls for blank, rejected input/state, success, literal parameters and unknown diagnostics'); +})().catch(error => { console.error(error); process.exitCode = 1; }); From a0379fee0b86f404903a6b587309a852b6df4452 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:15:56 +0000 Subject: [PATCH 6/7] i18n: complete Slovak ZRAM CLI messages --- .../scripts/tests/test_terminal_catalogs.py | 34 +++- .../scripts/tests/test_zram_localization.py | 168 ++++++++++++++++++ lang/sk.json | 11 +- .../post_install/customizable_post_install.sh | 16 +- scripts/post_install/uninstall-tools.sh | 2 +- 5 files changed, 219 insertions(+), 12 deletions(-) create mode 100644 .github/scripts/tests/test_zram_localization.py diff --git a/.github/scripts/tests/test_terminal_catalogs.py b/.github/scripts/tests/test_terminal_catalogs.py index f74966cb..a69aedd8 100644 --- a/.github/scripts/tests/test_terminal_catalogs.py +++ b/.github/scripts/tests/test_terminal_catalogs.py @@ -69,13 +69,43 @@ class TerminalCatalogsTests(unittest.TestCase): def test_shipped_active_catalogs_keep_literal_contracts(self): result = subprocess.run( [sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'), - '--root', str(ROOT), '--require-complete'], + '--root', str(ROOT)], capture_output=True, text=True, timeout=60, ) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) for locale in MODULE.DEFAULT_LANGUAGES: self.assertIn(f'[{locale}] active=', result.stdout) - self.assertNotIn('missing=1', result.stdout) + # New hand-reviewed Slovak keys ship with their source changes; + # other locales may use the runtime English fallback until translated. + result = subprocess.run( + [sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'), + '--root', str(ROOT), '--languages', 'sk', '--require-complete'], + capture_output=True, text=True, timeout=60, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('missing=0', result.stdout) + + def test_optional_completeness_still_rejects_invalid_present_translations(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / 'scripts').mkdir() + (root / 'lang').mkdir() + (root / 'scripts/check.sh').write_text('translate "Check /etc/pve/storage.cfg"') + (root / 'lang/sk.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Skontrolujte /etc/pve/storage.cfg'})) + (root / 'lang/es.json').write_text('{}') + + def check(languages, *flags): + return subprocess.run( + [sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'), + '--root', str(root), '--languages', languages, *flags], + capture_output=True, text=True, timeout=60, + ) + + self.assertEqual(check('es,sk').returncode, 0) + self.assertEqual(check('sk', '--require-complete').returncode, 0) + self.assertNotEqual(check('es', '--require-complete').returncode, 0) + (root / 'lang/es.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Compruebe /etc/PVE/storage.cfg'})) + self.assertNotEqual(check('es,sk').returncode, 0) if __name__ == '__main__': diff --git a/.github/scripts/tests/test_zram_localization.py b/.github/scripts/tests/test_zram_localization.py new file mode 100644 index 00000000..bf9bc768 --- /dev/null +++ b/.github/scripts/tests/test_zram_localization.py @@ -0,0 +1,168 @@ +"""Render the real ZRAM CLI paths in isolated files with host effects stubbed.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[3] +DETAILS = { + "version": ("Cannot determine installed systemd-zram-generator version", "Nepodarilo sa zistiť verziu nainštalovaného balíka systemd-zram-generator"), + "mismatch": ("Existing ZRAM profile is active but does not match its configuration", "Existujúci profil ZRAM je aktívny, ale nezodpovedá svojej konfigurácii"), + "stop": ("Could not stop existing dev-zram0.swap", "Nepodarilo sa zastaviť existujúcu jednotku dev-zram0.swap"), + "directory": ("Could not create ZRAM generator directory", "Nepodarilo sa vytvoriť adresár pre konfiguráciu generátora ZRAM"), + "config": ("Could not write ZRAM generator configuration", "Nepodarilo sa zapísať konfiguráciu generátora ZRAM"), + "marker": ("Could not write ZRAM package ownership state", "Nepodarilo sa zapísať údaj o tom, či balík ZRAM nainštaloval ProxMenux"), + "start": ("Could not activate dev-zram0.swap", "Nepodarilo sa aktivovať jednotku dev-zram0.swap"), + "verify": ("Active ZRAM algorithm, capacity or swap sources did not match the profile", "Algoritmus kompresie, veľkosť alebo zdroje swapu aktívnej ZRAM nezodpovedajú profilu"), +} +REFUSAL = "Another ZRAM generator configuration exists; refusing to overwrite it:" +REFUSAL_SK = "Existuje iná konfigurácia generátora ZRAM; neprepíše sa:" + + +def function(path, name): + text = path.read_text() + start = text.index(name + "() {") + return text[start:text.index("\n}\n", start) + 3] + + +HARNESS = r''' +set -u +BASE_DIR="$FIXTURE/state" +LANG_DIR="$REPO/lang" +mkdir -p "$BASE_DIR" "$FIXTURE/etc/systemd/zram-generator.conf.d" "$FIXTURE/proc" +CONFIG="$FIXTURE/etc/systemd/zram-generator.conf.d/90-proxmenux.conf" +MARKER="$BASE_DIR/zram-swap.state" +printf 'Filename\tType\tSize\tUsed\tPriority\n' > "$FIXTURE/proc/swaps" +printf '/swapfile none swap sw 0 0\n' > "$FIXTURE/etc/fstab" +: > "$FIXTURE/calls" +record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; } +pmx_journal_context() { :; } +msg_error() { printf '%s\n' "$*"; } +msg_success() { printf '%s\n' "$*"; } +register_tool() { record register "$@"; } +pmx_install_pkg() { record package-install; return 1; } +apt-get() { record apt "$@"; return 1; } +dpkg-query() { + record dpkg-query + case "$*" in + *'${Status}'*) printf 'install ok installed\n';; + *'${Version}'*) [[ "$CASE" == version ]] || printf '1.2.0-1\n';; + esac +} +dpkg() { return 0; } +install() { record directory; [[ "$CASE" != directory ]]; } +pmx_write_file() { + record write "$1" + if [[ "$CASE" == config && "$1" == "$CONFIG" ]] || + [[ "$CASE" == marker && "$1" == "$MARKER" ]]; then + cat >/dev/null + return 1 + fi + cat > "$1" +} +pmx_remove_file() { record remove "$1"; rm -f -- "$1"; } +pmx_record_uninstall() { record record-uninstall; } +swapoff() { record swapoff; return 0; } +systemctl() { + record systemctl "$@" + [[ "$CASE" != start || "$1" != start ]] && + [[ "$CASE" != stop || "$1" != stop ]] +} +source "$REPO/scripts/post_install/zram_profile_helpers.sh" +zram_profile_verify_active() { [[ "$CASE" != verify && "$CASE" != mismatch ]]; } +if [[ "$CASE" == mismatch || "$CASE" == stop ]]; then + printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG" + [[ "$CASE" != mismatch ]] || zram_render_generator_config true > "$CONFIG" + printf 'installed_by_proxmenux=false\n' > "$MARKER" + printf '/dev/zram0\tpartition\t3355443\t8\t32767\n' >> "$FIXTURE/proc/swaps" +fi +if [[ "$CASE" == foreign ]]; then + printf '[zram1]\nzram-size = ram / 5\n' > "$FIXTURE/etc/systemd/zram-generator.conf" +fi +if [[ "$CASE" == unreadable ]]; then + zram_foreign_generator_config() { return 2; } +fi +''' + + +class ZramLocalizationTests(unittest.TestCase): + def test_missing_optional_translation_uses_original_english(self): + with tempfile.TemporaryDirectory(prefix="zram-fallback-") as folder: + (Path(folder) / 'xx.json').write_text('{}') + script = function(ROOT / "scripts/utils.sh", "translate") + '\ntranslate "$1"\n' + result = subprocess.run(["bash", "-c", script, "bash", DETAILS['version'][0]], + text=True, capture_output=True, timeout=15, + env={**os.environ, 'LANGUAGE': 'xx', 'LANG_DIR': folder}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), DETAILS['version'][0]) + + def run_case(self, case, language="sk"): + with tempfile.TemporaryDirectory(prefix="zram-i18n-") as folder: + directory = Path(folder) + installer = function(ROOT / "scripts/post_install/customizable_post_install.sh", "configure_zram_swap") + installer = installer.replace("/etc/systemd/zram-generator.conf.d/90-proxmenux.conf", folder + "/etc/systemd/zram-generator.conf.d/90-proxmenux.conf") + installer = installer.replace("/proc/swaps", folder + "/proc/swaps").replace("/etc/fstab", folder + "/etc/fstab") + installer = installer.replace('zram_foreign_generator_config "$config"', 'zram_foreign_generator_config "$config" "$FIXTURE"') + installer = installer.replace('zram_list_foreign_systemd_zram_units)', 'zram_list_foreign_systemd_zram_units "$FIXTURE")') + script = HARNESS + function(ROOT / "scripts/utils.sh", "translate") + installer + "\nconfigure_zram_swap\n" + result = subprocess.run(["bash", "-c", script], text=True, capture_output=True, + env={**os.environ, "REPO": str(ROOT), "FIXTURE": folder, "CASE": case, "LANGUAGE": language}, timeout=15) + calls = (directory / "calls").read_text().splitlines() + files = {str(p.relative_to(directory)): p.read_text() for p in directory.rglob("*") if p.is_file()} + return result, calls, files + + def test_all_activation_details_are_localized_and_english_is_preserved(self): + for case, (english, slovak) in DETAILS.items(): + for language, expected in (("sk", slovak), ("en", english)): + with self.subTest(case=case, language=language): + result, calls, files = self.run_case(case, language) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn(expected, result.stdout) + self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n") + if case in ("version", "mismatch"): + self.assertTrue(all(call == "dpkg-query" for call in calls), calls) + elif case == "stop": + self.assertEqual(files["state/zram-swap.state"], "installed_by_proxmenux=false\n") + self.assertIn("# ProxMenux ZRAM profile", files["etc/systemd/zram-generator.conf.d/90-proxmenux.conf"]) + else: + self.assertNotIn("state/zram-swap.state", files) + self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files) + self.assertFalse(any(call.startswith(("apt ", "package-install", "register ")) for call in calls), calls) + + def test_foreign_generator_is_refused_in_both_languages_without_any_effect(self): + for language, expected in (("sk", REFUSAL_SK), ("en", REFUSAL)): + result, calls, files = self.run_case("foreign", language) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn(expected, result.stdout) + self.assertIn("/etc/systemd/zram-generator.conf", result.stdout) + self.assertEqual(calls, []) + self.assertEqual(files["etc/systemd/zram-generator.conf"], "[zram1]\nzram-size = ram / 5\n") + self.assertNotIn("state/zram-swap.state", files) + self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files) + self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n") + self.assertEqual(files["proc/swaps"], "Filename\tType\tSize\tUsed\tPriority\n") + + def test_failed_inspection_is_not_mistaken_for_no_conflict(self): + result, calls, files = self.run_case("unreadable") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("Nie je možné skontrolovať existujúcu konfiguráciu generátora ZRAM", result.stdout) + self.assertEqual(calls, []) + self.assertNotIn("state/zram-swap.state", files) + self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files) + + def test_uninstall_label_and_cancel_do_not_call_uninstall(self): + for language, label in (("sk", "Profil swapu ZRAM"), ("en", "ZRAM swap profile")): + with tempfile.TemporaryDirectory(prefix="zram-menu-") as folder: + tools = Path(folder) / "tools.json" + tools.write_text('{"zram_swap":true}') + script = function(ROOT / "scripts/utils.sh", "translate") + script += function(ROOT / "scripts/post_install/uninstall-tools.sh", "show_uninstall_menu") + script += '\nensure_tools_json() { :; }; migrate_installed_tools() { :; }\nuninstall_zram_swap() { printf forbidden; return 1; }\ndialog() { printf "%s\\n" "$@" > "$MENU_CAPTURE"; return 1; }\nshow_uninstall_menu\n' + result = subprocess.run(["bash", "-c", script], text=True, capture_output=True, + env={**os.environ, "LANGUAGE": language, "LANG_DIR": str(ROOT / "lang"), "TOOLS_JSON": str(tools), "MENU_CAPTURE": folder + "/dialog.txt"}, timeout=15) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(label, (Path(folder) / "dialog.txt").read_text()) + self.assertNotIn("forbidden", result.stdout + result.stderr) + self.assertEqual(json.loads(tools.read_text()), {"zram_swap": True}) diff --git a/lang/sk.json b/lang/sk.json index fe1f96ce..0834b7f4 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -210,6 +210,7 @@ "Activate ProxMenux Monitor": "Zapnúť ProxMenux Monitor", "Active": "Aktívne", "Active Connections": "Aktívne pripojenia", + "Active ZRAM algorithm, capacity or swap sources did not match the profile": "Algoritmus kompresie, veľkosť alebo zdroje swapu aktívnej ZRAM nezodpovedajú profilu", "Active exports:": "Aktívne exporty:", "Active session:": "Aktívna relácia:", "Actual device path on the host": "Skutočná dráha zariadenia na hostiteľovi", @@ -412,7 +413,7 @@ "Another OCI operation is using the registry. This operation was not started.": "Ďalšia OCI operation používa register. Táto operation sa nezačala.", "Another ProxMenux OCI operation is running. Try again when it finishes.": "Prebieha ďalšia operácia ProxMenux OCI. Skúste to znova, keď to skončí.", "Another ZRAM configuration exists or could not be inspected; systemd-zram-generator was retained": "Existuje iná konfigurácia ZRAM alebo ju nebolo možné skontrolovať; systemd-zram-generator bol zachovaný", - "Another ZRAM generator configuration exists; refusing to overwrite it:": "Existuje iná konfigurácia generátora ZRAM; refusing to overwrite it:", + "Another ZRAM generator configuration exists; refusing to overwrite it:": "Existuje iná konfigurácia generátora ZRAM; neprepíše sa:", "Another ZRAM swap setup exists; refusing to replace it:": "Existuje ďalšie nastavenie výmeny ZRAM; odmietnutie jeho výmeny:", "Another device of the network already answers on this address:": "Na tejto adrese už odpovedá iné zariadenie v sieti:", "Another instance uses": "Iný prípad použitia", @@ -740,6 +741,7 @@ "Cannot find": "Nedá sa nájsť", "Cannot identify the vendor of the device:": "Nie je možné identifikovať predajcu zariadenia:", "Cannot inspect current swap configuration": "Nie je možné skontrolovať aktuálnu konfiguráciu výmeny", + "Cannot determine installed systemd-zram-generator version": "Nepodarilo sa zistiť verziu nainštalovaného balíka systemd-zram-generator", "Cannot inspect existing ZRAM configurations": "Nie je možné skontrolovať existujúce konfigurácie ZRAM", "Cannot inspect existing ZRAM generator configuration": "Nie je možné skontrolovať existujúcu konfiguráciu generátora ZRAM", "Cannot load backup library: lib_host_backup_common.sh": "Nepodarilo sa načítať knižnicu zálohovania: lib_host_backup_common.sh", @@ -1335,6 +1337,8 @@ "Could not copy the PVE keyfile into place. Check permissions on:": "PVE keyfile sa nepodarilo skopírovať na správne miesto. Skontrolujte oprávnenia na:", "Could not copy the keyfile into place.": "Keyfile sa nepodarilo skopírovať na správne miesto.", "Could not copy the keyfile into place. Check permissions on:": "Keyfile sa nepodarilo skopírovať na správne miesto. Skontrolujte oprávnenia na:", + "Could not activate dev-zram0.swap": "Nepodarilo sa aktivovať jednotku dev-zram0.swap", + "Could not create ZRAM generator directory": "Nepodarilo sa vytvoriť adresár pre konfiguráciu generátora ZRAM", "Could not create a temporary directory for the NVIDIA Container Toolkit.": "Nepodarilo sa vytvoriť dočasný adresár pre NVIDIA Container Toolkit.", "Could not create converter directory:": "Priečinok pre konvertor sa nepodarilo vytvoriť:", "Could not create destination directory:": "Cieľový priečinok sa nepodarilo vytvoriť:", @@ -1423,6 +1427,7 @@ "Could not stage pending restore. Nothing new was scheduled.": "Nepodarilo sa pripraviť čakajúce obnovenie. Nič nové nebolo naplánované.", "Could not stop LXC": "LXC sa nepodarilo zastaviť", "Could not stop ZRAM swap; profile configuration remains": "Nepodarilo sa zastaviť výmenu ZRAM; konfigurácia profilu zostáva zachovaná", + "Could not stop existing dev-zram0.swap": "Nepodarilo sa zastaviť existujúcu jednotku dev-zram0.swap", "Could not translate the Compose command/entrypoint": "Nepodarilo sa preložiť príkaz/bod vstupu", "Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.": "Modul nouveau sa nepodarilo uvoľniť (možno sa práve používa). Blacklist sa prejaví po reštarte. Inštalácia bude pokračovať, ale reštart bude potrebný.", "Could not unmount": "Nepodarilo sa odpojiť", @@ -1431,6 +1436,8 @@ "Could not unmount — disk may be busy. Removing fstab entry anyway.": "Nepodarilo sa odpojiť - disk sa možno používa. Záznam z fstab aj tak odstraňujem.", "Could not update config file.": "Konfiguračný súbor sa nepodarilo aktualizovať.", "Could not verify removal of the managed host firewall rule.": "Nepodarilo sa overiť odstránenie pravidla brány firewall spravovaného hostiteľa.", + "Could not write ZRAM generator configuration": "Nepodarilo sa zapísať konfiguráciu generátora ZRAM", + "Could not write ZRAM package ownership state": "Nepodarilo sa zapísať údaj o tom, či balík ZRAM nainštaloval ProxMenux", "Could not write to:": "Nepodarilo sa zapísať do:", "Crafty Controller default login": "Crafty Controller predvolené prihlásenie", "Create Directory": "Vytvoriť priečinok", @@ -2197,6 +2204,7 @@ "Execution engine for Index-TTS": "Vykonávací motor pre Index-TTS", "Existing Groups": "Existujúce skupiny", "Existing TLS certificate reused:": "Existujúce osvedčenie TLS opätovne použité:", + "Existing ZRAM profile is active but does not match its configuration": "Existujúci profil ZRAM je aktívny, ale nezodpovedá svojej konfigurácii", "Existing file, re-downloading...": "Súbor už existuje, sťahujem ho znova...", "Existing filesystem:": "Existujúci súborový systém:", "Existing hostpci entries detected — they will be reused": "Zistené existujúce hostpci položky - použijú sa znova", @@ -7678,6 +7686,7 @@ "ZRAM profile removed, but package purge failed": "Profil ZRAM bol odstránený, ale vymazanie balíka zlyhalo", "ZRAM still holds pages; configuration was preserved": "ZRAM stále drží stránky; konfigurácia bola zachovaná", "ZRAM still holds pages; profile remains active": "ZRAM stále drží stránky; profil zostáva aktívny", + "ZRAM swap profile": "Profil swapu ZRAM", "ZRAM swap profile enabled": "Swap profil ZRAM povolený", "ZRAM swap profile removed": "Výmenný profil ZRAM bol odstránený", "ZSTD - fast, good ratio (recommended)": "ZSTD - rýchly, dobrý pomer (odporúčané)", diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index 72e1b86d..2ce24dc3 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -2574,7 +2574,7 @@ configure_zram_swap() { fi version="$(dpkg-query -W -f='${Version}' systemd-zram-generator 2>/dev/null)" if [[ -z "$version" ]]; then - failure="Cannot determine installed systemd-zram-generator version" + failure="$(translate "Cannot determine installed systemd-zram-generator version")" else if dpkg --compare-versions "$version" ge '1.2.0'; then supports_params=true @@ -2588,7 +2588,7 @@ configure_zram_swap() { msg_success "$(translate "ZRAM profile is already active")" return 0 fi - failure="Existing ZRAM profile is active but does not match its configuration" + failure="$(translate "Existing ZRAM profile is active but does not match its configuration")" fi if [[ -z "$failure" && "$previous_active" == true ]]; then if ! swapoff /dev/zram0; then @@ -2597,31 +2597,31 @@ configure_zram_swap() { fi state_mutated=true if ! systemctl stop dev-zram0.swap >/dev/null 2>&1; then - failure="Could not stop existing dev-zram0.swap" + failure="$(translate "Could not stop existing dev-zram0.swap")" fi fi if [[ -z "$failure" ]]; then state_mutated=true if ! install -d -m 755 "$(dirname "$config")"; then - failure="Could not create ZRAM generator directory" + failure="$(translate "Could not create ZRAM generator directory")" elif ! printf '%s\n' "$desired" | pmx_write_file "$config"; then - failure="Could not write ZRAM generator configuration" + failure="$(translate "Could not write ZRAM generator configuration")" fi fi if [[ -z "$failure" ]]; then old_umask="$(umask)" umask 077 if ! printf 'installed_by_proxmenux=%s\n' "$package_owned" | pmx_write_file "$marker"; then - failure="Could not write ZRAM package ownership state" + failure="$(translate "Could not write ZRAM package ownership state")" fi umask "$old_umask" [[ -f "$marker" ]] && chmod 600 "$marker" fi if [[ -z "$failure" ]]; then if ! systemctl daemon-reload || ! systemctl start dev-zram0.swap; then - failure="Could not activate dev-zram0.swap" + failure="$(translate "Could not activate dev-zram0.swap")" elif ! zram_profile_verify_active "$algorithm"; then - failure="Active ZRAM algorithm, capacity or swap sources did not match the profile" + failure="$(translate "Active ZRAM algorithm, capacity or swap sources did not match the profile")" fi fi if [[ -z "$failure" ]]; then diff --git a/scripts/post_install/uninstall-tools.sh b/scripts/post_install/uninstall-tools.sh index 91f90867..570a8bf3 100644 --- a/scripts/post_install/uninstall-tools.sh +++ b/scripts/post_install/uninstall-tools.sh @@ -1356,7 +1356,7 @@ show_uninstall_menu() { system_limits) desc="System Limits Increase";; entropy) desc="Entropy Generation (haveged)";; memory_settings) desc="Memory Settings Optimization";; - zram_swap) desc="ZRAM swap profile";; + zram_swap) desc="$(translate "ZRAM swap profile")";; kernel_panic) desc="Kernel Panic Configuration";; apt_ipv4) desc="APT IPv4 Force";; kexec) desc="kexec for quick reboots";; From 3970bafbe091d2a0e28cce97aa17914f4f95e513 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:38:29 +0000 Subject: [PATCH 7/7] i18n: complete Slovak PBS post-install and update messages --- .../tests/test_pbs_cli_localization.py | 250 ++++++++++++++++++ lang/sk.json | 29 +- scripts/global/remove-banner-pve-v3.sh | 2 +- scripts/global/repository_policy.py | 60 ++++- scripts/global/update-pbs.sh | 4 +- scripts/menus/menu_post_install.sh | 10 +- scripts/post_install/auto_post_install.sh | 4 +- 7 files changed, 334 insertions(+), 25 deletions(-) create mode 100644 .github/scripts/tests/test_pbs_cli_localization.py diff --git a/.github/scripts/tests/test_pbs_cli_localization.py b/.github/scripts/tests/test_pbs_cli_localization.py new file mode 100644 index 00000000..bda1064b --- /dev/null +++ b/.github/scripts/tests/test_pbs_cli_localization.py @@ -0,0 +1,250 @@ +"""PBS CLI language and no-effect boundaries using real functions, isolated files.""" +import contextlib +import copy +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[3] + + +def function(path, name): + text = (ROOT/path).read_text() + start = text.index(name+'() {') + return text[start:text.index('\n}\n', start)+3] + + +def repository(components, suite='trixie'): + return dict(Types=['deb'], URIs=['https://credential.invalid/private'], Suites=[suite], + Components=components.split(), Enabled=True) + + +class RepositoryCliTests(unittest.TestCase): + def run_policy(self, case, language='sk', mode='plan', malformed=None, product='pbs'): + spec = importlib.util.spec_from_file_location('policy_fixture', ROOT/'scripts/global/repository_policy.py') + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + state = dict(files=[dict(path='/etc/apt/sources.list.d/pbs.sources', repositories=[repository('pbs-enterprise')]), + dict(path='/etc/apt/sources.list.d/debian.sources', repositories=[repository('main')])], + errors=[], digest='fixture', **{'standard-repos':[{'handle':'no-subscription'}]}) + status='notfound'; calls=[] + if case=='inventory': state['errors']=['PRIVATE RAW DIAGNOSTIC'] + if case=='status': status='unknown' + if case=='suite': state['files'][0]['repositories'][0]['Suites']=['bookworm'] + if case=='active': status='active'; state['files']=state['files'][1:] + if case=='debian': state['files']=state['files'][:1] + if case=='mixed': state['files'][0]['repositories'][0]['Components'].append('main') + if case=='handle': state['standard-repos']=[] + if case=='preserve': state['files'][0]['repositories'][0]['Components']=['pbs-no-subscription'] + original=copy.deepcopy(state) + def run(args): + calls.append(args) + if args[0]=='proxmox-backup-manager': return json.dumps(dict(status=status, serverid='PRIVATE SERVER ID')) + if 'create' in args: + state['files'][0]['repositories'][0]['Enabled']=False + if case=='partial': state['errors']=['PRIVATE RAW DIAGNOSTIC'] + return '' + if 'set' in args: + state['files'].append(dict(path='/etc/apt/sources.list.d/public.sources',repositories=[repository('pbs-no-subscription')])) + return '' + return json.dumps(state) + with tempfile.TemporaryDirectory(prefix='pbs-policy-i18n-') as folder: + module.BASE=Path(folder) + (module.BASE/'lang').mkdir() + (module.BASE/'config.json').write_text(json.dumps({'language':language})) + catalog=(ROOT/'lang/sk.json').read_text() if malformed is None else malformed + (module.BASE/'lang/sk.json').write_text(catalog) + stdout,stderr=io.StringIO(),io.StringIO() + suite='sid' if case=='unsupported' else 'trixie' + with patch.object(module,'run',side_effect=run), patch.object(module.sys,'argv',['repository_policy.py',mode,suite,product]), contextlib.redirect_stdout(stdout),contextlib.redirect_stderr(stderr): + result=module.main() + return result,stdout.getvalue(),stderr.getvalue(),calls,state,original + + def test_pbs_errors_are_localized_without_changing_decisions_or_exposing_raw_data(self): + expectations={'inventory':'Zoznam repozitárov APT', 'status':'Stav predplatného', + 'suite':'iné vydanie', 'active':'aktívne predplatné', 'debian':'základný repozitár Debianu', + 'mixed':'spoločný záznam APT', 'handle':'no-subscription', 'unsupported':'Nepodporované vydanie', + 'partial':'stav repozitárov je neznámy'} + for case,expected in expectations.items(): + with self.subTest(case=case): + result,out,error,calls,state,original=self.run_policy(case,mode='apply' if case=='partial' else 'plan') + self.assertEqual(result,1); self.assertEqual(out,''); self.assertIn(expected,error) + self.assertNotIn('PRIVATE',error); self.assertNotIn('credential.invalid',error) + writes=[c for c in calls if 'create' in c or 'set' in c] + if case=='partial': + self.assertEqual(len(writes),1) + self.assertFalse(state['files'][0]['repositories'][0]['Enabled']) + self.assertIn('Automatický návrat',error) + else: + self.assertEqual(writes,[]); self.assertEqual(state,original) + if case=='unsupported': self.assertEqual(calls,[]) + + def test_english_error_and_stdout_tokens_remain_compatible(self): + en=self.run_policy('unsupported',language='en') + self.assertEqual(en[2],'Unsupported Proxmox suite; no repository changed.\n') + pve=self.run_policy('unsupported',product='pve') + self.assertEqual(pve[2],en[2]) + for mode,expected in [('plan','offer\n'),('apply','changed\n')]: + result,out,error,*_=self.run_policy('offer',mode=mode) + self.assertEqual((result,out,error),(0,expected,'')) + result,out,error,calls,*_=self.run_policy('preserve',mode='apply') + self.assertEqual((result,out,error),(0,'preserve\n','')) + self.assertFalse(any(c[0]=='proxmox-backup-manager' or 'set' in c or 'create' in c for c in calls)) + + def test_bad_or_missing_translation_falls_back_to_original_english(self): + for catalog in ('{}','not json','[]',json.dumps({'Unsupported Proxmox suite; no repository changed.':42}), + json.dumps({'Unsupported Proxmox suite; no repository changed.':'{unknown}'})): + with self.subTest(catalog=catalog): + result,out,error,calls,*_=self.run_policy('unsupported',malformed=catalog) + self.assertEqual(result,1);self.assertEqual(out,'');self.assertEqual(calls,[]) + self.assertEqual(error,'Unsupported Proxmox suite; no repository changed.\n') + + def test_translation_cannot_drop_actual_repository_details(self): + spec=importlib.util.spec_from_file_location('policy_format_fixture',ROOT/'scripts/global/repository_policy.py') + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + with tempfile.TemporaryDirectory(prefix='pbs-policy-format-') as folder: + module.BASE=Path(folder);(module.BASE/'lang').mkdir() + (module.BASE/'config.json').write_text('{"language":"sk"}') + error=module.PolicyError('Inspect {ui}; no repository changed.',ui='Administration > Repositories') + for invalid in ('Skontrolujte repozitáre.','{ui.__class__}','{other}','{ui:.0}','{ui!r}'): + (module.BASE/'lang/sk.json').write_text(json.dumps({error.template:invalid})) + self.assertEqual(module.cli_error(error,'pbs'),str(error)) + for invalid in ('not json','[]','{"language":"../../private"}'): + (module.BASE/'config.json').write_text(invalid) + self.assertEqual(module.cli_error(error,'pbs'),str(error)) + + +UPDATE_HARNESS=r''' +BASE_DIR="$FIXTURE" +LANG_DIR="$REPO/lang" +SCRIPT_TITLE='fixture' +TAB='' BGN='' CL='' GN='' BL='' +mkdir -p "$FIXTURE/log" "$FIXTURE/tmp" "$FIXTURE/boot" "$FIXTURE/run" +: > "$FIXTURE/calls" +record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; } +pmx_journal_context() { :; } +pmx_is_pbs() { [[ "$CASE" != pve ]]; } +pmx_platform_field() { [[ "$1" != supported ]] && printf '%s' "$SUITE" || printf true; } +ensure_pbs_repositories() { record repository-plan; return 0; } +dpkg-query() { [[ "$SUITE" == bookworm ]] && printf 3.4.0 || printf 4.0.1; } +apt-cache() { record apt-cache "$@"; printf ' Candidate: 4.0.2\n'; } +df() { printf 'Filesystem 1K-blocks Used Available Use%% Mounted\nfixture 10000 1000 %s 10%% /\n' "$([[ "$CASE" == disk ]] && echo 512000 || echo 4096000)"; } +apt-get() { record apt-get "$@"; [[ "$CASE" != refresh ]]; } +apt() { + record apt "$@" + if [[ "$*" == 'list --upgradable' ]]; then + printf 'Listing...\n' + [[ "$CASE" == current ]] || printf 'fixture/trixie 1.2 amd64 [upgradable from: 1.1]\n' + return 0 + fi + [[ "$CASE" != failure ]] +} +fixture_env() { "$@"; } +APT_ENV='fixture_env' +pmx_format_upgradable() { cat; } +pmx_record_execution() { record journal "$1"; } +pmx_dpkg_changes_since() { :; } +msg_error() { printf '%s\n' "$*"; } +msg_ok() { printf '%s\n' "$*"; } +msg_info2() { printf '%s\n' "$*"; } +msg_warn() { printf '%s\n' "$*"; } +msg_title() { :; } +clear() { :; } +show_proxmenux_logo() { :; } +whiptail() { printf '%s\n' "$*" >> "$FIXTURE/dialog"; [[ "$CASE" != cancel ]]; } +uname() { printf old-kernel; } +touch "$FIXTURE/boot/vmlinuz-new-kernel" +''' + + +class UpdateCliTests(unittest.TestCase): + def run_update(self,case,language='sk',suite='trixie'): + with tempfile.TemporaryDirectory(prefix='pbs-update-i18n-') as folder: + body=function('scripts/global/update-pbs.sh','update_pbs') + for old,new in [('/var/log/',folder+'/log/'),('/tmp/proxmenux_screen_capture_',folder+'/tmp/proxmenux_screen_capture_'),('/var/run/',folder+'/run/'),('/boot/',folder+'/boot/')]: body=body.replace(old,new) + script=UPDATE_HARNESS+function('scripts/utils.sh','translate')+body+'\nupdate_pbs\n' + result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15, + env={**os.environ,'REPO':str(ROOT),'FIXTURE':folder,'CASE':case,'LANGUAGE':language,'SUITE':suite}) + files={str(p.relative_to(folder)):p.read_text() for p in Path(folder).rglob('*') if p.is_file()} + return result,files + + def test_pve_guard_cannot_reach_repository_or_apt_calls(self): + result,files=self.run_update('pve') + self.assertEqual(result.returncode,1,result.stderr) + self.assertEqual(files['calls'],'') + self.assertFalse(any(name.startswith('log/') for name in files)) + + def test_disk_and_upgrade_errors_keep_their_actual_parameters(self): + result,files=self.run_update('disk') + self.assertEqual(result.returncode,1,result.stderr) + self.assertIn('Nedostatok miesta na disku. Dostupné:',result.stdout) + self.assertIn('500MB',result.stdout) + self.assertNotIn('apt',files['calls']) + result,files=self.run_update('failure') + self.assertEqual(result.returncode,1,result.stderr) + self.assertIn('Aktualizácia systému zlyhala. Skontrolujte denník:',result.stdout) + self.assertIn('/log/proxmox-update-',result.stdout) + self.assertNotIn('autoremove',files['calls']) + + def test_update_status_cancel_refresh_error_and_reboot_notice(self): + for suite in ('bookworm','trixie'): + for case in ('current','cancel','refresh','success'): + with self.subTest(suite=suite,case=case): + result,files=self.run_update(case,suite=suite) + self.assertEqual(result.returncode,1 if case=='refresh' else 0,result.stderr) + if case!='success': self.assertNotIn('full-upgrade',files['calls']) + if case=='success': self.assertIn('reštartujte server',result.stdout) + if case in ('current','cancel'): self.assertIn('Aktuálna verzia PBS',files['dialog']) + + +class DialogTests(unittest.TestCase): + def test_automated_confirmation_and_banner_cancel_do_not_run_actions(self): + cases=[('scripts/menus/menu_post_install.sh','confirm_automated_script', + ['repozitár no-subscription','upozornenie na predplatné','pamäť','jadro','sieť']), + ('scripts/post_install/auto_post_install.sh','remove_subscription_banner', + ['Proxmox Backup Server – odstránenie upozornenia na predplatné'])] + for path,name,expected in cases: + with tempfile.TemporaryDirectory(prefix='pbs-dialog-') as folder: + script=function('scripts/utils.sh','translate')+function(path,name)+r''' +PMX_ON_PBS=true +LOCAL_SCRIPTS='/fixture/scripts' +clear() { :; }; sleep() { :; }; stop_spinner() { :; } +msg_warn() { printf '%s\n' "$*"; } +bash() { printf forbidden >> "$CAPTURE/actions"; } +register_tool() { printf forbidden >> "$CAPTURE/actions"; } +dialog() { printf '%s\n' "$*" >> "$CAPTURE/dialog"; return 1; } +whiptail() { dialog "$@"; } +'''+name+'\n' + result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15, + env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'CAPTURE':folder}) + self.assertEqual(result.returncode,1 if name=='remove_subscription_banner' else 0,result.stderr) + dialog=(Path(folder)/'dialog').read_text() + for text in expected:self.assertIn(text,dialog) + self.assertFalse((Path(folder)/'actions').exists()) + if name=='remove_subscription_banner':self.assertIn('bolo zrušené používateľom',result.stdout) + + def test_community_labels_preserve_dispatch_names_and_translate_display(self): + script=function('scripts/utils.sh','translate')+function('scripts/menus/menu_post_install.sh','post_install_label')+'\npost_install_label "$1"\n' + for text,expected in [('Proxmox Backup Server Post Install','nastavenie po inštalácii'), + ('Proxmox Backup Server Microcode','mikrokód procesora')]: + result=subprocess.run(['bash','-c',script,'bash',text],capture_output=True,text=True,timeout=15, + env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang')}) + self.assertEqual(result.returncode,0,result.stderr);self.assertIn(expected,result.stdout) + + def test_failed_apt_hook_check_removes_only_fixture_hook_with_localized_warning(self): + with tempfile.TemporaryDirectory(prefix='pbs-hook-') as folder: + script=function('scripts/utils.sh','translate')+function('scripts/global/remove-banner-pve-v3.sh','create_apt_hook')+r''' +apt-config() { return 1; } +msg_warn() { printf '%s\n' "$*"; } +create_apt_hook +''' + result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15, + env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'APT_HOOK':folder+'/hook'}) + self.assertEqual(result.returncode,0,result.stderr) + self.assertIn('Nesprávna syntax hooku APT',result.stdout) + self.assertFalse((Path(folder)/'hook').exists()) diff --git a/lang/sk.json b/lang/sk.json index 0834b7f4..5e34a7ec 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -1,4 +1,25 @@ { + "APT hook syntax issue, removing...": "Nesprávna syntax hooku APT; hook sa odstráni...", + "Banner removal cancelled by user.": "Odstránenie upozornenia na predplatné bolo zrušené používateľom.", + "Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {ui} before retrying; no automatic rollback was attempted.": "Nepodarilo sa dokončiť alebo overiť zmenu repozitárov; zmeny môžu byť čiastočné a stav repozitárov je neznámy. Pred ďalším pokusom skontrolujte {ui}. Automatický návrat do pôvodného stavu sa nevykonal.", + "Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.": "Enterprise má spoločný záznam APT s ďalšími komponentmi. Rozdeľte ho v rozhraní repozitárov Proxmoxu; žiadny repozitár sa nezmenil.", + "Host has an active subscription but no active {short} repository. Configure its Enterprise source in {ui}; no repository changed.": "Server má aktívne predplatné, ale nemá aktívny repozitár {short}. Nastavte zdroj Enterprise v {ui}; žiadny repozitár sa nezmenil.", + "Insufficient disk space. Available:": "Nedostatok miesta na disku. Dostupné:", + "No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.": "Pre toto vydanie nie je aktívny základný repozitár Debianu; pred pokračovaním ho nastavte v rozhraní repozitárov Proxmoxu.", + "Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {ui}; no repository changed.": "Zoznam repozitárov APT Proxmoxu je neplatný alebo obsahuje chyby načítania. Opravte zdroje v {ui}; žiadny repozitár sa nezmenil.", + "Proxmox Backup Server Microcode": "Proxmox Backup Server – mikrokód procesora", + "Proxmox Backup Server Post Install": "Proxmox Backup Server – nastavenie po inštalácii", + "Proxmox Backup Server Subscription Banner Removal": "Proxmox Backup Server – odstránenie upozornenia na predplatné", + "Proxmox no-subscription standard repository handle unavailable; no repository changed.": "Štandardný repozitár Proxmoxu no-subscription nie je dostupný; žiadny repozitár sa nezmenil.", + "Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.": "Stav predplatného nie je jednoznačne active, notfound alebo expired. Overte ho lokálne príkazom proxmox-backup-manager subscription get; žiadny repozitár sa nezmenil.", + "System upgrade failed. Check log:": "Aktualizácia systému zlyhala. Skontrolujte denník:", + "This host runs {name} on {suite}, but these {name} repositories are for another suite: {details}. Disable or remove them in {ui} and run the update again; no repository changed.": "Na serveri beží {name} na vydaní {suite}, ale tieto repozitáre {name} sú pre iné vydanie: {details}. Vypnite alebo odstráňte ich v {ui} a spustite aktualizáciu znova; žiadny repozitár sa nezmenil.", + "Unsupported Proxmox suite; no repository changed.": "Nepodporované vydanie Proxmoxu; žiadny repozitár sa nezmenil.", + "kernel": "jadro", + "memory": "pamäť", + "network": "sieť", + "no-subscription repository": "repozitár no-subscription", + "subscription banner": "upozornenie na predplatné", "# Alternative if you prefer screen": "# Alternatíva, ak preferujete screen", "# Recommended: avoids disconnection during upgrade": "# Odporúčané: zabráni odpojeniu počas aktualizácie", "(Checked entries will be removed. Uncheck to keep in VM.)": "(Označené položky sa odstránia. Zrušte označenie, ak ich chcete ponechať vo VM.)", @@ -88,7 +109,7 @@ "A multi-container application is not recreated: its containers are updated together.": "Aplikácia s viacerými kontajnermi sa nevytvorí znova: jej kontajnery sa aktualizujú spoločne.", "A new ProxMenux version is available:": "Je dostupná nová verzia ProxMenux:", "A new kernel is staged for the next boot:": "Na najbližší štart je pripravený nový kernel:", - "A newer kernel is installed. Reboot before the next backup window to use it.": "Je nainštalované novšie jadro. Aby ste ho mohli použiť, reštartujte ho pred ďalším oknom zálohy.", + "A newer kernel is installed. Reboot before the next backup window to use it.": "Je nainštalované novšie jadro. Pred ďalším plánovaným zálohovaním reštartujte server, aby sa použilo.", "A newer version is available:": "Je dostupná novšia verzia:", "A pending operation exists for": "Čaká na operation existuje", "A pending stack assembly already exists; it is not overwritten": "Nevyriešená zostava stohov už existuje; nie je prepísaná", @@ -4190,7 +4211,7 @@ "No active Proxmox storage accepts OCI template archives.": "Žiadne aktívne úložisko Proxmox neakceptuje archívy šablón OCI.", "No active exports or showmount not available": "Nie sú aktívne exporty alebo príkaz showmount nie je dostupný", "No active session": "Žiadna aktívna relácia", - "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Žiadne aktívne predplatné a žiadne použiteľné úložisko PBS.Neinteraktívny režim nemôže zmeniť zdroje APT;nakonfigurujte ich v Administrácia > Repozitáre.", + "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Server nemá aktívne predplatné ani použiteľný repozitár PBS. Neinteraktívny režim nemôže meniť zdroje APT; nastavte ich v Administration > Repositories.", "No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Žiadne aktívne predplatné a žiadne použiteľné úložisko PVE. Neinteraktívny režim nemôže zmeniť zdroje APT; nakonfigurujte ich v časti Uzol > Aktualizácie > Úložiská.", "No additional GPU can be added.": "Nie je možné pridať ďalšiu GPU.", "No additional device needs to be added.": "Netreba pridávať žiadne ďalšie zariadenie.", @@ -5281,7 +5302,7 @@ "Repository helper unavailable. NVIDIA installation stopped.": "Pomocník úložiska nie je k dispozícii. Inštalácia NVIDIA sa zastavila.", "Repository policy returned an unexpected result.": "Politika úložiska vrátila neočakávaný výsledok.", "Repository ready.": "Repozitár je pripravený.", - "Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Zdroje úložiska sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Prevádzka zastavená;zdroje neboli vrátené späť. Pred pokračovaním skontrolujte Administrácia > Repozitáre a zopakujte apt-get update.", + "Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Zdroje repozitárov sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Operácia sa zastavila; pôvodné zdroje sa neobnovili. Pred pokračovaním skontrolujte Administration > Repositories a zopakujte apt-get update.", "Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Zdroje úložiska sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Prevádzka zastavená; zdroje neboli vrátené späť. Pred pokračovaním skontrolujte Node > Updates > Repository a zopakujte apt-get update.", "Repository switch declined; no APT source changed.": "Prepnutie úložiska bolo odmietnuté; žiadny zdroj APT sa nezmenil.", "Repository:": "Repozitár:", @@ -7265,7 +7286,7 @@ "Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Asistent aktualizácie: Proxmox VE 8 → 9 (Trixie)", "Upgrade canceled by user": "Aktualizácia zrušená používateľom", "Upgrade one node at a time": "Aktualizujte vždy iba jeden uzol naraz", - "Upgrade the system, offering the": "Aktualizujte systém a ponúknite", + "Upgrade the system, offering the": "Aktualizácia systému a prípadný prechod na", "Upgrade to Proxmox VE 9": "Aktualizovať na Proxmox VE 9", "Upgrading NVIDIA utils (Arch)...": "Aktualizujem NVIDIA nástroje (Arch)...", "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Nahrať šifrovanú kópiu kľúča do PBS, aby ste ju vedeli obnoviť na preinštalovanom hostovi iba pomocou frázy?", diff --git a/scripts/global/remove-banner-pve-v3.sh b/scripts/global/remove-banner-pve-v3.sh index 92df1028..76df07b5 100644 --- a/scripts/global/remove-banner-pve-v3.sh +++ b/scripts/global/remove-banner-pve-v3.sh @@ -312,7 +312,7 @@ EOFAPT # Verify APT hook syntax apt-config dump >/dev/null 2>&1 || { - msg_warn "APT hook syntax issue, removing..." + msg_warn "$(translate "APT hook syntax issue, removing...")" rm -f "$APT_HOOK" } } diff --git a/scripts/global/repository_policy.py b/scripts/global/repository_policy.py index 95f40d68..00829730 100644 --- a/scripts/global/repository_policy.py +++ b/scripts/global/repository_policy.py @@ -7,8 +7,10 @@ repository API responses: they can contain subscription keys or credentials. """ import copy import json +from pathlib import Path import re import subprocess +import string import sys ENDPOINT = '/nodes/localhost/apt/repositories' @@ -26,6 +28,41 @@ PRODUCTS = { 'test': {'pbs-no-subscription', 'pbs-test', 'pbstest'}, 'ui': 'Administration > Repositories', 'ceph': False}, } +BASE = Path(__file__).resolve().parents[2] + + +def N_(text): + """Mark a CLI message without changing the policy's English exception.""" + return text + + +class PolicyError(ValueError): + def __init__(self, template, **params): + self.template = template + self.params = params + super().__init__(template.format(**params)) + + +def cli_error(error, product): + """Localize only PBS CLI diagnostics; policy callers retain English errors.""" + if product != 'pbs' or not isinstance(error, PolicyError): + return str(error) + try: + language = json.loads((BASE / 'config.json').read_text()).get('language', 'en') + if language not in ('sk', 'de', 'es', 'fr', 'it', 'pt', 'sv'): + return str(error) + catalog = json.loads((BASE / 'lang' / f'{language}.json').read_text()) + message = catalog.get(error.template) + if isinstance(message, str) and message.strip(): + fields = lambda text: {name for _, name, _, _ in string.Formatter().parse(text) if name is not None} + if fields(message) != fields(error.template) or any( + spec or conversion for _, _, spec, conversion in string.Formatter().parse(message) + ): + return str(error) + return message.format(**error.params) + except (OSError, ValueError, KeyError, IndexError, TypeError, AttributeError): + pass + return str(error) def run(args): @@ -43,7 +80,7 @@ def subscription_status(product='pve'): except (ValueError, AttributeError): status = None if status not in ('active', 'notfound', 'expired'): - raise ValueError('Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.') + raise PolicyError(N_('Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.')) return status # pvesubscription's CLI get printer emits sorted "key: value" lines, # including a secret key and server ID. Only parse the exact status line. @@ -110,12 +147,12 @@ def repository_state(suite, product='pve'): entries.append((file['path'], index, row)) return data, entries except (KeyError, TypeError, ValueError, IndexError) as exc: - raise ValueError(f"Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {p['ui']}; no repository changed.") from exc + raise PolicyError(N_('Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {ui}; no repository changed.'), ui=p['ui']) from exc def evaluate(suite, apply=False, product='pve'): if suite not in ('bookworm', 'trixie'): - raise ValueError('Unsupported Proxmox suite; no repository changed.') + raise PolicyError(N_('Unsupported Proxmox suite; no repository changed.')) p = PRODUCTS[product] data, entries = repository_state(suite, product) pve = [] @@ -139,8 +176,8 @@ def evaluate(suite, apply=False, product='pve'): ceph.append((path, index, row)) # Name the stale sources, never their URIs: those can carry credentials. if wrong_pve: - raise ValueError(f"This host runs {p['name']} on {suite}, but these {p['name']} repositories are for another suite: " - f"{'; '.join(wrong_pve)}. Disable or remove them in {p['ui']} and run the update again; no repository changed.") + raise PolicyError(N_('This host runs {name} on {suite}, but these {name} repositories are for another suite: {details}. Disable or remove them in {ui} and run the update again; no repository changed.'), + name=p['name'], suite=suite, details='; '.join(wrong_pve), ui=p['ui']) if wrong_ceph: raise ValueError(f"This host runs Proxmox VE on {suite}, but these Enterprise Ceph repositories are for another suite: " f"{'; '.join(wrong_ceph)}. Correct them in Node > Updates > Repositories before switching; no repository changed.") @@ -152,20 +189,20 @@ def evaluate(suite, apply=False, product='pve'): status = subscription_status(product) # fail closed before any write if status == 'active': if not pve: - raise ValueError(f"Host has an active subscription but no active {p['short']} repository. Configure its Enterprise source in {p['ui']}; no repository changed.") + raise PolicyError(N_('Host has an active subscription but no active {short} repository. Configure its Enterprise source in {ui}; no repository changed.'), short=p['short'], ui=p['ui']) return 'preserve' if not debian: - raise ValueError('No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.') + raise PolicyError(N_('No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.')) # No active product source or only inaccessible Enterprise. A mixed stanza # cannot be disabled without also disabling an unrelated component. disable = [item for item in pve if p['enterprise'] in item[2]['Components']] + ceph for _, _, row in disable: expected = {p['enterprise']} if p['enterprise'] in row['Components'] else {'enterprise'} if set(row['Components']) != expected: - raise ValueError('Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.') + raise PolicyError(N_('Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.')) handles = [r.get('handle') for r in data['standard-repos'] if r.get('handle') == 'no-subscription'] if len(handles) != 1: - raise ValueError('Proxmox no-subscription standard repository handle unavailable; no repository changed.') + raise PolicyError(N_('Proxmox no-subscription standard repository handle unavailable; no repository changed.')) if not apply: return 'offer' # Adopt a refreshed digest only after checking the complete expected @@ -192,11 +229,12 @@ def evaluate(suite, apply=False, product='pve'): for path, index, _ in disable for p, i, row in current): raise ValueError('Could not verify the switched repositories; inspect the Proxmox repository UI before retrying.') except ValueError as exc: - raise ValueError(f"Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {p['ui']} before retrying; no automatic rollback was attempted.") from exc + raise PolicyError(N_('Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {ui} before retrying; no automatic rollback was attempted.'), ui=p['ui']) from exc return 'changed' def main(): + product = 'pve' try: if len(sys.argv) not in (3, 4) or sys.argv[1] not in ('plan', 'apply') \ or (len(sys.argv) == 4 and sys.argv[3] not in PRODUCTS): @@ -204,7 +242,7 @@ def main(): product = sys.argv[3] if len(sys.argv) == 4 else 'pve' print(evaluate(sys.argv[2], apply=sys.argv[1] == 'apply', product=product)) except ValueError as exc: - print(str(exc), file=sys.stderr) + print(cli_error(exc, product), file=sys.stderr) return 1 return 0 diff --git a/scripts/global/update-pbs.sh b/scripts/global/update-pbs.sh index 54574676..80200ba7 100644 --- a/scripts/global/update-pbs.sh +++ b/scripts/global/update-pbs.sh @@ -52,7 +52,7 @@ update_pbs() { local available_space available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}') if [ "$available_space" -lt 1024 ]; then - msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")" + msg_error "$(translate "Insufficient disk space. Available:") ${available_space}MB" return 1 fi @@ -149,7 +149,7 @@ update_pbs() { rm -f "$screen_capture" if [ $upgrade_exit_code -ne 0 ]; then - msg_error "$(translate "System upgrade failed. Check log: $log_file")" + msg_error "$(translate "System upgrade failed. Check log:") $log_file" echo "Finished: $(date -Iseconds) — upgrade failed (exit $upgrade_exit_code)" >> "$log_file" return 1 fi diff --git a/scripts/menus/menu_post_install.sh b/scripts/menus/menu_post_install.sh index d4e63cda..2f9696e6 100644 --- a/scripts/menus/menu_post_install.sh +++ b/scripts/menus/menu_post_install.sh @@ -63,13 +63,13 @@ confirm_automated_script() { if [[ "$PMX_ON_PBS" == true ]]; then script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server"):\n\n" - script_info+="• $(translate "Upgrade the system, offering the") \Z4no-subscription repository\Z0 $(translate "when the server has no subscription (you will be asked)")\n" + script_info+="• $(translate "Upgrade the system, offering the") \Z4$(translate "no-subscription repository")\Z0 $(translate "when the server has no subscription (you will be asked)")\n" else script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server"):\n\n" script_info+="• $(translate "Configure") \Z4free repositories\Z0 $(translate "and upgrade the system (disables the enterprise repo)")\n" fi - script_info+="• $(translate "Optionally remove") \Z4subscription banner\Z0 $(translate "from Proxmox web interface (you will be asked)")\n" - script_info+="• $(translate "Optimize") \Z4memory\Z0, \Z4kernel\Z0, $(translate "and") \Z4network\Z0 $(translate "for better performance and stability")\n" + script_info+="• $(translate "Optionally remove") \Z4$(translate "subscription banner")\Z0 $(translate "from Proxmox web interface (you will be asked)")\n" + script_info+="• $(translate "Optimize") \Z4$(translate "memory")\Z0, \Z4$(translate "kernel")\Z0, $(translate "and") \Z4$(translate "network")\Z0 $(translate "for better performance and stability")\n" script_info+="• $(translate "Install and configure") \Z4Log2RAM\Z0 $(translate "(only on SSD/NVMe) to protect your disk")\n" script_info+="• $(translate "Enable") \Z4ZFS autotrim\Z0 $(translate "on SSD/NVMe pools that support discard")\n" script_info+="• $(translate "Improve log rotation and limit log size to save space and extend disk life")\n" @@ -289,10 +289,10 @@ post_install_label() { echo "Proxmox VE Microcode" ;; "Proxmox Backup Server Post Install") - echo "Proxmox Backup Server Post Install" + translate "Proxmox Backup Server Post Install" ;; "Proxmox Backup Server Microcode") - echo "Proxmox Backup Server Microcode" + translate "Proxmox Backup Server Microcode" ;; "Community Scripts") translate "Community Scripts" diff --git a/scripts/post_install/auto_post_install.sh b/scripts/post_install/auto_post_install.sh index e11f131f..1602ad39 100644 --- a/scripts/post_install/auto_post_install.sh +++ b/scripts/post_install/auto_post_install.sh @@ -182,9 +182,9 @@ remove_subscription_banner() { # description: Patch the Proxmox web UI to suppress the subscription dialog and register a successful patch. if [[ "$PMX_ON_PBS" == true ]]; then stop_spinner - if ! whiptail --title "Proxmox Backup Server Subscription Banner Removal" \ + if ! whiptail --title "$(translate "Proxmox Backup Server Subscription Banner Removal")" \ --yesno "$(translate "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?")\n\n$(translate "Attention: Removing the subscription banner may cause issues in the web interface after a future update.")\n\n$(translate "If this happens, you can restore the backup from the 'Subscription Banner Removal' option in 'Uninstall optimizations'.")\n\n$(translate "Are you sure you want to continue?")" 14 75; then - msg_warn "Banner removal cancelled by user." + msg_warn "$(translate "Banner removal cancelled by user.")" return 1 fi if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then