diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 8a9548cd..586ff3f5 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -13,6 +13,9 @@ import sys import tempfile PYTHON_FILES = ( + "AppImage/scripts/tests/test_pbs_i18n.py", + "AppImage/scripts/tests/test_monitor_links_i18n.py", + "AppImage/scripts/tests/test_monitor_links.py", "AppImage/scripts/tests/test_local_app_icon_isolation.py", "AppImage/scripts/tests/test_local_app_icons.py", "AppImage/scripts/tests/test_docker_app_discovery.py", @@ -28,6 +31,9 @@ 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", "tests/lxc_updates/test_docker_app_discovery_ui.cjs", "tests/lxc_updates/test_native_app_discovery_ui.cjs", diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml index 1993c320..53880af5 100644 --- a/.github/workflows/test-offline-qualified.yml +++ b/.github/workflows/test-offline-qualified.yml @@ -16,6 +16,7 @@ on: - 'AppImage/scripts/tests/test_local_app_icons.py' - 'AppImage/scripts/tests/test_local_app_icon_isolation.py' - 'AppImage/scripts/tests/test_docker_app_discovery.py' + - 'AppImage/scripts/tests/test_pbs_i18n.py' - 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json' - 'AppImage/app/**' - 'AppImage/components/**' @@ -41,6 +42,7 @@ on: - 'AppImage/scripts/tests/test_local_app_icons.py' - 'AppImage/scripts/tests/test_local_app_icon_isolation.py' - 'AppImage/scripts/tests/test_docker_app_discovery.py' + - 'AppImage/scripts/tests/test_pbs_i18n.py' - 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json' - 'AppImage/app/**' - 'AppImage/components/**' diff --git a/.github/workflows/test-pbs.yml b/.github/workflows/test-pbs.yml index 2dbc71b0..84a23736 100644 --- a/.github/workflows/test-pbs.yml +++ b/.github/workflows/test-pbs.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/test-pbs.yml' - 'AppImage/scripts/*pbs*.py' - 'AppImage/scripts/tests/test_pbs.py' + - 'AppImage/scripts/tests/test_pbs_i18n.py' - 'AppImage/scripts/health_monitor.py' - 'AppImage/scripts/auth_manager.py' - 'AppImage/scripts/build_appimage.sh' @@ -51,6 +52,7 @@ jobs: - name: Platform, real TLS, APIs, health and AppImage packaging run: | python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v + python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do bash -n "$script" diff --git a/AppImage/components/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")}
@@ -8581,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
{t("backup.plan.warnings")}
@@ -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/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..b18a3e12 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,19 +205,26 @@ 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)}

}
})}

{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 +282,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 +313,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 +323,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 || 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 new file mode 100644 index 00000000..f8286da9 --- /dev/null +++ b/AppImage/components/pbs-problem-message.tsx @@ -0,0 +1,19 @@ +"use client" + +import { pbsErrorText, pbsIsKnownError } from "../lib/pbs-messages" +import { useT } from "../lib/i18n/provider" + +// 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 && !pbsIsKnownError(problem) &&
+ {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/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/lib/monitor-link-errors.ts b/AppImage/lib/monitor-link-errors.ts new file mode 100644 index 00000000..f186c227 --- /dev/null +++ b/AppImage/lib/monitor-link-errors.ts @@ -0,0 +1,29 @@ +type Translate = (key: string, params?: Record) => string + +const ERROR_KEYS: Record = { + monitor_link_address_required: "addressRequired", + monitor_link_invalid_address: "invalidAddress", + monitor_link_address_credentials: "addressCredentials", + monitor_link_invalid_port: "invalidPort", + monitor_link_invalid_request: "invalidRequest", + monitor_link_duplicate: "duplicate", + monitor_link_not_found: "notFound", + monitor_link_detected_remove: "detectedRemove", +} + +// fetchApi preserves the structured response on Error.body. Never expose raw +// server/network messages or treat arbitrary error codes as translation keys. +export function monitorLinkError(error: unknown, t: Translate): string { + const fallback = () => t("monitorLinks.saveFailed") + if (!error || typeof error !== "object" || !("body" in error)) return fallback() + const body = error.body + if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return fallback() + if (body.code === "monitor_link_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("monitorLinks.errors.limit", { count }) : fallback() + } + return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code) + ? t(`monitorLinks.errors.${ERROR_KEYS[body.code]}`) : fallback() +} diff --git a/AppImage/lib/pbs-messages.ts b/AppImage/lib/pbs-messages.ts new file mode 100644 index 00000000..6314d317 --- /dev/null +++ b/AppImage/lib/pbs-messages.ts @@ -0,0 +1,85 @@ +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, 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 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 + ? { key: "pbs.errors.connectionLimit", params: { count } } : null + } + 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 { + 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 cb93dc90..cb13efab 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.", @@ -72,7 +77,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", @@ -4315,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}", @@ -6616,6 +6787,17 @@ "addSelected": "Add selected", "pbsWebInterface": "PBS web interface", "changeAddress": "Change address", - "cancel": "Cancel" + "cancel": "Cancel", + "errors": { + "addressRequired": "Enter the address of the Monitor.", + "invalidAddress": "The address must start with http:// or https:// and name a host.", + "addressCredentials": "The address must not contain a user or password.", + "invalidPort": "The port of the address is not valid.", + "invalidRequest": "The request is not valid. Reload the page and try again.", + "limit": "At most {count} nodes can be added.", + "duplicate": "This address is already in the list.", + "notFound": "The node was not found. Reload the list and try again.", + "detectedRemove": "Only nodes added by hand can be removed. Detected nodes can be hidden." + } } } diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 18c9805b..86302c98 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é", @@ -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}", @@ -4807,7 +4925,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 +5759,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." }, @@ -7299,35 +7417,40 @@ "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ľ", "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)", - "token_id": "ID API tokenu (user@realm!token)", - "token_secret": "Tajný kľúč API tokenu (ponechajte prázdne, ak chcete zachovať 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.", + "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.", "testing": "Overuje sa pripojenie…", @@ -7339,9 +7462,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 +7474,7 @@ "schedule": "Plán", "sync": "Synchronizácia", "verify": "Overenie", - "prune": "Prerezávanie", + "prune": "Odstraňovanie starých záloh", "overview": "Prehľad", "logs": "Systémové záznamy", "terminal": "Terminál", @@ -7362,51 +7485,110 @@ "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 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.", + "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": "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})" + }, + "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": { - "cluster": "Cluster", + "cluster": "Klaster", "standalone": "Samostatné uzly", "pbs": "Proxmox Backup Server" }, "thisNode": "Tento uzol", "checking": "Kontrolujú sa uzly…", "loadFailed": "Nepodarilo sa načítať zoznam uzlov.", - "empty": "Zatiaľ žiadne ďalšie uzly. Pridajte jeden, aby ste odtiaľto otvorili jeho Monitor.", + "empty": "Zatiaľ tu nie sú ďalšie uzly. Pridaj uzol a jeho Monitor otvoríš priamo odtiaľto.", "noResponse": "Žiadna odpoveď", "pbsInterface": "rozhranie PBS", "openNewTab": "Otvoriť na novej karte", "manage": "Pridať alebo spravovať uzly…", "dialogTitle": "Prepojené uzly", - "dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.", + "dialogDescription": "Uzly klastra a nastavené servery Proxmox Backup Server sa zisťujú automaticky. Do Monitora každého uzla sa prihlasuješ samostatne.", "addTitle": "Pridať uzol", - "name": "Meno", - "address": "Adresa monitora", + "name": "Názov uzla", + "address": "Adresa Monitora", "type": "Typ", "add": "Pridať", "save": "Uložiť", "remove": "Odstrániť", - "visible": "Viditeľné", + "visible": "Zobraziť", "autoAddress": "Automaticky", - "detected": "Zistené", - "added": "Pridané", + "detected": "Zistený", + "added": "Pridaný", "saveFailed": "Nepodarilo sa uložiť zoznam uzlov.", "backTo": "Späť na {name}", - "cameFromTitle": "Pridať uzol monitora?", - "cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.", + "cameFromTitle": "Pridať uzol Monitora?", + "cameFromBody": "Pridaj {name} medzi prepojené uzly tohto Monitora.", "notNow": "Teraz nie", "knownTitle": "Pridať prepojené uzly?", - "knownBody": "{name} prepája tieto monitory. Vyberte tie, ktoré chcete pridať do tohto monitora.", + "knownBody": "Uzol {name} má prepojené tieto uzly. Vyber tie, ktoré chceš pridať aj sem.", "addSelected": "Pridať vybrané", "pbsWebInterface": "Webové rozhranie PBS", "changeAddress": "Zmeniť adresu", - "cancel": "Zrušiť" + "cancel": "Zrušiť", + "errors": { + "addressRequired": "Zadaj adresu Monitora.", + "invalidAddress": "Adresa musí začínať na http:// alebo https:// a obsahovať názov hostiteľa alebo IP adresu.", + "addressCredentials": "Adresa nesmie obsahovať používateľské meno ani heslo.", + "invalidPort": "Port v adrese nie je platný. Zadaj číslo od 1 do 65535.", + "invalidRequest": "Požiadavka nie je platná. Obnov stránku a skús to znova.", + "limit": "Môžeš pridať najviac {count} uzlov.", + "duplicate": "Táto adresa už je v zozname.", + "notFound": "Uzol sa nenašiel. Obnov zoznam a skús to znova.", + "detectedRemove": "Odstrániť môžeš iba ručne pridané uzly. Automaticky zistené uzly môžeš skryť." + } } } diff --git a/AppImage/scripts/flask_monitor_links_routes.py b/AppImage/scripts/flask_monitor_links_routes.py index 24c7d3ee..bf150aae 100644 --- a/AppImage/scripts/flask_monitor_links_routes.py +++ b/AppImage/scripts/flask_monitor_links_routes.py @@ -7,6 +7,13 @@ from jwt_middleware import require_auth, require_admin_scope monitor_links_bp = Blueprint("monitor_links", __name__) +def link_error(exc): + payload = {"error": str(exc), "code": exc.code} + if exc.params: + payload["params"] = exc.params + return jsonify(payload), 400 + + @monitor_links_bp.after_request def no_cache(response): response.headers["Cache-Control"] = "no-store" @@ -27,9 +34,9 @@ def add_link(): monitor_links.refresh() return jsonify(success=True, key=key) except monitor_links.LinkError as exc: - return jsonify(error=str(exc)), 400 + return link_error(exc) except OSError: - return jsonify(error="Could not save the node list."), 500 + return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500 @monitor_links_bp.route("/api/monitor-links/", methods=["PATCH"]) @@ -40,9 +47,9 @@ def update_link(key): monitor_links.refresh() return jsonify(success=True) except monitor_links.LinkError as exc: - return jsonify(error=str(exc)), 400 + return link_error(exc) except OSError: - return jsonify(error="Could not save the node list."), 500 + return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500 @monitor_links_bp.route("/api/monitor-links/", methods=["DELETE"]) @@ -53,6 +60,6 @@ def delete_link(key): monitor_links.refresh() return jsonify(success=True) except monitor_links.LinkError as exc: - return jsonify(error=str(exc)), 400 + return link_error(exc) except OSError: - return jsonify(error="Could not save the node list."), 500 + return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500 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/monitor_links.py b/AppImage/scripts/monitor_links.py index 169ca0f0..6bf46ebc 100644 --- a/AppImage/scripts/monitor_links.py +++ b/AppImage/scripts/monitor_links.py @@ -39,7 +39,10 @@ _refresh_lock = threading.Lock() class LinkError(ValueError): - pass + def __init__(self, message, *, code="monitor_links_save_failed", params=None): + super().__init__(message) + self.code = code + self.params = params def _load(): @@ -63,19 +66,19 @@ def _save(data): def normalize_url(value): """Return the origin of an http(s) address, or raise LinkError.""" if not isinstance(value, str) or not value.strip(): - raise LinkError("Enter the address of the Monitor.") + raise LinkError("Enter the address of the Monitor.", code="monitor_link_address_required") text = value.strip() if "://" not in text: text = "https://" + text parsed = urllib.parse.urlsplit(text) if parsed.scheme not in ("http", "https") or not parsed.hostname: - raise LinkError("The address must start with http:// or https:// and name a host.") + raise LinkError("The address must start with http:// or https:// and name a host.", code="monitor_link_invalid_address") if parsed.username or parsed.password: - raise LinkError("The address must not contain a user or password.") + raise LinkError("The address must not contain a user or password.", code="monitor_link_address_credentials") try: port = parsed.port except ValueError as exc: - raise LinkError("The port of the address is not valid.") from exc + raise LinkError("The port of the address is not valid.", code="monitor_link_invalid_port") from exc host = parsed.hostname if ":" in host: host = f"[{host}]" @@ -301,16 +304,16 @@ def start_prewarmer(): def add_link(payload): if not isinstance(payload, dict): - raise LinkError("Invalid request.") + raise LinkError("Invalid request.", code="monitor_link_invalid_request") name = str(payload.get("name") or "").strip()[:64] url = normalize_url(payload.get("url")) product = payload.get("product") if payload.get("product") in PRODUCTS else "pve" with _lock: data = _load() if len(data["manual"]) >= MAX_MANUAL: - raise LinkError(f"At most {MAX_MANUAL} nodes can be added.") + raise LinkError(f"At most {MAX_MANUAL} nodes can be added.", code="monitor_link_limit", params={"count": MAX_MANUAL}) if any(row.get("url") == url for row in data["manual"]): - raise LinkError("This address is already in the list.") + raise LinkError("This address is already in the list.", code="monitor_link_duplicate") identifier = secrets.token_hex(6) data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname, "url": url, "product": product}) @@ -320,13 +323,13 @@ def add_link(payload): def update_link(key, payload): if not isinstance(payload, dict): - raise LinkError("Invalid request.") + raise LinkError("Invalid request.", code="monitor_link_invalid_request") with _lock: data = _load() if key.startswith("manual:"): row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None) if row is None: - raise LinkError("Node not found.") + raise LinkError("Node not found.", code="monitor_link_not_found") if "name" in payload: row["name"] = str(payload["name"] or "").strip()[:64] or row["name"] if payload.get("url"): @@ -345,7 +348,7 @@ def update_link(key, payload): if not override: data["overrides"].pop(key, None) else: - raise LinkError("Node not found.") + raise LinkError("Node not found.", code="monitor_link_not_found") _save(data) @@ -354,6 +357,6 @@ def delete_link(key): data = _load() remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key] if len(remaining) == len(data["manual"]): - raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.") + raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.", code="monitor_link_detected_remove") data["manual"] = remaining _save(data) 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_monitor_links_i18n.py b/AppImage/scripts/tests/test_monitor_links_i18n.py new file mode 100644 index 00000000..197096c2 --- /dev/null +++ b/AppImage/scripts/tests/test_monitor_links_i18n.py @@ -0,0 +1,138 @@ +"""Exercise the shipped routes and persistence; localization is additive.""" +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 monitor_links as links + + +class MonitorLinksI18nTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.path = Path(temporary.name) / "links.json" + self.previous = {"manual": [{"id": "saved", "name": "Saved", "url": "https://saved.test", "product": "pve"}], "overrides": {}} + self.path.write_text(json.dumps(self.previous)) + for patch in [mock.patch.object(links, "LINKS_FILE", self.path), + mock.patch.object(links, "refresh"), mock.patch.object(links, "_probe")]: + patch.start() + self.addCleanup(patch.stop) + auth_provider = types.SimpleNamespace( + load_auth_config=lambda: {"enabled": True}, + verify_token=lambda token: "fixture" if token in ("admin", "reader") else None, + verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only") + if token in ("admin", "reader") else (None, None)) + middleware_spec = importlib.util.spec_from_file_location("fixture_jwt_middleware", ROOT / "AppImage/scripts/jwt_middleware.py") + middleware = importlib.util.module_from_spec(middleware_spec) + # Execute the shipped decorators; only credential verification is faked. + with mock.patch.dict(sys.modules, {"auth_manager": auth_provider}): + middleware_spec.loader.exec_module(middleware) + spec = importlib.util.spec_from_file_location("fixture_monitor_links_routes", ROOT / "AppImage/scripts/flask_monitor_links_routes.py") + routes = importlib.util.module_from_spec(spec) + # Import isolation keeps the real JWT module and other suites untouched. + with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}): + spec.loader.exec_module(routes) + app = Flask(__name__) + app.register_blueprint(routes.monitor_links_bp) + self.client = app.test_client() + + def send(self, method, path="", payload=None, role="admin"): + return self.client.open("/api/monitor-links" + path, method=method, + json=payload, headers={"Authorization": "Bearer " + role} if role else {}) + + def rejected(self, method, path, payload, message, code, params=None): + before = self.path.read_bytes() + with mock.patch.object(links, "_save") as save: + response = self.send(method, path, payload) + self.assertEqual(response.status_code, 400) + expected = {"error": message, "code": code} + if params: + expected["params"] = params + self.assertEqual(response.json, expected) + self.assertEqual(response.headers["Cache-Control"], "no-store") + save.assert_not_called() + links.refresh.assert_not_called() + links._probe.assert_not_called() + self.assertEqual(self.path.read_bytes(), before) + + def test_validation_errors_preserve_english_status_and_have_stable_codes_without_effects(self): + cases = [ + ("POST", "", None, "Invalid request.", "monitor_link_invalid_request"), + ("PATCH", "/manual:saved", [], "Invalid request.", "monitor_link_invalid_request"), + ("POST", "", {"url": " "}, "Enter the address of the Monitor.", "monitor_link_address_required"), + ("POST", "", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"), + ("POST", "", {"url": "https://user:pass@example.test"}, "The address must not contain a user or password.", "monitor_link_address_credentials"), + ("PATCH", "/manual:saved", {"name": "Unsaved", "url": "https://example.test:99999"}, "The port of the address is not valid.", "monitor_link_invalid_port"), + ("PATCH", "/cluster:node", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"), + ("POST", "", {"url": "https://saved.test/path"}, "This address is already in the list.", "monitor_link_duplicate"), + ("PATCH", "/manual:absent", {"url": "https://new.test"}, "Node not found.", "monitor_link_not_found"), + ("PATCH", "/other:node", {}, "Node not found.", "monitor_link_not_found"), + ("DELETE", "/cluster:node", None, "Only nodes added by hand can be removed; detected ones can be hidden.", "monitor_link_detected_remove"), + ] + for case in cases: + with self.subTest(case=case): + self.rejected(*case) + + def test_limit_uses_actual_count_and_preserves_previous_file(self): + with mock.patch.object(links, "MAX_MANUAL", 1): + self.rejected("POST", "", {"url": "https://new.test"}, "At most 1 nodes can be added.", + "monitor_link_limit", {"count": 1}) + + def test_write_failure_is_localizable_without_leaking_filesystem_details(self): + for method, path, payload in [("POST", "", {"url": "https://new.test"}), + ("PATCH", "/manual:saved", {"name": "Unsaved"}), + ("DELETE", "/manual:saved", None)]: + with self.subTest(method=method), mock.patch.object(links.os, "replace", side_effect=OSError("private path details")): + before = self.path.read_bytes() + response = self.send(method, path, payload) + self.assertEqual(response.status_code, 500) + self.assertEqual(response.json, {"error": "Could not save the node list.", "code": "monitor_links_save_failed"}) + self.assertEqual(self.path.read_bytes(), before) + links.refresh.assert_not_called() + links._probe.assert_not_called() + + def test_successful_crud_and_detected_hide_reset_keep_existing_contract(self): + response = self.send("POST", payload={"name": "New", "url": "new.test:8008/path", "product": "pbs"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(set(response.json), {"success", "key"}) + key = response.json["key"] + self.assertTrue(key.startswith("manual:")) + row = links._load()["manual"][-1] + self.assertEqual((row["url"], row["product"]), ("https://new.test:8008", "pbs")) + self.assertEqual(self.send("PATCH", "/" + key, {"url": "http://changed.test"}).json, {"success": True}) + self.assertEqual(links._load()["manual"][-1]["url"], "http://changed.test") + self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "https://override.test", "hidden": True}).json, {"success": True}) + self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "", "hidden": False}).json, {"success": True}) + self.assertEqual(links._load()["overrides"], {"cluster:node": {"hidden": False}}) + self.assertEqual(self.send("DELETE", "/" + key).json, {"success": True}) + self.assertEqual(links._load()["manual"], self.previous["manual"]) + self.assertEqual(self.path.stat().st_mode & 0o777, 0o600) + self.assertEqual(links.refresh.call_count, 5) + links._probe.assert_not_called() + + def test_existing_read_and_admin_decorators_still_guard_routes_before_effects(self): + with mock.patch.object(links, "cached_links", return_value={"self": {}, "links": []}) as cached, \ + mock.patch.object(links, "_save") as save: + for method, path in [("POST", ""), ("PATCH", "/manual:saved"), ("DELETE", "/manual:saved")]: + for role, status in [(None, 401), ("reader", 403)]: + self.assertEqual(self.send(method, path, {"url": "https://new.test"}, role).status_code, status) + self.assertEqual(self.send("GET", role=None).status_code, 401) + self.assertEqual(self.send("GET", "?all=1", role="reader").status_code, 200) + cached.assert_called_once_with(include_hidden=True) + save.assert_not_called() + links.refresh.assert_not_called() + links._probe.assert_not_called() + self.assertEqual(json.loads(self.path.read_text()), self.previous) + + +if __name__ == "__main__": + unittest.main() 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_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; }); diff --git a/tests/test_monitor_links_i18n.cjs b/tests/test_monitor_links_i18n.cjs new file mode 100644 index 00000000..46c566f4 --- /dev/null +++ b/tests/test_monitor_links_i18n.cjs @@ -0,0 +1,133 @@ +// Run the shipped translation adapter, transport and both dialog components. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const 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 translations = Object.fromEntries(['en', 'sk'].map(locale => [locale, JSON.parse(fs.readFileSync(`AppImage/messages/${locale}/common.json`))])); +const translate = locale => (key, params = {}) => { + const value = key.split('.').reduce((tree, part) => tree?.[part], translations[locale]); + assert.equal(typeof value, 'string', key); + return value.replace(/\{(\w+)\}/g, (match, key) => params[key] ?? match); +}; +const adapter = {exports: {}}; +vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/monitor-link-errors.ts', 'utf8')), adapter); +const {monitorLinkError} = adapter.exports; +const codes = { + monitor_link_address_required: 'addressRequired', monitor_link_invalid_address: 'invalidAddress', + monitor_link_address_credentials: 'addressCredentials', monitor_link_invalid_port: 'invalidPort', + monitor_link_invalid_request: 'invalidRequest', monitor_link_duplicate: 'duplicate', + monitor_link_not_found: 'notFound', monitor_link_detected_remove: 'detectedRemove', +}; +const failure = (code, params) => Object.assign(new Error('RAW ENGLISH SECRET DETAILS'), {body: {error: 'RAW ENGLISH SECRET DETAILS', code, params}}); + +const flatten = tree => !tree || typeof tree === 'boolean' ? [] : Array.isArray(tree) ? tree.flatMap(flatten) + : typeof tree === 'object' ? [tree, ...flatten(tree.props?.children)] : [tree]; +const text = tree => flatten(tree).filter(n => typeof n === 'string' || typeof n === 'number').join(''); +const jsx = (type, props) => ({type, props}); +const source = compile(fs.readFileSync('AppImage/components/monitor-switcher.tsx', 'utf8') + '\nexport {ManageLinksDialog};'); + +function fixture({locale = 'sk', links = [], origin = false, loadError = false, loading = false} = {}) { + let slots = [], cursor = 0, effects = [], tree, responseError, refreshFailure = false; + const calls = [], refreshes = [], storage = new Map(); + const candidates = [{u: 'https://other.test', n: 'Other', p: 'pve'}]; + const carried = Buffer.from(JSON.stringify(candidates)).toString('base64url'); + const location = {origin: 'https://current.test', hostname: 'current.test', pathname: '/', hash: '', + search: origin ? `?pmx_from=https%3A%2F%2Fsource.test&pmx_name=Source&pmx_links=${carried}` : ''}; + const react = { + useState(initial) {const slot = cursor++; if (!(slot in slots)) slots[slot] = typeof initial === 'function' ? initial() : initial; + return [slots[slot], value => {slots[slot] = typeof value === 'function' ? value(slots[slot]) : value;}];}, + useEffect(fn, deps) {const slot = cursor++; const old = slots[slot]; + if (!old || deps.some((dep, i) => dep !== old[i])) effects.push(fn); + slots[slot] = deps; + }, + }; + const exported = {}; + vm.runInNewContext(source, {exports: exported, URL, URLSearchParams, escape, unescape, + atob: value => Buffer.from(value, 'base64').toString('binary'), btoa: value => Buffer.from(value, 'binary').toString('base64'), + window: {location, history: {replaceState() {location.search = ''}}}, + sessionStorage: {getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value)}, + require(name) { + if (name === 'react') return react; + if (name === 'react/jsx-runtime') return {jsx, jsxs: jsx, Fragment: 'Fragment'}; + if (name === 'swr') return {default: () => ({data: {self: {name: 'Current', product: 'pve'}, links}, error: loadError, isLoading: loading, + mutate: async () => refreshes.push('all')}), mutate: async key => {refreshes.push(key); if (refreshFailure) throw new Error('RAW REFRESH ERROR');}}; + if (name.includes('i18n/provider')) return {useT: () => translate(locale)}; + if (name.includes('monitor-link-errors')) return {monitorLinkError}; + if (name.includes('api-config')) return {fetchApi: async (path, options) => {calls.push({path, ...options}); if (responseError) throw responseError; return {success: true};}}; + return new Proxy({}, {get: (_, key) => key}); + }, + }); + return { + render(component = 'ManageLinksDialog') {cursor = 0; effects = []; tree = exported[component]({open: true, onOpenChange() {}, nodeName: 'Current'}); effects.forEach(fn => fn()); return tree;}, + button(label) {const node = flatten(tree).find(n => n.type === 'Button' && (text(n) === label || n.props['aria-label'] === label)); assert.ok(node, label); return node;}, + input(id) {const node = flatten(tree).find(n => n.type === 'Input' && (n.props.id === id || n.props['aria-label'] === id)); assert.ok(node, id); return node;}, + nodes: () => flatten(tree), text: () => text(tree), fail: error => {responseError = error;}, failRefresh: () => {refreshFailure = true;}, calls, refreshes, storage, + }; +} + +async function main() { + for (const locale of ['en', 'sk']) { + const t = translate(locale); + for (const [code, key] of Object.entries(codes)) assert.equal(monitorLinkError(failure(code), t), t(`monitorLinks.errors.${key}`)); + assert.equal(monitorLinkError(failure('monitor_link_limit', {count: 64}), t), t('monitorLinks.errors.limit', {count: 64})); + for (const error of [null, undefined, 'raw', new Error('raw'), {body: null}, {body: {}}, + ...['constructor', '__proto__', 'toString', 'unknown', 'monitor_links_save_failed'].map(code => failure(code)), + ...[undefined, null, '64', 0, -1, 1.5, NaN, Infinity, 2 ** 54].map(count => failure('monitor_link_limit', {count}))]) { + assert.equal(monitorLinkError(error, t), t('monitorLinks.saveFailed')); + } + const f = fixture({locale, links: [{key: 'manual:saved', name: 'Saved', url: 'https://saved.test', manual: true}]}); + f.render(); f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render(); + f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://unsaved.test'}}); f.render(); + f.button(t('monitorLinks.cancel')).props.onClick(); f.render(); + assert.equal(f.calls.length, 0, 'Cancel must not write'); + f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render(); + assert.equal(f.input(t('monitorLinks.address')).props.value, 'https://saved.test', 'Cancel discards address draft'); + f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://changed.test'}}); f.render(); + await f.button(t('monitorLinks.save')).props.onClick(); f.render(); + assert.equal(f.calls[0].method, 'PATCH'); assert.deepEqual(JSON.parse(f.calls[0].body), {url: 'https://changed.test'}); + assert.equal(f.button(t('monitorLinks.add')).props.disabled, true, 'Blank address cannot be submitted'); + f.input('monitor-link-url').props.onChange({target: {value: 'ftp://bad.test'}}); f.render(); + f.fail(failure('monitor_link_invalid_address')); await f.button(t('monitorLinks.add')).props.onClick(); f.render(); + assert.ok(f.text().includes(t('monitorLinks.errors.invalidAddress'))); + assert.equal(f.input('monitor-link-url').props.value, 'ftp://bad.test', 'Failure retains draft'); + assert.ok(!f.text().includes('RAW ENGLISH')); + f.fail(new Error('raw network details')); await f.button(t('monitorLinks.remove')).props.onClick(); f.render(); + assert.ok(f.text().includes(t('monitorLinks.saveFailed'))); + const failedRead = fixture({locale, loadError: new Error('raw')}); failedRead.render(); + assert.ok(failedRead.text().includes(t('monitorLinks.loadFailed'))); + const pendingRead = fixture({locale, loading: true}); pendingRead.render(); + assert.ok(pendingRead.text().includes(t('monitorLinks.checking'))); + + const declined = fixture({locale, origin: true}); + for (let n = 0; n < 3; n++) declined.render('MonitorSwitcher'); + declined.button(t('monitorLinks.notNow')).props.onClick(); declined.render('MonitorSwitcher'); + assert.equal(declined.calls.length, 0, 'Not now must not write'); + const carried = fixture({locale, origin: true}); + for (let n = 0; n < 3; n++) carried.render('MonitorSwitcher'); + const boxes = carried.nodes().filter(n => n.type === 'Checkbox'); assert.equal(boxes.length, 2); + boxes[1].props.onCheckedChange(false); carried.render('MonitorSwitcher'); + carried.fail(failure('monitor_link_duplicate')); + carried.failRefresh(); + await carried.button(t('monitorLinks.addSelected')).props.onClick(); carried.render('MonitorSwitcher'); + assert.equal(carried.calls.length, 1, 'Only selected origin is attempted'); + assert.equal(JSON.parse(carried.calls[0].body).url, 'https://source.test'); + assert.ok(carried.text().includes(t('monitorLinks.errors.duplicate'))); + assert.equal(carried.refreshes.length, 1, 'Failed/partially successful batch refreshes the visible list'); + } + // Real authenticated transport preserves additive fields for the adapter. + const api = {exports: {}, process: {env: {}}, fetch: async () => new Response(JSON.stringify({error: 'Original English error', code: 'monitor_link_duplicate'}), {status: 400, headers: {'Content-Type': 'application/json'}})}; + vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/api-config.ts', 'utf8')), api); + try {await api.exports.fetchApi('/api/monitor-links', {method: 'POST'}); assert.fail('expected rejection');} + catch (error) {assert.equal(error.status, 400); assert.equal(error.message, 'Original English error'); assert.equal(monitorLinkError(error, translate('sk')), translations.sk.monitorLinks.errors.duplicate);} + const en = translations.en.monitorLinks, sk = translations.sk.monitorLinks; + assert.deepEqual(Object.keys(en.errors).sort(), Object.keys(sk.errors).sort()); + for (const key of Object.keys(en.errors)) { + assert.ok(sk.errors[key]); + assert.deepEqual(en.errors[key].match(/\{\w+\}/g), sk.errors[key].match(/\{\w+\}/g), key); + } + console.log('PASS: EN/SK known/unknown errors, real transport, both dialogs, Cancel/Not now zero writes, draft persistence, selected-only batch, localized loading/failure'); +} +main().catch(error => {console.error(error); process.exitCode = 1;}); diff --git a/tests/test_pbs_i18n.cjs b/tests/test_pbs_i18n.cjs new file mode 100644 index 00000000..93d92605 --- /dev/null +++ b/tests/test_pbs_i18n.cjs @@ -0,0 +1,154 @@ +// 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')); + 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); + 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.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'); + 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};