diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 9689e2ff..586ff3f5 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -31,6 +31,7 @@ PYTHON_FILES = ( "AppImage/scripts/tests/test_health_persistence_recurrence.py", ) NODE_FILES = ( + "tests/test_host_backup_messages.cjs", "tests/test_pbs_i18n.cjs", "tests/test_monitor_links_i18n.cjs", "tests/lxc_updates/test_local_app_icons_ui.cjs", diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 07e10b94..30434ff7 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -48,6 +48,7 @@ import { import { ScriptTerminalModal } from "./script-terminal-modal" import { RestoreProgressCard } from "./restore-progress-card" import { fetchApi, getApiUrl } from "../lib/api-config" +import { hostBackupErrorText } from "../lib/host-backup-messages" import { fetchTerminalTicket } from "../lib/terminal-ws" import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils" import { getStorageUsageColor } from "../lib/storage-usage-color" @@ -525,7 +526,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { await mutateInfo() closeUpload() } catch (e) { - setErr(e instanceof Error ? e.message : String(e)) + setErr(hostBackupErrorText(e, t)) setBusy(false) } } @@ -542,7 +543,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { await mutateInfo() closeDelete() } catch (e) { - setErr(e instanceof Error ? e.message : String(e)) + setErr(hostBackupErrorText(e, t)) setBusy(false) } } @@ -765,7 +766,7 @@ function KeyfileActionsBar({ setPass1("") setPass2("") } catch (e) { - setErr(e instanceof Error ? e.message : String(e)) + setErr(hostBackupErrorText(e, t)) } finally { setBusy(false) } @@ -956,7 +957,7 @@ export function HostBackup() { mutateJobs() setJobToDelete(null) } catch (e) { - setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` })) + setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${hostBackupErrorText(e, t)}` })) } finally { setBusyJobId(null) } @@ -1334,7 +1335,7 @@ export function HostBackup() {
{t("backup.archives.remoteQueryWarning")}
{remoteArchivesResp.errors.map((e, i) => (
- {e.backend}/{e.repo_name}: {e.error} + {e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
))} @@ -1761,7 +1762,7 @@ function InspectModal({ setImportFile(null) setImportPath("") } catch (e) { - setImportError(e instanceof Error ? e.message : String(e)) + setImportError(hostBackupErrorText(e, t)) } finally { setImporting(false) } @@ -1812,7 +1813,7 @@ function InspectModal({ : undefined, }) } catch (e) { - setRestoreError(e instanceof Error ? e.message : String(e)) + setRestoreError(hostBackupErrorText(e, t)) } finally { setRestorePreparing(false) } @@ -1841,7 +1842,7 @@ function InspectModal({ a.click() document.body.removeChild(a) } catch (e) { - setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) })) + setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) })) } finally { // The download itself runs in the browser's network stack; // we just initiated it. Clear the spinner immediately. @@ -1914,7 +1915,7 @@ function InspectModal({ document.body.removeChild(a) setExportTask(null) } catch (e) { - setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) })) + setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) })) } finally { setDownloading(false) } @@ -1962,7 +1963,7 @@ function InspectModal({ setShowDeleteArchiveConfirm(false) if (onDeleted) onDeleted(); else onClose() } catch (e) { - setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) })) + setError(t("backup.errors.deleteFailed", { error: hostBackupErrorText(e, t) })) } finally { setDeletingArchive(false) } @@ -1989,7 +1990,7 @@ function InspectModal({ ) as NonNullable setInstructions(res) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setFetchingInstructions(false) } @@ -2021,7 +2022,7 @@ function InspectModal({ ) setReport(res) } catch (e: any) { - setError(e?.message || t("backup.errors.preflightFailed")) + setError(hostBackupErrorText(e, t)) } finally { setRunning(false) } @@ -2164,7 +2165,7 @@ function InspectModal({ — {exportTask.message} {exportTask.state === "failed" && exportTask.error && ( -
{exportTask.error}
+
{hostBackupErrorText(exportTask.error, t)}
)} {exportTask.state === "completed" && exportTask.size_bytes > 0 && (
{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}
@@ -2706,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
{c.id} - {c.message} + {hostBackupErrorText(c.message, t)}
) @@ -3326,9 +3327,9 @@ function CreateJobDialog({ } catch (e) { const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } const detail = err.body?.tool_output - ? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}` - : err.message - setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) })) + ? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}` + : hostBackupErrorText(err, t) + setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) })) setPbsImportBusy(false) setSubmitting(false) return @@ -3391,7 +3392,7 @@ function CreateJobDialog({ }) onCreated() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setSubmitting(false) } @@ -3764,7 +3765,7 @@ function CreateJobDialog({ ) : (
- {t("backup.validation.invalidLabel")} {calendarPreview.error} + {t("backup.validation.invalidLabel")} {hostBackupErrorText(calendarPreview.error, t)}
) ) : ( @@ -4696,9 +4697,9 @@ function ManualBackupDialog({ } catch (e) { const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } const detail = err.body?.tool_output - ? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}` - : err.message - setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) })) + ? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}` + : hostBackupErrorText(err, t) + setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) })) setPbsImportBusy(false) setSubmitting(false) return @@ -4738,7 +4739,7 @@ function ManualBackupDialog({ ) onLaunched(resp.job_id) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setSubmitting(false) } @@ -5542,7 +5543,7 @@ function DestinationsSection({ await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" }) onChanged() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -5554,7 +5555,7 @@ function DestinationsSection({ await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" }) onChanged() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -5571,7 +5572,7 @@ function DestinationsSection({ ) onChanged() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -5599,7 +5600,7 @@ function DestinationsSection({ }) onChanged() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -6042,7 +6043,7 @@ function DestinationRow({ {capacity?.error && (

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

)} {!capacity && ( @@ -6341,7 +6342,7 @@ function AddDestinationDialog({ ) setGeneratedKey(resp) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setGeneratingKey(false) } @@ -6405,7 +6406,7 @@ function AddDestinationDialog({ } onSaved(savedRepo) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setSubmitting(false) } @@ -6756,7 +6757,7 @@ function UsbPicker({ mutate() if (resp?.mountpoint) onPick(resp.mountpoint) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -6784,7 +6785,7 @@ function UsbPicker({ setFormatTyped("") if (resp?.mountpoint) onPick(resp.mountpoint) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -7001,7 +7002,7 @@ function ExtraPathsSection() { setNewPath("") mutate() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setSubmitting(false) } @@ -7014,7 +7015,7 @@ function ExtraPathsSection() { await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" }) mutate() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyPath(null) } @@ -7180,7 +7181,7 @@ function UsbDrivesSection() { }) mutate() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -7198,7 +7199,7 @@ function UsbDrivesSection() { }) mutate() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -7222,7 +7223,7 @@ function UsbDrivesSection() { setFormatTarget(null) setFormatTyped("") } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusyKey(null) } @@ -7508,7 +7509,7 @@ function JobDetailModal({ setRunning(false) setRunBaseline(null) setRunBaselineLogPath(null) - setActionError(t("backup.errors.runFailed", { error: e instanceof Error ? e.message : String(e) })) + setActionError(t("backup.errors.runFailed", { error: hostBackupErrorText(e, t) })) } } @@ -7522,7 +7523,7 @@ function JobDetailModal({ await refetch() onChanged() } catch (e) { - setActionError(t("backup.errors.toggleFailed", { error: e instanceof Error ? e.message : String(e) })) + setActionError(t("backup.errors.toggleFailed", { error: hostBackupErrorText(e, t) })) } finally { setBusy("") } @@ -8155,7 +8156,7 @@ function PbsKeyfileRecoveryDialog({ }) onRecovered() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(hostBackupErrorText(e, t)) } finally { setBusy(false) } @@ -8342,7 +8343,7 @@ function ArchiveContentsModal({ body: JSON.stringify(body), }) .then((r: any) => setData(r)) - .catch((e) => setError(e instanceof Error ? e.message : String(e))) + .catch((e) => setError(hostBackupErrorText(e, t))) .finally(() => setLoading(false)) }, [open, source, repo_name, snapshot, path]) @@ -8571,7 +8572,7 @@ function PlanSummary({ plan }: { plan: any }) {
{t("backup.plan.blockers")}
@@ -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/lib/host-backup-messages.ts b/AppImage/lib/host-backup-messages.ts new file mode 100644 index 00000000..6129df23 --- /dev/null +++ b/AppImage/lib/host-backup-messages.ts @@ -0,0 +1,141 @@ +// Legacy HostBackup API messages are an English wire contract shared with CLI callers. +// Match complete, source-verified messages only; never derive a translation key from input. +// This presentation-only adapter keeps unknown tool diagnostics and parameter text intact. +type Translate = (key: string, params?: Record) => string + +const fixed = new Map([ + ["path must be absolute", "backup.apiErrors.absolutePath"], + ["this path is already configured", "backup.apiErrors.pathConfigured"], + ["path is required", "backup.apiErrors.pathRequired"], + ["path query parameter is required", "backup.apiErrors.pathQuery"], + ["path not found", "backup.apiErrors.pathNotFound"], + ["invalid job id", "backup.apiErrors.invalidJobId"], + ["job not found", "backup.apiErrors.jobNotFound"], + ["runner script not installed", "backup.apiErrors.runnerMissing"], + ["no restore state to dismiss", "backup.apiErrors.noRestore"], + ["invalid file name", "backup.apiErrors.invalidFile"], + ["not found", "backup.apiErrors.notFound"], + ["log path outside allowed directory", "backup.apiErrors.logOutside"], + ["targets must be a list", "backup.apiErrors.targetsList"], + ["empty expression", "backup.apiErrors.emptyExpression"], + ["invalid id (use letters, digits, _ or -)", "backup.apiErrors.invalidId"], + ["backend must be pbs, local or borg", "backup.apiErrors.backend"], + ["backend must be pbs or borg", "backup.apiErrors.remoteBackend"], + ["backend must be 'pbs' or 'borg'", "backup.apiErrors.remoteBackendQuoted"], + ["profile_mode must be default or custom", "backup.apiErrors.profileMode"], + ["borg cannot be attached (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttached"], + ["borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttachedLong"], + ["pve_parent_job_id and pve_storage are required for attached", "backup.apiErrors.parentRequired"], + ["paths must be a list of absolute paths", "backup.apiErrors.absolutePaths"], + ["custom profile needs at least one path", "backup.apiErrors.customPath"], + ["could not resolve default profile paths", "backup.apiErrors.defaultPaths"], + ["retention must be an object", "backup.apiErrors.retentionObject"], + ["pbs_repository is required", "backup.apiErrors.pbsRepository"], + ["pbs_encrypt_mode must be 'none', 'new' or 'existing'", "backup.apiErrors.pbsEncryption"], + ["failed to create PBS encryption keyfile", "backup.apiErrors.pbsKeyFailed"], + ["failed to create PBS encryption keyfile (is proxmox-backup-client installed?)", "backup.apiErrors.pbsClientMissing"], + ["borg_repo is required", "backup.apiErrors.borgRepository"], + ["borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.borgEncryption"], + ["encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.encryption"], + ["borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)", "backup.apiErrors.borgPassphrase"], + ["passphrase is required when encryption is enabled", "backup.apiErrors.passphrase"], + ["password is required", "backup.apiErrors.password"], + ["confirm_device must match device exactly (typo / safety check)", "backup.apiErrors.deviceConfirmation"], + ["invalid device path", "backup.apiErrors.invalidDevice"], + ["absolute mountpoint required", "backup.apiErrors.absoluteMountpoint"], + ["archive layout not recognized — no rootfs/ found", "backup.apiErrors.archiveLayout"], + ["archive not found", "backup.apiErrors.archiveMissing"], + ["archive path is not a regular file", "backup.apiErrors.archiveNotFile"], + ["invalid name", "backup.apiErrors.invalidName"], + ["invalid staging_path", "backup.apiErrors.invalidStaging"], + ["job name cannot be changed (delete + recreate to rename)", "backup.apiErrors.jobRename"], + ["key_path must be absolute", "backup.apiErrors.absoluteKey"], + ["mode must be \"local\" or \"ssh\"", "backup.apiErrors.mode"], + ["name is required (letters, digits, _ or -)", "backup.apiErrors.nameRequired"], + ["no log available for this job yet", "backup.apiErrors.noLog"], + ["no manual PBS configs file", "backup.apiErrors.noPbsConfig"], + ["output file missing", "backup.apiErrors.outputMissing"], + ["parser output was not valid JSON", "backup.apiErrors.parserJson"], + ["run_restore output was not valid JSON", "backup.apiErrors.restoreJson"], + ["repo is required", "backup.apiErrors.repoRequired"], + ["repo_name and snapshot are required", "backup.apiErrors.repoSnapshot"], + ["repo_name and snapshot are required for borg", "backup.apiErrors.borgRepoSnapshot"], + ["repo_name and snapshot are required for pbs", "backup.apiErrors.pbsRepoSnapshot"], + ["repo_name is required", "backup.apiErrors.repoName"], + ["restore tooling not installed on this host", "backup.apiErrors.restoreMissing"], + ["server and datastore are required", "backup.apiErrors.serverDatastore"], + ["snapshot is required", "backup.apiErrors.snapshotRequired"], + ["source must be pbs, borg, or local", "backup.apiErrors.source"], + ["ssh_port must be an integer between 1 and 65535", "backup.apiErrors.sshPort"], + ["ssh_user, ssh_host and ssh_remote_path are required for ssh mode", "backup.apiErrors.sshRequired"], + ["target has no repository path", "backup.apiErrors.repositoryPath"], + ["task not found or expired", "backup.apiErrors.taskExpired"], + ["invalid expression", "backup.apiErrors.invalidExpression"], + ["no report emitted", "backup.apiErrors.noReport"], + ["parse_manifest exited non-zero", "backup.apiErrors.parseManifestFailed"], +]) + +const parameterized: ReadonlyArray = [ + [new RegExp("^job already exists: ([\\s\\S]+)$"), "backup.apiErrors.jobExists"], + [new RegExp("^path does not exist on this host: ([\\s\\S]+)$"), "backup.apiErrors.pathMissing"], + [new RegExp("^device does not exist: ([\\s\\S]+)$"), "backup.apiErrors.deviceMissing"], + [new RegExp("^not a mountpoint: ([\\s\\S]+)$"), "backup.apiErrors.mountpoint"], + [new RegExp("^local archive not found: ([\\s\\S]+)$"), "backup.apiErrors.localArchiveMissing"], + [new RegExp("^Borg destination '([\\s\\S]+)' not found$"), "backup.apiErrors.borgDestination"], + [new RegExp("^PBS destination '([\\s\\S]+)' not found$"), "backup.apiErrors.pbsDestination"], + [new RegExp("^no password available for PBS repo '([\\s\\S]+)'$"), "backup.apiErrors.pbsPassword"], + [new RegExp("^Borg repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.borgRepoMissing"], + [new RegExp("^PBS repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.pbsRepoMissing"], + [new RegExp("^Borg target not found: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetMissing"], + [new RegExp("^PBS repo not found: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetMissing"], + [new RegExp("^PVE vzdump job not found: ([\\s\\S]+)$"), "backup.apiErrors.pveJobMissing"], + [new RegExp("^cannot persist state: ([\\s\\S]+)$"), "backup.apiErrors.statePersist"], + [new RegExp("^cannot prepare ssh dir: ([\\s\\S]+)$"), "backup.apiErrors.sshDirectory"], + [new RegExp("^cannot read log: ([\\s\\S]+)$"), "backup.apiErrors.logRead"], + [new RegExp("^could not update \\.env: ([\\s\\S]+)$"), "backup.apiErrors.envUpdate"], + [new RegExp("^failed to persist PBS config: ([\\s\\S]+)$"), "backup.apiErrors.pbsConfigWrite"], + [new RegExp("^failed to persist: ([\\s\\S]+)$"), "backup.apiErrors.persist"], + [new RegExp("^failed to remove archive: ([\\s\\S]+)$"), "backup.apiErrors.archiveRemove"], + [new RegExp("^failed to start: ([\\s\\S]+)$"), "backup.apiErrors.start"], + [new RegExp("^failed to update borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsUpdate"], + [new RegExp("^failed to update pbs-manual-configs\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetsUpdate"], + [new RegExp("^failed to update systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsUpdate"], + [new RegExp("^failed to write borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsWrite"], + [new RegExp("^failed to write job files: ([\\s\\S]+)$"), "backup.apiErrors.jobWrite"], + [new RegExp("^failed to write local-target\\.conf: ([\\s\\S]+)$"), "backup.apiErrors.localTargetWrite"], + [new RegExp("^failed to write systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsWrite"], + [new RegExp("^format failed: ([\\s\\S]+)$"), "backup.apiErrors.format"], + [new RegExp("^invalid on_calendar: ([\\s\\S]+)$"), "backup.apiErrors.calendar"], + [new RegExp("^mount failed: ([\\s\\S]+)$"), "backup.apiErrors.mount"], + [new RegExp("^parser invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.parserInvocation"], + [new RegExp("^preflight invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.preflightInvocation"], + [new RegExp("^public key not found after generation: ([\\s\\S]+)$"), "backup.apiErrors.publicKeyMissing"], + [new RegExp("^retention\\.([\\s\\S]+) must be an integer$"), "backup.apiErrors.retentionInteger"], + [new RegExp("^retention\\.([\\s\\S]+) must be non-negative$"), "backup.apiErrors.retentionNegative"], + [new RegExp("^ssh-keygen failed: ([\\s\\S]+)$"), "backup.apiErrors.keygen"], + [new RegExp("^systemctl failed: ([\\s\\S]+)$"), "backup.apiErrors.systemctl"], + [new RegExp("^umount failed: ([\\s\\S]+)$"), "backup.apiErrors.unmount"], + [new RegExp("^task is in state \"([\\s\\S]+)\", not completed$"), "backup.apiErrors.taskIncomplete"], + [new RegExp("^unknown kind: ([\\s\\S]+)$"), "backup.apiErrors.unknownKind"], + [new RegExp("^unknown mode \"([\\s\\S]+)\"$"), "backup.apiErrors.unknownMode"], + [new RegExp("^([\\s\\S]+) is the built-in default and cannot be removed$"), "backup.apiErrors.defaultRemove"], + [new RegExp("^([\\s\\S]+) is the built-in default; no need to add it$"), "backup.apiErrors.defaultAdd"], + [new RegExp("^pbs_encrypt_mode='existing' but no keyfile is installed at ([\\s\\S]+) — import one first with POST /api/host-backups/pbs-encryption/import$"), "backup.apiErrors.keyImport"], + [new RegExp("^PVE job storage mismatch \\(job uses ([\\s\\S]+), payload says ([\\s\\S]+)\\)$"), "backup.apiErrors.storageMismatch"], +] + +export function hostBackupErrorText(problem: unknown, t: Translate): string { + const message = typeof problem === "string" ? problem + : problem && typeof problem === "object" && "message" in problem && typeof problem.message === "string" + ? problem.message : "" + if (!message) return t("backup.apiErrors.requestFailed") + const key = fixed.get(message) + if (key) return t(key) + // Keep unusually large tool output verbatim, without running pattern matching over it. + if (message.length > 16384) return message + for (const [pattern, patternKey] of parameterized) { + const match = pattern.exec(message) + if (match) return t(patternKey, { value: match[1], other: match[2] ?? "" }) + } + return message +} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 7bc7b31c..cb13efab 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -4368,6 +4368,124 @@ "unrecoverable": "Without the key, encrypted backups cannot be restored.", "usesSharedKeyfileAt": "Uses shared keyfile at" }, + "apiErrors": { + "requestFailed": "The request failed.", + "absolutePath": "path must be absolute", + "pathConfigured": "this path is already configured", + "pathRequired": "path is required", + "pathQuery": "path query parameter is required", + "pathNotFound": "path not found", + "invalidJobId": "invalid job id", + "jobNotFound": "job not found", + "runnerMissing": "runner script not installed", + "noRestore": "no restore state to dismiss", + "invalidFile": "invalid file name", + "notFound": "not found", + "logOutside": "log path outside allowed directory", + "targetsList": "targets must be a list", + "emptyExpression": "empty expression", + "invalidId": "invalid id (use letters, digits, _ or -)", + "backend": "backend must be pbs, local or borg", + "remoteBackend": "backend must be pbs or borg", + "remoteBackendQuoted": "backend must be 'pbs' or 'borg'", + "profileMode": "profile_mode must be default or custom", + "borgAttached": "borg cannot be attached (PVE vzdump only writes to pbs/local storages)", + "borgAttachedLong": "borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)", + "parentRequired": "pve_parent_job_id and pve_storage are required for attached", + "absolutePaths": "paths must be a list of absolute paths", + "customPath": "custom profile needs at least one path", + "defaultPaths": "could not resolve default profile paths", + "retentionObject": "retention must be an object", + "pbsRepository": "pbs_repository is required", + "pbsEncryption": "pbs_encrypt_mode must be 'none', 'new' or 'existing'", + "pbsKeyFailed": "failed to create PBS encryption keyfile", + "pbsClientMissing": "failed to create PBS encryption keyfile (is proxmox-backup-client installed?)", + "borgRepository": "borg_repo is required", + "borgEncryption": "borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated", + "encryption": "encrypt_mode must be one of: none, repokey, keyfile, authenticated", + "borgPassphrase": "borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)", + "passphrase": "passphrase is required when encryption is enabled", + "password": "password is required", + "deviceConfirmation": "confirm_device must match device exactly (typo / safety check)", + "invalidDevice": "invalid device path", + "absoluteMountpoint": "absolute mountpoint required", + "archiveLayout": "archive layout not recognized — no rootfs/ found", + "archiveMissing": "archive not found", + "archiveNotFile": "archive path is not a regular file", + "invalidName": "invalid name", + "invalidStaging": "invalid staging_path", + "jobRename": "job name cannot be changed (delete + recreate to rename)", + "absoluteKey": "key_path must be absolute", + "mode": "mode must be \"local\" or \"ssh\"", + "nameRequired": "name is required (letters, digits, _ or -)", + "noLog": "no log available for this job yet", + "noPbsConfig": "no manual PBS configs file", + "outputMissing": "output file missing", + "parserJson": "parser output was not valid JSON", + "restoreJson": "run_restore output was not valid JSON", + "repoRequired": "repo is required", + "repoSnapshot": "repo_name and snapshot are required", + "borgRepoSnapshot": "repo_name and snapshot are required for borg", + "pbsRepoSnapshot": "repo_name and snapshot are required for pbs", + "repoName": "repo_name is required", + "restoreMissing": "restore tooling not installed on this host", + "serverDatastore": "server and datastore are required", + "snapshotRequired": "snapshot is required", + "source": "source must be pbs, borg, or local", + "sshPort": "ssh_port must be an integer between 1 and 65535", + "sshRequired": "ssh_user, ssh_host and ssh_remote_path are required for ssh mode", + "repositoryPath": "target has no repository path", + "taskExpired": "task not found or expired", + "invalidExpression": "invalid expression", + "noReport": "no report emitted", + "parseManifestFailed": "parse_manifest exited non-zero", + "jobExists": "job already exists: {value}", + "pathMissing": "path does not exist on this host: {value}", + "deviceMissing": "device does not exist: {value}", + "mountpoint": "not a mountpoint: {value}", + "localArchiveMissing": "local archive not found: {value}", + "borgDestination": "Borg destination '{value}' not found", + "pbsDestination": "PBS destination '{value}' not found", + "pbsPassword": "no password available for PBS repo '{value}'", + "borgRepoMissing": "Borg repo \"{value}\" not configured", + "pbsRepoMissing": "PBS repo \"{value}\" not configured", + "borgTargetMissing": "Borg target not found: {value}", + "pbsTargetMissing": "PBS repo not found: {value}", + "pveJobMissing": "PVE vzdump job not found: {value}", + "statePersist": "cannot persist state: {value}", + "sshDirectory": "cannot prepare ssh dir: {value}", + "logRead": "cannot read log: {value}", + "envUpdate": "could not update .env: {value}", + "pbsConfigWrite": "failed to persist PBS config: {value}", + "persist": "failed to persist: {value}", + "archiveRemove": "failed to remove archive: {value}", + "start": "failed to start: {value}", + "borgTargetsUpdate": "failed to update borg-targets.txt: {value}", + "pbsTargetsUpdate": "failed to update pbs-manual-configs.txt: {value}", + "unitsUpdate": "failed to update systemd units: {value}", + "borgTargetsWrite": "failed to write borg-targets.txt: {value}", + "jobWrite": "failed to write job files: {value}", + "localTargetWrite": "failed to write local-target.conf: {value}", + "unitsWrite": "failed to write systemd units: {value}", + "format": "format failed: {value}", + "calendar": "invalid on_calendar: {value}", + "mount": "mount failed: {value}", + "parserInvocation": "parser invocation failed: {value}", + "preflightInvocation": "preflight invocation failed: {value}", + "publicKeyMissing": "public key not found after generation: {value}", + "retentionInteger": "retention.{value} must be an integer", + "retentionNegative": "retention.{value} must be non-negative", + "keygen": "ssh-keygen failed: {value}", + "systemctl": "systemctl failed: {value}", + "unmount": "umount failed: {value}", + "taskIncomplete": "task is in state \"{value}\", not completed", + "unknownKind": "unknown kind: {value}", + "unknownMode": "unknown mode \"{value}\"", + "defaultRemove": "{value} is the built-in default and cannot be removed", + "defaultAdd": "{value} is the built-in default; no need to add it", + "keyImport": "No PBS keyfile is installed at {value}. Import the keyfile first.", + "storageMismatch": "PVE job storage mismatch (job uses {value}, payload says {other})" + }, "errors": { "backendNoStagingPath": "Backend did not return a staging path.", "deleteFailed": "Delete failed: {error}", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index f7312318..86302c98 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -4178,7 +4178,7 @@ "unmountTitle": "Odpojiť tento cieľ", "whereIsBorgRepo": "Kde je Borg repozitár?", "backupsKeptMessage": "Existujúce zálohy sa neodstránia.", - "localAttachHelpMessage": "Najprv pripojte jednotky USB.Ak chcete použiť cestu k úložisku PVE + /dump, nechajte pole prázdne (záložný: /var/lib/vz/dump).", + "localAttachHelpMessage": "Najprv pripoj USB disk. Ak pole necháš prázdne, použije sa cesta úložiska PVE doplnená o /dump; náhradná cesta je /var/lib/vz/dump.", "sshAuthorizedKeyHelp": "Pred pridaním do ~/.ssh/authorized_keys pre {user} na vzdialenom hostiteľovi skontrolujte riadok nižšie.", "sshKeyHelpMessage": "Cesta súkromného kľúča na tomto hostiteľovi.Použite {action}, potom skontrolujte a pridajte zobrazený riadok verejného kľúča do ~/.ssh/authorized_keys vzdialeného používateľa.", "sshKeySetupHelp": "Vytvorí kľúč na zadanej lokálnej ceste, ak žiadny neexistuje;inak prečíta svoj verejný kľúč.Na vzdialenom hostiteľovi nie je nainštalovaný žiadny kľúč.", @@ -4222,6 +4222,124 @@ "usesSharedKeyfileAt": "Používa spoločný súbor s kľúčom v", "pbsHelp": "Monitor používa {keyfile} s {option} na šifrované zálohy a obnovy PBS.Súbor kľúča je uložený v režime {mode}.Uschovajte si bezpečnú kópiu mimo tohto hostiteľa." }, + "apiErrors": { + "requestFailed": "Požiadavka zlyhala.", + "absolutePath": "Cesta musí začínať znakom /.", + "pathConfigured": "Táto cesta už je nastavená.", + "pathRequired": "Zadaj cestu.", + "pathQuery": "V požiadavke chýba cesta.", + "pathNotFound": "Cesta sa nenašla.", + "invalidJobId": "Neplatný identifikátor úlohy.", + "jobNotFound": "Úloha sa nenašla.", + "runnerMissing": "Na serveri chýba skript na spustenie zálohy.", + "noRestore": "Nie je tu žiadny záznam obnovy na zatvorenie.", + "invalidFile": "Neplatný názov súboru.", + "notFound": "Požadovaný záznam sa nenašiel.", + "logOutside": "Cesta k záznamu je mimo povoleného priečinka.", + "targetsList": "Ciele musia byť zadané ako zoznam.", + "emptyExpression": "Zadaj časový plán.", + "invalidId": "V identifikátore použi iba písmená, číslice, _ alebo -.", + "backend": "Vyber cieľ PBS, miestny archív alebo Borg.", + "remoteBackend": "Vyber cieľ PBS alebo Borg.", + "remoteBackendQuoted": "Vyber cieľ PBS alebo Borg.", + "profileMode": "Vyber predvolený alebo vlastný profil.", + "borgAttached": "Borg sa nedá pripojiť k úlohe PVE; vzdump zapisuje iba na PBS alebo miestne úložisko.", + "borgAttachedLong": "Borg sa nedá pripojiť k úlohe PVE; vzdump zapisuje iba na PBS alebo miestne úložisko.", + "parentRequired": "Vyber nadradenú úlohu PVE aj jej úložisko.", + "absolutePaths": "Zadaj zoznam ciest, ktoré začínajú znakom /.", + "customPath": "Do vlastného profilu pridaj aspoň jednu cestu.", + "defaultPaths": "Cesty predvoleného profilu sa nepodarilo zistiť.", + "retentionObject": "Pravidlá uchovávania záloh majú neplatný formát.", + "pbsRepository": "Zadaj repozitár PBS.", + "pbsEncryption": "Vyber režim šifrovania PBS: bez šifrovania, nový kľúč alebo existujúci kľúč.", + "pbsKeyFailed": "Súbor so šifrovacím kľúčom PBS sa nepodarilo vytvoriť.", + "pbsClientMissing": "Súbor so šifrovacím kľúčom PBS sa nepodarilo vytvoriť. Skontroluj, či je nainštalovaný proxmox-backup-client.", + "borgRepository": "Zadaj repozitár Borg.", + "borgEncryption": "Vyber podporovaný režim šifrovania Borg: none, repokey, keyfile alebo authenticated.", + "encryption": "Vyber podporovaný režim šifrovania: none, repokey, keyfile alebo authenticated.", + "borgPassphrase": "Šifrovaný repozitár Borg potrebuje heslo. Ulož ho pri cieli alebo ho zadaj v požiadavke.", + "passphrase": "Pri zapnutom šifrovaní zadaj heslo.", + "password": "Zadaj heslo.", + "deviceConfirmation": "Na potvrdenie zadaj presný názov zariadenia.", + "invalidDevice": "Neplatná cesta k zariadeniu.", + "absoluteMountpoint": "Prípojný bod musí byť cesta začínajúca znakom /.", + "archiveLayout": "Archív nemá očakávanú štruktúru: chýba priečinok rootfs/.", + "archiveMissing": "Archív sa nenašiel.", + "archiveNotFile": "Cesta k archívu neoznačuje bežný súbor.", + "invalidName": "Neplatný názov.", + "invalidStaging": "Neplatná cesta k dočasným súborom.", + "jobRename": "Názov existujúcej úlohy sa nedá zmeniť. Ak ju chceš premenovať, odstráň ju a vytvor novú.", + "absoluteKey": "Cesta k súboru s kľúčom musí začínať znakom /.", + "mode": "Vyber miestny cieľ alebo pripojenie cez SSH.", + "nameRequired": "Zadaj názov; použi iba písmená, číslice, _ alebo -.", + "noLog": "Pre túto úlohu zatiaľ nie je dostupný záznam.", + "noPbsConfig": "Súbor s ručne nastavenými cieľmi PBS sa nenašiel.", + "outputMissing": "Chýba výstupný súbor.", + "parserJson": "Výstup spracovania archívu nemá platný formát JSON.", + "restoreJson": "Výstup prípravy obnovy nemá platný formát JSON.", + "repoRequired": "Zadaj repozitár.", + "repoSnapshot": "Vyber repozitár aj zálohu.", + "borgRepoSnapshot": "Pre Borg vyber repozitár aj zálohu.", + "pbsRepoSnapshot": "Pre PBS vyber repozitár aj zálohu.", + "repoName": "Zadaj názov repozitára.", + "restoreMissing": "Na tomto serveri chýbajú nástroje na obnovu.", + "serverDatastore": "Zadaj server aj dátové úložisko.", + "snapshotRequired": "Vyber zálohu.", + "source": "Vyber zdroj PBS, Borg alebo miestny archív.", + "sshPort": "Port SSH musí byť celé číslo od 1 do 65535.", + "sshRequired": "Pre SSH zadaj používateľa, hostiteľa a vzdialenú cestu.", + "repositoryPath": "Cieľ nemá nastavenú cestu k repozitáru.", + "taskExpired": "Úloha sa nenašla alebo už vypršala.", + "invalidExpression": "Neplatný časový plán.", + "noReport": "Kontrola nevrátila žiadnu správu.", + "parseManifestFailed": "Spracovanie manifestu skončilo chybou.", + "jobExists": "Úloha s názvom {value} už existuje.", + "pathMissing": "Na tomto serveri sa nenašla cesta: {value}", + "deviceMissing": "Zariadenie neexistuje: {value}", + "mountpoint": "Táto cesta nie je prípojný bod: {value}", + "localArchiveMissing": "Miestny archív sa nenašiel: {value}", + "borgDestination": "Cieľ Borg „{value}“ sa nenašiel.", + "pbsDestination": "Cieľ PBS „{value}“ sa nenašiel.", + "pbsPassword": "Pre repozitár PBS „{value}“ nie je dostupné heslo.", + "borgRepoMissing": "Repozitár Borg „{value}“ nie je nastavený.", + "pbsRepoMissing": "Repozitár PBS „{value}“ nie je nastavený.", + "borgTargetMissing": "Cieľ Borg sa nenašiel: {value}", + "pbsTargetMissing": "Repozitár PBS sa nenašiel: {value}", + "pveJobMissing": "Úloha zálohovania PVE sa nenašla: {value}", + "statePersist": "Stav sa nepodarilo uložiť: {value}", + "sshDirectory": "Priečinok SSH sa nepodarilo pripraviť: {value}", + "logRead": "Záznam sa nepodarilo prečítať: {value}", + "envUpdate": "Súbor .env sa nepodarilo aktualizovať: {value}", + "pbsConfigWrite": "Nastavenia PBS sa nepodarilo uložiť: {value}", + "persist": "Údaje sa nepodarilo uložiť: {value}", + "archiveRemove": "Archív sa nepodarilo odstrániť: {value}", + "start": "Úlohu sa nepodarilo spustiť: {value}", + "borgTargetsUpdate": "Súbor borg-targets.txt sa nepodarilo aktualizovať: {value}", + "pbsTargetsUpdate": "Súbor pbs-manual-configs.txt sa nepodarilo aktualizovať: {value}", + "unitsUpdate": "Jednotky systemd sa nepodarilo aktualizovať: {value}", + "borgTargetsWrite": "Súbor borg-targets.txt sa nepodarilo uložiť: {value}", + "jobWrite": "Súbory úlohy sa nepodarilo uložiť: {value}", + "localTargetWrite": "Súbor local-target.conf sa nepodarilo uložiť: {value}", + "unitsWrite": "Jednotky systemd sa nepodarilo uložiť: {value}", + "format": "Formátovanie zlyhalo: {value}", + "calendar": "Neplatný časový plán: {value}", + "mount": "Disk sa nepodarilo pripojiť: {value}", + "parserInvocation": "Spracovanie archívu sa nepodarilo spustiť: {value}", + "preflightInvocation": "Kontrolu pred obnovou sa nepodarilo spustiť: {value}", + "publicKeyMissing": "Po vytvorení kľúča sa nenašiel verejný kľúč: {value}", + "retentionInteger": "Pravidlo uchovávania „{value}“ musí byť celé číslo.", + "retentionNegative": "Pravidlo uchovávania „{value}“ nesmie byť záporné.", + "keygen": "Vytvorenie kľúča SSH zlyhalo: {value}", + "systemctl": "Správa služby cez systemctl zlyhala: {value}", + "unmount": "Disk sa nepodarilo odpojiť: {value}", + "taskIncomplete": "Úloha ešte nie je dokončená. Jej stav: {value}", + "unknownKind": "Neznámy typ: {value}", + "unknownMode": "Neznámy režim: {value}", + "defaultRemove": "Cesta {value} je predvolená a nedá sa odstrániť.", + "defaultAdd": "Cesta {value} je predvolená; nemusíš ju pridávať.", + "keyImport": "V {value} nie je uložený súbor s kľúčom PBS. Najprv ho importuj.", + "storageMismatch": "Úložisko sa nezhoduje: úloha PVE používa {value}, požiadavka uvádza {other}." + }, "errors": { "backendNoStagingPath": "Cieľ nevrátil cestu k dočasným súborom.", "deleteFailed": "Mazanie zlyhalo: {error}", diff --git a/tests/test_host_backup_messages.cjs b/tests/test_host_backup_messages.cjs new file mode 100644 index 00000000..852979e0 --- /dev/null +++ b/tests/test_host_backup_messages.cjs @@ -0,0 +1,103 @@ +// Exercise the shipped error adapter and actual HostBackup handlers with a fake transport. +const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm'); +const { ts, read, compile, translator } = require('./backup_i18n_seam.cjs'); +const library = { exports: {} }; +vm.runInNewContext(compile(read('lib/host-backup-messages.ts')), library); +const { hostBackupErrorText } = library.exports; +// Independently read the real API's literal/f-string contracts without importing +// Flask or running any route, host probe, script or persistence operation. +const contracts = JSON.parse(require('node:child_process').execFileSync('python3', ['-I', '-B', '-c', ` +import ast,json,sys +tree=ast.parse(open(sys.argv[1]).read()) +def messages(node): + if isinstance(node,ast.Constant) and isinstance(node.value,str): return [node.value] + if isinstance(node,ast.JoinedStr): return [''.join(part.value if isinstance(part,ast.Constant) else 'PARAMETER' for part in node.values)] + if isinstance(node,ast.BoolOp): return [text for child in node.values for text in messages(child)] + return [] +result=set() +for function in tree.body: + if isinstance(function,ast.FunctionDef) and function.name.startswith(('api_host_backups_','api_host_restore')): + for node in ast.walk(function): + if isinstance(node,ast.Dict): + for key,value in zip(node.keys,node.values): + if isinstance(key,ast.Constant) and key.value=='error': result.update(messages(value)) +print(json.dumps(sorted(result))) +`, 'AppImage/scripts/flask_server.py'], { encoding: 'utf8' })); +const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten) + : typeof n === 'object' ? [n, ...flatten(n.props?.children)] : [n]; +const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join(''); + +function fixture(locale) { + let cursor = 0, slots = [], tree, failure = 'path must be absolute', mutations = 0; + const calls = [], t = translator(locale); + const jsx = (type, props) => typeof type === 'function' ? type(props) : { type, props }; + const hooks = { useState(initial) { const i = cursor++; if (!(i in slots)) slots[i] = initial; return [slots[i], value => { slots[i] = value; }]; } }; + const requireModule = name => { + if (name === 'react') return hooks; + if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx }; + if (name === 'swr') return { default: () => ({ data: { paths: [{ path: '/kept' }] }, mutate: () => { mutations++; } }) }; + if (name.includes('i18n/provider')) return { useT: () => t }; + if (name.endsWith('host-backup-messages')) return library.exports; + if (name.endsWith('api-config')) return { fetchApi: async (url, options) => { calls.push({ url, ...options }); if (failure) throw new Error(failure); return { status: 'ok' }; } }; + return new Proxy({}, { get: (_, key) => key }); + }; + const source = read('components/host-backup.tsx') + '\nexports.ExtraPathsSection = ExtraPathsSection;'; + const module = { exports: {} }; + vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX } }).outputText, + { exports: module.exports, require: requireModule, Error, console }); + return { calls, t, render() { cursor = 0; tree = module.exports.ExtraPathsSection(); return tree; }, text: () => text(tree), + input: () => flatten(tree).find(n => n.type === 'Input'), + add: () => flatten(tree).find(n => n.type === 'Button' && text(n).includes(t('backup.actions.addPath'))), + fail: message => { failure = message; }, mutations: () => mutations }; +} + +(async () => { + assert.ok(contracts.length > 100, 'real HostBackup error contracts discovered'); + for (const message of contracts) { + const translated = hostBackupErrorText(message, translator('sk')); + assert.notEqual(translated, message, 'application-owned message must be localized: ' + message); + assert.ok(!translated.includes('backup.apiErrors.'), 'every mapped key has a real Slovak value'); + assert.equal(hostBackupErrorText(message, translator('missing')), hostBackupErrorText(message, translator('en')), 'missing locale uses English'); + } + for (const locale of ['en', 'sk', 'missing']) { + const t = translator(locale); + assert.equal(hostBackupErrorText(new Error('path must be absolute'), t), t('backup.apiErrors.absolutePath')); + assert.equal(hostBackupErrorText({ message: 'this path is already configured' }, t), t('backup.apiErrors.pathConfigured')); + assert.equal(hostBackupErrorText('job already exists: test_1', t), t('backup.apiErrors.jobExists', { value: 'test_1' })); + const raw = 'tool failed: path must be absolute'; + assert.equal(hostBackupErrorText(raw, t), raw, 'external diagnostics must not be partially matched or discarded'); + const long = 'path does not exist on this host: ' + 'x'.repeat(20000); + assert.equal(hostBackupErrorText(long, t), long, 'large diagnostics preserved without pattern matching'); + for (const unknown of ['constructor', '__proto__', 'toString', 'future backend message']) assert.equal(hostBackupErrorText(unknown, t), unknown); + for (const invalid of [null, undefined, {}, 23]) assert.equal(hostBackupErrorText(invalid, t), t('backup.apiErrors.requestFailed')); + const unsafe = '{backup.actions.run}'; + assert.equal(hostBackupErrorText('path does not exist on this host: ' + unsafe, t), t('backup.apiErrors.pathMissing', { value: unsafe }), 'captured paths remain literal data'); + const f = fixture(locale); f.render(); + assert.equal(f.add().props.disabled, true); + await f.add().props.onClick(); assert.equal(f.calls.length, 0, 'blank path causes no request'); + f.input().props.onChange({ target: { value: 'relative-path' } }); f.render(); await f.add().props.onClick(); f.render(); + assert.equal(f.calls.length, 1); assert.equal(f.calls[0].url, '/api/host-backups/extra-paths'); + assert.equal(f.calls[0].method, 'POST'); assert.deepEqual(JSON.parse(f.calls[0].body), { path: 'relative-path' }); + assert.equal(f.input().props.value, 'relative-path'); assert.equal(f.mutations(), 0, 'rejected input preserves state and does not refresh as success'); + assert.ok(f.text().includes(t('backup.apiErrors.absolutePath'))); assert.ok(f.text().includes('/kept')); + f.fail('future backend message'); await f.add().props.onClick(); f.render(); assert.ok(f.text().includes('future backend message')); + f.fail(null); await f.add().props.onClick(); f.render(); assert.equal(f.input().props.value, ''); assert.equal(f.mutations(), 1); + } + // Adapter has no effect on the server's CLI/API/persistence contracts. + const host = read('components/host-backup.tsx'); + const ast = ts.createSourceFile('host-backup.tsx', host, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX), encryptionCatches = []; + function visit(node) { if (ts.isCatchClause(node) && node.block.getText(ast).includes('err.body?.tool_output')) encryptionCatches.push(node.block); ts.forEachChild(node, visit); } + visit(ast); assert.equal(encryptionCatches.length, 2, 'scheduled and manual backup encryption paths both checked'); + for (const locale of ['en', 'sk', 'missing']) for (const block of encryptionCatches) { + let displayed, effects = 0; + const code = ts.transpileModule('function run(e) '+block.getText(ast)+'; run(error);', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText; + const t = translator(locale), error = Object.assign(new Error('failed to create PBS encryption keyfile'), { body: { tool_output: 'EXTERNAL TOOL DIAGNOSTIC' } }); + vm.runInNewContext(code, { error, t, hostBackupErrorText, setError: value => { displayed = value; }, setPbsImportBusy: () => {}, setSubmitting: () => {}, onCreated: () => effects++ }); + assert.ok(displayed.includes(hostBackupErrorText(error, t)) && displayed.includes('EXTERNAL TOOL DIAGNOSTIC')); + assert.ok(!displayed.includes('{error}') && !displayed.includes('{detail}'), 'error interpolation must not leave an unresolved placeholder'); + assert.equal(effects, 0); + } + assert.ok(!host.includes('e instanceof Error ? e.message : String(e)'), 'all HostBackup catch consumers use the adapter'); + assert.ok(!host.includes('{e.error}') && !host.includes('{calendarPreview.error}'), 'structured error consumers use the adapter too'); + console.log('PASS: '+contracts.length+' real API contracts, HostBackup actual extra-path handlers, EN/SK/fallback, 0 calls for blank, rejected input/state, success, literal parameters and unknown diagnostics'); +})().catch(error => { console.error(error); process.exitCode = 1; });