"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, Loader2, MinusCircle, Play, RotateCcw, ShieldOff, XCircle, } from "lucide-react" import { fetchApi } from "../lib/api-config" import { useT } from "../lib/i18n/provider" interface Finding { check_id: string area: string severity: string state: string summary_key: string | null summary_params: Record affected: Array> evidence: string | null remediable_by: string | null exception?: { reason: string; accepted_by: string; accepted_at: number } | null } interface Run { run_id: string profile: string started_at: number finished_at: number | null status: string checks_total: number } // 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 = { fail: 0, warn: 1, accepted: 2, pass: 3, not_applicable: 4, } const STATE_STYLE: Record = { 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 }, not_applicable: { chip: "bg-muted text-muted-foreground border-border", Icon: MinusCircle }, } // 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 export function AuditReport() { const t = useT() 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 [showResolved, setShowResolved] = useState(false) 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 loadRun = useCallback(async (runId: string) => { try { const data: any = await fetchApi(`/api/audit/runs/${runId}`) 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)) 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(() => { refresh() }, [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: "full" }), }) 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, 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], ) const visible = useMemo(() => { const quiet = new Set(["pass", "not_applicable"]) return 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]) const acceptedCount = summary.accepted || 0 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.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 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")}
) } return (
{t("audit.title")} {/* 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")}

{latest && (
{(["fail", "warn", "accepted", "pass", "not_applicable"] as const) .filter((s) => summary[s]) .map((s) => { const { chip, Icon } = STATE_STYLE[s] return ( {t(`audit.states.${s}`)} {summary[s]} ) })}
{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 { chip, Icon } = STATE_STYLE[f.state] || STATE_STYLE.not_applicable const open = expanded.has(f.check_id) const muted = f.state === "accepted" || f.state === "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.affected.length > 0 && (

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

{f.affected.map((o, i) => ( {Object.values(o).filter(Boolean).join(" · ")} ))}
)} {f.evidence && (

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

{/* Wide command output scrolls inside its own box so the page itself never scrolls sideways. */}
                        {f.evidence}
                      
)} {/* 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.state === "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")}