mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-16 19:56:47 +00:00
Add audit and reports page, and a change journal
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>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import {
|
||||
ChevronDown, ChevronRight, FileCode, HelpCircle, Loader2, Package,
|
||||
Play, Settings2,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
|
||||
/**
|
||||
* What ProxMenux changed on this host.
|
||||
*
|
||||
* The complaint this answers is not that the tool changes things: it is
|
||||
* that afterwards nobody can say what it changed. Showing the script
|
||||
* does not answer it either — a function of four hundred lines may alter
|
||||
* two values, and the reader cannot tell which two. So what is shown
|
||||
* here is the difference and nothing else.
|
||||
*
|
||||
* Two distinctions are kept in front of the reader, because both bear on
|
||||
* what they can do about what they are looking at: whether ProxMenux
|
||||
* authored the change or merely ran something the user asked for, and
|
||||
* how much of the previous state is actually known.
|
||||
*/
|
||||
|
||||
interface Change {
|
||||
id: number
|
||||
recorded_at: number
|
||||
class: string
|
||||
operation: string
|
||||
source: string
|
||||
function: string
|
||||
function_version: string
|
||||
target: string
|
||||
before_ref: string
|
||||
after_ref: string
|
||||
capture: string
|
||||
revert: string
|
||||
exactness: string
|
||||
result: string
|
||||
recoverable: boolean
|
||||
detail: Record<string, unknown>
|
||||
diff?: {
|
||||
available: boolean; reason?: string
|
||||
added?: number; removed?: number; truncated?: boolean; hunks?: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
total: number
|
||||
by_class: Record<string, number>
|
||||
functions: Array<{
|
||||
function: string; source: string; version: string
|
||||
changes: number; last_change: number; first_change: number
|
||||
}>
|
||||
journal_started: number | null
|
||||
}
|
||||
|
||||
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof Settings2 }> = {
|
||||
configuration: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Settings2 },
|
||||
installation: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: Package },
|
||||
execution: { chip: "bg-muted text-muted-foreground border-border", Icon: Play },
|
||||
registration: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle },
|
||||
}
|
||||
|
||||
export function AuditChanges() {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const [changes, setChanges] = useState<Change[]>([])
|
||||
const [summary, setSummary] = useState<Summary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState<Set<number>>(new Set())
|
||||
const [filter, setFilter] = useState<string>("all")
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await fetchApi("/api/audit/changes?limit=500")
|
||||
if (res?.success) {
|
||||
setChanges(res.changes || [])
|
||||
setSummary(res.summary || null)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(res?.message || t("audit.changes.failed"))
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const toggle = (id: number) => setOpen((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const visible = useMemo(
|
||||
() => changes.filter((c) => filter === "all" || c.class === filter),
|
||||
[changes, filter],
|
||||
)
|
||||
|
||||
const when = (epoch: number) => new Date(epoch * 1000).toLocaleString(language)
|
||||
|
||||
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.changes.loading")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="py-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("audit.changes.intro")}</p>
|
||||
{/* A host with nothing recorded should say why, rather than
|
||||
looking like a host nothing has touched. */}
|
||||
{summary && summary.total === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("audit.changes.empty")}</p>
|
||||
)}
|
||||
{summary && summary.journal_started && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("audit.changes.since", { date: when(summary.journal_started) })}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(["all", "configuration", "installation", "execution", "registration"] as const)
|
||||
.filter((key) => key === "all" || summary?.by_class?.[key])
|
||||
.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setFilter(key)}
|
||||
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
||||
filter === key
|
||||
? "bg-blue-500 text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t(`audit.changes.class.${key}`)}
|
||||
{key !== "all" && summary?.by_class?.[key] !== undefined && (
|
||||
<span className="ml-1.5 tabular-nums">{summary.by_class[key]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{summary && summary.functions.length > 0 && (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<FileCode className="h-4 w-4" />{t("audit.changes.byFunction")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
{summary.functions.map((fn) => (
|
||||
<button
|
||||
key={fn.function}
|
||||
type="button"
|
||||
onClick={() => setFilter("all")}
|
||||
className="flex w-full flex-wrap items-center gap-2 rounded-md border
|
||||
border-border p-2.5 text-left hover:bg-white/5
|
||||
transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="font-mono text-sm text-foreground">{fn.function}</span>
|
||||
{fn.version && (
|
||||
<Badge variant="outline" className="text-xs">v{fn.version}</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs tabular-nums">
|
||||
{t("audit.changes.count", { count: String(fn.changes) })}
|
||||
</Badge>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{when(fn.last_change)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{visible.map((change) => {
|
||||
const style = CLASS_STYLE[change.class] || CLASS_STYLE.registration
|
||||
const Icon = style.Icon
|
||||
const expanded = open.has(change.id)
|
||||
const installed = String(change.detail?.installed || "")
|
||||
return (
|
||||
<Card key={change.id} className="bg-card border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(change.id)}
|
||||
aria-expanded={expanded}
|
||||
className="w-full text-left p-3 flex flex-wrap items-center gap-2
|
||||
rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded
|
||||
? <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={`${style.chip} gap-1.5 shrink-0`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`audit.changes.operation.${change.operation}`)}
|
||||
</Badge>
|
||||
<span className="min-w-0 font-mono text-sm text-foreground break-all">
|
||||
{change.target}
|
||||
</span>
|
||||
{change.diff?.available && (
|
||||
<Badge variant="outline" className="text-xs tabular-nums shrink-0">
|
||||
+{change.diff.added} −{change.diff.removed}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Whether the previous state is known is what decides
|
||||
if undoing this is even discussable. */}
|
||||
{change.capture === "unknown" && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{t("audit.changes.capture.unknown")}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{when(change.recorded_at)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<CardContent className="pt-0 pl-10 space-y-3">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t("audit.changes.function")}:{" "}
|
||||
<span className="font-mono text-foreground">{change.function || "—"}</span>
|
||||
{change.function_version && ` v${change.function_version}`}
|
||||
</span>
|
||||
<span>{t("audit.changes.source")}:{" "}
|
||||
<span className="font-mono">{change.source || "—"}</span></span>
|
||||
<span>{t("audit.changes.reversibility")}:{" "}
|
||||
{t(`audit.changes.exactness.${change.exactness}`)}</span>
|
||||
</div>
|
||||
|
||||
{installed && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.changes.packagesAdded")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.split(/\s+/).filter(Boolean).map((pkg) => (
|
||||
<Badge key={pkg} variant="outline" className="font-mono text-xs">
|
||||
{pkg}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{change.class === "execution" && Boolean(change.detail?.command) && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.changes.commandRun")}
|
||||
</p>
|
||||
<pre className="text-xs font-mono bg-background border border-border
|
||||
rounded-md p-2.5 overflow-x-auto">
|
||||
{String(change.detail.command)}
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("audit.changes.executionNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{change.diff && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.changes.difference")}
|
||||
</p>
|
||||
{change.diff.available ? (
|
||||
<>
|
||||
<pre className="text-xs font-mono bg-background border border-border
|
||||
rounded-md p-2.5 overflow-x-auto">
|
||||
{(change.diff.hunks || []).map((line: string, i: number) => (
|
||||
<div key={i} className={
|
||||
line.startsWith("+") ? "text-green-500"
|
||||
: line.startsWith("-") ? "text-red-400"
|
||||
: line.startsWith("@@") ? "text-blue-400" : ""
|
||||
}>{line}</div>
|
||||
))}
|
||||
</pre>
|
||||
{change.diff.truncated && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("audit.changes.diffTruncated")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("audit.changes.diffUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{change.capture === "unknown" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("audit.changes.unknownNote")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{visible.length === 0 && summary && summary.total > 0 && (
|
||||
<p className="text-sm text-muted-foreground px-1">{t("audit.changes.noneInFilter")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import {
|
||||
ChevronDown, ChevronRight, Flag, Loader2, MinusCircle,
|
||||
PlusCircle, ShieldOff, TrendingUp,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
|
||||
/**
|
||||
* How this assessment differs from an earlier one.
|
||||
*
|
||||
* A single run says what the host is like now. It cannot say whether
|
||||
* that is better or worse than last week, which is the question anyone
|
||||
* maintaining a machine actually asks — and the one that turns an audit
|
||||
* from a snapshot into a record.
|
||||
*
|
||||
* The distinction the engine draws and this view keeps: a finding that
|
||||
* stopped being reported because the host was fixed is not the same as
|
||||
* one that stopped because somebody accepted it. Both leave the list;
|
||||
* only the first is progress, and merging them would tell the reader a
|
||||
* problem went away when the decision was to live with it.
|
||||
*
|
||||
* It sits inside the assessment rather than in a view of its own,
|
||||
* because "what changed since last time" is context for the run being
|
||||
* read, not a separate place to visit.
|
||||
*/
|
||||
|
||||
interface Finding {
|
||||
check_id: string
|
||||
area: string
|
||||
classification: string
|
||||
}
|
||||
|
||||
interface Comparison {
|
||||
from: string
|
||||
to: string
|
||||
new: Finding[]
|
||||
resolved: Finding[]
|
||||
accepted: Finding[]
|
||||
unchanged: Finding[]
|
||||
retired: Finding[]
|
||||
unverified: Finding[]
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{ key: "new", Icon: PlusCircle, tone: "text-amber-500" },
|
||||
{ key: "resolved", Icon: MinusCircle, tone: "text-green-500" },
|
||||
{ key: "accepted", Icon: ShieldOff, tone: "text-indigo-400" },
|
||||
{ key: "retired", Icon: Flag, tone: "text-muted-foreground" },
|
||||
] as const
|
||||
|
||||
export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
|
||||
runId: string
|
||||
isBaseline: boolean
|
||||
onBaselineSet: () => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const [comparison, setComparison] = useState<Comparison | null>(null)
|
||||
const [baseline, setBaseline] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// fetchApi turns a non-2xx response into an Error whose message is the
|
||||
// backend's own English prose and whose `body` carries the parsed
|
||||
// payload. Both paths therefore go through here: only the reason code
|
||||
// crosses into a view that exists in eight languages.
|
||||
const reason = (source: any): string => {
|
||||
const code = String(source?.reason ?? source?.body?.reason ?? "")
|
||||
const key = `audit.comparison.reasons.${code}`
|
||||
const translated = t(key)
|
||||
return translated !== key ? translated : t("audit.comparison.failed")
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Asked for separately: a host with a single run answers the
|
||||
// comparison with an error, and inside a Promise.all that error
|
||||
// takes the status with it — leaving no way to tell a first
|
||||
// assessment from a comparison that genuinely failed, which is
|
||||
// how "two runs are required" reached a reader who had simply
|
||||
// never chosen a reference.
|
||||
const status: any = await fetchApi("/api/audit/status").catch(() => null)
|
||||
setBaseline(status?.baseline || null)
|
||||
|
||||
// With no reference chosen there is nothing to compare against,
|
||||
// and asking anyway answered 400 — an error in the browser console
|
||||
// for the ordinary state of a host assessed for the first time.
|
||||
let diff: any = null
|
||||
let failure: unknown = null
|
||||
if (status?.baseline) {
|
||||
try {
|
||||
diff = await fetchApi(`/api/audit/compare?to=${encodeURIComponent(runId)}`)
|
||||
} catch (e) {
|
||||
failure = e
|
||||
}
|
||||
}
|
||||
setComparison(diff?.success ? diff : null)
|
||||
// Having no reference run yet is the ordinary state of a host
|
||||
// assessed for the first time, and the view already says so.
|
||||
const failed = failure ?? (diff?.success === false ? diff : null)
|
||||
setError(failed && status?.baseline ? reason(failed) : null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const markBaseline = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res: any = await fetchApi("/api/audit/baseline", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ run_id: runId }),
|
||||
})
|
||||
if (res?.success) { onBaselineSet(); await load() }
|
||||
else setError(reason(res))
|
||||
} catch (e) {
|
||||
setError(reason(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t("audit.comparison.loading")}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const counts = comparison
|
||||
? GROUPS.map((g) => ({ ...g, n: (comparison[g.key] || []).length }))
|
||||
.filter((g) => g.n > 0)
|
||||
: []
|
||||
const comparable = comparison && comparison.from !== comparison.to
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
{!comparable ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{baseline ? t("audit.comparison.isBaseline") : t("audit.comparison.noBaseline")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full flex-wrap items-center gap-2 rounded-md -mx-2 px-2 py-1
|
||||
text-left hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
||||
<TrendingUp className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("audit.comparison.since", {
|
||||
date: baseline?.started_at
|
||||
? new Date(baseline.started_at * 1000).toLocaleDateString(language)
|
||||
: t("audit.comparison.previousRun"),
|
||||
})}
|
||||
</span>
|
||||
{counts.length === 0 ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("audit.comparison.noChange")}
|
||||
</Badge>
|
||||
) : counts.map(({ key, Icon, tone, n }) => (
|
||||
<Badge key={key} variant="outline" className={`text-xs gap-1.5 ${tone}`}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{t(`audit.comparison.${key}`)}
|
||||
<span className="tabular-nums font-semibold">{n}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-3 pl-6">
|
||||
{GROUPS.filter((g) => (comparison[g.key] || []).length > 0).map(
|
||||
({ key, Icon, tone }) => (
|
||||
<div key={key}>
|
||||
<p className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${tone}`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`audit.comparison.${key}`)}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
— {t(`audit.comparison.${key}Note`)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(comparison[key] || []).map((f) => (
|
||||
<Badge key={f.check_id} variant="outline" className="text-xs">
|
||||
{t(`audit.checks.${f.check_id}.title`)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{(comparison.unchanged || []).length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("audit.comparison.unchanged", {
|
||||
count: String(comparison.unchanged.length),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Choosing a reference is what makes every later run comparable,
|
||||
so the action lives beside the comparison it enables. */}
|
||||
{!isBaseline && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={markBaseline}
|
||||
disabled={saving}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{saving
|
||||
? <Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||||
: <Flag className="h-3 w-3 mr-1.5" />}
|
||||
{t("audit.comparison.setBaseline")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import { parseEvidence, type EvidenceBlock } from "../lib/evidence-format"
|
||||
|
||||
/**
|
||||
* Renders a finding's evidence as tables and labelled values.
|
||||
*
|
||||
* The evidence exists so a reader can verify the conclusion for
|
||||
* themselves. A JSON dump technically contains the same facts but asks
|
||||
* the reader to parse it first, which is the part they came here to
|
||||
* avoid.
|
||||
*/
|
||||
export function AuditEvidence({ evidence, locale }: {
|
||||
evidence: string | null
|
||||
locale: string
|
||||
}) {
|
||||
const blocks = parseEvidence(evidence, locale)
|
||||
if (blocks.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, i) => <Block key={i} block={block} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Block({ block }: { block: EvidenceBlock }) {
|
||||
const title = block.title
|
||||
? <p className="text-xs font-medium text-foreground mb-1">{block.title}</p>
|
||||
: null
|
||||
|
||||
if (block.kind === "table") {
|
||||
return (
|
||||
<div>
|
||||
{title}
|
||||
{/* Wide evidence scrolls inside its own box so the page itself
|
||||
never scrolls sideways. */}
|
||||
{/* Evidence is the widest thing on the page; on a narrow screen
|
||||
it stacks like the rest rather than scrolling sideways. */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
{block.rows.map((row, i) => (
|
||||
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
|
||||
{row.map((cell, j) => cell === "—" || cell === "" ? null : (
|
||||
<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">
|
||||
{block.columns[j]}</span>
|
||||
<span className="text-xs text-foreground tabular-nums break-words min-w-0">
|
||||
{cell}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/50">
|
||||
{block.columns.map((c) => (
|
||||
<th key={c} className="text-left font-medium px-3 py-1.5
|
||||
text-muted-foreground whitespace-nowrap">
|
||||
{c}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={i} className="border-t border-border">
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} className="px-3 py-1.5 align-top tabular-nums">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.kind === "pairs") {
|
||||
return (
|
||||
<div>
|
||||
{title}
|
||||
<dl className="grid grid-cols-[minmax(0,auto)_1fr] gap-x-4 gap-y-1 text-xs
|
||||
rounded-md border border-border px-3 py-2">
|
||||
{block.entries.map(([label, value]) => (
|
||||
<div key={label} className="contents">
|
||||
<dt className="text-muted-foreground whitespace-nowrap">{label}</dt>
|
||||
<dd className="text-foreground break-words tabular-nums">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{title}
|
||||
{block.lines.length > 0 && (
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 rounded-md
|
||||
border border-border px-3 py-2">
|
||||
{block.lines.map((line, i) => (
|
||||
<li key={i} className="break-words">{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { presentFinding, type PresentedFinding, type AuditTranslate } from "../lib/audit-presentation"
|
||||
|
||||
export function AuditFindingData({ finding, t, locale }: {
|
||||
finding: PresentedFinding; t: AuditTranslate; locale: string
|
||||
}) {
|
||||
return <div className="space-y-4">{presentFinding(finding, t, locale).map((group, index) =>
|
||||
<section key={index}>
|
||||
<p className="text-sm font-medium mb-2">{group.title}</p>
|
||||
{group.note && <p className="text-sm text-muted-foreground mb-2">{group.note}</p>}
|
||||
{/* Wide on a screen that has the width; stacked where it does
|
||||
not, so a heading stays beside the value it belongs to instead
|
||||
of scrolling away from it. */}
|
||||
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-muted-foreground"><tr>{group.columns.map((column, i) =>
|
||||
<th key={i} className="px-3 py-2 text-left font-medium">{column}</th>)}</tr></thead>
|
||||
<tbody>{group.rows.map((row, i) => <tr key={i} className="border-t border-border">
|
||||
{row.cells.map((cell, j) => <td key={j} className="px-3 py-2 align-top break-words tabular-nums">{cell}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="sm:hidden space-y-2">{group.rows.map((row, i) =>
|
||||
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
|
||||
{row.cells.map((cell, j) => cell === "" || cell === "—" ? null : (
|
||||
<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">{group.columns[j]}</span>
|
||||
<span className="text-sm text-foreground tabular-nums break-words min-w-0">{cell}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>)}</div>
|
||||
</section>)}</div>
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Boxes, CheckCircle2, HardDrive, Loader2, Settings2, SlidersHorizontal } from "lucide-react"
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
/**
|
||||
* Declares what is expected of this host.
|
||||
*
|
||||
* An assessment can see what the host does; it cannot see what it is
|
||||
* for. Everything on this page answers a question the host has no way of
|
||||
* answering itself — does this guest need a backup, must this one come
|
||||
* back by itself, is this storage essential — and each answer is what
|
||||
* turns an observation in the report into a warning, or takes it out of
|
||||
* the count entirely.
|
||||
*
|
||||
* Nothing here is required. A host with no declaration produces a
|
||||
* complete report; it just describes rather than judges.
|
||||
*/
|
||||
|
||||
interface Guest { vmid: number; name: string; type: string }
|
||||
interface Storage { id: string; type: string }
|
||||
interface GuestRule {
|
||||
backup?: string; autostart?: string
|
||||
recovery_objective_hours?: number; note?: string
|
||||
}
|
||||
interface Policy {
|
||||
guests: Record<string, GuestRule>
|
||||
storages: Record<string, { role?: string }>
|
||||
defaults: Record<string, unknown>
|
||||
thresholds: Record<string, number>
|
||||
}
|
||||
interface Vocabulary {
|
||||
expectations: string[]
|
||||
roles: string[]
|
||||
thresholds: Record<string, number>
|
||||
}
|
||||
|
||||
const EMPTY: Policy = { guests: {}, storages: {}, defaults: {}, thresholds: {} }
|
||||
|
||||
function PolicySelect({ value, options, prefix, onChange, inherited, inheritedKey, label, disabled }: {
|
||||
value: string; options: string[]; prefix: string
|
||||
onChange: (value: string) => void; inherited: string; inheritedKey?: string
|
||||
label: (key: string) => string; disabled?: boolean
|
||||
}) {
|
||||
// One component behind every dropdown on this tab, so it is also the
|
||||
// one place that decides they look like the rest of the interface.
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="w-full min-w-0 text-foreground sm:w-[12.5rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">{inherited}</SelectItem>
|
||||
{/* Declaring here what the default already says would be the same
|
||||
entry twice, reading the same. */}
|
||||
{options.filter((option) => option !== inheritedKey).map((option) => (
|
||||
<SelectItem key={option} value={option}>{label(`${prefix}.${option}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditPolicy() {
|
||||
const t = useT()
|
||||
const [policy, setPolicy] = useState<Policy>(EMPTY)
|
||||
const [vocabulary, setVocabulary] = useState<Vocabulary | null>(null)
|
||||
const [guests, setGuests] = useState<Guest[]>([])
|
||||
const [storages, setStorages] = useState<Storage[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [revision, setRevision] = useState<string | null>(null)
|
||||
const [conflict, setConflict] = useState(false)
|
||||
// The declaration is what turns an observation into a warning, so the
|
||||
// form stays locked until the reader says they are changing it.
|
||||
const [editing, setEditing] = useState(false)
|
||||
const locked = !editing || saving || !revision || conflict
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setRevision(null)
|
||||
try {
|
||||
const [current, inventory]: any[] = await Promise.all([
|
||||
fetchApi("/api/audit/policy"),
|
||||
// The declaration is about this host's own guests and storages,
|
||||
// so they are listed rather than typed in by identifier.
|
||||
fetchApi("/api/audit/inventory?profile=inventory"),
|
||||
])
|
||||
if (current?.success) {
|
||||
setPolicy({ ...EMPTY, ...current.policy })
|
||||
setVocabulary(current.vocabulary)
|
||||
setRevision(current.summary.revision)
|
||||
setDirty(false); setSaved(false); setConflict(false)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(current?.message || t("audit.policy.failed"))
|
||||
}
|
||||
const sections = inventory?.inventory?.sections
|
||||
setGuests((sections?.guests || []).map((g: any) => ({
|
||||
vmid: g.vmid, name: g.name, type: g.type,
|
||||
})))
|
||||
setStorages((sections?.storages || []).map((s: any) => ({
|
||||
id: s.id, type: s.type,
|
||||
})))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const setGuestRule = (vmid: number, field: keyof GuestRule, value: unknown) => {
|
||||
setPolicy((prev) => {
|
||||
const guests = { ...prev.guests }
|
||||
const rule: GuestRule = { ...(guests[String(vmid)] || {}) }
|
||||
// Absence inherits; explicit "unspecified" overrides the site default.
|
||||
if (value === "inherit" || value === "" || value === undefined) {
|
||||
delete rule[field]
|
||||
} else {
|
||||
;(rule as Record<string, unknown>)[field] = value
|
||||
}
|
||||
if (Object.keys(rule).length === 0) delete guests[String(vmid)]
|
||||
else guests[String(vmid)] = rule
|
||||
return { ...prev, guests }
|
||||
})
|
||||
setDirty(true); setSaved(false)
|
||||
}
|
||||
|
||||
const setStorageRole = (id: string, role: string) => {
|
||||
setPolicy((prev) => {
|
||||
const storages = { ...prev.storages }
|
||||
if (role === "inherit" || !role) delete storages[id]
|
||||
else storages[id] = { role }
|
||||
return { ...prev, storages }
|
||||
})
|
||||
setDirty(true); setSaved(false)
|
||||
}
|
||||
|
||||
const setThreshold = (name: string, raw: string) => {
|
||||
setPolicy((prev) => {
|
||||
const thresholds = { ...prev.thresholds }
|
||||
const value = Number(raw)
|
||||
if (!raw.trim()) delete thresholds[name]
|
||||
else thresholds[name] = value
|
||||
return { ...prev, thresholds }
|
||||
})
|
||||
setDirty(true); setSaved(false)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!revision || saving || conflict) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res: any = await fetchApi("/api/audit/policy", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...policy, expected_revision: revision }),
|
||||
})
|
||||
if (res?.success) {
|
||||
setRevision(res.summary.revision)
|
||||
setDirty(false); setSaved(true); setError(null); setEditing(false)
|
||||
}
|
||||
else setError(res?.message || t("audit.policy.failed"))
|
||||
} catch (e) {
|
||||
if ((e as { status?: number }).status === 409) {
|
||||
setConflict(true)
|
||||
setError(t("audit.policy.conflict"))
|
||||
} else setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const declared = useMemo(
|
||||
() => Object.keys(policy.guests).length + Object.keys(policy.storages).length
|
||||
+ Object.keys(policy.thresholds).length + Object.keys(policy.defaults).length,
|
||||
[policy],
|
||||
)
|
||||
|
||||
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.policy.loading")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const expectations = vocabulary?.expectations || ["required", "not_required", "unspecified"]
|
||||
const roles = vocabulary?.roles || ["essential", "optional", "unspecified"]
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => { event.preventDefault(); void save() }}>
|
||||
{error && <p className="text-sm text-red-400 px-1">{error}</p>}
|
||||
{(conflict || !revision) && <Button type="button" onClick={() => void load()}>
|
||||
{t("audit.policy.reload")}
|
||||
</Button>}
|
||||
<Card className={editing
|
||||
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: "bg-card border-border"}>
|
||||
<CardContent className="py-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("audit.policy.intro")}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("audit.policy.declaredCount", { count: String(declared) })}
|
||||
</Badge>
|
||||
{saved && <span className="text-sm text-green-500">{t("audit.policy.saved")}</span>}
|
||||
{editing ? (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background
|
||||
hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={() => { setEditing(false); void load() }}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white
|
||||
transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <CheckCircle2 className="h-3 w-3" />}
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto h-7 px-3 text-xs rounded-md border border-border bg-background
|
||||
hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => { setEditing(true); setSaved(false) }}
|
||||
disabled={!revision || conflict}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<fieldset disabled={locked} className="space-y-4 min-w-0">
|
||||
|
||||
|
||||
<Card className={editing
|
||||
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: "bg-card border-border"}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<Boxes className="h-4 w-4" />{t("audit.inventory.guests")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t("audit.policy.guestsNote")}</p>
|
||||
{guests.map((guest) => {
|
||||
const rule = policy.guests[String(guest.vmid)] || {}
|
||||
return (
|
||||
<div key={guest.vmid}
|
||||
className="rounded-md border border-border p-3 space-y-2
|
||||
sm:flex sm:flex-wrap sm:items-center sm:gap-3 sm:space-y-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs shrink-0">
|
||||
{guest.vmid}
|
||||
</Badge>
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{guest.name || "—"}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs uppercase shrink-0">
|
||||
{guest.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.backup")}</span>
|
||||
<PolicySelect label={t} disabled={locked}
|
||||
inherited={policy.defaults.backup
|
||||
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.backup}`) })
|
||||
: t("audit.policy.inheritUnset")}
|
||||
inheritedKey={policy.defaults.backup ? undefined : "unspecified"}
|
||||
value={rule.backup || "inherit"}
|
||||
options={expectations}
|
||||
prefix="audit.policy.expectation"
|
||||
onChange={(v) => setGuestRule(guest.vmid, "backup", v)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.autostart")}</span>
|
||||
<PolicySelect label={t} disabled={locked}
|
||||
inherited={policy.defaults.autostart
|
||||
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.autostart}`) })
|
||||
: t("audit.policy.inheritUnset")}
|
||||
inheritedKey={policy.defaults.autostart ? undefined : "unspecified"}
|
||||
value={rule.autostart || "inherit"}
|
||||
options={expectations}
|
||||
prefix="audit.policy.expectation"
|
||||
onChange={(v) => setGuestRule(guest.vmid, "autostart", v)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.objective")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.000001}
|
||||
step="any"
|
||||
inputMode="numeric"
|
||||
value={rule.recovery_objective_hours ?? ""}
|
||||
placeholder={policy.defaults.recovery_objective_hours
|
||||
? String(policy.defaults.recovery_objective_hours) : t("audit.policy.objectivePlaceholder")}
|
||||
onChange={(e) => setGuestRule(
|
||||
guest.vmid, "recovery_objective_hours",
|
||||
e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full min-w-0 rounded-md border border-border bg-background
|
||||
px-2 py-1.5 text-sm text-foreground focus:outline-none
|
||||
focus:ring-1 focus:ring-ring sm:w-24"
|
||||
/>
|
||||
{rule.recovery_objective_hours == null && policy.defaults.recovery_objective_hours != null && (
|
||||
<span>{t("audit.policy.inherit", { value: `${policy.defaults.recovery_objective_hours} ${t("audit.policy.objectivePlaceholder")}` })}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{guests.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("audit.policy.noGuests")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={editing
|
||||
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: "bg-card border-border"}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<HardDrive className="h-4 w-4" />{t("audit.inventory.storage")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t("audit.policy.storagesNote")}</p>
|
||||
{storages.map((storage) => (
|
||||
<div key={storage.id}
|
||||
className="rounded-md border border-border p-3 space-y-2
|
||||
sm:flex sm:items-center sm:gap-3 sm:space-y-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs shrink-0">
|
||||
{storage.id}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">{storage.type}</span>
|
||||
</div>
|
||||
<PolicySelect label={t} disabled={locked}
|
||||
inherited={policy.defaults.storage_role
|
||||
? t("audit.policy.inherit", { value: t(`audit.policy.role.${policy.defaults.storage_role}`) })
|
||||
: t("audit.policy.inheritUnset")}
|
||||
inheritedKey={policy.defaults.storage_role ? undefined : "unspecified"}
|
||||
value={policy.storages[storage.id]?.role || "inherit"}
|
||||
options={roles}
|
||||
prefix="audit.policy.role"
|
||||
onChange={(v) => setStorageRole(storage.id, v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={editing
|
||||
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||
: "bg-card border-border"}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<SlidersHorizontal className="h-4 w-4" />{t("audit.policy.thresholds")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t("audit.policy.thresholdsNote")}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{Object.entries(vocabulary?.thresholds || {}).map(([name, shipped]) => (
|
||||
<label key={name}
|
||||
className="flex flex-col items-end gap-1.5 rounded-md border
|
||||
border-border p-2.5 sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="w-full min-w-0 text-left text-sm text-foreground sm:flex-1">
|
||||
{t(`audit.policy.threshold.${name}`)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.000001}
|
||||
max={name.endsWith("_percent") ? 100 : undefined}
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={policy.thresholds[name] ?? ""}
|
||||
placeholder={String(shipped)}
|
||||
onChange={(e) => setThreshold(name, e.target.value)}
|
||||
className="w-24 shrink-0 rounded-md border border-border bg-background
|
||||
px-2 py-1.5 text-sm text-foreground tabular-nums
|
||||
focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</fieldset>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -10,22 +10,42 @@ import {
|
||||
} from "./ui/dialog"
|
||||
import {
|
||||
AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck,
|
||||
Loader2, MinusCircle, Play, RotateCcw, ShieldOff, XCircle,
|
||||
FileText, HelpCircle, Info, Loader2, MinusCircle, Play, RotateCcw,
|
||||
ShieldOff, XCircle,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { AuditInventory } from "./audit-inventory"
|
||||
import { AuditPolicy } from "./audit-policy"
|
||||
import { AuditChanges } from "./audit-changes"
|
||||
import { AuditComparison } from "./audit-comparison"
|
||||
import { Label } from "./ui/label"
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "./ui/select"
|
||||
import { unreadSources } from "../lib/audit-presentation"
|
||||
import { openAuditDocument, openReportWindow } from "../lib/audit-document"
|
||||
import { AuditEvidence } from "./audit-evidence"
|
||||
import { AuditFindingData } from "./audit-finding-data"
|
||||
import { affectedDescription, resultBreakdown } from "../lib/audit-presentation"
|
||||
import { useI18n } from "../lib/i18n/provider"
|
||||
|
||||
interface Finding {
|
||||
check_id: string
|
||||
area: string
|
||||
severity: string
|
||||
state: string
|
||||
classification: string
|
||||
decision?: string
|
||||
summary_key: string | null
|
||||
summary_params: Record<string, string | number>
|
||||
affected: Array<Record<string, unknown>>
|
||||
evidence: string | null
|
||||
remediable_by: string | null
|
||||
exception?: { reason: string; accepted_by: string; accepted_at: number } | null
|
||||
incomplete?: boolean
|
||||
collected_at?: number
|
||||
check_version?: number
|
||||
sources?: Array<{ source: string; collected_at: number; error?: string }>
|
||||
exception?: { reason: string; accepted_by: string; accepted_at: number; expires_at?: number | null } | null
|
||||
}
|
||||
|
||||
interface Run {
|
||||
@@ -35,46 +55,80 @@ interface Run {
|
||||
finished_at: number | null
|
||||
status: string
|
||||
checks_total: number
|
||||
checks_expected: number
|
||||
error?: string | null
|
||||
is_baseline?: number | boolean
|
||||
// Recorded by the engine: the sources it read and the declaration it
|
||||
// judged against. The document states the latter in its scope.
|
||||
metadata?: { policy?: {
|
||||
declared?: boolean; guests_declared?: number
|
||||
storages_declared?: number; thresholds_declared?: string[]
|
||||
} } | null
|
||||
}
|
||||
|
||||
// Findings are ordered by how much they demand attention, not by area.
|
||||
// Someone triaging wants the worst thing first regardless of where it
|
||||
// lives; grouping by area is the reading order of the printed document.
|
||||
const STATE_RANK: Record<string, number> = {
|
||||
fail: 0, warn: 1, accepted: 2, pass: 3, not_applicable: 4,
|
||||
//
|
||||
// One scale, worst first. There is no second ordering by severity any
|
||||
// more: gravity is the classification, so a finding cannot be a critical
|
||||
// observation or an informational failure.
|
||||
const CLASS_RANK: Record<string, number> = {
|
||||
critical: 0, warning: 1, observation: 2, unverified: 3,
|
||||
accepted: 4, conformant: 5, not_applicable: 6,
|
||||
}
|
||||
|
||||
const STATE_STYLE: Record<string, { chip: string; Icon: typeof XCircle }> = {
|
||||
fail: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle },
|
||||
warn: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle },
|
||||
accepted: { chip: "bg-muted text-muted-foreground border-border", Icon: ShieldOff },
|
||||
pass: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: CheckCircle2 },
|
||||
// Only the first two are problems. An observation is drawn in a neutral
|
||||
// tone on purpose: colouring planning information like a fault is what
|
||||
// made ordinary configurations read as defects.
|
||||
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof XCircle }> = {
|
||||
critical: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle },
|
||||
warning: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle },
|
||||
observation: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Info },
|
||||
unverified: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle },
|
||||
accepted: { chip: "bg-indigo-500/10 text-indigo-400 border-indigo-400/20", Icon: ShieldOff },
|
||||
conformant: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: CheckCircle2 },
|
||||
not_applicable: { chip: "bg-muted text-muted-foreground border-border", Icon: MinusCircle },
|
||||
}
|
||||
|
||||
/** What a finding reads as once the reader's decision is applied. */
|
||||
function shownAs(f: { classification: string; decision?: string }): string {
|
||||
return f.decision === "accepted" ? "accepted" : f.classification
|
||||
}
|
||||
|
||||
// An assessment older than this stops describing the current system, so
|
||||
// the age is surfaced before any count rather than as a footnote.
|
||||
const STALE_AFTER_DAYS = 30
|
||||
const SUMMARY_BADGE_CLASS = "h-6 gap-1.5 whitespace-nowrap px-2.5 py-0 text-xs"
|
||||
|
||||
export function AuditReport() {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
// Assessment and inventory answer different questions and are
|
||||
// read differently: one is triaged, the other is read through.
|
||||
const [view, setView] = useState<"assessment" | "inventory" | "changes" | "policy">("assessment")
|
||||
const [running, setRunning] = useState(false)
|
||||
const [latest, setLatest] = useState<Run | null>(null)
|
||||
const [findings, setFindings] = useState<Finding[]>([])
|
||||
const [summary, setSummary] = useState<Record<string, number>>({})
|
||||
const [areaFilter, setAreaFilter] = useState<string>("all")
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [showResolved, setShowResolved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accepting, setAccepting] = useState<Finding | null>(null)
|
||||
const [reason, setReason] = useState("")
|
||||
const [expiryDays, setExpiryDays] = useState<string>("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [progress, setProgress] = useState({ completed: 0, total: 0 })
|
||||
// The profile decides which question the page answers, so it governs
|
||||
// both what an assessment runs and what the inventory documents.
|
||||
const [profile, setProfile] = useState("full")
|
||||
const [profiles, setProfiles] = useState<Array<{ id: string; runs_checks: boolean }>>([])
|
||||
const [building, setBuilding] = useState(false)
|
||||
|
||||
const loadRun = useCallback(async (runId: string) => {
|
||||
try {
|
||||
const data: any = await fetchApi(`/api/audit/runs/${runId}`)
|
||||
const data: any = await fetchApi(`/api/audit/runs/${runId}?effective=1`)
|
||||
if (data?.success) setFindings(data.findings || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
@@ -86,6 +140,7 @@ export function AuditReport() {
|
||||
const data: any = await fetchApi("/api/audit/status")
|
||||
if (!data?.success) return
|
||||
setRunning(Boolean(data.running))
|
||||
setProgress({ completed: data.progress?.completed || 0, total: data.progress?.total || 0 })
|
||||
setSummary(data.summary || {})
|
||||
setLatest(data.latest || null)
|
||||
if (data.latest?.run_id) await loadRun(data.latest.run_id)
|
||||
@@ -97,8 +152,29 @@ export function AuditReport() {
|
||||
}
|
||||
}, [loadRun])
|
||||
|
||||
useEffect(() => {
|
||||
fetchApi("/api/audit/profiles")
|
||||
.then((d: any) => { if (d?.success) setProfiles(d.profiles || []) })
|
||||
.catch(() => { /* the page works on the default profile */ })
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
// Expiry changes a decision, not the assessment. One local timer and
|
||||
// a focus refresh keep it current without periodic scans or idle polling.
|
||||
useEffect(() => {
|
||||
const expiry = findings.flatMap((f) => f.exception?.expires_at ? [f.exception.expires_at] : [])
|
||||
if (!expiry.length) return
|
||||
const delay = Math.max(100, Math.min(2147483647, Math.min(...expiry) * 1000 - Date.now() + 100))
|
||||
const id = setTimeout(refresh, delay)
|
||||
return () => clearTimeout(id)
|
||||
}, [findings, refresh])
|
||||
useEffect(() => {
|
||||
const onFocus = () => { void refresh() }
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
}, [refresh])
|
||||
|
||||
// While an assessment is in flight the page polls; once it settles the
|
||||
// interval is dropped so an idle tab does not keep waking the backend.
|
||||
useEffect(() => {
|
||||
@@ -112,7 +188,7 @@ export function AuditReport() {
|
||||
try {
|
||||
const data: any = await fetchApi("/api/audit/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ profile: "full" }),
|
||||
body: JSON.stringify({ profile }),
|
||||
})
|
||||
if (data?.success) setRunning(true)
|
||||
else setError(data?.message || t("audit.errors.runFailed"))
|
||||
@@ -130,6 +206,7 @@ export function AuditReport() {
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
check_id: accepting.check_id,
|
||||
run_id: latest?.run_id,
|
||||
reason: reason.trim(),
|
||||
}
|
||||
if (expiryDays) body.expires_in_days = Number(expiryDays)
|
||||
@@ -163,17 +240,20 @@ export function AuditReport() {
|
||||
[findings],
|
||||
)
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const quiet = new Set(["pass", "not_applicable"])
|
||||
return findings
|
||||
// Every check is listed, worst first. Hiding what passed made the
|
||||
// reader guess whether a check was clean or had not run, which is
|
||||
// exactly the distinction this page exists to keep.
|
||||
const visible = useMemo(() =>
|
||||
findings
|
||||
.filter((f) => areaFilter === "all" || f.area === areaFilter)
|
||||
.filter((f) => showResolved || !quiet.has(f.state))
|
||||
.sort((a, b) =>
|
||||
(STATE_RANK[a.state] ?? 9) - (STATE_RANK[b.state] ?? 9) ||
|
||||
a.check_id.localeCompare(b.check_id))
|
||||
}, [findings, areaFilter, showResolved])
|
||||
(CLASS_RANK[shownAs(a)] ?? 9) - (CLASS_RANK[shownAs(b)] ?? 9) ||
|
||||
a.check_id.localeCompare(b.check_id)),
|
||||
[findings, areaFilter])
|
||||
|
||||
const acceptedCount = summary.accepted || 0
|
||||
const unverifiedChecks = findings.filter(
|
||||
(f) => f.classification === "unverified" || f.incomplete)
|
||||
const ageDays = latest?.finished_at
|
||||
? Math.floor((Date.now() / 1000 - latest.finished_at) / 86400)
|
||||
: null
|
||||
@@ -184,6 +264,7 @@ export function AuditReport() {
|
||||
// correctly under another. A check that failed to evaluate has no
|
||||
// per-check entry, hence the shared fallback.
|
||||
const summaryOf = (f: Finding) => {
|
||||
if (f.check_id === "backup.last_backup_age" && f.affected.length) return resultBreakdown(f, t)
|
||||
if (!f.summary_key) return ""
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(f.summary_params || {}).map(([k, v]) => [k, String(v)]),
|
||||
@@ -193,6 +274,11 @@ export function AuditReport() {
|
||||
return text === key ? t("audit.summaryFallback") : text
|
||||
}
|
||||
|
||||
const notApplicableText = (f: Finding) => {
|
||||
if (f.summary_key) return ""
|
||||
return f.classification === "not_applicable" ? t("audit.notApplicableScope") : ""
|
||||
}
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -210,15 +296,179 @@ export function AuditReport() {
|
||||
)
|
||||
}
|
||||
|
||||
// The document carries both halves, so the inventory is fetched at the
|
||||
// moment it is produced rather than kept in memory for a button that
|
||||
// may never be pressed.
|
||||
const generateDocument = async () => {
|
||||
// The window is opened on the click itself, before the inventory is
|
||||
// fetched, so the popup blocker sees the user gesture. It shows a
|
||||
// spinner while the document is composed.
|
||||
const target = openReportWindow(t("audit.document.building"))
|
||||
setBuilding(true)
|
||||
try {
|
||||
const inv: any = await fetchApi(
|
||||
`/api/audit/inventory?profile=${encodeURIComponent(profile)}`)
|
||||
openAuditDocument({
|
||||
profile,
|
||||
run: latest,
|
||||
findings,
|
||||
inventory: inv?.success ? inv.inventory : null,
|
||||
t,
|
||||
locale: language,
|
||||
}, target)
|
||||
} catch (e) {
|
||||
target?.close()
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBuilding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const documentButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={generateDocument}
|
||||
disabled={building}
|
||||
// Related to running an assessment, and secondary to it: the same
|
||||
// hue as that button, at the translucent weight the chips use.
|
||||
// Green is taken — here it means a conformant result, and this
|
||||
// report may be full of critical ones.
|
||||
className="shrink-0 border-blue-500/20 bg-blue-500/10 text-blue-500
|
||||
hover:bg-blue-500/20 hover:text-blue-500"
|
||||
>
|
||||
{building
|
||||
? <Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
: <FileText className="h-4 w-4 mr-2" />}
|
||||
{/* The icon carries the meaning where the width is short. */}
|
||||
<span className="sm:hidden">{t("audit.document.actionShort")}</span>
|
||||
<span className="hidden sm:inline">{t("audit.document.action")}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
const profilePicker = profiles.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm sm:w-auto sm:flex-none">
|
||||
<Label htmlFor="audit-profile" className="hidden shrink-0 text-muted-foreground sm:inline">
|
||||
{t("audit.profile.label")}
|
||||
</Label>
|
||||
<Select value={profile} onValueChange={setProfile}>
|
||||
<SelectTrigger id="audit-profile" className="min-w-0 flex-1 sm:w-56 sm:flex-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{t(`audit.profile.${p.id}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const viewTabs = (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("audit.viewSwitch.ariaLabel")}
|
||||
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||
sm:inline-flex sm:w-auto"
|
||||
>
|
||||
{(["assessment", "inventory", "changes", "policy"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={view === key}
|
||||
onClick={() => setView(key)}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
|
||||
view === key
|
||||
? "bg-blue-500 text-white shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t(`audit.viewSwitch.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Changes and policy carry no profile: one is what was done to this
|
||||
// host, the other what is expected of it, and neither narrows by report.
|
||||
if (view === "changes") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||
sm:items-center sm:gap-3">
|
||||
{viewTabs}
|
||||
</div>
|
||||
</div>
|
||||
<AuditChanges />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (view === "policy") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||
sm:items-center sm:gap-3">
|
||||
{viewTabs}
|
||||
</div>
|
||||
</div>
|
||||
<AuditPolicy />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (view === "inventory") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* A row that cannot wrap has nowhere to put the controls but
|
||||
beside the title, which then squeezes into two lines. Title
|
||||
and controls are separate rows until there is width for both. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||
sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2 sm:contents">
|
||||
{profilePicker}{documentButton}
|
||||
</div>
|
||||
{viewTabs}
|
||||
</div>
|
||||
</div>
|
||||
<AuditInventory profile={profile} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||
sm:items-center sm:gap-3">
|
||||
<div className="flex items-center gap-2 sm:contents">
|
||||
{profilePicker}{documentButton}
|
||||
</div>
|
||||
{viewTabs}
|
||||
</div>
|
||||
</div>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-xl lg:text-2xl font-bold text-foreground">
|
||||
<ClipboardCheck className="h-6 w-6" />
|
||||
{t("audit.title")}
|
||||
</CardTitle>
|
||||
{/* Stated before any count: an assessment nobody has run, or
|
||||
one run months ago, does not describe this host today. */}
|
||||
{!latest ? (
|
||||
@@ -233,6 +483,29 @@ export function AuditReport() {
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t("audit.readOnlyNotice")}</p>
|
||||
{running && <p role="status" className="text-sm text-muted-foreground">
|
||||
{t("audit.progress", { completed: String(progress.completed), total: String(progress.total) })}
|
||||
</p>}
|
||||
{/* A reading that could not be taken is information, not an
|
||||
alarm: it says the report is narrower than usual, and
|
||||
colouring it like a finding puts it above warnings the
|
||||
reader has to act on. A run that failed outright is the
|
||||
one thing here that does interrupt. */}
|
||||
{latest && (latest.status === "partial" || latest.status === "failed") && (
|
||||
<div
|
||||
{...(latest.status === "failed" ? { role: "alert" as const } : {})}
|
||||
className={`space-y-1 text-sm ${
|
||||
latest.status === "failed" ? "text-amber-500" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<p>{t(`audit.runStates.${latest.status}`)}</p>
|
||||
{unverifiedChecks.length > 0 && <p className="text-xs">
|
||||
{t("audit.unverifiedChecks", { checks: unverifiedChecks
|
||||
.map((f) => t(`audit.checks.${f.check_id}.title`)).join(" · ") })}
|
||||
</p>}
|
||||
</div>
|
||||
)}
|
||||
{latest?.error && <p className="text-xs text-amber-500">{latest.error}</p>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -248,21 +521,34 @@ export function AuditReport() {
|
||||
|
||||
{latest && (
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["fail", "warn", "accepted", "pass", "not_applicable"] as const)
|
||||
.filter((s) => summary[s])
|
||||
.map((s) => {
|
||||
const { chip, Icon } = STATE_STYLE[s]
|
||||
{/* One row of counters on one scale. Gravity is the
|
||||
classification itself, so there is nothing left to
|
||||
reconcile between two sets of numbers. */}
|
||||
<div role="group" aria-label={t("audit.results")}
|
||||
className="flex max-w-full flex-wrap items-center gap-2">
|
||||
{(["critical", "warning", "observation", "unverified",
|
||||
"accepted", "conformant", "not_applicable"] as const)
|
||||
.filter((c) => summary[c])
|
||||
.map((c) => {
|
||||
const { chip, Icon } = CLASS_STYLE[c]
|
||||
return (
|
||||
<Badge key={s} variant="outline" className={`${chip} gap-1.5`}>
|
||||
<Badge key={c} variant="outline" className={`${SUMMARY_BADGE_CLASS} ${chip}`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`audit.states.${s}`)}
|
||||
<span className="tabular-nums font-semibold">{summary[s]}</span>
|
||||
{t(`audit.classifications.${c}`)}
|
||||
<span className="tabular-nums font-semibold">{summary[c]}</span>
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* What changed since a reference run: context for the
|
||||
assessment being read, not a place of its own. */}
|
||||
<AuditComparison
|
||||
runId={latest.run_id}
|
||||
isBaseline={Boolean(latest.is_baseline)}
|
||||
onBaselineSet={refresh}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -289,13 +575,6 @@ export function AuditReport() {
|
||||
{t(`audit.areas.${a}`)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResolved((v) => !v)}
|
||||
className="ml-auto text-sm text-muted-foreground hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{showResolved ? t("audit.hidePassing") : t("audit.showPassing")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The count of accepted risks stays visible even when the
|
||||
@@ -324,9 +603,10 @@ export function AuditReport() {
|
||||
|
||||
<div className="space-y-2">
|
||||
{visible.map((f) => {
|
||||
const { chip, Icon } = STATE_STYLE[f.state] || STATE_STYLE.not_applicable
|
||||
const shown = shownAs(f)
|
||||
const { chip, Icon } = CLASS_STYLE[shown] || CLASS_STYLE.not_applicable
|
||||
const open = expanded.has(f.check_id)
|
||||
const muted = f.state === "accepted" || f.state === "not_applicable"
|
||||
const muted = shown === "accepted" || shown === "not_applicable"
|
||||
return (
|
||||
<Card
|
||||
key={f.check_id}
|
||||
@@ -336,14 +616,14 @@ export function AuditReport() {
|
||||
type="button"
|
||||
onClick={() => toggle(f.check_id)}
|
||||
aria-expanded={open}
|
||||
className="w-full text-left p-4 flex items-start gap-3 hover:bg-background/40 transition-colors rounded-lg"
|
||||
className="w-full text-left p-4 flex items-start gap-3 rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
{open
|
||||
? <ChevronDown className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />}
|
||||
<Badge variant="outline" className={`${chip} gap-1.5 shrink-0`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`audit.states.${f.state}`)}
|
||||
{t(`audit.classifications.${shown}`)}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -353,15 +633,28 @@ export function AuditReport() {
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t(`audit.areas.${f.area}`)}
|
||||
</Badge>
|
||||
{f.incomplete && <Badge variant="outline" className="text-xs text-amber-500">
|
||||
{t("audit.incomplete")}
|
||||
</Badge>}
|
||||
{f.affected.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs tabular-nums">
|
||||
{t("audit.affectedCount", { count: String(f.affected.length) })}
|
||||
{affectedDescription(f, t)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{f.summary_key && (
|
||||
|
||||
{(f.summary_key || notApplicableText(f)) && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{summaryOf(f)}
|
||||
{f.summary_key ? summaryOf(f) : notApplicableText(f)}
|
||||
</p>
|
||||
)}
|
||||
{/* "Could not be evaluated" describes the assessment, not
|
||||
the host. What could not be read is recorded against
|
||||
each source, and belongs here rather than two
|
||||
collapsed panels below. */}
|
||||
{unreadSources(f.sources, t) && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{unreadSources(f.sources, t)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -387,41 +680,42 @@ export function AuditReport() {
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{f.exception.accepted_by} ·{" "}
|
||||
{new Date(f.exception.accepted_at * 1000).toLocaleDateString()}
|
||||
{f.exception.expires_at && <> · {t("audit.expires", {
|
||||
when: new Date(f.exception.expires_at * 1000).toLocaleString(),
|
||||
})}</>}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{f.affected.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.detail.affected")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{f.affected.map((o, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs font-mono">
|
||||
{Object.values(o).filter(Boolean).join(" · ")}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Some checks carry a useful, structured positive reading
|
||||
in their evidence even when nothing is affected. The
|
||||
presenter returns no groups for checks without such a
|
||||
view, so rendering it unconditionally does not create
|
||||
empty space. */}
|
||||
<AuditFindingData finding={f} t={t} locale={language} />
|
||||
|
||||
{f.evidence && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t("audit.detail.evidence")}
|
||||
</p>
|
||||
{/* Wide command output scrolls inside its own box so
|
||||
the page itself never scrolls sideways. */}
|
||||
<pre className="text-xs font-mono bg-background border border-border rounded-md p-3 overflow-x-auto whitespace-pre">
|
||||
{f.evidence}
|
||||
</pre>
|
||||
</div>
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground mb-2">
|
||||
{t("audit.presentation.technical")}
|
||||
</summary>
|
||||
<AuditEvidence evidence={f.evidence} locale={language} />
|
||||
</details>
|
||||
)}
|
||||
{f.sources && f.sources.length > 0 && <details className="text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer">{t("audit.detail.sources")}</summary>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{f.sources.map((source) => <li key={source.source} className="break-all">
|
||||
{source.source} · {new Date(source.collected_at * 1000).toLocaleString()}
|
||||
{source.error && <span className="text-amber-500"> · {source.error}</span>}
|
||||
</li>)}
|
||||
</ul>
|
||||
</details>}
|
||||
|
||||
{/* Only an active finding can be accepted, and only an
|
||||
accepted one can be returned to the active set. */}
|
||||
{(f.state === "fail" || f.state === "warn") && (
|
||||
{(f.classification === "critical" || f.classification === "warning")
|
||||
&& !f.decision && !f.incomplete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -431,7 +725,7 @@ export function AuditReport() {
|
||||
{t("audit.acceptRisk.action")}
|
||||
</Button>
|
||||
)}
|
||||
{f.state === "accepted" && (
|
||||
{f.decision === "accepted" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -485,17 +779,16 @@ export function AuditReport() {
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||
{t("audit.acceptRisk.expiryHelp")}
|
||||
</p>
|
||||
<select
|
||||
id="audit-expiry"
|
||||
value={expiryDays}
|
||||
onChange={(e) => setExpiryDays(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-background p-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("audit.acceptRisk.expiryNever")}</option>
|
||||
<option value="90">{t("audit.acceptRisk.expiry90")}</option>
|
||||
<option value="180">{t("audit.acceptRisk.expiry180")}</option>
|
||||
<option value="365">{t("audit.acceptRisk.expiry365")}</option>
|
||||
</select>
|
||||
<Select value={expiryDays || "none"}
|
||||
onValueChange={(v) => setExpiryDays(v === "none" ? "" : v)}>
|
||||
<SelectTrigger id="audit-expiry" className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t("audit.acceptRisk.expiryNever")}</SelectItem>
|
||||
<SelectItem value="90">{t("audit.acceptRisk.expiry90")}</SelectItem>
|
||||
<SelectItem value="180">{t("audit.acceptRisk.expiry180")}</SelectItem>
|
||||
<SelectItem value="365">{t("audit.acceptRisk.expiry365")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1205,27 +1205,42 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
web_path: link.web_path || "/",
|
||||
logo_url: link.logo_url || "",
|
||||
}
|
||||
const last = draft.ports[draft.ports.length - 1]
|
||||
const indexAfterAdd = (last && last.port === "" && !last.description)
|
||||
? draft.ports.length - 1
|
||||
: draft.ports.length
|
||||
if (indexAfterAdd === draft.ports.length) {
|
||||
setField({ ports: [...draft.ports, entry] })
|
||||
} else {
|
||||
const ports = [...draft.ports]
|
||||
ports[indexAfterAdd] = entry
|
||||
setField({ ports })
|
||||
}
|
||||
setDetectorTest(null)
|
||||
setEditing((current) => {
|
||||
if (!current) return current
|
||||
const currentPorts = current.draft.ports
|
||||
// The buttons disappear after a port is added, but guard the state
|
||||
// update too so a double click can never create duplicate links.
|
||||
if (currentPorts.some((port) => port.port === link.host_port)) return current
|
||||
const ports = [...currentPorts]
|
||||
const last = ports[ports.length - 1]
|
||||
if (last && last.port === "" && !last.description) ports[ports.length - 1] = entry
|
||||
else ports.push(entry)
|
||||
return { ...current, draft: { ...current.draft, ports } }
|
||||
})
|
||||
// Ask the backend whether this service_name has a known catalog
|
||||
// category and, if so, patch the just-inserted port so the user
|
||||
// finds it pre-selected instead of having to open the dropdown.
|
||||
// Non-blocking — the port is already visible either way.
|
||||
// This must be a functional update: the response can arrive after the
|
||||
// user has added or edited more links, and must never restore the old
|
||||
// draft captured by this render.
|
||||
const q = (link.service_name || "").trim()
|
||||
if (q) {
|
||||
fetchApi<{ category: string | null }>(`/api/apps/suggest_category?name=${encodeURIComponent(q)}`)
|
||||
.then((r) => {
|
||||
if (!r?.category) return
|
||||
setPort(indexAfterAdd, { category: r.category })
|
||||
setEditing((current) => {
|
||||
if (!current) return current
|
||||
let changed = false
|
||||
const ports = current.draft.ports.map((port) => {
|
||||
if (port.port !== link.host_port || port.category) return port
|
||||
changed = true
|
||||
return { ...port, category: r.category || undefined }
|
||||
})
|
||||
return changed
|
||||
? { ...current, draft: { ...current.draft, ports } }
|
||||
: current
|
||||
})
|
||||
})
|
||||
.catch(() => { /* non-fatal — user can pick manually */ })
|
||||
}
|
||||
@@ -1235,15 +1250,32 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
const usedPorts = new Set(draft.ports.map((p) => p.port))
|
||||
const isDockerDraft = draft.helper_slug === "docker" ||
|
||||
(draft.installed_via === "binary" && draft.binary_path?.endsWith("/docker"))
|
||||
// A recognised Docker workload belongs in one place at a time. Keep its
|
||||
// links out of Docker while it is offered (or already registered) as an
|
||||
// independent app. Dismissing that detection makes the links available
|
||||
// under Docker again.
|
||||
const independentDockerWorkloadSlugs = new Set(
|
||||
(suggestions?.docker_workloads || [])
|
||||
.filter((workload) => !dismissedSlugs.has(workload.slug))
|
||||
.map((workload) => workload.slug),
|
||||
)
|
||||
const suggestableDockerLinks = isDockerDraft
|
||||
? (suggestions?.docker_web_links || []).filter((link) => !usedPorts.has(link.host_port))
|
||||
? (suggestions?.docker_web_links || []).filter(
|
||||
(link) =>
|
||||
!usedPorts.has(link.host_port) &&
|
||||
(!link.service_slug || !independentDockerWorkloadSlugs.has(link.service_slug)),
|
||||
)
|
||||
: []
|
||||
// A Docker registration uses structured container → published-port
|
||||
// suggestions below. Suppress the generic ss/netstat chips in that case
|
||||
// so the same endpoint is not presented twice without its workload name.
|
||||
const suggestable = isDockerDraft
|
||||
? []
|
||||
: (suggestions?.port_suggestions || []).filter((p) => !usedPorts.has(p))
|
||||
// Keep the generic ss/netstat probe available for Docker too. It covers
|
||||
// host-networked services and listeners that Docker does not expose in
|
||||
// NetworkSettings.Ports. Published ports already represented by a named
|
||||
// Docker workload stay deduplicated from the generic chips.
|
||||
const dockerPublishedPorts = new Set(
|
||||
(suggestions?.docker_web_links || []).map((link) => link.host_port),
|
||||
)
|
||||
const suggestable = (suggestions?.port_suggestions || []).filter(
|
||||
(port) => !usedPorts.has(port) && (!isDockerDraft || !dockerPublishedPorts.has(port)),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -778,6 +778,7 @@ export function ProxmoxDashboard() {
|
||||
<React.Fragment key="admin">
|
||||
{btn("logs", ScrollText, t("navigation.systemLogs"))}
|
||||
{btn("security", ShieldCheck, t("navigation.security"))}
|
||||
{btn("audit", ClipboardCheck, t("navigation.audit"))}
|
||||
{btn("settings", SettingsIcon, t("navigation.settings"))}
|
||||
{btn("about", Info, t("navigation.about"))}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -81,6 +81,12 @@ export function Security() {
|
||||
if (normalized.includes("invalid 2fa code")) {
|
||||
return st("errors.invalid2faCode")
|
||||
}
|
||||
if (normalized.includes("2fa code required")) {
|
||||
return st("errors.enter2faOrBackup")
|
||||
}
|
||||
if (normalized.includes("current password is incorrect")) {
|
||||
return st("errors.invalidPassword")
|
||||
}
|
||||
if (normalized.includes("invalid password")) {
|
||||
return st("errors.invalidPassword")
|
||||
}
|
||||
@@ -103,6 +109,7 @@ export function Security() {
|
||||
const [currentPassword, setCurrentPassword] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState("")
|
||||
const [changePasswordTotpCode, setChangePasswordTotpCode] = useState("")
|
||||
|
||||
const [show2FASetup, setShow2FASetup] = useState(false)
|
||||
const [show2FADisable, setShow2FADisable] = useState(false)
|
||||
@@ -976,6 +983,11 @@ export function Security() {
|
||||
return
|
||||
}
|
||||
|
||||
if (totpEnabled && !changePasswordTotpCode.trim()) {
|
||||
setError(st("errors.enter2faOrBackup"))
|
||||
return
|
||||
}
|
||||
|
||||
const pwError = validatePasswordStrength(newPassword, t)
|
||||
if (pwError) {
|
||||
setError(pwError)
|
||||
@@ -992,8 +1004,9 @@ export function Security() {
|
||||
Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
old_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
...(totpEnabled ? { totp_code: changePasswordTotpCode.trim() } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1012,6 +1025,7 @@ export function Security() {
|
||||
setCurrentPassword("")
|
||||
setNewPassword("")
|
||||
setConfirmNewPassword("")
|
||||
setChangePasswordTotpCode("")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : st("errors.changePasswordFailed"))
|
||||
} finally {
|
||||
@@ -2012,6 +2026,22 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totpEnabled && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="change-password-totp">{st("twoFactor.codeOrBackup")}</Label>
|
||||
<Input
|
||||
id="change-password-totp"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
placeholder={st("twoFactor.codeOrBackupPlaceholder")}
|
||||
value={changePasswordTotpCode}
|
||||
onChange={(e) => setChangePasswordTotpCode(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleChangePassword}
|
||||
@@ -2021,7 +2051,13 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
{loading ? st("auth.changing") : st("auth.changePassword")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowChangePassword(false)}
|
||||
onClick={() => {
|
||||
setShowChangePassword(false)
|
||||
setCurrentPassword("")
|
||||
setNewPassword("")
|
||||
setConfirmNewPassword("")
|
||||
setChangePasswordTotpCode("")
|
||||
}}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, ExternalLink, Search, Tag as TagIcon } from 'lucide-react'
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Tag as TagIcon } from 'lucide-react'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Switch } from "./ui/switch"
|
||||
@@ -281,17 +281,6 @@ function hasLxcPendingUpdates(vm: VMData): boolean {
|
||||
return osUpdates + appUpdates + dockerUpdates + delegatedUpdates > 0
|
||||
}
|
||||
|
||||
function buildRegisteredAppUrl(vm: VMData, port?: LxcAppPort): string | null {
|
||||
const custom = (port?.custom_url || "").trim()
|
||||
if (custom) return custom
|
||||
const rawIp = (vm.ip || "").trim().split("/")[0]
|
||||
if (!rawIp || rawIp === "DHCP" || !port?.port) return null
|
||||
const host = rawIp.includes(":") && !rawIp.startsWith("[") ? `[${rawIp}]` : rawIp
|
||||
const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http")
|
||||
const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : ""
|
||||
return `${scheme}://${host}:${port.port}${path}`
|
||||
}
|
||||
|
||||
interface VMConfig {
|
||||
cores?: number
|
||||
memory?: number
|
||||
@@ -5210,26 +5199,31 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const helperKnownNotUpdateable = !helperExists && !!uc?.helper_slug && uc?.helper_slug_source === "update_wrapper" && !!uc?.helper_updateable_known
|
||||
const helperUnlisted = !helperExists && !!uc?.helper_slug && uc?.helper_slug_source === "update_wrapper" && !uc?.helper_updateable_known
|
||||
// Registration, version tracking and update execution
|
||||
// are independent capabilities. Every saved app belongs
|
||||
// in Updates; installed_via only controls whether a
|
||||
// version state can be shown.
|
||||
// are independent capabilities. Docker-delegated apps
|
||||
// already have their complete lifecycle represented by
|
||||
// the image inventory, so rendering an app section for
|
||||
// them would duplicate the same update state.
|
||||
const registeredApps = (selectedVM.app_watches || []).filter(
|
||||
(a) => !a.managed_oci_app_id,
|
||||
)
|
||||
const independentlyUpdatedApps = registeredApps.filter(
|
||||
(a) => a.update_via !== "docker",
|
||||
)
|
||||
const helperSectionDetected = uc?.helper_slug !== "docker"
|
||||
&& (helperExists || helperKnownNotUpdateable || helperUnlisted || helperInferred)
|
||||
const helperMatchingApps = registeredApps.filter(
|
||||
const helperMatchingApps = independentlyUpdatedApps.filter(
|
||||
(a) => !!a.helper_slug && a.helper_slug === uc?.helper_slug,
|
||||
)
|
||||
const helperOnlyApps = helperMatchingApps.filter(
|
||||
(a) => !a.update_command,
|
||||
)
|
||||
// Every registered app gets exactly one Updates
|
||||
// section. Docker and the CT-wide helper identity use
|
||||
// their specialised sections; all other registrations
|
||||
// use the generic section even when installed_via is
|
||||
// empty (Web Link only) or dpkg/apk is OS-managed.
|
||||
const appSections = registeredApps.filter((a) => {
|
||||
// Every independently updated app gets exactly one
|
||||
// Updates section. Docker and the CT-wide helper
|
||||
// identity use their specialised sections; all other
|
||||
// registrations use the generic section even when
|
||||
// installed_via is empty (Web Link only) or dpkg/apk
|
||||
// is OS-managed.
|
||||
const appSections = independentlyUpdatedApps.filter((a) => {
|
||||
// Docker owns a dedicated section containing
|
||||
// Engine and image lifecycles. Its command editor
|
||||
// is rendered there so Docker never appears twice.
|
||||
@@ -5469,18 +5463,18 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
return (
|
||||
<div className="divide-y divide-border/50">
|
||||
{uc!.packages.map((p) => (
|
||||
<div key={p.name} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-0.5 sm:gap-2 py-2 text-sm">
|
||||
<span className="font-mono text-foreground/90 flex items-center gap-2 min-w-0">
|
||||
<div key={p.name} className="py-2 text-sm min-w-0">
|
||||
<span className="font-mono text-foreground/90 flex items-start gap-2 min-w-0">
|
||||
{p.security && (
|
||||
<Shield className="h-4 w-4 text-green-500 flex-shrink-0" aria-label={t("vmLxc.updates.securityUpdateAria")} />
|
||||
<Shield className="h-4 w-4 mt-0.5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.updates.securityUpdateAria")} />
|
||||
)}
|
||||
<span className="truncate">{p.name}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground flex-shrink-0 font-mono text-xs sm:text-sm">
|
||||
<span>{p.current || "—"}</span>
|
||||
<span>→</span>
|
||||
<span className="text-foreground">{p.latest}</span>
|
||||
<span className="break-all" title={p.name}>{p.name}</span>
|
||||
</span>
|
||||
<div className={`${p.security ? "pl-6" : ""} mt-1.5 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2 font-mono text-xs sm:text-sm`}>
|
||||
<span className="text-muted-foreground break-all">{p.current || "—"}</span>
|
||||
<span className="text-muted-foreground" aria-hidden="true">→</span>
|
||||
<span className="text-foreground break-all">{p.latest}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -5739,14 +5733,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Package className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
{image.installed_version ? (
|
||||
<span>
|
||||
<span className={image.update_available === false ? "text-green-500" : undefined}>
|
||||
{t("vmLxc.updates.installedLabel")} {" "}
|
||||
<code className="text-foreground/80">{image.installed_version}</code>
|
||||
<code className={image.update_available === false ? "text-green-500" : "text-foreground/80"}>{image.installed_version}</code>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className={image.update_available === false ? "text-green-500" : undefined}>
|
||||
{t("vmLxc.updates.imageInstalledTag")} {" "}
|
||||
<code className="text-foreground/80">{image.tag}</code>
|
||||
<code className={image.update_available === false ? "text-green-500" : "text-foreground/80"}>{image.tag}</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -5848,9 +5842,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const matchApp = helperOnlyApps[0] || null
|
||||
if (!matchApp || customCmdEditingApp === matchApp.id) return null
|
||||
const helperSelected = matchApp.update_method === "helper"
|
||||
const appWebUrl = helperUsesWebUpdater
|
||||
? buildRegisteredAppUrl(selectedVM, matchApp.ports?.[0])
|
||||
: null
|
||||
const helperTracksVersion = !!matchApp.installed_via
|
||||
const hasUpd = helperTracksVersion && matchApp.update_available === true
|
||||
const upToD = helperTracksVersion && matchApp.update_available === false && !!matchApp.installed_version
|
||||
@@ -5959,20 +5950,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)
|
||||
})()}
|
||||
{helperUsesWebUpdater && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("vmLxc.updates.adguardWebUpdateOnly")}
|
||||
</p>
|
||||
{appWebUrl && (
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={appWebUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4 mr-1.5" />
|
||||
{t("vmLxc.updates.openAdguard")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!helperExists && !helperUsesWebUpdater && (
|
||||
|
||||
Reference in New Issue
Block a user