diff --git a/backend/src/__tests__/compose-doctor-service.test.ts b/backend/src/__tests__/compose-doctor-service.test.ts index 2a77540c..1d8cf331 100644 --- a/backend/src/__tests__/compose-doctor-service.test.ts +++ b/backend/src/__tests__/compose-doctor-service.test.ts @@ -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(); diff --git a/backend/src/__tests__/preflight-ack-filter.test.ts b/backend/src/__tests__/preflight-ack-filter.test.ts new file mode 100644 index 00000000..6658925e --- /dev/null +++ b/backend/src/__tests__/preflight-ack-filter.test.ts @@ -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 => ({ + ruleId: 'uid-gid-risk', + severity: 'warning', + title: 'Check UID/GID alignment', + message: 'test', + service: 'web', + ...over, +}); + +const baseAck = (over: Partial = {}): 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); + }); +}); diff --git a/backend/src/__tests__/preflight-route.test.ts b/backend/src/__tests__/preflight-route.test.ts index 21c2d9e1..cbb2580b 100644 --- a/backend/src/__tests__/preflight-route.test.ts +++ b/backend/src/__tests__/preflight-route.test.ts @@ -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); + }); +}); diff --git a/backend/src/__tests__/update-guard-readiness.test.ts b/backend/src/__tests__/update-guard-readiness.test.ts index 4091815e..5adb4392 100644 --- a/backend/src/__tests__/update-guard-readiness.test.ts +++ b/backend/src/__tests__/update-guard-readiness.test.ts @@ -39,17 +39,17 @@ const summary = (over: Partial = {}): 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'); }); }); diff --git a/backend/src/__tests__/update-guard-service.test.ts b/backend/src/__tests__/update-guard-service.test.ts index b78d3da0..10cbc487 100644 --- a/backend/src/__tests__/update-guard-service.test.ts +++ b/backend/src/__tests__/update-guard-service.test.ts @@ -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()) }); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 192fa241..f9689259 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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([ + '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 diff --git a/backend/src/services/ComposeDoctorService.ts b/backend/src/services/ComposeDoctorService.ts index 6969a51c..46f5cc94 100644 --- a/backend/src/services/ComposeDoctorService.ts +++ b/backend/src/services/ComposeDoctorService.ts @@ -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 { + 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 = {}; + 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 { + 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 = { 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 { @@ -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, diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 88346d3c..07e2f0b5 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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 { + 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); diff --git a/backend/src/services/preflight/types.ts b/backend/src/services/preflight/types.ts index 44887108..4b7ad3e6 100644 --- a/backend/src/services/preflight/types.ts +++ b/backend/src/services/preflight/types.ts @@ -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). */ diff --git a/backend/src/services/updateGuard/readiness.ts b/backend/src/services/updateGuard/readiness.ts index 7b27994f..96610f5e 100644 --- a/backend/src/services/updateGuard/readiness.ts +++ b/backend/src/services/updateGuard/readiness.ts @@ -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': diff --git a/backend/src/utils/preflight-ack-filter.ts b/backend/src/utils/preflight-ack-filter.ts new file mode 100644 index 00000000..c375cb46 --- /dev/null +++ b/backend/src/utils/preflight-ack-filter.ts @@ -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; +} + +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 { + if (!json) return {}; + try { + const parsed = JSON.parse(json) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}; + const out: Record = {}; + 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( + findings: T[], + ctx: PreflightAckFilterContext, + acks: PreflightAcknowledgement[], + now: number = Date.now(), +): Array { + if (findings.length === 0) return []; + const buckets = new Map(); + 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); +} diff --git a/docs/features/compose-doctor.mdx b/docs/features/compose-doctor.mdx index a1f8a823..4bed6aff 100644 --- a/docs/features/compose-doctor.mdx +++ b/docs/features/compose-doctor.mdx @@ -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. + { 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 }); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 5a5a38ac..faf5487a 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -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 && ( - + )} {storageEnabled && ( diff --git a/frontend/src/components/stack/PreflightPanel.test.tsx b/frontend/src/components/stack/PreflightPanel.test.tsx index 32baab9e..7478016a 100644 --- a/frontend/src/components/stack/PreflightPanel.test.tsx +++ b/frontend/src/components/stack/PreflightPanel.test.tsx @@ -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 { - 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) { diff --git a/frontend/src/components/stack/PreflightPanel.tsx b/frontend/src/components/stack/PreflightPanel.tsx index 02783e74..c61bd83c 100644 --- a/frontend/src/components/stack/PreflightPanel.tsx +++ b/frontend/src/components/stack/PreflightPanel.tsx @@ -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 = { + 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 (
-
- {finding.service && ( - {finding.service} +
+
+ {finding.service && ( + {finding.service} + )} + {finding.title} +
+ {canEdit && onAcknowledge && ( + )} - {finding.title}
{finding.message}
{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 ( +
+
+
+
+ {finding.service && ( + {finding.service} + )} + {finding.title} +
+ {finding.acknowledgementReason && ( +
{finding.acknowledgementReason}
+ )} + {finding.acknowledgementExpiry && ( +
+ expires: {EXPIRY_LABELS[finding.acknowledgementExpiry]} +
+ )} +
+ {canEdit && finding.acknowledgementId != null && ( + + )} +
+
+ ); +} + +export default function PreflightPanel({ stackName, canEdit = false }: { stackName: string; canEdit?: boolean }) { const { activeNode } = useNodes(); const nodeId = activeNode?.id; const [report, setReport] = useState(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(null); + const [ackReason, setAckReason] = useState(''); + const [ackExpiry, setAckExpiry] = useState('forever'); + const [ackSaving, setAckSaving] = useState(false); + const [clearTarget, setClearTarget] = useState(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 (
@@ -198,8 +385,8 @@ export default function PreflightPanel({ stackName }: { stackName: string }) { ) : ( <> {summary && SummaryIcon && !dismissed && ( -
- {hasFindings && ( +
+ {hasActiveFindings && ( + {ackSectionOpen && ( +
+ {acknowledgedFindings.map((f, i) => ( + + ))} +
+ )} + + )} )} + + + + + {ackTarget && ( +
+ {ackTarget.title} + {ackTarget.service ? ` · ${ackTarget.service}` : ''} +
+ )} +
+ +