Merge commit '1e0595aac2b2fc3a1510e2d356ef01120ef918b4' into HEAD

# Conflicts:
#	.github/scripts/run_offline_qualified.py
This commit is contained in:
MacRimi
2026-10-21 23:14:30 +02:00
79 changed files with 7120 additions and 1530 deletions
+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>
)
+12 -11
View File
@@ -37,6 +37,7 @@ import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } fr
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
import { getCategoryLabel } from "../lib/category-label"
import { useT } from "@/lib/i18n/provider"
import { localizeAppEditorError } from "../lib/lxc-app-editor-errors"
// installed_via is optional now — an empty value means "register only,
// no version tracking, no warnings, just a clickable link". Docker
@@ -728,7 +729,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
if (lower.startsWith("registry unreachable")) {
return t("vmLxc.appEditor.upstreamErrorRegistry")
}
return msg
return localizeAppEditorError(msg, t)
}
const editorOpen = !!editing && editing.targetVmid === vmid
@@ -1212,7 +1213,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
}
const removeOne = async (appId: string) => {
if (!confirm("Remove this application from the CT's App tab?")) return
if (!confirm(t("vmLxc.appEditor.confirmRemove"))) return
setBusyAppId(appId)
setError(null)
try {
@@ -2069,14 +2070,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
id="app-package"
value={draft.package || ""}
onChange={(e) => setField({ package: e.target.value })}
placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"}
placeholder={t("vmLxc.appEditor.packageExample", { package: method === "dpkg" ? "jellyfin-server" : "tailscale" })}
/>
<div className="text-[10px] text-muted-foreground mt-1">
Auto-filled from Name. Verify with{" "}
{t("vmLxc.appEditor.packageHelpBefore")}{" "}
<code className="text-foreground/70">
{method === "dpkg" ? "dpkg -l | grep <app>" : "apk info | grep <app>"}
</code>{" "}
inside the CT.
{t("vmLxc.appEditor.packageHelpAfter")}
</div>
</div>
)}
@@ -2092,8 +2093,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
className="font-mono text-xs"
/>
<div className="text-[10px] text-muted-foreground mt-1">
Absolute path. Find it with{" "}
<code className="text-foreground/70">which &lt;app&gt;</code> or{" "}
{t("vmLxc.appEditor.binaryHelpBefore")}{" "}
<code className="text-foreground/70">which &lt;app&gt;</code>{" "}{t("vmLxc.appEditor.binaryHelpOr")}{" "}
<code className="text-foreground/70">systemctl show &lt;service&gt; -p ExecStart</code>.
</div>
</div>
@@ -2558,7 +2559,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
{detectorTest.installed.version ? (
<div className="font-mono text-sm text-emerald-400">{detectorTest.installed.version}</div>
) : (
<div className="text-xs text-amber-300">{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.installed.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
)}
<div className="mt-1.5 text-[10px] text-muted-foreground break-all">
{detectorTest.installed.method || "—"}
@@ -2574,7 +2575,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
) : detectorTest.upstream.version ? (
<div className="font-mono text-sm text-emerald-400">{detectorTest.upstream.version}</div>
) : (
<div className="text-xs text-amber-300">{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.upstream.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
)}
{detectorTest.upstream.type && (
<div className="mt-1.5 text-[10px] text-muted-foreground">{detectorTest.upstream.type}</div>
@@ -2587,7 +2588,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
<span>{localizeAppEditorError(error, t)}</span>
</div>
)}
@@ -3343,7 +3344,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
<span>{localizeAppEditorError(error, t)}</span>
</div>
)}
</div>
+13 -9
View File
@@ -14,6 +14,7 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { fetchApi } from "../lib/api-config"
import { monitorLinkError } from "../lib/monitor-link-errors"
import { useT } from "../lib/i18n/provider"
interface MonitorLink {
@@ -185,8 +186,9 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
await refreshCache("/api/monitor-links")
setOfferOrigin(false)
} catch (err) {
await refreshCache("/api/monitor-links")
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
setAddError(monitorLinkError(err, t))
try { await refreshCache("/api/monitor-links") }
catch { /* SWR shows the load error; keep the localized mutation error. */ }
} finally { setAdding(false) }
}
const notNow = () => {
@@ -260,7 +262,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
</DropdownMenu>
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
<DialogContent className="grid-cols-1 max-w-md" onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
@@ -290,7 +292,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const t = useT()
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
const { data, mutate, error: loadError, isLoading } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
const [addresses, setAddresses] = useState<Record<string, string>>({})
const [editing, setEditing] = useState<string | null>(null)
@@ -304,14 +306,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
await mutate()
await refreshCache("/api/monitor-links")
} catch (err) {
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
setError(monitorLinkError(err, t))
} finally { setBusy(false) }
}
const send = (path: string, method: string, body?: unknown) =>
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
@@ -320,7 +322,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
<div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
<div className="space-y-1.5">
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
@@ -350,6 +352,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
<div className="space-y-3">
{isLoading && <p className="text-sm text-muted-foreground">{t("monitorLinks.checking")}</p>}
{loadError && <p role="alert" className="text-sm text-amber-500">{t("monitorLinks.loadFailed")}</p>}
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
{data?.links.map(link => {
const own = link.manual || link.custom_url
@@ -378,8 +382,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
</div>
: isEditing
? <div className="flex gap-2">
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
? <div className="grid grid-cols-2 gap-2 sm:flex">
<Input className="col-span-2 min-w-0 sm:flex-1" value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
<Button size="sm" className="h-10 bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
+8 -5
View File
@@ -4,7 +4,8 @@ import { CalendarClock, Database } from "lucide-react"
import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress"
import { formatStorage } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
import { useT, useI18n } from "../lib/i18n/provider"
import { PBSProblemMessage } from "./pbs-problem-message"
export interface PBSDatastore {
name: string
@@ -13,6 +14,7 @@ export interface PBSDatastore {
used?: number | null
avail?: number | null
error?: string
error_code?: string
"backend-type"?: string
"estimated-full-date"?: number
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
@@ -42,7 +44,7 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
<span className="font-medium text-foreground truncate">{store.name}</span>
<span className="text-xs text-muted-foreground">—</span>
</div>
<p className="text-xs text-amber-500">{store.error || t("pbs.capacityUnavailable")}</p>
<PBSProblemMessage className="text-xs text-amber-500" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
</div>
}
const tone = usageTone(percent)
@@ -62,10 +64,11 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
const t = useT()
const { language } = useI18n()
const percent = datastorePercent(store)
const tone = percent == null ? null : usageTone(percent)
const fullDate = store["estimated-full-date"]
const type = store["backend-type"] || "datastore"
const type = store["backend-type"] || t("pbs.datastoreType")
return <div className={`border rounded-lg p-4 ${store.error ? "border-red-500/50 bg-red-500/5" : ""}`}>
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
@@ -99,13 +102,13 @@ export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
<PBSProblemMessage className="text-sm text-muted-foreground" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
)}
{(store.path || fullDate !== undefined) && <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{store.path && <span className="font-mono truncate" title={store.path}>{store.path}</span>}
{fullDate !== undefined && <span className="flex items-center">
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />{t("pbs.estimatedFull")}:
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")}</span>
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString(language) : t("pbs.estimatedFullUnknown")}</span>
</span>}
</div>}
</div>
+58 -32
View File
@@ -14,8 +14,10 @@ import { Label } from "./ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
import { PBSProblemMessage } from "./pbs-problem-message"
import { pbsDiagnostic, pbsResultText, pbsServiceState, pbsTaskType, formatPBSDuration } from "../lib/pbs-messages"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { useT, useI18n } from "../lib/i18n/provider"
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
interface Detected { name: string; url: string; fingerprint: string }
@@ -26,6 +28,7 @@ interface PBSStatus {
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
datastores: PBSDatastore[] | null; tasks: Task[] | null;
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
error_details?: Record<string, { code: string; params?: Record<string, unknown> }>;
}
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "", username: "", password: "" }
@@ -46,9 +49,10 @@ const TASK_ICONS: Record<string, typeof Activity> = {
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
}
// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running.
// PBS reports "unknown", "OK", "WARNINGS: n" or an error; no state is still running.
function taskState(task: Task): keyof typeof badgeTone {
if (!task.status) return task.endtime ? "muted" : "running"
if (task.status === "unknown") return "muted"
if (task.status === "OK") return "ok"
if (task.status.startsWith("WARNINGS")) return "warn"
return "error"
@@ -56,27 +60,19 @@ function taskState(task: Task): keyof typeof badgeTone {
// PBS reports "OK", "WARNINGS: n" or the error text of the last run.
function jobState(state?: string): keyof typeof badgeTone {
if (!state) return "muted"
if (!state || state === "unknown") return "muted"
if (state.toLowerCase() === "ok") return "ok"
if (state.toLowerCase().startsWith("warn")) return "warn"
return "error"
}
function formatDuration(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "—"
if (seconds < 60) return `${Math.round(seconds)}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`
const hours = Math.floor(minutes / 60)
return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
}
// `localOnly` is the PBS host's own Monitor: no connection picker, the
// header Refresh already reloads the page data. Its tabs show one `view` each.
export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: boolean; view?: "all" | "datastores" | "tasks" } = {}) {
const showStores = view !== "tasks"
const showTasks = view !== "datastores"
const t = useT()
const { language } = useI18n()
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
const [selected, setSelected] = useState("")
@@ -85,7 +81,8 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
const [method, setMethod] = useState<Method>("password")
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("")
const [error, setError] = useState("")
const [error, setError] = useState<unknown>(null)
const [errorFallback, setErrorFallback] = useState("pbs.saveFailed")
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
@@ -102,7 +99,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
}
const save = async () => {
setErrorFallback("pbs.saveFailed")
setBusy(true); setError(""); setMessage("")
let saved = false
try {
// A password is sent once to create a read-only token; only the token is kept.
const { username, password, ...connection } = form
@@ -113,22 +112,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
: await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
...connection, id: form.id || undefined, token_secret: form.token_secret || undefined,
}) })
saved = true
await reloadConnections()
setSelected(result.id)
setForm(emptyForm)
setManage(false)
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
} catch (err) { setError(saved ? { body: { code: "pbs_saved_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
finally { setBusy(false) }
}
const remove = async () => {
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
setErrorFallback("pbs.removeFailed")
setBusy(true); setError(""); setMessage("")
let removed = false
try {
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
removed = true
setForm(emptyForm)
await reloadConnections()
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
} catch (err) { setError(removed ? { body: { code: "pbs_removed_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
finally { setBusy(false) }
}
const now = Date.now() / 1000
@@ -150,8 +153,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
</Button>
</div>}
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
{(connectionError || statusError) && <div role="alert" className="text-sm text-red-500">
<PBSProblemMessage problem={connectionError || statusError} raw={pbsDiagnostic(connectionError || statusError)} />
</div>}
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 && <Card className="bg-card border-border">
@@ -171,7 +175,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
</CardContent>
</Card>}
{!localOnly && <Dialog open={manage} onOpenChange={open => { if (!busy) setManage(open) }}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
<DialogHeader>
<DialogTitle>{t("pbs.manage")}</DialogTitle>
<DialogDescription>{method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")}</DialogDescription>
@@ -201,19 +205,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
</Button>)}
</div>
</div>}
<div className="grid gap-4 md:grid-cols-2">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => {
const secret = field === "token_secret" || field === "password"
return <div key={field} className="space-y-2">
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
const help = field === "url" ? "pbs.urlHelp" : field === "username" ? "pbs.usernameHelp"
: field === "token_id" ? "pbs.tokenIdHelp" : field === "token_secret"
? form.id ? "pbs.tokenSecretKeepHelp" : "pbs.tokenSecretHelp" : undefined
return <div key={field} className="min-w-0 space-y-2">
<Label htmlFor={`pbs-${field}`} className="block leading-5">{t(`pbs.${field}`)}</Label>
<Input id={`pbs-${field}`} disabled={busy} type={secret ? "password" : field === "url" ? "url" : "text"}
aria-describedby={help ? `pbs-${field}-help` : undefined}
autoComplete={secret ? "new-password" : "off"} placeholder={field === "username" ? "root@pam" : undefined}
value={form[field as keyof typeof form]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
{help && <p id={`pbs-${field}-help`} className="text-xs text-muted-foreground">{t(help)}</p>}
</div>
})}
</div>
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
{!!error && <div role="alert" className="text-sm text-red-500">
<PBSProblemMessage problem={error} raw={pbsDiagnostic(error)} fallback={errorFallback} />
</div>}
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
<div className="flex flex-wrap justify-end gap-2">
{form.id && <Button variant="outline" disabled={busy} className="mr-auto text-red-500 hover:text-red-500" onClick={remove}>
@@ -230,14 +241,17 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
</Dialog>}
{data && <>
{!localOnly && <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span>
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString(language) })}</span>
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)}
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {pbsServiceState(state, t)}</Badge>)}
</div>}
{Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks)
.map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
.map(([section, detail]) => <div key={section} role="alert" className="text-sm text-amber-500">
<p className="font-medium">{t(`pbs.${["datastores", "tasks", "sync", "verify", "prune"].includes(section) ? section : "title"}`)}</p>
<PBSProblemMessage problem={data.error_details?.[section]} raw={detail} />
</div>)}
{showStores && <Card className="bg-card border-border">
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
@@ -268,14 +282,14 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
return <tr key={row.upid} className="border-b border-border/60 last:border-0">
<td className="py-2 pr-3"><span className="flex items-center gap-2">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-foreground">{row.worker_type}</span>
<span className="text-foreground" title={row.worker_type}>{pbsTaskType(row.worker_type, t)}</span>
{row.worker_id && <span className="truncate text-muted-foreground">{row.worker_id}</span>}
</span></td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatDuration(end - row.starttime) : "—"}</td>
<td className="py-2"><Badge variant="outline" className={`${badgeTone[state]} max-w-[16rem] truncate`} title={row.status}>
{state === "running" ? t("pbs.running") : row.status || "—"}
</Badge></td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString(language) : "—"}</td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatPBSDuration(end - row.starttime, t) : "—"}</td>
<td className="py-2">
<PBSResultBadge status={row.status} running={state === "running"} tone={badgeTone[state]} />
</td>
</tr>
})}</tbody>
</table></div>}
@@ -299,7 +313,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
<td className="py-2 pr-3"><span className="text-foreground">{t(`pbs.${kind}`)}</span> <span className="text-muted-foreground">· {row.id}</span></td>
<td className="py-2 pr-3 text-muted-foreground">{row.store || "—"}</td>
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{row.schedule || "—"}</td>
<td className="py-2"><Badge variant="outline" className={badgeTone[jobState(row["last-run-state"])]}>{row["last-run-state"] || "—"}</Badge></td>
<td className="py-2"><PBSResultBadge status={row["last-run-state"]} tone={badgeTone[jobState(row["last-run-state"])]} /></td>
</tr>)}</tbody>
</table></div>
})()}
@@ -309,3 +323,15 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
</>}
</div>
}
function PBSResultBadge({ status, running = false, tone }: { status?: string; running?: boolean; tone: string }) {
const t = useT()
const badge = <Badge variant="outline" className={`${tone} max-w-[16rem] truncate`}>
{running ? t("pbs.running") : pbsResultText(status, t)}
</Badge>
if (running || !status || status === "unknown" || /^ok$/i.test(status) || /^WARNINGS:\s*\d+\s*$/i.test(status)) return badge
return <details className="max-w-xs">
<summary className="cursor-pointer" aria-label={t("pbs.technicalDetails")}>{badge}</summary>
<pre className="mt-1 whitespace-pre-wrap break-words text-xs text-muted-foreground">{status}</pre>
</details>
}
@@ -0,0 +1,19 @@
"use client"
import { pbsErrorText, pbsIsKnownError } from "../lib/pbs-messages"
import { useT } from "../lib/i18n/provider"
// Known application messages are already explained in the active language.
// Preserve unrecognized external diagnostics as escaped technical details.
export function PBSProblemMessage({ problem, raw, fallback, className = "" }: {
problem?: unknown; raw?: string; fallback?: string; className?: string
}) {
const t = useT()
return <div className={`min-w-0 space-y-1 ${className}`}>
<p>{pbsErrorText(problem, t, fallback)}</p>
{raw && !pbsIsKnownError(problem) && <details className="text-xs">
<summary className="cursor-pointer">{t("pbs.technicalDetails")}</summary>
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{raw}</pre>
</details>}
</div>
}
+22 -10
View File
@@ -31,6 +31,7 @@ import {
import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { scriptCompletion, scriptCompletionOutput } from "@/lib/script-completion"
import { useT } from "../lib/i18n/provider"
interface WebInteraction {
@@ -52,6 +53,7 @@ interface ScriptTerminalModalProps {
params?: Record<string, string>
completedSuccessfullyMessage?: string
completedWithErrorMessage?: (exitCode: number) => string
cancelledExitCode?: number
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
@@ -69,6 +71,7 @@ export function ScriptTerminalModal({
params = { EXECUTION_MODE: "web" },
completedSuccessfullyMessage,
completedWithErrorMessage,
cancelledExitCode,
onComplete,
}: ScriptTerminalModalProps) {
const t = useT()
@@ -103,10 +106,12 @@ export function ScriptTerminalModal({
const getCompletionMessage = useCallback(
(exitCode: number) =>
exitCode === 0
scriptCompletion(exitCode, cancelledExitCode).kind === "cancelled"
? t("scriptTerminal.cancelled")
: exitCode === 0
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
[completedSuccessfullyMessage, completedWithErrorMessage, t],
[completedSuccessfullyMessage, completedWithErrorMessage, cancelledExitCode, t],
)
const terminalContainerRef = useRef<HTMLDivElement>(null)
@@ -139,6 +144,11 @@ export function ScriptTerminalModal({
reconnectTimeoutRef.current = setTimeout(async () => {
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
if (wsRef.current) {
// Replacing a stale socket is not a script result. Its close event
// must not complete the operation before the new socket reports it.
wsRef.current.onclose = null
wsRef.current.onerror = null
wsRef.current.onmessage = null
wsRef.current.close()
}
@@ -176,6 +186,7 @@ const initMessage = {
}
ws.onmessage = (event) => {
if (completionReceivedRef.current) return
// Filter out pong responses from heartbeat
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
@@ -189,10 +200,10 @@ const initMessage = {
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
termRef.current?.write(event.data)
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") termRef.current?.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -223,7 +234,7 @@ const initMessage = {
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -261,7 +272,7 @@ const initMessage = {
}
}
}, 1000)
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t])
const sendKey = useCallback((key: string) => {
if (!termRef.current) return
@@ -392,6 +403,7 @@ const initMessage = {
}
ws.onmessage = (event) => {
if (completionReceivedRef.current) return
// Filter out pong responses from heartbeat - don't display in terminal
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
@@ -405,10 +417,10 @@ const initMessage = {
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
term.write(event.data)
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") term.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -441,7 +453,7 @@ const initMessage = {
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -814,7 +826,7 @@ const initMessage = {
)}
{(isMobile || isTablet) && (
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex flex-wrap items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button
onPointerDown={(e) => {
e.preventDefault()
+20 -6
View File
@@ -7,6 +7,8 @@ import { Progress } from "./ui/progress"
import { Badge } from "./ui/badge"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
import { PBSProblemMessage } from "./pbs-problem-message"
import { pbsDiagnostic } from "../lib/pbs-messages"
import { NodeMetricsCharts } from "./node-metrics-charts"
import { NetworkTrafficChart } from "./network-traffic-chart"
import { TemperatureDetailModal } from "./temperature-detail-modal"
@@ -187,14 +189,20 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
}
interface PBSOverviewStatus {
datastores: PBSDatastore[] | null
errors?: Record<string, string>
error_details?: Record<string, { code: string }>
}
export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) {
const t = useT()
const { language } = useI18n()
const isPbs = product === "pbs"
// On Proxmox Backup Server the datastores take the place of guests and PVE storage.
const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>(
const { data: pbsStatus, error: pbsError } = useSWR<PBSOverviewStatus>(
isPbs ? "/api/pbs/status?server=local" : null,
(url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url),
(url: string) => fetchApi<PBSOverviewStatus>(url),
{ refreshInterval: 60000, revalidateOnFocus: false },
)
const datastores = pbsStatus?.datastores ?? null
@@ -545,13 +553,15 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{!datastores && !pbsError ? (
{!pbsStatus && !pbsError ? (
<div className="space-y-2 animate-pulse">
<div className="h-10 bg-muted rounded w-20"></div>
<div className="h-5 bg-muted rounded w-32"></div>
</div>
) : !datastores ? (
<p className="text-sm text-amber-500">{t("pbs.loadFailed")}</p>
<div role="alert" className="text-sm text-amber-500">
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
</div>
) : datastores.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
) : (
@@ -762,8 +772,12 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
{isPbs ? (
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground mb-2">{t("pbs.datastores")}</div>
{!datastores ? (
<div className="text-center py-4 text-muted-foreground text-sm">{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}</div>
{!pbsStatus && !pbsError ? (
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.loading")}</div>
) : !datastores ? (
<div role="alert" className="py-4 text-amber-500 text-sm">
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
</div>
) : datastores.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.noDatastores")}</div>
) : datastores.map(store => <DatastoreUsageRow key={store.name} store={store} />)}
+11 -4
View File
@@ -22,6 +22,7 @@ import useSWR from "swr"
import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { OCI_CANCELLED_EXIT_CODE, shouldRefreshOciAfterCompletion } from "@/lib/script-completion"
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
import { GuestBackupList } from "./guest-backup-list"
import type { GuestBackup } from "../lib/guest-backup-archives"
@@ -977,7 +978,9 @@ export function VirtualMachines() {
// A container restored from a backup carries the mark of its installation
// and has no record on this host until it is recovered from the menu.
const [ociRestored, setOciRestored] = useState(false)
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover" } | null>(null)
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover"; recoverPrevious?: boolean } | null>(null)
const ociCompletionCodeRef = useRef<number | undefined>(undefined)
useEffect(() => { ociCompletionCodeRef.current = undefined }, [ociAction])
// Firewall log state — fetched only when the operator opens that tab
// so a CT/VM without firewall use doesn't pay the pvesh cost on every
@@ -5515,7 +5518,7 @@ export function VirtualMachines() {
<Button
size="sm"
className={updateBtnCls}
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update" })}
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update", recoverPrevious: !!ociInstance.pending })}
>
{hasUpdate
? <ArrowUpCircle className="h-4 w-4 mr-1.5" />
@@ -5744,7 +5747,7 @@ export function VirtualMachines() {
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "recover" })}
>
<RotateCcw className="h-4 w-4 mr-1.5" />
{t("vmLxc.ociUpdates.recover")}
{t("vmLxc.ociUpdates.registerRestored")}
</Button>
</div>
)}
@@ -8014,6 +8017,7 @@ export function VirtualMachines() {
onClose={() => {
const vmid = ociAction.vmid
setOciAction(null)
if (!shouldRefreshOciAfterCompletion(ociCompletionCodeRef.current)) return
// The image, the digest and the record changed: read them again.
fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
.catch(() => undefined)
@@ -8027,7 +8031,10 @@ export function VirtualMachines() {
}}
scriptPath="/usr/local/share/proxmenux/scripts/oci/manage_instance.sh"
scriptName="oci_manage_instance"
title={ociAction.action === "update" ? t("vmLxc.ociUpdates.terminalTitleUpdate")
cancelledExitCode={OCI_CANCELLED_EXIT_CODE}
onComplete={(exitCode) => { ociCompletionCodeRef.current = exitCode }}
title={ociAction.action === "update" ? t(ociAction.recoverPrevious
? "vmLxc.ociUpdates.terminalTitleRecoverPrevious" : "vmLxc.ociUpdates.terminalTitleUpdate")
: ociAction.action === "recover" ? t("vmLxc.ociUpdates.terminalTitleRecover")
: t("vmLxc.ociUpdates.terminalTitleModify")}
description={t("vmLxc.ociUpdates.terminalDescription")}
+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
}
+130
View File
@@ -0,0 +1,130 @@
type Translate = (key: string, params?: Record<string, string | number>) => string
// Exact lxc_apps validation/persistence messages; never change their API contract.
// Anchored patterns leave unknown diagnostics intact, including paths and commands.
const RULES: [RegExp, string, string[]][] = [
[/^an OCI operation on this container is running$/, "ociBusy", []],
[/^([\s\S]+) is not installed via dpkg$/, "dpkgMissing", ["package"]],
[/^dpkg\-query failed$/, "dpkgFailed", []],
[/^no version matched in dpkg output$/, "dpkgNoVersion", []],
[/^apk info failed$/, "apkFailed", []],
[/^no version matched in apk output$/, "apkNoVersion", []],
[/^could not read ([\s\S]+)$/, "fileRead", ["path"]],
[/^file_regex did not match ([\s\S]+)$/, "fileNoMatch", ["path"]],
[/^binary invocation failed$/, "binaryFailed", []],
[/^no version matched in binary output$/, "binaryNoVersion", []],
[/^python \-c importlib\.metadata failed$/, "pythonFailed", []],
[/^no version matched in Python distribution output$/, "pythonNoVersion", []],
[/^could not resolve the container's installed image$/, "dockerImageResolve", []],
[/^docker inspect failed$/, "dockerInspectFailed", []],
[/^container has no ([\s\S]+) label$/, "dockerLabelMissing", ["label"]],
[/^docker label reports ([\s\S]+) \(mutable tag, not a version\)$/, "dockerLabelMutable", ["tag"]],
[/^no version matched in docker label output$/, "dockerLabelNoVersion", []],
[/^docker exec failed$/, "dockerExecFailed", []],
[/^no version matched in docker exec output$/, "dockerExecNoVersion", []],
[/^command_argv is empty$/, "commandEmpty", []],
[/^command failed$/, "commandFailed", []],
[/^no version matched in command output$/, "commandNoVersion", []],
[/^unsupported method: ([\s\S]+)$/, "unsupportedMethod", ["method"]],
[/^unknown upstream_type: ([\s\S]+)$/, "unknownUpstream", ["type"]],
[/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+): not found$/, "githubNotFound", ["repo"]],
[/^github rejected the request \(403\)$/, "githubRejected", []],
[/^github error (\d+)$/, "githubError", ["code"]],
[/^no tag \/ release name in response$/, "noRelease", []],
[/^tag_regex did not match '([\s\S]+)'$/, "tagNoMatch", ["tag"]],
[/^http error (\d+)$/, "httpError", ["code"]],
[/^invalid JSON response: ([\s\S]+)$/, "invalidJson", ["detail"]],
[/^json_path '([\s\S]+)' did not resolve$/, "jsonPathUnresolved", ["path"]],
[/^empty value at json_path$/, "jsonEmpty", []],
[/^no tags matched \(docker_hub\)$/, "dockerNoTags", []],
[/^tag_regex matched only moving tags; use Docker image digest tracking$/, "dockerMovingOnly", []],
[/^docker hub error (\d+)$/, "dockerHubError", ["code"]],
[/^invalid docker hub JSON: ([\s\S]+)$/, "dockerHubJson", ["detail"]],
[/^no tags returned by docker hub$/, "dockerHubEmpty", []],
[/^payload must be a JSON object$/, "payload", []],
[/^name is required and must be 1\-64 chars of letters\/digits\/spaces\/\._\+\-\(\)\/$/, "name", []],
[/^installed_via must be one of: ([\s\S]+) or empty$/, "method", ["values"]],
[/^package is required \(letters\/digits\/\._\+@:\/ up to 127 chars\)$/, "package", []],
[/^file_path is required and must be an absolute path$/, "filePath", []],
[/^file_regex is required$/, "fileRegex", []],
[/^file_regex is not a valid regex: ([\s\S]+)$/, "fileRegexInvalid", ["detail"]],
[/^binary_path is required and must be an absolute path$/, "binaryPath", []],
[/^python_path is required and must be an absolute path$/, "pythonPath", []],
[/^distribution is required \(PEP 503 name\)$/, "distribution", []],
[/^container_name is required \(docker naming rules\)$/, "container", []],
[/^label is required \(OCI label naming rules\)$/, "label", []],
[/^binary_path is required \(absolute path or bare command\)$/, "dockerBinary", []],
[/^installed_version is required for the manual method$/, "manualVersion", []],
[/^installed_version exceeds (\d+) chars$/, "versionLength", ["max"]],
[/^installed_version contains a forbidden control character$/, "versionControl", []],
[/^installed_regex is not a valid regex: ([\s\S]+)$/, "installedRegexInvalid", ["detail"]],
[/^upstream_type must be one of: ([\s\S]+)$/, "upstreamType", ["values"]],
[/^tag_regex is not a valid regex: ([\s\S]+)$/, "tagRegexInvalid", ["detail"]],
[/^repo is required for upstream_type=github$/, "repoRequired", []],
[/^repo must be 'owner\/name'$/, "repoFormat", []],
[/^github_source must be one of: ([\s\S]+)$/, "githubSource", ["values"]],
[/^upstream_url is required for upstream_type=http_json$/, "upstreamUrlRequired", []],
[/^upstream_url must be an http\(s\) URL$/, "upstreamUrl", []],
[/^upstream_url exceeds (\d+) chars$/, "upstreamUrlLength", ["max"]],
[/^upstream_json_path is required for upstream_type=http_json$/, "jsonPathRequired", []],
[/^upstream_json_path exceeds (\d+) chars$/, "jsonPathLength", ["max"]],
[/^upstream_json_path uses forbidden characters$/, "jsonPathCharacters", []],
[/^docker_image is required for upstream_type=docker_hub$/, "dockerImageRequired", []],
[/^docker_image exceeds (\d+) chars$/, "dockerImageLength", ["max"]],
[/^docker_image must match Docker Hub naming \(owner\/name or name\)$/, "dockerImageFormat", []],
[/^health_path must be a valid URL path \(max 255 chars\)$/, "healthPath", []],
[/^logo_url must be an http\(s\) URL or this VM's cached local icon$/, "logoUrl", []],
[/^invalid discovery_id$/, "discoveryId", []],
[/^helper_slug must be a lowercase slug \(letters\/digits\/\._\-\)$/, "helperSlug", []],
[/^update_via only accepts 'docker'$/, "updateVia", []],
[/^update_via=docker requires a docker_label or docker_exec detector$/, "dockerDetector", []],
[/^update_via=docker cannot be combined with ([\s\S]+)$/, "dockerConflict", ["field"]],
[/^update_command must be a string$/, "updateCommandType", []],
[/^update_command exceeds (\d+) chars$/, "updateCommandLength", ["max"]],
[/^update_command contains a null byte$/, "updateCommandNull", []],
[/^update_method must be none, helper or custom$/, "updateMethod", []],
[/^update_command is required for update_method=custom$/, "updateCommandRequired", []],
[/^update_command is only allowed for update_method=custom$/, "updateCommandOnlyCustom", []],
[/^a supported helper_slug is required for update_method=helper$/, "helperRequired", []],
[/^Docker\-delegated apps update through their image, not a separate app updater$/, "dockerUpdater", []],
[/^command_argv is required \(non\-empty list\)$/, "commandRequired", []],
[/^command_argv must be a non\-empty list of strings$/, "commandList", []],
[/^command_argv accepts at most (\d+) entries$/, "commandCount", ["max"]],
[/^command_argv\[(\d+)\] must be a non\-empty string$/, "commandEntry", ["index"]],
[/^command_argv\[(\d+)\] exceeds (\d+) chars$/, "commandEntryLength", ["index", "max"]],
[/^command_argv\[(\d+)\] contains a forbidden control character$/, "commandEntryControl", ["index"]],
[/^binary_args must be a list of strings$/, "binaryArgsList", []],
[/^binary_args accepts at most (\d+) entries$/, "binaryArgsCount", ["max"]],
[/^binary_args\[(\d+)\] must be a non\-empty string$/, "binaryArgEntry", ["index"]],
[/^binary_args\[(\d+)\] exceeds (\d+) chars$/, "binaryArgLength", ["index", "max"]],
[/^binary_args\[(\d+)\] contains a forbidden control character$/, "binaryArgControl", ["index"]],
[/^ports must be a list of \{port, description, scheme\}$/, "portsList", []],
[/^ports\[(\d+)\] must be an object$/, "portObject", ["index"]],
[/^ports\[(\d+)\]\.port is required$/, "portRequired", ["index"]],
[/^ports\[(\d+)\]\.port must be an integer$/, "portInteger", ["index"]],
[/^ports\[(\d+)\]\.port must be 1\-65535$/, "portRange", ["index"]],
[/^port (\d+) appears more than once for this app$/, "portDuplicate", ["port"]],
[/^ports\[(\d+)\]\.description has invalid characters$/, "portDescription", ["index"]],
[/^ports\[(\d+)\]\.scheme must be 'http' or 'https'$/, "portScheme", ["index"]],
[/^ports\[(\d+)\]\.web_path must be a valid URL path \(max 255 chars\)$/, "portPath", ["index"]],
[/^ports\[(\d+)\]\.logo_url must be an http\(s\) URL \(max 512 chars\)$/, "portLogo", ["index"]],
[/^ports\[(\d+)\]\.category has invalid characters or is too long$/, "portCategory", ["index"]],
[/^ports\[(\d+)\]\.custom_url must be an http\(s\) URL \(max 512 chars\)$/, "portCustomUrl", ["index"]],
[/^tag_regex exceeds 512 chars$/, "tagRegexLength", []],
[/^could not persist sidecar \(permission\?\)$/, "persistPermission", []],
[/^could not persist sidecar$/, "persist", []],
[/^no apps registered for this vmid$/, "noApps", []],
[/^app_id '([\s\S]+)' not found$/, "appMissing", ["appId"]],
[/^app not found$/, "appNotFound", []],
]
export function localizeAppEditorError(message: string | null | undefined, t: Translate): string {
if (!message) return ""
for (const [pattern, key, names] of RULES) {
const match = pattern.exec(message)
if (!match) continue
const params = Object.fromEntries(names.map((name, index) => [name, match[index + 1]]))
return t(`vmLxc.appEditor.errors.${key}`, params)
}
return message
}
+29
View File
@@ -0,0 +1,29 @@
type Translate = (key: string, params?: Record<string, string | number>) => string
const ERROR_KEYS: Record<string, string> = {
monitor_link_address_required: "addressRequired",
monitor_link_invalid_address: "invalidAddress",
monitor_link_address_credentials: "addressCredentials",
monitor_link_invalid_port: "invalidPort",
monitor_link_invalid_request: "invalidRequest",
monitor_link_duplicate: "duplicate",
monitor_link_not_found: "notFound",
monitor_link_detected_remove: "detectedRemove",
}
// fetchApi preserves the structured response on Error.body. Never expose raw
// server/network messages or treat arbitrary error codes as translation keys.
export function monitorLinkError(error: unknown, t: Translate): string {
const fallback = () => t("monitorLinks.saveFailed")
if (!error || typeof error !== "object" || !("body" in error)) return fallback()
const body = error.body
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return fallback()
if (body.code === "monitor_link_limit") {
const params = "params" in body ? body.params : null
const count = params && typeof params === "object" && "count" in params ? params.count : null
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
? t("monitorLinks.errors.limit", { count }) : fallback()
}
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code)
? t(`monitorLinks.errors.${ERROR_KEYS[body.code]}`) : fallback()
}
+85
View File
@@ -0,0 +1,85 @@
type Translate = (key: string, params?: Record<string, string | number>) => string
const ERROR_KEYS: Record<string, string> = {
pbs_config_read: "pbs.errors.configRead", pbs_invalid_config: "pbs.errors.invalidConfig",
pbs_invalid_field: "pbs.errors.invalidField", pbs_invalid_url: "pbs.errors.invalidUrl",
pbs_name_required: "pbs.errors.nameRequired", pbs_token_id_format: "pbs.errors.tokenIdFormat",
pbs_token_secret: "pbs.errors.tokenSecret", pbs_invalid_token_id: "pbs.errors.invalidTokenId",
pbs_invalid_fingerprint: "pbs.errors.invalidFingerprint", pbs_fingerprint_mismatch: "pbs.errors.fingerprintMismatch",
pbs_token_rejected: "pbs.errors.tokenRejected", pbs_invalid_response: "pbs.errors.invalidResponse",
pbs_invalid_json: "pbs.errors.invalidJson", pbs_connect_failed: "pbs.errors.connectFailed",
pbs_invalid_id: "pbs.errors.invalidId", pbs_invalid_datastores: "pbs.errors.invalidDatastores",
pbs_username: "pbs.errors.username", pbs_password: "pbs.errors.password",
pbs_login_rejected: "pbs.errors.loginRejected", pbs_invalid_login: "pbs.errors.invalidLogin",
pbs_token_create_denied: "pbs.errors.tokenCreateDenied", pbs_invalid_token: "pbs.errors.invalidToken",
pbs_audit_role_denied: "pbs.errors.auditRoleDenied", pbs_not_found: "pbs.errors.notFound",
pbs_read_failed: "pbs.errors.readFailed", pbs_invalid_list: "pbs.errors.invalidList",
pbs_local_unavailable: "pbs.errors.localUnavailable", pbs_auth_required: "pbs.errors.authRequired",
pbs_save_failed: "pbs.saveFailed", pbs_remove_failed: "pbs.removeFailed",
pbs_saved_refresh_failed: "pbs.savedRefreshFailed", pbs_removed_refresh_failed: "pbs.removedRefreshFailed",
pbs_datastore_offline: "pbs.errors.datastoreOffline", pbs_datastore_unavailable: "pbs.errors.datastoreUnavailable",
}
// Accept structured codes, never arbitrary error text as a translation key.
function pbsErrorTranslation(error: unknown): { key: string; params?: { count: number } } | null {
const body = error && typeof error === "object" && "body" in error ? error.body : error
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return null
if (body.code === "pbs_connection_limit") {
const params = "params" in body ? body.params : null
const count = params && typeof params === "object" && "count" in params ? params.count : null
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
? { key: "pbs.errors.connectionLimit", params: { count } } : null
}
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code) ? { key: ERROR_KEYS[body.code] } : null
}
export function pbsIsKnownError(error: unknown): boolean {
return pbsErrorTranslation(error) !== null
}
export function pbsErrorText(error: unknown, t: Translate, fallback = "pbs.loadFailed"): string {
const translated = pbsErrorTranslation(error)
return translated ? t(translated.key, translated.params) : t(fallback)
}
export function pbsDiagnostic(error: unknown): string | undefined {
if (!error || typeof error !== "object") return undefined
const body = "body" in error ? error.body : null
if (body && typeof body === "object" && "error" in body && typeof body.error === "string") return body.error
return "message" in error && typeof error.message === "string" ? error.message : undefined
}
const TASK_TYPES: Record<string, string> = {
backup: "backup", reader: "restore", verify: "verify", verificationjob: "verify",
verify_group: "verifyGroup", verify_snapshot: "verifySnapshot", prune: "prune", prunejob: "prune",
garbage_collection: "garbageCollection", sync: "sync", syncjob: "sync", aptupdate: "updateCheck",
termproxy: "terminal", dircreate: "createDatastore", "create-datastore": "createDatastore",
}
export function pbsTaskType(type: string | undefined, t: Translate): string {
return type && Object.prototype.hasOwnProperty.call(TASK_TYPES, type)
? t(`pbs.taskTypes.${TASK_TYPES[type]}`) : t("pbs.taskTypes.other", { type: type || "—" })
}
export function pbsResultText(status: string | undefined, t: Translate): string {
if (!status || status === "unknown") return t("pbs.states.unknown")
if (status.toLowerCase() === "ok") return t("pbs.states.ok")
const warnings = /^WARNINGS:\s*(\d+)\s*$/i.exec(status)
if (warnings && Number.isSafeInteger(Number(warnings[1]))) return t("pbs.states.warnings", { count: Number(warnings[1]) })
return /^warn/i.test(status) ? t("pbs.states.warning") : t("pbs.states.error")
}
export function pbsServiceState(state: string, t: Translate): string {
const known = ["active", "inactive", "failed", "activating", "deactivating", "unknown"]
return t(`pbs.serviceStates.${known.includes(state) ? state : "unknown"}`)
}
export function formatPBSDuration(seconds: number, t: Translate): string {
if (!Number.isFinite(seconds) || seconds < 0) return "—"
const unit = (key: string, count: number) => t(`pbs.durationUnits.${key}`, { count })
if (seconds < 60) return unit("seconds", Math.round(seconds))
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${unit("minutes", minutes)} ${unit("seconds", Math.round(seconds % 60))}`
const hours = Math.floor(minutes / 60)
return hours < 24 ? `${unit("hours", hours)} ${unit("minutes", minutes % 60)}`
: `${unit("days", Math.floor(hours / 24))} ${unit("hours", hours % 24)}`
}
+22
View File
@@ -0,0 +1,22 @@
// Native OCI `manage` reserves this code for explicit user cancellation.
export const OCI_CANCELLED_EXIT_CODE = 6
export function scriptCompletion(exitCode: number, cancelledExitCode?: number) {
if (exitCode === 0) return { kind: "success", color: "32" } as const
if (cancelledExitCode !== undefined && exitCode === cancelledExitCode) {
return { kind: "cancelled", color: "36" } as const
}
return { kind: "error", color: "31" } as const
}
export function shouldRefreshOciAfterCompletion(exitCode?: number) {
return exitCode !== OCI_CANCELLED_EXIT_CODE
}
export function scriptCompletionOutput(exitCode: number, message: string, cancelledExitCode?: number) {
const completion = scriptCompletion(exitCode, cancelledExitCode)
// Queue RIS with the result so earlier ncurses writes finish before reset.
// A synchronous terminal.reset() could be overwritten by that pending output.
const reset = completion.kind === "cancelled" ? "\x1bc" : ""
return `${reset}\x1b[${completion.color}m${message}\x1b[0m`
}
+315 -9
View File
@@ -14,7 +14,7 @@
"signInWith": "Sign in with",
"methodPassword": "User and password",
"methodToken": "API token",
"username": "User (user@realm)",
"username": "User",
"password": "Password",
"passwordHelp": "The password is used once to create a read-only API token (Audit role) for this Monitor. Only that token is saved, never the password. Enable Monitor authentication first.",
"cancel": "Cancel",
@@ -27,10 +27,15 @@
"editConnection": "Connection to edit",
"add": "Add a new connection",
"name": "Name",
"url": "HTTPS URL (include :8007 for the native PBS port)",
"token_id": "API token ID (user@realm!token)",
"token_secret": "API token secret (leave empty to keep the stored secret)",
"fingerprint": "SHA256 certificate fingerprint (optional)",
"url": "HTTPS address",
"urlHelp": "Include :8007 for the default PBS port, for example https://pbs.example:8007.",
"usernameHelp": "Use user@realm, for example root@pam.",
"tokenIdHelp": "Use user@realm!token, for example monitor@pbs!proxmenux.",
"tokenSecretHelp": "Enter the secret of this API token.",
"tokenSecretKeepHelp": "Leave empty to keep the saved token secret.",
"token_id": "API token ID",
"token_secret": "API token secret",
"fingerprint": "SHA256 fingerprint (optional)",
"tlsHelp": "Leave the fingerprint empty for normal CA and hostname verification. For a self-signed certificate, copy its SHA256 fingerprint from the PBS console or another trusted channel. The pin is checked before sending credentials.",
"saved": "PBS connection verified and saved.",
"saveFailed": "Could not save the PBS connection.",
@@ -72,7 +77,55 @@
"estimatedFull": "Estimated full",
"estimatedFullUnknown": "Not enough data yet",
"duration": "Duration",
"noJobs": "No sync, verification or prune schedules are configured."
"noJobs": "No sync, verification or prune schedules are configured.",
"removeFailed": "Could not remove the PBS connection.",
"savedRefreshFailed": "The PBS connection was saved, but the connection list could not be refreshed. Reload the page before trying another change.",
"removedRefreshFailed": "The PBS connection was removed, but the connection list could not be refreshed. Reload the page.",
"technicalDetails": "Technical details",
"datastoreType": "Datastore",
"errors": {
"configRead": "Could not read the saved PBS connections. Check the configuration file and permissions.",
"invalidConfig": "The PBS connection settings are not valid. Check the form and try again.",
"invalidField": "A connection field is not valid or is too long.",
"invalidUrl": "Use an HTTPS PBS address without embedded credentials, query parameters or a fragment.",
"nameRequired": "Enter a server name of at most 128 characters.",
"tokenIdFormat": "Enter the PBS API token ID in user@realm!token format.",
"tokenSecret": "Enter a valid API token secret without spaces or control characters.",
"invalidTokenId": "The API token ID must contain only ASCII characters.",
"invalidFingerprint": "Enter the certificate's SHA256 fingerprint: 64 hexadecimal characters, optionally separated by colons.",
"fingerprintMismatch": "The PBS certificate does not match the fingerprint. No credentials were sent.",
"tokenRejected": "PBS rejected the API token or its permissions. Check the ACLs of both the user and the token.",
"invalidResponse": "PBS returned an unexpected response. Redirects are not followed.",
"invalidJson": "PBS returned an invalid JSON document.",
"connectFailed": "Could not connect to PBS over verified HTTPS. Check the address, certificate and service.",
"invalidId": "The PBS connection ID is not valid. Reload the connection list.",
"invalidDatastores": "PBS returned an invalid datastore list.",
"connectionLimit": "At most {count} PBS connections may be configured.",
"username": "Enter the PBS user in user@realm format, for example root@pam.",
"password": "Enter the PBS user's password.",
"loginRejected": "PBS rejected the username or password.",
"invalidLogin": "PBS returned an invalid sign-in response.",
"tokenCreateDenied": "This PBS user cannot create API tokens.",
"invalidToken": "PBS returned an invalid API token.",
"auditRoleDenied": "This PBS user cannot grant the Audit role. Sign in as root@pam or another administrator.",
"notFound": "The PBS connection was not found. Reload the connection list.",
"readFailed": "Could not read PBS data. Check its service and permissions.",
"invalidList": "PBS returned an invalid data list.",
"localUnavailable": "PBS is not available on this host.",
"authRequired": "Enable local authentication in Monitor before managing remote PBS connections.",
"datastoreOffline": "The datastore is offline, in maintenance mode or its backing device is not mounted.",
"datastoreUnavailable": "The datastore filesystem is not available."
},
"taskTypes": {
"backup": "Backup", "restore": "Restore", "verify": "Verification",
"verifyGroup": "Backup group verification", "verifySnapshot": "Snapshot verification",
"prune": "Prune", "garbageCollection": "Garbage collection", "sync": "Synchronization",
"updateCheck": "Check for updates", "terminal": "Terminal", "createDatastore": "Create datastore",
"other": "Other task ({type})"
},
"states": { "ok": "OK", "unknown": "Unknown", "warnings": "Warnings: {count}", "warning": "Warning", "error": "Error" },
"serviceStates": { "active": "Active", "inactive": "Inactive", "failed": "Failed", "activating": "Starting", "deactivating": "Stopping", "unknown": "Unknown" },
"durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" }
},
"app": {
"title": "ProxMenux Monitor",
@@ -1327,9 +1380,11 @@
"update": "Update",
"modify": "Modify",
"recover": "Recover",
"registerRestored": "Register again",
"terminalTitleUpdate": "Update OCI application",
"terminalTitleRecoverPrevious": "Recover the previous OCI installation",
"terminalTitleModify": "Modify OCI application",
"terminalTitleRecover": "Recover restored OCI application",
"terminalTitleRecover": "Register restored OCI applications",
"terminalDescription": "Runs the same process as the ProxMenux menu: OCI manager Apps → Manage installed OCI applications.",
"keepBackup": "Keep the backup taken before updating",
"noKeepBackup": "The backup taken before updating is not kept",
@@ -1560,7 +1615,7 @@
"ociTitle": "OCI image container",
"ociBody": "This container was created from an OCI image outside OCI manager Apps. It is updated by replacing its image, which ProxMenux does not manage for it.",
"ociRestoredTitle": "Restored OCI application",
"ociRestoredBody": "This container was restored from a backup and this host has no record of its application, so it cannot be updated from here yet. Recover registers it again on this host, the same as OCI manager Apps → Manage installed OCI applications.",
"ociRestoredBody": "This container was restored from a backup and this host has no record of its OCI application, so it cannot be updated here yet. Register again opens the registration of restored OCI applications on this node, as in OCI manager Apps → Manage installed OCI applications.",
"dockerImagesTitle": "Docker images",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Images",
@@ -1620,6 +1675,127 @@
"loadFailed": "Could not load the bulk update configuration."
},
"appEditor": {
"confirmRemove": "Remove this application's record from the App tab? This does not uninstall the application.",
"packageExample": "e.g. {package}",
"packageHelpBefore": "Auto-filled from Name. Verify with",
"packageHelpAfter": "inside the CT.",
"binaryHelpBefore": "Absolute path. Find it with",
"binaryHelpOr": "or",
"errors": {
"ociBusy": "an OCI operation on this container is running",
"dpkgMissing": "{package} is not installed via dpkg",
"dpkgFailed": "dpkg-query failed",
"dpkgNoVersion": "no version matched in dpkg output",
"apkFailed": "apk info failed",
"apkNoVersion": "no version matched in apk output",
"fileRead": "could not read {path}",
"fileNoMatch": "file_regex did not match {path}",
"binaryFailed": "binary invocation failed",
"binaryNoVersion": "no version matched in binary output",
"pythonFailed": "python -c importlib.metadata failed",
"pythonNoVersion": "no version matched in Python distribution output",
"dockerImageResolve": "could not resolve the container's installed image",
"dockerInspectFailed": "docker inspect failed",
"dockerLabelMissing": "container has no {label} label",
"dockerLabelMutable": "docker label reports {tag} (mutable tag, not a version)",
"dockerLabelNoVersion": "no version matched in docker label output",
"dockerExecFailed": "docker exec failed",
"dockerExecNoVersion": "no version matched in docker exec output",
"commandEmpty": "command_argv is empty",
"commandFailed": "command failed",
"commandNoVersion": "no version matched in command output",
"unsupportedMethod": "unsupported method: {method}",
"unknownUpstream": "unknown upstream_type: {type}",
"githubNotFound": "{repo}: not found",
"githubRejected": "github rejected the request (403)",
"githubError": "github error {code}",
"noRelease": "no tag / release name in response",
"tagNoMatch": "tag_regex did not match '{tag}'",
"httpError": "http error {code}",
"invalidJson": "invalid JSON response: {detail}",
"jsonPathUnresolved": "json_path '{path}' did not resolve",
"jsonEmpty": "empty value at json_path",
"dockerNoTags": "no tags matched (docker_hub)",
"dockerMovingOnly": "tag_regex matched only moving tags; use Docker image digest tracking",
"dockerHubError": "docker hub error {code}",
"dockerHubJson": "invalid docker hub JSON: {detail}",
"dockerHubEmpty": "no tags returned by docker hub",
"payload": "payload must be a JSON object",
"name": "name is required and must be 1-64 chars of letters/digits/spaces/._+-()/",
"method": "installed_via must be one of: {values} or empty",
"package": "package is required (letters/digits/._+@:/ up to 127 chars)",
"filePath": "file_path is required and must be an absolute path",
"fileRegex": "file_regex is required",
"fileRegexInvalid": "file_regex is not a valid regex: {detail}",
"binaryPath": "binary_path is required and must be an absolute path",
"pythonPath": "python_path is required and must be an absolute path",
"distribution": "distribution is required (PEP 503 name)",
"container": "container_name is required (docker naming rules)",
"label": "label is required (OCI label naming rules)",
"dockerBinary": "binary_path is required (absolute path or bare command)",
"manualVersion": "installed_version is required for the manual method",
"versionLength": "installed_version exceeds {max} chars",
"versionControl": "installed_version contains a forbidden control character",
"installedRegexInvalid": "installed_regex is not a valid regex: {detail}",
"upstreamType": "upstream_type must be one of: {values}",
"tagRegexInvalid": "tag_regex is not a valid regex: {detail}",
"repoRequired": "repo is required for upstream_type=github",
"repoFormat": "repo must be 'owner/name'",
"githubSource": "github_source must be one of: {values}",
"upstreamUrlRequired": "upstream_url is required for upstream_type=http_json",
"upstreamUrl": "upstream_url must be an http(s) URL",
"upstreamUrlLength": "upstream_url exceeds {max} chars",
"jsonPathRequired": "upstream_json_path is required for upstream_type=http_json",
"jsonPathLength": "upstream_json_path exceeds {max} chars",
"jsonPathCharacters": "upstream_json_path uses forbidden characters",
"dockerImageRequired": "docker_image is required for upstream_type=docker_hub",
"dockerImageLength": "docker_image exceeds {max} chars",
"dockerImageFormat": "docker_image must match Docker Hub naming (owner/name or name)",
"healthPath": "health_path must be a valid URL path (max 255 chars)",
"logoUrl": "logo_url must be an http(s) URL or this VM's cached local icon",
"discoveryId": "invalid discovery_id",
"helperSlug": "helper_slug must be a lowercase slug (letters/digits/._-)",
"updateVia": "update_via only accepts 'docker'",
"dockerDetector": "update_via=docker requires a docker_label or docker_exec detector",
"dockerConflict": "update_via=docker cannot be combined with {field}",
"updateCommandType": "update_command must be a string",
"updateCommandLength": "update_command exceeds {max} chars",
"updateCommandNull": "update_command contains a null byte",
"updateMethod": "update_method must be none, helper or custom",
"updateCommandRequired": "update_command is required for update_method=custom",
"updateCommandOnlyCustom": "update_command is only allowed for update_method=custom",
"helperRequired": "a supported helper_slug is required for update_method=helper",
"dockerUpdater": "Docker-delegated apps update through their image, not a separate app updater",
"commandRequired": "command_argv is required (non-empty list)",
"commandList": "command_argv must be a non-empty list of strings",
"commandCount": "command_argv accepts at most {max} entries",
"commandEntry": "command_argv[{index}] must be a non-empty string",
"commandEntryLength": "command_argv[{index}] exceeds {max} chars",
"commandEntryControl": "command_argv[{index}] contains a forbidden control character",
"binaryArgsList": "binary_args must be a list of strings",
"binaryArgsCount": "binary_args accepts at most {max} entries",
"binaryArgEntry": "binary_args[{index}] must be a non-empty string",
"binaryArgLength": "binary_args[{index}] exceeds {max} chars",
"binaryArgControl": "binary_args[{index}] contains a forbidden control character",
"portsList": "ports must be a list of {port, description, scheme}",
"portObject": "ports[{index}] must be an object",
"portRequired": "ports[{index}].port is required",
"portInteger": "ports[{index}].port must be an integer",
"portRange": "ports[{index}].port must be 1-65535",
"portDuplicate": "port {port} appears more than once for this app",
"portDescription": "ports[{index}].description has invalid characters",
"portScheme": "ports[{index}].scheme must be 'http' or 'https'",
"portPath": "ports[{index}].web_path must be a valid URL path (max 255 chars)",
"portLogo": "ports[{index}].logo_url must be an http(s) URL (max 512 chars)",
"portCategory": "ports[{index}].category has invalid characters or is too long",
"portCustomUrl": "ports[{index}].custom_url must be an http(s) URL (max 512 chars)",
"tagRegexLength": "tag_regex exceeds 512 chars",
"persistPermission": "could not persist sidecar (permission?)",
"persist": "could not persist sidecar",
"noApps": "no apps registered for this vmid",
"appMissing": "app_id '{appId}' not found",
"appNotFound": "app not found"
},
"closePanel": "Close panel",
"cancelButton": "Cancel",
"saveButton": "Save",
@@ -4351,6 +4527,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}",
@@ -5431,6 +5725,7 @@
"websocketError": "WebSocket error occurred",
"connectionClosed": "Connection closed",
"completedSuccessfully": "Script completed successfully",
"cancelled": "Operation cancelled.",
"completedWithError": "Script finished with error code {code}",
"yes": "Yes",
"yourInput": "Your input:",
@@ -6652,6 +6947,17 @@
"addSelected": "Add selected",
"pbsWebInterface": "PBS web interface",
"changeAddress": "Change address",
"cancel": "Cancel"
"cancel": "Cancel",
"errors": {
"addressRequired": "Enter the address of the Monitor.",
"invalidAddress": "The address must start with http:// or https:// and name a host.",
"addressCredentials": "The address must not contain a user or password.",
"invalidPort": "The port of the address is not valid.",
"invalidRequest": "The request is not valid. Reload the page and try again.",
"limit": "At most {count} nodes can be added.",
"duplicate": "This address is already in the list.",
"notFound": "The node was not found. Reload the list and try again.",
"detectedRemove": "Only nodes added by hand can be removed. Detected nodes can be hidden."
}
}
}
+372 -66
View File
@@ -8,7 +8,7 @@
"notAvailable": "Nedostupné",
"serverOffline": "Server je offline",
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami",
"descriptionPbs": "Proxmox Backup Server Dashboard"
"descriptionPbs": "Prehľad systému Proxmox Backup Server"
},
"common": {
"notAvailable": "Nedostupné",
@@ -1032,7 +1032,7 @@
"pciPassthrough": "PCI Passthrough",
"usbDevices": "USB zariadenia",
"serialPorts": "Sériové porty",
"bridge": "Sieťový bridge",
"bridge": "Sieťový most (bridge)",
"enabled": "Zapnuté",
"disabled": "Vypnuté",
"on": "zapnuté",
@@ -1248,13 +1248,15 @@
"stackNote": "Súčasť aplikácie s viacerými kontajnermi (hlavný CT {primary}, {count} kontajnerov): aktualizácia sa týka všetkých.",
"memberNote": "Tento kontajner patrí do aplikácie s viacerými kontajnermi, ktorej hlavný CT je {primary}, a neaktualizuje sa samostatne. Aktualizácia sa vykonáva z hlavného CT, ktorý aktualizuje všetkých {count} kontajnerov naraz.",
"openPrimary": "Otvoriť hlavný CT",
"pendingNote": "Operácia na tomto kontajneri bola prerušená; tlačidlo Obnoviť spustí obnovenie.",
"pendingNote": "Operácia na tomto kontajneri zostala nedokončená. Tlačidlo Obnoviť otvorí možnosti obnovenia.",
"update": "Aktualizovať",
"modify": "Upraviť",
"recover": "Obnoviť",
"registerRestored": "Znova zaregistrovať",
"terminalTitleUpdate": "Aktualizovať OCI aplikáciu",
"terminalTitleRecoverPrevious": "Obnoviť predchádzajúcu OCI inštaláciu",
"terminalTitleModify": "Upraviť OCI aplikáciu",
"terminalTitleRecover": "Obnoviť obnovenú OCI aplikáciu",
"terminalTitleRecover": "Zaregistrovať OCI aplikácie obnovené zo zálohy",
"terminalDescription": "Použije sa rovnaký postup ako v ponuke Správca OCI aplikácií → Spravovať nainštalované OCI aplikácie.",
"keepBackup": "Ponechať zálohu vytvorenú pred aktualizáciou",
"noKeepBackup": "Záloha vytvorená pred aktualizáciou sa neponecháva",
@@ -1485,7 +1487,7 @@
"ociTitle": "Kontajner z OCI obrazu",
"ociBody": "Tento kontajner bol vytvorený z OCI (Docker) obrazu mimo Správcu OCI aplikácií. Aktualizuje sa nahradením obrazu, čo ProxMenux pri tomto kontajneri nespravuje.",
"ociRestoredTitle": "Obnovená OCI aplikácia",
"ociRestoredBody": "Tento kontajner bol obnovený zo zálohy a tento hostiteľ nemá záznam o jeho aplikácii, preto ho zatiaľ nemožno aktualizovať odtiaľto. Obnoviť ho znova zaregistruje na tomto hostiteľovi, rovnako ako OCI manager Apps → Spravovať nainštalované OCI aplikácie.",
"ociRestoredBody": "Tento kontajner bol obnovený zo zálohy, ale tento server nemá záznam o jeho OCI aplikácii, preto ju tu zatiaľ nemožno aktualizovať. Možnosť Znova zaregistrovať otvorí registráciu obnovených OCI aplikácií na tomto uzle, rovnako ako ponuka Správca OCI aplikácií → Spravovať nainštalované OCI aplikácie.",
"dockerImagesTitle": "Docker obrazy",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Obrazy",
@@ -1545,7 +1547,128 @@
"loadFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo načítať."
},
"appEditor": {
"methodOciImage": "OCI obraz (nainštalovaný ProxMenux)",
"confirmRemove": "Odobrať záznam aplikácie zo záložky Aplikácie? Samotná aplikácia zostane nainštalovaná.",
"packageExample": "napr. {package}",
"packageHelpBefore": "Doplní sa podľa názvu. Over ho príkazom",
"packageHelpAfter": "v kontajneri.",
"binaryHelpBefore": "Úplná cesta k programu. Nájdeš ju príkazom",
"binaryHelpOr": "alebo",
"errors": {
"ociBusy": "V tomto kontajneri práve prebieha operácia OCI.",
"dpkgMissing": "Balík {package} nie je nainštalovaný cez dpkg.",
"dpkgFailed": "Príkaz dpkg-query zlyhal.",
"dpkgNoVersion": "Vo výstupe dpkg sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"apkFailed": "Príkaz apk info zlyhal.",
"apkNoVersion": "Vo výstupe apk sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"fileRead": "Súbor {path} sa nepodarilo prečítať.",
"fileNoMatch": "Regulárny výraz file_regex nenašiel zhodu v súbore {path}.",
"binaryFailed": "Program sa nepodarilo spustiť.",
"binaryNoVersion": "Vo výstupe programu sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"pythonFailed": "Zisťovanie verzie cez python -c importlib.metadata zlyhalo.",
"pythonNoVersion": "Vo výstupe balíka Pythonu sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"dockerImageResolve": "Nainštalovaný obraz kontajnera sa nepodarilo určiť.",
"dockerInspectFailed": "Príkaz docker inspect zlyhal.",
"dockerLabelMissing": "Kontajner nemá štítok {label}.",
"dockerLabelMutable": "Štítok Dockeru uvádza {tag}. Je to premenlivý tag, ktorý neurčuje konkrétnu verziu.",
"dockerLabelNoVersion": "V štítku Dockeru sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"dockerExecFailed": "Príkaz docker exec zlyhal.",
"dockerExecNoVersion": "Vo výstupe docker exec sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"commandEmpty": "Príkaz a argumenty (command_argv) nie sú vyplnené.",
"commandFailed": "Príkaz zlyhal.",
"commandNoVersion": "Vo výstupe príkazu sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"unsupportedMethod": "Nepodporovaný spôsob zisťovania verzie: {method}",
"unknownUpstream": "Neznámy zdroj dostupnej verzie (upstream_type): {type}",
"githubNotFound": "Repozitár {repo} sa nenašiel.",
"githubRejected": "GitHub odmietol požiadavku (403).",
"githubError": "Chyba GitHubu {code}.",
"noRelease": "Odpoveď neobsahuje tag ani názov vydania.",
"tagNoMatch": "Regulárny výraz tag_regex nenašiel zhodu s hodnotou „{tag}“.",
"httpError": "Chyba HTTP {code}.",
"invalidJson": "Odpoveď neobsahuje platný JSON: {detail}",
"jsonPathUnresolved": "Cesta v JSON „{path}“ sa nenašla.",
"jsonEmpty": "Na zadanej ceste v JSON je prázdna hodnota.",
"dockerNoTags": "Na Docker Hube sa nenašli tagy zodpovedajúce filtru.",
"dockerMovingOnly": "Filtru tag_regex zodpovedajú iba premenlivé tagy. Použi sledovanie obrazov Docker podľa digestu.",
"dockerHubError": "Chyba Docker Hubu {code}.",
"dockerHubJson": "Docker Hub nevrátil platný JSON: {detail}",
"dockerHubEmpty": "Docker Hub nevrátil žiadne tagy.",
"payload": "Údaje musia byť objekt JSON.",
"name": "Zadaj názov s dĺžkou 1 až 64 znakov. Povolené sú písmená, číslice, medzery a ._+-()/.",
"method": "Spôsob zisťovania verzie (installed_via) musí byť jedna z hodnôt: {values}, alebo môže zostať prázdny.",
"package": "Zadaj názov balíka (package), najviac 127 znakov. Povolené sú písmená, číslice a ._+@:/.",
"filePath": "Zadaj úplnú cestu k súboru (file_path), začínajúcu lomkou /.",
"fileRegex": "Zadaj regulárny výraz na získanie verzie zo súboru (file_regex).",
"fileRegexInvalid": "Regulárny výraz pre súbor (file_regex) je neplatný: {detail}",
"binaryPath": "Zadaj úplnú cestu k programu (binary_path), začínajúcu lomkou /.",
"pythonPath": "Zadaj úplnú cestu k interpretu Pythonu (python_path), začínajúcu lomkou /.",
"distribution": "Zadaj názov balíka Pythonu (distribution) podľa PEP 503.",
"container": "Zadaj názov kontajnera (container_name) podľa pravidiel Dockeru.",
"label": "Zadaj názov štítka (label) podľa pravidiel OCI.",
"dockerBinary": "Zadaj cestu k programu alebo samotný názov príkazu (binary_path).",
"manualVersion": "Pri ručnom zadávaní vyplň nainštalovanú verziu (installed_version).",
"versionLength": "Nainštalovaná verzia (installed_version) môže mať najviac {max} znakov.",
"versionControl": "Nainštalovaná verzia (installed_version) obsahuje nepovolený riadiaci znak.",
"installedRegexInvalid": "Regulárny výraz pre nainštalovanú verziu (installed_regex) je neplatný: {detail}",
"upstreamType": "Zdroj dostupnej verzie (upstream_type) musí byť jedna z hodnôt: {values}.",
"tagRegexInvalid": "Regulárny výraz pre tagy (tag_regex) je neplatný: {detail}",
"repoRequired": "Pre zdroj GitHub zadaj repozitár (repo).",
"repoFormat": "Repozitár (repo) musí mať tvar „vlastník/názov“ (owner/name).",
"githubSource": "Zdroj na GitHube (github_source) musí byť jedna z hodnôt: {values}.",
"upstreamUrlRequired": "Pre zdroj HTTP JSON zadaj adresu (upstream_url).",
"upstreamUrl": "Adresa zdroja (upstream_url) musí začínať na http:// alebo https://.",
"upstreamUrlLength": "Adresa zdroja (upstream_url) môže mať najviac {max} znakov.",
"jsonPathRequired": "Pre zdroj HTTP JSON zadaj cestu k verzii v JSON (upstream_json_path).",
"jsonPathLength": "Cesta k verzii v JSON (upstream_json_path) môže mať najviac {max} znakov.",
"jsonPathCharacters": "Cesta k verzii v JSON (upstream_json_path) obsahuje nepovolené znaky.",
"dockerImageRequired": "Pre zdroj Docker Hub zadaj názov obrazu (docker_image).",
"dockerImageLength": "Názov obrazu (docker_image) môže mať najviac {max} znakov.",
"dockerImageFormat": "Názov obrazu (docker_image) musí mať tvar owner/name alebo name podľa pravidiel Docker Hubu.",
"healthPath": "Cesta na kontrolu stavu (health_path) musí byť platná cesta URL s najviac 255 znakmi.",
"logoUrl": "Logo (logo_url) musí byť adresa HTTP/HTTPS alebo lokálna ikona uložená pre tento stroj.",
"discoveryId": "Identifikátor nájdenej aplikácie (discovery_id) je neplatný.",
"helperSlug": "Identifikátor inštalačného skriptu (helper_slug) môže obsahovať malé písmená, číslice a ._-.",
"updateVia": "Pole update_via podporuje iba hodnotu docker.",
"dockerDetector": "Pri update_via=docker vyber detektor docker_label alebo docker_exec.",
"dockerConflict": "Pole {field} nemožno použiť spolu s update_via=docker.",
"updateCommandType": "Aktualizačný príkaz (update_command) musí byť text.",
"updateCommandLength": "Aktualizačný príkaz (update_command) môže mať najviac {max} znakov.",
"updateCommandNull": "Aktualizačný príkaz (update_command) obsahuje nepovolený nulový znak.",
"updateMethod": "Spôsob aktualizácie (update_method) musí byť none, helper alebo custom.",
"updateCommandRequired": "Pre vlastnú aktualizáciu zadaj príkaz (update_command).",
"updateCommandOnlyCustom": "Aktualizačný príkaz (update_command) je povolený iba pri update_method=custom.",
"helperRequired": "Pre update_method=helper vyber podporovaný inštalačný skript (helper_slug).",
"dockerUpdater": "Aplikácia v Dockeri sa aktualizuje spolu s obrazom. Samostatný aktualizátor aplikácie tu nemožno nastaviť.",
"commandRequired": "Zadaj príkaz a jeho argumenty (command_argv) ako neprázdny zoznam.",
"commandList": "Príkaz a argumenty (command_argv) musia byť neprázdny zoznam textových hodnôt.",
"commandCount": "Príkaz a argumenty (command_argv) môžu mať najviac {max} položiek.",
"commandEntry": "Položka command_argv[{index}] musí obsahovať text.",
"commandEntryLength": "Položka command_argv[{index}] môže mať najviac {max} znakov.",
"commandEntryControl": "Položka command_argv[{index}] obsahuje nepovolený riadiaci znak.",
"binaryArgsList": "Argumenty programu (binary_args) musia byť zoznam textových hodnôt.",
"binaryArgsCount": "Argumenty programu (binary_args) môžu mať najviac {max} položiek.",
"binaryArgEntry": "Argument binary_args[{index}] musí obsahovať text.",
"binaryArgLength": "Argument binary_args[{index}] môže mať najviac {max} znakov.",
"binaryArgControl": "Argument binary_args[{index}] obsahuje nepovolený riadiaci znak.",
"portsList": "Webové odkazy (ports) musia byť zoznam položiek s poľami port, description a scheme.",
"portObject": "Webový odkaz ports[{index}] musí byť objekt.",
"portRequired": "Vo webovom odkaze ports[{index}] zadaj port.",
"portInteger": "Port vo webovom odkaze ports[{index}] musí byť celé číslo.",
"portRange": "Port vo webovom odkaze ports[{index}] musí byť od 1 do 65535.",
"portDuplicate": "Port {port} je pri tejto aplikácii uvedený viackrát.",
"portDescription": "Popis webového odkazu ports[{index}] obsahuje nepovolené znaky.",
"portScheme": "Protokol webového odkazu ports[{index}] musí byť http alebo https.",
"portPath": "Cesta webového odkazu ports[{index}].web_path musí byť platná cesta URL s najviac 255 znakmi.",
"portLogo": "Logo webového odkazu ports[{index}].logo_url musí byť adresa HTTP/HTTPS s najviac 512 znakmi.",
"portCategory": "Kategória webového odkazu ports[{index}].category obsahuje nepovolené znaky alebo je príliš dlhá.",
"portCustomUrl": "Vlastná adresa webového odkazu ports[{index}].custom_url musí byť adresa HTTP/HTTPS s najviac 512 znakmi.",
"tagRegexLength": "Regulárny výraz pre tagy (tag_regex) môže mať najviac 512 znakov.",
"persistPermission": "Evidenciu aplikácie sa nepodarilo uložiť. Skontroluj oprávnenia na zápis.",
"persist": "Evidenciu aplikácie sa nepodarilo uložiť.",
"noApps": "Pre tento stroj nie sú evidované žiadne aplikácie.",
"appMissing": "Záznam aplikácie „{appId}“ sa nenašiel.",
"appNotFound": "Záznam aplikácie sa nenašiel."
},
"methodOciImage": "OCI obraz (nainštaloval ProxMenux)",
"ociImageHelp": "Verzia aj dostupné aktualizácie sa určujú podľa obrazu, z ktorého bol tento kontajner vytvorený. Keďže ho nainštaloval ProxMenux, netreba nič nastavovať: nainštalovaný obraz sa porovnáva s obrazom, ktorý je dnes dostupný v jeho registri.",
"ociAppVersion": "Aplikácia",
"ociImage": "Obraz",
@@ -1585,7 +1708,7 @@
"installedRegexPlaceholder": "napr. v?(\\d+\\.\\d+\\.\\d+)",
"installedRegexAlt": "install_regex",
"regexCaptureGroupLabel": "Regex (so zachytávacou skupinou)",
"regexPlaceholderVersion": "verzia:\\s*(\\d+\\.\\d+\\.\\d+)",
"regexPlaceholderVersion": "version:\\s*(\\d+\\.\\d+\\.\\d+)",
"optionalSuffix": "(voliteľné)",
"methodNone": "Žiadny (iba odkaz)",
"methodDpkg": "balík dpkg (Debian/Ubuntu)",
@@ -1606,12 +1729,12 @@
"binaryArgsPlaceholder": "napr. --version (oddelené čiarkou)",
"filePathLabel": "Cesta k súboru",
"commandLabel": "Príkaz (argv oddelené čiarkou, bez shellu)",
"commandPlaceholder": "myapp, verzia, --json",
"commandPlaceholder": "myapp, version, --json",
"commandServerExample": "server, -v",
"containerNameLabel": "Názov kontajnera",
"containerNamePlaceholder": "napr. <your-container-name>",
"ociLabelKeyLabel": "Kľúč OCI labelu",
"pythonInterpreterLabel": "Cesta k Python interpreteru",
"ociLabelKeyLabel": "Kľúč štítka OCI",
"pythonInterpreterLabel": "Cesta k interpretu Pythonu",
"pythonInterpreterPlaceholder": "napr. /opt/<your-app>/venv/bin/python",
"pipDistLabel": "Názov distribúcie (pip balík)",
"pipDistPlaceholder": "napr. <your-pip-package>",
@@ -1651,7 +1774,7 @@
"latestFromLabel": "Najnovšia verzia z",
"upToDateBadge": "Aktuálne",
"updateAvailableBadge": "Dostupná aktualizácia",
"portDescriptionPlaceholder": "Popis (napr. Web UI, go2rtc, admin)",
"portDescriptionPlaceholder": "Popis (napr. webové rozhranie, go2rtc, správa)",
"portPortPlaceholder": "port",
"portHttp": "http",
"portHttps": "https",
@@ -1670,7 +1793,7 @@
"detectMethodDockerLabel": "štítok docker",
"detectMethodDockerExec": "docker exec",
"detectMethodCommand": "príkaz",
"binaryVersionHint": "binárna verzia",
"binaryVersionHint": "program (výstup --version)",
"systemctlHint": "systemctl show <service> -p ExecStart",
"whichHint": "ktorá <aplikácia>",
"apkInfoHint": "\" : \"informácie o apk | grep",
@@ -1721,7 +1844,7 @@
"detectorTestTitle": "Výsledok testu detektora",
"detectorTestNotSaved": "Iba koncept · neuložené",
"detectorTestNoVersion": "Nebola zistená žiadna verzia",
"detectorTestNoUpstream": "Nie je nastavený zdroj upstream",
"detectorTestNoUpstream": "Nie je nastavený zdroj dostupnej verzie",
"detectorTestFailed": "Test detektora zlyhal",
"deleteFailed": "Odstránenie zlyhalo",
"dismissFailed": "Nájdenú aplikáciu sa nepodarilo skryť",
@@ -1768,17 +1891,17 @@
"addAnotherApplication": "Registrovať ďalšiu aplikáciu",
"doneButton": "Hotovo",
"editButton": "Upraviť",
"upstreamErrorTimeout": "Časový limit siete pri kontaktovaní upstream",
"upstreamErrorTimeout": "Zdroj dostupnej verzie neodpovedal včas.",
"upstreamErrorNetwork": "Chyba siete: {detail}",
"upstreamErrorGithubRateLimit": "Bol dosiahnutý limit požiadaviek GitHubu. V časti Nastavenia → GitHub API nakonfigurujte voliteľný token alebo to skúste znova neskôr.",
"upstreamErrorRegistry": "Register obrazu sa nepodarilo kontaktovať. O niekoľko minút sa skontroluje znova.",
"upstreamErrorGeneric": "Kontrola proti prúdu zlyhala: {detail}",
"notificationsEnabled": "Upozornenia na upstream aktualizácie sú ZAPNUTÉ – kliknutím ich stlmíte",
"notificationsMuted": "Upstream upozornenia na aktualizácie MUTED – kliknutím aktivujete",
"notifyUpstreamLabel": "Upozorniť ma, keď bude k dispozícii nová upstream verzia",
"notifyUpstreamHelp": "Odošle `app_update_available` do kanálov povolených v Nastaveniach → Upozornenia. Vypnite, ak túto aplikáciu nie je možné aktualizovať na vašom boxe.",
"notificationsEnabled": "Upozornenia na nové verzie sú zapnuté — kliknutím ich stlmíš",
"notificationsMuted": "Upozornenia na nové verzie sú stlmené — kliknutím ich zapneš",
"notifyUpstreamLabel": "Upozorniť ma na novú dostupnú verziu",
"notifyUpstreamHelp": "Odošle upozornenie app_update_available do kanálov zapnutých v Nastaveniach → Upozornenia. Vypni, ak túto aplikáciu na svojom serveri nemôžeš aktualizovať.",
"excludeFromBadgeLabel": "Vylúčiť z počítadla aktualizácií LXC",
"excludeFromBadgeHelp": "Nezapočítajte túto aplikáciu do odznaku súhrnných aktualizácií na karte zoznamu LXC.Užitočné, keď ste úmyselne pripnutý ku konkrétnej verzii (požiadavka na sledovanie, zmrazenie kompatibility).Nemá vplyv na vlastný stav karty Aplikácia ani na odchádzajúce upozornenie.",
"excludeFromBadgeHelp": "Nezapočíta túto aplikáciu do počtu aktualizácií v zozname LXC. Hodí sa, ak chceš zámerne zostať pri konkrétnej verzii, napríklad kvôli kompatibilite. Stav na záložke Aplikácie aj upozornenia zostanú nezmenené.",
"ociLabelPlaceholder": "org.opencontainers.image.version",
"stackMemberTitle": "Časť aplikácie s viacerými kontajnermi",
"stackMemberBody": "Tento kontajner podporuje aplikáciu, ktorej hlavný kontajner je {primary}. Zobrazuje sa tam aplikácia, jej webové odkazy a jej verzia."
@@ -1872,7 +1995,7 @@
},
"networkInterfaces": {
"title": "Vylúčené sieťové rozhrania",
"description": "Vylúčte sieťové rozhrania, napríklad most, bond alebo fyzické sieťové karty, z kontroly stavu a upozornení. Hodí sa to pre rozhrania, ktoré sú zámerne vypnuté alebo sa nepoužívajú.",
"description": "Vylúčte sieťové rozhrania, napríklad sieťové mosty (bridge), bond alebo fyzické sieťové karty, z kontroly stavu a upozornení. Hodí sa to pre rozhrania, ktoré sú zámerne vypnuté alebo sa nepoužívajú.",
"emptyTitle": "Nenašli sa žiadne sieťové rozhrania",
"interface": "Rozhranie",
"down": "VYPNUTÉ",
@@ -3540,7 +3663,7 @@
},
"loading": {
"title": "Načítavam údaje o sieti...",
"description": "Kontrolujem rozhrania, sieťové mosty a premávku"
"description": "Kontrolujem rozhrania, sieťové mosty (bridge) a premávku"
},
"errors": {
"serverUnavailableTitle": "Server nie je dostupný",
@@ -3554,7 +3677,7 @@
},
"sections": {
"physicalInterfaces": "Fyzické rozhrania",
"bridgeInterfaces": "Sieťové mosty",
"bridgeInterfaces": "Sieťové mosty (bridge)",
"vmLxcInterfaces": "Sieťové rozhrania VM a LXC"
},
"summary": {
@@ -3574,8 +3697,8 @@
},
"interfaceTypes": {
"physical": "Fyzické",
"bridge": "Most",
"bridges": "Mosty",
"bridge": "Sieťový most (bridge)",
"bridges": "Sieťové mosty (bridge)",
"bond": "Bond",
"vlan": "VLAN",
"virtual": "Virtuálne",
@@ -3669,7 +3792,7 @@
"inactiveTitle": "Rozhranie je neaktívne",
"inactiveDescription": "Toto rozhranie je momentálne vypnuté. Štatistiky sieťovej premávky nie sú dostupné.",
"bondConfiguration": "Nastavenie bondu",
"bridgeConfiguration": "Nastavenie mosta"
"bridgeConfiguration": "Nastavenie sieťového mosta (bridge)"
},
"flow": {
"title": "Tok siete"
@@ -4214,7 +4337,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ľúč.",
@@ -4258,6 +4381,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}",
@@ -4843,7 +5084,7 @@
"keepAll": "Ponechať všetky zálohy",
"description": "Predvolená politika uchovávania záloh uložená v Proxmoxe. Zálohovacie úlohy s vlastnou politikou majú pred týmto predvoleným nastavením prednosť.",
"refresh": "Obnoviť",
"saved": "Politika uchovávania záloh úložiska bola uložená. Prerezávanie sa nevykonalo.",
"saved": "Pravidlá uchovávania záloh na úložisku boli uložené. Žiadne zálohy sa teraz neodstránili.",
"choosePolicy": "Vyberte Ponechať všetky alebo aspoň jeden počet uchovávaných záloh väčší ako nula.",
"saveFailed": "Nepodarilo sa uložiť politiku uchovávania záloh úložiska. Ak sa konfigurácia zmenila, obnovte zoznam.",
"loadFailed": "Nepodarilo sa načítať politiku uchovávania záloh úložiska.",
@@ -5355,6 +5596,7 @@
"websocketError": "Chyba WebSocket spojenia",
"connectionClosed": "Spojenie bolo zatvorené",
"completedSuccessfully": "Skript sa úspešne dokončil",
"cancelled": "Operácia bola zrušená.",
"completedWithError": "Skript skončil s chybovým kódom {code}",
"yes": "Áno",
"yourInput": "Tvoj vstup:",
@@ -5677,7 +5919,7 @@
"nextStep": {
"missing": "Nastavte si držanie na týchto pracovných miestach, alebo na sklade, na ktorý píšu. Proxmox berie nastavenie úlohy ako prvý, potom úložisko je, potom uzol predvolený.",
"notDeclared": "Tieto práce uchovávajú všetky kópie, ktoré robia. To je zámerná voľba pre niektoré destinácie, ale potvrďte, že cieľ má priestor na rast.",
"onServer": "Tieto úlohy píšu na záložný server, ktorý ich prerezáva podľa vlastných pravidiel. Z tohto uzla sa nedá čítať to, čo drží, takže skontrolujte, či tam nie je."
"onServer": "Tieto úlohy ukladajú zálohy na zálohovací server. Ten odstraňuje staré zálohy podľa vlastných pravidiel. Z tohto uzla sa nedá zistiť, ktoré zálohy uchováva; skontroluj ich priamo na zálohovacom serveri."
},
"explanation": "O tom, koľko výtlačkov sa uchováva a ako dlho sa uchováva. Bez nej sa cieľ naplní, kým neprestane prijímať nové kópie, a záloha, ktorú nemožno napísať, je tá, o ktorej sa dozviete v deň, keď ju potrebujete."
},
@@ -6087,11 +6329,11 @@
}
},
"bridge_without_ports": {
"title": "Porty bridgeov",
"rationale": "Konfigurácia portov každého bridge. Bridge bez fyzického portu obsluhuje internú alebo smerovanú sieť.",
"title": "Porty sieťových mostov (bridge)",
"rationale": "Konfigurácia portov každého sieťového mosta (bridge). Sieťový most (bridge) bez fyzického portu obsluhuje internú alebo smerovanú sieť.",
"summary": {
"allConnected": "Všetkých {total} bridgeov má port",
"isolated": "{count} z {total} bridgeov nemá port",
"allConnected": "Všetkých {total} sieťových mostov (bridge) má port",
"isolated": "{count} z {total} sieťových mostov (bridge) nemá port",
"evaluationFailed": "Kontrolu sa nepodarilo vyhodnotiť"
}
}
@@ -6307,13 +6549,13 @@
"network": "Sieť",
"adapters": "Adaptéry",
"bond": "Bond",
"bridge": "Bridge",
"bridges": "Bridge",
"bridge": "Sieťový most (bridge)",
"bridges": "Sieťové mosty (bridge)",
"physicalAdapters": "Fyzické adaptéry",
"interface": "Rozhranie",
"driver": "Ovládač",
"state": "Stav",
"networkDiagramNote": "Cesta od kábla ku každému VM a LXC: fyzický adaptér, bond, ak ich zoskupuje, bridge a pripojené VM a LXC.",
"networkDiagramNote": "Cesta od kábla ku každému VM a LXC: fyzický adaptér, bond, ak ich zoskupuje, sieťový most (bridge) a pripojené VM a LXC.",
"storageAndProtection": "Úložisko a ochrana",
"storage": "Úložisko",
"content": "Obsah",
@@ -7335,35 +7577,40 @@
"title": "Proxmox Backup Server",
"server": "Server PBS",
"chooseServer": "Vybrať server PBS",
"local": "Miestny PBS",
"local": "Lokálny PBS",
"thisNode": "PBS na tomto uzle",
"updatedAt": "Aktualizované o {time}",
"servicesActive": "Služby sú aktívne",
"detectedTitle": "PBS zistené v úložisku Proxmox",
"detectedHelp": "Tieto Proxmox Backup Servery sú nastavené ako úložisko záloh. Pripojte každý z nich API tokenom len na čítanie, aby ste tu videli jeho datastory, úlohy a joby.",
"detectedTitle": "PBS zistené v nastaveniach úložísk Proxmoxu",
"detectedHelp": "Tieto servery Proxmox Backup Server sú nastavené ako úložiská záloh. Pripoj ich pomocou API tokenu iba na čítanie a zobrazia sa tu ich dátové úložiská, úlohy a plány údržby.",
"connect": "Pripojiť",
"tasksTab": "Úlohy",
"signInWith": "Prihlásiť sa pomocou",
"methodPassword": "Používateľ a heslo",
"methodToken": "API token",
"username": "Používateľ (pouzivatel@realm)",
"username": "Používateľ",
"password": "Heslo",
"passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu len na čítanie (rola Audit) pre tento Monitor. Uloží sa iba tento token, nikdy nie heslo. Najprv zapnite overovanie v Monitore.",
"passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu iba na čítanie s rolou Audit pre tento Monitor. Uloží sa iba token, heslo sa neukladá. Najprv zapni overovanie v Monitore.",
"cancel": "Zrušiť",
"refresh": "Obnoviť",
"manage": "Spravovať pripojenia PBS",
"loadFailed": "Nepodarilo sa načítať stav PBS.",
"loading": "Načítavajú sa údaje PBS…",
"noServer": "Pridajte pripojenie PBS na zobrazenie dátových úložísk, úloh a plánov.",
"permissionsHelp": "Pred uložením prihlasovacích údajov povoľte overovanie v Monitore. Použite vyhradený API token PBS s rolou Audit na / v ACL používateľa aj tokenu, so zapnutým dedením. Tento panel údaje PBS iba číta.",
"noServer": "Pridaj pripojenie PBS a zobrazia sa jeho dátové úložiská, úlohy a plány údržby.",
"permissionsHelp": "Pred uložením prihlasovacích údajov zapni overovanie v Monitore. Použi vyhradený API token PBS. Používateľ aj token musia mať v zozname prístupových oprávnení (ACL) rolu Audit na ceste / so zapnutým dedením. Tento panel údaje PBS iba číta.",
"editConnection": "Pripojenie na úpravu",
"add": "Pridať nové pripojenie",
"name": "Názov",
"url": "HTTPS URL (pre pôvodný port PBS uveďte :8007)",
"token_id": "ID API tokenu (user@realm!token)",
"token_secret": "Tajný kľúč API tokenu (ponechajte prázdne, ak chcete zachovať uložený kľúč)",
"fingerprint": "Odtlačok SHA256 certifikátu (voliteľný)",
"tlsHelp": "Pre bežné overenie certifikačnej autority a názvu hostiteľa ponechajte odtlačok prázdny. Pri certifikáte s vlastným podpisom skopírujte jeho odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného kanála. Odtlačok sa kontroluje pred odoslaním prihlasovacích údajov.",
"url": "Adresa HTTPS",
"urlHelp": "Štandardný port PBS je 8007, napríklad https://pbs.example:8007.",
"usernameHelp": "Použi meno a overovaciu oblasť oddelené znakom @, napríklad root@pam.",
"tokenIdHelp": "Použi formát meno@oblasť!token, napríklad monitor@pbs!proxmenux.",
"tokenSecretHelp": "Zadaj tajný kľúč k tomuto API tokenu.",
"tokenSecretKeepHelp": "Prázdne pole zachová uložený tajný kľúč tokenu.",
"token_id": "ID API tokenu",
"token_secret": "Tajný kľúč API tokenu",
"fingerprint": "Odtlačok SHA256 (voliteľný)",
"tlsHelp": "Pri bežnom overovaní certifikačnej autority a názvu hostiteľa nechaj odtlačok prázdny. Pri certifikáte s vlastným podpisom skopíruj odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného zdroja. Kontroluje sa ešte pred odoslaním prihlasovacích údajov.",
"saved": "Pripojenie PBS bolo overené a uložené.",
"saveFailed": "Nepodarilo sa uložiť pripojenie PBS.",
"testing": "Overuje sa pripojenie…",
@@ -7375,9 +7622,9 @@
"total": "Kapacita súborového systému",
"used": "Použité",
"free": "Dostupné",
"noDatastores": "Žiadne dostupné dátové úložiská. Skontrolujte konfiguráciu úložísk a oprávnenia používateľa a tokenu.",
"noDatastores": "Nie sú dostupné žiadne dátové úložiská. Skontroluj ich nastavenia a oprávnenia používateľa aj tokenu.",
"tasks": "Nedávne úlohy",
"noTasks": "Neboli vrátené žiadne nedávne úlohy.",
"noTasks": "PBS nevrátil žiadne nedávne úlohy.",
"task": "Úloha",
"started": "Spustené",
"status": "Stav",
@@ -7387,7 +7634,7 @@
"schedule": "Plán",
"sync": "Synchronizácia",
"verify": "Overenie",
"prune": "Prerezávanie",
"prune": "Odstraňovanie starých záloh",
"overview": "Prehľad",
"logs": "Systémové záznamy",
"terminal": "Terminál",
@@ -7398,51 +7645,110 @@
"memory": "Pamäť",
"uptime": "Čas prevádzky",
"capacityUnavailable": "Kapacita nie je k dispozícii.",
"datastoresUnavailable": "{count} nie je k dispozícii",
"datastoresUnavailable": "Nedostupné úložiská: {count}",
"datastoresOnline": "Všetky dostupné",
"moreDatastores": "+{count} ďalších dátových úložísk",
"estimatedFull": "Odhadovaný plný",
"estimatedFullUnknown": "Zatiaľ nie je dostatok údajov",
"estimatedFull": "Odhadované zaplnenie",
"estimatedFullUnknown": "Zatiaľ sa nedá odhadnúť",
"duration": "Trvanie",
"noJobs": "Nie sú nakonfigurované žiadne plány synchronizácie, overovania ani odstraňovania."
"noJobs": "Nie sú nastavené žiadne plány synchronizácie, overovania ani odstraňovania starých záloh.",
"removeFailed": "Nepodarilo sa odstrániť pripojenie PBS.",
"savedRefreshFailed": "Pripojenie PBS sa uložilo, ale nepodarilo sa obnoviť zoznam pripojení. Pred ďalšou zmenou obnov stránku.",
"removedRefreshFailed": "Pripojenie PBS sa odstránilo, ale nepodarilo sa obnoviť zoznam pripojení. Obnov stránku.",
"technicalDetails": "Technické podrobnosti",
"datastoreType": "Dátové úložisko",
"errors": {
"configRead": "Nepodarilo sa načítať uložené pripojenia PBS. Skontroluj konfiguračný súbor a oprávnenia.",
"invalidConfig": "Nastavenia pripojenia PBS nie sú platné. Skontroluj formulár a skús to znova.",
"invalidField": "Niektoré pole pripojenia nie je platné alebo je príliš dlhé.",
"invalidUrl": "Použi adresu HTTPS bez vložených prihlasovacích údajov, parametrov za otáznikom a časti za znakom #.",
"nameRequired": "Zadaj názov servera s najviac 128 znakmi.",
"tokenIdFormat": "Zadaj ID API tokenu PBS vo formáte user@realm!token.",
"tokenSecret": "Zadaj platný tajný kľúč API tokenu bez medzier a riadiacich znakov.",
"invalidTokenId": "ID API tokenu môže obsahovať iba znaky ASCII.",
"invalidFingerprint": "Zadaj odtlačok SHA256 certifikátu: 64 šestnástkových znakov, prípadne oddelených dvojbodkami.",
"fingerprintMismatch": "Certifikát PBS sa nezhoduje s odtlačkom. Prihlasovacie údaje sa neodoslali.",
"tokenRejected": "PBS odmietol API token alebo jeho oprávnenia. Skontroluj zoznam prístupových oprávnení (ACL) používateľa aj tokenu.",
"invalidResponse": "PBS vrátil neočakávanú odpoveď. Presmerovania sa nenasledujú.",
"invalidJson": "PBS vrátil neplatný dokument JSON.",
"connectFailed": "Nepodarilo sa pripojiť k PBS cez overené HTTPS. Skontroluj adresu, certifikát a službu.",
"invalidId": "ID pripojenia PBS nie je platné. Obnov zoznam pripojení.",
"invalidDatastores": "PBS vrátil neplatný zoznam dátových úložísk.",
"connectionLimit": "Môžeš nastaviť najviac {count} pripojení PBS.",
"username": "Zadaj používateľa PBS vo formáte user@realm, napríklad root@pam.",
"password": "Zadaj heslo používateľa PBS.",
"loginRejected": "PBS odmietol používateľské meno alebo heslo.",
"invalidLogin": "PBS vrátil neplatnú odpoveď na prihlásenie.",
"tokenCreateDenied": "Tento používateľ PBS nemôže vytvárať API tokeny.",
"invalidToken": "PBS vrátil neplatný API token.",
"auditRoleDenied": "Tento používateľ PBS nemôže prideliť rolu Audit. Prihlás sa ako root@pam alebo iný správca.",
"notFound": "Pripojenie PBS sa nenašlo. Obnov zoznam pripojení.",
"readFailed": "Nepodarilo sa načítať údaje PBS. Skontroluj službu a oprávnenia.",
"invalidList": "PBS vrátil neplatný zoznam údajov.",
"localUnavailable": "PBS na tomto hostiteľovi nie je dostupný.",
"authRequired": "Pred správou vzdialených pripojení PBS zapni lokálne overovanie v Monitore.",
"datastoreOffline": "Dátové úložisko je odpojené, v režime údržby alebo jeho zariadenie nie je pripojené.",
"datastoreUnavailable": "Súborový systém dátového úložiska nie je dostupný."
},
"taskTypes": {
"backup": "Zálohovanie", "restore": "Obnova", "verify": "Overovanie",
"verifyGroup": "Overovanie skupiny záloh", "verifySnapshot": "Overovanie snímky",
"prune": "Odstraňovanie starých záloh", "garbageCollection": "Čistenie nepoužívaných dát", "sync": "Synchronizácia",
"updateCheck": "Kontrola aktualizácií", "terminal": "Terminál", "createDatastore": "Vytvorenie dátového úložiska",
"other": "Iná úloha ({type})"
},
"states": { "ok": "V poriadku", "unknown": "Neznámy", "warnings": "Upozornenia: {count}", "warning": "Upozornenie", "error": "Chyba" },
"serviceStates": { "active": "Aktívna", "inactive": "Neaktívna", "failed": "Zlyhala", "activating": "Spúšťa sa", "deactivating": "Zastavuje sa", "unknown": "Neznámy stav" },
"durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" }
},
"monitorLinks": {
"groups": {
"cluster": "Cluster",
"cluster": "Klaster",
"standalone": "Samostatné uzly",
"pbs": "Proxmox Backup Server"
},
"thisNode": "Tento uzol",
"checking": "Kontrolujú sa uzly…",
"loadFailed": "Nepodarilo sa načítať zoznam uzlov.",
"empty": "Zatiaľ žiadne ďalšie uzly. Pridajte jeden, aby ste odtiaľto otvorili jeho Monitor.",
"empty": "Zatiaľ tu nie sú ďalšie uzly. Pridaj uzol a jeho Monitor otvoríš priamo odtiaľto.",
"noResponse": "Žiadna odpoveď",
"pbsInterface": "rozhranie PBS",
"openNewTab": "Otvoriť na novej karte",
"manage": "Pridať alebo spravovať uzly…",
"dialogTitle": "Prepojené uzly",
"dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.",
"dialogDescription": "Uzly klastra a nastavené servery Proxmox Backup Server sa zisťujú automaticky. Do Monitora každého uzla sa prihlasuješ samostatne.",
"addTitle": "Pridať uzol",
"name": "Meno",
"address": "Adresa monitora",
"name": "Názov uzla",
"address": "Adresa Monitora",
"type": "Typ",
"add": "Pridať",
"save": "Uložiť",
"remove": "Odstrániť",
"visible": "Viditeľné",
"visible": "Zobraziť",
"autoAddress": "Automaticky",
"detected": "Zistené",
"added": "Pridané",
"detected": "Zistený",
"added": "Pridaný",
"saveFailed": "Nepodarilo sa uložiť zoznam uzlov.",
"backTo": "Späť na {name}",
"cameFromTitle": "Pridať uzol monitora?",
"cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.",
"cameFromTitle": "Pridať uzol Monitora?",
"cameFromBody": "Pridaj {name} medzi prepojené uzly tohto Monitora.",
"notNow": "Teraz nie",
"knownTitle": "Pridať prepojené uzly?",
"knownBody": "{name} prepája tieto monitory. Vyberte tie, ktoré chcete pridať do tohto monitora.",
"knownBody": "Uzol {name} má prepojené tieto uzly. Vyber tie, ktoré chceš pridať aj sem.",
"addSelected": "Pridať vybrané",
"pbsWebInterface": "Webové rozhranie PBS",
"changeAddress": "Zmeniť adresu",
"cancel": "Zrušiť"
"cancel": "Zrušiť",
"errors": {
"addressRequired": "Zadaj adresu Monitora.",
"invalidAddress": "Adresa musí začínať na http:// alebo https:// a obsahovať názov hostiteľa alebo IP adresu.",
"addressCredentials": "Adresa nesmie obsahovať používateľské meno ani heslo.",
"invalidPort": "Port v adrese nie je platný. Zadaj číslo od 1 do 65535.",
"invalidRequest": "Požiadavka nie je platná. Obnov stránku a skús to znova.",
"limit": "Môžeš pridať najviac {count} uzlov.",
"duplicate": "Táto adresa už je v zozname.",
"notFound": "Uzol sa nenašiel. Obnov zoznam a skús to znova.",
"detectedRemove": "Odstrániť môžeš iba ručne pridané uzly. Automaticky zistené uzly môžeš skryť."
}
}
}
+13 -6
View File
@@ -7,6 +7,13 @@ from jwt_middleware import require_auth, require_admin_scope
monitor_links_bp = Blueprint("monitor_links", __name__)
def link_error(exc):
payload = {"error": str(exc), "code": exc.code}
if exc.params:
payload["params"] = exc.params
return jsonify(payload), 400
@monitor_links_bp.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-store"
@@ -27,9 +34,9 @@ def add_link():
monitor_links.refresh()
return jsonify(success=True, key=key)
except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400
return link_error(exc)
except OSError:
return jsonify(error="Could not save the node list."), 500
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["PATCH"])
@@ -40,9 +47,9 @@ def update_link(key):
monitor_links.refresh()
return jsonify(success=True)
except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400
return link_error(exc)
except OSError:
return jsonify(error="Could not save the node list."), 500
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["DELETE"])
@@ -53,6 +60,6 @@ def delete_link(key):
monitor_links.refresh()
return jsonify(success=True)
except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400
return link_error(exc)
except OSError:
return jsonify(error="Could not save the node list."), 500
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
+15 -11
View File
@@ -8,6 +8,10 @@ from jwt_middleware import require_auth, require_admin_scope
pbs_bp = Blueprint("pbs", __name__)
def pbs_error(exc, status):
return jsonify(error=str(exc), **exc.problem()), status
@pbs_bp.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-store"
@@ -27,7 +31,7 @@ def servers():
servers = pbs.public_servers()
return jsonify(servers=servers, local_available=pbs.local_available(), detected=_detected(servers))
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 500
return pbs_error(exc, 500)
def _detected(servers):
@@ -57,42 +61,42 @@ def _protected_configuration():
@require_admin_scope
def save_server():
if not _protected_configuration():
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
return jsonify(error="Enable local authentication before saving remote PBS credentials.", code="pbs_auth_required"), 409
try:
identifier = pbs.save_server(request.get_json(silent=True))
return jsonify(success=True, id=identifier, servers=pbs.public_servers())
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 400
return pbs_error(exc, 400)
except OSError:
return jsonify(error="Could not save PBS connection."), 500
return jsonify(error="Could not save PBS connection.", code="pbs_save_failed"), 500
@pbs_bp.route("/api/pbs/servers/login", methods=["POST"])
@require_admin_scope
def connect_with_password():
if not _protected_configuration():
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
return jsonify(error="Enable local authentication before saving remote PBS credentials.", code="pbs_auth_required"), 409
try:
identifier = pbs.connect_with_password(request.get_json(silent=True))
return jsonify(success=True, id=identifier, servers=pbs.public_servers())
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 400
return pbs_error(exc, 400)
except OSError:
return jsonify(error="Could not save PBS connection."), 500
return jsonify(error="Could not save PBS connection.", code="pbs_save_failed"), 500
@pbs_bp.route("/api/pbs/servers/<identifier>", methods=["DELETE"])
@require_admin_scope
def delete_server(identifier):
if not _protected_configuration():
return jsonify(error="Enable local authentication before changing PBS connections."), 409
return jsonify(error="Enable local authentication before changing PBS connections.", code="pbs_auth_required"), 409
try:
pbs.delete_server(identifier)
return jsonify(success=True)
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 400
return pbs_error(exc, 400)
except OSError:
return jsonify(error="Could not remove PBS connection."), 500
return jsonify(error="Could not remove PBS connection.", code="pbs_remove_failed"), 500
@pbs_bp.route("/api/pbs/status")
@@ -101,4 +105,4 @@ def status():
try:
return jsonify(pbs.get_status(request.args.get("server", "local")))
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 502
return pbs_error(exc, 502)
+15 -12
View File
@@ -39,7 +39,10 @@ _refresh_lock = threading.Lock()
class LinkError(ValueError):
pass
def __init__(self, message, *, code="monitor_links_save_failed", params=None):
super().__init__(message)
self.code = code
self.params = params
def _load():
@@ -63,19 +66,19 @@ def _save(data):
def normalize_url(value):
"""Return the origin of an http(s) address, or raise LinkError."""
if not isinstance(value, str) or not value.strip():
raise LinkError("Enter the address of the Monitor.")
raise LinkError("Enter the address of the Monitor.", code="monitor_link_address_required")
text = value.strip()
if "://" not in text:
text = "https://" + text
parsed = urllib.parse.urlsplit(text)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise LinkError("The address must start with http:// or https:// and name a host.")
raise LinkError("The address must start with http:// or https:// and name a host.", code="monitor_link_invalid_address")
if parsed.username or parsed.password:
raise LinkError("The address must not contain a user or password.")
raise LinkError("The address must not contain a user or password.", code="monitor_link_address_credentials")
try:
port = parsed.port
except ValueError as exc:
raise LinkError("The port of the address is not valid.") from exc
raise LinkError("The port of the address is not valid.", code="monitor_link_invalid_port") from exc
host = parsed.hostname
if ":" in host:
host = f"[{host}]"
@@ -301,16 +304,16 @@ def start_prewarmer():
def add_link(payload):
if not isinstance(payload, dict):
raise LinkError("Invalid request.")
raise LinkError("Invalid request.", code="monitor_link_invalid_request")
name = str(payload.get("name") or "").strip()[:64]
url = normalize_url(payload.get("url"))
product = payload.get("product") if payload.get("product") in PRODUCTS else "pve"
with _lock:
data = _load()
if len(data["manual"]) >= MAX_MANUAL:
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.")
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.", code="monitor_link_limit", params={"count": MAX_MANUAL})
if any(row.get("url") == url for row in data["manual"]):
raise LinkError("This address is already in the list.")
raise LinkError("This address is already in the list.", code="monitor_link_duplicate")
identifier = secrets.token_hex(6)
data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname,
"url": url, "product": product})
@@ -320,13 +323,13 @@ def add_link(payload):
def update_link(key, payload):
if not isinstance(payload, dict):
raise LinkError("Invalid request.")
raise LinkError("Invalid request.", code="monitor_link_invalid_request")
with _lock:
data = _load()
if key.startswith("manual:"):
row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None)
if row is None:
raise LinkError("Node not found.")
raise LinkError("Node not found.", code="monitor_link_not_found")
if "name" in payload:
row["name"] = str(payload["name"] or "").strip()[:64] or row["name"]
if payload.get("url"):
@@ -345,7 +348,7 @@ def update_link(key, payload):
if not override:
data["overrides"].pop(key, None)
else:
raise LinkError("Node not found.")
raise LinkError("Node not found.", code="monitor_link_not_found")
_save(data)
@@ -354,6 +357,6 @@ def delete_link(key):
data = _load()
remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key]
if len(remaining) == len(data["manual"]):
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.")
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.", code="monitor_link_detected_remove")
data["manual"] = remaining
_save(data)
+44 -34
View File
@@ -39,7 +39,13 @@ MAX_RESPONSE = 4 * 1024 * 1024
class PBSError(ValueError):
pass
def __init__(self, message, *, code="pbs_unknown", params=None):
super().__init__(message)
self.code = code
self.params = params
def problem(self):
return {"code": self.code, **({"params": self.params} if self.params else {})}
def get_platform():
@@ -87,7 +93,7 @@ def _load_servers():
except FileNotFoundError:
return []
except (OSError, ValueError) as exc:
raise PBSError("Could not read the PBS server configuration.") from exc
raise PBSError("Could not read the PBS server configuration.", code="pbs_config_read") from exc
def _save_servers(servers):
@@ -113,11 +119,11 @@ def public_servers():
def _validate_server(data, previous=None, fields=("name", "url", "token_id", "token_secret", "fingerprint")):
if not isinstance(data, dict):
raise PBSError("Invalid PBS server configuration.")
raise PBSError("Invalid PBS server configuration.", code="pbs_invalid_config")
previous = previous or {}
row = {field: data.get(field, previous.get(field, "")) for field in fields}
if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()):
raise PBSError("Invalid PBS connection field.")
raise PBSError("Invalid PBS connection field.", code="pbs_invalid_field")
row["name"] = row["name"].strip()
row["url"] = row["url"].strip().rstrip("/")
try:
@@ -127,26 +133,26 @@ def _validate_server(data, previous=None, fields=("name", "url", "token_id", "to
or parts.query or parts.fragment):
raise ValueError()
except ValueError as exc:
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.") from exc
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.", code="pbs_invalid_url") from exc
if not row["name"] or len(row["name"]) > 128:
raise PBSError("A server name of at most 128 characters is required.")
raise PBSError("A server name of at most 128 characters is required.", code="pbs_name_required")
if "token_id" not in row:
return _validated_fingerprint(row)
row["token_id"] = row["token_id"].strip()
if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]):
raise PBSError("Enter a PBS API token ID in user@realm!token format.")
raise PBSError("Enter a PBS API token ID in user@realm!token format.", code="pbs_token_id_format")
if (not row["token_secret"] or not row["token_secret"].isascii()
or any(char.isspace() or ord(char) < 32 for char in row["token_secret"])):
raise PBSError("A valid API token secret is required.")
raise PBSError("A valid API token secret is required.", code="pbs_token_secret")
if not row["token_id"].isascii():
raise PBSError("Invalid API token ID.")
raise PBSError("Invalid API token ID.", code="pbs_invalid_token_id")
return _validated_fingerprint(row)
def _validated_fingerprint(row):
fingerprint = row["fingerprint"].strip().lower().replace(":", "")
if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint):
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).")
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).", code="pbs_invalid_fingerprint")
row["fingerprint"] = fingerprint
return row
@@ -158,7 +164,8 @@ def _remote_get(server, endpoint):
def _remote_request(server, method, endpoint, headers, form=None,
rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs."):
rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs.",
*, rejected_code="pbs_token_rejected"):
"""One request to PBS; credentials leave only after the certificate is checked."""
parts = urlsplit(server["url"])
fingerprint = server.get("fingerprint")
@@ -175,7 +182,7 @@ def _remote_request(server, method, endpoint, headers, form=None,
if fingerprint:
actual = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).hexdigest()
if not hmac.compare_digest(actual, fingerprint):
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.")
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.", code="pbs_fingerprint_mismatch")
path = parts.path.rstrip("/") + "/api2/json" + endpoint
body = urlencode(form) if form is not None else None
request_headers = {"Accept": "application/json", **headers}
@@ -185,17 +192,17 @@ def _remote_request(server, method, endpoint, headers, form=None,
response = connection.getresponse()
content = response.read(MAX_RESPONSE + 1)
if response.status in (401, 403):
raise PBSError(rejected)
raise PBSError(rejected, code=rejected_code)
if response.status != 200 or len(content) > MAX_RESPONSE:
raise PBSError("PBS returned an invalid response. Redirects are not followed.")
raise PBSError("PBS returned an invalid response. Redirects are not followed.", code="pbs_invalid_response")
document = json.loads(content)
if not isinstance(document, dict) or "data" not in document:
raise PBSError("PBS returned an invalid JSON document.")
raise PBSError("PBS returned an invalid JSON document.", code="pbs_invalid_json")
return document["data"]
except PBSError:
raise
except (OSError, http.client.HTTPException, ValueError) as exc:
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.") from exc
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.", code="pbs_connect_failed") from exc
finally:
connection.close()
@@ -203,19 +210,19 @@ def _remote_request(server, method, endpoint, headers, form=None,
def save_server(data):
identifier = data.get("id") if isinstance(data, dict) else None
if identifier is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(identifier)):
raise PBSError("Invalid PBS server ID.")
raise PBSError("Invalid PBS server ID.", code="pbs_invalid_id")
with _lock:
previous = next((row for row in _load_servers() if row["id"] == identifier), None)
row = _validate_server(data, previous)
# /version may be public; validate credentials on the protected status API.
usage = _remote_get(row, "/status/datastore-usage")
if not isinstance(usage, list):
raise PBSError("PBS returned an invalid datastore list.")
raise PBSError("PBS returned an invalid datastore list.", code="pbs_invalid_datastores")
row.update(id=identifier or "pbs-" + secrets.token_hex(6), revision=secrets.token_hex(16))
with _lock:
servers = _load_servers()
if previous is None and len(servers) >= 32:
raise PBSError("At most 32 PBS connections may be configured.")
raise PBSError("At most 32 PBS connections may be configured.", code="pbs_connection_limit", params={"count": 32})
servers = [existing for existing in servers if existing["id"] != row["id"]]
_save_servers([*servers, row])
return row["id"]
@@ -228,27 +235,27 @@ def connect_with_password(data):
username = data.get("username") if isinstance(data, dict) else None
password = data.get("password") if isinstance(data, dict) else None
if not isinstance(username, str) or not re.fullmatch(r"[^\s:!@]+@[^\s:!@]+", username.strip()):
raise PBSError("Enter the PBS user as user@realm, for example root@pam.")
raise PBSError("Enter the PBS user as user@realm, for example root@pam.", code="pbs_username")
if not isinstance(password, str) or not password or len(password) > 4096:
raise PBSError("Enter the password of the PBS user.")
raise PBSError("Enter the password of the PBS user.", code="pbs_password")
ticket = _remote_request(endpoint, "POST", "/access/ticket", {},
{"username": username.strip(), "password": password},
rejected="PBS rejected the user name or password.")
rejected="PBS rejected the user name or password.", rejected_code="pbs_login_rejected")
if not isinstance(ticket, dict) or not ticket.get("ticket") or not ticket.get("CSRFPreventionToken"):
raise PBSError("PBS returned an invalid sign-in response.")
raise PBSError("PBS returned an invalid sign-in response.", code="pbs_invalid_login")
session = {"Cookie": "PBSAuthCookie=" + quote(ticket["ticket"], safe=""),
"CSRFPreventionToken": ticket["CSRFPreventionToken"]}
user = str(ticket.get("username") or username.strip())
name = "proxmenux-" + secrets.token_hex(4)
token_path = f"/access/users/{quote(user, safe='')}/token/{name}"
created = _remote_request(endpoint, "POST", token_path, session, {"comment": "ProxMenux Monitor, read-only"},
rejected="This PBS user cannot create API tokens.")
rejected="This PBS user cannot create API tokens.", rejected_code="pbs_token_create_denied")
if not isinstance(created, dict) or not created.get("tokenid") or not created.get("value"):
raise PBSError("PBS returned an invalid API token.")
raise PBSError("PBS returned an invalid API token.", code="pbs_invalid_token")
try:
_remote_request(endpoint, "PUT", "/access/acl", session,
{"path": "/", "role": "Audit", "auth-id": created["tokenid"], "propagate": "1"},
rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.")
rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.", rejected_code="pbs_audit_role_denied")
return save_server({**endpoint, "token_id": created["tokenid"], "token_secret": created["value"]})
except PBSError:
try:
@@ -263,7 +270,7 @@ def delete_server(identifier):
servers = _load_servers()
remaining = [row for row in servers if row["id"] != identifier]
if len(remaining) == len(servers):
raise PBSError("PBS server not found.")
raise PBSError("PBS server not found.", code="pbs_not_found")
_save_servers(remaining)
@@ -276,7 +283,7 @@ def _cli(*arguments):
raise ValueError()
return data
except (OSError, subprocess.SubprocessError, ValueError) as exc:
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.") from exc
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.", code="pbs_read_failed") from exc
def _local_api(path):
@@ -290,7 +297,7 @@ def _local_api(path):
raise ValueError()
return data
except (OSError, subprocess.SubprocessError, ValueError) as exc:
raise PBSError("Could not read PBS " + path + ". Check its service and permissions.") from exc
raise PBSError("Could not read PBS " + path + ". Check its service and permissions.", code="pbs_read_failed") from exc
def _local_datastores():
@@ -305,12 +312,14 @@ def _local_datastores():
inactive = store.get("backing-device") and not (path and os.path.ismount(path))
if not path or inactive or store.get("maintenance-mode"):
row["error"] = "Datastore is offline, in maintenance or has no mounted backing device."
row["error_code"] = "pbs_datastore_offline"
else:
try:
capacity = shutil.disk_usage(path)
row.update(total=capacity.total, used=capacity.used, avail=capacity.free)
except OSError:
row["error"] = "Datastore filesystem is not available."
row["error_code"] = "pbs_datastore_unavailable"
result.append(row)
return result
@@ -331,10 +340,10 @@ def get_status(identifier):
cached = _status_cache.get(identifier)
if cached and cached[0] > time.monotonic():
return cached[1]
errors = {}
errors, error_details = {}, {}
if identifier == "local":
if not local_available():
raise PBSError("The local host is not a PBS server.")
raise PBSError("The local host is not a PBS server.", code="pbs_local_unavailable")
name, version = get_platform().get("hostname") or socket.gethostname(), _local_version()
services = _local_services()
operations = {"datastores": _local_datastores,
@@ -346,7 +355,7 @@ def get_status(identifier):
with _lock:
server = next((row for row in _load_servers() if row["id"] == identifier), None)
if not server:
raise PBSError("PBS server not found.")
raise PBSError("PBS server not found.", code="pbs_not_found")
metadata = _remote_get(server, "/version")
name, version = server["name"], metadata.get("version") if isinstance(metadata, dict) else None
services = {}
@@ -354,18 +363,19 @@ def get_status(identifier):
"sync": "/admin/sync", "verify": "/admin/verify", "prune": "/admin/prune"}
operations = {key: (lambda endpoint=path: _remote_get(server, endpoint)) for key, path in endpoints.items()}
result = {"server": identifier, "name": name, "version": version, "services": services,
"fetched_at": int(time.time()), "errors": errors}
"fetched_at": int(time.time()), "errors": errors, "error_details": error_details}
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {key: executor.submit(operation) for key, operation in operations.items()}
for key, future in futures.items():
try:
data = future.result()
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
raise PBSError("PBS returned an invalid list.")
raise PBSError("PBS returned an invalid list.", code="pbs_invalid_list")
result[key] = [{**row, "name": row.get("store", row.get("name"))} for row in data] if key == "datastores" else data
except PBSError as exc:
result[key] = None # Unknown/forbidden is never presented as zero.
errors[key] = str(exc)
error_details[key] = exc.problem()
with _lock:
if len(_status_cache) >= 33:
_status_cache.clear()
@@ -0,0 +1,138 @@
"""Exercise the shipped routes and persistence; localization is additive."""
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest import mock
from flask import Flask
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
import monitor_links as links
class MonitorLinksI18nTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.path = Path(temporary.name) / "links.json"
self.previous = {"manual": [{"id": "saved", "name": "Saved", "url": "https://saved.test", "product": "pve"}], "overrides": {}}
self.path.write_text(json.dumps(self.previous))
for patch in [mock.patch.object(links, "LINKS_FILE", self.path),
mock.patch.object(links, "refresh"), mock.patch.object(links, "_probe")]:
patch.start()
self.addCleanup(patch.stop)
auth_provider = types.SimpleNamespace(
load_auth_config=lambda: {"enabled": True},
verify_token=lambda token: "fixture" if token in ("admin", "reader") else None,
verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only")
if token in ("admin", "reader") else (None, None))
middleware_spec = importlib.util.spec_from_file_location("fixture_jwt_middleware", ROOT / "AppImage/scripts/jwt_middleware.py")
middleware = importlib.util.module_from_spec(middleware_spec)
# Execute the shipped decorators; only credential verification is faked.
with mock.patch.dict(sys.modules, {"auth_manager": auth_provider}):
middleware_spec.loader.exec_module(middleware)
spec = importlib.util.spec_from_file_location("fixture_monitor_links_routes", ROOT / "AppImage/scripts/flask_monitor_links_routes.py")
routes = importlib.util.module_from_spec(spec)
# Import isolation keeps the real JWT module and other suites untouched.
with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}):
spec.loader.exec_module(routes)
app = Flask(__name__)
app.register_blueprint(routes.monitor_links_bp)
self.client = app.test_client()
def send(self, method, path="", payload=None, role="admin"):
return self.client.open("/api/monitor-links" + path, method=method,
json=payload, headers={"Authorization": "Bearer " + role} if role else {})
def rejected(self, method, path, payload, message, code, params=None):
before = self.path.read_bytes()
with mock.patch.object(links, "_save") as save:
response = self.send(method, path, payload)
self.assertEqual(response.status_code, 400)
expected = {"error": message, "code": code}
if params:
expected["params"] = params
self.assertEqual(response.json, expected)
self.assertEqual(response.headers["Cache-Control"], "no-store")
save.assert_not_called()
links.refresh.assert_not_called()
links._probe.assert_not_called()
self.assertEqual(self.path.read_bytes(), before)
def test_validation_errors_preserve_english_status_and_have_stable_codes_without_effects(self):
cases = [
("POST", "", None, "Invalid request.", "monitor_link_invalid_request"),
("PATCH", "/manual:saved", [], "Invalid request.", "monitor_link_invalid_request"),
("POST", "", {"url": " "}, "Enter the address of the Monitor.", "monitor_link_address_required"),
("POST", "", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
("POST", "", {"url": "https://user:pass@example.test"}, "The address must not contain a user or password.", "monitor_link_address_credentials"),
("PATCH", "/manual:saved", {"name": "Unsaved", "url": "https://example.test:99999"}, "The port of the address is not valid.", "monitor_link_invalid_port"),
("PATCH", "/cluster:node", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
("POST", "", {"url": "https://saved.test/path"}, "This address is already in the list.", "monitor_link_duplicate"),
("PATCH", "/manual:absent", {"url": "https://new.test"}, "Node not found.", "monitor_link_not_found"),
("PATCH", "/other:node", {}, "Node not found.", "monitor_link_not_found"),
("DELETE", "/cluster:node", None, "Only nodes added by hand can be removed; detected ones can be hidden.", "monitor_link_detected_remove"),
]
for case in cases:
with self.subTest(case=case):
self.rejected(*case)
def test_limit_uses_actual_count_and_preserves_previous_file(self):
with mock.patch.object(links, "MAX_MANUAL", 1):
self.rejected("POST", "", {"url": "https://new.test"}, "At most 1 nodes can be added.",
"monitor_link_limit", {"count": 1})
def test_write_failure_is_localizable_without_leaking_filesystem_details(self):
for method, path, payload in [("POST", "", {"url": "https://new.test"}),
("PATCH", "/manual:saved", {"name": "Unsaved"}),
("DELETE", "/manual:saved", None)]:
with self.subTest(method=method), mock.patch.object(links.os, "replace", side_effect=OSError("private path details")):
before = self.path.read_bytes()
response = self.send(method, path, payload)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json, {"error": "Could not save the node list.", "code": "monitor_links_save_failed"})
self.assertEqual(self.path.read_bytes(), before)
links.refresh.assert_not_called()
links._probe.assert_not_called()
def test_successful_crud_and_detected_hide_reset_keep_existing_contract(self):
response = self.send("POST", payload={"name": "New", "url": "new.test:8008/path", "product": "pbs"})
self.assertEqual(response.status_code, 200)
self.assertEqual(set(response.json), {"success", "key"})
key = response.json["key"]
self.assertTrue(key.startswith("manual:"))
row = links._load()["manual"][-1]
self.assertEqual((row["url"], row["product"]), ("https://new.test:8008", "pbs"))
self.assertEqual(self.send("PATCH", "/" + key, {"url": "http://changed.test"}).json, {"success": True})
self.assertEqual(links._load()["manual"][-1]["url"], "http://changed.test")
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "https://override.test", "hidden": True}).json, {"success": True})
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "", "hidden": False}).json, {"success": True})
self.assertEqual(links._load()["overrides"], {"cluster:node": {"hidden": False}})
self.assertEqual(self.send("DELETE", "/" + key).json, {"success": True})
self.assertEqual(links._load()["manual"], self.previous["manual"])
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
self.assertEqual(links.refresh.call_count, 5)
links._probe.assert_not_called()
def test_existing_read_and_admin_decorators_still_guard_routes_before_effects(self):
with mock.patch.object(links, "cached_links", return_value={"self": {}, "links": []}) as cached, \
mock.patch.object(links, "_save") as save:
for method, path in [("POST", ""), ("PATCH", "/manual:saved"), ("DELETE", "/manual:saved")]:
for role, status in [(None, 401), ("reader", 403)]:
self.assertEqual(self.send(method, path, {"url": "https://new.test"}, role).status_code, status)
self.assertEqual(self.send("GET", role=None).status_code, 401)
self.assertEqual(self.send("GET", "?all=1", role="reader").status_code, 200)
cached.assert_called_once_with(include_hidden=True)
save.assert_not_called()
links.refresh.assert_not_called()
links._probe.assert_not_called()
self.assertEqual(json.loads(self.path.read_text()), self.previous)
if __name__ == "__main__":
unittest.main()
+6 -4
View File
@@ -205,7 +205,7 @@ class PBSManagerTests(unittest.TestCase):
def test_password_sign_in_keeps_only_a_read_only_token(self):
calls = []
def request(server, method, endpoint, headers, form=None, rejected=""):
def request(server, method, endpoint, headers, form=None, rejected="", **kwargs):
calls.append((method, endpoint, form))
if endpoint == "/access/ticket":
return {"ticket": "PBS:root@pam:ticket", "CSRFPreventionToken": "csrf", "username": "root@pam"}
@@ -227,7 +227,7 @@ class PBSManagerTests(unittest.TestCase):
def test_password_sign_in_removes_its_token_when_the_role_cannot_be_granted(self):
calls = []
def request(server, method, endpoint, headers, form=None, rejected=""):
def request(server, method, endpoint, headers, form=None, rejected="", **kwargs):
calls.append((method, endpoint))
if endpoint == "/access/ticket":
return {"ticket": "t", "CSRFPreventionToken": "c"}
@@ -351,15 +351,17 @@ class TLSTests(unittest.TestCase):
def test_bad_pin_and_untrusted_ca_send_no_http_credentials(self):
for fingerprint in ('0' * 64, ''):
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError):
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError) as caught:
pbs._remote_get({**self.connection, "fingerprint": fingerprint}, '/status/datastore-usage')
self.assertEqual(caught.exception.code, "pbs_fingerprint_mismatch" if fingerprint else "pbs_connect_failed")
self.assertEqual(self.requests, [])
def test_bad_credentials_and_redirects_are_rejected(self):
for status in (401, 403, 302):
type(self).response_status = status
with self.subTest(status=status), self.assertRaises(pbs.PBSError):
with self.subTest(status=status), self.assertRaises(pbs.PBSError) as caught:
pbs._remote_get(self.connection, '/status/datastore-usage')
self.assertEqual(caught.exception.code, "pbs_token_rejected" if status in (401, 403) else "pbs_invalid_response")
if __name__ == '__main__':
+196
View File
@@ -0,0 +1,196 @@
"""PBS localization metadata must preserve routes, security and persistence."""
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest import mock
from flask import Flask
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
import auth_manager
import pbs_manager as pbs
REAL_REMOTE_REQUEST = pbs._remote_request
class PBSI18nTests(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup)
self.root = Path(temp.name)
for patch in [mock.patch.multiple(auth_manager, CONFIG_DIR=self.root, AUTH_CONFIG_FILE=self.root / "auth.json"),
mock.patch.object(pbs, "_remote_get", return_value=[]),
mock.patch.object(pbs, "_remote_request"),
mock.patch.object(pbs, "local_available", return_value=False)]:
patch.start(); self.addCleanup(patch.stop)
(self.root / "auth.json").write_text(json.dumps({"enabled": True, "password_hash": "fixture"}))
self.server = {"name": "Fixture", "url": "https://pbs.invalid:8007", "token_id": "audit@pbs!monitor", "token_secret": "fake-fixture-secret", "fingerprint": ""}
self.id = pbs.save_server(self.server)
pbs._remote_get.reset_mock(); pbs._status_cache.clear()
auth = types.SimpleNamespace(load_auth_config=auth_manager.load_auth_config, verify_token=lambda _: "fixture",
verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only"))
spec = importlib.util.spec_from_file_location("fixture_pbs_jwt", ROOT / "AppImage/scripts/jwt_middleware.py")
middleware = importlib.util.module_from_spec(spec)
with mock.patch.dict(sys.modules, {"auth_manager": auth}): spec.loader.exec_module(middleware)
spec = importlib.util.spec_from_file_location("fixture_pbs_routes", ROOT / "AppImage/scripts/flask_pbs_routes.py")
routes = importlib.util.module_from_spec(spec)
with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}): spec.loader.exec_module(routes)
app = Flask(__name__); app.register_blueprint(routes.pbs_bp); self.client = app.test_client()
def send(self, method="POST", path="/api/pbs/servers", data=None, role="admin"):
return self.client.open(path, method=method, json=data, headers={"Authorization": "Bearer " + role} if role else {})
def reject(self, data, message, code, path="/api/pbs/servers"):
before = (self.root / "pbs_servers.json").read_bytes()
with mock.patch.object(pbs, "_save_servers") as save:
response = self.send(path=path, data=data)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json, {"error": message, "code": code})
self.assertEqual(response.headers["Cache-Control"], "no-store")
save.assert_not_called(); pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
def test_invalid_input_is_localizable_before_any_remote_or_save_effect(self):
cases = [
(None, "Invalid PBS server configuration.", "pbs_invalid_config"),
({**self.server, "name": []}, "Invalid PBS connection field.", "pbs_invalid_field"),
({**self.server, "url": "http://pbs.invalid"}, "Use an HTTPS PBS URL without embedded credentials, query or fragment.", "pbs_invalid_url"),
({**self.server, "name": ""}, "A server name of at most 128 characters is required.", "pbs_name_required"),
({**self.server, "token_id": "bad"}, "Enter a PBS API token ID in user@realm!token format.", "pbs_token_id_format"),
({**self.server, "token_secret": "bad\nsecret"}, "A valid API token secret is required.", "pbs_token_secret"),
({**self.server, "token_id": "človek@pbs!monitor"}, "Invalid API token ID.", "pbs_invalid_token_id"),
({**self.server, "fingerprint": "invalid"}, "The certificate fingerprint must be SHA256 (64 hex characters).", "pbs_invalid_fingerprint"),
({**self.server, "id": "../../bad"}, "Invalid PBS server ID.", "pbs_invalid_id"),
]
for data, message, code in cases:
with self.subTest(code=code): self.reject(data, message, code)
for data, message, code in [
({**self.server, "username": "missing-realm", "password": "fake"}, "Enter the PBS user as user@realm, for example root@pam.", "pbs_username"),
({**self.server, "username": "root@pam", "password": ""}, "Enter the password of the PBS user.", "pbs_password"),
]:
with self.subTest(code=code): self.reject(data, message, code, "/api/pbs/servers/login")
def test_authentication_and_scope_guards_precede_all_mutations(self):
before = (self.root / "pbs_servers.json").read_bytes()
save_patch = mock.patch.object(pbs, "_save_servers")
save = save_patch.start(); self.addCleanup(save_patch.stop)
for path, method in [("/api/pbs/servers", "POST"), ("/api/pbs/servers/login", "POST"), ("/api/pbs/servers/" + self.id, "DELETE")]:
for role, status in [(None, 401), ("reader", 403)]:
self.assertEqual(self.send(method, path, self.server, role).status_code, status)
(self.root / "auth.json").write_text(json.dumps({"enabled": False}))
for path in ("/api/pbs/servers", "/api/pbs/servers/login"):
response = self.send(path=path, data=self.server)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json, {"error": "Enable local authentication before saving remote PBS credentials.", "code": "pbs_auth_required"})
response = self.send("DELETE", "/api/pbs/servers/" + self.id)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json["code"], "pbs_auth_required")
pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
save.assert_not_called()
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
def test_corrupt_configuration_missing_server_and_local_unavailable_keep_status(self):
response = self.send("DELETE", "/api/pbs/servers/absent")
self.assertEqual((response.status_code, response.json), (400, {"error": "PBS server not found.", "code": "pbs_not_found"}))
for identifier, code in [("absent", "pbs_not_found"), ("local", "pbs_local_unavailable")]:
response = self.send("GET", "/api/pbs/status?server=" + identifier)
self.assertEqual(response.status_code, 502); self.assertEqual(response.json["code"], code)
(self.root / "pbs_servers.json").write_text("invalid json")
response = self.send("GET", "/api/pbs/servers")
self.assertEqual((response.status_code, response.json), (500, {"error": "Could not read the PBS server configuration.", "code": "pbs_config_read"}))
pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
def test_save_delete_failures_leave_previous_bytes_and_do_not_leak_details(self):
for path, method, data, code, message in [
("/api/pbs/servers", "POST", {**self.server, "id": self.id}, "pbs_save_failed", "Could not save PBS connection."),
("/api/pbs/servers/" + self.id, "DELETE", None, "pbs_remove_failed", "Could not remove PBS connection."),
]:
before = (self.root / "pbs_servers.json").read_bytes()
with self.subTest(method=method), mock.patch.object(pbs.os, "replace", side_effect=OSError("private filesystem detail")):
response = self.send(method, path, data)
self.assertEqual((response.status_code, response.json), (500, {"error": message, "code": code}))
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
def test_partial_status_preserves_legacy_error_null_and_cache_with_additive_metadata(self):
def remote(server, endpoint):
if endpoint == "/version": return {"version": "4.2.5"}
if endpoint == "/admin/verify": raise pbs.PBSError("Fixture access denied", code="pbs_token_rejected")
if endpoint == "/admin/prune": return {"invalid": "list"}
return []
with mock.patch.object(pbs, "_remote_get", side_effect=remote) as remote_call:
result = self.send("GET", "/api/pbs/status?server=" + self.id)
count = remote_call.call_count
self.assertEqual(self.send("GET", "/api/pbs/status?server=" + self.id).json, result.json)
self.assertEqual(remote_call.call_count, count)
self.assertEqual(result.status_code, 200)
self.assertIsNone(result.json["verify"]); self.assertIsNone(result.json["prune"])
self.assertEqual(result.json["sync"], [])
self.assertEqual(result.json["errors"]["verify"], "Fixture access denied")
self.assertEqual(result.json["error_details"]["verify"], {"code": "pbs_token_rejected"})
self.assertEqual(result.json["error_details"]["prune"], {"code": "pbs_invalid_list"})
def test_local_datastore_errors_and_command_errors_keep_legacy_messages(self):
with mock.patch.object(pbs, "_cli", return_value=[{"name": "offline", "path": "/fixture", "backing-device": "fixture"}]), \
mock.patch.object(pbs.os.path, "ismount", return_value=False), mock.patch.object(pbs.shutil, "disk_usage") as usage:
store = pbs._local_datastores()[0]
self.assertEqual(store["error"], "Datastore is offline, in maintenance or has no mounted backing device.")
self.assertEqual(store["error_code"], "pbs_datastore_offline"); usage.assert_not_called(); self.assertIsNone(store["total"])
with mock.patch.object(pbs.subprocess, "run", side_effect=OSError("fixture")):
for fn, args in [(pbs._cli, ("task", "list")), (pbs._local_api, ("/admin/sync",))]:
with self.subTest(args=args), self.assertRaises(pbs.PBSError) as caught: fn(*args)
self.assertEqual(caught.exception.code, "pbs_read_failed")
self.assertIn("Check its service and permissions.", str(caught.exception))
def test_remote_failure_codes_follow_the_actual_request_and_keep_english(self):
cases = [
(401, b'{}', "pbs_token_rejected", "PBS rejected the API token or its permissions. Check both the user and token ACLs."),
(302, b'{}', "pbs_invalid_response", "PBS returned an invalid response. Redirects are not followed."),
(200, b'{}', "pbs_invalid_json", "PBS returned an invalid JSON document."),
(200, b'invalid-json', "pbs_connect_failed", "Could not connect to PBS over verified HTTPS. Check the address, certificate and service."),
]
for status, body, code, message in cases:
connection = mock.Mock(); connection.getresponse.return_value.status = status
connection.getresponse.return_value.read.return_value = body
with self.subTest(code=code), mock.patch.object(pbs.http.client, "HTTPSConnection", return_value=connection), \
self.assertRaises(pbs.PBSError) as caught:
REAL_REMOTE_REQUEST(self.server, "GET", "/version", {})
self.assertEqual((str(caught.exception), caught.exception.problem()), (message, {"code": code}))
connection.close.assert_called_once()
for code in ("pbs_login_rejected", "pbs_token_create_denied", "pbs_audit_role_denied"):
connection = mock.Mock(); connection.getresponse.return_value.status = 403
connection.getresponse.return_value.read.return_value = b'{}'
with self.subTest(code=code), mock.patch.object(pbs.http.client, "HTTPSConnection", return_value=connection), self.assertRaises(pbs.PBSError) as caught:
REAL_REMOTE_REQUEST(self.server, "POST", "/access/ticket", {}, rejected="Original context", rejected_code=code)
self.assertEqual((str(caught.exception), caught.exception.code), ("Original context", code))
def test_connection_limit_and_invalid_datastore_response_do_not_save(self):
before = (self.root / "pbs_servers.json").read_bytes()
with mock.patch.object(pbs, "_save_servers") as save, mock.patch.object(pbs, "_load_servers", return_value=[{"id": str(i)} for i in range(32)]):
response = self.send(data=self.server)
self.assertEqual(response.json, {"error": "At most 32 PBS connections may be configured.", "code": "pbs_connection_limit", "params": {"count": 32}})
self.assertEqual(response.status_code, 400); save.assert_not_called()
pbs._remote_get.return_value = {}
response = self.send(data=self.server)
self.assertEqual(response.json["code"], "pbs_invalid_datastores")
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
def test_login_failures_keep_saved_state_and_cleanup_a_created_token(self):
before = (self.root / "pbs_servers.json").read_bytes()
login = {"name": "Fixture", "url": "https://pbs.invalid", "username": "root@pam", "password": "fake"}
for stage, code in [("ticket", "pbs_invalid_login"), ("token", "pbs_invalid_token"), ("acl", "pbs_audit_role_denied")]:
calls = []
def request(server, method, endpoint, headers, form=None, rejected="", rejected_code="pbs_token_rejected"):
calls.append((method, endpoint))
if endpoint == "/access/ticket": return None if stage == "ticket" else {"ticket": "fake", "CSRFPreventionToken": "fake"}
if method == "POST": return None if stage == "token" else {"tokenid": "audit@pbs!fixture", "value": "fake"}
if method == "PUT": raise pbs.PBSError(rejected, code=rejected_code)
pbs._remote_request.side_effect = request
response = self.send(path="/api/pbs/servers/login", data=login)
self.assertEqual(response.status_code, 400); self.assertEqual(response.json["code"], code)
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
self.assertEqual(sum(method == "DELETE" for method, _ in calls), 1 if stage == "acl" else 0)
if __name__ == "__main__": unittest.main()