"use client" import { useCallback, useEffect, useState } from "react" import { Badge } from "./ui/badge" import { Button } from "./ui/button" import { ArrowDownRight, ArrowUpRight, 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. * * The same care applies to a finding that is still reported. One that * was already failing has not appeared now, so it is shown as having got * worse or better with where it came from, rather than as new — reading * "new" against work that lowered a critical to a warning would punish * exactly the reader who fixed something. * * 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 // Only the findings that moved carry where they came from. previous_classification?: string previous_affected?: number affected_count?: number } interface Comparison { from: string to: string new: Finding[] worse: Finding[] better: Finding[] resolved: Finding[] accepted: Finding[] unchanged: Finding[] retired: Finding[] unverified: Finding[] } /** What moved, in the reader's terms: the gravity when that is what * changed, otherwise how many objects the finding now covers. */ function movement(f: Finding, t: (k: string) => string): string | null { if (!f.previous_classification) return null if (f.previous_classification !== f.classification) { return `${t(`audit.classifications.${f.previous_classification}`)} → ${t( `audit.classifications.${f.classification}`, )}` } if (f.previous_affected === undefined || f.affected_count === undefined) return null return `${f.previous_affected} → ${f.affected_count}` } const GROUPS = [ { key: "new", Icon: PlusCircle, tone: "text-amber-500" }, { key: "worse", Icon: ArrowUpRight, tone: "text-red-400" }, { key: "better", Icon: ArrowDownRight, tone: "text-emerald-400" }, { 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(null) const [baseline, setBaseline] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [open, setOpen] = useState(false) const [error, setError] = useState(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 (

{t("audit.comparison.loading")}

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

{error}

} {!comparable ? (

{baseline ? t("audit.comparison.isBaseline") : t("audit.comparison.noBaseline")}

) : ( <> {open && (
{GROUPS.filter((g) => (comparison[g.key] || []).length > 0).map( ({ key, Icon, tone }) => (

{t(`audit.comparison.${key}`)} — {t(`audit.comparison.${key}Note`)}

{(comparison[key] || []).map((f) => { const moved = movement(f, t) return ( {t(`audit.checks.${f.check_id}.title`)} {moved && ( {moved} )} ) })}
), )} {(comparison.unchanged || []).length > 0 && (

{t("audit.comparison.unchanged", { count: String(comparison.unchanged.length), })}

)}
)} )} {/* Choosing a reference is what makes every later run comparable, so the action lives beside the comparison it enables. */} {!isBaseline && ( )}
) }