mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
i18n: complete Slovak PBS dialogs, status and errors
This commit is contained in:
@@ -13,6 +13,7 @@ import sys
|
||||
import tempfile
|
||||
|
||||
PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_pbs_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links.py",
|
||||
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
|
||||
@@ -30,6 +31,7 @@ PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||
)
|
||||
NODE_FILES = (
|
||||
"tests/test_pbs_i18n.cjs",
|
||||
"tests/test_monitor_links_i18n.cjs",
|
||||
"tests/lxc_updates/test_local_app_icons_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
|
||||
|
||||
@@ -16,6 +16,7 @@ on:
|
||||
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
@@ -41,6 +42,7 @@ on:
|
||||
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
- '.github/workflows/test-pbs.yml'
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'AppImage/scripts/tests/test_pbs.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/auth_manager.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
@@ -51,6 +52,7 @@ jobs:
|
||||
- name: Platform, real TLS, APIs, health and AppImage packaging
|
||||
run: |
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
|
||||
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
|
||||
bash -n "$script"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +205,7 @@ 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">
|
||||
@@ -213,7 +217,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
})}
|
||||
</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 +236,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 +277,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 +308,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 +318,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 || /^ok$/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,18 @@
|
||||
"use client"
|
||||
|
||||
import { pbsErrorText } from "../lib/pbs-messages"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Keep PBS's original diagnostic available without making it the UI message.
|
||||
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 && <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>
|
||||
}
|
||||
@@ -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} />)}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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 from fetchApi, partial status or a datastore. Legacy
|
||||
// English errors remain diagnostics; never interpret arbitrary text as a key.
|
||||
export function pbsErrorText(error: unknown, t: Translate, fallback = "pbs.loadFailed"): string {
|
||||
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 t(fallback)
|
||||
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
|
||||
? t("pbs.errors.connectionLimit", { count }) : t(fallback)
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code) ? t(ERROR_KEYS[body.code]) : 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)}`
|
||||
}
|
||||
@@ -72,7 +72,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",
|
||||
|
||||
@@ -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é",
|
||||
@@ -7299,35 +7299,35 @@
|
||||
"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ľ (user@realm)",
|
||||
"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)",
|
||||
"url": "Adresa HTTPS (pre štandardný port PBS uveď :8007)",
|
||||
"token_id": "ID API tokenu (user@realm!token)",
|
||||
"token_secret": "Tajný kľúč API tokenu (ponechajte prázdne, ak chcete zachovať uložený kľúč)",
|
||||
"token_secret": "Tajný kľúč API tokenu (prázdne pole zachová 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.",
|
||||
"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…",
|
||||
@@ -7339,9 +7339,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",
|
||||
@@ -7351,7 +7351,7 @@
|
||||
"schedule": "Plán",
|
||||
"sync": "Synchronizácia",
|
||||
"verify": "Overenie",
|
||||
"prune": "Prerezávanie",
|
||||
"prune": "Prerezávanie záloh",
|
||||
"overview": "Prehľad",
|
||||
"logs": "Systémové záznamy",
|
||||
"terminal": "Terminál",
|
||||
@@ -7362,13 +7362,61 @@
|
||||
"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 prerezávania 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": "Prerezávanie 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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,134 @@
|
||||
// Shipped PBS code with only transport, hooks and UI primitives replaced.
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const compile = source => ts.transpileModule(source, {compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX}}).outputText;
|
||||
const catalogs = Object.fromEntries(['en','sk'].map(l => [l, JSON.parse(fs.readFileSync(`AppImage/messages/${l}/common.json`))]));
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const value = key.split('.').reduce((tree, part) => tree?.[part], catalogs[locale]);
|
||||
assert.equal(typeof value, 'string', key);
|
||||
return value.replace(/\{(\w+)\}/g, (m,k) => params[k] ?? m);
|
||||
};
|
||||
const library = {exports: {}};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/pbs-messages.ts','utf8')), library);
|
||||
const messages = library.exports;
|
||||
const failure = code => Object.assign(new Error('RAW DIAGNOSTIC'), {body: {error:'RAW DIAGNOSTIC',code}});
|
||||
const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten)
|
||||
: typeof n === 'object' ? n.type === 'Dialog' && !n.props.open ? [] : [n,...flatten(n.props?.children)] : [n];
|
||||
const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
|
||||
function fixture(locale='sk', options={}) {
|
||||
let cursor=0, slots=[], effects=[], tree, apiError, reloadError;
|
||||
const calls=[], confirmations=[], reloads=[];
|
||||
const servers={local_available:false,servers:[{id:'saved',name:'Saved PBS',url:'https://saved.invalid:8007',token_id:'audit@pbs!monitor',has_token:true,fingerprint:''}],detected:[]};
|
||||
const status={name:'Fixture',version:'4.2.5',fetched_at:1700000000,services:{'proxmox-backup':'inactive'},datastores:[],tasks:[],sync:[],verify:[],prune:[],errors:{}};
|
||||
const t=translate(locale), loaded={};
|
||||
const jsx=(type,props) => typeof type === 'function' ? type(props) : ({type,props});
|
||||
const react={useState(initial){const i=cursor++;if(!(i in slots))slots[i]=typeof initial==='function'?initial():initial;return [slots[i],value=>{slots[i]=typeof value==='function'?value(slots[i]):value;}];},
|
||||
useEffect(fn,deps){const i=cursor++, old=slots[i];if(!old||deps.some((d,j)=>d!==old[j]))effects.push(fn);slots[i]=deps;}};
|
||||
function requireModule(name) {
|
||||
if(name==='react')return react;
|
||||
if(name==='react/jsx-runtime')return {jsx,jsxs:jsx,Fragment:'Fragment'};
|
||||
if(name==='swr')return {default:key=>({data:key?.startsWith('/api/pbs/status')?status:key?servers:undefined,error:options.loadError,
|
||||
isLoading:false,mutate:async()=>{reloads.push(key);if(reloadError)throw reloadError;}})};
|
||||
if(name.includes('i18n/provider'))return {useT:()=>t,useI18n:()=>({language:locale})};
|
||||
if(name.includes('pbs-messages'))return messages;
|
||||
if(name.includes('api-config'))return {fetchApi:async(path,opts)=>{calls.push({path,...opts});if(apiError)throw apiError;return {id:'new',success:true};}};
|
||||
if(name.endsWith('pbs-problem-message'))return load('AppImage/components/pbs-problem-message.tsx');
|
||||
if(name.endsWith('pbs-datastore-usage'))return load('AppImage/components/pbs-datastore-usage.tsx');
|
||||
if(name.includes('lib/utils'))return {formatStorage:n=>String(n)+' GB'};
|
||||
return new Proxy({}, {get:(_,key)=>key});
|
||||
}
|
||||
function load(path) {if(loaded[path])return loaded[path];const e={};loaded[path]=e;
|
||||
vm.runInNewContext(compile(fs.readFileSync(path,'utf8')), {exports:e,require:requireModule,Date,window:{confirm:message=>{confirmations.push(message);return options.confirm===true;}}});return e;}
|
||||
const panel=load('AppImage/components/pbs-panel.tsx').PBSPanel;
|
||||
return {t,servers,status,calls,confirmations,reloads,jsx,requireModule,
|
||||
render(props){cursor=0;effects=[];tree=panel(props);effects.forEach(fn=>fn());return tree;},
|
||||
nodes:()=>flatten(tree),text:()=>text(tree),
|
||||
button(label){const n=flatten(tree).find(n=>n.type==='Button'&&text(n)===label);assert.ok(n,label);return n;},
|
||||
input(field){const n=flatten(tree).find(n=>n.type==='Input'&&n.props.id==='pbs-'+field);assert.ok(n,field);return n;},
|
||||
select(label){const n=flatten(tree).find(n=>n.type==='Select'&&flatten(n).some(n=>n.props?.['aria-label']===label));assert.ok(n,label);return n;},
|
||||
fail:e=>{apiError=e;},failReload:e=>{reloadError=e;},load,
|
||||
};
|
||||
}
|
||||
|
||||
function overview(locale, sourcePath='AppImage/components/system-overview.tsx') {
|
||||
const source=fs.readFileSync(sourcePath,'utf8'), ast=ts.createSourceFile('overview.tsx',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX), branches=[];
|
||||
function visit(n){if(ts.isConditionalExpression(n)&&n.condition.getText(ast)==='isPbs'&&n.whenTrue.getText(ast).includes('pbs.datastores'))branches.push(n.whenTrue.getText(ast));ts.forEachChild(n,visit);}visit(ast);
|
||||
assert.equal(branches.length,2,'overview summary and storage details both consume PBS');
|
||||
const f=fixture(locale);
|
||||
for(const branch of branches) {
|
||||
for(const state of ['partial','network','empty','loading']) {
|
||||
const pbsStatus=state==='loading'||state==='network'?undefined:{datastores:state==='empty'?[]:null,errors:{datastores:'RAW DIAGNOSTIC'},error_details:{datastores:{code:'pbs_token_rejected'}}};
|
||||
const context={isPbs:true,pbsStatus,pbsError:state==='network'?failure('pbs_connect_failed'):undefined,datastores:pbsStatus?.datastores,
|
||||
t:f.t,require:f.requireModule,pbsDiagnostic:messages.pbsDiagnostic,PBSProblemMessage:f.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage,
|
||||
DatastoreUsageRow:f.load('AppImage/components/pbs-datastore-usage.tsx').DatastoreUsageRow};
|
||||
for(const name of ['Card','CardHeader','CardTitle','CardContent','Database','Badge'])context[name]=name;
|
||||
vm.runInNewContext(compile('exports.result=('+branch+');'),{...context,exports:context});
|
||||
const rendered=text(context.result);
|
||||
if(state==='partial') {assert.ok(rendered.includes(f.t('pbs.errors.tokenRejected')));assert.ok(!rendered.includes(f.t('pbs.loading')));assert.ok(!flatten(context.result).some(n=>n.props?.className?.includes('animate-pulse')));}
|
||||
if(state==='network')assert.ok(rendered.includes(f.t('pbs.errors.connectFailed')));
|
||||
if(state==='empty')assert.ok(rendered.includes(f.t('pbs.noDatastores')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const backend=fs.readFileSync('AppImage/scripts/pbs_manager.py','utf8')+fs.readFileSync('AppImage/scripts/flask_pbs_routes.py','utf8');
|
||||
const codes=new Set([...backend.matchAll(/(?:code|rejected_code)="(pbs_\w+)"/g)].map(m=>m[1]));
|
||||
for(const locale of ['en','sk']) {
|
||||
const t=translate(locale);
|
||||
for(const code of codes) {
|
||||
if(code==='pbs_unknown')continue;
|
||||
const err=failure(code);err.body.params={count:32};
|
||||
assert.notEqual(messages.pbsErrorText(err,t),t('pbs.loadFailed'),code);
|
||||
assert.ok(!messages.pbsErrorText(err,t).includes('RAW'));
|
||||
}
|
||||
for(const err of [null,undefined,'raw',new Error('raw'),{body:null},{code:'constructor'},{code:'__proto__'},{code:'future'},
|
||||
...[null,0,-1,1.2,'32',Infinity,NaN,2**54].map(count=>({code:'pbs_connection_limit',params:{count}}))]) {
|
||||
assert.equal(messages.pbsErrorText(err,t),t('pbs.loadFailed'));
|
||||
}
|
||||
assert.equal(messages.pbsResultText('OK',t),t('pbs.states.ok'));
|
||||
assert.equal(messages.pbsResultText('WARNINGS: 2',t),t('pbs.states.warnings',{count:2}));
|
||||
assert.equal(messages.pbsResultText(undefined,t),t('pbs.states.unknown'));
|
||||
assert.equal(messages.pbsResultText('unknown',t),t('pbs.states.unknown'));
|
||||
assert.equal(messages.pbsResultText('TASK ERROR: raw',t),t('pbs.states.error'));
|
||||
for(const n of [-1,NaN,Infinity])assert.equal(messages.formatPBSDuration(n,t),'—');
|
||||
for(const [n,label] of [[0,'0 s'],[59,'59 s'],[60,'1 min 0 s'],[3599,'59 min 59 s'],[3600,'1 h 0 min'],[86399,'23 h 59 min'],[86400,'1 d 0 h']])assert.equal(messages.formatPBSDuration(n,t),label);
|
||||
assert.equal(messages.pbsTaskType('garbage_collection',t),t('pbs.taskTypes.garbageCollection'));
|
||||
assert.equal(messages.pbsTaskType('future',t),t('pbs.taskTypes.other',{type:'future'}));
|
||||
assert.equal(messages.pbsServiceState('constructor',t),t('pbs.serviceStates.unknown'));
|
||||
const unknown=fixture(locale);unknown.status.tasks=[{upid:'unknown',endtime:1700000000,status:'unknown'}];unknown.status.sync=[{id:'unknown','last-run-state':'unknown'}];
|
||||
unknown.render();unknown.render();
|
||||
const unknownBadges=unknown.nodes().filter(n=>n.type==='Badge'&&text(n)===t('pbs.states.unknown'));
|
||||
assert.equal(unknownBadges.length,2);assert.ok(unknownBadges.every(n=>!n.props.className.includes('red-')));
|
||||
const f=fixture(locale);f.render();f.render();f.button(t('pbs.manage')).props.onClick();f.render();
|
||||
f.input('name').props.onChange({target:{value:'Draft'}});f.render();
|
||||
f.input('password').props.onChange({target:{value:'fake-one-time-password'}});f.render();
|
||||
f.button(t('pbs.cancel')).props.onClick();f.render();assert.equal(f.calls.length,0);
|
||||
f.button(t('pbs.manage')).props.onClick();f.render();assert.equal(f.input('password').props.value,'');
|
||||
f.input('name').props.onChange({target:{value:'Draft'}});f.render();f.input('url').props.onChange({target:{value:'http://bad.invalid'}});f.render();
|
||||
f.fail(failure('pbs_invalid_url'));await f.button(t('pbs.save')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.errors.invalidUrl')));assert.equal(f.input('url').props.value,'http://bad.invalid');
|
||||
assert.equal(f.calls[0].path,'/api/pbs/servers/login');assert.ok(!Object.hasOwn(JSON.parse(f.calls[0].body),'token_secret'));
|
||||
f.button(t('pbs.methodToken')).props.onClick();f.render();await f.button(t('pbs.save')).props.onClick();f.render();
|
||||
assert.equal(f.calls[1].path,'/api/pbs/servers');assert.ok(!Object.hasOwn(JSON.parse(f.calls[1].body),'password'));
|
||||
f.select(t('pbs.editConnection')).props.onValueChange('saved');f.render();await f.button(t('pbs.remove')).props.onClick();f.render();
|
||||
assert.deepEqual(f.confirmations,[t('pbs.removeConfirm')]);assert.equal(f.calls.length,2,'Declined remove has no DELETE');
|
||||
const remove=fixture(locale,{confirm:true});remove.render();remove.render();remove.button(t('pbs.manage')).props.onClick();remove.render();remove.select(t('pbs.editConnection')).props.onValueChange('saved');remove.render();
|
||||
await remove.button(t('pbs.remove')).props.onClick();remove.render();assert.equal(remove.calls.length,1);assert.equal(remove.calls[0].method,'DELETE');
|
||||
const failedRemove=fixture(locale,{confirm:true});failedRemove.render();failedRemove.render();failedRemove.button(t('pbs.manage')).props.onClick();failedRemove.render();failedRemove.select(t('pbs.editConnection')).props.onValueChange('saved');failedRemove.render();failedRemove.fail(new Error('network failure'));
|
||||
await failedRemove.button(t('pbs.remove')).props.onClick();failedRemove.render();assert.ok(failedRemove.text().includes(t('pbs.removeFailed')));assert.equal(failedRemove.calls.length,1);
|
||||
const failedReload=fixture(locale);failedReload.render();failedReload.render();failedReload.button(t('pbs.manage')).props.onClick();failedReload.render();failedReload.failReload(new Error('raw reload'));
|
||||
for(const [field,value] of Object.entries({name:'Valid',url:'https://fixture.invalid',username:'root@pam',password:'fake'})){failedReload.input(field).props.onChange({target:{value}});failedReload.render();}
|
||||
assert.equal(failedReload.button(t('pbs.save')).props.disabled,false);
|
||||
await failedReload.button(t('pbs.save')).props.onClick();failedReload.render();assert.equal(failedReload.calls.length,1,'No automatic mutation retry after refresh fails');assert.ok(failedReload.text().includes(t('pbs.savedRefreshFailed')));
|
||||
const removeReload=fixture(locale,{confirm:true});removeReload.render();removeReload.render();removeReload.button(t('pbs.manage')).props.onClick();removeReload.render();removeReload.select(t('pbs.editConnection')).props.onValueChange('saved');removeReload.render();removeReload.failReload(new Error('network'));
|
||||
await removeReload.button(t('pbs.remove')).props.onClick();removeReload.render();assert.equal(removeReload.calls.length,1);assert.ok(removeReload.text().includes(t('pbs.removedRefreshFailed')));
|
||||
const store=f.load('AppImage/components/pbs-datastore-usage.tsx').PBSDatastoreCard({store:{name:'Offline',total:null,used:null,error:'RAW DIAGNOSTIC',error_code:'pbs_datastore_offline'}});
|
||||
assert.ok(text(store).includes(t('pbs.errors.datastoreOffline')));assert.ok(!text(store).includes('0 GB'));
|
||||
overview(locale);
|
||||
}
|
||||
console.log('PASS: all structured PBS error codes, EN/SK/unknown diagnostics, forms/login/token/remove/cancel, no automatic retry, task/service/duration states and both real overview branches');
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1});
|
||||
module.exports={fixture,overview};
|
||||
Reference in New Issue
Block a user