feat(pbs): post-install menu for Proxmox Backup Server

- Open the post-install menu from the PBS menu
- List only the customizable options that apply to PBS
- Offer the PBS no-subscription repository through the native repository API
- Upgrade PBS with the package list shown before applying it
- Remove and restore the subscription notice on the PBS web interface
- Install Figurine on PBS without the Proxmox VE aliases
- Offer the Helper-Scripts PBS post-install and microcode scripts
- Tune firewall bridges after the systemd udev rules in the automated post-install
- Add PBS repository policy tests and syntax checks
- Document the PBS differences on the post-install page
- Use backups in the Spanish PBS update message
This commit is contained in:
MacRimi
2026-10-17 18:13:44 +02:00
parent ce1a3de719
commit 98ae1f3626
34 changed files with 1976 additions and 227 deletions
+13 -6
View File
@@ -42,6 +42,7 @@ import {
} from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { useT } from "@/lib/i18n/provider"
import { useIsPbs } from "@/lib/use-platform"
interface CategoryCheck {
status: string
@@ -119,6 +120,7 @@ const CATEGORIES = [
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
const t = useT()
const isPbs = useIsPbs()
const [loading, setLoading] = useState(true)
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
@@ -460,6 +462,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
return originalStatus?.toUpperCase() || "UNKNOWN"
}
// Checks the host reports as not applicable (guests on PBS) are not listed or counted.
const visibleCategories = CATEGORIES.filter(({ key }) =>
!(healthData?.details?.[key as keyof typeof healthData.details] as { not_applicable?: boolean } | undefined)?.not_applicable)
const categoryLabel = (key: string) => t(`healthStatus.categories.${isPbs && key === "services" ? "pbsServices" : key}`)
const getHealthStats = () => {
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
@@ -469,7 +476,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
let critical = 0
let unknown = 0
CATEGORIES.forEach(({ key }) => {
visibleCategories.forEach(({ key }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details]
if (categoryData) {
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
@@ -481,7 +488,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}
})
return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown }
return { total: visibleCategories.length, healthy, info, warnings, critical, unknown }
}
const stats = getHealthStats()
@@ -759,7 +766,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{/* Category List */}
<div className="space-y-2">
{CATEGORIES.map(({ key, Icon }) => {
{visibleCategories.map(({ key, Icon }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details]
const originalStatus = categoryData?.status || "UNKNOWN"
const status = getEffectiveStatus(key, originalStatus)
@@ -784,7 +791,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1.5 sm:gap-2">
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
<p className="font-medium text-xs sm:text-sm truncate">{categoryLabel(key)}</p>
{hasChecks && (
<span className="text-[10px] text-muted-foreground shrink-0">
({Object.values(checks).filter(c => c.installed !== false).length})
@@ -871,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{filteredDismissed.map((item) => {
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
const CatIcon = catMeta?.Icon || BellOff
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
const catLabel = catMeta ? categoryLabel(catMeta.key) : item.category
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
return (
@@ -936,7 +943,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div key={cs.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? categoryLabel(catMeta.key) : cs.label}</span>
</div>
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
{durationLabel}
+283
View File
@@ -0,0 +1,283 @@
"use client"
import { useEffect, useState } from "react"
import useSWR, { mutate as refreshCache } from "swr"
import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Plus, Server, Trash2 } from "lucide-react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Switch } from "./ui/switch"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface MonitorLink {
key: string
name: string
product: "pve" | "pbs"
group: "cluster" | "standalone" | "pbs"
url: string | null
status: "online" | "offline" | "unknown"
native_url?: string | null
manual: boolean
hidden: boolean
custom_url: boolean
}
interface MonitorLinks { self: { name: string; product: string }; links: MonitorLink[] }
const GROUPS = ["cluster", "standalone", "pbs"] as const
const fetcher = (url: string) => fetchApi<MonitorLinks>(url)
const ORIGIN_KEY = "proxmenux-came-from"
interface Origin { url: string; name: string; product: "pve" | "pbs" }
// The Monitor a jump came from, as the browser reached it. Only a bare http(s)
// origin is accepted, and the parameters leave the address at once.
function takeOrigin(): (Origin & { fresh?: boolean }) | null {
if (typeof window === "undefined") return null
const params = new URLSearchParams(window.location.search)
const from = params.get("pmx_from")
if (from !== null) {
const name = params.get("pmx_name") || ""
const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve"
for (const key of ["pmx_from", "pmx_name", "pmx_product"]) params.delete(key)
const query = params.toString()
window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash)
try {
const parsed = new URL(from)
if (/^https?:$/.test(parsed.protocol) && parsed.origin === from && parsed.origin !== window.location.origin) {
const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product }
try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ }
return { ...origin, fresh: true }
}
} catch { /* not an address */ }
}
try {
const stored = JSON.parse(sessionStorage.getItem(ORIGIN_KEY) || "null")
return stored && typeof stored.url === "string" && /^https?:\/\//.test(stored.url) ? stored : null
} catch { return null }
}
// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there.
function destination(link: MonitorLink): { href: string; native: boolean } | null {
if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true }
return link.url ? { href: link.url, native: false } : null
}
function ProductIcon({ product, className }: { product: string; className?: string }) {
return product === "pbs" ? <DatabaseBackup className={className} /> : <Server className={className} />
}
export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: string; compact?: boolean }) {
const t = useT()
const [manageOpen, setManageOpen] = useState(false)
// The backend keeps the list ready, so the menu opens already filled.
const { data, error, isLoading } = useSWR<MonitorLinks>("/api/monitor-links", fetcher, { revalidateOnFocus: false, refreshInterval: 300000 })
const [origin, setOrigin] = useState<(Origin & { fresh?: boolean }) | null>(null)
const [offerOrigin, setOfferOrigin] = useState(false)
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState("")
useEffect(() => { setOrigin(takeOrigin()) }, [])
const originKnown = !!origin && !!data?.links.some(link => link.url === origin.url)
useEffect(() => {
// Offered once, right after arriving, when this Monitor has no way back yet.
if (origin?.fresh && data && !originKnown) setOfferOrigin(true)
}, [origin, data, originKnown])
// The address carries where the jump comes from, so the other Monitor can offer the way back.
const withOrigin = (href: string) => {
const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" })
return `${href}/?${params.toString()}`
}
const open = (href: string, newTab: boolean) => {
if (newTab) window.open(href, "_blank", "noopener,noreferrer")
else window.location.assign(href)
}
const go = (link: MonitorLink, newTab: boolean) => {
const target = destination(link)
if (!target) return
if (target.native) open(target.href, true)
else open(withOrigin(target.href), newTab)
}
const addOrigin = async () => {
if (!origin) return
setAdding(true); setAddError("")
try {
await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: origin.name, url: origin.url, product: origin.product }) })
await refreshCache("/api/monitor-links")
setOfferOrigin(false)
} catch (err) {
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
} finally { setAdding(false) }
}
return <div onClick={event => event.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className={`gap-2 border-border bg-transparent hover:bg-secondary ${compact ? "h-8 px-2.5 text-xs" : ""}`}>
<Server className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
<span className="max-w-[12rem] truncate">{t("status.node", { node: nodeName })}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t("monitorLinks.thisNode")}</DropdownMenuLabel>
<DropdownMenuItem disabled className="gap-2 opacity-100">
<span className="h-2 w-2 rounded-full bg-green-500" />
<ProductIcon product={data?.self.product || ""} className="h-4 w-4 text-muted-foreground" />
<span className="truncate font-medium">{nodeName}</span>
</DropdownMenuItem>
{origin && !originKnown && <DropdownMenuItem className="gap-2" onSelect={() => open(withOrigin(origin.url), false)}>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{t("monitorLinks.backTo", { name: origin.name })}</span>
</DropdownMenuItem>}
{isLoading && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.checking")}</p>}
{error && <p className="px-2 py-1.5 text-xs text-amber-500">{t("monitorLinks.loadFailed")}</p>}
{data && data.links.length === 0 && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.empty")}</p>}
{data && GROUPS.map(group => {
const rows = data.links.filter(link => link.group === group)
if (rows.length === 0) return null
return <div key={group}>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t(`monitorLinks.groups.${group}`)}</DropdownMenuLabel>
{rows.map(link => {
const target = destination(link)
return <DropdownMenuItem key={link.key} disabled={!target} className="gap-2" onSelect={() => go(link, false)}>
<span className={`h-2 w-2 shrink-0 rounded-full ${link.status === "online" ? "bg-green-500" : "bg-gray-500"}`} />
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{link.name}</span>
{target?.native
? <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsInterface")}</span>
: link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
{target && <button type="button" aria-label={t("monitorLinks.openNewTab")} title={t("monitorLinks.openNewTab")}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={event => { event.preventDefault(); event.stopPropagation(); go(link, true) }}>
<ExternalLink className="h-3.5 w-3.5" />
</button>}
</DropdownMenuItem>
})}
</div>
})}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2" onSelect={() => setManageOpen(true)}>
<Plus className="h-4 w-4" />{t("monitorLinks.manage")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
{origin && <Dialog open={offerOrigin} onOpenChange={setOfferOrigin}>
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t("monitorLinks.cameFromTitle")}</DialogTitle>
<DialogDescription>{t("monitorLinks.cameFromBody", { name: origin.name })}</DialogDescription>
</DialogHeader>
<p className="break-all rounded-md border border-border bg-muted/40 px-3 py-2 font-mono text-sm">{origin.url}</p>
{addError && <p role="alert" className="text-sm text-red-500">{addError}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" disabled={adding} onClick={() => setOfferOrigin(false)}>{t("monitorLinks.notNow")}</Button>
<Button disabled={adding} onClick={addOrigin}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
</div>
</DialogContent>
</Dialog>}
</div>
}
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const t = useT()
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
const [addresses, setAddresses] = useState<Record<string, string>>({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState("")
const run = async (action: () => Promise<unknown>) => {
setBusy(true); setError("")
try {
await action()
await mutate()
await refreshCache("/api/monitor-links")
} catch (err) {
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
} finally { setBusy(false) }
}
const send = (path: string, method: string, body?: unknown) =>
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
<DialogDescription>{t("monitorLinks.dialogDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
<div className="space-y-1.5">
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor="monitor-link-url">{t("monitorLinks.address")}</Label>
<Input id="monitor-link-url" value={form.url} disabled={busy} placeholder="https://192.168.1.20:8008"
onChange={event => setForm({ ...form, url: event.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor="monitor-link-product">{t("monitorLinks.type")}</Label>
<Select value={form.product} disabled={busy} onValueChange={product => setForm({ ...form, product })}>
<SelectTrigger id="monitor-link-product" className="w-full sm:w-56"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pve">Proxmox VE</SelectItem>
<SelectItem value="pbs">Proxmox Backup Server</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button size="sm" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
await send("/api/monitor-links", "POST", form)
setForm({ name: "", url: "", product: "pve" })
})}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
</div>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
<div className="space-y-3">
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
{data?.links.map(link => {
const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "")
return <div key={link.key} className={`space-y-2 rounded-lg border border-border p-3 ${link.hidden ? "opacity-60" : ""}`}>
<div className="flex items-center gap-2">
<ProductIcon product={link.product} className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{link.name}</span>
<span className="text-xs text-muted-foreground">{link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")}</span>
{!link.manual && <label className="flex items-center gap-2 text-xs text-muted-foreground">
{t("monitorLinks.visible")}
<Switch checked={!link.hidden} disabled={busy} className="data-[state=checked]:bg-blue-600"
onCheckedChange={checked => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { hidden: !checked }))} />
</label>}
{link.manual && <Button variant="ghost" size="sm" disabled={busy} aria-label={t("monitorLinks.remove")}
onClick={() => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "DELETE"))}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>}
</div>
<div className="flex gap-2">
<Input value={address} disabled={busy} aria-label={t("monitorLinks.address")}
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${link.url ? `: ${link.url}` : ""}`}
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
<Button variant="outline" size="sm" className="h-10" disabled={busy || addresses[link.key] === undefined}
onClick={() => run(async () => {
await send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { url: addresses[link.key] })
setAddresses(({ [link.key]: _, ...rest }) => rest)
})}>{t("monitorLinks.save")}</Button>
</div>
</div>
})}
</div>
</DialogContent>
</Dialog>
}
+10 -4
View File
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { useIsPbs } from "../lib/use-platform"
import {
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
@@ -154,6 +155,8 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } {
}
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key }))
// Guests, vzdump backups and the cluster do not exist on Proxmox Backup Server.
const PVE_ONLY_CATEGORIES = new Set(["vm_ct", "backup", "cluster"])
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
@@ -330,6 +333,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
export function NotificationSettings() {
const t = useT()
const isPbs = useIsPbs()
const { resolvedTheme } = useTheme()
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
const [status, setStatus] = useState<ServiceStatus | null>(null)
@@ -703,7 +707,7 @@ export function NotificationSettings() {
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.notificationCategories")}</Label>
</div>
<div className="space-y-2">
{EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => {
{EVENT_CATEGORIES.filter(cat => cat.key !== "other" && !(isPbs && PVE_ONLY_CATEGORIES.has(cat.key))).map(cat => {
const isEnabled = overrides.categories[cat.key] ?? true
const isExpanded = expandedCategories.has(`${chName}.${cat.key}`)
// Hide the LXC update toggle when the user has disabled the
@@ -922,7 +926,7 @@ export function NotificationSettings() {
const wasEnabled = originalConfig.enabled
const isNowDisabled = !nextConfig.enabled
if (wasEnabled && isNowDisabled) {
if (wasEnabled && isNowDisabled && !isPbs) {
try {
await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" })
} catch {
@@ -1217,8 +1221,10 @@ export function NotificationSettings() {
setConfig(newConfig)
setOriginalConfig(newConfig)
// 2) Auto-configure PVE webhook
try {
// 2) Auto-configure PVE webhook; PBS has no /etc/pve notifications
if (isPbs) {
setWebhookSetup({ status: "idle", fallback_commands: [], error: "" })
} else try {
const setup = await fetchApi<{
configured: boolean
secret?: string
-44
View File
@@ -1,44 +0,0 @@
"use client"
import { useState } from "react"
import useSWR from "swr"
import { PBSPanel } from "./pbs-panel"
import { Security } from "./security"
import { SystemLogs } from "./system-logs"
import { TerminalPanel } from "./terminal-panel"
import { ThemeToggle } from "./theme-toggle"
import { Button } from "./ui/button"
import { Card, CardContent } from "./ui/card"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface Vitals { hostname: string; cpu_usage: number; memory_usage: number; memory_used: number; memory_total: number; uptime: string }
export function PBSDashboard() {
const t = useT()
const [tab, setTab] = useState("overview")
const { data } = useSWR<Vitals>("/api/system", url => fetchApi<Vitals>(url), { refreshInterval: 10000 })
return <div className="container mx-auto max-w-7xl space-y-6 px-4 py-6">
<header className="flex flex-wrap items-center gap-3 border-b pb-4">
<div className="mr-auto"><h1 className="text-2xl font-bold">ProxMenux · PBS</h1><p className="text-sm text-muted-foreground">{data?.hostname || t("pbs.loading")}</p></div>
<ThemeToggle />
<Button variant="outline" onClick={() => { localStorage.removeItem("proxmenux-auth-token"); window.location.reload() }}>{t("pbs.logout")}</Button>
</header>
<nav aria-label={t("pbs.navigation")} className="flex flex-wrap gap-2">
{(["overview", "logs", "terminal", "security"] as const).map(key =>
<Button key={key} variant={tab === key ? "default" : "outline"} onClick={() => setTab(key)}>{t(`pbs.${key}`)}</Button>)}
</nav>
{tab === "overview" && <>
<div className="grid gap-4 sm:grid-cols-3">
{[[t("pbs.cpu"), data ? `${data.cpu_usage.toFixed(1)}%` : "—"],
[t("pbs.memory"), data ? `${data.memory_used.toFixed(1)} / ${data.memory_total.toFixed(1)} GiB (${data.memory_usage.toFixed(1)}%)` : "—"],
[t("pbs.uptime"), data?.uptime || "—"]].map(([label, value]) =>
<Card key={label}><CardContent className="p-4"><p className="text-xs text-muted-foreground">{label}</p><p className="mt-1 text-lg font-semibold">{value}</p></CardContent></Card>)}
</div>
<PBSPanel />
</>}
{tab === "logs" && <SystemLogs />}
{tab === "terminal" && <TerminalPanel />}
{tab === "security" && <Security hostType="pbs" />}
</div>
}
+112
View File
@@ -0,0 +1,112 @@
"use client"
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"
export interface PBSDatastore {
name: string
path?: string
total?: number | null
used?: number | null
avail?: number | null
error?: string
"backend-type"?: string
"estimated-full-date"?: number
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
}
export function datastorePercent(store: PBSDatastore): number | null {
if (store.total == null || store.used == null || store.total <= 0) return null
return (store.used / store.total) * 100
}
// Same thresholds and colours as the PVE storage cards.
export function usageTone(percent: number): { bar: string; text: string; used: string } {
if (percent > 90) return { bar: "[&>div]:bg-red-500", text: "text-red-500", used: "text-red-400" }
if (percent > 75) return { bar: "[&>div]:bg-yellow-500", text: "text-yellow-500", used: "text-yellow-400" }
return { bar: "[&>div]:bg-blue-500", text: "text-muted-foreground", used: "text-blue-400" }
}
const gib = (bytes: number) => formatStorage(bytes / 1024 ** 3)
// A datastore without a readable capacity keeps its message: it is never shown as empty.
export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatastore; compact?: boolean }) {
const t = useT()
const percent = datastorePercent(store)
if (percent == null) {
return <div className="space-y-1">
<div className="flex items-center justify-between gap-2 text-sm">
<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>
</div>
}
const tone = usageTone(percent)
return <div className="space-y-1">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="font-medium text-foreground truncate">{store.name}</span>
<span className={`text-xs font-semibold tabular-nums ${tone.text}`}>{percent.toFixed(1)}%</span>
</div>
<Progress value={percent} className={`${compact ? "h-1.5" : "h-2"} ${tone.bar}`} />
{!compact && <div className="flex justify-between text-xs text-muted-foreground tabular-nums">
<span>{gib(store.used!)} / {gib(store.total!)}</span>
<span>{t("pbs.free")}: <span className="font-semibold text-green-500">{gib(store.avail ?? store.total! - store.used!)}</span></span>
</div>}
</div>
}
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
const t = useT()
const percent = datastorePercent(store)
const tone = percent == null ? null : usageTone(percent)
const fullDate = store["estimated-full-date"]
const type = store["backend-type"] || "datastore"
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">
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<h3 className="font-semibold text-base md:text-lg truncate">{store.name}</h3>
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">{type}</Badge>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge className={store.error ? "bg-red-500/10 text-red-500 border-red-500/20" : "bg-green-500/10 text-green-500 border-green-500/20"}>
{store.error ? t("storage.status.error") : t("storage.status.active")}
</Badge>
{percent != null && <span className="text-sm font-medium">{percent.toFixed(2)}%</span>}
</div>
</div>
{percent != null && tone ? (
<div className="space-y-2">
<Progress value={percent} className={`h-2 ${tone.bar}`} />
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">{t("storage.total")}</p>
<p className="font-medium">{gib(store.total!)}</p>
</div>
<div>
<p className="text-muted-foreground">{t("storage.used")}</p>
<p className={`font-medium ${tone.used}`}>{gib(store.used!)}</p>
</div>
<div>
<p className="text-muted-foreground">{t("storage.available")}</p>
<p className="font-medium text-green-400">{gib(store.avail ?? store.total! - store.used!)}</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
)}
{(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>}
</div>}
</div>
}
+150 -52
View File
@@ -2,29 +2,74 @@
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 { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
import { fetchApi } from "../lib/api-config"
import { formatBytes } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
interface Connections { local_available: boolean; servers: Connection[] }
interface Datastore { name: string; path?: string; total?: number | null; used?: number | null; avail?: number | null; error?: string }
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<string, string>;
datastores: Datastore[] | null; tasks: Task[] | null;
datastores: PBSDatastore[] | null; tasks: Task[] | null;
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
}
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" }
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm"
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",
}
export function PBSPanel() {
const TASK_ICONS: Record<string, typeof Activity> = {
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 "OK", "WARNINGS: n" or the error text; a task without one is still running.
function taskState(task: Task): keyof typeof badgeTone {
if (!task.status) return task.endtime ? "muted" : "running"
if (task.status === "OK") return "ok"
if (task.status.startsWith("WARNINGS")) return "warn"
return "error"
}
function jobState(state?: string): keyof typeof badgeTone {
if (!state) return "muted"
if (state === "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.
export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
const t = useT()
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
@@ -69,36 +114,43 @@ export function PBSPanel() {
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
finally { setBusy(false) }
}
const capacity = (value?: number | null) => value == null ? "—" : formatBytes(value)
const now = Date.now() / 1000
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-xl font-semibold">{t("pbs.title")}</h2>
<select aria-label={t("pbs.server")} className={`${selectClass} sm:ml-auto`} value={selected}
onChange={event => setSelected(event.target.value)}>
{!selected && <option value="">{t("pbs.chooseServer")}</option>}
{connections?.local_available && <option value="local">{t("pbs.local")}</option>}
{connections?.servers.map(row => <option key={row.id} value={row.id}>{row.name}</option>)}
</select>
<Button variant="outline" disabled={isLoading || !selected} onClick={() => mutate()}>{t("pbs.refresh")}</Button>
<Button variant="outline" onClick={() => setManage(!manage)}>{t("pbs.manage")}</Button>
</div>
return <div className="space-y-4 md:space-y-6">
{!localOnly && <div className="flex flex-wrap items-center gap-3">
<h2 className="flex items-center text-2xl font-semibold text-foreground"><Server className="mr-2 h-5 w-5" />{t("pbs.title")}</h2>
<Select value={selected || undefined} onValueChange={setSelected}>
<SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
<SelectContent>
{connections?.local_available && <SelectItem value="local">{t("pbs.local")}</SelectItem>}
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
</SelectContent>
</Select>
<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)}>
<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>}
{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>}
{manage && <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 aria-label={t("pbs.editConnection")} className={`${selectClass} w-full`} value={form.id}
onChange={event => {
const row = connections?.servers.find(item => item.id === event.target.value)
setForm(row ? { ...row, token_secret: "" } : emptyForm)
}}>
<option value="">{t("pbs.add")}</option>
{connections?.servers.map(row => <option key={row.id} value={row.id}>{row.name}</option>)}
</select>
<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">
@@ -118,32 +170,78 @@ export function PBSPanel() {
</CardContent>
</Card>}
{data && <>
<p className="text-sm text-muted-foreground">{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</p>
{Object.keys(data.services).length > 0 && <div className="flex flex-wrap gap-3 text-sm">
{Object.entries(data.services).map(([service, state]) => <span key={service} className={state === "active" ? "text-green-500" : "text-red-500"}>{service}: {state}</span>)}
</div>}
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</span>
{Object.entries(data.services).map(([service, state]) =>
<Badge key={service} variant="outline" className={state === "active" ? badgeTone.ok : 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>)}
<Card><CardHeader><CardTitle>{t("pbs.datastores")}</CardTitle></CardHeader><CardContent>
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
{data.datastores && data.datastores.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.name")}</th><th>{t("pbs.total")}</th><th>{t("pbs.used")}</th><th>{t("pbs.free")}</th></tr></thead>
<tbody>{data.datastores.map(row => <tr key={row.name} className="border-b"><td className="p-2 font-medium">{row.name}{row.error && <p className="font-normal text-xs text-amber-500">{row.error}</p>}</td><td>{capacity(row.total)}</td><td>{capacity(row.used)}</td><td>{capacity(row.avail)}</td></tr>)}</tbody>
</table></div>}
</CardContent></Card>
<Card><CardHeader><CardTitle>{t("pbs.tasks")}</CardTitle></CardHeader><CardContent>
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.task")}</th><th>{t("pbs.started")}</th><th>{t("pbs.status")}</th></tr></thead>
<tbody>{data.tasks.map(row => <tr key={row.upid} className="border-b"><td className="p-2">{row.worker_type} {row.worker_id}</td><td>{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td><td>{row.status || (row.endtime ? "—" : t("pbs.running"))}</td></tr>)}</tbody>
</table></div>}
</CardContent></Card>
<Card><CardHeader><CardTitle>{t("pbs.jobs")}</CardTitle></CardHeader><CardContent>
<div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.job")}</th><th>{t("pbs.datastores")}</th><th>{t("pbs.schedule")}</th><th>{t("pbs.status")}</th></tr></thead>
<tbody>{(["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row =>
<tr key={`${kind}-${row.id}`} className="border-b"><td className="p-2">{t(`pbs.${kind}`)} · {row.id}</td><td>{row.store || "—"}</td><td>{row.schedule || "—"}</td><td>{row["last-run-state"] || "—"}</td></tr>))}</tbody>
</table></div>
</CardContent></Card>
<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>}
{data.datastores && data.datastores.length > 0 && <div className="space-y-4">
{data.datastores.map(store => <PBSDatastoreCard key={store.name} store={store} />)}
</div>}
</CardContent>
</Card>
<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>
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="py-2 pr-3 font-medium">{t("pbs.task")}</th>
<th className="py-2 pr-3 font-medium">{t("pbs.started")}</th>
<th className="py-2 pr-3 font-medium">{t("pbs.duration")}</th>
<th className="py-2 font-medium">{t("pbs.status")}</th>
</tr></thead>
<tbody>{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 <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>
{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>
</tr>
})}</tbody>
</table></div>}
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader><CardTitle className="flex items-center text-foreground"><ListChecks className="mr-2 h-5 w-5" />{t("pbs.jobs")}</CardTitle></CardHeader>
<CardContent>
{(() => {
const jobs = (["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => ({ kind, row })))
if (jobs.length === 0) return <p className="text-sm text-muted-foreground">{t("pbs.noJobs")}</p>
return <div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="py-2 pr-3 font-medium">{t("pbs.job")}</th>
<th className="py-2 pr-3 font-medium">{t("pbs.datastores")}</th>
<th className="py-2 pr-3 font-medium">{t("pbs.schedule")}</th>
<th className="py-2 font-medium">{t("pbs.status")}</th>
</tr></thead>
<tbody>{jobs.map(({ kind, row }) => <tr key={`${kind}-${row.id}`} className="border-b border-border/60 last:border-0">
<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>
</tr>)}</tbody>
</table></div>
})()}
</CardContent>
</Card>
</>}
</div>
}
+53 -26
View File
@@ -18,7 +18,8 @@ import { Security } from "./security"
import { Profile } from "./profile"
import { About } from "./about"
import { BackupTab } from "./backup-tab"
import { PBSDashboard } from "./pbs-dashboard"
import { PBSPanel } from "./pbs-panel"
import { MonitorSwitcher } from "./monitor-switcher"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
@@ -46,7 +47,9 @@ import {
ClipboardCheck,
Info,
DatabaseBackup,
Database,
ChevronDown,
Clock,
} from "lucide-react"
import Image from "next/image"
import { ThemeToggle } from "./theme-toggle"
@@ -105,10 +108,27 @@ export function ProxmoxDashboard() {
}
function PVEDashboard() {
return <MonitorDashboard product="pve" />
}
function PBSDashboard() {
return <MonitorDashboard product="pbs" />
}
type Product = "pve" | "pbs"
type TopTab = TabId | "datastores"
// 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"]
function MonitorDashboard({ product }: { product: Product }) {
const t = useT()
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
const { order: tabOrder } = useTabOrder()
const isPbs = product === "pbs"
useSWR(isPbs ? null : "/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
useSWR(isPbs ? null : "/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
const { order: userTabOrder } = useTabOrder()
const tabOrder: TopTab[] = isPbs ? PBS_TABS : userTabOrder
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
status: "healthy",
uptime: "Loading...",
@@ -126,8 +146,8 @@ function PVEDashboard() {
// runs post-hydration; the tiny flash is acceptable and matches the
// pattern next-themes uses for the same reason.
useEffect(() => {
setActiveTab(firstActualTab())
}, [])
setActiveTab(isPbs ? "overview" : firstActualTab())
}, [isPbs])
const [infoCount, setInfoCount] = useState(0)
const [updateAvailable, setUpdateAvailable] = useState(false)
const [showNavigation, setShowNavigation] = useState(true)
@@ -400,6 +420,7 @@ function PVEDashboard() {
const getActiveTabLabel = () => {
switch (activeTab) {
case "overview": return t("navigation.overview")
case "datastores": return t("pbs.datastores")
case "apps": return t("navigation.apps")
case "vms": return t("navigation.virtualMachines")
case "storage": return t("navigation.storage")
@@ -419,8 +440,9 @@ function PVEDashboard() {
return (
<div className="min-h-screen bg-background">
<OnboardingCarousel />
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
{/* Both describe the PVE Monitor. */}
{!isPbs && <OnboardingCarousel />}
{!isPbs && <ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />}
{!isServerConnected && (
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
@@ -473,22 +495,17 @@ function PVEDashboard() {
</div>
<div className="min-w-0">
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
<p className="text-xs md:text-sm text-muted-foreground">{t(isPbs ? "app.descriptionPbs" : "app.description")}</p>
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Server className="h-3 w-3" />
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
<Clock className="h-3 w-3" />
<span className="truncate">{t("status.uptime", { uptime: displayUptime })}</span>
</div>
</div>
</div>
{/* Desktop Actions */}
<div className="hidden lg:flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Server className="h-4 w-4 text-muted-foreground" />
<div className="text-sm">
<div className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
</div>
</div>
<MonitorSwitcher nodeName={displayServerName} />
<div className="flex items-center gap-2">
<Badge variant="outline" className={statusColor}>
@@ -589,9 +606,7 @@ function PVEDashboard() {
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{t("status.uptime", { uptime: displayUptime })}
</span>
<MonitorSwitcher nodeName={displayServerName} compact />
</div>
</div>
</header>
@@ -632,7 +647,7 @@ function PVEDashboard() {
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
]
].filter(item => !isPbs || item.value !== "audit")
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
const isNodeActive = activeNodeItem !== undefined
@@ -698,9 +713,10 @@ function PVEDashboard() {
</DropdownMenuContent>
</DropdownMenu>
)
const renderTop = (id: TabId) => {
const renderTop = (id: TopTab) => {
switch (id) {
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return renderDirect("datastores", Database, t("pbs.datastores"))
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"))
@@ -710,7 +726,7 @@ function PVEDashboard() {
}
}
return (
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-4" : "grid-cols-7"} bg-card border border-border`}>
{tabOrder.map(renderTop)}
</TabsList>
)
@@ -771,6 +787,7 @@ function PVEDashboard() {
{tabOrder.map((id): React.ReactNode => {
switch (id) {
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return btn("datastores", Database, t("pbs.datastores"))
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"))
@@ -786,7 +803,7 @@ function PVEDashboard() {
<React.Fragment key="admin">
{btn("logs", ScrollText, t("navigation.systemLogs"))}
{btn("security", ShieldCheck, t("navigation.security"))}
{btn("audit", ClipboardCheck, t("navigation.audit"))}
{!isPbs && btn("audit", ClipboardCheck, t("navigation.audit"))}
{btn("settings", SettingsIcon, t("navigation.settings"))}
{btn("about", Info, t("navigation.about"))}
</React.Fragment>
@@ -820,9 +837,16 @@ function PVEDashboard() {
5 s / 59 s refresh intervals keep the data fresh in the
background — reopening the tab is instant. */}
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
<SystemOverview key={`overview-${componentKey}`} />
<SystemOverview key={`overview-${componentKey}`} product={product} />
</TabsContent>
{isPbs && (
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
<PBSPanel key={`datastores-${componentKey}`} localOnly />
</TabsContent>
)}
{!isPbs && <>
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
<AppsDashboard key={`apps-${componentKey}`} />
</TabsContent>
@@ -848,11 +872,13 @@ function PVEDashboard() {
<TabsContent value="hardware" className="space-y-4 md:space-y-6 mt-0">
<Hardware key={`hardware-${componentKey}`} />
</TabsContent>
</>}
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
<SystemLogs key={`logs-${componentKey}`} />
</TabsContent>
{!isPbs && <>
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
<AuditReport key={`audit-${componentKey}`} />
</TabsContent>
@@ -860,13 +886,14 @@ function PVEDashboard() {
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
<BackupTab key={`backup-${componentKey}`} />
</TabsContent>
</>}
<TabsContent value="terminal" className="mt-0">
<TerminalPanel key={`terminal-${componentKey}`} />
</TabsContent>
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
<Security key={`security-${componentKey}`} />
<Security key={`security-${componentKey}`} hostType={product} />
</TabsContent>
{/* Profile tab — not surfaced in the top tabs nav. The only
+13 -6
View File
@@ -16,6 +16,7 @@ import { Input } from "./ui/input"
import { getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
import { useIsPbs } from "../lib/use-platform"
import type { LanguageCode } from "../lib/i18n/languages"
import { NavTabOrderCard } from "./nav-tab-order-card"
@@ -312,6 +313,7 @@ interface NetworkInterface {
export function Settings() {
const { language, setLanguage, t } = useI18n()
const isPbs = useIsPbs()
const tFallback = (key: string, fallback: string) => {
const translated = t(key)
return translated === key ? fallback : translated
@@ -1124,7 +1126,7 @@ export function Settings() {
</Card>
{/* Navigation Tab Order — user-orderable top-level tabs */}
<NavTabOrderCard />
{!isPbs && <NavTabOrderCard />}
{/* Network Units Settings */}
<Card>
@@ -1280,7 +1282,7 @@ export function Settings() {
{/* Per-category rows */}
<div className="divide-y divide-border/50">
{suppressionCategories.map((cat) => {
{suppressionCategories.filter(cat => !isPbs || healthCategoryKey(cat) !== "vms").map((cat) => {
const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse
const effectiveHours = getEffectiveHours(cat)
const isCustomMode = effectiveHours === -2 || (cat.key in customValues)
@@ -1288,7 +1290,8 @@ export function Settings() {
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
const categoryKey = isPbs && healthCategoryKey(cat) === "services" ? "pbsServices" : healthCategoryKey(cat)
const catLabel = tFallback(`settings.healthMonitor.categories.${categoryKey}`, cat.label)
return (
<div key={cat.key}>
@@ -1496,6 +1499,7 @@ export function Settings() {
</CardContent>
</Card>
{!isPbs && <>
{/* Remote Storage Exclusions */}
<Card>
<CardHeader>
@@ -1678,6 +1682,7 @@ export function Settings() {
)}
</CardContent>
</Card>
</>}
{/* Network Interface Exclusions */}
<Card>
@@ -1875,6 +1880,7 @@ export function Settings() {
is re-enabled). */}
<LxcUpdateDetection />
{!isPbs && <>
{/* GitHub API — optional authentication for app upstream checks. */}
<Card>
<CardHeader>
@@ -1981,15 +1987,16 @@ export function Settings() {
)}
</CardContent>
</Card>
</>}
{/* Notification Settings */}
<NotificationSettings />
{/* Notification Settings (PVE only for now) */}
{!isPbs && <NotificationSettings />}
{/* Issue #195: snippets storage selector. Only renders when more
than one storage advertises content=snippets — on a typical
standalone host with just `local` there's nothing to choose,
so showing an empty selector would be noise. */}
{snippetsCandidates.length > 1 && (
{!isPbs && snippetsCandidates.length > 1 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
+70 -10
View File
@@ -1,10 +1,12 @@
"use client"
import { useState, useEffect } from "react"
import useSWR from "swr"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Progress } from "./ui/progress"
import { Badge } from "./ui/badge"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight } from "lucide-react"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
import { NodeMetricsCharts } from "./node-metrics-charts"
import { NetworkTrafficChart } from "./network-traffic-chart"
import { TemperatureDetailModal } from "./temperature-detail-modal"
@@ -185,9 +187,17 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
}
export function SystemOverview() {
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 }>(
isPbs ? "/api/pbs/status?server=local" : null,
(url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url),
{ refreshInterval: 60000, revalidateOnFocus: false },
)
const datastores = pbsStatus?.datastores ?? null
const [systemData, setSystemData] = useState<SystemData | null>(null)
const [vmData, setVmData] = useState<VMData[]>([])
const [storageData, setStorageData] = useState<StorageData | null>(null)
@@ -212,8 +222,8 @@ export function SystemOverview() {
const fetchAllData = async () => {
const [systemResult, vmResult, storageResults, networkResult] = await Promise.all([
fetchSystemData().finally(() => setLoadingStates((prev) => ({ ...prev, system: false }))),
fetchVMData().finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
Promise.all([fetchStorageData(), fetchProxmoxStorageData()]).finally(() =>
(isPbs ? Promise.resolve([]) : fetchVMData()).finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()]).finally(() =>
setLoadingStates((prev) => ({ ...prev, storage: false })),
),
fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))),
@@ -248,12 +258,13 @@ export function SystemOverview() {
}, 5000)
const vmInterval = setInterval(async () => {
if (isPbs) return
const data = await fetchVMData()
setVmData(data)
}, 59000)
const storageInterval = setInterval(async () => {
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), fetchProxmoxStorageData()])
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()])
if (storage) setStorageData(storage)
if (proxmoxStorage) setProxmoxStorageData(proxmoxStorage)
}, 59000)
@@ -278,7 +289,7 @@ export function SystemOverview() {
clearInterval(networkInterval)
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
}
}, [t])
}, [t, isPbs])
if (!hasAttemptedLoad || loadingStates.system) {
return (
@@ -527,7 +538,46 @@ export function SystemOverview() {
</CardContent>
</Card>
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
{isPbs ? (
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{t("pbs.datastores")}</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{!datastores && !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>
) : datastores.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
) : (
<div className="space-y-3">
<div className="flex items-end justify-between">
<span className="text-4xl font-bold leading-none text-foreground">{datastores.length}</span>
{datastores.some(store => store.error) ? (
<Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
{t("pbs.datastoresUnavailable", { count: datastores.filter(store => store.error).length })}
</Badge>
) : (
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("pbs.datastoresOnline")}
</Badge>
)}
</div>
{datastores.slice(0, 2).map(store => <DatastoreUsageRow key={store.name} store={store} compact />)}
{datastores.length > 2 && (
<p className="text-xs text-muted-foreground">{t("pbs.moreDatastores", { count: datastores.length - 2 })}</p>
)}
</div>
)}
</CardContent>
</Card>
) : (
/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
@@ -565,6 +615,7 @@ export function SystemOverview() {
)}
</CardContent>
</Card>
)}
<Card
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`}
@@ -708,7 +759,16 @@ export function SystemOverview() {
</div>
</div>
{vmLxcStorages && vmLxcStorages.length > 0 ? (
{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>
) : 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} />)}
</div>
) : vmLxcStorages && vmLxcStorages.length > 0 ? (
<div className="space-y-2 pb-3 border-b border-border">
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
<div className="flex justify-between items-center">
@@ -744,7 +804,7 @@ export function SystemOverview() {
</div>
)}
{localStorage && (
{!isPbs && localStorage && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
<div className="flex justify-between items-center">
@@ -899,7 +959,7 @@ export function SystemOverview() {
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
<span className="text-muted-foreground">{t(isPbs ? "overview.pbsVersion" : "overview.proxmoxVersion")}</span>
<span className="text-foreground">{systemData.proxmox_version || t("app.notAvailable")}</span>
</div>
<div className="flex justify-between">