"use client" import { useEffect, useState } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Input } from "./ui/input" import { SlidersHorizontal, Cpu, MemoryStick, HardDrive, Server, Thermometer, Settings2, Check, Loader2, RotateCcw, AlertCircle, FolderOpen, Database, Waves, } from "lucide-react" import { getApiUrl, getAuthToken } from "../lib/api-config" // Local fetch wrapper that *preserves* the JSON body on non-2xx // responses so we can surface backend validation messages // (e.g. "critical must be >= warning") to the user. The shared // `fetchApi` throws a generic "API request failed: 400" on any // non-OK response, eating the body. async function fetchJson(endpoint: string, init?: RequestInit): Promise { const token = getAuthToken() const headers: Record = { "Content-Type": "application/json", ...((init?.headers as Record) || {}), } if (token) headers["Authorization"] = `Bearer ${token}` const res = await fetch(getApiUrl(endpoint), { ...init, headers, cache: "no-store", }) let data: any = null try { data = await res.json() } catch { // empty body — fall through with raw status } if (!res.ok) { if (res.status === 401 && typeof window !== "undefined") { try { localStorage.removeItem("proxmenux-auth-token") } catch {} const path = window.location.pathname if (!path.startsWith("/auth") && !path.startsWith("/login")) { window.location.assign("/") } } const msg = (data && (data.message || data.error)) || `${res.status} ${res.statusText}` throw new Error(msg) } return data as T } // ─── Types ─────────────────────────────────────────────────────────────────── // // The backend returns a tree of leaves. Each leaf carries the metadata // the UI needs to render an input + the recommended/customised flags. // We mirror the shape rather than hand-coding it to keep the contract // in one place — the backend is the source of truth. interface ThresholdLeaf { value: number recommended: number customised: boolean unit: string min: number max: number step: number } interface ThresholdsTree { cpu: { warning: ThresholdLeaf; critical: ThresholdLeaf } memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_critical: ThresholdLeaf } host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf } lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf } cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf } disk_temperature: { hdd: { warning: ThresholdLeaf; critical: ThresholdLeaf } ssd: { warning: ThresholdLeaf; critical: ThresholdLeaf } nvme: { warning: ThresholdLeaf; critical: ThresholdLeaf } sas: { warning: ThresholdLeaf; critical: ThresholdLeaf } } // Phase 3 additions lxc_mount: { warning: ThresholdLeaf; critical: ThresholdLeaf } pve_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf } zfs_pool: { warning: ThresholdLeaf; critical: ThresholdLeaf } } // Pending edits: { "section/key" : "76" } — kept as raw strings while // the user types so partial input ("8" mid-type) doesn't fail the // numeric coercion. Coerced + validated on Save. type PendingEdits = Record // ─── Section descriptors ───────────────────────────────────────────────────── // // Drives both the render order and the labels. Keeping it data-only // means adding a new section later (Phase 4) is one entry, not a JSX // surgery. interface SectionField { // Path in the thresholds tree, e.g. ["cpu", "warning"] or // ["disk_temperature", "nvme", "critical"]. path: string[] label: string } interface SectionDef { id: string // Backend section key — used by the reset endpoint title: string icon: React.ComponentType<{ className?: string }> description?: string fields: SectionField[] // For tabular sections (disk temperature) we group by sub-key. When // present, fields are rendered in a 2-column grid (warning, critical) // labelled by sub-key (HDD / SSD / NVMe / SAS). rowGroups?: Array<{ subKey: string; label: string }> } // Order: compute → heat → storage capacity. Reading top-to-bottom // flows naturally with no domain jumps: // • Compute (CPU usage, RAM/Swap) // • Heat (CPU temp, then disk temp — both °C) // • Storage capacity (host → LXC rootfs → LXC mounts → PVE → ZFS, // i.e. concrete to abstract) const SECTIONS: SectionDef[] = [ // ── Compute ───────────────────────────────────────────────────── { id: "cpu", title: "CPU usage", icon: Cpu, fields: [ { path: ["cpu", "warning"], label: "Warning" }, { path: ["cpu", "critical"], label: "Critical" }, ], }, { id: "memory", title: "Memory & Swap", icon: MemoryStick, fields: [ { path: ["memory", "warning"], label: "Memory warning" }, { path: ["memory", "critical"], label: "Memory critical" }, { path: ["memory", "swap_critical"], label: "Swap critical" }, ], }, // ── Heat ──────────────────────────────────────────────────────── { id: "cpu_temperature", title: "CPU temperature", icon: Thermometer, fields: [ { path: ["cpu_temperature", "warning"], label: "Warning" }, { path: ["cpu_temperature", "critical"], label: "Critical" }, ], }, { id: "disk_temperature", title: "Disk temperature", icon: Thermometer, description: "Per-class thresholds. Same units (°C) — different defaults because each class tolerates a different envelope.", rowGroups: [ { subKey: "hdd", label: "HDD" }, { subKey: "ssd", label: "SSD" }, { subKey: "nvme", label: "NVMe" }, { subKey: "sas", label: "SAS" }, ], // For row-group sections, `fields` is unused — we generate per-row // path lookups from the rowGroups + a hardcoded ["warning","critical"]. fields: [], }, // ── Storage capacity ──────────────────────────────────────────── { id: "host_storage", title: "Disk space — host", icon: HardDrive, description: "Applies to / and every mountpoint under /var/lib/vz, /mnt/* etc.", fields: [ { path: ["host_storage", "warning"], label: "Warning" }, { path: ["host_storage", "critical"], label: "Critical" }, ], }, { id: "lxc_rootfs", title: "Disk space — LXC rootfs", icon: Server, description: "Per-container root disk, evaluated against the rootfs size from PVE.", fields: [ { path: ["lxc_rootfs", "warning"], label: "Warning" }, { path: ["lxc_rootfs", "critical"], label: "Critical" }, ], }, { id: "lxc_mount", title: "LXC mount points", icon: FolderOpen, description: "Capacity of mountpoints inside running CTs (mp0, mp1, NFS, bind mounts). Excludes the rootfs — that's covered above.", fields: [ { path: ["lxc_mount", "warning"], label: "Warning" }, { path: ["lxc_mount", "critical"], label: "Critical" }, ], }, { id: "pve_storage", title: "PVE storage capacity", icon: Database, description: "Block-style PVE storages: LVM, LVM-thin, ZFS-pool, RBD/Ceph, PBS. Filesystem-style (dir/nfs/cifs) is already covered by host disk thresholds.", fields: [ { path: ["pve_storage", "warning"], label: "Warning" }, { path: ["pve_storage", "critical"], label: "Critical" }, ], }, { id: "zfs_pool", title: "ZFS pool capacity", icon: Waves, description: "ZFS pools at the host level — independent of PVE registration so rpool and dedicated backup pools are also monitored.", fields: [ { path: ["zfs_pool", "warning"], label: "Warning" }, { path: ["zfs_pool", "critical"], label: "Critical" }, ], }, ] // ─── Helpers ───────────────────────────────────────────────────────────────── function getLeaf(tree: ThresholdsTree | null, path: string[]): ThresholdLeaf | null { if (!tree) return null let node: any = tree for (const p of path) { if (node == null || typeof node !== "object") return null node = node[p] } return node as ThresholdLeaf | null } function pathKey(path: string[]): string { return path.join("/") } // ─── Component ─────────────────────────────────────────────────────────────── export function HealthThresholds() { const [tree, setTree] = useState(null) const [loading, setLoading] = useState(true) const [editMode, setEditMode] = useState(false) const [saving, setSaving] = useState(false) const [savedFlash, setSavedFlash] = useState(false) const [error, setError] = useState(null) const [pending, setPending] = useState({}) // Load on mount + auto-refresh after each save const fetchTree = async () => { try { setLoading(true) const res = await fetchJson<{ success: boolean; thresholds: ThresholdsTree }>( "/api/health/thresholds", ) if (res?.success && res.thresholds) setTree(res.thresholds) } catch (err) { setError(err instanceof Error ? err.message : "Failed to load thresholds") } finally { setLoading(false) } } useEffect(() => { fetchTree() }, []) const hasPendingChanges = Object.keys(pending).length > 0 // Build the partial payload from pending. Any blank or unparseable // entry is skipped — the backend will reject anything malformed // anyway, but we want to fail fast on the UI side too. const buildPayload = (): Record | null => { const payload: Record = {} for (const [key, raw] of Object.entries(pending)) { const parts = key.split("/") const trimmed = raw.trim() if (trimmed === "") continue const num = Number(trimmed) if (!isFinite(num)) { setError(`Invalid value for ${key}: must be a number`) return null } // Walk into payload mirroring the path let cur: any = payload for (let i = 0; i < parts.length - 1; i++) { cur[parts[i]] = cur[parts[i]] || {} cur = cur[parts[i]] } cur[parts[parts.length - 1]] = num } return payload } const handleEdit = () => { setEditMode(true) setError(null) } const handleCancel = () => { setEditMode(false) setPending({}) setError(null) } const handleSave = async () => { const payload = buildPayload() if (payload === null) return if (Object.keys(payload).length === 0) { setEditMode(false) return } try { setSaving(true) setError(null) const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>( "/api/health/thresholds", { method: "PUT", body: JSON.stringify(payload) }, ) if (!data.success || !data.thresholds) { setError(data.message || "Save failed") return } setTree(data.thresholds) setPending({}) setEditMode(false) setSavedFlash(true) setTimeout(() => setSavedFlash(false), 2000) } catch (err) { setError(err instanceof Error ? err.message : "Network error while saving") } finally { setSaving(false) } } const handleResetSection = async (sectionId: string) => { if (!confirm(`Reset all "${SECTIONS.find((s) => s.id === sectionId)?.title}" thresholds to recommended values?`)) return try { const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>( `/api/health/thresholds/reset?section=${encodeURIComponent(sectionId)}`, { method: "POST" }, ) if (!data.success || !data.thresholds) { setError(data.message || "Reset failed") return } setTree(data.thresholds) // Drop any pending edits within this section so the UI stays // consistent — the values were just reset on the server. setPending((p) => { const next: PendingEdits = {} for (const [k, v] of Object.entries(p)) { if (!k.startsWith(sectionId + "/")) next[k] = v } return next }) } catch (err) { setError(err instanceof Error ? err.message : "Network error while resetting") } } const handleResetAll = async () => { if (!confirm("Reset ALL thresholds to recommended values? This affects every section.")) return try { const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>( "/api/health/thresholds/reset", { method: "POST" }, ) if (!data.success || !data.thresholds) { setError(data.message || "Reset failed") return } setTree(data.thresholds) setPending({}) } catch (err) { setError(err instanceof Error ? err.message : "Network error while resetting") } } const renderField = (path: string[], label: string) => { const leaf = getLeaf(tree, path) if (!leaf) return null const key = pathKey(path) const editingValue = pending[key] ?? String(leaf.value) // Pick the badge palette from the leaf name so warning rows render // amber and critical rows render red. `swap_critical` and any other // *_critical key fall into the red bucket via the substring check. const last = path[path.length - 1] || "" const isCritical = last.toLowerCase().includes("critical") const isWarning = last.toLowerCase().includes("warning") const badgeClasses = isCritical ? "bg-red-500/10 text-red-500 border-red-500/30" : isWarning ? "bg-amber-500/10 text-amber-500 border-amber-500/30" : "bg-muted text-muted-foreground border-border" return (
{leaf.recommended} {leaf.unit} setPending((p) => ({ ...p, [key]: e.target.value })) } className={`w-20 h-7 text-xs text-right tabular-nums ${ !editMode ? "opacity-70" : "" } ${ leaf.customised && !(key in pending) ? "border-blue-500/40" : "" }`} /> {leaf.unit}
) } return (
Health Monitor Thresholds
{!loading && (
{savedFlash && ( Saved )} {editMode ? ( <> ) : ( <> )}
)}
The Health Monitor and notifications fire when these thresholds are crossed. Recommended values are shown with their reference color (amber for warning, red for critical); your edits override them. Leave a value unchanged to keep the recommended.
{loading ? (
) : !tree ? (
Failed to load thresholds.
) : (
{error && (
{error}
)} {SECTIONS.map((section) => { const Icon = section.icon return (

{section.title}

{!editMode && ( )}
{section.description && (

{section.description}

)}
{section.rowGroups ? section.rowGroups.map((group) => (
{group.label}
{renderField([section.id, group.subKey, "warning"], "Warning")} {renderField([section.id, group.subKey, "critical"], "Critical")}
)) : section.fields.map((f) => renderField(f.path, f.label))}
) })}
)}
) }