mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user