From fdbc1b1ebb7ce95b415f6edf8a40518cf28d44e8 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 4 Jul 2026 21:20:20 -0400 Subject: [PATCH 01/12] feat: move Blueprint orchestration and Federation placement to Community tier (#1555) * feat: move core Blueprint orchestration to Community tier Blueprints CRUD, reconciliation, and drift modes are now available on Community. Pin remains Admiral-only via Federation placement controls. * feat: move Federation placement controls to Community tier Remove requirePaid from cordon, uncordon, and blueprint pin routes. Ungate the Federation tab and gate cordon UI on node:manage only. Update licensing and fleet docs for the new tier split. --- README.md | 2 +- .../src/__tests__/blueprints-authz.test.ts | 49 ++--- .../blueprints-community-tier.test.ts | 171 ++++++++++++++++++ .../src/__tests__/nodes-cordon-authz.test.ts | 18 +- backend/src/routes/blueprints.ts | 16 +- backend/src/routes/nodes.ts | 2 - docs/features/alerts-notifications.mdx | 10 +- docs/features/blueprint-model.mdx | 14 +- docs/features/fleet-federation.mdx | 14 +- docs/features/fleet-view.mdx | 12 +- docs/features/licensing.mdx | 4 +- docs/features/overview.mdx | 6 +- docs/getting-started/introduction.mdx | 2 +- frontend/src/components/FleetView.tsx | 22 +-- .../src/components/FleetView/NodeCard.tsx | 8 +- .../FleetView/__tests__/NodeCard.test.tsx | 23 ++- .../components/blueprints/DeploymentsTab.tsx | 4 +- .../src/components/mobile/MobileFleet.tsx | 4 +- 18 files changed, 264 insertions(+), 117 deletions(-) create mode 100644 backend/src/__tests__/blueprints-community-tier.test.ts diff --git a/README.md b/README.md index 551ad4f5..f4193a92 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ See [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) for the current limitation list - [Auto-update policies](https://docs.sencho.io/features/auto-update-policies) for image rollouts - [Scheduled operations](https://docs.sencho.io/features/scheduled-operations) on cron - [Webhooks](https://docs.sencho.io/features/webhooks) on stack lifecycle events -- [Blueprints](https://docs.sencho.io/features/blueprint-model): declarative fleet templates with drift detection **(Admiral)** +- [Blueprints](https://docs.sencho.io/features/blueprint-model): declarative fleet templates with drift detection - Encrypted [Fleet Secrets](https://docs.sencho.io/features/fleet-secrets) pushed to labeled nodes **(Admiral)** ### Security diff --git a/backend/src/__tests__/blueprints-authz.test.ts b/backend/src/__tests__/blueprints-authz.test.ts index 74e8fe6b..397dad65 100644 --- a/backend/src/__tests__/blueprints-authz.test.ts +++ b/backend/src/__tests__/blueprints-authz.test.ts @@ -1,13 +1,12 @@ /** * Authorization parity tests for /api/blueprints. * - * The Blueprints UI gates affordances on the paid tier and admin role; these - * tests pin the matching server-side guards so a UI gate and a route guard - * cannot silently drift apart. Specifically: - * - PUT /:id/pin requires the paid tier AND admin role (the admin-role half is - * the parity gap the Federation pin control was hardened to match). - * - The mutation routes require admin role. - * - The read routes require paid tier but NOT admin role. + * The Blueprints UI gates edit affordances on admin role; these tests pin the + * matching server-side guards so a UI gate and a route guard cannot silently + * drift apart. Specifically: + * - PUT /:id/pin requires admin role on every tier (Federation placement). + * - Core mutation routes require admin role on every tier. + * - Read routes require auth but NOT admin role. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; @@ -119,7 +118,7 @@ describe('PUT /api/blueprints/:id/pin authorization', () => { expect(res.body.pinned_node_id).toBeNull(); }); - it('rejects an admin on a Community license with PAID_REQUIRED', async () => { + it('lets a Community admin pin a blueprint', async () => { setLicense('community'); const node = seedNode(); const bp = seedBlueprint([node.id]); @@ -129,8 +128,8 @@ describe('PUT /api/blueprints/:id/pin authorization', () => { .set('Cookie', adminCookie) .send({ nodeId: node.id }); - expect(res.status).toBe(403); - expect(res.body.code).toBe('PAID_REQUIRED'); + expect(res.status).toBe(200); + expect(res.body.pinned_node_id).toBe(node.id); }); it('rejects a non-admin on a paid license with ADMIN_REQUIRED', async () => { @@ -148,9 +147,8 @@ describe('PUT /api/blueprints/:id/pin authorization', () => { }); describe('Blueprint mutation routes require admin role', () => { - // Tier is paid in beforeEach, so requirePaid passes and the admin guard is - // what rejects. The gate short-circuits before id parsing, so dummy ids are - // sufficient to prove the role boundary. + // The gate short-circuits before id parsing, so dummy ids are sufficient + // to prove the role boundary. const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string }> = [ { name: 'create', method: 'post', path: '/api/blueprints' }, { name: 'update', method: 'put', path: '/api/blueprints/1' }, @@ -166,18 +164,25 @@ describe('Blueprint mutation routes require admin role', () => { expect(res.body.code).toBe('ADMIN_REQUIRED'); }); - it('rejects an admin on a Community license from creating with PAID_REQUIRED', async () => { + it('lets a Community admin create when the body is valid (not PAID_REQUIRED)', async () => { setLicense('community'); + const node = seedNode(); const res = await request(app) .post('/api/blueprints') .set('Cookie', adminCookie) - .send({}); - expect(res.status).toBe(403); - expect(res.body.code).toBe('PAID_REQUIRED'); + .send({ + name: 'community-bp', + compose_content: 'services:\n app:\n image: nginx\n', + selector: { type: 'nodes', ids: [node.id] }, + drift_mode: 'enforce', + }); + expect(res.status).toBe(201); + expect(res.body.name).toBe('community-bp'); + expect(res.body.code).not.toBe('PAID_REQUIRED'); }); }); -describe('Blueprint read routes require paid tier but not admin role', () => { +describe('Blueprint read routes require auth but not admin role', () => { it('lets a non-admin paid user list blueprints', async () => { seedBlueprint([]); const res = await request(app).get('/api/blueprints').set('Cookie', viewerCookie); @@ -209,10 +214,12 @@ describe('Blueprint read routes require paid tier but not admin role', () => { expect(res.body.classification).toBeDefined(); }); - it('rejects an admin on a Community license from listing with PAID_REQUIRED', async () => { + it('lets a Community admin list blueprints (not PAID_REQUIRED)', async () => { setLicense('community'); + seedBlueprint([]); const res = await request(app).get('/api/blueprints').set('Cookie', adminCookie); - expect(res.status).toBe(403); - expect(res.body.code).toBe('PAID_REQUIRED'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.code).not.toBe('PAID_REQUIRED'); }); }); diff --git a/backend/src/__tests__/blueprints-community-tier.test.ts b/backend/src/__tests__/blueprints-community-tier.test.ts new file mode 100644 index 00000000..79b0e979 --- /dev/null +++ b/backend/src/__tests__/blueprints-community-tier.test.ts @@ -0,0 +1,171 @@ +/** + * Confirms core Blueprint CRUD, reconciliation, and Federation pin routes are + * reachable on the Community tier. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let adminCookie: string; +let viewerAuthHeader: string; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let BlueprintReconciler: typeof import('../services/BlueprintReconciler').BlueprintReconciler; +let counter = 0; + +function mockTier(tier: 'paid' | 'community') { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier); +} + +function seedNode(): { id: number; name: string } { + counter += 1; + const name = `bp-community-node-${counter}`; + const db = DatabaseService.getInstance().getDb(); + const result = db.prepare( + `INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at) + VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`, + ).run(name, Date.now()); + return { id: result.lastInsertRowid as number, name }; +} + +function validBlueprintBody(nodeId: number) { + return { + name: `bp-community-${counter + 1}`, + compose_content: 'services:\n app:\n image: nginx\n', + selector: { type: 'nodes', ids: [nodeId] }, + drift_mode: 'enforce', + }; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ LicenseService } = await import('../services/LicenseService')); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ BlueprintReconciler } = await import('../services/BlueprintReconciler')); + + DatabaseService.getInstance().addUser({ username: 'bp-community-viewer', password_hash: 'hash', role: 'viewer' }); + const viewerToken = jwt.sign({ username: 'bp-community-viewer' }, TEST_JWT_SECRET, { expiresIn: '1h' }); + viewerAuthHeader = `Bearer ${viewerToken}`; + + adminCookie = await loginAsTestAdmin(app); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + vi.restoreAllMocks(); + mockTier('community'); + vi.spyOn(BlueprintReconciler.getInstance(), 'reconcileOne').mockResolvedValue(undefined); + const db = DatabaseService.getInstance().getDb(); + db.prepare('DELETE FROM blueprint_deployments').run(); + db.prepare('DELETE FROM blueprints').run(); + db.prepare('DELETE FROM nodes WHERE is_default = 0').run(); +}); + +describe('Blueprints on Community tier', () => { + it('GET /api/blueprints returns 200 for an admin', async () => { + const res = await request(app).get('/api/blueprints').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.code).not.toBe('PAID_REQUIRED'); + }); + + it('POST /api/blueprints creates a blueprint for an admin', async () => { + const node = seedNode(); + counter += 1; + const res = await request(app) + .post('/api/blueprints') + .set('Cookie', adminCookie) + .send(validBlueprintBody(node.id)); + expect(res.status).toBe(201); + expect(res.body.drift_mode).toBe('enforce'); + }); + + it('GET /api/blueprints/:id returns detail for a viewer', async () => { + const node = seedNode(); + counter += 1; + const created = await request(app) + .post('/api/blueprints') + .set('Cookie', adminCookie) + .send(validBlueprintBody(node.id)); + expect(created.status).toBe(201); + + const res = await request(app) + .get(`/api/blueprints/${created.body.id}`) + .set('Authorization', viewerAuthHeader); + expect(res.status).toBe(200); + expect(res.body.blueprint.id).toBe(created.body.id); + }); + + it('POST /api/blueprints/:id/apply triggers reconciliation for an admin', async () => { + const node = seedNode(); + counter += 1; + const created = await request(app) + .post('/api/blueprints') + .set('Cookie', adminCookie) + .send(validBlueprintBody(node.id)); + expect(created.status).toBe(201); + + const res = await request(app) + .post(`/api/blueprints/${created.body.id}/apply`) + .set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.code).not.toBe('PAID_REQUIRED'); + }); + + it('rejects blueprint mutations for a viewer with ADMIN_REQUIRED', async () => { + const res = await request(app) + .post('/api/blueprints') + .set('Authorization', viewerAuthHeader) + .send({}); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + }); + + it('lets a Community viewer list blueprints', async () => { + const res = await request(app).get('/api/blueprints').set('Authorization', viewerAuthHeader); + expect(res.status).toBe(200); + expect(res.body.code).not.toBe('PAID_REQUIRED'); + }); + + it('node_proxy with community tier header can reach apply-local (not PAID_REQUIRED)', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const res = await request(app) + .post('/api/blueprints/apply-local') + .set('Authorization', `Bearer ${token}`) + .set('x-sencho-tier', 'community') + .send({}); + expect(res.status).not.toBe(403); + expect(res.body.code).not.toBe('PAID_REQUIRED'); + }); + + it('node_proxy with community tier header can list blueprints', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const res = await request(app) + .get('/api/blueprints') + .set('Authorization', `Bearer ${token}`) + .set('x-sencho-tier', 'community'); + expect(res.status).toBe(200); + expect(res.body.code).not.toBe('PAID_REQUIRED'); + }); + + it('PUT /api/blueprints/:id/pin succeeds for a Community admin', async () => { + const node = seedNode(); + counter += 1; + const created = await request(app) + .post('/api/blueprints') + .set('Cookie', adminCookie) + .send(validBlueprintBody(node.id)); + expect(created.status).toBe(201); + + const res = await request(app) + .put(`/api/blueprints/${created.body.id}/pin`) + .set('Cookie', adminCookie) + .send({ nodeId: node.id }); + expect(res.status).toBe(200); + expect(res.body.pinned_node_id).toBe(node.id); + }); +}); diff --git a/backend/src/__tests__/nodes-cordon-authz.test.ts b/backend/src/__tests__/nodes-cordon-authz.test.ts index bf191389..3d926f7c 100644 --- a/backend/src/__tests__/nodes-cordon-authz.test.ts +++ b/backend/src/__tests__/nodes-cordon-authz.test.ts @@ -4,10 +4,10 @@ * * These guard the same boundary the NodeCard cordon control renders against, so * a UI gate and a route guard cannot silently drift apart. Cordon/uncordon - * require the paid tier AND the node:manage permission (held by admin and + * require the node:manage permission on every tier (held by admin and * node-admin roles). The guard order is: * rejectApiTokenScope (SCOPE_DENIED) -> requirePermission (PERMISSION_DENIED) - * -> requirePaid (PAID_REQUIRED) -> invalid-id 400 + * -> invalid-id 400 * -> reason 400 (cordon only) -> 404. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; @@ -115,15 +115,15 @@ describe('POST /api/nodes/:id/cordon authorization', () => { }, ); - it('rejects an admin on a Community license with PAID_REQUIRED', async () => { + it('lets a Community admin cordon a node', async () => { setLicense('community'); const node = seedNode(); const res = await request(app) .post(`/api/nodes/${node.id}/cordon`) .set('Cookie', adminCookie) .send({}); - expect(res.status).toBe(403); - expect(res.body.code).toBe('PAID_REQUIRED'); + expect(res.status).toBe(200); + expect(res.body.cordoned).toBe(true); }); it('rejects a full-admin API token with SCOPE_DENIED (API tokens cannot manage nodes)', async () => { @@ -190,7 +190,7 @@ describe('POST /api/nodes/:id/cordon authorization', () => { expect(res.body.cordoned_reason).toBeNull(); }); - it('checks node:manage before the tier gate (Community viewer gets PERMISSION_DENIED, not PAID_REQUIRED)', async () => { + it('checks node:manage before route validation (Community viewer gets PERMISSION_DENIED, not PAID_REQUIRED)', async () => { setLicense('community'); const node = seedNode(); const res = await request(app) @@ -243,15 +243,15 @@ describe('POST /api/nodes/:id/uncordon authorization', () => { }, ); - it('rejects an admin on a Community license with PAID_REQUIRED', async () => { + it('lets a Community admin uncordon a node', async () => { setLicense('community'); const node = seedCordonedNode(); const res = await request(app) .post(`/api/nodes/${node.id}/uncordon`) .set('Cookie', adminCookie) .send({}); - expect(res.status).toBe(403); - expect(res.body.code).toBe('PAID_REQUIRED'); + expect(res.status).toBe(200); + expect(res.body.cordoned).toBe(false); }); it('rejects a full-admin API token with SCOPE_DENIED', async () => { diff --git a/backend/src/routes/blueprints.ts b/backend/src/routes/blueprints.ts index c8d42b5b..1c48a475 100644 --- a/backend/src/routes/blueprints.ts +++ b/backend/src/routes/blueprints.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { authMiddleware } from '../middleware/auth'; -import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates'; +import { requireAdmin, requireBody } from '../middleware/tierGates'; import { requirePermission } from '../middleware/permissions'; import { DatabaseService, @@ -114,7 +114,6 @@ function summarizeBlueprint(blueprintId: number) { } blueprintsRouter.get('/', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; try { const blueprints = DatabaseService.getInstance().listBlueprints(); const summaries = blueprints.map(b => { @@ -131,7 +130,6 @@ blueprintsRouter.get('/', (req: Request, res: Response): void => { }); blueprintsRouter.post('/', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const body = req.body as BlueprintBody; @@ -171,7 +169,6 @@ blueprintsRouter.post('/', (req: Request, res: Response): void => { }); blueprintsRouter.get('/:id', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { @@ -185,7 +182,6 @@ blueprintsRouter.get('/:id', (req: Request, res: Response): void => { }); blueprintsRouter.put('/:id', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const id = parseIntParam(req, res, 'id'); @@ -258,7 +254,6 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => { }); blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; @@ -314,11 +309,10 @@ blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise => { - if (!requirePaid(req, res)) return; const body = (req.body ?? {}) as { stackName?: unknown; composeContent?: unknown; markerContent?: unknown }; if (typeof body.stackName !== 'string' || !isValidStackName(body.stackName)) { res.status(400).json({ error: 'Invalid stack name' }); @@ -359,7 +353,6 @@ blueprintsRouter.post('/apply-local', async (req: Request, res: Response): Promi }); blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; @@ -379,7 +372,6 @@ blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise }); blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Response): Promise => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; @@ -457,7 +449,6 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons }); blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response): Promise => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; @@ -487,7 +478,6 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response) }); blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; const id = parseIntParam(req, res, 'id'); if (id === null) return; try { @@ -517,7 +507,6 @@ blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => { }); blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; const id = parseIntParam(req, res, 'id'); @@ -558,7 +547,6 @@ blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise { - if (!requirePaid(req, res)) return; if (!requireBody(req, res)) return; const composeContent = typeof req.body?.compose_content === 'string' ? req.body.compose_content : ''; if (!composeContent.trim()) { diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index 9ba7d245..bf0cb95a 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -393,7 +393,6 @@ nodesRouter.post('/:id/cordon', (req: Request, res: Response) => { if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return; const nodeIdParam = req.params.id as string; if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return; - if (!requirePaid(req, res)) return; if (!/^[1-9]\d*$/.test(nodeIdParam)) { res.status(400).json({ error: 'Invalid node id' }); return; @@ -432,7 +431,6 @@ nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => { if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return; const nodeIdParam = req.params.id as string; if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return; - if (!requirePaid(req, res)) return; if (!/^[1-9]\d*$/.test(nodeIdParam)) { res.status(400).json({ error: 'Invalid node id' }); return; diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 1f4f72eb..7d2bfabd 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -148,10 +148,10 @@ Every alert Sencho dispatches carries a category that you can filter on in the b | `monitor_alert` | Monitor alert | Per-stack threshold breach, host CPU/RAM/disk warning, or healthcheck failure | | `scan_finding` | Scan finding | Vulnerability-scan completion, per-violation alert, post-deploy scan failure, or auto-update gate block | | `system` | System | Sencho version, Trivy auto-update, fleet sync, daemon connectivity, scheduled-task lifecycle, cloud-backup upload failure | -| `blueprint_deployed` | `blueprint_deployed` | Blueprint provisioned a new deployment (Admiral) | -| `blueprint_deployment_failed` | `blueprint_deployment_failed` | Blueprint deployment errored out (Admiral) | -| `blueprint_drift_detected` | `blueprint_drift_detected` | Blueprint drift detected in `suggest` or `enforce` mode (Admiral) | -| `blueprint_drift_correction_failed` | `blueprint_drift_correction_failed` | Blueprint enforce-mode redeploy failed (Admiral) | +| `blueprint_deployed` | `blueprint_deployed` | Blueprint provisioned a new deployment | +| `blueprint_deployment_failed` | `blueprint_deployment_failed` | Blueprint deployment errored out | +| `blueprint_drift_detected` | `blueprint_drift_detected` | Blueprint drift detected in `suggest` or `enforce` mode | +| `blueprint_drift_correction_failed` | `blueprint_drift_correction_failed` | Blueprint enforce-mode redeploy failed | The four `blueprint_*` categories are accepted by routing rules but render as raw category strings in the bell because the frontend label map omits them. @@ -364,7 +364,7 @@ See [Vulnerability scanning](/features/vulnerability-scanning). `warning`/`system`: `Cloud backup failed for scheduled snapshot : `. See [Fleet backups](/features/fleet-backups). -### Blueprints (Admiral) +### Blueprints - `warning`/`blueprint_drift_detected`: `Blueprint "" drifted on node "": ` (suggest mode), with stateful-safeguard variants when enforce mode declines to redeploy. - `error`/`blueprint_drift_correction_failed`: `Auto-fix for "" on node "" failed: `. diff --git a/docs/features/blueprint-model.mdx b/docs/features/blueprint-model.mdx index 185a867a..1d2cbb0e 100644 --- a/docs/features/blueprint-model.mdx +++ b/docs/features/blueprint-model.mdx @@ -8,7 +8,7 @@ A **Blueprint** bundles a `docker-compose.yml` with a node selector and a drift Blueprints live under **Fleet · Deployments**. - Blueprints require a Sencho **Admiral** license. Creating, editing, withdrawing, and pinning blueprints requires an admin role; operators and viewers can read the catalog and the detail sheet. + Blueprints are available on every tier as Sencho's Compose-first orchestration model. Creating, editing, withdrawing, and applying blueprints requires an admin role; operators and viewers can read the catalog and the detail sheet. Pinning a blueprint to a specific node lives under **Fleet · Federation** on every tier (admin role required). @@ -37,7 +37,7 @@ Drift detection runs on every tick for every Active deployment regardless of pol **Stateful safety rails.** Stateful and state-unknown blueprints enter **Awaiting confirmation** on every fresh node before the first deploy, and **Evict blocked** when a node falls out of the selector while still hosting a stateful deployment. The reconciler never deploys empty volumes or destroys named volumes without a human acknowledging the action. -**Pin override and cordon respect.** Admiral users can pin a blueprint to a single node from **Fleet · Federation**. A pin replaces the selector entirely, deploys only to the pinned node, and overrides the cordon flag on that node. Cordoning a node otherwise prevents the reconciler from picking it for new placements; existing deployments on a cordoned node keep running and stay drift-checked. +**Pin override and cordon respect.** Admins can pin a blueprint to a single node from **Fleet · Federation**. A pin replaces the selector entirely, deploys only to the pinned node, and overrides the cordon flag on that node. Cordoning a node otherwise prevents the reconciler from picking it for new placements; existing deployments on a cordoned node keep running and stay drift-checked. **Vulnerability-policy participation.** Local blueprint deploys evaluate against the same pre-deploy policy gate that the per-stack deploy lane uses. If an enabled policy blocks one of the blueprint's image references, the deployment row moves to **Failed** and the stack is never written to disk. Remote blueprint deploys are routed through the remote node's stack deploy endpoint, so policy enforcement runs on the remote instance with that node's credentials and scanner state. @@ -47,8 +47,8 @@ Drift detection runs on every tick for every Active deployment regardless of pol | Requirement | Detail | |---|---| -| License tier | **Admiral** to read, create, edit, withdraw, and pin blueprints. | -| User role | **Admin** to create, edit, withdraw, accept, and pin. Operators and viewers can read the catalog and the detail sheet. | +| License tier | **Community** to read, create, edit, withdraw, apply, and pin blueprints. | +| User role | **Admin** to create, edit, withdraw, accept, and apply. Operators and viewers can read the catalog and the detail sheet. Pinning requires admin. | | Nodes | At least one node that the selector resolves to. Remote nodes need a healthy proxy connection; see [Multi-node management](/features/multi-node) and [Pilot Agent](/features/pilot-agent) for enrollment. | | Compose YAML | Valid `docker-compose.yml`, 96 KiB or fewer. | | Blueprint name | 1 to 64 characters matching `^[a-z0-9][a-z0-9_-]*$`. The name doubles as the stack directory on every targeted node and is immutable after creation. | @@ -228,7 +228,7 @@ Stateless blueprints withdraw all deployments and then delete in a single click. ## Federation: pin a blueprint to a single node -Admiral users can pin a blueprint to a specific node from **Fleet · Federation**. A pinned blueprint deploys only to its pinned node, regardless of the configured selector, and overrides the cordon flag on that node. The Blueprint detail sheet shows a read-only `Pin` section when a pin is in place; pin management itself lives in the Federation tab. +Admins can pin a blueprint to a specific node from **Fleet · Federation**. A pinned blueprint deploys only to its pinned node, regardless of the configured selector, and overrides the cordon flag on that node. The Blueprint detail sheet shows a read-only `Pin` section when a pin is in place; pin management itself lives in the Federation tab. Federation tab pin policy table with one blueprint pinned and one unpinned @@ -251,7 +251,7 @@ Both events route through the standard alert pipeline. Configure delivery channe ## Security and trust boundaries -**Who can do what.** The license tier and the user role together determine the available actions. Reading the catalog, the detail sheet, and the deployment status requires Admiral. Creating, editing, withdrawing, accepting a stateful deploy, applying on demand, and pinning a blueprint require the admin role on top of the Admiral tier. +**Who can do what.** The license tier and the user role together determine the available actions. Reading the catalog, the detail sheet, and the deployment status is available on every tier. Creating, editing, withdrawing, accepting a stateful deploy, and applying on demand require the admin role. Pinning a blueprint requires the admin role. **The marker file is the trust root.** The reconciler will only deploy into, modify, or withdraw a directory that carries a `.blueprint.json` marker whose blueprint ID matches. A pre-existing directory with no marker, or a marker referencing a different blueprint, surfaces as **Name conflict** and is never modified. @@ -299,7 +299,7 @@ A future Volume Migration feature will automate this with app-aware backup tooli **Single-node managed Postgres.** A stateful `pg-fleet` blueprint with a `nodes` selector pointing at one database node and drift mode **Suggest**. The first deploy enters **Awaiting confirmation** so the operator chooses **Deploy fresh**. Subsequent compose changes (image bump, config change) re-enter **Awaiting confirmation** on the same node so the operator can decide whether the new revision is safe for the existing volume. Drift on the running container fires a `blueprint_drift_detected` notification but never auto-redeploys. -**Pin a blueprint to a specific node despite the selector.** Admiral users open **Fleet · Federation**, find the blueprint in the pin policy table, and pick the target node from the **Pinned to** dropdown. The pin overrides the selector for that blueprint, deploys only to the pinned node, and also overrides cordon on that node. Useful for relocating a stateful service to a specific host without rewriting the selector. Clear the pin to restore selector-driven placement. +**Pin a blueprint to a specific node despite the selector.** Open **Fleet · Federation**, find the blueprint in the pin policy table, and pick the target node from the **Pinned to** dropdown. The pin overrides the selector for that blueprint, deploys only to the pinned node, and also overrides cordon on that node. Useful for relocating a stateful service to a specific host without rewriting the selector. Clear the pin to restore selector-driven placement. **Observe-only audit blueprint.** A stateless monitoring stack (Vector, Promtail, a Prometheus exporter) with drift mode **Observe**. Drift is recorded silently in the deployment table; no notification fires and no auto-fix runs. Useful when you want Sencho to track placement and detect divergence on a low-signal stack without paging anyone. diff --git a/docs/features/fleet-federation.mdx b/docs/features/fleet-federation.mdx index ea330017..523c05a9 100644 --- a/docs/features/fleet-federation.mdx +++ b/docs/features/fleet-federation.mdx @@ -12,7 +12,7 @@ Federation lives under **Fleet → Federation**. -Federation is an Admiral feature. Cordon and pin actions require an admin user role. +Federation placement controls (cordon, uncordon, pin, and unpin) are available on every tier. Cordon and pin mutations require an admin user role, or the node-admin role for cordon when scoped to that node. ## Placement control, not placement automation @@ -32,7 +32,7 @@ In practice, Federation hands you four operator decisions: ### Cordon a node -Cordon marks a node as unschedulable. From the moment a node is cordoned, the reconciler skips it for new placements; existing stacks keep running, drift checks keep running, and revision bumps still redeploy in place. The cordon is visible to every tier (the read-only `Cordoned` pill on the node card) so non-Admiral operators understand why a node is not picking up new work. Uncordon to lift the restriction. +Cordon marks a node as unschedulable. From the moment a node is cordoned, the reconciler skips it for new placements; existing stacks keep running, drift checks keep running, and revision bumps still redeploy in place. The cordon is visible to every tier (the read-only `Cordoned` pill on the node card) so operators can see why a node is not picking up new work. Uncordon to lift the restriction. The cordon reason is free-form text (up to 256 characters). It surfaces in the Federation tab summary, in the cordon pill's tooltip on the node card, and on the audit log row for the action. @@ -50,8 +50,8 @@ Cordon, uncordon, and pin actions all flow through Sencho's standard audit log. | Requirement | Why it matters | |-------------|----------------| -| **Admiral license active on the control instance** | Federation enforcement, the tab itself, and the cordon and pin controls all gate on the control instance's tier. Remote nodes inherit Admiral through the [proxy's tier assertion](/features/multi-node#license-enforcement-across-nodes), so a paid control plane covers the whole fleet. | -| **Admin role for the active user** | Operator and viewer roles can read cordon state but cannot toggle it. Pin policy edits are also admin-only. | +| **Community or Admiral on the control instance** | Federation placement is part of the Compose-first control plane. Remote nodes inherit the control instance tier through the [proxy tier assertion](/features/multi-node#license-enforcement-across-nodes). | +| **Admin or node-admin role** | Pin policy edits require admin. Cordon and uncordon require `node:manage` (admin, or node-admin when scoped to that node). Operator and viewer roles can read cordon state but cannot toggle it. | | **At least one Blueprint defined** | The Pin policy table is empty until you create a blueprint under **Fleet → Deployments**. Cordon does not require any blueprints; it only suppresses *new* placements from blueprints that exist later. | | **Active connection to each remote node** | Cordon state is written to the control instance's local database, but it only takes effect once the reconciler runs against the fleet's current node set. A remote node that is `Offline` still carries its cordon flag and resumes honouring it as soon as it comes back. | @@ -117,7 +117,7 @@ The **Effective** column in the Pin policy table is computed live each time the ## Security and audit -Federation actions require both an Admiral license and an admin user role. The `Cordoned` pill on the node card stays visible at every tier as a read-only signal, so a non-Admiral operator can still understand why a node is skipping new work. +Federation mutations require an admin user role, or the node-admin role for cordon when scoped to that node. The `Cordoned` pill on the node card stays visible at every tier as a read-only signal, so operators without placement permissions can still see why a node is skipping new work. Every cordon, uncordon, and pin action is captured in the audit log. Each row carries the actor, the action (`node.cordon`, `node.uncordon`, `blueprint.pin`), the affected resource, and the cordon reason where applicable. Filter the **Audit** view by these action names for the full history. @@ -176,7 +176,7 @@ Pinning a stateful blueprint that is currently deployed on multiple nodes shrink The Fleet Overview grid polls every 30 seconds, so the `Cordoned` pill can lag the action by up to that long. The Federation tab summary and the audit log update immediately; if the audit log shows the cordon action but the badge is still missing, click **Refresh** on the Fleet page to force an immediate re-fetch. - Federation requires an Admiral license. The rest of the blueprint surface (catalog, deployments, drift) is available on lower tiers, and the read-only `Cordoned` pill on a node card is visible everywhere. If Federation is missing on an Admiral license, sign in as an admin user; cordon and pin actions are admin-only. + Federation is available on every tier under **Fleet → Federation**. Cordon and pin mutations require an admin user (or node-admin for cordon on nodes they manage). If the tab is missing, refresh the Fleet page; if controls are read-only, sign in as an admin. Federation reads from the same Blueprints registry that powers **Fleet → Deployments**. If the Deployments tab shows blueprints but Federation does not, refresh the Fleet page (the Pin policy table caches its source list when the tab mounts). If Deployments is also empty, no blueprint has been saved yet; create one there first. @@ -199,6 +199,6 @@ Federation is one tab in a larger Fleet view, and it focuses on a narrow slice o | [Fleet Actions](/features/fleet-actions) | Bulk operations across labelled nodes (restart, stop). | Fleet Actions runs imperative operations on existing deployments; Federation steers declarative placement decisions. | | [Fleet Sync](/features/fleet-sync) | Push-only replication of security policies (scan policies, CVE suppressions) from a control instance to replica instances. | Fleet Sync replicates *security state*, not placement; the two features do not interact. | | [Blueprints](/features/blueprint-model) | The declarative deployment model whose reconciler Federation steers. | Required reading: without Blueprints, Federation has nothing to do. | -| [Licensing](/features/licensing) | The full tier matrix and what each tier unlocks. | The single source of truth for the Admiral requirement called out at the top of this page. | +| [Licensing](/features/licensing) | The full tier matrix and what each tier unlocks. | The single source of truth for which fleet capabilities require Admiral. | Federation is not Fleet Sync, not Mesh, and not the remote-node proxy. The cordon flag affects only declarative blueprint deployments, not manually deployed stacks. If you are looking for a way to take an entire node fully out of service (existing deployments included), see the deployment table's withdraw flow on each affected blueprint; cordon by itself is intentionally non-destructive. diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 159b58fa..d0976e16 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -38,7 +38,7 @@ A single rail summarises the state of every registered node so you can read the ### Tabs -The Fleet view is a tab strip. Five tab triggers are visible to every tier; the Deployments, Routing, Federation, and Secrets triggers only render when the active license unlocks them. A vertical separator after **Status** divides the per-node monitoring tabs from the fleet-wide orchestration tabs. +The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Deployments, and Actions. Snapshots appears for admins. Deployments is available on Community. Routing and Secrets render when the active license unlocks them. Federation is available on every tier. A vertical separator after **Status** divides the per-node monitoring tabs from the fleet-wide orchestration tabs. | Tab | Tier | What it does | |-----|------|--------------| @@ -46,9 +46,9 @@ The Fleet view is a tab strip. Five tab triggers are visible to every tier; the | **Snapshots** | Community | Snapshot every compose file across the fleet. See [Fleet-Wide Backups](/features/fleet-backups). | | **Status** | Community | One card per node summarising which automations and security features are configured. Covered below. | | **Map** | Community | A read-only map of how stacks, services, networks, volumes, and ports relate across the fleet, with anomaly flags. Covered below. | -| **Deployments** | Admiral | Blueprint deployments and reconciler state. See [Blueprints](/features/blueprint-model). | +| **Deployments** | Community | Blueprint deployments and reconciler state. See [Blueprints](/features/blueprint-model). | | **Routing** | Admiral | Cross-node service routing via Sencho Mesh. See [Sencho Mesh](/features/sencho-mesh). | -| **Federation** | Admiral | Cordon nodes and pin blueprints to specific hosts. See [Fleet Federation](/features/fleet-federation). | +| **Federation** | Community | Cordon nodes and pin blueprints to specific hosts. See [Fleet Federation](/features/fleet-federation). | | **Actions** | Community (admin role) | Fleet-wide bulk operations: stop stacks by label, bulk-assign labels, prune Docker resources. See [Fleet Actions](/features/fleet-actions). | | **Secrets** | Admiral | Encrypted env-var bundles you push to labeled nodes. See [Fleet Secrets](/features/fleet-secrets). | @@ -90,7 +90,7 @@ Every node renders as a card. The local node is pinned at the top of the grid wi | **Version badge** | The node's Sencho version in mono tabular numerals (e.g. `v0.76.3`). Hidden if the node cannot report a version. | | **Update available** badge | Warning pill shown when a newer Sencho release is published for this node. | | **Critical** badge | Destructive pill with a triangle icon, surfaced when the online node is above 90% CPU or 90% disk. | -| **Cordoned** badge | Warning pill with a Ban icon, surfaced when an Admiral has cordoned the node. The badge tooltip carries the cordon reason or the default *Unschedulable: new blueprint deployments skip this node*. See [Fleet Federation](/features/fleet-federation) for the full cordon and pin flow. | +| **Cordoned** badge | Warning pill with a Ban icon, surfaced when the node is cordoned. The badge tooltip carries the cordon reason or the default *Unschedulable: new blueprint deployments skip this node*. See [Fleet Federation](/features/fleet-federation) for the full cordon and pin flow. | | **Updating / Updated / Failed** badge | Update progress indicator, shown only while or just after an update flows through. Failed states surface inline retry and dismiss buttons and a cursor-following error tooltip. | | **Container stats grid** | Three cells: **Running** (active containers), **Stopped** (exited containers), **Stacks** (count, or `-` if the node has not reported). Hidden on offline nodes. | | **CPU / RAM / Disk bars** | Each row shows the metric icon, the percent (CPU) or `used / total` (RAM, Disk), and a horizontal bar that tints amber at 60%, destructive at 80% (CPU/RAM), or amber at 75% / destructive at 90% (Disk). Hidden on offline nodes. | @@ -107,9 +107,9 @@ Every card carries a three-dot **Node actions** kebab in the top-right corner. T |--------|-------| | **Edit node** | Opens the Edit dialog prefilled with the node's connection details. For proxy-mode remotes, saving with a changed API URL or token re-runs the connection test automatically. | | **Delete node** | Opens a destructive confirmation. The local (default) node has no Delete option. Deleting a remote only removes it from this console; the remote instance and its containers are untouched. | -| **Cordon node** / **Uncordon node** | Marks the node unschedulable so new blueprint deployments skip it (Admiral). Existing deployments keep running. | +| **Cordon node** / **Uncordon node** | Marks the node unschedulable so new blueprint deployments skip it. Existing deployments keep running. Requires the `node:manage` permission (admin, or node-admin when scoped to that node). | -The menu is admin-only. Non-admin users see no kebab on the card. +Edit and delete remain admin-only. Users without `node:manage` and without edit/delete affordances see no kebab on the card. ### Topology view diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 2af62443..1278bcf0 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -26,6 +26,8 @@ See [the pricing page](https://sencho.io/pricing) for current pricing. - Real-time container stats, global logs, the interactive network topology graph (Hub, Grouped, and Free layouts), node labels, and stack labels - Git sources for compose stacks - Multi-node management in both Proxy and Pilot Agent modes +- Blueprints: fleet-wide compose templates with drift detection (Observe, Suggest, and Enforce), reconciliation, and pin-to-node placement +- Fleet Federation: cordon and uncordon nodes and pin blueprints to specific hosts - Fleet View with search, sort, filter, and node-card drill-down - Manual and scheduled fleet snapshots (create, browse, restore, delete) and Remote OTA node updates (per-node and **Update all**) - Actions tab (stop stacks fleet-wide by label, assign a label to stacks across nodes, prune Docker resources fleet-wide; admin role required), plus fleet-wide bulk Sencho restart @@ -48,7 +50,7 @@ See [the pricing page](https://sencho.io/pricing) for current pricing. - **Governance:** advanced RBAC roles (Deployer, Node Admin, Auditor), scoped permissions per stack or node, and audit log export (CSV, JSON), anomaly detection, and configurable retention beyond the recent window - **Security:** Fleet Secrets, AWS ECR registry credentials, and LDAP / Active Directory authentication -- **Fleet operations:** node cordon, Blueprints, and Sencho Mesh (cross-node container networking) +- **Fleet operations:** Sencho Mesh (cross-node container networking) - **Managed continuity:** Sencho Cloud Backup (a managed, off-site snapshot allowance) - **Operator access:** the Host Console (a browser-based terminal on the Sencho host) diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 8c49ec47..9f0e31c6 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -91,7 +91,7 @@ Browse pre-configured application templates. Filter by category (Media, Automati ### Blueprints -Fleet-wide compose templates that Sencho keeps in sync across the nodes you choose. One declaration covers many nodes via label selectors, drift detection always runs, and stateful blueprints get a confirmation prompt before first deploy and before eviction. Admiral. [Learn more →](/features/blueprint-model) +Fleet-wide compose templates that Sencho keeps in sync across the nodes you choose. One declaration covers many nodes via label selectors, drift detection always runs, and stateful blueprints get a confirmation prompt before first deploy and before eviction. [Learn more →](/features/blueprint-model) ## Resources @@ -149,7 +149,7 @@ Export your entire homelab as a single Markdown archive. The **Export Dossier** ### Fleet Federation -Operator-driven placement controls for fleets running Blueprints. Cordon a node to mark it unschedulable for new work, or pin a blueprint to a specific node to override selector matches. Cordon affects new placements only; existing deployments keep running. Admiral. [Learn more →](/features/fleet-federation) +Operator-driven placement controls for fleets running Blueprints. Cordon a node to mark it unschedulable for new work, or pin a blueprint to a specific node to override selector matches. Cordon affects new placements only; existing deployments keep running. [Learn more →](/features/fleet-federation) ### Fleet Actions @@ -243,4 +243,4 @@ Personalize Sencho's look: choose a theme (Dim, OLED, Light, or Auto) and one of ### Licensing & billing -Community is the complete self-hosted control plane, free forever, including the full vulnerability-scanning and deploy-enforcement suite. Admiral adds governance and fleet control for teams: advanced RBAC, audit log export and retention, Fleet Secrets, Blueprints, Sencho Mesh, and more. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing) +Community is the complete self-hosted control plane, free forever, including the full vulnerability-scanning and deploy-enforcement suite. Admiral adds governance and fleet control for teams: advanced RBAC, audit log export and retention, Fleet Secrets, Sencho Mesh, and more. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing) diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index 82f773ce..4c01e813 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -64,7 +64,7 @@ The **Fleet** view is the multi-node command center. The masthead summarizes onl The Fleet toolbar includes **Check Updates**, **Refresh**, and **Add node** for admins. The **Overview** tab supports search, sort, status filters, label filters, and a Grid or Topology view. Node cards show online state, resource use, container counts, version state, update actions, and direct drill-down into stacks on that node. -Beyond **Overview**, Fleet provides tabs for **Snapshots**, node **Status**, a dependency **Map**, blueprint **Deployments**, mesh **Routing**, **Federation**, fleet **Actions**, and **Secrets**. Some fleet tabs require Admiral. See [Licensing](/features/licensing) for the full tier breakdown. +Beyond **Overview**, Fleet provides tabs for **Snapshots**, node **Status**, a dependency **Map**, blueprint **Deployments**, mesh **Routing**, **Federation**, fleet **Actions**, and **Secrets**. Routing and Secrets require Admiral. Federation placement (cordon and pin) is available on every tier. See [Licensing](/features/licensing) for the full tier breakdown. ## Resources, templates, and logs diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index deed0276..b065017c 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -144,13 +144,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR )} - {isPaid && ( - + Deployments - )} {isPaid && ( @@ -158,13 +156,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR )} - {isPaid && ( - + Federation - )} Actions @@ -261,11 +257,9 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR )} - {isPaid && ( - + - )} {isPaid && ( @@ -273,13 +267,9 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR )} - {isPaid && ( - - - - - - )} + + + {/* Fleet Actions runs against the whole fleet, so it takes the unfiltered node list rather than the overview-filtered view. */} diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 8c95177f..8484c837 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -21,7 +21,6 @@ import { formatBytes } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { formatVersion } from '@/lib/version'; -import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; import { useNodes, type Node } from '@/context/NodeContext'; import { cordonNode, uncordonNode } from '@/lib/nodesApi'; @@ -71,16 +70,13 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u const [cordonReason, setCordonReason] = useState(''); const [cordonSubmitting, setCordonSubmitting] = useState(false); - const { isPaid } = useLicense(); const { isAdmin, can } = useAuth(); const { nodes: registryNodes } = useNodes(); const registryNode = registryNodes.find(n => n.id === node.id); const canEdit = Boolean(isAdmin && onEdit && registryNode); const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default); - // Cordon is a paid feature AND requires node:manage, matching the backend guard - // (requirePermission('node:manage','node',id) + requirePaid). Gating on tier - // alone would surface the control to deployer/viewer/auditor users whose calls 403. - const canCordon = isPaid && can('node:manage', 'node', String(node.id)); + // Cordon requires node:manage, matching the backend guard. + const canCordon = can('node:manage', 'node', String(node.id)); const nodeMuteActions = useNodeMuteActions( node.id, node.name, diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index c0d1b726..2f811daa 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -55,24 +55,23 @@ describe('NodeCard', () => { expect(screen.queryByText('Running')).not.toBeInTheDocument(); }); - it('hides the actions menu for a free-tier user', () => { + it('hides the actions menu when the user lacks node:manage and no edit/delete affordances', () => { + useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); useLicenseMock.mockReturnValue({ isPaid: false }); render(); expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument(); }); - it('exposes the actions menu (cordon entry point) for a paid admin', () => { - useLicenseMock.mockReturnValue({ isPaid: true }); + it('exposes the actions menu (cordon entry point) for a Community admin with node:manage', () => { + useLicenseMock.mockReturnValue({ isPaid: false }); render(); - // With no edit/delete affordances wired, the menu renders iff cordon is - // allowed: isPaid && can('node:manage'). The admin's can() returns true. expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument(); }); it('exposes the cordon control for a node-admin via the node:manage permission', async () => { const can = vi.fn((action: string) => action === 'node:manage'); useAuthMock.mockReturnValue({ isAdmin: false, can }); - useLicenseMock.mockReturnValue({ isPaid: true }); + useLicenseMock.mockReturnValue({ isPaid: false }); render(); await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); @@ -80,18 +79,18 @@ describe('NodeCard', () => { expect(can).toHaveBeenCalledWith('node:manage', 'node', '2'); }); - it('hides the cordon control from a paid user lacking node:manage', () => { + it('hides the cordon control from a user lacking node:manage', () => { useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); - useLicenseMock.mockReturnValue({ isPaid: true }); + useLicenseMock.mockReturnValue({ isPaid: false }); render(); - // The paid tier alone must not surface cordon to a deployer/viewer/auditor. + // node:manage alone must not surface cordon when false to a deployer/viewer/auditor. expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument(); }); it('shows Uncordon when the node is already cordoned', async () => { const can = vi.fn((action: string) => action === 'node:manage'); useAuthMock.mockReturnValue({ isAdmin: false, can }); - useLicenseMock.mockReturnValue({ isPaid: true }); + useLicenseMock.mockReturnValue({ isPaid: false }); render(); await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); @@ -104,13 +103,13 @@ describe('NodeCard', () => { }; it('renders the update button for an admin when an update is available', () => { - useAuthMock.mockReturnValue({ isAdmin: true }); + useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) }); render(); expect(screen.getByRole('button', { name: /Update/ })).toBeInTheDocument(); }); it('hides the update button for a non-admin but still shows the read-only badge', () => { - useAuthMock.mockReturnValue({ isAdmin: false }); + useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); render(); expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument(); expect(screen.getByText('Update available')).toBeInTheDocument(); diff --git a/frontend/src/components/blueprints/DeploymentsTab.tsx b/frontend/src/components/blueprints/DeploymentsTab.tsx index 22a07cfe..90582db1 100644 --- a/frontend/src/components/blueprints/DeploymentsTab.tsx +++ b/frontend/src/components/blueprints/DeploymentsTab.tsx @@ -16,13 +16,11 @@ import { BlueprintEmptyState } from './BlueprintEmptyState'; import { FleetTabHeading, FleetEmptyState } from '../fleet/FleetEmptyState'; import { BlueprintDetail } from './BlueprintDetail'; import { BlueprintEditor } from './BlueprintEditor'; -import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; export function DeploymentsTab() { - const { isPaid } = useLicense(); const { isAdmin } = useAuth(); - const canEdit = isPaid && isAdmin; + const canEdit = isAdmin; const [blueprints, setBlueprints] = useState([]); const [distinctLabels, setDistinctLabels] = useState([]); const [loading, setLoading] = useState(true); diff --git a/frontend/src/components/mobile/MobileFleet.tsx b/frontend/src/components/mobile/MobileFleet.tsx index e9992917..7d363dcc 100644 --- a/frontend/src/components/mobile/MobileFleet.tsx +++ b/frontend/src/components/mobile/MobileFleet.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { ChevronRight, Loader2 } from 'lucide-react'; import { apiFetch } from '@/lib/api'; -import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; import { cordonNode, uncordonNode } from '@/lib/nodesApi'; @@ -159,9 +158,8 @@ function NodeDetail({ onInspectStack: (nodeId: number, stackName: string) => void; onCordonChange: () => void; }) { - const { isPaid } = useLicense(); const { can } = useAuth(); - const canCordon = isPaid && can('node:manage', 'node', String(node.id)); + const canCordon = can('node:manage', 'node', String(node.id)); const [confirmOpen, setConfirmOpen] = useState(false); const [submitting, setSubmitting] = useState(false); From c677b8bb661e48d3f5ebdfd89112416a6ad3e2ac Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 5 Jul 2026 01:03:09 -0400 Subject: [PATCH 02/12] fix: fall back to enroll token when pilot tunnel JWT is rejected (#1566) * fix: fall back to enroll token when pilot tunnel JWT is rejected On HTTP 401/404 upgrade rejection, delete stale pilot.jwt and retry with SENCHO_ENROLL_TOKEN. * test: import pilot agent module after DATA_DIR is set in fallback test The auth-fallback test statically imported pilot/agent, which freezes its pilot.jwt path from DATA_DIR at module load, before setupTestDb redirects DATA_DIR to a writable temp dir. On the Linux CI runner the path resolved to a non-writable /app/data, so persistToken silently failed and the round-trip assertion read null. Import the module dynamically in beforeAll after setupTestDb, matching the sibling unit test. * fix: remove unexpected-response listener that blocked pilot reconnect The ws library skips abortHandshake when an unexpected-response listener exists, so error and close never fire and the agent hangs in CONNECTING. Detect auth rejection via the abortHandshake error message instead; the close handler already performs enroll-token fallback and reconnect. --- .../pilot-agent-auth-fallback-unit.test.ts | 129 +++++++++++++++ .../pilot-agent-auth-fallback.test.ts | 150 ++++++++++++++++++ .../__tests__/pilot-agent-fs-errors.test.ts | 28 +++- .../pilot-agent-loopback-auth.test.ts | 1 + backend/src/pilot/agent.ts | 61 ++++++- docs/features/pilot-agent.mdx | 10 +- 6 files changed, 368 insertions(+), 11 deletions(-) create mode 100644 backend/src/__tests__/pilot-agent-auth-fallback-unit.test.ts create mode 100644 backend/src/__tests__/pilot-agent-auth-fallback.test.ts diff --git a/backend/src/__tests__/pilot-agent-auth-fallback-unit.test.ts b/backend/src/__tests__/pilot-agent-auth-fallback-unit.test.ts new file mode 100644 index 00000000..00e826a5 --- /dev/null +++ b/backend/src/__tests__/pilot-agent-auth-fallback-unit.test.ts @@ -0,0 +1,129 @@ +/** + * Unit tests for PilotAgent auth fallback event handling using a stub WebSocket. + */ +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +const { wsInstances, mockAttachSwitchboard } = vi.hoisted(() => ({ + wsInstances: [] as Array<{ + emit: (event: string, ...args: unknown[]) => boolean; + readyState: number; + close: ReturnType; + }>, + mockAttachSwitchboard: vi.fn(() => ({ + handleJsonFrame: vi.fn(() => false), + handleBinaryFrame: vi.fn(() => false), + cleanup: vi.fn(), + tcpStreamCount: vi.fn(() => 0), + openReverseStream: vi.fn(() => null), + })), +})); + +vi.mock('../mesh/tcpStreamSwitchboard', () => ({ + attachTcpStreamSwitchboard: mockAttachSwitchboard, + resolveByComposeLabels: vi.fn(), +})); + +vi.mock('ws', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { EventEmitter } = require('events') as typeof import('events'); + class MockWebSocket extends EventEmitter { + readyState = 0; + close = vi.fn(); + constructor(..._args: unknown[]) { + super(); + wsInstances.push(this); + } + } + return { default: MockWebSocket }; +}); + +let tmpDir: string; +let PilotAgent: typeof import('../pilot/agent').PilotAgent; +let readPersistedToken: typeof import('../pilot/agent').readPersistedToken; +let persistToken: typeof import('../pilot/agent').persistToken; +let clearPersistedToken: typeof import('../pilot/agent').clearPersistedToken; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ PilotAgent, readPersistedToken, persistToken, clearPersistedToken } = await import('../pilot/agent')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +describe('PilotAgent auth fallback (stub WebSocket)', () => { + beforeEach(() => { + wsInstances.length = 0; + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => { /* swallow */ }); + vi.spyOn(console, 'warn').mockImplementation(() => { /* swallow */ }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + clearPersistedToken(); + }); + + it('swaps to the enroll token and clears pilot.jwt after HTTP 401 on upgrade', () => { + persistToken('stale-on-disk'); + const agent = new PilotAgent({ + primaryUrl: 'http://primary.invalid', + loopbackPort: 1, + initialToken: 'stale-token', + enrollToken: 'fresh-enroll-token', + enrolling: false, + }); + + (agent as unknown as { connect: () => void }).connect(); + + const firstWs = wsInstances[0]!; + // Real ws (no unexpected-response listener): abortHandshake emits error then close. + firstWs.emit('error', new Error('Unexpected server response: 401')); + firstWs.emit('close', 1006, Buffer.from('')); + + expect(readPersistedToken()).toBeNull(); + expect((agent as unknown as { token: string }).token).toBe('fresh-enroll-token'); + // scheduleReconnect doubles backoff after scheduling the imminent retry. + expect((agent as unknown as { backoff: number }).backoff).toBe(2_000); + }); + + it('swaps to the enroll token after HTTP 404 on upgrade', () => { + persistToken('stale-on-disk'); + const agent = new PilotAgent({ + primaryUrl: 'http://primary.invalid', + loopbackPort: 1, + initialToken: 'stale-token', + enrollToken: 'fresh-enroll-token', + enrolling: false, + }); + + (agent as unknown as { connect: () => void }).connect(); + + const firstWs = wsInstances[0]!; + firstWs.emit('error', new Error('Unexpected server response: 404')); + firstWs.emit('close', 1006, Buffer.from('')); + + expect(readPersistedToken()).toBeNull(); + expect((agent as unknown as { token: string }).token).toBe('fresh-enroll-token'); + }); + + it('does not swap when already connecting with the enroll token', () => { + const agent = new PilotAgent({ + primaryUrl: 'http://primary.invalid', + loopbackPort: 1, + initialToken: 'only-enroll-token', + enrollToken: 'only-enroll-token', + enrolling: true, + }); + + (agent as unknown as { connect: () => void }).connect(); + + const firstWs = wsInstances[0]!; + firstWs.emit('error', new Error('Unexpected server response: 401')); + firstWs.emit('close', 1006, Buffer.from('')); + + expect((agent as unknown as { token: string }).token).toBe('only-enroll-token'); + }); +}); diff --git a/backend/src/__tests__/pilot-agent-auth-fallback.test.ts b/backend/src/__tests__/pilot-agent-auth-fallback.test.ts new file mode 100644 index 00000000..ab1a0e69 --- /dev/null +++ b/backend/src/__tests__/pilot-agent-auth-fallback.test.ts @@ -0,0 +1,150 @@ +/** + * Regression tests for pilot agent auth fallback when a persisted tunnel + * token is rejected at WebSocket upgrade (401 invalid JWT, 404 unknown node). + * + * Without fallback the agent reconnects forever with the same stale + * pilot.jwt credential even when SENCHO_ENROLL_TOKEN carries a fresh + * enrollment JWT. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import http from 'http'; +import jwt from 'jsonwebtoken'; +import { WebSocketServer } from 'ws'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { attachUpgrade } from '../websocket/upgradeHandler'; +import { PilotTunnelManager } from '../services/PilotTunnelManager'; +import { WebSocket } from 'ws'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +// agent.ts freezes its pilot.jwt path from DATA_DIR at module load, so it must +// be imported only after setupTestDb() points DATA_DIR at the writable tmp dir. +let readPersistedToken: typeof import('../pilot/agent').readPersistedToken; +let persistToken: typeof import('../pilot/agent').persistToken; +let clearPersistedToken: typeof import('../pilot/agent').clearPersistedToken; + +let server: http.Server; +let port: number; +let pilotTunnelWss: WebSocketServer; +let mainWss: WebSocketServer; +let nodeId: number; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ readPersistedToken, persistToken, clearPersistedToken } = await import('../pilot/agent')); + + server = http.createServer(); + mainWss = new WebSocketServer({ noServer: true }); + pilotTunnelWss = new WebSocketServer({ noServer: true }); + attachUpgrade(server, { wss: mainWss, pilotTunnelWss }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') { + reject(new Error('listen returned unexpected address')); + return; + } + port = addr.port; + resolve(); + }); + }); + + nodeId = DatabaseService.getInstance().addNode({ + name: `pilot-auth-fallback-${Date.now()}`, + type: 'remote', + mode: 'pilot_agent', + compose_dir: '/tmp/x', + is_default: false, + api_url: '', + api_token: '', + }); +}); + +afterAll(async () => { + const mgr = PilotTunnelManager.getInstance(); + mgr.closeTunnel(nodeId); + mgr.removeAllListeners('tunnel-up'); + mgr.removeAllListeners('tunnel-down'); + pilotTunnelWss.close(); + mainWss.close(); + await new Promise((resolve) => server.close(() => resolve())); + cleanupTestDb(tmpDir); +}); + +afterEach(() => { + PilotTunnelManager.getInstance().closeTunnel(nodeId); + clearPersistedToken(); +}); + +function mintStaleTunnelTokenWrongSecret(): string { + return jwt.sign( + { scope: 'pilot_tunnel', nodeId }, + 'wrong-secret-not-the-control-instance', + { expiresIn: '365d' }, + ); +} + +function mintStaleTunnelTokenWrongNode(): string { + return jwt.sign( + { scope: 'pilot_tunnel', nodeId: 99_999_999 }, + TEST_JWT_SECRET, + { expiresIn: '365d' }, + ); +} + +describe('clearPersistedToken', () => { + it('removes an existing pilot.jwt file', () => { + persistToken('stale-token'); + expect(readPersistedToken()).toBe('stale-token'); + clearPersistedToken(); + expect(readPersistedToken()).toBeNull(); + }); + + it('does not throw when the file is already absent', () => { + clearPersistedToken(); + expect(() => clearPersistedToken()).not.toThrow(); + expect(readPersistedToken()).toBeNull(); + }); +}); + +describe('pilot tunnel upgrade rejection (in-process integration)', () => { + it('rejects a stale tunnel JWT signed with the wrong secret at upgrade', async () => { + const staleToken = mintStaleTunnelTokenWrongSecret(); + const ws = new WebSocket(`ws://127.0.0.1:${port}/api/pilot/tunnel`, { + headers: { + Authorization: `Bearer ${staleToken}`, + 'x-sencho-agent-version': 'auth-fallback-test/1.0', + }, + }); + const result = await new Promise<{ status?: number }>((resolve) => { + ws.on('unexpected-response', (_req, res) => { + resolve({ status: res.statusCode }); + res.destroy(); + }); + ws.on('error', () => { /* close follows */ }); + }); + expect(result.status).toBe(401); + }); + + it('rejects a tunnel JWT for an unknown node with HTTP 404 at upgrade', async () => { + const staleToken = mintStaleTunnelTokenWrongNode(); + const ws = new WebSocket(`ws://127.0.0.1:${port}/api/pilot/tunnel`, { + headers: { + Authorization: `Bearer ${staleToken}`, + 'x-sencho-agent-version': 'auth-fallback-test/1.0', + }, + }); + const result = await new Promise<{ status?: number }>((resolve) => { + ws.on('unexpected-response', (_req, res) => { + resolve({ status: res.statusCode }); + res.destroy(); + }); + ws.on('error', () => { /* close follows */ }); + }); + expect(result.status).toBe(404); + }); +}); diff --git a/backend/src/__tests__/pilot-agent-fs-errors.test.ts b/backend/src/__tests__/pilot-agent-fs-errors.test.ts index b832f9bd..172eb147 100644 --- a/backend/src/__tests__/pilot-agent-fs-errors.test.ts +++ b/backend/src/__tests__/pilot-agent-fs-errors.test.ts @@ -19,11 +19,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } = vi.hoisted(() => ({ +const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync, mockUnlinkSync } = vi.hoisted(() => ({ mockReadFileSync: vi.fn(), mockWriteFileSync: vi.fn(), mockExistsSync: vi.fn(), mockMkdirSync: vi.fn(), + mockUnlinkSync: vi.fn(), })); vi.mock('fs', () => { @@ -32,13 +33,14 @@ vi.mock('fs', () => { writeFileSync: mockWriteFileSync, existsSync: mockExistsSync, mkdirSync: mockMkdirSync, + unlinkSync: mockUnlinkSync, }; return { ...mock, default: mock }; }); // agent.ts is imported AFTER vi.mock so the mock is in place when the // module's top-level fs import resolves. -import { readPersistedToken, persistToken } from '../pilot/agent'; +import { readPersistedToken, persistToken, clearPersistedToken } from '../pilot/agent'; let errorSpy: ReturnType; let warnSpy: ReturnType; @@ -167,3 +169,25 @@ describe('persistToken', () => { expect(() => persistToken('test-token')).not.toThrow(); }); }); + +describe('clearPersistedToken', () => { + it('unlinks the token file on the happy path', () => { + clearPersistedToken(); + expect(mockUnlinkSync).toHaveBeenCalledWith(expect.stringContaining('pilot.jwt')); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn on ENOENT (file already absent)', () => { + mockUnlinkSync.mockImplementationOnce(() => { throw fsError('ENOENT', 'no such file'); }); + clearPersistedToken(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns on EACCES (read-only volume)', () => { + mockUnlinkSync.mockImplementationOnce(() => { throw fsError('EACCES', 'permission denied'); }); + clearPersistedToken(); + expect(warnSpy).toHaveBeenCalledOnce(); + expect(String(warnSpy.mock.calls[0][0])).toContain('Failed to remove persisted tunnel token'); + expect(String(warnSpy.mock.calls[0][0])).toContain('EACCES'); + }); +}); diff --git a/backend/src/__tests__/pilot-agent-loopback-auth.test.ts b/backend/src/__tests__/pilot-agent-loopback-auth.test.ts index 0b88120e..ed0ea08e 100644 --- a/backend/src/__tests__/pilot-agent-loopback-auth.test.ts +++ b/backend/src/__tests__/pilot-agent-loopback-auth.test.ts @@ -33,6 +33,7 @@ describe('PilotAgent loopback auth injection', () => { primaryUrl: 'http://primary.invalid', loopbackPort: 1, initialToken: 'irrelevant-for-this-test', + enrollToken: null, enrolling: false, }); mintHeader = (agent as unknown as { getLoopbackAuthHeader: () => string | null }).getLoopbackAuthHeader.bind(agent); diff --git a/backend/src/pilot/agent.ts b/backend/src/pilot/agent.ts index 4419ab4a..58c30a6a 100644 --- a/backend/src/pilot/agent.ts +++ b/backend/src/pilot/agent.ts @@ -61,6 +61,7 @@ export function startPilotAgent(loopbackPort: number): void { primaryUrl, loopbackPort, initialToken: persistedToken || enrollToken!, + enrollToken: enrollToken ?? null, enrolling: !persistedToken, }); // Register the agent as MeshService's reverse dialer so outbound @@ -80,13 +81,18 @@ interface AgentOptions { primaryUrl: string; loopbackPort: number; initialToken: string; + enrollToken: string | null; enrolling: boolean; } export class PilotAgent { private readonly options: AgentOptions; private token: string; + /** Fallback credential from SENCHO_ENROLL_TOKEN when the persisted token is rejected. */ + private readonly enrollToken: string | null; private backoff = RECONNECT_MIN_MS; + /** Set when the WS upgrade is rejected with 401 or 404 before the handshake completes. */ + private upgradeRejected = false; private ws: WebSocket | null = null; private pingTimer?: NodeJS.Timeout; private reconnectTimer?: NodeJS.Timeout; @@ -113,6 +119,7 @@ export class PilotAgent { constructor(options: AgentOptions) { this.options = options; this.token = options.initialToken; + this.enrollToken = options.enrollToken; this.agentVersion = getSenchoVersion() || '0.0.0'; this.customCa = readPilotCaBundle(); } @@ -170,6 +177,8 @@ export class PilotAgent { private connect(): void { if (this.shuttingDown) return; + this.upgradeRejected = false; + const wsUrl = httpUrlToWs(this.options.primaryUrl) + '/api/pilot/tunnel'; const ws = new WebSocket(wsUrl, { headers: { @@ -187,6 +196,22 @@ export class PilotAgent { ...(this.customCa ? { ca: this.customCa } : {}), }); this.ws = ws; + let opened = false; + let lastHandshakeError: string | null = null; + + // Do NOT register an 'unexpected-response' listener here. The ws library + // skips abortHandshake (and therefore never emits 'error' or 'close') + // when a listener exists for that event. Auth rejection is detected via + // the error message abortHandshake emits: "Unexpected server response: N". + ws.on('error', (err) => { + console.warn('[Pilot] Tunnel error:', err.message); + lastHandshakeError = err.message; + if (/Unexpected server response: (401|404)/.test(err.message)) { + this.upgradeRejected = true; + } + // 'close' will follow; reconnect is scheduled there. + }); + this.switchboard = attachTcpStreamSwitchboard({ ws, resolveTarget: resolveByComposeLabels, @@ -195,6 +220,7 @@ export class PilotAgent { }); ws.on('open', () => { + opened = true; // Backoff intentionally NOT reset here: a TCP-level connect that // immediately fails the protocol handshake (incompatible version, // bad token consumed at upgrade) would otherwise reset the @@ -222,12 +248,19 @@ export class PilotAgent { ws.on('close', (code, reason) => { console.log('[Pilot] Tunnel closed:', code, reason?.toString?.() ?? ''); this.cleanupAfterDisconnect(); + + const authRejected = this.upgradeRejected + || (!opened && lastHandshakeError != null && /Unexpected server response: (401|404)/.test(lastHandshakeError)); + if (authRejected && this.enrollToken && this.token !== this.enrollToken) { + clearPersistedToken(); + console.log('[Pilot] Persisted token rejected; falling back to enroll token'); + this.token = this.enrollToken; + this.backoff = RECONNECT_MIN_MS; + } + this.upgradeRejected = false; + this.scheduleReconnect(); }); - ws.on('error', (err) => { - console.warn('[Pilot] Tunnel error:', err.message); - // 'close' will follow; reconnect is scheduled there. - }); } private cleanupAfterDisconnect(): void { @@ -612,3 +645,23 @@ export function persistToken(token: string): void { } } +/** + * Remove the persisted long-lived tunnel token from disk. Used when the + * control instance rejects the stored credential at upgrade so the agent + * can fall back to SENCHO_ENROLL_TOKEN without re-poisoning on restart. + * ENOENT is normal when no file was written yet. + * + * Exposed for unit tests. + */ +export function clearPersistedToken(): void { + try { + fs.unlinkSync(TOKEN_PATH); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return; + console.warn( + `[Pilot] Failed to remove persisted tunnel token at ${sanitizeForLog(TOKEN_PATH)} (${sanitizeForLog(code ?? 'unknown')}: ${sanitizeForLog((err as Error).message)})`, + ); + } +} + diff --git a/docs/features/pilot-agent.mdx b/docs/features/pilot-agent.mdx index 3dbe1ced..45f729b7 100644 --- a/docs/features/pilot-agent.mdx +++ b/docs/features/pilot-agent.mdx @@ -151,7 +151,7 @@ Deploying through Compose also lets the control instance push over-the-air updat The agent boots, reads its configuration, and dials `wss:///api/pilot/tunnel` carrying the enrollment token in an `Authorization: Bearer` header. -The control instance verifies the token, marks the enrollment slot consumed, and replies with a `hello` frame followed by a control frame that carries a **long-lived tunnel JWT** (365-day expiry). The agent writes that token to its data volume at `/app/data/pilot.jwt`. The original enrollment token is now useless: the agent does not need it again, and the control instance refuses to accept it a second time. +The control instance verifies the token, marks the enrollment slot consumed, and replies with a `hello` frame followed by a control frame that carries a **long-lived tunnel JWT** (365-day expiry). The agent writes that token to its data volume at `/app/data/pilot.jwt`. The control instance will not accept the same enrollment token again, but leave `SENCHO_ENROLL_TOKEN` in the compose file: the agent uses it as a fallback if the persisted tunnel JWT is ever rejected at upgrade (for example after a secret rotation), clearing the stale file and re-enrolling automatically. The tunnel is now active. The Endpoint column in the Nodes table flips from `tunnel (waiting)` to `tunnel (seen Xs ago)` and the node's status badge turns Online. @@ -205,7 +205,7 @@ The agent dials `wss://`. Certificate validation is on by default against the sy ### What rotating the JWT secret does -The tunnel JWTs are signed with the control instance's `auth_jwt_secret`. If that secret rotates (because the control instance was rebuilt from scratch, restored into a different environment, or manually rotated), every existing tunnel JWT stops verifying. The agents will reconnect, fail authentication, and back off. Re-enroll each affected node (regenerate the enrollment token, then redeploy the agent on the remote with the refreshed compose file) to issue a new tunnel JWT signed by the current secret. +The tunnel JWTs are signed with the control instance's `auth_jwt_secret`. If that secret rotates (because the control instance was rebuilt from scratch, restored into a different environment, or manually rotated), every existing tunnel JWT stops verifying. Regenerate enrollment for each affected node, update the agent compose file with the fresh token, and restart the agent containers. When `SENCHO_ENROLL_TOKEN` is present in the running container, the agent clears the stale `pilot.jwt` and re-enrolls on its own; you do not need to delete the data volume by hand. ## Self-signed control-instance TLS certificates @@ -261,7 +261,7 @@ These environment variables are read by the **agent** container at boot. |---|---|---|---| | `SENCHO_MODE` | Yes | none | Must be `pilot`. Putting any other value here causes the container to start as a normal Sencho instance, not as an agent. | | `SENCHO_PRIMARY_URL` | Yes | none | The base URL of your control instance (e.g. `https://sencho.example.com`). The agent appends `/api/pilot/tunnel` and dials `wss://`. | -| `SENCHO_ENROLL_TOKEN` | First boot only | none | The 15-minute enrollment token issued by the control instance. Ignored on subsequent boots once `pilot.jwt` is on disk. | +| `SENCHO_ENROLL_TOKEN` | First boot; keep for recovery | none | The 15-minute enrollment token issued by the control instance. During normal operation the agent dials with `pilot.jwt` instead. If that persisted token is rejected at upgrade (for example after a secret rotation or node re-registration), the agent falls back to this value automatically when it is still present in the container environment, clears the stale file, and re-enrolls. Update it in compose after regenerating enrollment on the control instance. | | `SENCHO_PILOT_CA_FILE` | Optional | unset | Absolute path inside the container to a PEM bundle. Use when your control instance's TLS chain is rooted in a private CA. | | `DATA_DIR` | Optional | `/app/data` | Where the persisted `pilot.jwt` is stored. Override only if you are mounting a different volume layout. | | `COMPOSE_DIR` | Optional | `/app/compose` | Root directory where compose stack folders live. Enrollment sets it to the absolute path selected for the node and mounts that path identically on the host and in the agent. | @@ -281,7 +281,7 @@ These are the boundaries operators should know about before designing a fleet ar - **No mode conversion.** The Edit dialog shows a Mode field for an enrolled node, but switching a node between Pilot Agent and Distributed API Proxy after enrollment leaves the credentials and connection state inconsistent. To change modes, delete the node and re-create it in the desired mode. - **No audit log entries for enrollment lifecycle.** Node creation, enrollment regeneration, and node deletion do not write to the audit log today. This is on the roadmap. - **`pilot.jwt` is not cleaned up on node deletion.** When you delete a node from the control instance, the agent's persisted token stays on the remote's data volume. The agent will fail to reconnect on next restart, but the file persists. If you are repurposing the host, tear the agent down with `docker compose -f compose.yaml down -v` to remove the `sencho-agent-data` volume. -- **JWT-secret rotation invalidates every tunnel.** Rebuilding the control instance from scratch or rotating `auth_jwt_secret` requires re-enrolling every agent. There is no out-of-band re-issuance flow. +- **JWT-secret rotation invalidates existing tunnel JWTs.** Rebuilding the control instance from scratch or rotating `auth_jwt_secret` requires a fresh enrollment token on each agent. When that token is still in the container environment, the agent re-enrolls automatically without manual deletion of `pilot.jwt`. - **One tunnel per node.** Splitting a node's load across multiple control instances or running multiple agent containers against the same control instance for the same node is not supported. - **Mesh and pilot share the per-tunnel stream pool.** A node that runs heavy Sencho Mesh traffic counts those streams against the same 1024-stream cap as HTTP and WebSocket traffic. - **The agent has no UI of its own.** All operation flows through the control instance. The agent's container logs (`docker logs sencho-agent`) are the only direct visibility into agent-side behaviour. @@ -320,7 +320,7 @@ The generic node-connectivity issues (a node showing Offline, a pilot agent stuc - The persisted tunnel credential is signed with the control instance's `auth_jwt_secret`. If that secret was not part of the backup (or has been rotated for any other reason), existing tunnels stop verifying. Regenerate enrollment for each affected node from Settings → Nodes and restart the agent containers so they consume the fresh tokens. There is no global re-issuance command. + The persisted tunnel credential is signed with the control instance's `auth_jwt_secret`. If that secret was not part of the backup (or has been rotated for any other reason), existing tunnels stop verifying. Regenerate enrollment for each affected node from Settings → Nodes, update the agent compose file with the fresh `SENCHO_ENROLL_TOKEN`, and restart the agent containers. The agent removes the stale `pilot.jwt` and completes enrollment automatically when the new token is in the container environment. From bb35c1bc928862b6bd18a199dd478426b097a271 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 5 Jul 2026 02:52:17 -0400 Subject: [PATCH 03/12] feat: add sidebar update indicator toggle and Stack Health badge (#1570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add sidebar update indicator toggle and Stack Health badge - Add image_update_sidebar_indicators setting (default off, node-scoped) - Gate the Updates filter chip and sidebar status indicators on the setting - Add "Update available" badge to Stack Health table (always visible) - Extend ImageUpdateStatus with sidebarIndicators boolean - Poll /api/image-updates/status alongside /detail in useImageUpdates - React to SENCHO_SETTINGS_CHANGED for instant toggle propagation - Reset sidebar state on node switch; generation-guard stale responses - Disable toggle when status is null (loading) or field is absent (old node) - Wire stackUpdates through ViewRouter → HomeDashboard → StackHealthTable - Update settings registry, operator docs, and sidebar/dashboard docs * fix: guard against stale node renders, memo drift, and cross-node error toasts - Track owning node ID in useImageUpdates state so React never renders node B with node A's data before the passive effect resets (P2) - Replace incorrect stackUpdates dependency with sidebarStackUpdates in chipFilteredFiles useMemo (P3) - Guard the error toast in handleSidebarIndicatorsChange so a stale PATCH failure from node A does not surface while viewing node B (P3) * fix: default sidebar update indicators to on (opt-out) The sidebar indicators are a safe convenience that most users want. Switching the default from off to on matches the opt-out convention used by prune_on_update, reclaim_hero, and health_gate_enabled. --- backend/src/routes/settings.ts | 2 + backend/src/services/DatabaseService.ts | 1 + backend/src/services/ImageUpdateService.ts | 9 ++ docs/features/dashboard.mdx | 2 +- docs/features/sidebar.mdx | 2 +- docs/openapi.yaml | 3 + docs/reference/settings.mdx | 10 ++ frontend/src/components/EditorLayout.tsx | 6 +- .../components/EditorLayout/ViewRouter.tsx | 4 + .../EditorLayout/hooks/useStackListState.ts | 24 ++- frontend/src/components/HomeDashboard.tsx | 5 +- .../components/dashboard/StackHealthTable.tsx | 15 +- .../components/settings/UpdatesSection.tsx | 67 ++++++++- frontend/src/components/settings/registry.ts | 2 +- frontend/src/components/settings/types.ts | 2 + .../components/sidebar/SidebarFilterChips.tsx | 6 +- .../src/components/sidebar/StackSidebar.tsx | 3 + frontend/src/hooks/useImageUpdates.ts | 138 ++++++++++++++---- frontend/src/types/imageUpdates.ts | 2 + 19 files changed, 256 insertions(+), 47 deletions(-) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index e642975d..d4b83435 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -32,6 +32,7 @@ const ALLOWED_SETTING_KEYS = new Set([ 'health_gate_enabled', 'health_gate_window_seconds', 'env_block_deploy_on_missing_required', + 'image_update_sidebar_indicators', ]); // Keys whose write requires a paid license, not just an admin role. @@ -62,6 +63,7 @@ const SettingsPatchSchema = z.object({ health_gate_enabled: z.enum(['0', '1']), health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String), env_block_deploy_on_missing_required: z.enum(['0', '1']), + image_update_sidebar_indicators: z.enum(['0', '1']), }).partial(); export const settingsRouter = Router(); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index b09d810c..88346d3c 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1665,6 +1665,7 @@ export class DatabaseService { stmt.run('image_update_check_interval_minutes', '120'); stmt.run('image_update_check_mode', 'interval'); stmt.run('image_update_check_cron', ''); + stmt.run('image_update_sidebar_indicators', '1'); stmt.run('env_block_deploy_on_missing_required', '0'); // Seed the default local node if none exists diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 661744e7..d0d4257b 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -47,6 +47,7 @@ export interface ImageUpdateStatus { manualCooldownRemainingMs: number; mode: 'interval' | 'cron'; cronExpression: string | null; + sidebarIndicators: boolean; } // ─── Compose file helpers ──────────────────────────────────────────────────── @@ -390,6 +391,13 @@ export class ImageUpdateService { } public getStatus(): ImageUpdateStatus { + let sidebarIndicators = false; + try { + const settings = DatabaseService.getInstance().getGlobalSettings(); + sidebarIndicators = settings.image_update_sidebar_indicators === '1'; + } catch (e) { + console.warn('[ImageUpdateService] Failed to read sidebar indicator setting:', e); + } return { checking: this.isRunning, intervalMinutes: Math.round(this.intervalMs / (60 * 1000)), @@ -399,6 +407,7 @@ export class ImageUpdateService { manualCooldownRemainingMs: this.getManualCooldownRemainingMs(), mode: this.mode, cronExpression: this.cronExpression, + sidebarIndicators, }; } diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 0baa7984..ae6ffeb5 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -62,7 +62,7 @@ A mono table of every stack discovered in the active node's `COMPOSE_DIR`, sorte | Column | Description | |--------|-------------| | **Status dot** | Green when the stack is running and its 10-minute peak CPU is under 80%, amber when peak CPU is at or above 80%, rose when any container has exited or peak CPU is at or above 90% | -| **STACK** | Stack name, derived from the compose file (extension stripped) | +| **STACK** | Stack name with an orange "Update available" badge when a newer image has been detected. The badge appears regardless of the sidebar indicator setting. | | **HOST** | Active node this stack belongs to | | **UP** | How long the oldest running container has been up, in compact units (`s` / `m` / `h` / `d`); a stopped or never-started stack reads `--` | | **CPU** | Latest aggregate CPU across the stack's containers | diff --git a/docs/features/sidebar.mdx b/docs/features/sidebar.mdx index d552be3d..385c81ae 100644 --- a/docs/features/sidebar.mdx +++ b/docs/features/sidebar.mdx @@ -28,7 +28,7 @@ Four chips sit below the search box. Each shows a live count to the right of its - **All**: every stack on the node. - **Up**: stacks that are running with nothing crashed. A stack whose only stopped container finished cleanly (an init job that exited without error) still counts as up. - **Down**: stacks that need attention, whether fully stopped or running with at least one crashed container (the `PT` state described below). -- **Updates**: stacks with at least one image update available. The chip renders in orange when the count is non-zero so you can spot pending updates at a glance. +- **Updates**: stacks with at least one image update available. The chip renders in orange when the count is non-zero so you can spot pending updates at a glance. The Updates chip and the trailing update indicators on stack rows are controlled by the Image Update Checks [sidebar setting](/reference/settings#image-update-checks---sidebar). When the setting is off, the chip and indicators are hidden. Filter chip row showing ALL (15), Up (15), Down (0), and Updates (1) with the Updates chip highlighted in orange and a collapse toggle icon on the right diff --git a/docs/openapi.yaml b/docs/openapi.yaml index eb731ed9..ee280f22 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -686,6 +686,9 @@ components: cronExpression: type: ["string", "null"] description: 5-field cron expression when mode is 'cron', null otherwise. + sidebarIndicators: + type: boolean + description: Whether sidebar update-status indicators are enabled. Controlled by the `image_update_sidebar_indicators` global setting. Default is `false`. responses: Unauthorized: diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 362a15ac..6432e5bb 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -465,6 +465,16 @@ Configure how often this node polls container registries to detect available ima The section footer shows the last-checked timestamp and when the next check is scheduled. +### Sidebar + +| Setting | Default | Description | +|---------|---------|-------------| +| **Show update status in sidebar** | On | When on, the sidebar shows a pulsing dot on stacks with an available update, a warning icon when a check fails, and an Updates filter chip. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected. | + + + Nodes running older versions of Sencho do not expose this setting. Upgrade the node to enable the toggle. + + --- ## Webhooks diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 2e69e388..a2d30b4f 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -128,6 +128,8 @@ export default function EditorLayout() { toggleBulkMode, toggleSelect, clearSelection, handleBulkAction, stackUpdates, fetchImageUpdates, + sidebarIndicators, + sidebarStackUpdates, pinned, isCollapsed, toggleCollapse, remoteSearchLoading, @@ -669,7 +671,7 @@ export default function EditorLayout() { stackLabelMap, stackStatuses: stackStatuses as Record, stackCounts, - stackUpdates, + stackUpdates: sidebarStackUpdates, gitSourcePendingMap, pinnedFiles: pinned, isCollapsed, @@ -697,6 +699,7 @@ export default function EditorLayout() { onToggleSelect={toggleSelect} onClearSelection={clearSelection} onBulkAction={handleBulkAction} + showUpdatesChip={sidebarIndicators} /> ); @@ -777,6 +780,7 @@ export default function EditorLayout() { fleetTab={fleetTab} onFleetTabConsumed={() => setFleetTab(null)} renderEditor={renderEditor} + stackUpdates={stackUpdates} /> ); diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index 36bc0295..7979ab86 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -14,6 +14,7 @@ import type { NotificationItem } from '../dashboard/types'; import type { ScheduleTaskPrefill } from '../ScheduledOperationsView'; import type { MuteRuleDraft } from '@/lib/muteRules'; import type { ActiveView } from './hooks/useViewNavigationState'; +import type { StackUpdateInfo } from '@/types/imageUpdates'; import type { SecurityTab, FleetTab } from '@/lib/events'; // Paid-tier views are loaded on demand. Their internal PaidGate / @@ -99,6 +100,7 @@ export interface ViewRouterProps { // (large) editor JSX is only allocated when activeView === 'editor', // not on every parent render that lands on a different view. renderEditor: () => ReactNode; + stackUpdates: Record; } export function ViewRouter({ @@ -128,6 +130,7 @@ export function ViewRouter({ fleetTab, onFleetTabConsumed, renderEditor, + stackUpdates, }: ViewRouterProps): ReactNode { const { can } = useAuth(); if (activeView === 'settings') { @@ -251,6 +254,7 @@ export function ViewRouter({ onOpenSettingsSection={onOpenSettingsSection} notifications={notifications} onClearNotifications={onClearNotifications} + stackUpdates={stackUpdates} /> ); } diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.ts index 668d3931..db36f41b 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackListState.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackListState.ts @@ -8,6 +8,7 @@ import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse'; import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions'; import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch'; import { SENCHO_LABELS_CHANGED } from '@/lib/events'; +import type { StackUpdateInfo } from '@/types/imageUpdates'; import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards'; import type { StackAction, StackActionResult } from '../EditorView'; import type { Label as StackLabel } from '../../label-types'; @@ -60,6 +61,8 @@ export interface RemoteResult { files: Array<{ file: string; status: StackRowStatus }>; } +const EMPTY_UPDATES: Record = {}; + export function useStackListState() { const { nodes, activeNode } = useNodes(); @@ -96,7 +99,8 @@ export function useStackListState() { const [bulkMode, setBulkMode] = useState(false); const [selectedFiles, setSelectedFiles] = useState>(new Set()); - const { stackUpdates, refresh: fetchImageUpdates } = useImageUpdates(activeNode?.id); + const { stackUpdates, refresh: fetchImageUpdates, sidebarIndicators } = useImageUpdates(activeNode?.id); + const sidebarStackUpdates = sidebarIndicators ? stackUpdates : EMPTY_UPDATES; const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id); const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id); const { runBulk } = useBulkStackActions(); @@ -295,16 +299,16 @@ export function useStackListState() { all: filteredFiles.length, up: filteredFiles.filter(f => stackStatuses[f] === 'running').length, down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length, - updates: filteredFiles.filter(f => stackUpdates[f]?.hasUpdate).length, - }), [filteredFiles, stackStatuses, stackUpdates]); + updates: filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate).length, + }), [filteredFiles, stackStatuses, sidebarStackUpdates]); const chipFilteredFiles = useMemo(() => { if (filterChip === 'all') return filteredFiles; if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running'); if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f])); - if (filterChip === 'updates') return filteredFiles.filter(f => stackUpdates[f]?.hasUpdate); + if (filterChip === 'updates') return filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate); return filteredFiles; - }, [filteredFiles, filterChip, stackStatuses, stackUpdates]); + }, [filteredFiles, filterChip, stackStatuses, sidebarStackUpdates]); const toggleBulkMode = useCallback(() => { setBulkMode(prev => { @@ -381,6 +385,15 @@ export function useStackListState() { }); }, [remoteStackResults, nodes]); + // When the sidebar indicator toggle is turned off, reset an active Updates + // filter to 'all' so the user is not stuck in a filter that shows nothing. + useEffect(() => { + if (!sidebarIndicators && filterChip === 'updates') { + // eslint-disable-next-line react-hooks/set-state-in-effect + setFilterChip('all'); + } + }, [sidebarIndicators, filterChip]); + return { files, setFiles, filesNodeId, selectedFile, setSelectedFile, @@ -410,6 +423,7 @@ export function useStackListState() { scheduleStateInvalidateRefresh, toggleBulkMode, toggleSelect, clearSelection, handleBulkAction, stackUpdates, fetchImageUpdates, + sidebarIndicators, sidebarStackUpdates, pinned, pin, unpin, isPinned, isCollapsed, toggleCollapse, remoteSearchLoading, diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 06ef0446..60323cec 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -1,6 +1,7 @@ import { useNodes } from '@/context/NodeContext'; import type { NotificationItem } from './dashboard/types'; import type { SectionId } from './settings/types'; +import type { StackUpdateInfo } from '@/types/imageUpdates'; import { HealthStatusBar, ResourceGauges, @@ -16,11 +17,12 @@ interface HomeDashboardProps { onOpenSettingsSection?: (section: SectionId) => void; notifications: NotificationItem[]; onClearNotifications: () => void | Promise; + stackUpdates?: Record; } const NOOP = () => {}; -export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications }: HomeDashboardProps) { +export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications, stackUpdates = {} }: HomeDashboardProps) { const { activeNode, nodes } = useNodes(); const data = useDashboardData(); const activeNodeName = activeNode?.name || 'Local'; @@ -49,6 +51,7 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection metrics={data.metrics} stackCpuSeries={data.stackCpuSeries} onNavigateToStack={onNavigateToStack ?? NOOP} + stackUpdates={stackUpdates} />
diff --git a/frontend/src/components/dashboard/StackHealthTable.tsx b/frontend/src/components/dashboard/StackHealthTable.tsx index 5172b5cd..ce8f40c7 100644 --- a/frontend/src/components/dashboard/StackHealthTable.tsx +++ b/frontend/src/components/dashboard/StackHealthTable.tsx @@ -4,6 +4,7 @@ import { Sparkline } from '@/components/ui/sparkline'; import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types'; +import type { StackUpdateInfo } from '@/types/imageUpdates'; import { aggregateCurrentUsage } from './aggregateCurrentUsage'; import { classifyRow, type RowState } from './classifyRow'; @@ -12,6 +13,7 @@ interface StackHealthTableProps { metrics: MetricPoint[]; stackCpuSeries: Record; onNavigateToStack: (stackFile: string) => void; + stackUpdates?: Record; } type SortKey = 'stack' | 'up' | 'cpu' | 'mem'; @@ -90,6 +92,7 @@ export function StackHealthTable({ metrics, stackCpuSeries, onNavigateToStack, + stackUpdates = {}, }: StackHealthTableProps) { const [page, setPage] = useState(0); // null = the default health-state ordering (worst first); a SortKey switches @@ -127,9 +130,10 @@ export function StackHealthTable({ runningSince: entry.runningSince ?? null, source: entry.source ?? 'local', mainPort: entry.mainPort ?? null, + hasUpdate: stackUpdates[file]?.hasUpdate ?? false, }; }); - }, [stackStatuses, stackAggregates, stackCpuSeries]); + }, [stackStatuses, stackAggregates, stackCpuSeries, stackUpdates]); const rows = useMemo(() => { const list = [...baseRows]; @@ -246,7 +250,14 @@ export function StackHealthTable({ className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-[var(--density-row-x)] py-[var(--density-row-y)] transition-colors hover:bg-accent/5 ${rowTint[row.state]}`} >
+ + + + + + ); } diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 7cb82c13..ce2d7203 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -237,7 +237,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ group: 'automation', label: 'Image update checks', description: 'How often this node polls registries to detect available image updates and raise notifications.', - keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck'], + keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck', 'sidebar', 'badge', 'dot', 'indicator', 'status'], tier: null, scope: 'node', }, diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index 06fe6288..efbeba45 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -20,6 +20,7 @@ export interface PatchableSettings { health_gate_enabled?: '0' | '1'; health_gate_window_seconds?: string; env_block_deploy_on_missing_required?: '0' | '1'; + image_update_sidebar_indicators?: '0' | '1'; } export const DEFAULT_SETTINGS: PatchableSettings = { @@ -44,6 +45,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = { health_gate_enabled: '1', health_gate_window_seconds: '90', env_block_deploy_on_missing_required: '0', + image_update_sidebar_indicators: '1', }; export type SectionId = diff --git a/frontend/src/components/sidebar/SidebarFilterChips.tsx b/frontend/src/components/sidebar/SidebarFilterChips.tsx index 735f9484..22ea33cb 100644 --- a/frontend/src/components/sidebar/SidebarFilterChips.tsx +++ b/frontend/src/components/sidebar/SidebarFilterChips.tsx @@ -15,6 +15,7 @@ interface SidebarFilterChipsProps { onChange: (chip: FilterChip) => void; visible: boolean; onToggle: () => void; + showUpdatesChip?: boolean; } const chips: { id: FilterChip; label: string }[] = [ @@ -24,12 +25,13 @@ const chips: { id: FilterChip; label: string }[] = [ { id: 'updates', label: 'Updates' }, ]; -export function SidebarFilterChips({ active, counts, onChange, visible, onToggle }: SidebarFilterChipsProps) { +export function SidebarFilterChips({ active, counts, onChange, visible, onToggle, showUpdatesChip = true }: SidebarFilterChipsProps) { + const visibleChips = showUpdatesChip ? chips : chips.filter(c => c.id !== 'updates'); return (
{visible ? (
- {chips.map(({ id, label }) => { + {visibleChips.map(({ id, label }) => { const count = counts[id]; const displayCount = count > 99 ? '99+' : count; const isActive = active === id; diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx index 31ebcbf3..c5cc1d6f 100644 --- a/frontend/src/components/sidebar/StackSidebar.tsx +++ b/frontend/src/components/sidebar/StackSidebar.tsx @@ -33,6 +33,7 @@ export interface StackSidebarProps { onToggleSelect: (file: string) => void; onClearSelection: () => void; onBulkAction: (action: BulkAction) => void; + showUpdatesChip?: boolean; } export function StackSidebar(props: StackSidebarProps) { @@ -41,6 +42,7 @@ export function StackSidebar(props: StackSidebarProps) { searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange, list, activitySummary, onActivityAction, bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction, + showUpdatesChip = true, } = props; const [filtersVisible, setFiltersVisible] = useState(() => { @@ -84,6 +86,7 @@ export function StackSidebar(props: StackSidebarProps) { onChange={onFilterChipChange} visible={filtersVisible} onToggle={handleToggleFilters} + showUpdatesChip={showUpdatesChip} /> {selectedFiles.size > 0 && ( >({}); + const [sidebarIndicators, setSidebarIndicators] = useState(false); + + // Track which node owns the current state. When activeNodeId changes + // React renders once with the old owner before the passive effect clears + // the data. Returning empty defaults when the IDs mismatch prevents a + // single-frame flash of the wrong node's data. + const [ownerNodeId, setOwnerNodeId] = useState(activeNodeId); + + // Generation counter: every activeNodeId change increments it, and every + // await is gated against it so a slow response from a previous node is + // discarded. + const genRef = useRef(0); const refresh = useCallback(async () => { - try { - const res = await apiFetch('/image-updates/detail'); - if (res.ok) { - setStackUpdates(await res.json() as Record); - return; - } - // A remote node on an older Sencho lacks /detail; fall back to the boolean - // map so update badges keep working until that node is upgraded. - if (res.status === 404) { - const boolRes = await apiFetch('/image-updates'); - if (boolRes.ok) { - const bool = await boolRes.json() as Record; - const synthesized: Record = {}; - for (const [stack, hasUpdate] of Object.entries(bool)) { - synthesized[stack] = { hasUpdate, checkStatus: 'ok', lastError: null, checkedAt: 0 }; - } - setStackUpdates(synthesized); + const gen = ++genRef.current; + const targetNodeId = activeNodeId ?? null; + + // Self-contained status helper: owns fetch, parse, and state write. + // A failure here never blocks the detail path below. + const fetchStatus = async (): Promise => { + try { + const res = await apiFetch('/image-updates/status', { nodeId: targetNodeId }); + if (genRef.current !== gen) return; + if (res.ok) { + const data = await res.json() as ImageUpdateStatus; + if (genRef.current !== gen) return; + setSidebarIndicators(data.sidebarIndicators ?? false); } else { - console.error('[ImageUpdates] /detail 404 fallback to /image-updates failed:', boolRes.status); + console.error('[ImageUpdates] status fetch returned', res.status); } - return; + } catch (e) { + console.error('[ImageUpdates] status fetch failed:', e); } - // Any other non-ok (500, or a proxy 5xx from an unreachable remote): keep - // the last-known state on screen, but do not let the failure go silent. - console.error('[ImageUpdates] /image-updates/detail returned', res.status); - } catch (e: unknown) { - console.error('[ImageUpdates] fetch failed:', e); - } - }, []); + }; + + // Self-contained detail helper: owns fetch, parse, 404 fallback, and + // state write. A failure here never blocks the status path above. + const fetchDetail = async (): Promise => { + try { + const res = await apiFetch('/image-updates/detail', { nodeId: targetNodeId }); + if (genRef.current !== gen) return; + if (res.ok) { + const data = await res.json() as Record; + if (genRef.current !== gen) return; + setStackUpdates(data); + return; + } + // A remote node on an older Sencho lacks /detail; fall back to the boolean + // map so update badges keep working until that node is upgraded. + if (res.status === 404) { + const boolRes = await apiFetch('/image-updates', { nodeId: targetNodeId }); + if (genRef.current !== gen) return; + if (boolRes.ok) { + const bool = await boolRes.json() as Record; + if (genRef.current !== gen) return; + const synthesized: Record = {}; + for (const [stack, hasUpdate] of Object.entries(bool)) { + synthesized[stack] = { hasUpdate, checkStatus: 'ok', lastError: null, checkedAt: 0 }; + } + setStackUpdates(synthesized); + } else { + console.error('[ImageUpdates] /detail 404 fallback to /image-updates failed:', boolRes.status); + } + return; + } + // Any other non-ok (500, or a proxy 5xx from an unreachable remote): keep + // the last-known state on screen, but do not let the failure go silent. + console.error('[ImageUpdates] /image-updates/detail returned', res.status); + } catch (e: unknown) { + console.error('[ImageUpdates] fetch failed:', e); + } + }; + + await Promise.allSettled([fetchStatus(), fetchDetail()]); + }, [activeNodeId]); // Pin the interval to the latest closure without retriggering it on // every render the way putting `refresh` into the deps array would. const refreshRef = useRef(refresh); refreshRef.current = refresh; + // Poll on mount and on node change. Reset state and capture the owning + // node BEFORE fetching so the old node's data is cleared before the new + // node's first response arrives, and the guard above returns empty defaults + // on the render before this effect fires. useEffect(() => { + genRef.current += 1; + setStackUpdates({}); // eslint-disable-line react-hooks/set-state-in-effect + setSidebarIndicators(false); // eslint-disable-line react-hooks/set-state-in-effect + setOwnerNodeId(activeNodeId); // eslint-disable-line react-hooks/set-state-in-effect void refreshRef.current(); const id = setInterval(() => { void refreshRef.current(); }, IMAGE_UPDATE_POLL_MS); return () => clearInterval(id); }, [activeNodeId]); - return { stackUpdates, refresh }; + // React to settings changes so toggling the sidebar-indicator preference + // propagates immediately without waiting for the 5-minute poll. + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent<{ changedKeys?: string[] }>).detail; + if (detail?.changedKeys?.includes('image_update_sidebar_indicators')) { + refreshRef.current(); + } + }; + window.addEventListener(SENCHO_SETTINGS_CHANGED, handler); + return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler); + }, []); + + // Return empty defaults until the owning node matches the active node. + // This prevents React from rendering node B with node A's update data and + // sidebar preference during the single frame before the passive effect fires. + const isOwner = activeNodeId !== undefined && activeNodeId === ownerNodeId; + return { + stackUpdates: isOwner ? stackUpdates : {} as Record, + refresh, + sidebarIndicators: isOwner ? sidebarIndicators : false, + }; } diff --git a/frontend/src/types/imageUpdates.ts b/frontend/src/types/imageUpdates.ts index 9fe4701e..0097768a 100644 --- a/frontend/src/types/imageUpdates.ts +++ b/frontend/src/types/imageUpdates.ts @@ -22,6 +22,8 @@ export interface ImageUpdateStatus { mode: 'interval' | 'cron'; /** 5-field cron expression when mode is 'cron', null otherwise. */ cronExpression: string | null; + /** Whether sidebar update-status indicators are enabled. Optional for older-node compatibility. */ + sidebarIndicators?: boolean; } /** From fbe98676cb8a0d0021873cfa768adaf8c1455946 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 5 Jul 2026 02:53:26 -0400 Subject: [PATCH 04/12] feat: bound container health pane so logs stay visible on large stacks (#1556) * feat: bound container health pane so logs stay visible on large stacks On the stack overview page, multi-container stacks (8+ services) pushed the logs pane off-screen because the Command Center card had shrink-0 and no scroll mechanism. This reworks the desktop layout for stacks with more than one container: - The Command Center card is bounded at max-h-[42%] so it never consumes the full left column. The container list scrolls internally via Sencho's existing Radix ScrollArea. - A summary strip shows total / running / paused / unhealthy counts above the list, with a Compact / Detailed density toggle. Detailed (default) shows CPU / Mem / Net sparklines; Compact hides them for a denser list. - The logs pane has a guaranteed min-h-[180px] so it is always reachable. - Single-container stacks are untouched: the card keeps its original shrink-0 behaviour, and no summary strip or density toggle appears. Mobile is unaffected (it already uses segmented Health / Logs / Compose panes). * fix: key ContainersHealth by node+stack so density resets on navigation The density toggle state (compact/detailed) is local to ContainersHealth. Without a key, selecting Compact on one multi-container stack would persist when navigating to a single-container stack, hiding sparklines while also hiding the toggle itself. Keying by node+stack ensures a clean remount. Also added test coverage for zero containers and density reset on remount. * fix: remove unused rerender variable from ContainersHealth test --- docs/features/stack-management.mdx | 2 +- docs/images/stack-view/containers.png | Bin 11912 -> 263224 bytes .../components/EditorLayout/EditorView.tsx | 37 ++++- .../EditorLayout/MobileStackDetail.tsx | 1 + .../__tests__/ContainersHealth.test.tsx | 137 ++++++++++++++++++ .../EditorLayout/editor-view-blocks.tsx | 49 ++++++- 6 files changed, 221 insertions(+), 5 deletions(-) diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 6c8594d0..5f8df0c9 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -203,7 +203,7 @@ The same links button appears on each container row and on the update cards in [ ## Container health strip -Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" without expanding anything. +Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" When the stack has multiple containers, a summary strip appears above the list showing total, running, paused, and unhealthy counts, along with a **Compact / Detailed** toggle. Compact mode shows status, name, uptime, port, and action buttons; detailed mode (the default) adds CPU, memory, and network I/O sparklines. CONTAINERS section showing a container card with health badge, uptime, port mapping, open link, and CPU, memory, and network stat tiles diff --git a/docs/images/stack-view/containers.png b/docs/images/stack-view/containers.png index 6e92e77b44a1a1534b5e2f54bf5338824a5b7855..962c9287af4dfdff13a94d967aa57b3f8bb57c43 100644 GIT binary patch literal 263224 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 YJ4XiF@8vee;}aoMBTK{F|2m)gKkPG8h5!Hn literal 11912 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 Ak^lez diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index adfa3f7b..5e157a74 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -38,6 +38,7 @@ import ErrorBoundary from '../ErrorBoundary'; import StackAnatomyPanel from '../StackAnatomyPanel'; import { StackFileExplorer } from '@/components/files/StackFileExplorer'; import { useIsMobile } from '@/hooks/use-is-mobile'; +import { ScrollArea } from '../ui/scroll-area'; import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks'; import { MobileStackDetail } from './MobileStackDetail'; import { RecoveryChip } from './RecoveryChip'; @@ -355,7 +356,7 @@ export function EditorView(props: EditorViewProps) { {/* Command Center Card (identity + health strip). Hidden when the logs are expanded so the logs pane fills the column. */} {!logsExpanded && ( - + 1 ? 'flex flex-col min-h-0 max-h-[42%]' : 'shrink-0'}`}>
@@ -407,6 +408,23 @@ export function EditorView(props: EditorViewProps) { panelStartedAt={panelStartedAt} variant="band" /> + {safeContainers.length > 1 ? ( + + + + + + ) : ( + )} )} - {/* Logs Section (fills remaining left-column height) */} + {/* Logs Section (fills remaining left-column height). On multi- + container stacks a min-h guarantees logs are never hidden. */} + {safeContainers.length > 1 ? ( +
setLogsExpanded((v) => !v)} /> +
+ ) : ( + setLogsExpanded((v) => !v)} + /> + )}
)} diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index e2a5a1cf..5126ab7b 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -225,6 +225,7 @@ export function MobileStackDetail(props: EditorViewProps) { openLogViewer={openLogViewer} openBashModal={openBashModal} serviceAction={serviceAction} + key={`${activeNode?.id ?? 'local'}:${stackName}`} />
)} diff --git a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx index ab127ef1..0662d70a 100644 --- a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx @@ -80,3 +80,140 @@ describe('ContainersHealth published port link', () => { expect(screen.getByText(/8080 → 80\/tcp/)).toBeInTheDocument(); }); }); + +describe('density toggle and summary strip', () => { + function makeContainer(overrides: Partial = {}): ContainerInfo { + return { + Id: overrides.Id || 'abc', + Names: overrides.Names || ['/app'], + State: overrides.State || 'running', + Status: overrides.Status || 'Up 1 hour', + Image: overrides.Image || 'nginx', + ...overrides, + } as unknown as ContainerInfo; + } + + function renderMany(containers: ContainerInfo[]) { + return render( + , + ); + } + + it('does not render summary strip or density toggle for a single container', () => { + renderMany([makeContainer()]); + expect(screen.queryByText(/container/)).toBeNull(); + expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Detailed view' })).toBeNull(); + }); + + it('renders summary counts for multiple containers', () => { + renderMany([ + makeContainer({ Id: 'a', State: 'running' }), + makeContainer({ Id: 'b', State: 'running' }), + makeContainer({ Id: 'c', State: 'paused' }), + ]); + expect(screen.getByText(/3 containers/i)).toBeInTheDocument(); + expect(screen.getByText(/2 up/i)).toBeInTheDocument(); + expect(screen.getByText(/1 paused/i)).toBeInTheDocument(); + }); + + it('shows unhealthy count in summary', () => { + renderMany([ + makeContainer({ Id: 'a', State: 'running', healthStatus: 'healthy' }), + makeContainer({ Id: 'b', State: 'running', healthStatus: 'unhealthy' }), + ]); + expect(screen.getByText(/1 unhealthy/i)).toBeInTheDocument(); + }); + + it('renders density toggle buttons for multiple containers', () => { + renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]); + expect(screen.getByRole('button', { name: 'Compact view' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Detailed view' })).toBeInTheDocument(); + }); + + it('detailed mode is the default', () => { + renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]); + const detailed = screen.getByRole('button', { name: 'Detailed view' }); + expect(detailed).toHaveAttribute('aria-pressed', 'true'); + }); + + it('hides sparkline grids in compact mode', () => { + renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]); + // Sparklines visible by default in detailed mode (two containers, two cpu labels) + expect(screen.getAllByText('cpu')).toHaveLength(2); + + fireEvent.click(screen.getByRole('button', { name: 'Compact view' })); + // Sparkline labels hidden in compact mode + expect(screen.queryByText('cpu')).toBeNull(); + }); + + it('shows sparkline grids again when switching back to detailed', () => { + renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]); + fireEvent.click(screen.getByRole('button', { name: 'Compact view' })); + expect(screen.queryByText('cpu')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Detailed view' })); + expect(screen.getAllByText('cpu')).toHaveLength(2); + }); + + it('keeps header row actions visible in compact mode', () => { + renderMany([ + makeContainer({ Id: 'a', State: 'running', Service: 'web' }), + makeContainer({ Id: 'b', State: 'running' }), + ]); + fireEvent.click(screen.getByRole('button', { name: 'Compact view' })); + // View logs button still present + expect(screen.getAllByRole('button', { name: 'View logs' })).toHaveLength(2); + }); + + it('renders empty state for zero containers without summary strip', () => { + renderMany([]); + expect(screen.getByText(/no containers running/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Detailed view' })).toBeNull(); + }); + + it('resets density to detailed on remount (key change)', () => { + const { unmount } = render( + , + ); + // Switch to compact + fireEvent.click(screen.getByRole('button', { name: 'Compact view' })); + expect(screen.queryByText('cpu')).toBeNull(); + + // Simulate navigating to a single-container stack (new key) + unmount(); + render( + , + ); + // Density reset; single container shows sparklines + expect(screen.getByText('cpu')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull(); + }); +}); diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index af513343..8230ba44 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -16,6 +16,8 @@ import { ArrowUpRight, Copy, CloudDownload, + Layers, + List, } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '../ui/button'; @@ -339,6 +341,9 @@ export function ContainersHealth({ }: ContainersHealthProps) { const [copiedUrlId, setCopiedUrlId] = useState(null); const copiedUrlTimerRef = useRef(null); + // Compact mode hides sparkline grids across all containers for a denser + // list. Detailed mode (default) shows CPU / Mem / Net per container. + const [density, setDensity] = useState<'compact' | 'detailed'>('detailed'); useEffect(() => () => { if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current); }, []); @@ -368,7 +373,46 @@ export function ContainersHealth({ {safeContainers.length === 0 ? (
No containers running for this stack.
) : ( -
+ <> + {/* Summary strip + density toggle appear only for multi-container + stacks; single-container stacks keep the original layout. */} + {safeContainers.length > 1 && (() => { + const total = safeContainers.length; + const running = safeContainers.filter(c => c.State === 'running').length; + const unhealthy = safeContainers.filter(c => c.healthStatus === 'unhealthy').length; + const paused = safeContainers.filter(c => c.State === 'paused').length; + return ( +
+
+ {total} container{total !== 1 ? 's' : ''} + {running} up + {paused > 0 && {paused} paused} + {unhealthy > 0 && {unhealthy} unhealthy} +
+
+ + +
+
+ ); + })()} +
{safeContainers.map(container => { let mainPort: number | undefined; let mainPortPrivate: number | undefined; @@ -517,7 +561,7 @@ export function ContainersHealth({ )}
- {isActive ? ( + {isActive && density === 'detailed' ? (
@@ -552,6 +596,7 @@ export function ContainersHealth({ ); })}
+ )}
); From ecd757270fd6d5970925496c1c7d6bf499adb080 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 5 Jul 2026 03:11:21 -0400 Subject: [PATCH 05/12] fix: parse LSIO volume :ro suffixes for App Store deploys (#1557) LSIO encodes read-only mounts in volume path (e.g. /var/log:ro). Map host_path and optional correctly so fail2ban and similar templates generate valid compose specs. Fixes #1554 --- .../template-service-registry.test.ts | 77 +++++++++++++++++++ .../src/__tests__/template-service.test.ts | 23 ++++++ backend/src/services/TemplateService.ts | 63 ++++++++++++--- 3 files changed, 151 insertions(+), 12 deletions(-) diff --git a/backend/src/__tests__/template-service-registry.test.ts b/backend/src/__tests__/template-service-registry.test.ts index 99598d70..9bc34e01 100644 --- a/backend/src/__tests__/template-service-registry.test.ts +++ b/backend/src/__tests__/template-service-registry.test.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import axios from 'axios'; +import YAML from 'yaml'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; vi.mock('axios', () => ({ @@ -103,4 +104,80 @@ describe('TemplateService.getTemplates registry size cap', () => { expect(plex!.env).toEqual([{ name: 'PUID', label: 'User ID', default: '1000' }]); expect(plex!.categories).toEqual(['Media']); }); + + it('maps LSIO :ro volume paths and skips optional volumes (fail2ban)', async () => { + const service = new TemplateService(); + service.clearCache(); + mockedGet.mockResolvedValueOnce({ + data: { + data: { + repositories: { + linuxserver: { + fail2ban: { + name: 'fail2ban', + description: 'Ban IPs', + config: { + volumes: [ + { path: '/config', host_path: '/path/to/fail2ban/config', optional: false }, + { path: '/var/log:ro', host_path: '/var/log', optional: false }, + { + path: '/remotelogs/nginx:ro', + host_path: '/path/to/nginx/log', + optional: true, + }, + ], + }, + }, + }, + }, + }, + }, + }); + + const templates = await service.getTemplates(); + const fail2ban = templates.find(t => t.title === 'fail2ban')!; + expect(fail2ban.volumes).toEqual([ + { container: '/config', bind: './config' }, + { container: '/var/log', bind: '/var/log', readonly: true }, + ]); + + const yaml = service.generateComposeFromTemplate(fail2ban, 'fail2ban'); + const parsed = YAML.parse(yaml) as { services: { fail2ban: { volumes: string[] } } }; + expect(parsed.services.fail2ban.volumes).toEqual([ + './config:/config', + '/var/log:/var/log:ro', + ]); + }); + + it('maps LSIO :ro volume with placeholder host_path (mame)', async () => { + const service = new TemplateService(); + service.clearCache(); + mockedGet.mockResolvedValueOnce({ + data: { + data: { + repositories: { + linuxserver: { + mame: { + name: 'mame', + description: 'MAME', + config: { + volumes: [ + { path: '/config', host_path: '/path/to/config', optional: false }, + { path: '/mame:ro', host_path: '/path/to/mame/assets', optional: false }, + ], + }, + }, + }, + }, + }, + }, + }); + + const templates = await service.getTemplates(); + const mame = templates.find(t => t.title === 'mame'); + expect(mame!.volumes).toEqual([ + { container: '/config', bind: './config' }, + { container: '/mame', bind: './mame', readonly: true }, + ]); + }); }); diff --git a/backend/src/__tests__/template-service.test.ts b/backend/src/__tests__/template-service.test.ts index 263324bc..ea082984 100644 --- a/backend/src/__tests__/template-service.test.ts +++ b/backend/src/__tests__/template-service.test.ts @@ -84,6 +84,29 @@ describe('TemplateService', () => { expect(svc.volumes).toEqual(['./config:/config:ro']); }); + it('generates valid compose for fail2ban-shaped template volumes', () => { + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'fail2ban', + description: 'Ban IPs', + image: 'lscr.io/linuxserver/fail2ban:latest', + volumes: [ + { container: '/config', bind: './config' }, + { container: '/var/log', bind: '/var/log', readonly: true }, + ], + }, 'fail2ban'), 'fail2ban'); + expect(svc.volumes).toEqual(['./config:/config', '/var/log:/var/log:ro']); + }); + + it('repairs stale LSIO volume paths that still embed :ro in container and bind', () => { + const svc = serviceOf(service.generateComposeFromTemplate({ + title: 'fail2ban', + description: 'Ban IPs', + image: 'lscr.io/linuxserver/fail2ban:latest', + volumes: [{ container: '/var/log:ro', bind: './log:ro' }], + }, 'fail2ban'), 'fail2ban'); + expect(svc.volumes).toEqual(['./log:/var/log:ro']); + }); + it('includes env_file only when env vars are present', () => { const withEnv = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts index 1c60f021..ba03f326 100644 --- a/backend/src/services/TemplateService.ts +++ b/backend/src/services/TemplateService.ts @@ -57,7 +57,12 @@ interface ComposeServiceDefinition { // Typed shapes for the LinuxServer.io API response interface LsioPort { external?: number; internal: number; protocol?: string } -interface LsioVolume { path: string } +interface LsioVolume { + path: string; + host_path?: string; + desc?: string; + optional?: boolean; +} interface LsioEnvVar { name: string; desc?: string; default?: string } interface LsioAppConfig { ports?: LsioPort[]; volumes?: LsioVolume[]; environment?: LsioEnvVar[] } interface LsioApp { @@ -223,6 +228,45 @@ function getCategoriesForApp(name: string): string[] { return LSIO_CATEGORY_MAP[name.toLowerCase()] ?? ['Other']; } +function lastPathSegment(path: string): string { + return path.split('/').filter(Boolean).pop() || 'data'; +} + +function parseLsioVolumePath(path: string): { container: string; readonly: boolean } { + const match = path.match(/:(ro|rw)$/); + if (match) { + return { container: path.slice(0, -match[0].length), readonly: match[1] === 'ro' }; + } + return { container: path, readonly: false }; +} + +function defaultBindForLsioVolume(containerPath: string, hostPath?: string): string { + if (hostPath && !hostPath.startsWith('/path/to/')) { + return hostPath; + } + return `./${lastPathSegment(containerPath)}`; +} + +function stripErroneousBindMode(bind: string): string { + if (/^[a-zA-Z]:/.test(bind)) { + return bind; + } + const match = bind.match(/^(.*):(ro|rw)$/); + return match ? match[1] : bind; +} + +function mapLsioVolume(v: LsioVolume): TemplateVolume | null { + if (v.optional) { + return null; + } + const { container, readonly } = parseLsioVolumePath(v.path); + return { + container, + bind: defaultBindForLsioVolume(container, v.host_path), + ...(readonly ? { readonly: true } : {}), + }; +} + export class TemplateService { private static readonly CACHE_KEY = 'templates:all'; private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -280,13 +324,9 @@ export class TemplateService { source: 'linuxserver', // Map configs if available, otherwise default to empty arrays ports: (app.config?.ports ?? []).map((p: LsioPort) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`), - volumes: (app.config?.volumes ?? []).map((v: LsioVolume) => { - const folderName = v.path.split('/').filter(Boolean).pop() || 'data'; - return { - container: v.path, - bind: `./${folderName}` - }; - }), + volumes: (app.config?.volumes ?? []) + .map((v: LsioVolume) => mapLsioVolume(v)) + .filter((v): v is TemplateVolume => v !== null), env: (app.config?.environment ?? []).map((e: LsioEnvVar) => ({ name: e.name, label: e.desc || e.name, @@ -343,10 +383,9 @@ export class TemplateService { // handles any escaping the raw value needs. volumes.push(vol); } else if (vol.container) { - const containerPath = vol.container; - const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data'; - const hostPath = vol.bind ? vol.bind : `./${containerFolder}`; - const options = vol.readonly ? ':ro' : ''; + const { container: containerPath, readonly: pathReadonly } = parseLsioVolumePath(vol.container); + const hostPath = stripErroneousBindMode(vol.bind ?? defaultBindForLsioVolume(containerPath)); + const options = (vol.readonly === true || pathReadonly) ? ':ro' : ''; volumes.push(`${hostPath}:${containerPath}${options}`); } } From 122c1b80732292628dace7344bee31e584cf6bdb Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 5 Jul 2026 03:50:40 -0400 Subject: [PATCH 06/12] fix: stop Doctor exposing hash fragments as unset variables (#1558) Classify Compose stderr unset-variable warnings into intentional references vs literal-dollar fragments from secret values. Adds env-literal-dollar preflight rule and safe remediation text. Fixes #1550. --- .../__tests__/compose-doctor-service.test.ts | 36 +++++- backend/src/__tests__/env-var-parse.test.ts | 17 +++ backend/src/__tests__/preflight-rules.test.ts | 31 ++++- .../unset-env-classification.test.ts | 85 +++++++++++++ backend/src/helpers/envFileResolution.ts | 3 + backend/src/helpers/envVarParse.ts | 51 ++++++++ backend/src/helpers/unsetEnvClassification.ts | 112 ++++++++++++++++++ backend/src/services/ComposeDoctorService.ts | 50 +++++--- backend/src/services/EnvInventoryService.ts | 10 +- backend/src/services/preflight/rules.ts | 29 ++++- backend/src/services/preflight/types.ts | 5 +- docs/features/compose-doctor.mdx | 12 +- 12 files changed, 416 insertions(+), 25 deletions(-) create mode 100644 backend/src/__tests__/unset-env-classification.test.ts create mode 100644 backend/src/helpers/unsetEnvClassification.ts diff --git a/backend/src/__tests__/compose-doctor-service.test.ts b/backend/src/__tests__/compose-doctor-service.test.ts index 55de4dde..2a77540c 100644 --- a/backend/src/__tests__/compose-doctor-service.test.ts +++ b/backend/src/__tests__/compose-doctor-service.test.ts @@ -85,7 +85,7 @@ describe('runPreflight', () => { expect(report.renderable).toBe(true); expect(report.status).toBe('high'); // env-unset + 0.0.0.0 exposure are high expect(report.highestSeverity).toBe('high'); - expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-unset', 'port-exposed-all-interfaces', 'image-latest', 'no-healthcheck'])); + expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-literal-dollar', 'port-exposed-all-interfaces', 'image-latest', 'no-healthcheck'])); expect(report.ranBy).toBe('tester'); expect(report.sourceHash).toBeTruthy(); @@ -128,6 +128,40 @@ describe('runPreflight', () => { expect(JSON.stringify(report)).not.toContain(SECRET); }); + it('does not expose hash fragments as unset variable names (#1550)', async () => { + const stack = 'hashfrag'; + const dir = path.join(process.env.COMPOSE_DIR as string, stack); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'compose.yaml'), + [ + 'services:', + ' demo:', + ' image: alpine:3', + ' environment:', + ' - EXAMPLE_AUTH_HASH=$2b$10$E6SDEbshpc$vCSrREDACTED', + ].join('\n'), + ); + try { + stubDocker( + { name: stack, services: { demo: { image: 'alpine:3', environment: { EXAMPLE_AUTH_HASH: '' } } }, networks: {}, volumes: {} }, + 'WARN The "E6SDEbshpc" variable is not set. Defaulting to a blank string.\n' + + 'WARN The "vCSr" variable is not set. Defaulting to a blank string.\n', + ); + const report = await doctor().runPreflight(nodeId, stack, 'tester'); + const literal = report.findings.filter(f => f.ruleId === 'env-literal-dollar'); + const unset = report.findings.filter(f => f.ruleId === 'env-unset'); + expect(literal.length).toBeGreaterThan(0); + expect(unset.some(f => f.title.includes('E6SDEbshpc') || f.title.includes('vCSr'))).toBe(false); + const persisted = JSON.stringify(db().getPreflightFindings(db().getLatestPreflightRun(nodeId, stack)!.id)); + expect(persisted).not.toContain('E6SDEbshpc'); + expect(persisted).not.toContain('vCSr'); + expect(literal[0].title).toContain('likely secret'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it('replaces the prior run rather than accumulating', async () => { stubDocker({ name: STACK, services: { web: { image: 'nginx:latest' } }, networks: {}, volumes: {} }); await doctor().runPreflight(nodeId, STACK, null); diff --git a/backend/src/__tests__/env-var-parse.test.ts b/backend/src/__tests__/env-var-parse.test.ts index 64b6f5a2..0c981e81 100644 --- a/backend/src/__tests__/env-var-parse.test.ts +++ b/backend/src/__tests__/env-var-parse.test.ts @@ -8,6 +8,8 @@ import { readEnvFileKeys, parseUnsetEnvVars, parseMissingRequiredVars, + parseBareDollarRefs, + parseIntentionalBareDollarRefs, } from '../helpers/envVarParse'; describe('parseInterpolationRefs', () => { @@ -35,6 +37,21 @@ describe('parseInterpolationRefs', () => { expect(parseInterpolationRefs('x: $${ESCAPED}').map(r => r.name)).not.toContain('ESCAPED'); }); + it('extracts bare $VAR refs but skips $$ escapes and ${VAR} forms', () => { + expect(parseBareDollarRefs('host: $DB_HOST and $${LIT} and ${BRACED}')).toEqual(['DB_HOST']); + }); + + it('keeps intentional bare refs for self-refs and whole-value refs only', () => { + const src = [ + '- TOKEN=$TOKEN', + '- FOO=$BAR', + '- EXAMPLE_AUTH_HASH=$2b$10$E6SDEbshpc$vCSrREDACTED', + 'EXAMPLE_AUTH_HASH: $2b$10$E6SDEbshpc$vCSrREDACTED', + 'command: echo $HELLO', + ].join('\n'); + expect(parseIntentionalBareDollarRefs(src).sort()).toEqual(['BAR', 'HELLO', 'TOKEN']); + }); + it('merges flags across repeated references of one name', () => { const refs = parseInterpolationRefs('${X} then ${X:?e}'); expect(refs).toHaveLength(1); diff --git a/backend/src/__tests__/preflight-rules.test.ts b/backend/src/__tests__/preflight-rules.test.ts index e3a9fe54..e1cbbfa6 100644 --- a/backend/src/__tests__/preflight-rules.test.ts +++ b/backend/src/__tests__/preflight-rules.test.ts @@ -26,6 +26,7 @@ function ctx(over: Partial = {}): PreflightContext { const m = over.model !== undefined ? over.model : model([]); return { stackName: 'proj', platform: 'linux', model: m, renderable: true, renderError: null, unsetEnvVars: [], + literalDollarWarnings: [], missingEnvFiles: [], sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true, nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(), @@ -55,6 +56,34 @@ describe('env-unset', () => { expect(f[0].severity).toBe('high'); expect(f.map(x => x.sourcePath)).toEqual(['FOO', 'BAR']); }); + it('mentions literal-dollar escapes in remediation', () => { + const f = ids(runRules(ctx({ unsetEnvVars: ['FOO'] })), 'env-unset'); + expect(f[0].remediation).toContain('$$'); + expect(f[0].remediation).toContain('single-quote'); + }); +}); + +describe('env-literal-dollar', () => { + it('emits a safe finding for likely-secret literal dollar warnings', () => { + const f = ids(runRules(ctx({ + literalDollarWarnings: [{ envKey: 'EXAMPLE_AUTH_HASH', likelySecret: true, service: 'demo' }], + })), 'env-literal-dollar'); + expect(f).toHaveLength(1); + expect(f[0].severity).toBe('high'); + expect(f[0].title).toContain('likely secret'); + expect(f[0].sourcePath).toBe('EXAMPLE_AUTH_HASH'); + expect(f[0].service).toBe('demo'); + expect(f[0].title).not.toContain('E6SDEbshpc'); + expect(f[0].remediation).toContain('$$'); + }); + it('omits fragment names from generic literal-dollar findings', () => { + const f = ids(runRules(ctx({ + literalDollarWarnings: [{ likelySecret: false }], + })), 'env-literal-dollar'); + expect(f).toHaveLength(1); + expect(f[0].sourcePath).toBeUndefined(); + expect(f[0].title).toContain('environment value'); + }); }); describe('env-file-missing', () => { @@ -408,7 +437,7 @@ describe('rule registry completeness', () => { // The canonical rule set. Adding or removing a rule must update this list, // which forces a deliberate pass over the docs and the frontend severity map. const EXPECTED_RULE_IDS = [ - 'render-failed', 'env-unset', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces', + 'render-failed', 'env-unset', 'env-literal-dollar', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces', 'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host', 'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only', 'node-state-unavailable', diff --git a/backend/src/__tests__/unset-env-classification.test.ts b/backend/src/__tests__/unset-env-classification.test.ts new file mode 100644 index 00000000..204cfe9d --- /dev/null +++ b/backend/src/__tests__/unset-env-classification.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { classifyUnsetEnvVars } from '../helpers/unsetEnvClassification'; +import type { StackEnvSources } from '../helpers/envFileResolution'; + +function sources(over: Partial = {}): StackEnvSources { + return { + stackDir: '/stack', + baseDir: '/compose', + composeFiles: [], + envFiles: [], + inlineEnvKeysByService: {}, + interpolationRefs: [], + authoredComposeText: '', + ...over, + }; +} + +describe('classifyUnsetEnvVars', () => { + it('keeps intentional ${VAR} refs as unset variables', () => { + const src = sources({ + authoredComposeText: 'services:\n web:\n image: nginx\n environment:\n - DB_HOST=${DB_HOST}\n', + interpolationRefs: [{ name: 'DB_HOST', required: false, hasDefault: false, alternate: false }], + }); + const result = classifyUnsetEnvVars(['DB_HOST', 'E6SDEbshpc'], src); + expect(result.intentional).toEqual(['DB_HOST']); + expect(result.literalDollar).toHaveLength(1); + expect(result.literalDollar[0].likelySecret).toBe(false); + }); + + it('classifies bcrypt hash fragments as literal-dollar warnings without exposing fragments', () => { + const compose = [ + 'services:', + ' demo:', + ' image: alpine:3', + ' environment:', + ' - EXAMPLE_AUTH_HASH=$2b$10$E6SDEbshpc$vCSrREDACTED', + ].join('\n'); + const src = sources({ + authoredComposeText: compose, + inlineEnvKeysByService: { demo: ['EXAMPLE_AUTH_HASH'] }, + }); + const result = classifyUnsetEnvVars(['E6SDEbshpc', 'vCSr'], src); + expect(result.intentional).toEqual([]); + expect(result.literalDollar).toHaveLength(1); + expect(result.literalDollar[0]).toMatchObject({ + envKey: 'EXAMPLE_AUTH_HASH', + likelySecret: true, + service: 'demo', + }); + expect(JSON.stringify(result)).not.toContain('E6SDEbshpc'); + expect(JSON.stringify(result)).not.toContain('vCSr'); + }); + + it('classifies map-form bcrypt hash fragments as literal-dollar warnings', () => { + const compose = [ + 'services:', + ' demo:', + ' image: alpine:3', + ' environment:', + ' EXAMPLE_AUTH_HASH: $2b$10$E6SDEbshpc$vCSrREDACTED', + ].join('\n'); + const src = sources({ + authoredComposeText: compose, + inlineEnvKeysByService: { demo: ['EXAMPLE_AUTH_HASH'] }, + }); + const result = classifyUnsetEnvVars(['E6SDEbshpc', 'vCSr'], src); + expect(result.intentional).toEqual([]); + expect(result.literalDollar[0]?.envKey).toBe('EXAMPLE_AUTH_HASH'); + }); + + it('treats bare $VAR references as intentional', () => { + const compose = 'services:\n web:\n image: nginx\n environment:\n - TOKEN=$TOKEN\n'; + const src = sources({ authoredComposeText: compose }); + const result = classifyUnsetEnvVars(['TOKEN'], src); + expect(result.intentional).toEqual(['TOKEN']); + expect(result.literalDollar).toEqual([]); + }); + + it('attributes env-file-only spurious fragments to a lone likely-secret key', () => { + const src = sources({ authoredComposeText: 'services:\n web:\n image: nginx\n env_file:\n - ./secrets.env\n' }); + const result = classifyUnsetEnvVars(['E6SDEbshpc'], src, ['EXAMPLE_AUTH_HASH']); + expect(result.intentional).toEqual([]); + expect(result.literalDollar).toEqual([{ envKey: 'EXAMPLE_AUTH_HASH', likelySecret: true }]); + }); +}); diff --git a/backend/src/helpers/envFileResolution.ts b/backend/src/helpers/envFileResolution.ts index 5293681f..2f30d231 100644 --- a/backend/src/helpers/envFileResolution.ts +++ b/backend/src/helpers/envFileResolution.ts @@ -55,6 +55,8 @@ export interface StackEnvSources { inlineEnvKeysByService: Record; /** `${}` references found across the authored compose source. */ interpolationRefs: InterpolationRef[]; + /** Concatenated authored compose file text (in-memory classification only). */ + authoredComposeText: string; } interface EnvFileEntry { @@ -281,6 +283,7 @@ export async function resolveStackEnvSources(nodeId: number, stackName: string): envFiles: [...byPath.values(), ...unresolved], inlineEnvKeysByService, interpolationRefs: parseInterpolationRefs(authoredText), + authoredComposeText: authoredText, }; } diff --git a/backend/src/helpers/envVarParse.ts b/backend/src/helpers/envVarParse.ts index d2720b48..fec06e66 100644 --- a/backend/src/helpers/envVarParse.ts +++ b/backend/src/helpers/envVarParse.ts @@ -49,6 +49,57 @@ export function parseInterpolationRefs(source: string): InterpolationRef[] { return [...byName.values()]; } +// Bare $VAR (no braces). The leading (?(); + for (const m of source.matchAll(BARE_DOLLAR_RE)) names.add(m[1]); + return [...names]; +} + +/** True when a key name follows env-var naming (UPPER_SNAKE), not compose fields like `command`. */ +function looksLikeEnvKey(key: string): boolean { + return key === key.toUpperCase() && /[A-Z]/.test(key); +} + +/** Parse an inline environment key/value assignment from a compose source line. */ +function parseEnvAssignment(line: string): { keyName: string; valuePart: string } | null { + const trimmed = line.trim(); + const listEq = trimmed.match(/^-\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + if (listEq) return { keyName: listEq[1], valuePart: listEq[2].trim() }; + const listColon = trimmed.match(/^-\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s+(.*)$/); + if (listColon) return { keyName: listColon[1], valuePart: listColon[2].trim() }; + const mapEq = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + if (mapEq && looksLikeEnvKey(mapEq[1])) return { keyName: mapEq[1], valuePart: mapEq[2].trim() }; + const mapColon = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s+(.*)$/); + if (mapColon && looksLikeEnvKey(mapColon[1])) return { keyName: mapColon[1], valuePart: mapColon[2].trim() }; + return null; +} + +/** + * Bare `$VAR` references Compose is meant to interpolate: self-references + * (`TOKEN=$TOKEN`), whole-value refs (`FOO=$BAR`), and refs outside env values. + * Fragments embedded inside a literal value (e.g. bcrypt hashes) are excluded. + */ +export function parseIntentionalBareDollarRefs(source: string): string[] { + const names = new Set(); + for (const line of source.split(/\r?\n/)) { + const assignment = parseEnvAssignment(line); + if (assignment) { + const { keyName, valuePart } = assignment; + for (const m of valuePart.matchAll(BARE_DOLLAR_RE)) { + const name = m[1]; + if (keyName === name || valuePart === `$${name}`) names.add(name); + } + } else { + for (const m of line.matchAll(BARE_DOLLAR_RE)) names.add(m[1]); + } + } + return [...names]; +} + /** * Pull the KEY name from a single env-file line, or null for a blank/comment line. * Handles `KEY=value`, `export KEY=value`, and a bare `KEY` (value sourced from the diff --git a/backend/src/helpers/unsetEnvClassification.ts b/backend/src/helpers/unsetEnvClassification.ts new file mode 100644 index 00000000..25b23ba2 --- /dev/null +++ b/backend/src/helpers/unsetEnvClassification.ts @@ -0,0 +1,112 @@ +/** + * Classify Compose stderr "unset variable" names into intentional references vs + * spurious fragments from literal `$` sequences inside env values (e.g. bcrypt + * hashes). Names only; env values are never read or returned. + */ + +import type { StackEnvSources } from './envFileResolution'; +import { parseIntentionalBareDollarRefs } from './envVarParse'; +import { isLikelySecretKey } from './secretClassification'; + +export interface LiteralDollarWarning { + envKey?: string; + likelySecret: boolean; + service?: string; +} + +export interface UnsetEnvClassification { + intentional: string[]; + literalDollar: LiteralDollarWarning[]; +} + +function intentionalRefNames(envSources: StackEnvSources): Set { + const names = new Set(); + for (const ref of envSources.interpolationRefs) names.add(ref.name); + for (const name of parseIntentionalBareDollarRefs(envSources.authoredComposeText)) names.add(name); + return names; +} + +/** Parse an inline `environment:` key from a compose source line (names only). */ +function extractEnvKeyFromComposeLine(line: string): string | null { + const trimmed = line.trim(); + const listMatch = trimmed.match(/^-\s*([A-Za-z_][A-Za-z0-9_]*)\s*[:=]/); + if (listMatch) return listMatch[1]; + const mapMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:/); + if (mapMatch) return mapMatch[1]; + const eqMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/); + if (eqMatch) return eqMatch[1]; + return null; +} + +function findServiceForEnvKey( + inlineEnvKeysByService: Record, + envKey: string, +): string | undefined { + for (const [service, keys] of Object.entries(inlineEnvKeysByService)) { + if (keys.includes(envKey)) return service; + } + return undefined; +} + +function attributeFragmentToEnvKey(authoredText: string, fragment: string): string | null { + const needle = `$${fragment}`; + for (const line of authoredText.split(/\r?\n/)) { + if (!line.includes(needle)) continue; + const key = extractEnvKeyFromComposeLine(line); + if (key) return key; + } + return null; +} + +/** + * Split stderr unset names into intentional Compose variable references and + * literal-dollar warnings safe to show in Doctor (no hash/secret fragments). + */ +export function classifyUnsetEnvVars( + unsetNames: string[], + envSources: StackEnvSources, + envFileKeys: string[] = [], +): UnsetEnvClassification { + const intentionalSet = intentionalRefNames(envSources); + const intentional: string[] = []; + const spurious: string[] = []; + for (const name of unsetNames) { + if (intentionalSet.has(name)) intentional.push(name); + else spurious.push(name); + } + + if (spurious.length === 0) { + return { intentional, literalDollar: [] }; + } + + const warnings = new Map(); + + const addWarning = (w: LiteralDollarWarning) => { + const id = w.envKey ?? (w.likelySecret ? '__secret__' : '__generic__'); + if (!warnings.has(id)) warnings.set(id, w); + }; + + for (const fragment of spurious) { + const envKey = attributeFragmentToEnvKey(envSources.authoredComposeText, fragment); + if (envKey) { + addWarning({ + envKey, + likelySecret: isLikelySecretKey(envKey), + service: findServiceForEnvKey(envSources.inlineEnvKeysByService, envKey), + }); + } + } + + const unattributed = spurious.some(f => !attributeFragmentToEnvKey(envSources.authoredComposeText, f)); + if (unattributed) { + const secretFileKeys = envFileKeys.filter(isLikelySecretKey); + if (secretFileKeys.length === 1) { + const key = secretFileKeys[0]; + addWarning({ envKey: key, likelySecret: true }); + } else { + addWarning({ likelySecret: secretFileKeys.length > 0 }); + } + } + + return { intentional, literalDollar: [...warnings.values()] }; +} diff --git a/backend/src/services/ComposeDoctorService.ts b/backend/src/services/ComposeDoctorService.ts index 69517172..6969a51c 100644 --- a/backend/src/services/ComposeDoctorService.ts +++ b/backend/src/services/ComposeDoctorService.ts @@ -19,8 +19,9 @@ import type { import { isPathWithinBase } from '../utils/validation'; import { getErrorMessage } from '../utils/errors'; import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog'; -import { parseUnsetEnvVars, parseMissingRequiredVars } from '../helpers/envVarParse'; +import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse'; import { resolveStackEnvSources } from '../helpers/envFileResolution'; +import { classifyUnsetEnvVars, type LiteralDollarWarning } from '../helpers/unsetEnvClassification'; const MAX_RENDER_ERROR = 600; // chars kept from a (redacted) render error @@ -142,13 +143,43 @@ export class ComposeDoctorService { let renderError: string | null = null; let model: EffectiveModel | null = null; let unsetEnvVars: string[] = []; + let literalDollarWarnings: LiteralDollarWarning[] = []; + let missingEnvFiles: MissingEnvFile[] = []; + + let envSources: Awaited> | null = null; + try { + envSources = await resolveStackEnvSources(nodeId, stackName); + missingEnvFiles = envSources.envFiles + .filter(f => f.isInjectionSource && f.required && f.existence === 'missing') + .map(f => ({ rawPath: f.rawPaths[0], services: f.declaringServices })); + } catch (err) { + console.warn('[ComposeDoctor] env-file resolution failed for %s:', + sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown'))); + } + try { const result = await ComposeService.getInstance(nodeId).renderConfig(stackName); if (result.rendered !== null) { // Unset-variable warnings come from stderr and do not depend on the // model parsing, so capture them before attempting the parse, so a parse // failure does not also suppress the env-unset findings. - unsetEnvVars = parseUnsetEnvVars(result.stderr); + const rawUnset = parseUnsetEnvVars(result.stderr); + if (envSources) { + const envFileKeys: string[] = []; + for (const file of envSources.envFiles) { + if (!file.resolvedPath || file.existence !== 'present') continue; + const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, baseDir); + if (!unverifiable) envFileKeys.push(...keys); + } + const classified = classifyUnsetEnvVars(rawUnset, envSources, envFileKeys); + unsetEnvVars = classified.intentional; + literalDollarWarnings = classified.literalDollar; + } else { + // Without authored env context, never surface raw stderr names as unset + // variables; they may be literal-dollar fragments from secret values. + unsetEnvVars = []; + literalDollarWarnings = rawUnset.length > 0 ? [{ likelySecret: false }] : []; + } try { model = parseEffectiveModel(JSON.parse(result.rendered), stackName); renderable = true; @@ -179,20 +210,6 @@ export class ComposeDoctorService { const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : []; const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName); - // Required `env_file:` declarations whose file is absent. Optional - // (required: false) and interpolated/escaping paths are excluded. Fail-soft: - // a resolution error simply yields no env-file findings. - let missingEnvFiles: MissingEnvFile[] = []; - try { - const envSources = await resolveStackEnvSources(nodeId, stackName); - missingEnvFiles = envSources.envFiles - .filter(f => f.isInjectionSource && f.required && f.existence === 'missing') - .map(f => ({ rawPath: f.rawPaths[0], services: f.declaringServices })); - } catch (err) { - console.warn('[ComposeDoctor] env-file resolution failed for %s:', - sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown'))); - } - return { stackName, platform: process.platform, @@ -200,6 +217,7 @@ export class ComposeDoctorService { renderable, renderError, unsetEnvVars, + literalDollarWarnings, missingEnvFiles, sourceServiceNames, sourceReadable, diff --git a/backend/src/services/EnvInventoryService.ts b/backend/src/services/EnvInventoryService.ts index 69db393a..a2aee16a 100644 --- a/backend/src/services/EnvInventoryService.ts +++ b/backend/src/services/EnvInventoryService.ts @@ -20,6 +20,7 @@ import { ComposeService } from './ComposeService'; import { parseEffectiveModel } from './preflight/effectiveModel'; import { resolveStackEnvSources, type EnvFileExistence } from '../helpers/envFileResolution'; import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse'; +import { classifyUnsetEnvVars } from '../helpers/unsetEnvClassification'; import { isLikelySecretKey } from '../helpers/secretClassification'; export type EnvSource = 'compose-inline' | 'env-file' | 'dotenv' | 'process-env' | 'compose-ref'; @@ -78,7 +79,14 @@ export async function buildEnvInventory(nodeId: number, stackName: string): Prom const effectiveKeys = new Set(); const effectiveKeysByService = new Map>(); if (result.rendered !== null) { - unsetVars = new Set(parseUnsetEnvVars(result.stderr)); + const rawUnset = parseUnsetEnvVars(result.stderr); + const envFileKeys: string[] = []; + for (const file of sources.envFiles) { + if (!file.resolvedPath || file.existence !== 'present') continue; + const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, sources.baseDir); + if (!unverifiable) envFileKeys.push(...keys); + } + unsetVars = new Set(classifyUnsetEnvVars(rawUnset, sources, envFileKeys).intentional); try { const model = parseEffectiveModel(JSON.parse(result.rendered), stackName); for (const svc of model.services) { diff --git a/backend/src/services/preflight/rules.ts b/backend/src/services/preflight/rules.ts index a8fe0ed6..dca4e00b 100644 --- a/backend/src/services/preflight/rules.ts +++ b/backend/src/services/preflight/rules.ts @@ -82,11 +82,37 @@ const envUnset: PreflightRule = { title: `Unset variable ${name}`, message: `"${name}" is referenced by the Compose model but is not set in the environment or any consulted env file. Compose substitutes an empty string, which often breaks the container silently.`, sourcePath: name, - remediation: `Define ${name} in a .env or env_file, or give it a default with \${${name}:-value}.`, + remediation: `Define ${name} in a .env or env_file, or give it a default with \${${name}:-value}. If the value is a literal secret or hash containing \`$\`, escape \`$\` as \`$$\` in Compose YAML or single-quote the value in an env file.`, })); }, }; +const LITERAL_DOLLAR_REMEDIATION = + 'If this was intended as a Compose variable, define it in the project environment or give it a default. ' + + 'If it is part of a literal secret or hash, escape literal dollar signs as `$$` in Compose YAML or single-quote the value in an env file.'; + +const envLiteralDollar: PreflightRule = { + id: 'env-literal-dollar', + run(ctx) { + return ctx.literalDollarWarnings.map(w => { + const likelySecret = w.likelySecret; + const title = likelySecret + ? 'Literal dollar sign in likely secret value may be interpolated' + : 'Literal dollar sign in environment value may be interpolated'; + const keyHint = w.envKey ? ` for "${w.envKey}"` : ''; + return { + ruleId: 'env-literal-dollar', + severity: 'high' as const, + title, + message: `Compose treated a literal $ sequence inside an environment value${keyHint} as variable interpolation and may substitute an empty string for part of the value.`, + sourcePath: w.envKey, + remediation: LITERAL_DOLLAR_REMEDIATION, + service: w.service, + }; + }); + }, +}; + const envFileMissing: PreflightRule = { id: 'env-file-missing', run(ctx) { @@ -730,6 +756,7 @@ const sensitiveServiceBroadExposure: PreflightRule = { export const PREFLIGHT_RULES: PreflightRule[] = [ renderFailed, envUnset, + envLiteralDollar, envFileMissing, portConflictNode, portConflictInternal, diff --git a/backend/src/services/preflight/types.ts b/backend/src/services/preflight/types.ts index 1f7f5cb0..44887108 100644 --- a/backend/src/services/preflight/types.ts +++ b/backend/src/services/preflight/types.ts @@ -1,5 +1,6 @@ import type { EffectiveModel } from './effectiveModel'; import type { ExposureIntent } from '../network/types'; +import type { LiteralDollarWarning } from '../../helpers/unsetEnvClassification'; /** Graded severity of a single preflight finding. */ export type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info'; @@ -87,8 +88,10 @@ export interface PreflightContext { renderable: boolean; /** Redacted + truncated render error, or null. */ renderError: string | null; - /** Variable names Compose reported as unset (defaulted to empty string). */ + /** Variable names Compose reported as unset (intentional references only). */ unsetEnvVars: string[]; + /** Literal `$` sequences misread as variables; never includes fragment names. */ + literalDollarWarnings: LiteralDollarWarning[]; /** Declared `env_file:` paths that are required but absent on disk (names only). */ missingEnvFiles: MissingEnvFile[]; /** Service names parsed from the literal source file (pre-render). */ diff --git a/docs/features/compose-doctor.mdx b/docs/features/compose-doctor.mdx index 653f0ade..a1f8a823 100644 --- a/docs/features/compose-doctor.mdx +++ b/docs/features/compose-doctor.mdx @@ -5,7 +5,7 @@ description: Run a preflight check on a stack before you deploy. Compose Doctor The **Doctor** tab in the right-hand **Anatomy** panel answers one question before you apply a change: *what will Docker actually run, and is it safe on this node?* Compose Doctor renders the effective Compose model (the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied) and then runs a set of deterministic checks against it and the live Docker state on the node it would deploy to. -The check is advisory. It never blocks a deploy and never changes a stack. It runs on demand: press **run preflight** and Sencho renders the model, runs all 30 checks, and stores the result so the tab still shows it the next time you open the stack. +The check is advisory. It never blocks a deploy and never changes a stack. It runs on demand: press **run preflight** and Sencho renders the model, runs all 31 checks, and stores the result so the tab still shows it the next time you open the stack. ## Where to find it @@ -24,7 +24,7 @@ Every preflight run follows three steps: 1. **Render** the effective model. Sencho calls `docker compose config` on the stack, which resolves all variable interpolation, `include` directives, profile overrides, and `env_file` references into a single, normalized model. 2. **Snapshot** live Docker state. Sencho reads which host ports are in use, which containers are running, and which named networks and volumes exist on the target node. -3. **Run 30 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix. +3. **Run 31 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix. Sencho stores exactly one run per stack per node, so a new run immediately overwrites the previous one; there is no history. @@ -72,7 +72,7 @@ A small colored dot appears on the **Doctor** tab label when the last run found ## What it checks -All 30 rules are listed below, organized by topic. +All 31 rules are listed below, organized by topic. ### Model rendering @@ -84,7 +84,8 @@ All 30 rules are listed below, organized by topic. | Rule | Severity | What it detects | |------|----------|----------------| -| Unset variable | High | A variable referenced by the model has no value in the environment or any consulted env file. Compose silently substitutes an empty string, which often breaks the container without a clear error. | +| Unset variable | High | An intentional `${VAR}` or `$VAR` reference has no value in the environment or any consulted env file. Compose silently substitutes an empty string, which often breaks the container without a clear error. | +| Literal dollar in value | High | A literal `$` inside an environment value (common in bcrypt hashes and other secrets) was treated as Compose interpolation. Sencho reports the env key when it can identify one, never a fragment of the value. | | Missing env file | High | A path listed under `env_file:` does not exist in the stack directory. Compose fails to start the stack when a required env file is absent. | ### Port conflicts @@ -250,6 +251,9 @@ There is no tier gate: Compose Doctor is available on all plans. The five exposure intent rules activate when the stack publishes at least one host port. The "unclassified" warning clears as soon as you set an exposure intent in the Networking tab. The "port not in dossier" warning clears once you add a matching access URL in the Stack Dossier. If an intent is already set and the finding still fires, check that the intent is configured on the correct node and for the correct service. + + Compose treats unescaped `$` in environment values as variable references. Bcrypt hashes and other secrets often contain `$` characters, so an unquoted value in Compose YAML or a double-quoted `.env` line can be partially blanked out at runtime. Doctor flags this as **Literal dollar in value** and names the env key when it can, without showing part of the secret. Fix it by escaping each literal `$` as `$$` in Compose YAML, or by single-quoting the value in an env file. + The summary card shows the username and relative time: "ran 5 minutes ago by admin". This reflects the most recent run stored for this stack on the currently active node. From 4077546492885b3f94806ee024366e4eb0906707 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 5 Jul 2026 04:12:03 -0400 Subject: [PATCH 07/12] 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 --- .../__tests__/compose-doctor-service.test.ts | 36 +- .../__tests__/preflight-ack-filter.test.ts | 89 +++++ backend/src/__tests__/preflight-route.test.ts | 61 ++++ .../__tests__/update-guard-readiness.test.ts | 26 +- .../__tests__/update-guard-service.test.ts | 2 +- backend/src/routes/stacks.ts | 140 +++++++ backend/src/services/ComposeDoctorService.ts | 66 +++- backend/src/services/DatabaseService.ts | 101 +++++- backend/src/services/preflight/types.ts | 10 + backend/src/services/updateGuard/readiness.ts | 4 +- backend/src/utils/preflight-ack-filter.ts | 131 +++++++ docs/features/compose-doctor.mdx | 17 + .../StackAnatomyPanel.doctor.test.tsx | 2 +- frontend/src/components/StackAnatomyPanel.tsx | 7 +- .../components/stack/PreflightPanel.test.tsx | 22 +- .../src/components/stack/PreflightPanel.tsx | 342 ++++++++++++++++-- 16 files changed, 1002 insertions(+), 54 deletions(-) create mode 100644 backend/src/__tests__/preflight-ack-filter.test.ts create mode 100644 backend/src/utils/preflight-ack-filter.ts 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}` : ''} +
+ )} +
+ +