feat(monitor): sign in to a PBS with user and password and add a Tasks tab

- Connect a PBS with user and password, creating a read-only API token
- Never store the password; remove the token when the role cannot be granted
- Show the PBS connection form as a dialog with the project contrast and blue actions
- Move recent tasks and maintenance schedules of a PBS to a new Tasks tab
- Keep the Datastores tab for datastores, disks and pools
This commit is contained in:
MacRimi
2026-10-19 23:12:31 +02:00
parent cb585632c6
commit 77a6d1c397
13 changed files with 282 additions and 61 deletions
+92 -48
View File
@@ -12,6 +12,7 @@ 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 { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
@@ -27,7 +28,8 @@ interface PBSStatus {
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
}
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" }
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",
@@ -69,14 +71,17 @@ function formatDuration(seconds: number) {
}
// `localOnly` is the PBS host's own Monitor: no connection picker, the
// header Refresh already reloads the page data.
export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
// 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 { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
const [selected, setSelected] = useState("")
const [manage, setManage] = useState(false)
const [form, setForm] = useState(emptyForm)
const [method, setMethod] = useState<Method>("password")
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("")
const [error, setError] = useState("")
@@ -91,16 +96,26 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
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 () => {
setBusy(true); setError(""); setMessage("")
try {
const result = await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
...form, id: form.id || undefined, token_secret: form.token_secret || undefined,
}) })
// 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,
}) })
await reloadConnections()
setSelected(result.id)
setForm(emptyForm)
setMessage(t("pbs.saved"))
setManage(false)
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
finally { setBusy(false) }
}
@@ -130,7 +145,7 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
<Button variant="outline" size="sm" disabled={isLoading || !selected} onClick={() => mutate()}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />{t("pbs.refresh")}
</Button>
<Button variant="outline" size="sm" onClick={() => setManage(!manage)}>
<Button variant="outline" size="sm" onClick={() => openManage()}>
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
</Button>
</div>}
@@ -147,56 +162,83 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
<span className="block truncate text-sm font-medium text-foreground">{row.name}</span>
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
</span>
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => {
setForm({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint })
setError(""); setMessage(""); setManage(true)
}}>{t("pbs.connect")}</Button>
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700"
onClick={() => openManage({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint })}>
{t("pbs.connect")}
</Button>
</div>)}
</CardContent>
</Card>}
{manage && !localOnly && <Card>
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{t("pbs.permissionsHelp")}</p>
<Select value={form.id || NEW_CONNECTION} onValueChange={value => {
const row = connections?.servers.find(item => item.id === value)
setForm(row ? { ...row, token_secret: "" } : emptyForm)
}}>
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NEW_CONNECTION}>{t("pbs.add")}</SelectItem>
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
</SelectContent>
</Select>
<div className="grid gap-4 md:grid-cols-2">
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
<div key={field} className="space-y-2">
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
<Input id={`pbs-${field}`} disabled={busy} type={field === "token_secret" ? "password" : field === "url" ? "url" : "text"}
autoComplete={field === "token_secret" ? "new-password" : "off"}
value={form[field]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
</div>)}
{!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">
<DialogHeader>
<DialogTitle>{t("pbs.manage")}</DialogTitle>
<DialogDescription>{method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("pbs.editConnection")}</Label>
<Select value={form.id || NEW_CONNECTION} disabled={busy} onValueChange={value => {
const row = connections?.servers.find(item => item.id === value)
setForm(row ? { ...emptyForm, ...row, token_secret: "" } : emptyForm)
setMethod(row ? "token" : "password"); setError("")
}}>
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NEW_CONNECTION}>{t("pbs.add")}</SelectItem>
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{!form.id && <div className="space-y-2">
<Label>{t("pbs.signInWith")}</Label>
<div className="grid grid-cols-2 gap-2">
{(["password", "token"] as const).map(option =>
<Button key={option} type="button" variant="outline" disabled={busy} onClick={() => { setMethod(option); setError("") }}
className={method === option ? "border-blue-600 bg-blue-600/15 text-foreground hover:bg-blue-600/20" : "bg-background"}>
{t(option === "password" ? "pbs.methodPassword" : "pbs.methodToken")}
</Button>)}
</div>
</div>}
<div className="grid gap-4 md:grid-cols-2">
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => {
const secret = field === "token_secret" || field === "password"
return <div key={field} className="space-y-2">
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
<Input id={`pbs-${field}`} disabled={busy} type={secret ? "password" : field === "url" ? "url" : "text"}
autoComplete={secret ? "new-password" : "off"} placeholder={field === "username" ? "root@pam" : undefined}
value={form[field as keyof typeof form]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
</div>
})}
</div>
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
{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}>
<Trash2 className="mr-2 h-4 w-4" />{t("pbs.remove")}
</Button>}
<Button variant="outline" disabled={busy} onClick={() => setManage(false)}>{t("pbs.cancel")}</Button>
<Button className="bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
disabled={busy || !form.url.trim() || !form.name.trim()} onClick={save}>
{busy ? t("pbs.testing") : t("pbs.save")}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
<div className="flex gap-2">
<Button disabled={busy} onClick={save}>{busy ? t("pbs.testing") : t("pbs.save")}</Button>
{form.id && <Button variant="destructive" disabled={busy} onClick={remove}>{t("pbs.remove")}</Button>}
</div>
</CardContent>
</Card>}
</DialogContent>
</Dialog>}
{data && <>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{!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>
{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>)}
</div>
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
</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>)}
<Card className="bg-card border-border">
{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>
<CardContent>
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
@@ -204,8 +246,9 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
{data.datastores.map(store => <PBSDatastoreCard key={store.name} store={store} />)}
</div>}
</CardContent>
</Card>
</Card>}
{showTasks && <>
<Card className="bg-card border-border">
<CardHeader><CardTitle className="flex items-center text-foreground"><Activity className="mr-2 h-5 w-5" />{t("pbs.tasks")}</CardTitle></CardHeader>
<CardContent>
@@ -261,6 +304,7 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
})()}
</CardContent>
</Card>
</>}
</>}
</div>
}
+13 -4
View File
@@ -32,6 +32,7 @@ import {
CheckCircle,
XCircle,
Server,
ListChecks,
Menu,
LayoutDashboard,
HardDrive,
@@ -116,11 +117,11 @@ function PBSDashboard() {
}
type Product = "pve" | "pbs"
type TopTab = TabId | "datastores"
type TopTab = TabId | "datastores" | "pbs-tasks"
// Proxmox Backup Server has no guests, apps or PVE storage: it keeps the
// same header, health and administration with its own top-level sections.
const PBS_TABS: TopTab[] = ["overview", "datastores", "terminal", "admin"]
const PBS_TABS: TopTab[] = ["overview", "datastores", "pbs-tasks", "terminal", "admin"]
function MonitorDashboard({ product }: { product: Product }) {
const t = useT()
@@ -421,6 +422,7 @@ function MonitorDashboard({ product }: { product: Product }) {
switch (activeTab) {
case "overview": return t("navigation.overview")
case "datastores": return t("pbs.datastores")
case "pbs-tasks": return t("pbs.tasksTab")
case "apps": return t("navigation.apps")
case "vms": return t("navigation.virtualMachines")
case "storage": return t("navigation.storage")
@@ -717,6 +719,7 @@ function MonitorDashboard({ product }: { product: Product }) {
switch (id) {
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return renderDirect("datastores", Database, t("pbs.datastores"))
case "pbs-tasks": return renderDirect("pbs-tasks", ListChecks, t("pbs.tasksTab"))
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
@@ -726,7 +729,7 @@ function MonitorDashboard({ product }: { product: Product }) {
}
}
return (
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-4" : "grid-cols-7"} bg-card border border-border`}>
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-5" : "grid-cols-7"} bg-card border border-border`}>
{tabOrder.map(renderTop)}
</TabsList>
)
@@ -788,6 +791,7 @@ function MonitorDashboard({ product }: { product: Product }) {
switch (id) {
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return btn("datastores", Database, t("pbs.datastores"))
case "pbs-tasks": return btn("pbs-tasks", ListChecks, t("pbs.tasksTab"))
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
@@ -842,10 +846,15 @@ function MonitorDashboard({ product }: { product: Product }) {
{isPbs && (
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
<PBSPanel key={`datastores-${componentKey}`} localOnly />
<PBSPanel key={`datastores-${componentKey}`} localOnly view="datastores" />
<StorageOverview key={`datastore-disks-${componentKey}`} disksOnly />
</TabsContent>
)}
{isPbs && (
<TabsContent value="pbs-tasks" className="space-y-4 md:space-y-6 mt-0">
<PBSPanel key={`pbs-tasks-${componentKey}`} localOnly view="tasks" />
</TabsContent>
)}
{!isPbs && <>
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">