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