"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { Activity, Boxes, ChevronDown, ChevronRight, CircuitBoard, Cpu, HardDrive, Loader2, MemoryStick, Network, Package, Plug, Server, Share2, Wrench, } from "lucide-react" import { fetchApi } from "../lib/api-config" import { useT, useI18n } from "../lib/i18n/provider" import { subscriptionLabel } from "../lib/audit-presentation" interface UplinkHop { kind: string; id: string; mode?: string; role?: string } interface GuestDisk { slot: string; storage: string | null; volume: string size: string; passthrough?: boolean } interface GuestNic { slot: string; name: string; bridge: string; mac: string; vlan: string uplink: UplinkHop[] | null } interface GuestBackup { job: string; storage: string; schedule: string; retention: string } interface Guest { vmid: number; type: string; name: string; cores: string; memory: string ostype: string; onboot: boolean; tags: string; protected: boolean unprivileged: boolean | null; features: string | null agent: boolean | null; cpu: string | null disks: GuestDisk[]; interfaces: GuestNic[]; backups: GuestBackup[] } interface Inventory { collected_at: number node: string unavailable: Record sections: { identity?: Record cluster?: any hardware?: any storages?: any[] guests?: Guest[] passthrough?: any[] applications?: any[] custom_links?: any[] proxmenux?: { optimizations: any[]; pending_updates: any[] } network?: { bridges: Record } | null latency?: { window: string; targets: any[] } | null } } const GiB = 1024 ** 3 function bytes(value: number | null | undefined): string { if (!value || value <= 0) return "—" const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] let n = value, i = 0 while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ } return `${n >= 100 || i < 2 ? Math.round(n) : n.toFixed(1)} ${units[i]}` } function Field({ label, value }: { label: string; value: React.ReactNode }) { if (value === null || value === undefined || value === "") return null return (

{label}

{value}

) } /** * One table shape for the whole view. * * The inventory is read across as much as down — a disk's model beside * its bus beside its wear — so the sections that enumerate things share * a single table rather than each inventing its own row layout. * * On a narrow screen the same rows are stacked instead. A disk table is * eight columns wide; sideways scrolling technically fits it on a phone, * but reading a row then means dragging back and forth to pair each * value with its heading. Stacked, the heading travels with the value. */ function DataTable({ columns, rows }: { columns: string[] rows: React.ReactNode[][] }) { if (rows.length === 0) return null return ( <>
{columns.map((c, i) => ( ))} {rows.map((row, i) => ( {row.map((cell, j) => ( ))} ))}
{c}
{cell}
{rows.map((row, i) => (
{row.map((cell, j) => { // A cell with nothing in it would leave a heading standing // alone, which reads as missing data rather than as absent. if (cell === null || cell === undefined || cell === "" || cell === "—") return null return (
{columns[j]} {cell}
) })}
))}
) } function Mono({ children }: { children: React.ReactNode }) { return {children} } function Section({ icon, title, count, children, note, }: { icon: React.ReactNode; title: string; count?: number children: React.ReactNode; note?: string }) { const [open, setOpen] = useState(true) return ( {open && ( {note &&

{note}

} {children}
)}
) } /** A heading inside a section, matching the printed document's. */ function Sub({ icon, title, note }: { icon: React.ReactNode; title: string; note?: string }) { return (

{icon}{title} {note && — {note}}

) } // The uplink is the point of the network section: a bridge on its own // says nothing, the path from it to the wire is what an operator needs. function Uplink({ hops }: { hops: UplinkHop[] | null }) { const t = useT() if (hops === null) { return {t("audit.inventory.unresolved")} } if (hops.length === 0) { return {t("audit.inventory.noUplink")} } return ( {hops.map((h, i) => ( {i > 0 && } {h.id}{h.mode ? ` · ${h.mode}` : ""} ))} ) } export function AuditInventory({ profile = "full" }: { profile?: string }) { const t = useT() const { language } = useI18n() const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [openGuest, setOpenGuest] = useState>(new Set()) const load = useCallback(async () => { try { const res: any = await fetchApi( `/api/audit/inventory?profile=${encodeURIComponent(profile)}`) if (res?.success) { setData(res.inventory); setError(null) } else setError(res?.message || t("audit.inventory.failed")) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } }, [t, profile]) useEffect(() => { load() }, [load]) const apps = useMemo(() => { const byGuest = new Map() for (const a of data?.sections.applications || []) { if (!byGuest.has(a.vmid)) byGuest.set(a.vmid, []) byGuest.get(a.vmid)!.push(a) } return byGuest }, [data]) if (loading) { return (
{t("audit.inventory.loading")}
) } if (error) return

{error}

if (!data) return null const s = data.sections const hw = s.hardware || {} const mem = hw.memory || {} const when = (value: number | string | null | undefined) => { if (!value) return "—" const date = typeof value === "number" ? new Date(value * 1000) : new Date(/[Z+]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value}Z`) return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(language) } const toggle = (vmid: number) => setOpenGuest((prev) => { const next = new Set(prev) next.has(vmid) ? next.delete(vmid) : next.add(vmid) return next }) return (

{t("audit.inventory.collectedAt", { when: new Date(data.collected_at * 1000).toLocaleString(language), })}

{/* Sections that could not be read are named, so an empty list is never mistaken for a section that was read and found nothing. */} {Object.keys(data.unavailable || {}).length > 0 && (

{t("audit.inventory.unavailable")}

{Object.entries(data.unavailable).map(([k, v]) => (

{k} — {v}

))}
)} {s.identity && (
} title={t("audit.inventory.identity")}>
)} {"cluster" in s && (
} title={t("audit.document.cluster")} count={s.cluster ? (s.cluster.nodes || []).length : undefined} note={s.cluster ? undefined : t("audit.document.standaloneNote")}> {s.cluster && ( <>
{t(s.cluster.quorate ? "audit.document.quorate" : "audit.document.inquorate")} )} />
[ {n.name} {n.local && {" "}({t("audit.document.thisNode")})} , {n.nodeid || "—"}, {n.ring0_addr || "—"}, {n.ring1_addr || "—"}, n.online === false ? {t("audit.document.unreachable")} : n.online === true ? {t("audit.document.member")} : "—", ])} /> )}
)} {s.hardware && (
} title={t("audit.document.architecture")}>
{(mem.modules || []).length > 0 && ( <> } title={t("audit.document.memoryModules")} note={t("audit.document.slotsFilled", { used: String(mem.populated ?? 0), total: String(mem.slots ?? mem.populated ?? 0), })} /> [ {m.locator || "—"}, m.size || "—", m.type || "—", m.form_factor || "—", m.speed || "—", [m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—", ])} /> )} {(hw.controllers || []).length > 0 && ( <> } title={t("audit.document.controllers")} /> [ {c.slot}, c.class, c.name, ])} /> )}
)} {(hw.disks || []).length > 0 && (
} title={t("audit.document.storageDevices")} count={(hw.disks || []).length}> { const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase()) return [ {d.name}, d.model || "—", {d.serial || "—"}, bytes(d.size_bytes), `${(d.bus || "—").toUpperCase()} · ${d.rotational ? "HDD" : "SSD"}`, ok ? {t("audit.document.healthy")} : d.health && d.health !== "unknown" ? {d.health} : "—", typeof d.power_on_hours === "number" && d.power_on_hours > 0 ? t("audit.document.years", { years: (d.power_on_hours / 8760).toFixed(1) }) : "—", (d.observations || []).length ? {d.observations.length} : , ] })} /> {(hw.disks || []).some((d: any) => (d.observations || []).length > 0) && ( <> } title={t("audit.document.observations")} note={t("audit.document.observationsNote")} /> {(hw.disks || []).filter((d: any) => (d.observations || []).length).map((d: any) => (

{d.name} {d.model}

[ o.type || "—", {o.severity ? t(`audit.classifications.${ o.severity === "critical" ? "critical" : "warning"}`) : "—"}, String(o.count ?? "—"), when(o.first_seen), when(o.last_seen), {o.message || ""}, ])} />
))} )}
)} {(s.network || (hw.adapters || []).length > 0) && (
} title={t("audit.inventory.network")} count={Object.keys(s.network?.bridges || {}).length || undefined}> {(hw.adapters || []).length > 0 && ( <> } title={t("audit.document.physicalAdapters")} /> [ {a.name}, a.state === "up" ? {a.state} : {a.state || "—"}, a.speed_mbps ? (a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`) : "—", {a.mac || "—"}, a.driver || "—", {a.pci || "—"}, ])} /> )} {s.network && ( <> } title={t("audit.document.bridges")} />
{Object.entries(s.network.bridges || {}).map(([id, b]: [string, any]) => (
{id} {b.vlan_interface && ( VLAN {b.vlan_interface} )} {(s.guests || []).filter((g) => (g.interfaces || []).some((n) => n.bridge === id)).length} {" "}{t("audit.inventory.guests").toLowerCase()}
))}
)}
)} {s.latency?.targets?.length ? (
} title={t("audit.document.latency")} note={t("audit.document.latencyNote")}> [ {t(`audit.document.target.${target.target}`)}, target.min_ms != null ? `${target.min_ms} ms` : "—", target.avg_ms != null ? `${target.avg_ms} ms` : "—", target.max_ms != null ? `${target.max_ms} ms` : "—", target.packet_loss != null ? `${target.packet_loss} %` : "—", String(target.samples), ])} />
) : null} {s.storages && (
} title={t("audit.inventory.storage")} count={(s.storages || []).length}> [ {st.id}, st.type, {st.content}, st.shared ? {t("audit.inventory.yes")} : , {st.server || st.path || "—"}, String((s.guests || []).filter((g) => (g.disks || []).some((d) => d.storage === st.id)).length), ])} />
)} {s.guests && (
} title={t("audit.inventory.guests")} count={(s.guests || []).length}>
{(s.guests || []).map((g) => { const open = openGuest.has(g.vmid) const guestApps = apps.get(g.vmid) || [] return (
{open && (
{g.type === "lxc" && ( )} {g.type === "lxc" && } {g.type === "qemu" && ( )} {g.type === "qemu" && }
{g.disks.length > 0 && (
} title={t("audit.inventory.disks")} /> [ {d.slot}, d.storage ? {d.storage} : {t("audit.inventory.passthrough")}, {d.volume}, d.size || "—", ])} />
)} {g.interfaces.length > 0 && (
} title={t("audit.inventory.interfaces")} />
{g.interfaces.map((n) => (
{n.slot} {n.bridge} {n.vlan && VLAN {n.vlan}} {n.mac && {n.mac}}
))}
)}
} title={t("audit.inventory.protection")} /> {g.backups.length === 0 ? (

{t("audit.inventory.noBackupDetail")}

) : ( [ {b.job}, {b.storage}, b.schedule || "—", b.retention || "—", ])} /> )}
{guestApps.length > 0 && (
} title={t("audit.inventory.applications")} />
{guestApps.map((a: any, i: number) => (
{a.name} {a.version || t("audit.inventory.versionUnknown")} {a.update_available && ( {a.available} )} {(a.ports || []).map((p: any, j: number) => ( {p.scheme}:{p.port}{p.path} ))}
))}
)}
)}
) })}
)} {(s.passthrough || []).length > 0 && (
} title={t("audit.inventory.passthroughTitle")} count={(s.passthrough || []).length}> [ {p.vmid}, p.guest || "—", {p.slot || "—"}, {p.address || "—"}, (p.iommu_groups || []).join(", ") || "—", // Everything in a group moves together, so a shared group is // what decides whether the passthrough is possible. (p.shared_group_devices || []).length ? {p.shared_group_devices.length} : , ])} />
)} {s.proxmenux && (
} title={t("audit.inventory.proxmenux")} count={(s.proxmenux.optimizations || []).length}> { const pending = (s.proxmenux!.pending_updates || []) .find((u: any) => u.key === o.key) return [ {o.key}, o.version || "—", pending ? {t("audit.document.updateAvailable", { version: String(pending.available) })} : {t("audit.document.current")}, ] })} />
)}
) }