feat: acknowledge Compose Doctor preflight findings per stack (#1560)

* feat: acknowledge Compose Doctor preflight findings per stack

Add node-scoped preflight acknowledgements with read-time filtering.

Supports four expiry modes and activeStatus for banner, tab dot, and readiness.

* fix: align preflight acknowledge UI with design system

Use Combobox, modal chrome, mono fields, and non-destructive clear confirm.

* fix: update test mocks to match new preflight field names

The preflight-acknowledgements feature renamed status-\>activeStatus and
highestSeverity-\>activeHighestSeverity in the preflight report shape. The
corresponding test mocks in three files still used the old field names,
causing 6 test failures across backend and frontend.

- backend: update-guard-service mock now passes activeStatus
- frontend PreflightPanel: Report interface and report() helper now include
  activeStatus, activeHighestSeverity, activeCount, acknowledgedCount
- frontend StackAnatomyPanel doctor: mock API response now includes
  activeHighestSeverity and activeStatus
This commit is contained in:
Anso
2026-07-05 04:12:03 -04:00
committed by GitHub
parent 122c1b8073
commit 4077546492
16 changed files with 1002 additions and 54 deletions
@@ -25,7 +25,7 @@ beforeEach(() => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/preflight')) {
return jsonRes({ stack: 'web', ranAt: 1, ranBy: 'x', renderable: true, renderError: null, status: 'high', highestSeverity: badgeSeverity, findings: [] });
return jsonRes({ stack: 'web', ranAt: 1, ranBy: 'x', renderable: true, renderError: null, status: 'high', highestSeverity: badgeSeverity, activeStatus: badgeSeverity === 'warning' ? 'warning' : 'high', activeHighestSeverity: badgeSeverity, activeCount: 0, acknowledgedCount: 0, findings: [] });
}
return jsonRes(null, false); // git-source, update-preview, scan-status
});
@@ -140,8 +140,9 @@ export default function StackAnatomyPanel({
if (cancelled || !res.ok) return;
const data = await res.json();
if (!cancelled) {
setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null);
setPreflightFindings(Array.isArray(data?.findings) ? data.findings : undefined);
setPreflightSeverity(typeof data?.activeHighestSeverity === 'string' ? data.activeHighestSeverity : null);
const findings = Array.isArray(data?.findings) ? data.findings : undefined;
setPreflightFindings(findings?.filter((f: { acknowledged?: boolean }) => !f.acknowledged));
}
} catch {
if (!cancelled) { setPreflightSeverity(null); setPreflightFindings(undefined); }
@@ -635,7 +636,7 @@ export default function StackAnatomyPanel({
)}
{doctorEnabled && (
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
<PreflightPanel stackName={stackName} />
<PreflightPanel stackName={stackName} canEdit={canEdit} />
</TabsContent>
)}
{storageEnabled && (
@@ -22,6 +22,7 @@ interface Finding {
sourcePath?: string;
remediation?: string;
service?: string;
acknowledged?: boolean;
}
interface Report {
stack: string;
@@ -31,11 +32,30 @@ interface Report {
renderError: string | null;
status: string;
highestSeverity: string | null;
activeStatus: string;
activeHighestSeverity: string | null;
activeCount: number;
acknowledgedCount: number;
findings: Finding[];
}
function report(partial: Partial<Report>): Report {
return { stack: 'web', ranAt: 1000, ranBy: 'admin', renderable: true, renderError: null, status: 'pass', highestSeverity: null, findings: [], ...partial };
const base: Report = {
stack: 'web', ranAt: 1000, ranBy: 'admin',
renderable: true, renderError: null,
status: 'pass', highestSeverity: null,
activeStatus: 'pass', activeHighestSeverity: null,
activeCount: 0, acknowledgedCount: 0,
findings: [],
};
const merged = { ...base, ...partial };
// Derive activeStatus/activeHighestSeverity/activeCount from the old
// fields when the caller only set those (so existing tests work without
// every call site listing the new field names).
if (partial.status !== undefined && partial.activeStatus === undefined) merged.activeStatus = merged.status;
if (partial.highestSeverity !== undefined && partial.activeHighestSeverity === undefined) merged.activeHighestSeverity = merged.highestSeverity;
if (partial.findings !== undefined && partial.activeCount === undefined) merged.activeCount = merged.findings.filter(f => !f.acknowledged).length;
return merged;
}
function jsonRes(body: unknown, ok = true) {
+317 -25
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, X, type LucideIcon,
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, X,
ChevronDown, ChevronRight, ShieldCheck, type LucideIcon,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -8,10 +9,14 @@ import { toast } from '@/components/ui/toast-store';
import { formatTimeAgo } from '@/lib/relativeTime';
import { useNodes } from '@/context/NodeContext';
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
// Mirrors the backend payload shape (the frontend never imports backend).
type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
type PreflightStatus = 'never-run' | 'pass' | 'unrenderable' | PreflightSeverity;
type PreflightAckExpiryMode = 'forever' | 'until_compose_change' | 'days' | 'until_image_change';
interface PreflightFinding {
ruleId: string;
@@ -21,6 +26,10 @@ interface PreflightFinding {
sourcePath?: string;
remediation?: string;
service?: string;
acknowledged?: boolean;
acknowledgementId?: number;
acknowledgementReason?: string;
acknowledgementExpiry?: PreflightAckExpiryMode;
}
interface PreflightReport {
@@ -31,10 +40,15 @@ interface PreflightReport {
renderError: string | null;
status: PreflightStatus;
highestSeverity: PreflightSeverity | null;
activeStatus: PreflightStatus;
activeHighestSeverity: PreflightSeverity | null;
activeCount: number;
acknowledgedCount: number;
findings: PreflightFinding[];
}
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
const MODAL_FIELD_LABEL = LABEL_CLASS;
const ACTION_CLASS =
'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40';
const CARD_CLASS = 'rounded-lg border px-3 py-2.5';
@@ -48,7 +62,13 @@ const SEVERITY_META: Record<PreflightSeverity, { label: string; icon: LucideIcon
const GROUP_ORDER: PreflightSeverity[] = ['blocker', 'high', 'warning', 'info'];
/** The header summary card: a single read on the overall result. */
const EXPIRY_LABELS: Record<PreflightAckExpiryMode, string> = {
forever: 'Forever',
until_compose_change: 'Until Compose changes',
days: '30 days',
until_image_change: 'Until image changes',
};
function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon; tone: string; line: string } {
if (!report.renderable) {
return {
@@ -58,26 +78,61 @@ function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon
line: report.renderError ?? 'Sencho could not render the effective Compose model.',
};
}
if (report.findings.length === 0) {
if (report.activeCount === 0 && report.acknowledgedCount === 0) {
return { label: 'all clear', icon: Check, tone: 'border-success/40 bg-success/[0.06] text-success', line: 'No issues found in the effective model.' };
}
const meta = SEVERITY_META[report.highestSeverity ?? 'info'];
const counts = GROUP_ORDER
.map(sev => ({ sev, n: report.findings.filter(f => f.severity === sev).length }))
const meta = SEVERITY_META[report.activeHighestSeverity ?? 'info'];
const activeParts = GROUP_ORDER
.map(sev => ({ sev, n: report.findings.filter(f => !f.acknowledged && f.severity === sev).length }))
.filter(c => c.n > 0)
.map(c => `${c.n} ${SEVERITY_META[c.sev].label}`)
.join(' · ');
return { label: meta.label, icon: meta.icon, tone: meta.tone, line: counts };
const line = report.acknowledgedCount > 0
? `${report.activeCount} active${activeParts ? ` (${activeParts})` : ''} · ${report.acknowledgedCount} acknowledged`
: (activeParts || `${report.activeCount} active`);
return { label: report.activeCount === 0 ? 'acknowledged' : meta.label, icon: report.activeCount === 0 ? ShieldCheck : meta.icon, tone: report.activeCount === 0 ? 'border-muted bg-card/40 text-stat-subtitle' : meta.tone, line };
}
function FindingRow({ finding }: { finding: PreflightFinding }) {
function expiryComboboxOptions(finding: PreflightFinding): ComboboxOption[] {
const options: ComboboxOption[] = [
{ value: 'forever', label: EXPIRY_LABELS.forever },
{ value: 'until_compose_change', label: EXPIRY_LABELS.until_compose_change },
{ value: 'days', label: EXPIRY_LABELS.days },
];
if (finding.service) {
options.push({ value: 'until_image_change', label: EXPIRY_LABELS.until_image_change });
}
return options;
}
function FindingRow({
finding,
canEdit,
onAcknowledge,
}: {
finding: PreflightFinding;
canEdit: boolean;
onAcknowledge?: (finding: PreflightFinding) => void;
}) {
return (
<div className="border-t border-muted py-2 first:border-t-0">
<div className="flex flex-wrap items-center gap-2">
{finding.service && (
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{finding.service && (
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
)}
<span className="text-[12px] font-medium text-foreground/90">{finding.title}</span>
</div>
{canEdit && onAcknowledge && (
<button
type="button"
data-testid={`preflight-ack-btn-${finding.ruleId}-${finding.service ?? 'stack'}`}
onClick={() => onAcknowledge(finding)}
className="shrink-0 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand"
>
acknowledge
</button>
)}
<span className="text-[12px] font-medium text-foreground/90">{finding.title}</span>
</div>
<div className="mt-1 text-[12px] leading-relaxed text-foreground/80">{finding.message}</div>
{finding.remediation && (
@@ -92,7 +147,50 @@ function FindingRow({ finding }: { finding: PreflightFinding }) {
);
}
export default function PreflightPanel({ stackName }: { stackName: string }) {
function AcknowledgedRow({
finding,
canEdit,
onClear,
}: {
finding: PreflightFinding;
canEdit: boolean;
onClear: (finding: PreflightFinding) => void;
}) {
return (
<div className="border-t border-muted py-2 first:border-t-0 opacity-80">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{finding.service && (
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-stat-subtitle">{finding.service}</span>
)}
<span className="text-[12px] font-medium text-foreground/80">{finding.title}</span>
</div>
{finding.acknowledgementReason && (
<div className="mt-1 text-[11px] text-stat-subtitle">{finding.acknowledgementReason}</div>
)}
{finding.acknowledgementExpiry && (
<div className="mt-0.5 font-mono text-[10px] text-stat-subtitle">
expires: {EXPIRY_LABELS[finding.acknowledgementExpiry]}
</div>
)}
</div>
{canEdit && finding.acknowledgementId != null && (
<button
type="button"
data-testid={`preflight-clear-ack-${finding.acknowledgementId}`}
onClick={() => onClear(finding)}
className="shrink-0 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand"
>
clear
</button>
)}
</div>
</div>
);
}
export default function PreflightPanel({ stackName, canEdit = false }: { stackName: string; canEdit?: boolean }) {
const { activeNode } = useNodes();
const nodeId = activeNode?.id;
const [report, setReport] = useState<PreflightReport | null>(null);
@@ -100,9 +198,29 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
const [loadError, setLoadError] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [running, setRunning] = useState(false);
const [ackOpen, setAckOpen] = useState(false);
const [ackTarget, setAckTarget] = useState<PreflightFinding | null>(null);
const [ackReason, setAckReason] = useState('');
const [ackExpiry, setAckExpiry] = useState<PreflightAckExpiryMode>('forever');
const [ackSaving, setAckSaving] = useState(false);
const [clearTarget, setClearTarget] = useState<PreflightFinding | null>(null);
const [clearing, setClearing] = useState(false);
const [ackSectionOpen, setAckSectionOpen] = useState(false);
const refreshReport = async () => {
try {
const res = await apiFetch(`/stacks/${stackName}/preflight`);
if (!res.ok) {
toast.error('Failed to refresh the preflight report.');
return;
}
setReport((await res.json()) as PreflightReport);
setLoadError(false);
} catch {
toast.error('Failed to refresh the preflight report.');
}
};
// Passive load of the last stored run when the stack or active node changes.
// Read-only: opening the tab never renders or stores anything.
useEffect(() => {
let cancelled = false;
const run = async () => {
@@ -131,7 +249,6 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
return () => { cancelled = true; };
}, [stackName, nodeId, reloadKey]);
// Running preflight renders the effective model and stores the result.
const runPreflight = async () => {
setRunning(true);
try {
@@ -149,13 +266,83 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
}
};
const activeFindings = useMemo(
() => report?.findings.filter(f => !f.acknowledged) ?? [],
[report?.findings],
);
const acknowledgedFindings = useMemo(
() => report?.findings.filter(f => f.acknowledged) ?? [],
[report?.findings],
);
const summary = report && report.status !== 'never-run' ? summaryMeta(report) : null;
const SummaryIcon = summary?.icon;
const busy = loading || running;
// Dismiss the result banner (and the Doctor tab dot) until the findings change.
const { dismissed, dismiss } = usePreflightDismiss(stackName, nodeId, report?.findings);
const hasFindings = (report?.findings.length ?? 0) > 0;
const { dismissed, dismiss } = usePreflightDismiss(stackName, nodeId, activeFindings);
const hasActiveFindings = activeFindings.length > 0;
const openAckDialog = (finding: PreflightFinding) => {
setAckTarget(finding);
setAckReason('');
setAckExpiry('forever');
setAckOpen(true);
};
const submitAck = async () => {
if (!ackTarget) return;
setAckSaving(true);
try {
const res = await apiFetch(`/stacks/${stackName}/preflight/acknowledgements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ruleId: ackTarget.ruleId,
service: ackTarget.service ?? null,
reason: ackReason.trim(),
expiryMode: ackExpiry,
expiresInDays: ackExpiry === 'days' ? 30 : undefined,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { error?: string };
toast.error(data.error ?? 'Failed to acknowledge the finding.');
return;
}
setAckOpen(false);
setAckTarget(null);
await refreshReport();
toast.success('Finding acknowledged.');
} catch {
toast.error('Failed to acknowledge the finding.');
} finally {
setAckSaving(false);
}
};
const confirmClear = async () => {
if (!clearTarget?.acknowledgementId) return;
setClearing(true);
try {
const res = await apiFetch(
`/stacks/${stackName}/preflight/acknowledgements/${clearTarget.acknowledgementId}`,
{ method: 'DELETE' },
);
if (!res.ok && res.status !== 204) {
toast.error('Failed to clear the acknowledgement.');
return;
}
setClearTarget(null);
await refreshReport();
toast.success('Acknowledgement cleared.');
} catch {
toast.error('Failed to clear the acknowledgement.');
} finally {
setClearing(false);
}
};
const ackExpiryOptions = ackTarget ? expiryComboboxOptions(ackTarget) : [{ value: 'forever', label: EXPIRY_LABELS.forever }];
return (
<div data-testid="preflight-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
@@ -198,8 +385,8 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
) : (
<>
{summary && SummaryIcon && !dismissed && (
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone, 'relative')}>
{hasFindings && (
<div data-testid="preflight-status" data-status={report.activeStatus} className={cn(CARD_CLASS, summary.tone, 'relative')}>
{hasActiveFindings && (
<button
type="button"
onClick={dismiss}
@@ -225,19 +412,124 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
)}
{GROUP_ORDER.map(sev => {
const items = report.findings.filter(f => f.severity === sev);
const items = activeFindings.filter(f => f.severity === sev);
if (items.length === 0) return null;
return (
<section key={sev}>
<div className={cn(LABEL_CLASS, 'mb-1.5')}>{SEVERITY_META[sev].label} · {items.length}</div>
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
{items.map((f, i) => <FindingRow key={`${f.ruleId}-${f.service ?? ''}-${i}`} finding={f} />)}
{items.map((f, i) => (
<FindingRow
key={`${f.ruleId}-${f.service ?? ''}-${i}`}
finding={f}
canEdit={canEdit}
onAcknowledge={openAckDialog}
/>
))}
</div>
</section>
);
})}
{acknowledgedFindings.length > 0 && (
<section data-testid="preflight-acknowledged-section">
<button
type="button"
onClick={() => setAckSectionOpen(v => !v)}
className={cn(LABEL_CLASS, 'mb-1.5 inline-flex items-center gap-1 hover:text-brand')}
>
{ackSectionOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
acknowledged · {acknowledgedFindings.length}
</button>
{ackSectionOpen && (
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
{acknowledgedFindings.map((f, i) => (
<AcknowledgedRow
key={`ack-${f.acknowledgementId ?? i}`}
finding={f}
canEdit={canEdit}
onClear={setClearTarget}
/>
))}
</div>
)}
</section>
)}
</>
)}
<Modal open={ackOpen} onOpenChange={setAckOpen} size="md">
<ModalHeader
kicker="COMPOSE DOCTOR · ACKNOWLEDGE"
title="Accept this finding"
description="Accept a Compose Doctor finding for this stack."
/>
<ModalBody>
{ackTarget && (
<div className="rounded-md border border-card-border bg-card/40 px-3 py-2 font-mono text-[12px] text-foreground/80">
{ackTarget.title}
{ackTarget.service ? ` · ${ackTarget.service}` : ''}
</div>
)}
<div className="space-y-2">
<Label htmlFor="preflight-ack-reason" className={MODAL_FIELD_LABEL}>Note (optional)</Label>
<textarea
id="preflight-ack-reason"
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50"
placeholder="Why is this finding acceptable for this stack?"
value={ackReason}
onChange={(e) => setAckReason(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="preflight-ack-expiry" className={MODAL_FIELD_LABEL}>Show again when</Label>
<Combobox
id="preflight-ack-expiry"
options={ackExpiryOptions}
value={ackExpiry}
onValueChange={(value) => setAckExpiry(value as PreflightAckExpiryMode)}
placeholder="Select expiry"
disabled={ackSaving}
className="w-full"
/>
{ackExpiry === 'until_image_change' && (
<p className="text-[11px] leading-relaxed text-stat-subtitle">
Re-surfaces when the service image reference changes in the effective model, not on silent digest re-pulls.
</p>
)}
</div>
</ModalBody>
<ModalFooter
hint="SHOW AGAIN"
hintAccent={EXPIRY_LABELS[ackExpiry]}
secondary={(
<Button variant="outline" size="sm" onClick={() => setAckOpen(false)} disabled={ackSaving}>
Cancel
</Button>
)}
primary={(
<Button size="sm" onClick={submitAck} disabled={ackSaving}>
{ackSaving ? 'Saving…' : 'Acknowledge'}
</Button>
)}
/>
</Modal>
<ConfirmModal
open={clearTarget !== null}
onOpenChange={(open) => { if (!open) setClearTarget(null); }}
kicker="COMPOSE DOCTOR · CLEAR"
title="Clear acknowledgement"
description="Clear a Compose Doctor acknowledgement for this stack."
hint="RESTORES active finding"
confirmLabel="Clear"
confirming={clearing}
onConfirm={confirmClear}
>
<p className="text-sm text-stat-subtitle">
This finding will count as active again on the next preflight read.
</p>
</ConfirmModal>
</div>
);
}