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
+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);