mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-17 04:06:47 +00:00
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>
|
||
|
|
)
|
||
|
|
}
|