diff --git a/backend/src/__tests__/fleet-action-card-endpoints.test.ts b/backend/src/__tests__/fleet-action-card-endpoints.test.ts new file mode 100644 index 00000000..8203a58b --- /dev/null +++ b/backend/src/__tests__/fleet-action-card-endpoints.test.ts @@ -0,0 +1,331 @@ +/** + * Tests for the Fleet Action card preview / estimate endpoints and the dry-run + * flag added to the existing fleet-stop / fleet-prune routes. + * + * Covers: + * - POST /api/fleet/labels/match-preview (new): auth, tier, validation, real return shape. + * - POST /api/fleet/prune/estimate (new): auth, tier, validation, local + remote fan-out. + * - POST /api/fleet/labels/fleet-stop with dryRun: true: rehearses without invoking the destructive leaf. + * - POST /api/fleet/labels/fleet-prune with dryRun: true: rehearses without invoking pruneManagedOnly / pruneSystem. + */ +import { beforeAll, beforeEach, afterAll, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let mockFsStacks: string[] = []; +const pruneManagedOnly = vi.fn(); +const pruneSystem = vi.fn(); +const estimateManagedReclaim = vi.fn(); +const estimateSystemReclaim = vi.fn(); +const getContainersByStack = vi.fn(); +const stopContainer = vi.fn(); +const restartContainer = vi.fn(); +const invalidateNodeCaches = vi.fn(); + +vi.mock('../services/FileSystemService', () => ({ + FileSystemService: { + getInstance: vi.fn(() => ({ + getStacks: vi.fn(async () => mockFsStacks), + })), + }, +})); + +vi.mock('../services/DockerController', () => ({ + default: { + getInstance: vi.fn(() => ({ + pruneManagedOnly, + pruneSystem, + estimateManagedReclaim, + estimateSystemReclaim, + getContainersByStack, + stopContainer, + restartContainer, + })), + }, +})); + +vi.mock('../helpers/cacheInvalidation', () => ({ + invalidateNodeCaches, +})); + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let db: import('../services/DatabaseService').DatabaseService; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let activeBulkActions: typeof import('../routes/labels').activeBulkActions; +let labelCounter = 0; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ LicenseService } = await import('../services/LicenseService')); + ({ activeBulkActions } = await import('../routes/labels')); + const { DatabaseService } = await import('../services/DatabaseService'); + db = DatabaseService.getInstance(); + authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + // restoreAllMocks resets spies but leaves call history on module-top vi.fn() + // mocks intact; clearAllMocks zeroes that history so per-test call counts are + // not polluted by earlier tests. + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + mockFsStacks = ['alpha', 'beta']; + pruneManagedOnly.mockResolvedValue({ success: true, reclaimedBytes: 0 }); + pruneSystem.mockResolvedValue({ success: true, reclaimedBytes: 0 }); + estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 0 }); + estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 0 }); + getContainersByStack.mockResolvedValue([{ Id: 'container-1' }]); + stopContainer.mockResolvedValue(undefined); + restartContainer.mockResolvedValue(undefined); + activeBulkActions.clear(); + db.getDb().prepare('DELETE FROM stack_label_assignments').run(); + db.getDb().prepare('DELETE FROM stack_labels').run(); +}); + +async function createAssignedLabel(name: string, stacks: string[]) { + const created = await request(app) + .post('/api/labels') + .set('Authorization', authHeader) + .send({ name: `${name}-${++labelCounter}`, color: 'teal' }); + expect(created.status).toBe(201); + + for (const stack of stacks) { + const assigned = await request(app) + .put(`/api/stacks/${stack}/labels`) + .set('Authorization', authHeader) + .send({ labelIds: [created.body.id] }); + expect(assigned.status).toBe(200); + } + + return created.body as { id: number; node_id: number; name: string }; +} + +describe('POST /api/fleet/labels/match-preview', () => { + it('returns 401 without auth', async () => { + const res = await request(app) + .post('/api/fleet/labels/match-preview') + .send({ labelName: 'x' }); + expect(res.status).toBe(401); + }); + + it('returns 403 PAID_REQUIRED on community tier', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + const res = await request(app) + .post('/api/fleet/labels/match-preview') + .set('Authorization', authHeader) + .send({ labelName: 'x' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('returns 400 when labelName is missing or empty', async () => { + const a = await request(app) + .post('/api/fleet/labels/match-preview') + .set('Authorization', authHeader) + .send({}); + expect(a.status).toBe(400); + const b = await request(app) + .post('/api/fleet/labels/match-preview') + .set('Authorization', authHeader) + .send({ labelName: ' ' }); + expect(b.status).toBe(400); + }); + + it('returns matched counts and per-node stack lists for a real label', async () => { + const label = await createAssignedLabel('preview', ['alpha', 'beta']); + const res = await request(app) + .post('/api/fleet/labels/match-preview') + .set('Authorization', authHeader) + .send({ labelName: label.name }); + expect(res.status).toBe(200); + expect(res.body.matchedNodes).toBe(1); + expect(res.body.matchedStacks).toBe(2); + expect(res.body.perNode).toHaveLength(1); + expect(res.body.perNode[0].stackCount).toBe(2); + expect(res.body.perNode[0].stackNames.sort()).toEqual(['alpha', 'beta']); + }); + + it('returns zero counts for an unknown label without erroring', async () => { + const res = await request(app) + .post('/api/fleet/labels/match-preview') + .set('Authorization', authHeader) + .send({ labelName: 'does-not-exist' }); + expect(res.status).toBe(200); + expect(res.body.matchedNodes).toBe(0); + expect(res.body.matchedStacks).toBe(0); + }); +}); + +describe('POST /api/fleet/prune/estimate', () => { + it('returns 401 without auth', async () => { + const res = await request(app) + .post('/api/fleet/prune/estimate') + .send({ targets: ['images'], scope: 'managed' }); + expect(res.status).toBe(401); + }); + + it('returns 403 PAID_REQUIRED on community tier', async () => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + const res = await request(app) + .post('/api/fleet/prune/estimate') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'managed' }); + expect(res.status).toBe(403); + }); + + it('returns 400 when targets is empty', async () => { + const res = await request(app) + .post('/api/fleet/prune/estimate') + .set('Authorization', authHeader) + .send({ targets: [], scope: 'managed' }); + expect(res.status).toBe(400); + }); + + it('aggregates per-node reclaimable bytes from estimateManagedReclaim on the local node', async () => { + estimateManagedReclaim.mockImplementation(async (target: string) => + ({ reclaimableBytes: target === 'images' ? 4096 : 256 })); + const res = await request(app) + .post('/api/fleet/prune/estimate') + .set('Authorization', authHeader) + .send({ targets: ['images', 'volumes'], scope: 'managed' }); + expect(res.status).toBe(200); + expect(res.body.totalBytes).toBe(4096 + 256); + expect(res.body.perNode).toHaveLength(1); + expect(res.body.perNode[0].reachable).toBe(true); + expect(res.body.perNode[0].reclaimableBytes).toBe(4096 + 256); + expect(pruneManagedOnly).not.toHaveBeenCalled(); + expect(pruneSystem).not.toHaveBeenCalled(); + }); + + it('uses estimateSystemReclaim when scope is "all"', async () => { + estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 1024 }); + const res = await request(app) + .post('/api/fleet/prune/estimate') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'all' }); + expect(res.status).toBe(200); + expect(estimateManagedReclaim).not.toHaveBeenCalled(); + expect(estimateSystemReclaim).toHaveBeenCalled(); + expect(res.body.perNode[0].reclaimableBytes).toBe(1024); + }); + + it('marks a remote node unreachable when its estimate endpoint is down', async () => { + const remoteId = db.addNode({ + name: 'remote-est', + type: 'remote', + api_url: 'http://remote-est.example:1852', + api_token: 'tok', + compose_dir: '/app/compose', + is_default: false, + }); + try { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED')); + const res = await request(app) + .post('/api/fleet/prune/estimate') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'managed' }); + expect(res.status).toBe(200); + const remote = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId); + expect(remote.reachable).toBe(false); + expect(remote.error).toMatch(/ECONNREFUSED/); + expect(remote.reclaimableBytes).toBe(0); + } finally { + db.deleteNode(remoteId); + } + }); +}); + +describe('POST /api/fleet/labels/fleet-stop with dryRun: true', () => { + it('marks each stack dryRun: true and does not invoke containerActionForStack', async () => { + const label = await createAssignedLabel('dry-stop', ['alpha', 'beta']); + const res = await request(app) + .post('/api/fleet/labels/fleet-stop') + .set('Authorization', authHeader) + .send({ labelName: label.name, dryRun: true }); + expect(res.status).toBe(200); + expect(res.body.results).toHaveLength(1); + const node = res.body.results[0]; + expect(node.matched).toBe(true); + expect(node.stackResults).toHaveLength(2); + for (const stack of node.stackResults) { + expect(stack.success).toBe(true); + expect(stack.dryRun).toBe(true); + } + // containerActionForStack walks DockerController.stopContainer / restartContainer + // internally; if dry-run incorrectly invoked it, those mocks would record calls. + expect(stopContainer).not.toHaveBeenCalled(); + expect(restartContainer).not.toHaveBeenCalled(); + // Dry run must not bust caches. + expect(invalidateNodeCaches).not.toHaveBeenCalled(); + }); + + it('still runs the real action when dryRun is omitted', async () => { + const label = await createAssignedLabel('real-stop', ['alpha']); + const res = await request(app) + .post('/api/fleet/labels/fleet-stop') + .set('Authorization', authHeader) + .send({ labelName: label.name }); + expect(res.status).toBe(200); + // The destructive path invokes containerActionForStack, which in turn calls + // DockerController.stopContainer for each container on the matched stack. + expect(stopContainer).toHaveBeenCalled(); + // The destructive path should also have invalidated the local node's cache. + expect(invalidateNodeCaches).toHaveBeenCalled(); + }); +}); + +describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => { + it('routes to estimateManagedReclaim and marks each target dryRun: true', async () => { + estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 2048 }); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images', 'volumes'], scope: 'managed', dryRun: true }); + expect(res.status).toBe(200); + const node = res.body.results[0]; + expect(node.reachable).toBe(true); + expect(node.targets).toHaveLength(2); + for (const t of node.targets) { + expect(t.success).toBe(true); + expect(t.reclaimedBytes).toBe(2048); + expect(t.dryRun).toBe(true); + } + expect(pruneManagedOnly).not.toHaveBeenCalled(); + expect(pruneSystem).not.toHaveBeenCalled(); + expect(estimateManagedReclaim).toHaveBeenCalledTimes(2); + expect(invalidateNodeCaches).not.toHaveBeenCalled(); + }); + + it('routes to estimateSystemReclaim when scope is "all"', async () => { + estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 8192 }); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'all', dryRun: true }); + expect(res.status).toBe(200); + expect(estimateManagedReclaim).not.toHaveBeenCalled(); + expect(estimateSystemReclaim).toHaveBeenCalled(); + expect(pruneManagedOnly).not.toHaveBeenCalled(); + expect(pruneSystem).not.toHaveBeenCalled(); + expect(res.body.results[0].targets[0].reclaimedBytes).toBe(8192); + }); + + it('still invokes pruneManagedOnly when dryRun is omitted', async () => { + pruneManagedOnly.mockResolvedValue({ success: true, reclaimedBytes: 512 }); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'managed' }); + expect(res.status).toBe(200); + expect(pruneManagedOnly).toHaveBeenCalled(); + expect(estimateManagedReclaim).not.toHaveBeenCalled(); + expect(res.body.results[0].targets[0].dryRun).toBeUndefined(); + }); +}); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 61a5587a..8c950b57 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -1041,17 +1041,18 @@ fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: R fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; - const body = req.body as { labelName?: unknown } | undefined; + const body = req.body as { labelName?: unknown; dryRun?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); return; } - const { labelName } = body; + const { labelName, dryRun } = body; if (typeof labelName !== 'string' || labelName.trim().length === 0) { res.status(400).json({ error: 'labelName is required' }); return; } const trimmed = labelName.trim(); + const isDryRun = dryRun === true; try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); @@ -1068,7 +1069,8 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: if (node.type === 'local') { // Share the per-node bulk lock with `POST /api/labels/:id/action` so // a fleet-stop and a per-label action cannot double-stop the same - // containers concurrently on the same local node. + // containers concurrently on the same local node. Dry run acquires + // the same lock so the rehearsal exercises the same contention path. const lockKey = `bulk:${node.id}`; if (activeBulkActions.has(lockKey)) { return { @@ -1081,14 +1083,18 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: const fsStacks = await FileSystemService.getInstance(node.id).getStacks(); const fsStackSet = new Set(fsStacks); const validStacks = stackNames.filter(name => fsStackSet.has(name)); - const stackResults: { stackName: string; success: boolean; error?: string }[] = []; + const stackResults: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] = []; for (const stackName of validStacks) { + if (isDryRun) { + stackResults.push({ stackName, success: true, dryRun: true }); + continue; + } const outcome = await containerActionForStack(node.id, stackName, 'stop'); if (outcome.kind === 'ok') stackResults.push({ stackName, success: true }); else if (outcome.kind === 'no-containers') stackResults.push({ stackName, success: false, error: 'No containers found for this stack' }); else stackResults.push({ stackName, success: false, error: outcome.message }); } - if (stackResults.some(r => r.success)) invalidateNodeCaches(node.id); + if (!isDryRun && stackResults.some(r => r.success)) invalidateNodeCaches(node.id); return { nodeId: node.id, nodeName: node.name, matched: true, stackResults }; } finally { activeBulkActions.delete(lockKey); @@ -1105,7 +1111,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/labels/${label.id}/action`, { method: 'POST', headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'stop' }), + body: JSON.stringify({ action: 'stop', dryRun: isDryRun }), signal: AbortSignal.timeout(60000), }); if (!response.ok) { @@ -1116,7 +1122,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: stackResults: stackNames.map(stackName => ({ stackName, success: false, error: message })), }; } - const remote = (await response.json()) as { results?: { stackName: string; success: boolean; error?: string }[] }; + const remote = (await response.json()) as { results?: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] }; return { nodeId: node.id, nodeName: node.name, matched: true, stackResults: remote.results ?? [] }; } catch (err) { const errorMsg = getErrorMessage(err, 'Failed to reach remote node'); @@ -1149,7 +1155,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; - const body = req.body as { targets?: unknown; scope?: unknown } | undefined; + const body = req.body as { targets?: unknown; scope?: unknown; dryRun?: unknown } | undefined; if (!body || typeof body !== 'object') { res.status(400).json({ error: 'Request body is required' }); return; @@ -1169,8 +1175,9 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res } const targets: FleetPruneTarget[] = Array.from(dedup); const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed'; + const isDryRun = body.dryRun === true; - type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string }; + type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string; dryRun?: boolean }; type NodeResult = { nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[]; }; @@ -1196,6 +1203,13 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res let anySuccess = false; for (const target of targets) { try { + if (isDryRun) { + const estimate = scope === 'managed' + ? await dockerController.estimateManagedReclaim(target, knownStacks) + : await dockerController.estimateSystemReclaim(target, knownStacks); + targetResults.push({ target, success: true, reclaimedBytes: estimate.reclaimableBytes, dryRun: true }); + continue; + } const result = scope === 'managed' ? await dockerController.pruneManagedOnly(target, knownStacks) : await dockerController.pruneSystem(target); @@ -1205,7 +1219,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res targetResults.push({ target, success: false, reclaimedBytes: 0, error: getErrorMessage(err, 'Prune failed') }); } } - if (anySuccess) invalidateNodeCaches(node.id); + if (anySuccess && !isDryRun) invalidateNodeCaches(node.id); return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targetResults }; } finally { activeBulkActions.delete(lockKey); @@ -1232,7 +1246,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res const response = await fetch(`${baseUrl}/api/system/prune/system`, { method: 'POST', headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ target, scope }), + body: JSON.stringify({ target, scope, dryRun: isDryRun }), signal: AbortSignal.timeout(120000), }); if (!response.ok) { @@ -1242,12 +1256,14 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res targetResults.push({ target, success: false, reclaimedBytes: 0, error: message }); continue; } - const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number } | null; + const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number; dryRun?: boolean } | null; if (!remote || typeof remote.reclaimedBytes !== 'number') { targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' }); continue; } - targetResults.push({ target, success: remote.success !== false, reclaimedBytes: remote.reclaimedBytes }); + const entry: TargetResult = { target, success: remote.success !== false, reclaimedBytes: remote.reclaimedBytes }; + if (remote.dryRun) entry.dryRun = true; + targetResults.push(entry); } catch (err) { const message = getErrorMessage(err, 'Failed to reach remote node'); nodeUnreachable = message; @@ -1269,6 +1285,158 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res } }); +// ─── Fleet Actions: blast-radius preview endpoints (non-destructive) ─── +// +// Power the live readouts in the Fleet Action cards. Same auth gates as the +// destructive endpoints above so the surface stays uniform: an operator who +// can fire `fleet-stop` is also the operator who can ask how big it would be. + +// Per-label fleet preview. Walks the central node list and looks up the label +// + assignments table for each node. No remote fan-out: stack-to-label +// assignments live in the central DB even for remote nodes, populated by the +// nodes' own UIs and synced via Distributed API. +fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + if (!requireAdmin(req, res)) return; + const body = req.body as { labelName?: unknown } | undefined; + if (!body || typeof body !== 'object') { + res.status(400).json({ error: 'Request body is required' }); + return; + } + const { labelName } = body; + if (typeof labelName !== 'string' || labelName.trim().length === 0) { + res.status(400).json({ error: 'labelName is required' }); + return; + } + const trimmed = labelName.trim(); + try { + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + let matchedStacks = 0; + const perNode = nodes.map((node) => { + const label = db.getLabels(node.id).find(l => l.name === trimmed); + const stackNames = label ? db.getStacksForLabel(label.id, node.id) : []; + matchedStacks += stackNames.length; + return { + nodeId: node.id, + nodeName: node.name, + stackCount: stackNames.length, + stackNames, + }; + }); + const matchedNodes = perNode.filter(n => n.stackCount > 0).length; + res.json({ matchedNodes, matchedStacks, perNode }); + } catch (error) { + console.error('[Fleet] match-preview error:', error); + res.status(500).json({ error: getErrorMessage(error, 'Failed to compute match preview') }); + } +}); + +// Fleet-wide prune size estimate. Local node uses the controller estimate +// helper; remote nodes hit `/api/system/prune/estimate` per target. Same +// fan-out shape as `/labels/fleet-prune` minus the locks (estimation is read +// only). +fleetRouter.post('/prune/estimate', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + if (!requireAdmin(req, res)) return; + + const body = req.body as { targets?: unknown; scope?: unknown } | undefined; + if (!body || typeof body !== 'object') { + res.status(400).json({ error: 'Request body is required' }); + return; + } + const rawTargets = Array.isArray(body.targets) ? body.targets : null; + if (!rawTargets || rawTargets.length === 0) { + res.status(400).json({ error: 'targets must be a non-empty array' }); + return; + } + const dedup = new Set(); + for (const t of rawTargets) { + if (typeof t !== 'string' || !(FLEET_PRUNE_TARGETS as readonly string[]).includes(t)) { + res.status(400).json({ error: `Invalid target: ${typeof t === 'string' ? t : typeof t}` }); + return; + } + dedup.add(t as FleetPruneTarget); + } + const targets: FleetPruneTarget[] = Array.from(dedup); + const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed'; + + type NodeEstimate = { + nodeId: number; nodeName: string; reclaimableBytes: number; reachable: boolean; error?: string; + }; + + try { + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + const perNode: NodeEstimate[] = await Promise.all(nodes.map(async (node): Promise => { + if (node.type === 'local') { + try { + const knownStacks = scope === 'managed' ? await FileSystemService.getInstance(node.id).getStacks() : []; + const dockerController = DockerController.getInstance(node.id); + let nodeBytes = 0; + for (const target of targets) { + const result = scope === 'managed' + ? await dockerController.estimateManagedReclaim(target, knownStacks) + : await dockerController.estimateSystemReclaim(target, knownStacks); + nodeBytes += result.reclaimableBytes; + } + return { nodeId: node.id, nodeName: node.name, reclaimableBytes: nodeBytes, reachable: true }; + } catch (err) { + return { + nodeId: node.id, nodeName: node.name, reclaimableBytes: 0, reachable: false, + error: getErrorMessage(err, 'Failed to estimate locally'), + }; + } + } + + if (!node.api_url || !node.api_token) { + return { + nodeId: node.id, nodeName: node.name, reclaimableBytes: 0, reachable: false, + error: 'Remote node not configured', + }; + } + const baseUrl = node.api_url.replace(/\/$/, ''); + // Estimate is a live readout; fan out the per-target fetches in parallel + // so wall time matches the slowest single call rather than the sum. + // (The destructive sibling stays serial because Docker prune is internally + // serialized and one failure should short-circuit later targets there.) + const perTarget = await Promise.all(targets.map(async (target): Promise<{ bytes: number; error?: string }> => { + try { + const response = await fetch(`${baseUrl}/api/system/prune/estimate`, { + method: 'POST', + headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ target, scope }), + signal: AbortSignal.timeout(15000), + }); + if (!response.ok) { + const errBody = (await response.json().catch(() => ({}))) as { error?: string }; + return { bytes: 0, error: errBody.error || `Remote returned ${response.status}` }; + } + const remote = (await response.json().catch(() => null)) as { reclaimableBytes?: number } | null; + if (!remote || typeof remote.reclaimableBytes !== 'number') { + return { bytes: 0, error: 'Invalid response from remote node' }; + } + return { bytes: remote.reclaimableBytes }; + } catch (err) { + return { bytes: 0, error: getErrorMessage(err, 'Failed to reach remote node') }; + } + })); + const firstError = perTarget.find(t => t.error)?.error; + if (firstError) { + return { nodeId: node.id, nodeName: node.name, reclaimableBytes: 0, reachable: false, error: firstError }; + } + const nodeBytes = perTarget.reduce((sum, t) => sum + t.bytes, 0); + return { nodeId: node.id, nodeName: node.name, reclaimableBytes: nodeBytes, reachable: true }; + })); + + const totalBytes = perNode.reduce((acc, n) => acc + (n.reachable ? n.reclaimableBytes : 0), 0); + res.json({ totalBytes, perNode }); + } catch (error) { + console.error('[Fleet] prune-estimate error:', error); + res.status(500).json({ error: getErrorMessage(error, 'Failed to compute prune estimate') }); + } +}); + // ─── Fleet Snapshots (manual: Community; scheduled: Skipper+) ─── fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise => { diff --git a/backend/src/routes/labels.ts b/backend/src/routes/labels.ts index 07d26e80..b1bd48c7 100644 --- a/backend/src/routes/labels.ts +++ b/backend/src/routes/labels.ts @@ -172,12 +172,13 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo try { const id = parseIntParam(req, res, 'id', 'label ID'); if (id === null) return; - const { action } = req.body; + const { action, dryRun } = req.body; const validActions = ['deploy', 'stop', 'restart']; if (!action || !validActions.includes(action)) { res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` }); return; } + const isDryRun = dryRun === true; const nodeId = req.nodeId ?? 0; @@ -200,11 +201,17 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo const fsStackNames = new Set(fsStacks); const validStacks = stackNames.filter(name => fsStackNames.has(name)); - if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length }); + if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length, dryRun: isDryRun }); - const results: { stackName: string; success: boolean; error?: string }[] = []; + const results: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] = []; for (const stackName of validStacks) { + if (isDryRun) { + // Rehearse the action under the same lock + label resolution + fs + // intersection. Skip the destructive leaf call. + results.push({ stackName, success: true, dryRun: true }); + continue; + } try { if (action === 'deploy') { const gate = await enforcePolicyPreDeploy( @@ -235,10 +242,10 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo const succeeded = results.filter(r => r.success).length; const failed = results.length - succeeded; - console.log(`[Labels] Bulk ${sanitizeForLog(action)} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`); - if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action complete:', { id, action, total: results.length, succeeded, failed }); + console.log(`[Labels] Bulk ${sanitizeForLog(action)}${isDryRun ? ' (dry run)' : ''} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`); + if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action complete:', { id, action, total: results.length, succeeded, failed, dryRun: isDryRun }); - if (succeeded > 0) { + if (succeeded > 0 && !isDryRun) { invalidateNodeCaches(req.nodeId); } res.json({ results }); diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index 32f2575b..ba9ac534 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -49,15 +49,37 @@ systemMaintenanceRouter.post('/prune/orphans', async (req: Request, res: Respons systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; try { - const { target, scope } = req.body as { target: string; scope?: string }; + const { target, scope, dryRun } = req.body as { target: string; scope?: string; dryRun?: boolean }; if (!['containers', 'images', 'networks', 'volumes'].includes(target)) { return res.status(400).json({ error: 'Invalid prune target' }); } const pruneScope = scope === 'managed' ? 'managed' : 'all'; - console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`); + const isDryRun = dryRun === true; const dockerController = DockerController.getInstance(req.nodeId); + if (isDryRun) { + // Rehearse the destructive path: same scope resolution, same Docker + // enumeration, no remove calls. Containers have no managed estimate + // helper because pruneManagedOnly does not handle them. + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + let estimate: { reclaimableBytes: number }; + if (pruneScope === 'managed' && target !== 'containers') { + estimate = await dockerController.estimateManagedReclaim( + target as 'images' | 'volumes' | 'networks', + knownStacks, + ); + } else { + estimate = await dockerController.estimateSystemReclaim( + target as 'containers' | 'images' | 'networks' | 'volumes', + knownStacks, + ); + } + res.json({ message: 'Dry run', success: true, dryRun: true, reclaimedBytes: estimate.reclaimableBytes }); + return; + } + + console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`); let result: { success: boolean; reclaimedBytes: number }; if (pruneScope === 'managed' && target !== 'containers') { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); @@ -80,6 +102,41 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response } }); +// Non-destructive size estimate for a prune target/scope. The Fleet Actions +// "Prune fleet-wide" card calls this on each remote node to populate its live +// blast-radius readout before the operator confirms. Reuses the same Docker +// enumeration as `/prune/system` so the estimate matches what the destructive +// path would reclaim. +systemMaintenanceRouter.post('/prune/estimate', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const { target, scope } = req.body as { target: string; scope?: string }; + if (!['containers', 'images', 'networks', 'volumes'].includes(target)) { + return res.status(400).json({ error: 'Invalid prune target' }); + } + const pruneScope = scope === 'managed' ? 'managed' : 'all'; + const dockerController = DockerController.getInstance(req.nodeId); + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + + let result: { reclaimableBytes: number }; + if (pruneScope === 'managed' && target !== 'containers') { + result = await dockerController.estimateManagedReclaim( + target as 'images' | 'volumes' | 'networks', + knownStacks, + ); + } else { + result = await dockerController.estimateSystemReclaim( + target as 'containers' | 'images' | 'networks' | 'volumes', + knownStacks, + ); + } + res.json({ reclaimableBytes: result.reclaimableBytes }); + } catch (error: unknown) { + console.error('Prune estimate error:', error); + res.status(500).json({ error: 'Failed to estimate reclaimable bytes' }); + } +}); + systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) => { try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index ff9c1ff1..9a498235 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -386,6 +386,69 @@ class DockerController { return { success: true, reclaimedBytes }; } + /** + * Non-destructive sibling of `pruneManagedOnly` used by the dry-run path and + * the `/api/system/prune/estimate` route. Walks the same filter rules but + * does not call `.remove()`. Kept structurally parallel to the destructive + * method so the two stay in lockstep when the enumeration logic changes. + */ + public async estimateManagedReclaim( + target: 'images' | 'volumes' | 'networks', + knownStackNames: string[], + ): Promise<{ reclaimableBytes: number }> { + const knownSet = new Set(knownStackNames); + const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames); + let reclaimableBytes = 0; + + if (target === 'volumes') { + const rawVolumeData = await this.docker.listVolumes(); + const rawVolumes: any[] = (this.validateApiData(rawVolumeData)).Volumes || []; + const prunable = rawVolumes.filter((v: any) => { + return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack) + && (v.UsageData?.RefCount ?? 1) === 0; + }); + for (const vol of prunable) reclaimableBytes += vol.UsageData?.Size ?? 0; + } else if (target === 'networks') { + // Networks have no on-disk size; the dry-run still reports 0 so the + // shape matches the destructive path. + } else if (target === 'images') { + const allContainers = await this.docker.listContainers({ all: true }); + const resolvedBase = path.resolve(COMPOSE_DIR); + const absDirToStack = DockerController.buildAbsDirMap(knownStackNames); + const unmanagedImageIds = new Set(); + for (const c of allContainers as any[]) { + const stack = DockerController.resolveContainerStack( + c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + if (!stack) unmanagedImageIds.add(c.ImageID); + } + const rawImages = await this.docker.listImages({ all: false }); + const prunable = (rawImages as any[]).filter((img: any) => + img.Containers === 0 && !unmanagedImageIds.has(img.Id), + ); + for (const img of prunable) reclaimableBytes += img.Size ?? 0; + } + + return { reclaimableBytes }; + } + + /** + * Non-destructive estimate for the `all` (system) prune scope. Reuses the + * same disk-usage source as the resources page so the readout matches what + * the operator already sees there. + */ + public async estimateSystemReclaim( + target: 'containers' | 'images' | 'networks' | 'volumes', + knownStackNames: string[], + ): Promise<{ reclaimableBytes: number }> { + const df = await this.getDiskUsageClassified(knownStackNames); + if (target === 'images') return { reclaimableBytes: df.reclaimableImages }; + if (target === 'containers') return { reclaimableBytes: df.reclaimableContainers }; + if (target === 'volumes') return { reclaimableBytes: df.reclaimableVolumes }; + // Networks have no on-disk size. + return { reclaimableBytes: 0 }; + } + public async getDiskUsageClassified(knownStackNames: string[]): Promise<{ reclaimableImages: number; reclaimableContainers: number; diff --git a/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx b/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx index dc234eee..debaa1d4 100644 --- a/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx +++ b/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx @@ -1,4 +1,4 @@ -import { Square, Tags, Eraser } from 'lucide-react'; +import { Tags } from 'lucide-react'; import { useLicense } from '@/context/LicenseContext'; import type { FleetNode } from '@/components/FleetView/types'; import { LabelFleetStopCard } from './cards/LabelFleetStopCard'; @@ -19,22 +19,24 @@ export function FleetActionsTab({ nodes }: Props) { } if (!isPaid) { - // Every action is Skipper+. Community users see a calm empty state with - // upgrade context rather than a stripped-down launcher. - return ( - - ); + return ; } + // Grid breakpoints per audit §18.7: 1 / 2 / 3 columns at 760 / 1280. The + // 4-column breakpoint is reserved for when a 4th card lands; v1 has three + // so it is intentionally omitted to avoid a hanging gap on wide displays. return ( -
- - - +
+ + +
); } +// Note: the §18.8 Community-tier "single full-width card with cyan rail + locked +// footer (shell only, no upsell)" redesign is deferred to a follow-up PR. +// Tracked in docs/internal/refactor/fleet-action-card-migration.md. function EmptyState() { return (
diff --git a/frontend/src/components/fleet/FleetActions/cards/BulkLabelAssignCard.tsx b/frontend/src/components/fleet/FleetActions/cards/BulkLabelAssignCard.tsx index ccc9cb25..a1fe800a 100644 --- a/frontend/src/components/fleet/FleetActions/cards/BulkLabelAssignCard.tsx +++ b/frontend/src/components/fleet/FleetActions/cards/BulkLabelAssignCard.tsx @@ -1,11 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; -import type { LucideIcon } from 'lucide-react'; -import { Loader2, Server } from 'lucide-react'; -import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { ConfirmModal } from '@/components/ui/modal'; import { Checkbox } from '@/components/ui/checkbox'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { FleetActionCard } from '@/components/ui/fleet-action-card'; +import { SheetSection } from '@/components/ui/system-sheet'; import { LabelPill } from '@/components/LabelPill'; import { fetchForNode } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; @@ -13,20 +11,17 @@ import { cn } from '@/lib/utils'; import type { FleetNode } from '@/components/FleetView/types'; import type { Label } from '@/components/label-types'; import { ResultsList, type ResultRow } from '../ResultsList'; -import { TONE_RAIL, TONE_BG, type AccentTone } from './tone'; interface NodeStackResult { stackName: string; success: boolean; error?: string } interface Props { nodes: FleetNode[]; - icon: LucideIcon; - accentTone: AccentTone; } -export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) { - const [selectedNodeId, setSelectedNodeId] = useState(() => { +export function BulkLabelAssignCard({ nodes }: Props) { + const [selectedNodeId, setSelectedNodeId] = useState(() => { const local = nodes.find(n => n.type === 'local'); - return String(local?.id ?? nodes[0]?.id ?? ''); + return Number(local?.id ?? nodes[0]?.id ?? 0); }); const [stacks, setStacks] = useState([]); const [labels, setLabels] = useState([]); @@ -37,12 +32,10 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) { const [running, setRunning] = useState(false); const [results, setResults] = useState([]); - const nodeId = useMemo(() => Number(selectedNodeId) || 0, [selectedNodeId]); - const selectedNode = useMemo(() => nodes.find(n => n.id === nodeId), [nodes, nodeId]); + const selectedNode = useMemo(() => nodes.find(n => n.id === selectedNodeId), [nodes, selectedNodeId]); - // Load stacks + labels whenever the node changes. useEffect(() => { - if (!nodeId) return; + if (!selectedNodeId) return; let cancelled = false; async function load() { setLoadingLists(true); @@ -51,8 +44,8 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) { setResults([]); try { const [stacksRes, labelsRes] = await Promise.all([ - fetchForNode(`/fleet/node/${nodeId}/stacks`, nodeId), - fetchForNode('/labels', nodeId), + fetchForNode(`/fleet/node/${selectedNodeId}/stacks`, selectedNodeId), + fetchForNode('/labels', selectedNodeId), ]); const stacksList = stacksRes.ok ? ((await stacksRes.json()) as string[]) : []; const labelsList = labelsRes.ok ? ((await labelsRes.json()) as Label[]) : []; @@ -71,7 +64,7 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) { } load(); return () => { cancelled = true; }; - }, [nodeId]); + }, [selectedNodeId]); function toggleStack(stackName: string) { setSelectedStacks(prev => { @@ -93,6 +86,11 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) { if (selectedStacks.size === stacks.length) setSelectedStacks(new Set()); else setSelectedStacks(new Set(stacks)); } + function clearSelection() { + setSelectedStacks(new Set()); + setSelectedLabels(new Set()); + setResults([]); + } async function run() { if (selectedStacks.size === 0) return; @@ -101,7 +99,7 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) { const toastId = toast.loading(`Assigning labels to ${assignments.length} stack${assignments.length === 1 ? '' : 's'}…`); setRunning(true); try { - const res = await fetchForNode('/fleet-actions/labels/bulk-assign', nodeId, { + const res = await fetchForNode('/fleet-actions/labels/bulk-assign', selectedNodeId, { method: 'POST', body: JSON.stringify({ assignments }), }); @@ -131,147 +129,159 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) { } } + const blastValue = useMemo(() => { + if (selectedStacks.size === 0 || selectedLabels.size === 0 || !selectedNode) return 'awaiting target'; + const stackLabel = `${selectedStacks.size} ${selectedStacks.size === 1 ? 'stack' : 'stacks'}`; + // "local · " prefix triggers the primitive's cyan-dot path per §18.5. + if (selectedNode.type === 'local') return `local · ${stackLabel}`; + return `${selectedNode.name} · ${stackLabel}`; + }, [selectedNode, selectedStacks.size, selectedLabels.size]); + return ( - - - -
- - - -
-

Bulk label assign

-

- Pick a node, multi-select stacks, and replace their labels in one shot. -

-
-
+ <> + setConfirmOpen(true), + variant: 'primary', + disabled: running || selectedStacks.size === 0 || selectedLabels.size === 0, + }} + footerContext="Reversible · yes · reassign anytime" + > + + + -
-
- - - {loadingLists && Loading…} -
- -
-
- - Stacks ({selectedStacks.size}/{stacks.length}) + 0 + ? (selectedStacks.size === stacks.length ? 'all selected' : 'multi-select') + : undefined} + > +
+ {stacks.length === 0 && ( + + {loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'} - {stacks.length > 0 && ( - - )} -
-
- {stacks.length === 0 && ( - - {loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'} - - )} - {stacks.map(stackName => ( - - ))} -
-
- -
-
- Labels ({selectedLabels.size}/{labels.length}) -
-
- {labels.length === 0 && ( - - {loadingLists ? 'Loading…' : selectedNode ? `No labels defined on ${selectedNode.name}.` : ''} - - )} - {labels.map(label => ( - !running && toggleLabel(label.id)} /> - ))} -
+ {stackName} + + ))}
+ {stacks.length > 0 && ( + + )} + -

+ +

+ {labels.length === 0 && ( + + {loadingLists ? 'Loading…' : selectedNode ? `No labels defined on ${selectedNode.name}.` : ''} + + )} + {labels.map(label => ( + !running && toggleLabel(label.id)} + /> + ))} +
+

Selected labels replace each chosen stack's existing label set on this node. Selecting no labels clears assignments.

+ -
- - {!running && results.length > 0 && ( - - )} -
+ {results.length > 0 && ( + + + + )} + - {results.length > 0 && ( - - )} -
- - { if (!open) setConfirmOpen(false); }} - variant="default" - kicker="Bulk label assign" - title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`} - description={ - selectedLabels.size === 0 - ? 'No labels selected, this will clear existing assignments on the selected stacks.' - : `Each selected stack's existing label set on ${selectedNode?.name ?? 'this node'} will be replaced with the chosen labels.` - } - confirmLabel="Apply" - confirming={running} - onConfirm={run} - /> - - + { if (!open) setConfirmOpen(false); }} + variant="default" + kicker="Bulk label assign" + title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`} + description={ + selectedLabels.size === 0 + ? 'No labels selected, this will clear existing assignments on the selected stacks.' + : `Each selected stack's existing label set on ${selectedNode?.name ?? 'this node'} will be replaced with the chosen labels.` + } + confirmLabel="Apply" + confirming={running} + onConfirm={run} + /> + + ); +} + +interface NodeSegmentedProps { + nodes: FleetNode[]; + value: number; + onChange: (id: number) => void; + disabled: boolean; +} + +function NodeSegmented({ nodes, value, onChange, disabled }: NodeSegmentedProps) { + return ( +
+ {nodes.map(n => { + const active = n.id === value; + return ( + + ); + })} +
); } diff --git a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx index ea047db7..a2f8cebb 100644 --- a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx +++ b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx @@ -1,16 +1,14 @@ -import { useState } from 'react'; -import type { LucideIcon } from 'lucide-react'; -import { Loader2, AlertTriangle } from 'lucide-react'; -import { Card, CardContent } from '@/components/ui/card'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { ConfirmModal } from '@/components/ui/modal'; +import { FleetActionCard } from '@/components/ui/fleet-action-card'; +import { SheetSection } from '@/components/ui/system-sheet'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { cn, formatBytes } from '@/lib/utils'; import type { FleetNode } from '@/components/FleetView/types'; import { ResultsList, type ResultRow } from '../ResultsList'; -import { TONE_RAIL, TONE_BG, type AccentTone } from './tone'; type PruneTarget = 'images' | 'volumes' | 'networks'; type PruneScope = 'managed' | 'all'; @@ -21,24 +19,35 @@ const ALL_TARGETS: ReadonlyArray<{ id: PruneTarget; label: string }> = [ { id: 'networks', label: 'Networks' }, ]; -interface TargetResult { target: PruneTarget; success: boolean; reclaimedBytes: number; error?: string } +interface TargetResult { target: PruneTarget; success: boolean; reclaimedBytes: number; error?: string; dryRun?: boolean } interface FleetPruneNodeResult { nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[]; } +interface PruneEstimateNode { nodeId: number; nodeName: string; reclaimableBytes: number; reachable: boolean; error?: string } +interface PruneEstimateResponse { totalBytes: number; perNode: PruneEstimateNode[] } + +type EstimateState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'unavailable' } + | { kind: 'ready'; data: PruneEstimateResponse }; + interface Props { nodes: FleetNode[]; - icon: LucideIcon; - accentTone: AccentTone; } -export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) { +const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]'; +const ESTIMATE_ROW_LIMIT = 6; + +export function FleetPruneCard({ nodes }: Props) { const nodeCount = nodes.length; const [targets, setTargets] = useState>(new Set(['images'])); const [scope, setScope] = useState('managed'); const [confirmOpen, setConfirmOpen] = useState(false); const [running, setRunning] = useState(false); const [results, setResults] = useState([]); + const [estimate, setEstimate] = useState({ kind: 'idle' }); const toggleTarget = (target: PruneTarget) => { setTargets(prev => { @@ -49,16 +58,53 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) { }); }; - async function run() { + // Re-estimate when the operator's choices change. Debounced because each + // tick fans out per-target HTTP to every remote node; back-to-back clicks + // on the target checkboxes would otherwise pile concurrent fleet-wide fans + // onto the backend. + const estimateDebounceRef = useRef | null>(null); + useEffect(() => { + if (estimateDebounceRef.current) clearTimeout(estimateDebounceRef.current); + if (targets.size === 0) { + setEstimate({ kind: 'idle' }); + return; + } + let cancelled = false; + setEstimate({ kind: 'loading' }); + estimateDebounceRef.current = setTimeout(async () => { + try { + const res = await apiFetch('/fleet/prune/estimate', { + method: 'POST', + body: JSON.stringify({ targets: Array.from(targets), scope }), + }); + if (cancelled) return; + if (res.status === 404 || !res.ok) { + setEstimate({ kind: 'unavailable' }); + return; + } + const data = (await res.json()) as PruneEstimateResponse; + if (!cancelled) setEstimate({ kind: 'ready', data }); + } catch { + if (!cancelled) setEstimate({ kind: 'unavailable' }); + } + }, 350); + return () => { + cancelled = true; + if (estimateDebounceRef.current) clearTimeout(estimateDebounceRef.current); + }; + }, [targets, scope]); + + async function run(opts: { dryRun: boolean }) { if (targets.size === 0) return; const selected = Array.from(targets); - const toastId = toast.loading(`Pruning ${selected.join(', ')} across the fleet…`); + const verb = opts.dryRun ? 'Dry-running prune of' : 'Pruning'; + const toastId = toast.loading(`${verb} ${selected.join(', ')} across the fleet…`); setRunning(true); setResults([]); try { const res = await apiFetch('/fleet/labels/fleet-prune', { method: 'POST', - body: JSON.stringify({ targets: selected, scope }), + body: JSON.stringify({ targets: selected, scope, dryRun: opts.dryRun }), }); const body = await res.json().catch(() => ({})); toast.dismiss(toastId); @@ -73,7 +119,7 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) { return { key: `node-${node.nodeId}`, label: node.reachable - ? `${node.nodeName} · ${formatBytes(totalBytes)}` + ? `${node.nodeName} · ${formatBytes(totalBytes)}${opts.dryRun ? ' (dry run)' : ''}` : `${node.nodeName} (unreachable)`, success: allOk, error: node.reachable ? undefined : node.error, @@ -92,7 +138,9 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) { (sum, n) => sum + n.targets.reduce((s, t) => s + (t.reclaimedBytes ?? 0), 0), 0, ); - if (okNodes === totalNodes && totalNodes > 0) { + if (opts.dryRun) { + toast.success(`Dry run: ${formatBytes(totalReclaimed)} would be reclaimed across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`); + } else if (okNodes === totalNodes && totalNodes > 0) { toast.success(`Reclaimed ${formatBytes(totalReclaimed)} across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`); } else if (okNodes === 0) { toast.error('Prune failed on every node. See results below.'); @@ -108,130 +156,171 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) { } } - const targetCount = targets.size; const isAllScope = scope === 'all'; + const blastValue = useMemo(() => { + if (targets.size === 0) return 'awaiting target'; + if (estimate.kind === 'loading') return '~ estimating…'; + if (estimate.kind === 'unavailable') return '~ estimate unavailable'; + if (estimate.kind === 'ready') { + const { totalBytes } = estimate.data; + if (totalBytes === 0) return '0 reclaimable'; + return `~ ${formatBytes(totalBytes)} reclaimable`; + } + return 'awaiting target'; + }, [targets.size, estimate]); + + const blastTone = estimate.kind === 'loading' || estimate.kind === 'unavailable' ? 'muted' as const : undefined; + return ( - - - -
- - - -
-

Prune Docker resources fleet-wide

-

- Reclaim space on {nodeCount} node{nodeCount === 1 ? '' : 's'} by removing unused images, volumes, and networks. Reclaimed bytes are approximate. -

-
-
- -
-
-
Targets
-
- {ALL_TARGETS.map(t => ( - - ))} -
-
- -
-
Scope
-
- - -
-

- {scope === 'managed' - ? 'Restricts to resources owned by stacks Sencho manages.' - : 'Removes every unused resource, including workloads Sencho does not manage.'} -

+ toggleTarget(t.id)} + disabled={running} + /> + {t.label} + + ))}
+ -
+ +
+ - {!running && results.length > 0 && ( - - )}
+

+ {scope === 'managed' + ? 'Restricts to resources owned by stacks Sencho manages.' + : 'Removes every unused resource, including workloads Sencho does not manage.'} +

+
- {results.length === 0 && !running && ( -
-
- - - Prune is destructive and cannot be undone. Each target is run serially per node; reclaimed bytes appear per node and per target below. - -
-
- )} + {targets.size > 0 && } - {results.length > 0 && ( - - )} -
+ {results.length > 0 && ( + + + + )} + - { if (!open) setConfirmOpen(false); }} - variant="destructive" - kicker="Fleet prune" - title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'} - description={ - isAllScope - ? 'This runs docker prune --all on every reachable node. Any image, volume, or network not currently in use will be deleted, including resources from workloads Sencho does not manage. This cannot be undone.' - : 'Sencho will remove unused Docker resources owned by stacks known to this fleet on every reachable node. Active resources are not touched.' - } - confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'} - confirming={running} - onConfirm={run} - /> - - + { if (!open) setConfirmOpen(false); }} + variant="destructive" + kicker="Fleet prune" + title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'} + description={ + isAllScope + ? 'This runs docker prune --all on every reachable node. Any image, volume, or network not currently in use will be deleted, including resources from workloads Sencho does not manage. This cannot be undone.' + : 'Sencho will remove unused Docker resources owned by stacks known to this fleet on every reachable node. Active resources are not touched.' + } + confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'} + confirming={running} + onConfirm={() => run({ dryRun: false })} + /> + + ); +} + +function EstimateSection({ estimate }: { estimate: EstimateState }) { + if (estimate.kind === 'idle' || estimate.kind === 'loading') { + return ( + +
walking each node's docker daemon
+
+ ); + } + if (estimate.kind === 'unavailable') { + return ( + +
estimate endpoint did not respond
+
+ ); + } + const { perNode } = estimate.data; + const visible = perNode.slice(0, ESTIMATE_ROW_LIMIT); + const remaining = perNode.length - visible.length; + return ( + +
+
    + {visible.map((n) => ( +
  • + + {n.reachable ? 'OK' : '--'} + + {n.nodeName} + + {n.reachable ? formatBytes(n.reclaimableBytes) : (n.error ?? 'unreachable')} + +
  • + ))} + {remaining > 0 && ( +
  • + + {remaining} more node{remaining === 1 ? '' : 's'} +
  • + )} +
+
+
); } diff --git a/frontend/src/components/fleet/FleetActions/cards/LabelFleetStopCard.tsx b/frontend/src/components/fleet/FleetActions/cards/LabelFleetStopCard.tsx index b2c0cc67..36a04327 100644 --- a/frontend/src/components/fleet/FleetActions/cards/LabelFleetStopCard.tsx +++ b/frontend/src/components/fleet/FleetActions/cards/LabelFleetStopCard.tsx @@ -1,19 +1,16 @@ -import { useEffect, useState } from 'react'; -import type { LucideIcon } from 'lucide-react'; -import { Loader2, AlertTriangle } from 'lucide-react'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ConfirmModal } from '@/components/ui/modal'; import { Input } from '@/components/ui/input'; +import { FleetActionCard } from '@/components/ui/fleet-action-card'; +import { SheetSection } from '@/components/ui/system-sheet'; import { apiFetch, fetchForNode } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; import type { FleetNode } from '@/components/FleetView/types'; import type { Label } from '@/components/label-types'; import { ResultsList, type ResultRow } from '../ResultsList'; -import { TONE_RAIL, TONE_BG, type AccentTone } from './tone'; -interface NodeStackResult { stackName: string; success: boolean; error?: string } +interface NodeStackResult { stackName: string; success: boolean; error?: string; dryRun?: boolean } interface FleetStopNodeResult { nodeId: number; nodeName: string; @@ -21,21 +18,31 @@ interface FleetStopNodeResult { stackResults: NodeStackResult[]; } +interface MatchPreviewNode { nodeId: number; nodeName: string; stackCount: number; stackNames: string[] } +interface MatchPreviewResponse { matchedNodes: number; matchedStacks: number; perNode: MatchPreviewNode[] } + +type PreviewState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'unavailable' } + | { kind: 'ready'; data: MatchPreviewResponse }; + interface Props { nodes: FleetNode[]; - icon: LucideIcon; - accentTone: AccentTone; } -export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) { +const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]'; +const PREVIEW_ROW_LIMIT = 6; + +export function LabelFleetStopCard({ nodes }: Props) { const [labelName, setLabelName] = useState(''); const [knownLabelNames, setKnownLabelNames] = useState([]); const [confirmOpen, setConfirmOpen] = useState(false); const [running, setRunning] = useState(false); const [results, setResults] = useState([]); + const [preview, setPreview] = useState({ kind: 'idle' }); - // Aggregate label names across reachable nodes for autocomplete. Offline - // nodes are skipped so the page-load fanout doesn't hang on dead remotes. + // Aggregate label names across reachable nodes for autocomplete. useEffect(() => { let cancelled = false; async function loadSuggestions() { @@ -48,7 +55,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) { const list = (await res.json()) as Label[]; for (const l of list) names.add(l.name); } catch { - /* ignore — this node is unreachable, not user-facing */ + /* unreachable node, not user-facing */ } })); if (!cancelled) setKnownLabelNames(Array.from(names).sort()); @@ -57,16 +64,68 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) { return () => { cancelled = true; }; }, [nodes]); - async function run() { + // Debounced live preview. The blast-radius readout and the preview section + // both read from the same state. + const debounceRef = useRef | null>(null); + useEffect(() => { + const trimmed = labelName.trim(); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (trimmed.length === 0) { + setPreview({ kind: 'idle' }); + return; + } + setPreview({ kind: 'loading' }); + debounceRef.current = setTimeout(async () => { + try { + const res = await apiFetch('/fleet/labels/match-preview', { + method: 'POST', + body: JSON.stringify({ labelName: trimmed }), + }); + if (res.status === 404) { + setPreview({ kind: 'unavailable' }); + return; + } + if (!res.ok) { + setPreview({ kind: 'unavailable' }); + return; + } + const data = (await res.json()) as MatchPreviewResponse; + setPreview({ kind: 'ready', data }); + } catch { + setPreview({ kind: 'unavailable' }); + } + }, 500); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [labelName]); + + const blastValue = useMemo(() => { + const trimmed = labelName.trim(); + if (trimmed.length === 0) return 'awaiting target'; + if (preview.kind === 'loading') return 'resolving…'; + if (preview.kind === 'unavailable') return 'preview unavailable'; + if (preview.kind === 'ready') { + const { matchedNodes, matchedStacks } = preview.data; + if (matchedStacks === 0 || matchedNodes === 0) return '0 nodes match'; + return `${matchedStacks} stacks · ${matchedNodes} nodes`; + } + return 'awaiting target'; + }, [labelName, preview]); + + const blastTone = preview.kind === 'loading' || preview.kind === 'unavailable' ? 'muted' as const : undefined; + + async function run(opts: { dryRun: boolean }) { const trimmed = labelName.trim(); if (!trimmed) return; - const toastId = toast.loading(`Stopping stacks labeled "${trimmed}" across the fleet…`); + const verb = opts.dryRun ? 'Dry-running' : 'Stopping'; + const toastId = toast.loading(`${verb} stacks labeled "${trimmed}" across the fleet…`); setRunning(true); setResults([]); try { const res = await apiFetch('/fleet/labels/fleet-stop', { method: 'POST', - body: JSON.stringify({ labelName: trimmed }), + body: JSON.stringify({ labelName: trimmed, dryRun: opts.dryRun }), }); const body = await res.json().catch(() => ({})); toast.dismiss(toastId); @@ -78,7 +137,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) { const rows: ResultRow[] = apiResults.map((node) => ({ key: `node-${node.nodeId}`, label: node.matched - ? `${node.nodeName} · ${node.stackResults.length} stack${node.stackResults.length === 1 ? '' : 's'}` + ? `${node.nodeName} · ${node.stackResults.length} stack${node.stackResults.length === 1 ? '' : 's'}${opts.dryRun ? ' (dry run)' : ''}` : `${node.nodeName} (no matching label)`, success: node.matched && node.stackResults.every(s => s.success), error: node.matched ? undefined : 'Label not present', @@ -95,6 +154,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) { const ok = stacksTouched.filter(s => s.success).length; const failed = stacksTouched.length - ok; if (matchedNodes === 0) toast.info('No nodes have a label by that name.'); + else if (opts.dryRun) toast.success(`Dry run: would stop ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`); else if (failed === 0 && ok > 0) toast.success(`Stopped ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`); else if (ok === 0 && failed === 0) toast.info('Label matched but no stacks were assigned to it.'); else toast.warning(`${ok} stopped, ${failed} failed. See results below.`); @@ -107,92 +167,144 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) { } } + const trimmed = labelName.trim(); + const previewSection = renderPreviewSection(preview, trimmed); + + // Freshness placeholder: until a run-record store exists, the footer carries + // reversibility alone. Add freshness once a /fleet/labels/last-fleet-stop + // endpoint or equivalent ships. + // TODO(freshness): emit "last fleet-stop {age}" once the run-record source lands. + const footerContext = 'Reversible · no'; + return ( - - - -
- - - -
-

Stop fleet by label

-

- Stop every stack labeled with this name on every node. Labels are matched by name across the fleet. -

-
-
+ <> + run({ dryRun: true }), + disabled: running || trimmed.length === 0, + }} + primaryAction={{ + label: 'Stop fleet', + onClick: () => setConfirmOpen(true), + variant: 'destructive', + disabled: running || trimmed.length === 0, + }} + footerContext={footerContext} + > + + setLabelName(e.target.value)} + placeholder="e.g. production" + className="h-9 text-sm" + disabled={running} + /> + + {knownLabelNames.map(n => + -
-
- - setLabelName(e.target.value)} - placeholder="e.g. production" - className="h-9 text-sm" - disabled={running} - /> - - {knownLabelNames.map(n => -
+ {previewSection} -
- - {!running && results.length > 0 && ( - - )} -
+ {results.length > 0 && ( + + + + )} + - {results.length === 0 && !running && ( -
-
- - - Different nodes can have their own label rows. Stops are dispatched per node and report - per-stack results below. - -
-
- )} - - {results.length > 0 && ( - - )} -
- - { if (!open) setConfirmOpen(false); }} - variant="destructive" - kicker="Fleet stop" - title={`Stop all stacks labeled "${labelName.trim()}"?`} - description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted." - confirmLabel="Stop fleet" - confirming={running} - onConfirm={run} - /> -
-
+ { if (!open) setConfirmOpen(false); }} + variant="destructive" + kicker="Fleet stop" + title={`Stop all stacks labeled "${trimmed}"?`} + description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted." + confirmLabel="Stop fleet" + confirming={running} + onConfirm={() => run({ dryRun: false })} + /> + + ); +} + +function renderPreviewSection(preview: PreviewState, trimmed: string) { + if (trimmed.length === 0) return null; + if (preview.kind === 'loading') { + return ( + +
looking up label across the fleet
+
+ ); + } + if (preview.kind === 'unavailable') { + return ( + +
preview endpoint did not respond
+
+ ); + } + if (preview.kind === 'ready') { + const { matchedStacks, matchedNodes, perNode } = preview.data; + if (matchedStacks === 0) { + return ( + +
no node has a label by that name
+
+ ); + } + return ( + + + + ); + } + return null; +} + +interface PreviewWellProps { + perNode: MatchPreviewNode[]; +} + +function PreviewWell({ perNode }: PreviewWellProps) { + const flat = perNode.flatMap(n => n.stackNames.map(s => ({ stack: s, node: n.nodeName }))); + const visible = flat.slice(0, PREVIEW_ROW_LIMIT); + const remaining = flat.length - visible.length; + const remainingNodes = remaining > 0 + ? new Set(flat.slice(PREVIEW_ROW_LIMIT).map(r => r.node)).size + : 0; + return ( +
+
    + {visible.map((row, i) => ( +
  • + + UP + + {row.stack} + {row.node} +
  • + ))} + {remaining > 0 && ( +
  • + + {remaining} more across {remainingNodes} node{remainingNodes === 1 ? '' : 's'} +
  • + )} +
+
); } diff --git a/frontend/src/components/fleet/FleetActions/cards/tone.ts b/frontend/src/components/fleet/FleetActions/cards/tone.ts deleted file mode 100644 index 962f6b2e..00000000 --- a/frontend/src/components/fleet/FleetActions/cards/tone.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Tone palette shared by the Fleet Actions cards. The cards live as siblings -// under `cards/`, so the lookup tables sit next to them rather than hoisting -// to a global tokens module. - -export type AccentTone = 'rose' | 'purple' | 'amber'; - -export const TONE_RAIL: Record = { - rose: 'bg-[var(--label-rose)]', - purple: 'bg-[var(--label-purple)]', - amber: 'bg-[var(--label-amber)]', -}; - -export const TONE_BG: Record = { - rose: 'bg-[var(--label-rose-bg)] text-[var(--label-rose)]', - purple: 'bg-[var(--label-purple-bg)] text-[var(--label-purple)]', - amber: 'bg-[var(--label-amber-bg)] text-[var(--label-amber)]', -}; diff --git a/frontend/src/components/ui/fleet-action-card.tsx b/frontend/src/components/ui/fleet-action-card.tsx new file mode 100644 index 00000000..c6ca5133 --- /dev/null +++ b/frontend/src/components/ui/fleet-action-card.tsx @@ -0,0 +1,209 @@ +import * as React from 'react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +// Tracked-mono kicker. The toolbar/footer band and the blast-radius readout +// share `0.18em`; SheetSection headers inside the body use `0.22em` (set in +// system-sheet.tsx). +const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]'; + +export type FleetActionClass = 'destructive' | 'transformative' | 'maintenance'; +export type BlastRadiusTone = 'warning' | 'success' | 'muted'; +export type PrimaryActionVariant = 'primary' | 'destructive'; + +export interface FleetActionCardProps { + /** Crumb segments. Last segment renders in --stat-title; the rest in --stat-subtitle with separators. */ + crumb: string[]; + /** Italic serif noun for the action (e.g. "Stop by label."). Section rung. */ + name: string; + /** One-line mono summary of the action's shape (e.g. "label-match · per-node fan-out"). */ + meta: string; + /** Closed set; drives the chip color, the blast dot color, and the primary-button variant invariant. */ + actionClass: FleetActionClass; + /** + * Live blast-radius readout shown in the toolbar slot. Literal-prefix rules: + * - "awaiting target" → dot muted, text muted. + * - "0 ..." → dot muted, primary + secondary forced-disabled. + * - "local · ..." → dot in --brand (cyan) instead of the class color. + * - Otherwise → dot in the class color; tone overrides text color only. + */ + blastRadius: { value: string; tone?: BlastRadiusTone }; + /** Optional outline secondary action (Dry run / Reset). Disabled automatically on "awaiting"/"0 ..." readouts. */ + secondaryAction?: { label: string; onClick: () => void; disabled?: boolean }; + /** Required primary action. The variant must follow the action-class invariants documented below. */ + primaryAction: { label: string; onClick: () => void; variant: PrimaryActionVariant; disabled?: boolean }; + /** Tracked-mono footer line. Omits the band entirely when undefined. */ + footerContext?: string; + /** `` blocks. */ + children: React.ReactNode; +} + +const CLASS_LABEL: Record = { + destructive: 'Destructive', + transformative: 'Transformative', + maintenance: 'Maintenance', +}; + +const CHIP_CLASS: Record = { + destructive: 'border-destructive/40 bg-destructive/10 text-destructive', + transformative: 'border-action-transformative/40 bg-action-transformative/10 text-action-transformative', + maintenance: 'border-warning/40 bg-warning/10 text-warning', +}; + +const DOT_CLASS: Record = { + destructive: 'bg-destructive', + transformative: 'bg-action-transformative', + maintenance: 'bg-warning', +}; + +const TONE_TEXT_CLASS: Record = { + warning: 'text-warning', + success: 'text-success', + muted: 'text-stat-icon', +}; + +export function FleetActionCard(props: FleetActionCardProps) { + const { + crumb, name, meta, actionClass, blastRadius, + secondaryAction, primaryAction, footerContext, children, + } = props; + + // Literal-prefix detection per audit §18.5. These strings are the source of + // truth callers pass in; do NOT layer additional state props onto the + // primitive for what the prefix already expresses. + const isAwaitingTarget = blastRadius.value === 'awaiting target'; + const isZeroMatch = blastRadius.value.startsWith('0 '); + const isLocalScope = blastRadius.value.startsWith('local · '); + const forceDisable = isAwaitingTarget || isZeroMatch; + + const dotClass = (isAwaitingTarget || isZeroMatch) + ? 'bg-stat-subtitle/50' + : isLocalScope + ? 'bg-brand' + : DOT_CLASS[actionClass]; + + const blastTextClass = (isAwaitingTarget || isZeroMatch) + ? 'text-stat-icon' + : blastRadius.tone + ? TONE_TEXT_CLASS[blastRadius.tone] + : 'text-stat-value'; + + // Dev-mode invariant: a maintenance card with a destructive primary should + // also signal irreversibility in its footer, otherwise the visual class + // (amber) and the action variant (rose) disagree about safety. + if (import.meta.env?.MODE !== 'production') { + if (actionClass === 'maintenance' && primaryAction.variant === 'destructive') { + if (!footerContext || !footerContext.includes('Reversible · no')) { + console.warn( + '[FleetActionCard] maintenance + destructive primary should carry a "Reversible · no" footer.', + { name, footerContext }, + ); + } + } + } + + const lastCrumb = crumb[crumb.length - 1] ?? ''; + const headCrumbs = crumb.slice(0, -1); + + return ( + +