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
@@ -282,11 +282,45 @@ describe('getLatest', () => {
});
});
describe('preflight acknowledgements', () => {
const STACK = 'doctorack';
beforeEach(() => { writeStack(STACK); });
afterEach(() => { fs.rmSync(path.join(process.env.COMPOSE_DIR as string, STACK), { recursive: true, force: true }); });
it('lowers activeStatus when a finding is acknowledged', async () => {
stubDocker(
{ name: STACK, services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} },
);
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
expect(report.status).toBe('high');
const target = report.findings.find(f => f.ruleId === 'port-exposed-all-interfaces');
expect(target).toBeTruthy();
db().upsertPreflightAcknowledgement({
node_id: nodeId,
stack_name: STACK,
rule_id: target!.ruleId,
service: target!.service ?? null,
reason: 'intentional',
expiry_mode: 'forever',
expires_at: null,
anchor_rendered_hash: null,
anchor_image_ref: null,
created_by: 'tester',
created_at: Date.now(),
});
const latest = doctor().getLatest(nodeId, STACK);
expect(latest.acknowledgedCount).toBe(1);
expect(latest.activeCount).toBe(latest.findings.length - 1);
expect(latest.status).toBe('high');
expect(latest.activeStatus).not.toBe('high');
});
});
describe('node deletion cleanup', () => {
it('removes preflight runs and findings for a deleted node', () => {
const ghostNode = 987654;
db().replacePreflightRun(
{ id: 'run-x', node_id: ghostNode, stack_name: 's', source_hash: null, rendered_hash: null, status: 'pass', highest_severity: null, created_at: 1, created_by: null },
{ id: 'run-x', node_id: ghostNode, stack_name: 's', source_hash: null, rendered_hash: null, service_images: null, status: 'pass', highest_severity: null, created_at: 1, created_by: null },
[{ id: 'find-x', run_id: 'run-x', rule_id: 'privileged', severity: 'high', title: 't', message: 'm', source_path: null, remediation: null, service: 's', created_at: 1 }],
);
expect(db().getLatestPreflightRun(ghostNode, 's')).toBeDefined();
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import type { PreflightAcknowledgement } from '../services/DatabaseService';
import { applyPreflightAcknowledgements, isPreflightAckActive } from '../utils/preflight-ack-filter';
import type { PreflightFinding } from '../services/preflight/types';
const baseFinding = (over: Partial<PreflightFinding> = {}): PreflightFinding => ({
ruleId: 'uid-gid-risk',
severity: 'warning',
title: 'Check UID/GID alignment',
message: 'test',
service: 'web',
...over,
});
const baseAck = (over: Partial<PreflightAcknowledgement> = {}): PreflightAcknowledgement => ({
id: 1,
node_id: 1,
stack_name: 'demo',
rule_id: 'uid-gid-risk',
service: 'web',
reason: 'verified ownership',
expiry_mode: 'forever',
expires_at: null,
anchor_rendered_hash: null,
anchor_image_ref: null,
created_by: 'admin',
created_at: Date.now(),
...over,
});
describe('applyPreflightAcknowledgements', () => {
const ctx = { renderedHash: 'hash-a', serviceImages: { web: 'nginx:1.2' } };
it('marks a matching service-scoped ack as acknowledged', () => {
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [baseAck()], Date.now());
expect(out[0].acknowledged).toBe(true);
expect(out[0].acknowledgementId).toBe(1);
});
it('prefers a service-scoped ack over a rule-wide ack', () => {
const ruleWide = baseAck({ id: 2, service: null });
const serviceScoped = baseAck({ id: 3, service: 'web' });
const out = applyPreflightAcknowledgements(
[baseFinding()],
ctx,
[ruleWide, serviceScoped],
Date.now(),
);
expect(out[0].acknowledgementId).toBe(3);
});
it('does not acknowledge when until_compose_change hash differs', () => {
const ack = baseAck({ expiry_mode: 'until_compose_change', anchor_rendered_hash: 'old-hash' });
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
expect(out[0].acknowledged).toBe(false);
});
it('acknowledges when until_compose_change hash matches', () => {
const ack = baseAck({ expiry_mode: 'until_compose_change', anchor_rendered_hash: 'hash-a' });
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
expect(out[0].acknowledged).toBe(true);
});
it('expires days mode after expires_at', () => {
const now = 1_000_000;
const ack = baseAck({ expiry_mode: 'days', expires_at: now - 1 });
expect(isPreflightAckActive(ack, ctx, now)).toBe(false);
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], now);
expect(out[0].acknowledged).toBe(false);
});
it('honors until_image_change while image ref matches', () => {
const ack = baseAck({
expiry_mode: 'until_image_change',
anchor_image_ref: 'nginx:1.2',
});
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
expect(out[0].acknowledged).toBe(true);
});
it('re-surfaces until_image_change when image ref changes', () => {
const ack = baseAck({
expiry_mode: 'until_image_change',
anchor_image_ref: 'nginx:1.0',
});
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
expect(out[0].acknowledged).toBe(false);
});
});
@@ -80,3 +80,64 @@ describe('preflight routes', () => {
expect(res.status).toBe(404);
});
});
describe('preflight acknowledgement routes', () => {
let stackDir: string;
beforeEach(() => {
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n');
stub();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(stackDir, { recursive: true, force: true });
});
it('POST acknowledges a finding and GET preflight reflects activeStatus', async () => {
const run = await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
expect(run.status).toBe(200);
const target = run.body.findings.find((f: { ruleId: string }) => f.ruleId === 'port-exposed-all-interfaces');
expect(target).toBeTruthy();
const ack = await request(app)
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
.set('Authorization', authHeader)
.send({ ruleId: target.ruleId, service: target.service ?? null, reason: 'intentional', expiryMode: 'forever' });
expect(ack.status).toBe(201);
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
expect(get.body.acknowledgedCount).toBeGreaterThanOrEqual(1);
const acked = get.body.findings.find((f: { ruleId: string; service?: string }) =>
f.ruleId === target.ruleId && f.service === target.service);
expect(acked?.acknowledged).toBe(true);
expect(get.body.activeCount).toBe(get.body.findings.length - get.body.acknowledgedCount);
});
it('DELETE clears an acknowledgement', async () => {
await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
const list = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
const target = list.body.findings[0];
const ack = await request(app)
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
.set('Authorization', authHeader)
.send({ ruleId: target.ruleId, service: target.service ?? null, expiryMode: 'forever' });
const del = await request(app)
.delete(`/api/stacks/${STACK}/preflight/acknowledgements/${ack.body.id}`)
.set('Authorization', authHeader);
expect(del.status).toBe(204);
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
const again = get.body.findings.find((f: { ruleId: string; service?: string }) =>
f.ruleId === target.ruleId && f.service === target.service);
expect(again?.acknowledged).toBe(false);
});
it('rejects until_image_change without a service', async () => {
await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
const res = await request(app)
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
.set('Authorization', authHeader)
.send({ ruleId: 'port-exposed-all-interfaces', expiryMode: 'until_image_change' });
expect(res.status).toBe(400);
});
});
@@ -39,17 +39,17 @@ const summary = (over: Partial<UpdatePreviewSummary> = {}): UpdatePreviewSummary
describe('preflightSignal', () => {
const cases = [
{ status: 'never-run', expected: 'unknown', affects: false },
{ status: 'blocker', expected: 'blocked', affects: true },
{ status: 'unrenderable', expected: 'attention', affects: true },
{ status: 'high', expected: 'attention', affects: true },
{ status: 'warning', expected: 'warning', affects: true },
{ status: 'pass', expected: 'ok', affects: true },
{ status: 'info', expected: 'ok', affects: true },
{ activeStatus: 'never-run', expected: 'unknown', affects: false },
{ activeStatus: 'blocker', expected: 'blocked', affects: true },
{ activeStatus: 'unrenderable', expected: 'attention', affects: true },
{ activeStatus: 'high', expected: 'attention', affects: true },
{ activeStatus: 'warning', expected: 'warning', affects: true },
{ activeStatus: 'pass', expected: 'ok', affects: true },
{ activeStatus: 'info', expected: 'ok', affects: true },
] as const;
it.each(cases)('maps preflight status $status to $expected', ({ status, expected, affects }) => {
const signal = preflightSignal({ status });
it.each(cases)('maps preflight activeStatus $activeStatus to $expected', ({ activeStatus, expected, affects }) => {
const signal = preflightSignal({ activeStatus });
expect(signal.status).toBe(expected);
expect(signal.affectsVerdict).toBe(affects);
});
@@ -182,10 +182,10 @@ describe('aggregateVerdict', () => {
});
it('reaches every verdict from realistic signal sets', () => {
expect(aggregateVerdict([preflightSignal({ status: 'blocker' }), driftSignal(0)])).toBe('blocked');
expect(aggregateVerdict([preflightSignal({ status: 'high' }), driftSignal(0)])).toBe('review_required');
expect(aggregateVerdict([preflightSignal({ activeStatus: 'blocker' }), driftSignal(0)])).toBe('blocked');
expect(aggregateVerdict([preflightSignal({ activeStatus: 'high' }), driftSignal(0)])).toBe('review_required');
expect(aggregateVerdict([containersSignal('error'), driftSignal(0)])).toBe('unknown');
expect(aggregateVerdict([driftSignal(1), preflightSignal({ status: 'pass' })])).toBe('ready_with_warnings');
expect(aggregateVerdict([driftSignal(0), preflightSignal({ status: 'pass' }), healthchecksSignal([probe()])])).toBe('ready');
expect(aggregateVerdict([driftSignal(1), preflightSignal({ activeStatus: 'pass' })])).toBe('ready_with_warnings');
expect(aggregateVerdict([driftSignal(0), preflightSignal({ activeStatus: 'pass' }), healthchecksSignal([probe()])])).toBe('ready');
});
});
@@ -142,7 +142,7 @@ describe('UpdateGuardService.computeUpdateReadiness wiring', () => {
});
it('produces a ready verdict from healthy collaborator outputs', async () => {
mockGetLatest.mockReturnValue({ status: 'pass' });
mockGetLatest.mockReturnValue({ activeStatus: 'pass' });
mockGetOpenDriftFindings.mockReturnValue([]);
mockListContainers.mockResolvedValue([{ Id: 'aaa', Names: ['/app-web-1'], State: 'running' }]);
mockGetContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectResult()) });
+140
View File
@@ -23,6 +23,9 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService';
import { ComposeDoctorService } from '../services/ComposeDoctorService';
import { RULE_IDS } from '../services/preflight/rules';
import { parseServiceImages, isPreflightAckActive } from '../utils/preflight-ack-filter';
import type { PreflightAckExpiryMode } from '../services/DatabaseService';
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
import { buildStorageInventory } from '../services/storage/inventory';
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
@@ -1249,6 +1252,143 @@ stacksRouter.post('/:stackName/preflight/run', async (req: Request, res: Respons
}
});
const PREFLIGHT_ACK_EXPIRY_MODES = new Set<PreflightAckExpiryMode>([
'forever', 'until_compose_change', 'days', 'until_image_change',
]);
const PREFLIGHT_RULE_ID_SET = new Set(RULE_IDS);
stacksRouter.get('/:stackName/preflight/acknowledgements', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
const run = DatabaseService.getInstance().getLatestPreflightRun(req.nodeId, stackName);
const ctx = {
renderedHash: run?.rendered_hash ?? null,
serviceImages: parseServiceImages(run?.service_images ?? null),
};
const now = Date.now();
const rows = DatabaseService.getInstance().getPreflightAcknowledgements(req.nodeId, stackName).map((ack) => ({
...ack,
active: isPreflightAckActive(ack, ctx, now),
}));
res.json(rows);
} catch (error) {
console.error('[Stacks] Failed to list preflight acknowledgements for %s:', sanitizeForLog(stackName),
sanitizeForLog(inspect(error, { depth: 4 })));
res.status(500).json({ error: 'Failed to load preflight acknowledgements' });
}
});
stacksRouter.post('/:stackName/preflight/acknowledgements', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
const body = req.body ?? {};
const ruleId = typeof body.ruleId === 'string' ? body.ruleId.trim() : '';
if (!PREFLIGHT_RULE_ID_SET.has(ruleId)) {
res.status(400).json({ error: 'ruleId must be a known Compose Doctor rule id' });
return;
}
const serviceRaw = body.service == null || body.service === ''
? null
: String(body.service).trim();
if (serviceRaw !== null && !isValidServiceName(serviceRaw)) {
res.status(400).json({ error: 'service must be a valid service name' });
return;
}
const expiryMode = typeof body.expiryMode === 'string' ? body.expiryMode.trim() : 'forever';
if (!PREFLIGHT_ACK_EXPIRY_MODES.has(expiryMode as PreflightAckExpiryMode)) {
res.status(400).json({ error: 'expiryMode must be forever, until_compose_change, days, or until_image_change' });
return;
}
if (expiryMode === 'until_image_change' && !serviceRaw) {
res.status(400).json({ error: 'until_image_change requires a specific service' });
return;
}
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
if (reason.length > 2000) {
res.status(400).json({ error: 'reason is too long' });
return;
}
let expiresAt: number | null = null;
if (expiryMode === 'days') {
const days = Number(body.expiresInDays ?? 30);
if (!Number.isFinite(days) || days <= 0 || days > 3650) {
res.status(400).json({ error: 'expiresInDays must be between 1 and 3650' });
return;
}
expiresAt = Date.now() + Math.round(days * 86_400_000);
}
const run = DatabaseService.getInstance().getLatestPreflightRun(req.nodeId, stackName);
if (!run) {
res.status(400).json({ error: 'Run Compose Doctor before acknowledging a finding' });
return;
}
const serviceImages = parseServiceImages(run.service_images);
let anchorRenderedHash: string | null = null;
let anchorImageRef: string | null = null;
if (expiryMode === 'until_compose_change') {
if (!run.rendered_hash) {
res.status(400).json({ error: 'The latest preflight run has no compose fingerprint to anchor against' });
return;
}
anchorRenderedHash = run.rendered_hash;
}
if (expiryMode === 'until_image_change') {
const imageRef = serviceImages[serviceRaw!] ?? null;
if (!imageRef) {
res.status(400).json({ error: 'The latest preflight run has no image reference for that service' });
return;
}
anchorImageRef = imageRef;
}
try {
const ack = DatabaseService.getInstance().upsertPreflightAcknowledgement({
node_id: req.nodeId,
stack_name: stackName,
rule_id: ruleId,
service: serviceRaw,
reason,
expiry_mode: expiryMode as PreflightAckExpiryMode,
expires_at: expiresAt,
anchor_rendered_hash: anchorRenderedHash,
anchor_image_ref: anchorImageRef,
created_by: req.user?.username ?? 'unknown',
created_at: Date.now(),
});
res.status(201).json(ack);
} catch (error) {
console.error('[Stacks] Failed to create preflight acknowledgement for %s:', sanitizeForLog(stackName),
sanitizeForLog(inspect(error, { depth: 4 })));
res.status(500).json({ error: 'Failed to create preflight acknowledgement' });
}
});
stacksRouter.delete('/:stackName/preflight/acknowledgements/:id', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid acknowledgement id' });
return;
}
const existing = DatabaseService.getInstance().getPreflightAcknowledgement(id);
if (!existing || existing.node_id !== req.nodeId || existing.stack_name !== stackName) {
res.status(404).json({ error: 'Acknowledgement not found' });
return;
}
try {
DatabaseService.getInstance().deletePreflightAcknowledgement(id);
res.status(204).end();
} catch (error) {
console.error('[Stacks] Failed to delete preflight acknowledgement for %s:', sanitizeForLog(stackName),
sanitizeForLog(inspect(error, { depth: 4 })));
res.status(500).json({ error: 'Failed to delete preflight acknowledgement' });
}
});
// Compose Network Inspector: per-stack networking facts (network map, service
// membership, published ports/bindings, network_mode, extra_hosts, runtime
// drift) derived from the authored effective model + live snapshot. Read-only
+62 -4
View File
@@ -15,6 +15,7 @@ import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './pref
import type {
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus, MissingEnvFile,
} from './preflight/types';
import { applyPreflightAcknowledgements, parseServiceImages } from '../utils/preflight-ack-filter';
import { isPathWithinBase } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
@@ -41,6 +42,55 @@ function highestOf(findings: PreflightFinding[]): PreflightSeverity | null {
return best;
}
function activeFields(
renderable: boolean,
findings: PreflightFinding[],
): Pick<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> {
const active = findings.filter(f => !f.acknowledged);
const acknowledgedCount = findings.length - active.length;
const activeHighestSeverity = highestOf(active);
const activeStatus: PreflightStatus = !renderable
? 'unrenderable'
: (activeHighestSeverity ?? 'pass');
return {
activeStatus,
activeHighestSeverity,
activeCount: active.length,
acknowledgedCount,
};
}
function buildServiceImages(model: EffectiveModel | null): string | null {
if (!model) return null;
const map: Record<string, string> = {};
for (const svc of model.services) {
if (svc.image) map[svc.name] = svc.image;
}
return Object.keys(map).length > 0 ? JSON.stringify(map) : null;
}
function enrichReport(
nodeId: number,
stackName: string,
report: Omit<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'>,
): PreflightReport {
const db = DatabaseService.getInstance();
const acks = db.getPreflightAcknowledgements(nodeId, stackName);
const serviceImages = parseServiceImages(
db.getLatestPreflightRun(nodeId, stackName)?.service_images ?? null,
);
const findings = applyPreflightAcknowledgements(
report.findings,
{ renderedHash: report.renderedHash, serviceImages },
acks,
);
return {
...report,
findings,
...activeFields(report.renderable, findings),
};
}
/**
* Compose Doctor: renders the effective model and runs the deterministic
* preflight rule registry against the active node. Advisory only (it never
@@ -82,6 +132,7 @@ export class ComposeDoctorService {
const findings = sortFindings(runRules(ctx));
const highestSeverity = highestOf(findings);
const status: PreflightStatus = !ctx.renderable ? 'unrenderable' : (highestSeverity ?? 'pass');
const serviceImages = buildServiceImages(ctx.model);
const report: PreflightReport = {
stack: stackName,
@@ -94,9 +145,13 @@ export class ComposeDoctorService {
sourceHash: hashes.sourceHash,
renderedHash: hashes.renderedHash,
findings,
activeStatus: status,
activeHighestSeverity: highestSeverity,
activeCount: findings.length,
acknowledgedCount: 0,
};
this.persist(nodeId, report);
return report;
this.persist(nodeId, report, serviceImages);
return enrichReport(nodeId, stackName, report);
}
/** Read the last stored run for a stack, mapped to the report shape. */
@@ -107,6 +162,7 @@ export class ComposeDoctorService {
return {
stack: stackName, ranAt: null, ranBy: null, renderable: true, renderError: null,
status: 'never-run', highestSeverity: null, sourceHash: null, renderedHash: null, findings: [],
activeStatus: 'never-run', activeHighestSeverity: null, activeCount: 0, acknowledgedCount: 0,
};
}
const findings = sortFindings(db.getPreflightFindings(run.id).map(r => ({
@@ -121,7 +177,7 @@ export class ComposeDoctorService {
const renderable = run.status !== 'unrenderable';
// The render error is carried by the render-failed finding, not a column.
const renderError = renderable ? null : (findings.find(f => f.ruleId === RENDER_FAILED_RULE_ID)?.message ?? null);
return {
const base: Omit<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> = {
stack: stackName,
ranAt: run.created_at,
ranBy: run.created_by,
@@ -133,6 +189,7 @@ export class ComposeDoctorService {
renderedHash: run.rendered_hash,
findings,
};
return enrichReport(nodeId, stackName, base);
}
private async buildContext(nodeId: number, stackName: string, sourceServiceNames: string[], sourceReadable: boolean): Promise<PreflightContext> {
@@ -323,7 +380,7 @@ export class ComposeDoctorService {
}
/** Persist the run, replacing any prior run for this stack. Best-effort. */
private persist(nodeId: number, report: PreflightReport): void {
private persist(nodeId: number, report: PreflightReport, serviceImages: string | null): void {
if (report.ranAt === null) return;
try {
const runId = randomUUID();
@@ -334,6 +391,7 @@ export class ComposeDoctorService {
stack_name: report.stack,
source_hash: report.sourceHash,
rendered_hash: report.renderedHash,
service_images: serviceImages,
status: report.status,
highest_severity: report.highestSeverity,
created_at: report.ranAt,
+98 -3
View File
@@ -122,6 +122,8 @@ export interface PreflightRunRow {
stack_name: string;
source_hash: string | null;
rendered_hash: string | null;
/** JSON map of service name to image ref at run time (for until_image_change acks). */
service_images: string | null;
status: string;
highest_severity: string | null;
created_at: number;
@@ -168,6 +170,24 @@ export interface PreflightFindingRow {
created_at: number;
}
export type PreflightAckExpiryMode = 'forever' | 'until_compose_change' | 'days' | 'until_image_change';
/** Operator acknowledgement of a specific Compose Doctor finding on a stack. */
export interface PreflightAcknowledgement {
id: number;
node_id: number;
stack_name: string;
rule_id: string;
service: string | null;
reason: string;
expiry_mode: PreflightAckExpiryMode;
expires_at: number | null;
anchor_rendered_hash: string | null;
anchor_image_ref: string | null;
created_by: string;
created_at: number;
}
/** A persisted drift finding: one service-scoped divergence, open until resolved. */
export interface StackDriftFindingRow {
id: number;
@@ -1470,6 +1490,26 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_preflight_findings_run
ON preflight_findings(run_id);
CREATE TABLE IF NOT EXISTS preflight_acknowledgements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
rule_id TEXT NOT NULL,
service TEXT,
reason TEXT NOT NULL DEFAULT '',
expiry_mode TEXT NOT NULL DEFAULT 'forever'
CHECK (expiry_mode IN ('forever','until_compose_change','days','until_image_change')),
expires_at INTEGER,
anchor_rendered_hash TEXT,
anchor_image_ref TEXT,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_preflight_ack_unique
ON preflight_acknowledgements(node_id, stack_name, rule_id, COALESCE(service,''));
CREATE INDEX IF NOT EXISTS idx_preflight_ack_stack
ON preflight_acknowledgements(node_id, stack_name);
CREATE TABLE IF NOT EXISTS stack_exposure (
node_id INTEGER NOT NULL DEFAULT 0,
stack_name TEXT NOT NULL,
@@ -1583,6 +1623,8 @@ export class DatabaseService {
maybeAddCol('cve_suppressions', 'status', "TEXT NOT NULL DEFAULT 'accepted'");
maybeAddCol('cve_suppressions', 'justification', 'TEXT');
maybeAddCol('preflight_runs', 'service_images', 'TEXT');
// Scheduled operations migrations
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
@@ -2884,9 +2926,12 @@ export class DatabaseService {
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ? AND stack_name = ?').run(run.node_id, run.stack_name);
this.db.prepare(
`INSERT INTO preflight_runs
(id, node_id, stack_name, source_hash, rendered_hash, status, highest_severity, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(run.id, run.node_id, run.stack_name, run.source_hash, run.rendered_hash, run.status, run.highest_severity, run.created_at, run.created_by);
(id, node_id, stack_name, source_hash, rendered_hash, service_images, status, highest_severity, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
run.id, run.node_id, run.stack_name, run.source_hash, run.rendered_hash,
run.service_images ?? null, run.status, run.highest_severity, run.created_at, run.created_by,
);
const insert = this.db.prepare(
`INSERT INTO preflight_findings
(id, run_id, rule_id, severity, title, message, source_path, remediation, service, created_at)
@@ -2912,6 +2957,55 @@ export class DatabaseService {
).all(runId) as PreflightFindingRow[];
}
public getPreflightAcknowledgements(nodeId: number, stackName: string): PreflightAcknowledgement[] {
return this.db.prepare(
'SELECT * FROM preflight_acknowledgements WHERE node_id = ? AND stack_name = ? ORDER BY created_at DESC, id DESC',
).all(nodeId, stackName) as PreflightAcknowledgement[];
}
public getPreflightAcknowledgement(id: number): PreflightAcknowledgement | null {
return (
(this.db.prepare('SELECT * FROM preflight_acknowledgements WHERE id = ?')
.get(id) as PreflightAcknowledgement | undefined) ?? null
);
}
public upsertPreflightAcknowledgement(
ack: Omit<PreflightAcknowledgement, 'id'>,
): PreflightAcknowledgement {
const existing = this.db.prepare(
`SELECT id FROM preflight_acknowledgements
WHERE node_id = ? AND stack_name = ? AND rule_id = ? AND COALESCE(service, '') = COALESCE(?, '')`,
).get(ack.node_id, ack.stack_name, ack.rule_id, ack.service) as { id: number } | undefined;
if (existing) {
this.db.prepare(
`UPDATE preflight_acknowledgements
SET reason = ?, expiry_mode = ?, expires_at = ?, anchor_rendered_hash = ?,
anchor_image_ref = ?, created_by = ?, created_at = ?
WHERE id = ?`,
).run(
ack.reason, ack.expiry_mode, ack.expires_at, ack.anchor_rendered_hash,
ack.anchor_image_ref, ack.created_by, ack.created_at, existing.id,
);
return this.getPreflightAcknowledgement(existing.id)!;
}
const result = this.db.prepare(
`INSERT INTO preflight_acknowledgements
(node_id, stack_name, rule_id, service, reason, expiry_mode, expires_at,
anchor_rendered_hash, anchor_image_ref, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
ack.node_id, ack.stack_name, ack.rule_id, ack.service, ack.reason, ack.expiry_mode,
ack.expires_at, ack.anchor_rendered_hash, ack.anchor_image_ref, ack.created_by, ack.created_at,
);
return { ...ack, id: result.lastInsertRowid as number };
}
public deletePreflightAcknowledgement(id: number): boolean {
const result = this.db.prepare('DELETE FROM preflight_acknowledgements WHERE id = ?').run(id);
return result.changes > 0;
}
// --- Health Gate Runs ---
public insertHealthGateRun(run: HealthGateRunRow): void {
@@ -3316,6 +3410,7 @@ export class DatabaseService {
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ?)').run(id);
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM preflight_acknowledgements WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_exposure WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM health_gate_runs WHERE node_id = ?').run(id);
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
+10
View File
@@ -26,6 +26,11 @@ export interface PreflightFinding {
remediation?: string;
/** Service the finding is scoped to, when applicable. */
service?: string;
/** True when an active acknowledgement covers this finding. */
acknowledged?: boolean;
acknowledgementId?: number;
acknowledgementReason?: string;
acknowledgementExpiry?: 'forever' | 'until_compose_change' | 'days' | 'until_image_change';
}
/** The full report returned by both the GET (latest) and POST (run) routes. */
@@ -42,6 +47,11 @@ export interface PreflightReport {
sourceHash: string | null;
renderedHash: string | null;
findings: PreflightFinding[];
/** Severity/status after filtering acknowledged findings. */
activeStatus: PreflightStatus;
activeHighestSeverity: PreflightSeverity | null;
activeCount: number;
acknowledgedCount: number;
}
/** A declared `env_file:` that is required and absent on disk (names only). */
@@ -27,13 +27,13 @@ const formatAge = (timestamp: number, now: number): string => {
};
export function preflightSignal(
input: { status: PreflightStatus } | Errored,
input: { activeStatus: PreflightStatus } | Errored,
): ReadinessSignal {
const base = { id: 'preflight' as const, title: 'Compose Doctor' };
if (input === 'error') {
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'The stored preflight report could not be read.' };
}
switch (input.status) {
switch (input.activeStatus) {
case 'never-run':
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'Compose Doctor has not been run for this stack yet. Run it for a deeper pre-update check.' };
case 'blocker':
+131
View File
@@ -0,0 +1,131 @@
/**
* Read-time Compose Doctor acknowledgement filter.
*
* Acknowledgements never modify stored finding rows. They are applied at read time
* so clearing an ack resurfaces findings without re-running preflight.
*/
import type { PreflightAckExpiryMode, PreflightAcknowledgement } from '../services/DatabaseService';
import type { PreflightFinding } from '../services/preflight/types';
export interface PreflightAcknowledgementDecision {
acknowledged: boolean;
acknowledgementId?: number;
acknowledgementReason?: string;
acknowledgementExpiry?: PreflightAckExpiryMode;
}
export interface PreflightAckFilterContext {
renderedHash: string | null;
/** Parsed service name to image ref from the latest stored run. */
serviceImages: Record<string, string>;
}
function matchesService(ackService: string | null, findingService: string | undefined): boolean {
if (ackService === null) return true;
return ackService === (findingService ?? null);
}
function isActive(
ack: PreflightAcknowledgement,
ctx: PreflightAckFilterContext,
findingService: string | undefined,
now: number,
): boolean {
switch (ack.expiry_mode) {
case 'forever':
return true;
case 'until_compose_change':
return ctx.renderedHash !== null
&& ack.anchor_rendered_hash !== null
&& ctx.renderedHash === ack.anchor_rendered_hash;
case 'days':
return ack.expires_at !== null && ack.expires_at > now;
case 'until_image_change': {
const svc = findingService ?? ack.service ?? null;
if (!svc) return false;
const current = ctx.serviceImages[svc] ?? null;
return current !== null
&& ack.anchor_image_ref !== null
&& current === ack.anchor_image_ref;
}
default:
return false;
}
}
function specificityScore(ack: PreflightAcknowledgement): number {
return ack.service ? 1 : 0;
}
function pickFromBucket(
bucket: PreflightAcknowledgement[],
findingService: string | undefined,
ctx: PreflightAckFilterContext,
now: number,
): PreflightAcknowledgement | null {
let best: PreflightAcknowledgement | null = null;
let bestScore = -1;
for (const ack of bucket) {
if (!matchesService(ack.service, findingService)) continue;
if (!isActive(ack, ctx, findingService, now)) continue;
const score = specificityScore(ack);
if (score > bestScore) {
best = ack;
bestScore = score;
}
}
return best;
}
export function parseServiceImages(json: string | null | undefined): Record<string, string> {
if (!json) return {};
try {
const parsed = JSON.parse(json) as unknown;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'string' && value.length > 0) out[key] = value;
}
return out;
} catch {
return {};
}
}
export function applyPreflightAcknowledgements<T extends PreflightFinding>(
findings: T[],
ctx: PreflightAckFilterContext,
acks: PreflightAcknowledgement[],
now: number = Date.now(),
): Array<T & PreflightAcknowledgementDecision> {
if (findings.length === 0) return [];
const buckets = new Map<string, PreflightAcknowledgement[]>();
for (const ack of acks) {
const existing = buckets.get(ack.rule_id);
if (existing) {
existing.push(ack);
} else {
buckets.set(ack.rule_id, [ack]);
}
}
return findings.map((f) => {
const bucket = buckets.get(f.ruleId);
const match = bucket ? pickFromBucket(bucket, f.service, ctx, now) : null;
if (!match) return { ...f, acknowledged: false };
return {
...f,
acknowledged: true,
acknowledgementId: match.id,
acknowledgementReason: match.reason,
acknowledgementExpiry: match.expiry_mode,
};
});
}
export function isPreflightAckActive(
ack: PreflightAcknowledgement,
ctx: PreflightAckFilterContext,
now: number = Date.now(),
): boolean {
return isActive(ack, ctx, ack.service ?? undefined, now);
}
+17
View File
@@ -204,6 +204,23 @@ Running preflight before updating gives the readiness check the most accurate si
Preflight runs against the **active node**, so selecting a remote node checks the stack against that machine's live Docker state. On mobile, the same report appears under the **Compose** section of the stack detail view.
## Acknowledging findings
When a finding is valid in general but intentional for your stack, you can acknowledge it so it no longer counts toward the active warning total, the Doctor tab badge, or the update readiness check.
1. Open the **Doctor** tab and run preflight if you have not already.
2. On any active finding row, click **acknowledge**.
3. Optionally add a note explaining why the finding is acceptable.
4. Choose when Sencho should show the finding again:
- **Forever** until you clear the acknowledgement manually.
- **Until Compose changes** when the stack's compose fingerprint changes (for example after you edit `compose.yaml`).
- **30 days** on a rolling timer.
- **Until image changes** for a specific service when that service's image reference in the effective model changes. This tracks tag or reference changes, not silent digest re-pulls of the same tag.
Acknowledged findings move to a collapsible **Acknowledged** section at the bottom of the report. You can clear an acknowledgement there to restore the finding to the active list immediately.
Acknowledgements are stored per node, stack, rule, and service. They do not sync across nodes in a fleet. Clearing an acknowledgement or letting an expiry mode lapse restores the finding on the next preflight read without re-running checks.
<Frame>
<img
src="/images/compose-doctor/compose-doctor-findings.png"
@@ -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>
);
}