"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Button } from "./ui/button" import { Badge } from "./ui/badge" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "./ui/dialog" import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck, 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 classification: string decision?: string summary_key: string | null summary_params: Record affected: Array> evidence: string | null remediable_by: string | 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 { run_id: string profile: string started_at: number 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. // // 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 = { critical: 0, warning: 1, observation: 2, unverified: 3, accepted: 4, conformant: 5, not_applicable: 6, } // 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 = { 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(null) const [findings, setFindings] = useState([]) const [summary, setSummary] = useState>({}) const [areaFilter, setAreaFilter] = useState("all") const [expanded, setExpanded] = useState>(new Set()) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) const [accepting, setAccepting] = useState(null) const [reason, setReason] = useState("") const [expiryDays, setExpiryDays] = useState("") 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>([]) const [building, setBuilding] = useState(false) const loadRun = useCallback(async (runId: string) => { try { 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)) } }, []) const refresh = useCallback(async () => { try { 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) setError(null) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } }, [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(() => { if (!running) return const id = setInterval(refresh, 2000) return () => clearInterval(id) }, [running, refresh]) const startRun = async () => { setError(null) try { const data: any = await fetchApi("/api/audit/run", { method: "POST", body: JSON.stringify({ profile }), }) if (data?.success) setRunning(true) else setError(data?.message || t("audit.errors.runFailed")) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } } // Accepting or revoking changes which findings are active, so the run // is re-read afterwards rather than patched in place: the stored // finding is what the next report will show. const submitAcceptance = async () => { if (!accepting || !reason.trim()) return setSaving(true) try { const body: Record = { check_id: accepting.check_id, run_id: latest?.run_id, reason: reason.trim(), } if (expiryDays) body.expires_in_days = Number(expiryDays) const data: any = await fetchApi("/api/audit/exceptions", { method: "POST", body: JSON.stringify(body), }) if (!data?.success) throw new Error(data?.message || "") setAccepting(null) setReason("") setExpiryDays("") await refresh() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSaving(false) } } const revokeAcceptance = async (checkId: string) => { try { await fetchApi(`/api/audit/exceptions/${checkId}`, { method: "DELETE" }) await refresh() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } } const areas = useMemo( () => Array.from(new Set(findings.map((f) => f.area))).sort(), [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) .sort((a, b) => (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 const stale = ageDays !== null && ageDays >= STALE_AFTER_DAYS // The backend stores which sentence applies and its numbers, not the // sentence itself, so a finding recorded under one language still reads // 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)]), ) const key = `audit.checks.${f.check_id}.summary.${f.summary_key}` const text = t(key, params) 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) next.has(id) ? next.delete(id) : next.add(id) return next }) } if (loading) { return (
{t("audit.loading")}
) } // 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 = ( ) const profilePicker = profiles.length > 0 ? (
) : null const viewTabs = (
{(["assessment", "inventory", "changes", "policy"] as const).map((key) => ( ))}
) // 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 (

{t("audit.title")}

{viewTabs}
) } if (view === "policy") { return (

{t("audit.title")}

{viewTabs}
) } if (view === "inventory") { return (
{/* 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. */}

{t("audit.title")}

{profilePicker}{documentButton}
{viewTabs}
) } return (

{t("audit.title")}

{profilePicker}{documentButton}
{viewTabs}
{/* Stated before any count: an assessment nobody has run, or one run months ago, does not describe this host today. */} {!latest ? (

{t("audit.neverRun")}

) : (

{t("audit.lastRun", { when: new Date((latest.finished_at || latest.started_at) * 1000) .toLocaleString(), })} {stale && ` — ${t("audit.stale", { days: String(ageDays) })}`}

)}

{t("audit.readOnlyNotice")}

{running &&

{t("audit.progress", { completed: String(progress.completed), total: String(progress.total) })}

} {/* 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") && (

{t(`audit.runStates.${latest.status}`)}

{unverifiedChecks.length > 0 &&

{t("audit.unverifiedChecks", { checks: unverifiedChecks .map((f) => t(`audit.checks.${f.check_id}.title`)).join(" · ") })}

}
)} {latest?.error &&

{latest.error}

}
{latest && ( {/* One row of counters on one scale. Gravity is the classification itself, so there is nothing left to reconcile between two sets of numbers. */}
{(["critical", "warning", "observation", "unverified", "accepted", "conformant", "not_applicable"] as const) .filter((c) => summary[c]) .map((c) => { const { chip, Icon } = CLASS_STYLE[c] return ( {t(`audit.classifications.${c}`)} {summary[c]} ) })}
{/* What changed since a reference run: context for the assessment being read, not a place of its own. */}
{areas.map((a) => ( ))}
{/* The count of accepted risks stays visible even when the findings themselves are filtered out of the list, so a decision to live with something is never silently lost. */} {acceptedCount > 0 && (

{t("audit.acceptedNotice", { count: String(acceptedCount) })}

)}
)}
{error && (

{error}

)} {latest && visible.length === 0 && ( {t("audit.noFindings")} )}
{visible.map((f) => { const shown = shownAs(f) const { chip, Icon } = CLASS_STYLE[shown] || CLASS_STYLE.not_applicable const open = expanded.has(f.check_id) const muted = shown === "accepted" || shown === "not_applicable" return ( {open && (

{t("audit.detail.why")}

{t(`audit.checks.${f.check_id}.rationale`)}

{f.exception && (

{t("audit.detail.acceptedRisk")}

{f.exception.reason}

{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(), })}}

)} {/* 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. */} {f.evidence && (
{t("audit.presentation.technical")}
)} {f.sources && f.sources.length > 0 &&
{t("audit.detail.sources")}
    {f.sources.map((source) =>
  • {source.source} · {new Date(source.collected_at * 1000).toLocaleString()} {source.error && · {source.error}}
  • )}
} {/* Only an active finding can be accepted, and only an accepted one can be returned to the active set. */} {(f.classification === "critical" || f.classification === "warning") && !f.decision && !f.incomplete && ( )} {f.decision === "accepted" && ( )}
)}
) })}
!o && setAccepting(null)}> {t("audit.acceptRisk.title")} {accepting && t(`audit.checks.${accepting.check_id}.title`)}
{/* The reason is required, not encouraged. An acceptance without one cannot be told apart later from having silenced the check. */}

{t("audit.acceptRisk.reasonHelp")}