mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer. The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded. The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
761 lines
34 KiB
TypeScript
761 lines
34 KiB
TypeScript
"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<string, string>
|
|
sections: {
|
|
identity?: Record<string, string | null>
|
|
cluster?: any
|
|
hardware?: any
|
|
storages?: any[]
|
|
guests?: Guest[]
|
|
passthrough?: any[]
|
|
applications?: any[]
|
|
custom_links?: any[]
|
|
proxmenux?: { optimizations: any[]; pending_updates: any[] }
|
|
network?: { bridges: Record<string, any> } | 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 (
|
|
<div className="min-w-0">
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<p className="text-sm text-foreground break-words">{value}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<>
|
|
<div className="hidden sm:block overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-xs text-muted-foreground">
|
|
{columns.map((c, i) => (
|
|
<th key={i} className="pb-2 pr-4 font-medium whitespace-nowrap">{c}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, i) => (
|
|
<tr key={i} className="border-t border-border">
|
|
{row.map((cell, j) => (
|
|
<td key={j} className="py-2 pr-4 align-top tabular-nums">{cell}</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="sm:hidden space-y-2">
|
|
{rows.map((row, i) => (
|
|
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
|
|
{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 (
|
|
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
<span className="text-xs text-muted-foreground shrink-0">{columns[j]}</span>
|
|
<span className="text-sm text-foreground tabular-nums break-words min-w-0">
|
|
{cell}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function Mono({ children }: { children: React.ReactNode }) {
|
|
return <span className="font-mono text-xs text-muted-foreground">{children}</span>
|
|
}
|
|
|
|
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 (
|
|
<Card className="bg-card border-border">
|
|
<CardHeader className="pb-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-expanded={open}
|
|
className="flex w-full items-center gap-2 rounded-md -mx-2 -my-1 px-2 py-1
|
|
text-left hover:bg-white/5 transition-colors cursor-pointer"
|
|
>
|
|
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
: <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
|
<CardTitle className="flex min-w-0 items-center gap-2 text-base font-semibold text-foreground">
|
|
{icon}<span className="min-w-0 break-words">{title}</span>
|
|
</CardTitle>
|
|
{count !== undefined && (
|
|
<Badge variant="outline" className="ml-auto shrink-0 text-xs tabular-nums">{count}</Badge>
|
|
)}
|
|
</button>
|
|
</CardHeader>
|
|
{open && (
|
|
<CardContent className="pt-0 space-y-3">
|
|
{note && <p className="text-xs text-muted-foreground">{note}</p>}
|
|
{children}
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
/** A heading inside a section, matching the printed document's. */
|
|
function Sub({ icon, title, note }: {
|
|
icon: React.ReactNode; title: string; note?: string
|
|
}) {
|
|
return (
|
|
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-foreground mt-4 mb-1 first:mt-0">
|
|
{icon}{title}
|
|
{note && <span className="font-normal text-muted-foreground">— {note}</span>}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
// 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 <span className="text-xs text-muted-foreground italic">{t("audit.inventory.unresolved")}</span>
|
|
}
|
|
if (hops.length === 0) {
|
|
return <span className="text-xs text-muted-foreground">{t("audit.inventory.noUplink")}</span>
|
|
}
|
|
return (
|
|
<span className="flex flex-wrap items-center gap-1 text-xs">
|
|
{hops.map((h, i) => (
|
|
<span key={`${h.id}-${i}`} className="flex items-center gap-1">
|
|
{i > 0 && <span className="text-muted-foreground">→</span>}
|
|
<Badge variant="outline" className="font-mono text-xs">
|
|
{h.id}{h.mode ? ` · ${h.mode}` : ""}
|
|
</Badge>
|
|
</span>
|
|
))}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export function AuditInventory({ profile = "full" }: { profile?: string }) {
|
|
const t = useT()
|
|
const { language } = useI18n()
|
|
const [data, setData] = useState<Inventory | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [openGuest, setOpenGuest] = useState<Set<number>>(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<number, any[]>()
|
|
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 (
|
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.inventory.loading")}
|
|
</div>
|
|
)
|
|
}
|
|
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
|
|
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 (
|
|
<div className="space-y-4">
|
|
<p className="text-xs text-muted-foreground px-1">
|
|
{t("audit.inventory.collectedAt", {
|
|
when: new Date(data.collected_at * 1000).toLocaleString(language),
|
|
})}
|
|
</p>
|
|
|
|
{/* 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 && (
|
|
<Card className="bg-card border-border">
|
|
<CardContent className="py-3 space-y-1">
|
|
<p className="text-xs font-medium text-muted-foreground">
|
|
{t("audit.inventory.unavailable")}
|
|
</p>
|
|
{Object.entries(data.unavailable).map(([k, v]) => (
|
|
<p key={k} className="text-xs text-muted-foreground">
|
|
<span className="font-mono">{k}</span> — {v}
|
|
</p>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{s.identity && (
|
|
<Section icon={<Server className="h-4 w-4 text-blue-500" />} title={t("audit.inventory.identity")}>
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Field label={t("audit.inventory.node")} value={s.identity?.node} />
|
|
<Field label={t("audit.inventory.pveVersion")} value={s.identity?.pve_version} />
|
|
<Field label={t("audit.inventory.kernel")} value={s.identity?.kernel} />
|
|
<Field label={t("audit.inventory.subscription")} value={subscriptionLabel(t, s.identity?.subscription)} />
|
|
<Field label={t("audit.inventory.cluster")}
|
|
value={s.identity?.cluster || t("audit.inventory.standalone")} />
|
|
<Field label={t("audit.document.system")}
|
|
value={[hw.system?.manufacturer, hw.system?.product].filter(Boolean).join(" ")} />
|
|
</div>
|
|
</Section>
|
|
)}
|
|
|
|
{"cluster" in s && (
|
|
<Section icon={<Share2 className="h-4 w-4 text-cyan-500" />} title={t("audit.document.cluster")}
|
|
count={s.cluster ? (s.cluster.nodes || []).length : undefined}
|
|
note={s.cluster ? undefined : t("audit.document.standaloneNote")}>
|
|
{s.cluster && (
|
|
<>
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<Field label={t("audit.inventory.cluster")} value={s.cluster.name} />
|
|
<Field label={t("audit.document.quorum")}
|
|
value={s.cluster.quorate == null ? "—" : (
|
|
<Badge variant="outline" className={s.cluster.quorate
|
|
? "bg-green-500/10 text-green-500 border-green-500/20"
|
|
: "bg-red-500/10 text-red-500 border-red-500/20"}>
|
|
{t(s.cluster.quorate ? "audit.document.quorate"
|
|
: "audit.document.inquorate")}
|
|
</Badge>
|
|
)} />
|
|
<Field label={t("audit.document.votes")}
|
|
value={`${s.cluster.total_votes ?? "—"} / ${s.cluster.expected_votes ?? "—"}`} />
|
|
</div>
|
|
<DataTable
|
|
columns={[t("audit.document.nodeName"), "nodeid", "ring0", "ring1",
|
|
t("audit.document.state")]}
|
|
rows={(s.cluster.nodes || []).map((n: any) => [
|
|
<span className="font-medium text-foreground">
|
|
{n.name}
|
|
{n.local && <span className="text-muted-foreground">
|
|
{" "}({t("audit.document.thisNode")})</span>}
|
|
</span>,
|
|
<Mono>{n.nodeid || "—"}</Mono>,
|
|
<Mono>{n.ring0_addr || "—"}</Mono>,
|
|
<Mono>{n.ring1_addr || "—"}</Mono>,
|
|
n.online === false
|
|
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
|
|
{t("audit.document.unreachable")}</Badge>
|
|
: n.online === true
|
|
? <Badge variant="outline">{t("audit.document.member")}</Badge>
|
|
: "—",
|
|
])}
|
|
/>
|
|
</>
|
|
)}
|
|
</Section>
|
|
)}
|
|
|
|
{s.hardware && (
|
|
<Section icon={<Cpu className="h-4 w-4 text-indigo-500" />} title={t("audit.document.architecture")}>
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Field label={t("audit.inventory.cpu")} value={hw.cpu?.model} />
|
|
<Field label={t("audit.inventory.topology")}
|
|
value={hw.cpu ? t("audit.inventory.cpuLayout", {
|
|
sockets: String(hw.cpu.sockets), cores: String(hw.cpu.cores_per_socket),
|
|
threads: String(hw.cpu.threads),
|
|
}) : ""} />
|
|
<Field label={t("audit.inventory.memory")}
|
|
value={hw.memory_bytes ? `${(hw.memory_bytes / GiB).toFixed(0)} GiB` : ""} />
|
|
<Field label={t("audit.inventory.virtualisation")} value={hw.cpu?.virtualisation} />
|
|
<Field label={t("audit.inventory.serial")} value={hw.system?.serial} />
|
|
<Field label={t("audit.document.board")}
|
|
value={[hw.board?.manufacturer, hw.board?.product].filter(Boolean).join(" ")} />
|
|
<Field label={t("audit.inventory.bios")}
|
|
value={[hw.bios?.vendor, hw.bios?.version, hw.bios?.date].filter(Boolean).join(" · ")} />
|
|
<Field label={t("audit.inventory.iommuGroups")} value={hw.iommu_groups} />
|
|
</div>
|
|
|
|
{(mem.modules || []).length > 0 && (
|
|
<>
|
|
<Sub icon={<MemoryStick className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
title={t("audit.document.memoryModules")}
|
|
note={t("audit.document.slotsFilled", {
|
|
used: String(mem.populated ?? 0),
|
|
total: String(mem.slots ?? mem.populated ?? 0),
|
|
})} />
|
|
<DataTable
|
|
columns={[t("audit.document.slot"), t("audit.document.size"),
|
|
t("audit.document.type"), t("audit.document.formFactor"),
|
|
t("audit.document.speed"), t("audit.document.manufacturer")]}
|
|
rows={(mem.modules || []).map((m: any) => [
|
|
<Mono>{m.locator || "—"}</Mono>, m.size || "—", m.type || "—",
|
|
m.form_factor || "—", m.speed || "—",
|
|
[m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—",
|
|
])}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{(hw.controllers || []).length > 0 && (
|
|
<>
|
|
<Sub icon={<CircuitBoard className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
title={t("audit.document.controllers")} />
|
|
<DataTable
|
|
columns={["PCI", t("audit.document.class"), t("audit.document.device")]}
|
|
rows={(hw.controllers || []).map((c: any) => [
|
|
<Mono>{c.slot}</Mono>, c.class, c.name,
|
|
])}
|
|
/>
|
|
</>
|
|
)}
|
|
</Section>
|
|
)}
|
|
|
|
{(hw.disks || []).length > 0 && (
|
|
<Section icon={<HardDrive className="h-4 w-4 text-amber-500" />} title={t("audit.document.storageDevices")}
|
|
count={(hw.disks || []).length}>
|
|
<DataTable
|
|
columns={[t("audit.document.device"), t("audit.document.model"),
|
|
t("audit.document.serial"), t("audit.document.size"),
|
|
t("audit.document.bus"), "SMART", t("audit.document.serviceLife"),
|
|
t("audit.document.events")]}
|
|
rows={(hw.disks || []).map((d: any) => {
|
|
const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase())
|
|
return [
|
|
<span className="font-medium text-foreground">{d.name}</span>,
|
|
d.model || "—",
|
|
<Mono>{d.serial || "—"}</Mono>,
|
|
bytes(d.size_bytes),
|
|
`${(d.bus || "—").toUpperCase()} · ${d.rotational ? "HDD" : "SSD"}`,
|
|
ok
|
|
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
|
{t("audit.document.healthy")}</Badge>
|
|
: d.health && d.health !== "unknown"
|
|
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
|
|
{d.health}</Badge>
|
|
: "—",
|
|
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
|
|
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
|
|
{d.observations.length}</Badge>
|
|
: <span className="text-muted-foreground">—</span>,
|
|
]
|
|
})}
|
|
/>
|
|
|
|
{(hw.disks || []).some((d: any) => (d.observations || []).length > 0) && (
|
|
<>
|
|
<Sub icon={<Activity className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
title={t("audit.document.observations")}
|
|
note={t("audit.document.observationsNote")} />
|
|
{(hw.disks || []).filter((d: any) => (d.observations || []).length).map((d: any) => (
|
|
<div key={d.name} className="mt-2">
|
|
<p className="text-xs font-medium text-foreground mb-1">
|
|
{d.name} <span className="font-normal text-muted-foreground">{d.model}</span>
|
|
</p>
|
|
<DataTable
|
|
columns={[t("audit.document.event"), t("audit.document.severity"),
|
|
t("audit.document.occurrences"), t("audit.document.firstSeen"),
|
|
t("audit.document.lastSeen"), t("audit.document.detail")]}
|
|
rows={d.observations.map((o: any) => [
|
|
o.type || "—",
|
|
<Badge variant="outline" className={o.severity === "critical"
|
|
? "bg-red-500/10 text-red-500 border-red-500/20"
|
|
: "bg-amber-500/10 text-amber-500 border-amber-500/20"}>
|
|
{o.severity
|
|
? t(`audit.classifications.${
|
|
o.severity === "critical" ? "critical" : "warning"}`)
|
|
: "—"}</Badge>,
|
|
String(o.count ?? "—"),
|
|
when(o.first_seen), when(o.last_seen),
|
|
<span className="text-muted-foreground break-words">{o.message || ""}</span>,
|
|
])}
|
|
/>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</Section>
|
|
)}
|
|
|
|
{(s.network || (hw.adapters || []).length > 0) && (
|
|
<Section icon={<Network className="h-4 w-4 text-green-500" />} title={t("audit.inventory.network")}
|
|
count={Object.keys(s.network?.bridges || {}).length || undefined}>
|
|
{(hw.adapters || []).length > 0 && (
|
|
<>
|
|
<Sub icon={<Plug className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
title={t("audit.document.physicalAdapters")} />
|
|
<DataTable
|
|
columns={[t("audit.document.interface"), t("audit.document.state"),
|
|
t("audit.document.speed"), "MAC", t("audit.document.driver"), "PCI"]}
|
|
rows={(hw.adapters || []).map((a: any) => [
|
|
<span className="font-medium text-foreground">{a.name}</span>,
|
|
a.state === "up"
|
|
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
|
{a.state}</Badge>
|
|
: <Badge variant="outline">{a.state || "—"}</Badge>,
|
|
a.speed_mbps
|
|
? (a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`)
|
|
: "—",
|
|
<Mono>{a.mac || "—"}</Mono>, a.driver || "—", <Mono>{a.pci || "—"}</Mono>,
|
|
])}
|
|
/>
|
|
</>
|
|
)}
|
|
{s.network && (
|
|
<>
|
|
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
title={t("audit.document.bridges")} />
|
|
<div className="space-y-2">
|
|
{Object.entries(s.network.bridges || {}).map(([id, b]: [string, any]) => (
|
|
<div key={id} className="flex flex-wrap items-center gap-2 text-sm">
|
|
<Badge variant="outline" className="font-mono">{id}</Badge>
|
|
<span className="text-muted-foreground">→</span>
|
|
<Uplink hops={b.uplink} />
|
|
{b.vlan_interface && (
|
|
<Badge variant="outline" className="text-xs">VLAN {b.vlan_interface}</Badge>
|
|
)}
|
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
|
{(s.guests || []).filter((g) =>
|
|
(g.interfaces || []).some((n) => n.bridge === id)).length}
|
|
{" "}{t("audit.inventory.guests").toLowerCase()}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Section>
|
|
)}
|
|
|
|
{s.latency?.targets?.length ? (
|
|
<Section icon={<Activity className="h-4 w-4 text-sky-500" />} title={t("audit.document.latency")}
|
|
note={t("audit.document.latencyNote")}>
|
|
<DataTable
|
|
columns={[t("audit.document.target.label"), t("audit.document.minimum"),
|
|
t("audit.document.average"), t("audit.document.maximum"),
|
|
t("audit.document.packetLoss"), t("audit.document.samples")]}
|
|
rows={s.latency.targets.map((target: any) => [
|
|
<span className="font-medium text-foreground">
|
|
{t(`audit.document.target.${target.target}`)}</span>,
|
|
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),
|
|
])}
|
|
/>
|
|
</Section>
|
|
) : null}
|
|
|
|
{s.storages && (
|
|
<Section icon={<HardDrive className="h-4 w-4 text-purple-500" />} title={t("audit.inventory.storage")}
|
|
count={(s.storages || []).length}>
|
|
<DataTable
|
|
columns={[t("audit.inventory.name"), t("audit.inventory.type"),
|
|
t("audit.inventory.content"), t("audit.inventory.shared"),
|
|
t("audit.document.location"), t("audit.inventory.guests")]}
|
|
rows={(s.storages || []).map((st: any) => [
|
|
<Mono>{st.id}</Mono>, st.type,
|
|
<span className="text-muted-foreground text-xs">{st.content}</span>,
|
|
st.shared
|
|
? <Badge variant="outline">{t("audit.inventory.yes")}</Badge>
|
|
: <span className="text-muted-foreground">—</span>,
|
|
<span className="text-muted-foreground text-xs break-all">
|
|
{st.server || st.path || "—"}</span>,
|
|
String((s.guests || []).filter((g) =>
|
|
(g.disks || []).some((d) => d.storage === st.id)).length),
|
|
])}
|
|
/>
|
|
</Section>
|
|
)}
|
|
|
|
{s.guests && (
|
|
<Section icon={<Boxes className="h-4 w-4 text-emerald-500" />} title={t("audit.inventory.guests")}
|
|
count={(s.guests || []).length}>
|
|
<div className="space-y-2">
|
|
{(s.guests || []).map((g) => {
|
|
const open = openGuest.has(g.vmid)
|
|
const guestApps = apps.get(g.vmid) || []
|
|
return (
|
|
<div key={g.vmid} className="rounded-md border border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggle(g.vmid)}
|
|
aria-expanded={open}
|
|
className="flex w-full flex-wrap items-center gap-2 rounded-md p-3 text-left hover:bg-white/5 transition-colors cursor-pointer"
|
|
>
|
|
{open ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
|
<Badge variant="outline" className="font-mono text-xs">{g.vmid}</Badge>
|
|
<span className="font-medium text-foreground">{g.name || "—"}</span>
|
|
<Badge variant="outline" className="text-xs uppercase">{g.type}</Badge>
|
|
{/* Whether a guest needs a backup is not visible from
|
|
here, so its absence is stated, not flagged. */}
|
|
{g.backups.length === 0 && (
|
|
<Badge variant="outline" className="text-xs">
|
|
{t("audit.inventory.noBackup")}
|
|
</Badge>
|
|
)}
|
|
<span className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums">
|
|
{g.cores}c · {g.memory}MB
|
|
</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="border-t border-border p-3 space-y-4">
|
|
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
|
<Field label={t("audit.inventory.ostype")} value={g.ostype} />
|
|
<Field label={t("audit.inventory.onboot")}
|
|
value={g.onboot ? t("audit.inventory.yes") : t("audit.inventory.no")} />
|
|
<Field label={t("audit.inventory.tags")} value={g.tags} />
|
|
{g.type === "lxc" && (
|
|
<Field label={t("audit.inventory.privilege")}
|
|
value={g.unprivileged ? t("audit.inventory.unprivileged")
|
|
: t("audit.inventory.privileged")} />
|
|
)}
|
|
{g.type === "lxc" && <Field label={t("audit.inventory.features")} value={g.features} />}
|
|
{g.type === "qemu" && (
|
|
<Field label={t("audit.inventory.agent")}
|
|
value={g.agent ? t("audit.inventory.yes") : t("audit.inventory.no")} />
|
|
)}
|
|
{g.type === "qemu" && <Field label={t("audit.inventory.cpuModel")} value={g.cpu} />}
|
|
</div>
|
|
|
|
{g.disks.length > 0 && (
|
|
<div>
|
|
<Sub icon={<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
title={t("audit.inventory.disks")} />
|
|
<DataTable
|
|
columns={[t("audit.document.slot"), t("audit.document.storage"),
|
|
t("audit.inventory.name"), t("audit.document.size")]}
|
|
rows={g.disks.map((d) => [
|
|
<Mono>{d.slot}</Mono>,
|
|
d.storage
|
|
? <Badge variant="outline" className="font-mono text-xs">{d.storage}</Badge>
|
|
: <Badge variant="outline" className="text-xs">
|
|
{t("audit.inventory.passthrough")}</Badge>,
|
|
<span className="font-mono text-xs break-all">{d.volume}</span>,
|
|
d.size || "—",
|
|
])}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{g.interfaces.length > 0 && (
|
|
<div>
|
|
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
title={t("audit.inventory.interfaces")} />
|
|
<div className="space-y-1">
|
|
{g.interfaces.map((n) => (
|
|
<div key={n.slot} className="flex flex-wrap items-center gap-1.5 text-xs">
|
|
<Badge variant="outline" className="font-mono">{n.slot}</Badge>
|
|
<span className="text-muted-foreground">→</span>
|
|
<Badge variant="outline" className="font-mono">{n.bridge}</Badge>
|
|
<span className="text-muted-foreground">→</span>
|
|
<Uplink hops={n.uplink} />
|
|
{n.vlan && <Badge variant="outline">VLAN {n.vlan}</Badge>}
|
|
{n.mac && <Mono>{n.mac}</Mono>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Sub icon={<Package className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
title={t("audit.inventory.protection")} />
|
|
{g.backups.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("audit.inventory.noBackupDetail")}</p>
|
|
) : (
|
|
<DataTable
|
|
columns={[t("audit.document.backup"), t("audit.document.storage"),
|
|
t("audit.inventory.type"), t("audit.document.content")]}
|
|
rows={g.backups.map((b) => [
|
|
<Mono>{b.job}</Mono>,
|
|
<Badge variant="outline" className="font-mono text-xs">{b.storage}</Badge>,
|
|
b.schedule || "—", b.retention || "—",
|
|
])}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{guestApps.length > 0 && (
|
|
<div>
|
|
<Sub icon={<Wrench className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
title={t("audit.inventory.applications")} />
|
|
<div className="space-y-1">
|
|
{guestApps.map((a: any, i: number) => (
|
|
<div key={`${a.slug}-${i}`} className="flex flex-wrap items-center gap-1.5 text-xs">
|
|
<span className="text-foreground">{a.name}</span>
|
|
<Mono>{a.version || t("audit.inventory.versionUnknown")}</Mono>
|
|
{a.update_available && (
|
|
<Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
|
|
{a.available}
|
|
</Badge>
|
|
)}
|
|
{(a.ports || []).map((p: any, j: number) => (
|
|
<Badge key={j} variant="outline" className="font-mono">
|
|
{p.scheme}:{p.port}{p.path}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</Section>
|
|
)}
|
|
|
|
{(s.passthrough || []).length > 0 && (
|
|
<Section icon={<Plug className="h-4 w-4 text-violet-500" />} title={t("audit.inventory.passthroughTitle")}
|
|
count={(s.passthrough || []).length}>
|
|
<DataTable
|
|
columns={[t("audit.document.vmid"), t("audit.document.name"),
|
|
t("audit.document.slot"), t("audit.document.device"),
|
|
t("audit.document.iommuGroup"), t("audit.inventory.sharedGroup")]}
|
|
rows={(s.passthrough || []).map((p: any) => [
|
|
<Mono>{p.vmid}</Mono>,
|
|
p.guest || "—",
|
|
<Mono>{p.slot || "—"}</Mono>,
|
|
<Mono>{p.address || "—"}</Mono>,
|
|
(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
|
|
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
|
|
{p.shared_group_devices.length}</Badge>
|
|
: <span className="text-muted-foreground">—</span>,
|
|
])}
|
|
/>
|
|
</Section>
|
|
)}
|
|
|
|
{s.proxmenux && (
|
|
<Section icon={<Wrench className="h-4 w-4 text-orange-500" />} title={t("audit.inventory.proxmenux")}
|
|
count={(s.proxmenux.optimizations || []).length}>
|
|
<DataTable
|
|
columns={[t("audit.document.name"), t("audit.document.version"),
|
|
t("audit.document.state")]}
|
|
rows={(s.proxmenux.optimizations || []).map((o: any) => {
|
|
const pending = (s.proxmenux!.pending_updates || [])
|
|
.find((u: any) => u.key === o.key)
|
|
return [
|
|
<Mono>{o.key}</Mono>,
|
|
o.version || "—",
|
|
pending
|
|
? <Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
|
|
{t("audit.document.updateAvailable", { version: String(pending.available) })}
|
|
</Badge>
|
|
: <Badge variant="outline">{t("audit.document.current")}</Badge>,
|
|
]
|
|
})}
|
|
/>
|
|
</Section>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|