"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 { 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" | "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>([]) // Set when a Lynis-bearing assessment is about to run and the stored // report is missing or stale: the user decides whether to run Lynis now. const [lynisPrompt, setLynisPrompt] = useState(null) 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 doRun = async (runLynis: boolean) => { setLynisPrompt(null) setError(null) try { const data: any = await fetchApi("/api/audit/run", { method: "POST", body: JSON.stringify({ profile, run_lynis: runLynis }), }) if (data?.success) setRunning(true) else setError(data?.message || t("audit.errors.runFailed")) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } } // A profile that includes the Lynis check asks the user before running, // since producing a fresh Lynis report takes a few minutes. When a // recent report already exists — or Lynis is not installed — the run // starts straight away and reuses it. const startRun = async () => { setError(null) const spec = profiles.find((p) => p.id === profile) const runsLynis = !!spec && (spec.areas === null || spec.areas.includes("security")) if (runsLynis) { try { const r: any = await fetchApi("/api/audit/lynis-readiness") if (r?.success && r.installed && (!r.has_report || r.stale)) { setLynisPrompt({ ageDays: r.age_days ?? null, stale: !!r.stale }) return } } catch { /* Readiness is advisory; on failure run without Lynis rather than block. */ } } doRun(false) } // 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)}`) // A focused report shows only the checks its profile runs, even // when the findings on screen came from a full assessment: the // report is scoped to its question, not to whichever run produced // the data. `areas: null` (full, diagnostic) keeps everything. const spec = profiles.find((p) => p.id === profile) const scopedFindings = !spec || spec.areas === null ? findings : findings.filter((f) => spec.areas!.includes(f.area) || spec.include.includes(f.check_id)) openAuditDocument({ profile, run: latest, findings: scopedFindings, 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", "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}
) } 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 && setLynisPrompt(null)}> {t("audit.lynis.title")} {lynisPrompt?.stale ? t("audit.lynis.bodyStale", { days: String(lynisPrompt?.ageDays ?? "") }) : t("audit.lynis.bodyNotRun")} !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")}