"use client" import { useEffect, useState } from "react" import useSWR from "swr" import { Activity, ArchiveRestore, Database, DatabaseBackup, Download, ListChecks, RefreshCw, Scissors, Server, Settings2, ShieldCheck, Terminal, Trash2, } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { Button } from "./ui/button" import { Input } from "./ui/input" 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, 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 } interface Connections { local_available: boolean; servers: Connection[]; detected?: Detected[] } interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string } interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string } interface PBSStatus { name: string; version: string | null; fetched_at: number; services: Record; datastores: PBSDatastore[] | null; tasks: Task[] | null; sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record; error_details?: Record }>; } const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "", username: "", password: "" } type Method = "password" | "token" const NEW_CONNECTION = "__new__" const badgeTone = { ok: "bg-green-500/10 text-green-500 border-green-500/20", warn: "bg-amber-500/10 text-amber-500 border-amber-500/20", error: "bg-red-500/10 text-red-500 border-red-500/20", running: "bg-blue-500/10 text-blue-500 border-blue-500/20", muted: "bg-muted text-muted-foreground border-border", } const TASK_ICONS: Record = { backup: DatabaseBackup, reader: ArchiveRestore, verify: ShieldCheck, verificationjob: ShieldCheck, verify_group: ShieldCheck, verify_snapshot: ShieldCheck, prune: Scissors, prunejob: Scissors, garbage_collection: Trash2, sync: RefreshCw, syncjob: RefreshCw, aptupdate: Download, termproxy: Terminal, dircreate: Database, "create-datastore": Database, } // 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" } // PBS reports "OK", "WARNINGS: n" or the error text of the last run. function jobState(state?: string): keyof typeof badgeTone { if (!state || state === "unknown") return "muted" if (state.toLowerCase() === "ok") return "ok" if (state.toLowerCase().startsWith("warn")) return "warn" return "error" } // `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( "/api/pbs/servers", url => fetchApi(url), { revalidateOnFocus: false }) const [selected, setSelected] = useState("") const [manage, setManage] = useState(false) const [form, setForm] = useState(emptyForm) const [method, setMethod] = useState("password") const [busy, setBusy] = useState(false) const [message, setMessage] = useState("") const [error, setError] = useState(null) const [errorFallback, setErrorFallback] = useState("pbs.saveFailed") const { data, error: statusError, isLoading, mutate } = useSWR( selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null, url => fetchApi(url), { refreshInterval: 30000, revalidateOnFocus: false }) useEffect(() => { if (!connections) return if (selected === "local" && connections.local_available) return if (connections.servers.some(row => row.id === selected)) return setSelected(connections.local_available ? "local" : connections.servers[0]?.id || "") }, [connections, selected]) const openManage = (next = emptyForm) => { setForm(next); setMethod(next.id ? "token" : "password"); setError(""); setMessage(""); setManage(true) } 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 const result = !form.id && method === "password" ? await fetchApi<{ id: string }>("/api/pbs/servers/login", { method: "POST", body: JSON.stringify({ name: form.name, url: form.url, fingerprint: form.fingerprint, username, password, }) }) : 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(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(removed ? { body: { code: "pbs_removed_refresh_failed" }, message: pbsDiagnostic(err) } : err) } finally { setBusy(false) } } const now = Date.now() / 1000 return
{!localOnly &&

{t("pbs.title")}

} {(connectionError || statusError) &&
} {isLoading &&

{t("pbs.loading")}

} {!selected && !connectionError && connections &&

{t("pbs.noServer")}

} {!localOnly && !manage && (connections?.detected?.length || 0) > 0 && {t("pbs.detectedTitle")}

{t("pbs.detectedHelp")}

{connections?.detected?.map(row =>
{row.name} {row.url}
)}
} {!localOnly && { if (!busy) setManage(open) }}> {t("pbs.manage")} {method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")}
{!form.id &&
{(["password", "token"] as const).map(option => )}
}
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => { const secret = field === "token_secret" || field === "password" const help = field === "url" ? "pbs.urlHelp" : field === "username" ? "pbs.usernameHelp" : field === "token_id" ? "pbs.tokenIdHelp" : field === "token_secret" ? form.id ? "pbs.tokenSecretKeepHelp" : "pbs.tokenSecretHelp" : undefined return
setForm({ ...form, [field]: event.target.value })} /> {help &&

{t(help)}

}
})}

{t("pbs.tlsHelp")}

{!!error &&
} {message &&

{message}

}
{form.id && }
} {data && <> {!localOnly &&
{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString(language) })} {Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active") ? {t("pbs.servicesActive")} : Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) => {service}: {pbsServiceState(state, t)})}
} {Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks) .map(([section, detail]) =>

{t(`pbs.${["datastores", "tasks", "sync", "verify", "prune"].includes(section) ? section : "title"}`)}

)} {showStores && {t("pbs.datastores")} {data.datastores?.length === 0 &&

{t("pbs.noDatastores")}

} {data.datastores && data.datastores.length > 0 &&
{data.datastores.map(store => )}
}
} {showTasks && <> {t("pbs.tasks")} {data.tasks?.length === 0 &&

{t("pbs.noTasks")}

} {data.tasks && data.tasks.length > 0 &&
{data.tasks.map(row => { const Icon = TASK_ICONS[row.worker_type || ""] || Activity const state = taskState(row) const end = row.endtime ?? (state === "running" ? now : undefined) return })}
{t("pbs.task")} {t("pbs.started")} {t("pbs.duration")} {t("pbs.status")}
{pbsTaskType(row.worker_type, t)} {row.worker_id && {row.worker_id}} {row.starttime ? new Date(row.starttime * 1000).toLocaleString(language) : "—"} {row.starttime && end ? formatPBSDuration(end - row.starttime, t) : "—"}
}
{t("pbs.jobs")} {(() => { const jobs = (["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => ({ kind, row }))) if (jobs.length === 0) return

{t("pbs.noJobs")}

return
{jobs.map(({ kind, row }) => )}
{t("pbs.job")} {t("pbs.datastores")} {t("pbs.schedule")} {t("pbs.status")}
{t(`pbs.${kind}`)} · {row.id} {row.store || "—"} {row.schedule || "—"}
})()}
} }
} function PBSResultBadge({ status, running = false, tone }: { status?: string; running?: boolean; tone: string }) { const t = useT() const badge = {running ? t("pbs.running") : pbsResultText(status, t)} if (running || !status || status === "unknown" || /^ok$/i.test(status) || /^WARNINGS:\s*\d+\s*$/i.test(status)) return badge return
{badge}
{status}
}