i18n: localize host backup application errors

This commit is contained in:
VAIO73
2026-10-20 08:45:29 +00:00
parent 1dc9e92f74
commit aed77ecefe
6 changed files with 527 additions and 45 deletions
+1
View File
@@ -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",
+45 -44
View File
@@ -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() {
<div className="font-medium">{t("backup.archives.remoteQueryWarning")}</div>
{remoteArchivesResp.errors.map((e, i) => (
<div key={i} className="font-mono break-all">
{e.backend}/{e.repo_name}: {e.error}
{e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
</div>
))}
</div>
@@ -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<typeof instructions>
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({
<span className="text-muted-foreground">— {exportTask.message}</span>
</div>
{exportTask.state === "failed" && exportTask.error && (
<div className="text-red-500 mt-1">{exportTask.error}</div>
<div className="text-red-500 mt-1">{hostBackupErrorText(exportTask.error, t)}</div>
)}
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
<div className="text-emerald-400">{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}</div>
@@ -2706,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<span className={`font-mono ${color}`}>{c.id}</span>
<span className="text-muted-foreground ml-2">{c.message}</span>
<span className="text-muted-foreground ml-2">{hostBackupErrorText(c.message, t)}</span>
</div>
</div>
)
@@ -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({
</>
) : (
<div className="text-red-500">
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {calendarPreview.error}
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {hostBackupErrorText(calendarPreview.error, t)}
</div>
)
) : (
@@ -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 && (
<p className="mt-3 text-[11px] text-muted-foreground italic">
{t("backup.destinations.capacityUnavailable", { error: capacity.error })}
{t("backup.destinations.capacityUnavailable", { error: hostBackupErrorText(capacity.error, t) })}
</p>
)}
{!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 }) {
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">{t("backup.plan.blockers")}</div>
<ul className="space-y-0.5 ml-4 list-disc">
{blockers.map((b, i) => (
<li key={i} className="text-red-400">{typeof b === "string" ? b : (b.message || JSON.stringify(b))}</li>
<li key={i} className="text-red-400">{hostBackupErrorText(typeof b === "string" ? b : (b.message || JSON.stringify(b)), t)}</li>
))}
</ul>
</div>
@@ -8581,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">{t("backup.plan.warnings")}</div>
<ul className="space-y-0.5 ml-4 list-disc">
{warnings.map((w, i) => (
<li key={i} className="text-amber-400">{typeof w === "string" ? w : (w.message || JSON.stringify(w))}</li>
<li key={i} className="text-amber-400">{hostBackupErrorText(typeof w === "string" ? w : (w.message || JSON.stringify(w)), t)}</li>
))}
</ul>
</div>
@@ -8611,7 +8612,7 @@ function StorageSection({ storage }: { storage: any }) {
<div className="min-w-0 flex-1">
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
{e.message && <div className="text-muted-foreground text-[11px]">{e.message}</div>}
{e.message && <div className="text-muted-foreground text-[11px]">{hostBackupErrorText(e.message, t)}</div>}
</div>
</li>
)
+141
View File
@@ -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, string | number>) => string
const fixed = new Map<string, string>([
["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<readonly [RegExp, string]> = [
[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
}
+118
View File
@@ -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}",
+119 -1
View File
@@ -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}",
+103
View File
@@ -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: <img src=x onerror=unsafe()> 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 = '<img src=x onerror=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; });