Merge remote-tracking branch 'origin/develop' into HEAD

# Conflicts:
#	.github/scripts/run_offline_qualified.py
#	lang/sk.json
This commit is contained in:
MacRimi
2026-10-21 23:13:45 +02:00
34 changed files with 2265 additions and 333 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>
)
+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>
}
+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} />)}
+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
}
+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)}`
}
+189 -7
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",
@@ -4317,6 +4370,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}",
@@ -6619,6 +6790,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."
}
}
}
+217 -35
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é",
@@ -4180,7 +4180,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ľúč.",
@@ -4224,6 +4224,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}",
@@ -4809,7 +4927,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.",
@@ -5644,7 +5762,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."
},
@@ -7302,35 +7420,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…",
@@ -7342,9 +7465,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",
@@ -7354,7 +7477,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",
@@ -7365,51 +7488,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()