feat(fleet): redesign Fleet Action cards to the System Sheet recipe (#1137)

Lift <FleetActionCard> primitive from audit §20 / DESIGN §9.12. Migrate
the three v1 cards (stop-by-label, bulk-label-assign, prune-fleet-wide)
to consume it: cyan rail on every card, action class as a chip, blast
radius as a live readout in the toolbar, preview section replaces the
warning banner, footer carries reversibility plus freshness. Drop the
per-card tone rail, the icon prop, and cards/tone.ts.

Add /fleet/labels/match-preview and /fleet/prune/estimate for the live
blast readouts (chrome falls back to "preview unavailable" if either
404s). Add dryRun: true to the existing fleet-stop and fleet-prune
endpoints so the Dry run button rehearses the full code path (locks,
per-node fan-out, remote propagation) without firing the destructive
leaf call. Result flows into the existing ResultsList.

Extend <SheetSection> with optional meta. Add --action-transformative
semantic token.
This commit is contained in:
Anso
2026-05-21 11:45:52 -04:00
committed by GitHub
parent d0e140444a
commit 6f301e005a
13 changed files with 1471 additions and 423 deletions
@@ -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();
});
});
+181 -13
View File
@@ -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<void> => { fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return; if (!requirePaid(req, res)) return;
if (!requireAdmin(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') { if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'Request body is required' }); res.status(400).json({ error: 'Request body is required' });
return; return;
} }
const { labelName } = body; const { labelName, dryRun } = body;
if (typeof labelName !== 'string' || labelName.trim().length === 0) { if (typeof labelName !== 'string' || labelName.trim().length === 0) {
res.status(400).json({ error: 'labelName is required' }); res.status(400).json({ error: 'labelName is required' });
return; return;
} }
const trimmed = labelName.trim(); const trimmed = labelName.trim();
const isDryRun = dryRun === true;
try { try {
const db = DatabaseService.getInstance(); const db = DatabaseService.getInstance();
const nodes = db.getNodes(); const nodes = db.getNodes();
@@ -1068,7 +1069,8 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
if (node.type === 'local') { if (node.type === 'local') {
// Share the per-node bulk lock with `POST /api/labels/:id/action` so // 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 // 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}`; const lockKey = `bulk:${node.id}`;
if (activeBulkActions.has(lockKey)) { if (activeBulkActions.has(lockKey)) {
return { return {
@@ -1081,14 +1083,18 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
const fsStacks = await FileSystemService.getInstance(node.id).getStacks(); const fsStacks = await FileSystemService.getInstance(node.id).getStacks();
const fsStackSet = new Set(fsStacks); const fsStackSet = new Set(fsStacks);
const validStacks = stackNames.filter(name => fsStackSet.has(name)); 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) { for (const stackName of validStacks) {
if (isDryRun) {
stackResults.push({ stackName, success: true, dryRun: true });
continue;
}
const outcome = await containerActionForStack(node.id, stackName, 'stop'); const outcome = await containerActionForStack(node.id, stackName, 'stop');
if (outcome.kind === 'ok') stackResults.push({ stackName, success: true }); 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 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 }); 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 }; return { nodeId: node.id, nodeName: node.name, matched: true, stackResults };
} finally { } finally {
activeBulkActions.delete(lockKey); 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`, { const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/labels/${label.id}/action`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' }, 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), signal: AbortSignal.timeout(60000),
}); });
if (!response.ok) { 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 })), 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 ?? [] }; return { nodeId: node.id, nodeName: node.name, matched: true, stackResults: remote.results ?? [] };
} catch (err) { } catch (err) {
const errorMsg = getErrorMessage(err, 'Failed to reach remote node'); 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 (!requirePaid(req, res)) return;
if (!requireAdmin(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') { if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'Request body is required' }); res.status(400).json({ error: 'Request body is required' });
return; return;
@@ -1169,8 +1175,9 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
} }
const targets: FleetPruneTarget[] = Array.from(dedup); const targets: FleetPruneTarget[] = Array.from(dedup);
const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed'; 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 = { type NodeResult = {
nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[]; 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; let anySuccess = false;
for (const target of targets) { for (const target of targets) {
try { 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' const result = scope === 'managed'
? await dockerController.pruneManagedOnly(target, knownStacks) ? await dockerController.pruneManagedOnly(target, knownStacks)
: await dockerController.pruneSystem(target); : 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') }); 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 }; return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targetResults };
} finally { } finally {
activeBulkActions.delete(lockKey); 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`, { const response = await fetch(`${baseUrl}/api/system/prune/system`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' }, 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), signal: AbortSignal.timeout(120000),
}); });
if (!response.ok) { 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 }); targetResults.push({ target, success: false, reclaimedBytes: 0, error: message });
continue; 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') { if (!remote || typeof remote.reclaimedBytes !== 'number') {
targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' }); targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' });
continue; 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) { } catch (err) {
const message = getErrorMessage(err, 'Failed to reach remote node'); const message = getErrorMessage(err, 'Failed to reach remote node');
nodeUnreachable = message; 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<void> => {
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<void> => {
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<FleetPruneTarget>();
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<NodeEstimate> => {
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+) ─── // ─── Fleet Snapshots (manual: Community; scheduled: Skipper+) ───
fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => { fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
+13 -6
View File
@@ -172,12 +172,13 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
try { try {
const id = parseIntParam(req, res, 'id', 'label ID'); const id = parseIntParam(req, res, 'id', 'label ID');
if (id === null) return; if (id === null) return;
const { action } = req.body; const { action, dryRun } = req.body;
const validActions = ['deploy', 'stop', 'restart']; const validActions = ['deploy', 'stop', 'restart'];
if (!action || !validActions.includes(action)) { if (!action || !validActions.includes(action)) {
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` }); res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
return; return;
} }
const isDryRun = dryRun === true;
const nodeId = req.nodeId ?? 0; 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 fsStackNames = new Set(fsStacks);
const validStacks = stackNames.filter(name => fsStackNames.has(name)); 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) { 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 { try {
if (action === 'deploy') { if (action === 'deploy') {
const gate = await enforcePolicyPreDeploy( 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 succeeded = results.filter(r => r.success).length;
const failed = results.length - succeeded; const failed = results.length - succeeded;
console.log(`[Labels] Bulk ${sanitizeForLog(action)} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} 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 }); 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); invalidateNodeCaches(req.nodeId);
} }
res.json({ results }); res.json({ results });
+59 -2
View File
@@ -49,15 +49,37 @@ systemMaintenanceRouter.post('/prune/orphans', async (req: Request, res: Respons
systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response) => { systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
try { 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)) { if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
return res.status(400).json({ error: 'Invalid prune target' }); return res.status(400).json({ error: 'Invalid prune target' });
} }
const pruneScope = scope === 'managed' ? 'managed' : 'all'; const pruneScope = scope === 'managed' ? 'managed' : 'all';
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`); const isDryRun = dryRun === true;
const dockerController = DockerController.getInstance(req.nodeId); 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 }; let result: { success: boolean; reclaimedBytes: number };
if (pruneScope === 'managed' && target !== 'containers') { if (pruneScope === 'managed' && target !== 'containers') {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); 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) => { systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) => {
try { try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
+63
View File
@@ -386,6 +386,69 @@ class DockerController {
return { success: true, reclaimedBytes }; 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<any>(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<string>();
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<{ public async getDiskUsageClassified(knownStackNames: string[]): Promise<{
reclaimableImages: number; reclaimableImages: number;
reclaimableContainers: number; reclaimableContainers: number;
@@ -1,4 +1,4 @@
import { Square, Tags, Eraser } from 'lucide-react'; import { Tags } from 'lucide-react';
import { useLicense } from '@/context/LicenseContext'; import { useLicense } from '@/context/LicenseContext';
import type { FleetNode } from '@/components/FleetView/types'; import type { FleetNode } from '@/components/FleetView/types';
import { LabelFleetStopCard } from './cards/LabelFleetStopCard'; import { LabelFleetStopCard } from './cards/LabelFleetStopCard';
@@ -19,22 +19,24 @@ export function FleetActionsTab({ nodes }: Props) {
} }
if (!isPaid) { if (!isPaid) {
// Every action is Skipper+. Community users see a calm empty state with return <EmptyState />;
// upgrade context rather than a stripped-down launcher.
return (
<EmptyState />
);
} }
// 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 ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-[18px] items-start auto-rows-min">
<LabelFleetStopCard nodes={nodes} accentTone="rose" icon={Square} /> <LabelFleetStopCard nodes={nodes} />
<BulkLabelAssignCard nodes={nodes} accentTone="purple" icon={Tags} /> <BulkLabelAssignCard nodes={nodes} />
<FleetPruneCard nodes={nodes} accentTone="amber" icon={Eraser} /> <FleetPruneCard nodes={nodes} />
</div> </div>
); );
} }
// 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() { function EmptyState() {
return ( return (
<div className="rounded-lg border border-card-border/60 bg-card p-8 text-center"> <div className="rounded-lg border border-card-border/60 bg-card p-8 text-center">
@@ -1,11 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; 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 { Button } from '@/components/ui/button';
import { ConfirmModal } from '@/components/ui/modal'; import { ConfirmModal } from '@/components/ui/modal';
import { Checkbox } from '@/components/ui/checkbox'; 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 { LabelPill } from '@/components/LabelPill';
import { fetchForNode } from '@/lib/api'; import { fetchForNode } from '@/lib/api';
import { toast } from '@/components/ui/toast-store'; 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 { FleetNode } from '@/components/FleetView/types';
import type { Label } from '@/components/label-types'; import type { Label } from '@/components/label-types';
import { ResultsList, type ResultRow } from '../ResultsList'; 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 }
interface Props { interface Props {
nodes: FleetNode[]; nodes: FleetNode[];
icon: LucideIcon;
accentTone: AccentTone;
} }
export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) { export function BulkLabelAssignCard({ nodes }: Props) {
const [selectedNodeId, setSelectedNodeId] = useState<string>(() => { const [selectedNodeId, setSelectedNodeId] = useState<number>(() => {
const local = nodes.find(n => n.type === 'local'); 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<string[]>([]); const [stacks, setStacks] = useState<string[]>([]);
const [labels, setLabels] = useState<Label[]>([]); const [labels, setLabels] = useState<Label[]>([]);
@@ -37,12 +32,10 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultRow[]>([]); const [results, setResults] = useState<ResultRow[]>([]);
const nodeId = useMemo(() => Number(selectedNodeId) || 0, [selectedNodeId]); const selectedNode = useMemo(() => nodes.find(n => n.id === selectedNodeId), [nodes, selectedNodeId]);
const selectedNode = useMemo(() => nodes.find(n => n.id === nodeId), [nodes, nodeId]);
// Load stacks + labels whenever the node changes.
useEffect(() => { useEffect(() => {
if (!nodeId) return; if (!selectedNodeId) return;
let cancelled = false; let cancelled = false;
async function load() { async function load() {
setLoadingLists(true); setLoadingLists(true);
@@ -51,8 +44,8 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
setResults([]); setResults([]);
try { try {
const [stacksRes, labelsRes] = await Promise.all([ const [stacksRes, labelsRes] = await Promise.all([
fetchForNode(`/fleet/node/${nodeId}/stacks`, nodeId), fetchForNode(`/fleet/node/${selectedNodeId}/stacks`, selectedNodeId),
fetchForNode('/labels', nodeId), fetchForNode('/labels', selectedNodeId),
]); ]);
const stacksList = stacksRes.ok ? ((await stacksRes.json()) as string[]) : []; const stacksList = stacksRes.ok ? ((await stacksRes.json()) as string[]) : [];
const labelsList = labelsRes.ok ? ((await labelsRes.json()) as Label[]) : []; const labelsList = labelsRes.ok ? ((await labelsRes.json()) as Label[]) : [];
@@ -71,7 +64,7 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
} }
load(); load();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [nodeId]); }, [selectedNodeId]);
function toggleStack(stackName: string) { function toggleStack(stackName: string) {
setSelectedStacks(prev => { setSelectedStacks(prev => {
@@ -93,6 +86,11 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
if (selectedStacks.size === stacks.length) setSelectedStacks(new Set()); if (selectedStacks.size === stacks.length) setSelectedStacks(new Set());
else setSelectedStacks(new Set(stacks)); else setSelectedStacks(new Set(stacks));
} }
function clearSelection() {
setSelectedStacks(new Set());
setSelectedLabels(new Set());
setResults([]);
}
async function run() { async function run() {
if (selectedStacks.size === 0) return; 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'}`); const toastId = toast.loading(`Assigning labels to ${assignments.length} stack${assignments.length === 1 ? '' : 's'}`);
setRunning(true); setRunning(true);
try { try {
const res = await fetchForNode('/fleet-actions/labels/bulk-assign', nodeId, { const res = await fetchForNode('/fleet-actions/labels/bulk-assign', selectedNodeId, {
method: 'POST', method: 'POST',
body: JSON.stringify({ assignments }), 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 ( return (
<Card className="relative overflow-hidden bg-card shadow-card-bevel"> <>
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', TONE_RAIL[accentTone])} /> <FleetActionCard
<CardContent className="p-6"> crumb={['Fleet', 'Actions', 'Bulk label assign']}
<div className="flex items-start gap-3 mb-4"> name="Bulk label assign."
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}> meta="one node · multi-stack · replaces existing label set"
<Icon className="h-5 w-5" strokeWidth={1.5} /> actionClass="transformative"
</span> blastRadius={{ value: blastValue }}
<div className="flex-1"> secondaryAction={{
<h3 className="text-base font-medium text-stat-value">Bulk label assign</h3> label: 'Reset',
<p className="mt-1 text-xs text-stat-subtitle"> onClick: clearSelection,
Pick a node, multi-select stacks, and replace their labels in one shot. disabled: running || (selectedStacks.size === 0 && selectedLabels.size === 0),
</p> }}
</div> primaryAction={{
</div> label: 'Apply',
onClick: () => setConfirmOpen(true),
variant: 'primary',
disabled: running || selectedStacks.size === 0 || selectedLabels.size === 0,
}}
footerContext="Reversible · yes · reassign anytime"
>
<SheetSection title="Node" meta={loadingLists ? 'loading…' : undefined}>
<NodeSegmented
nodes={nodes}
value={selectedNodeId}
onChange={setSelectedNodeId}
disabled={running}
/>
</SheetSection>
<div className="space-y-3"> <SheetSection
<div className="flex items-center gap-2"> title={`Stacks · ${selectedStacks.size} / ${stacks.length}`}
<Server className="h-3.5 w-3.5 text-stat-subtitle" strokeWidth={1.5} /> meta={stacks.length > 0
<Select value={selectedNodeId} onValueChange={setSelectedNodeId} disabled={running}> ? (selectedStacks.size === stacks.length ? 'all selected' : 'multi-select')
<SelectTrigger className="w-56 h-8 text-xs"> : undefined}
<SelectValue placeholder="Select a node" /> >
</SelectTrigger> <div className="grid gap-0.5 max-h-44 overflow-auto pr-1 border border-card-border/40 rounded-md p-2">
<SelectContent> {stacks.length === 0 && (
{nodes.map(n => ( <span className="text-xs text-stat-subtitle">
<SelectItem key={n.id} value={String(n.id)}> {loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'}
{n.name} {n.type === 'local' ? '(local)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{loadingLists && <span className="text-xs text-stat-subtitle">Loading</span>}
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] uppercase tracking-wide text-stat-subtitle">
Stacks ({selectedStacks.size}/{stacks.length})
</span> </span>
{stacks.length > 0 && ( )}
<button {stacks.map(stackName => (
type="button" <label
key={stackName}
className="flex items-center gap-2 py-1 px-1 rounded hover:bg-glass-highlight cursor-pointer"
>
<Checkbox
checked={selectedStacks.has(stackName)}
onCheckedChange={() => toggleStack(stackName)}
disabled={running} disabled={running}
onClick={toggleAllStacks}
className="text-xs text-stat-subtitle hover:text-stat-value disabled:opacity-50"
>
{selectedStacks.size === stacks.length ? 'Clear' : 'Select all'}
</button>
)}
</div>
<div className="grid gap-0.5 max-h-44 overflow-auto pr-1 border border-card-border/40 rounded-md p-2">
{stacks.length === 0 && (
<span className="text-xs text-stat-subtitle">
{loadingLists ? 'Loading…' : selectedNode ? `No stacks on ${selectedNode.name}.` : 'Pick a node.'}
</span>
)}
{stacks.map(stackName => (
<label
key={stackName}
className="flex items-center gap-2 py-1 px-1 rounded hover:bg-glass-highlight cursor-pointer"
>
<Checkbox
checked={selectedStacks.has(stackName)}
onCheckedChange={() => toggleStack(stackName)}
disabled={running}
/>
<span className="text-xs font-mono text-stat-value">{stackName}</span>
</label>
))}
</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">
Labels ({selectedLabels.size}/{labels.length})
</div>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-auto p-2 border border-card-border/40 rounded-md">
{labels.length === 0 && (
<span className="text-xs text-stat-subtitle">
{loadingLists ? 'Loading…' : selectedNode ? `No labels defined on ${selectedNode.name}.` : ''}
</span>
)}
{labels.map(label => (
<LabelPill
key={label.id}
label={label}
active={selectedLabels.has(label.id)}
onClick={() => !running && toggleLabel(label.id)}
/> />
))} <span className="text-xs font-mono text-stat-value">{stackName}</span>
</div> </label>
))}
</div> </div>
{stacks.length > 0 && (
<button
type="button"
disabled={running}
onClick={toggleAllStacks}
className="mt-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle hover:text-stat-value disabled:opacity-50"
>
{selectedStacks.size === stacks.length ? 'Clear all' : 'Select all'}
</button>
)}
</SheetSection>
<p className="text-[11px] text-stat-subtitle"> <SheetSection
title={`Labels · ${selectedLabels.size} / ${labels.length}`}
meta="replaces existing"
>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-auto p-2 border border-card-border/40 rounded-md">
{labels.length === 0 && (
<span className="text-xs text-stat-subtitle">
{loadingLists ? 'Loading…' : selectedNode ? `No labels defined on ${selectedNode.name}.` : ''}
</span>
)}
{labels.map(label => (
<LabelPill
key={label.id}
label={label}
active={selectedLabels.has(label.id)}
onClick={() => !running && toggleLabel(label.id)}
/>
))}
</div>
<p className="mt-2 text-[11px] text-stat-subtitle">
Selected labels replace each chosen stack's existing label set on this node. Selected labels replace each chosen stack's existing label set on this node.
Selecting no labels clears assignments. Selecting no labels clears assignments.
</p> </p>
</SheetSection>
<div className="flex items-center gap-2"> {results.length > 0 && (
<Button <SheetSection title="Per-stack results">
variant="outline" <ResultsList results={results} />
size="sm" </SheetSection>
disabled={running || selectedStacks.size === 0} )}
onClick={() => setConfirmOpen(true)} </FleetActionCard>
className="gap-2"
>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.5} /> : <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
Apply to {selectedStacks.size} stack{selectedStacks.size === 1 ? '' : 's'}
</Button>
{!running && results.length > 0 && (
<button
type="button"
onClick={() => setResults([])}
className="text-xs text-stat-subtitle hover:text-stat-value"
>
Clear results
</button>
)}
</div>
{results.length > 0 && ( <ConfirmModal
<ResultsList title="Per-stack results" results={results} /> open={confirmOpen}
)} onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
</div> variant="default"
kicker="Bulk label assign"
<ConfirmModal title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`}
open={confirmOpen} description={
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }} selectedLabels.size === 0
variant="default" ? 'No labels selected, this will clear existing assignments on the selected stacks.'
kicker="Bulk label assign" : `Each selected stack's existing label set on ${selectedNode?.name ?? 'this node'} will be replaced with the chosen labels.`
title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`} }
description={ confirmLabel="Apply"
selectedLabels.size === 0 confirming={running}
? 'No labels selected, this will clear existing assignments on the selected stacks.' onConfirm={run}
: `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 {
</CardContent> nodes: FleetNode[];
</Card> value: number;
onChange: (id: number) => void;
disabled: boolean;
}
function NodeSegmented({ nodes, value, onChange, disabled }: NodeSegmentedProps) {
return (
<div className="inline-flex flex-wrap rounded-md border border-card-border/60 overflow-hidden">
{nodes.map(n => {
const active = n.id === value;
return (
<Button
key={n.id}
type="button"
variant={active ? 'default' : 'outline'}
size="sm"
disabled={disabled}
onClick={() => onChange(n.id)}
className={cn('rounded-none border-0 h-8 px-3 text-xs', active && 'pointer-events-none')}
>
{n.name}{n.type === 'local' ? ' (local)' : ''}
</Button>
);
})}
</div>
); );
} }
@@ -1,16 +1,14 @@
import { useState } from 'react'; import { useEffect, useMemo, useRef, 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 { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { ConfirmModal } from '@/components/ui/modal'; 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 { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store'; import { toast } from '@/components/ui/toast-store';
import { cn, formatBytes } from '@/lib/utils'; import { cn, formatBytes } from '@/lib/utils';
import type { FleetNode } from '@/components/FleetView/types'; import type { FleetNode } from '@/components/FleetView/types';
import { ResultsList, type ResultRow } from '../ResultsList'; import { ResultsList, type ResultRow } from '../ResultsList';
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
type PruneTarget = 'images' | 'volumes' | 'networks'; type PruneTarget = 'images' | 'volumes' | 'networks';
type PruneScope = 'managed' | 'all'; type PruneScope = 'managed' | 'all';
@@ -21,24 +19,35 @@ const ALL_TARGETS: ReadonlyArray<{ id: PruneTarget; label: string }> = [
{ id: 'networks', label: 'Networks' }, { 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 { interface FleetPruneNodeResult {
nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[]; 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 { interface Props {
nodes: FleetNode[]; 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 nodeCount = nodes.length;
const [targets, setTargets] = useState<Set<PruneTarget>>(new Set(['images'])); const [targets, setTargets] = useState<Set<PruneTarget>>(new Set(['images']));
const [scope, setScope] = useState<PruneScope>('managed'); const [scope, setScope] = useState<PruneScope>('managed');
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultRow[]>([]); const [results, setResults] = useState<ResultRow[]>([]);
const [estimate, setEstimate] = useState<EstimateState>({ kind: 'idle' });
const toggleTarget = (target: PruneTarget) => { const toggleTarget = (target: PruneTarget) => {
setTargets(prev => { 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<ReturnType<typeof setTimeout> | 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; if (targets.size === 0) return;
const selected = Array.from(targets); 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); setRunning(true);
setResults([]); setResults([]);
try { try {
const res = await apiFetch('/fleet/labels/fleet-prune', { const res = await apiFetch('/fleet/labels/fleet-prune', {
method: 'POST', method: 'POST',
body: JSON.stringify({ targets: selected, scope }), body: JSON.stringify({ targets: selected, scope, dryRun: opts.dryRun }),
}); });
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
toast.dismiss(toastId); toast.dismiss(toastId);
@@ -73,7 +119,7 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
return { return {
key: `node-${node.nodeId}`, key: `node-${node.nodeId}`,
label: node.reachable label: node.reachable
? `${node.nodeName} · ${formatBytes(totalBytes)}` ? `${node.nodeName} · ${formatBytes(totalBytes)}${opts.dryRun ? ' (dry run)' : ''}`
: `${node.nodeName} (unreachable)`, : `${node.nodeName} (unreachable)`,
success: allOk, success: allOk,
error: node.reachable ? undefined : node.error, 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), (sum, n) => sum + n.targets.reduce((s, t) => s + (t.reclaimedBytes ?? 0), 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'}.`); toast.success(`Reclaimed ${formatBytes(totalReclaimed)} across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`);
} else if (okNodes === 0) { } else if (okNodes === 0) {
toast.error('Prune failed on every node. See results below.'); 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 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 ( return (
<Card className="relative overflow-hidden bg-card shadow-card-bevel"> <>
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', TONE_RAIL[accentTone])} /> <FleetActionCard
<CardContent className="p-6"> crumb={['Fleet', 'Actions', 'Prune resources']}
<div className="flex items-start gap-3 mb-4"> name="Prune fleet-wide."
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}> meta="images · volumes · networks · serial per node"
<Icon className="h-5 w-5" strokeWidth={1.5} /> actionClass="maintenance"
</span> blastRadius={{ value: blastValue, tone: blastTone }}
<div className="flex-1"> secondaryAction={{
<h3 className="text-base font-medium text-stat-value">Prune Docker resources fleet-wide</h3> label: running ? 'Running…' : 'Dry run',
<p className="mt-1 text-xs text-stat-subtitle"> onClick: () => run({ dryRun: true }),
Reclaim space on {nodeCount} node{nodeCount === 1 ? '' : 's'} by removing unused images, volumes, and networks. Reclaimed bytes are approximate. disabled: running || targets.size === 0,
</p> }}
</div> primaryAction={{
</div> label: 'Prune fleet',
onClick: () => setConfirmOpen(true),
<div className="space-y-3"> variant: 'destructive',
<div> // Block the destructive confirm until the operator has actually
<div className="text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">Targets</div> // seen what the readout says will be reclaimed. Falling back to a
<div className="flex flex-wrap gap-3"> // confirm modal with no estimate context is the audit §F20.3 problem.
{ALL_TARGETS.map(t => ( disabled: running || targets.size === 0 || estimate.kind !== 'ready',
<label }}
key={t.id} footerContext={`Reversible · no · serial across ${nodeCount} node${nodeCount === 1 ? '' : 's'}`}
className="flex items-center gap-2 py-1 px-2 rounded hover:bg-glass-highlight cursor-pointer" >
> <SheetSection
<Checkbox title={`Targets · ${targets.size} / ${ALL_TARGETS.length}`}
checked={targets.has(t.id)} meta={targets.size === 0 ? 'pick at least one' : undefined}
onCheckedChange={() => toggleTarget(t.id)} >
disabled={running} <div className="flex flex-wrap gap-3">
/> {ALL_TARGETS.map(t => (
<span className="text-xs text-stat-value">{t.label}</span> <label
</label> key={t.id}
))} className="flex items-center gap-2 py-1 px-2 rounded hover:bg-glass-highlight cursor-pointer"
</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">Scope</div>
<div className="inline-flex rounded-md border border-card-border/60 overflow-hidden">
<Button
type="button"
variant={scope === 'managed' ? 'default' : 'outline'}
size="sm"
disabled={running}
onClick={() => setScope('managed')}
className="rounded-none border-0 h-8 px-3 text-xs"
> >
Managed only <Checkbox
</Button> checked={targets.has(t.id)}
<Button onCheckedChange={() => toggleTarget(t.id)}
type="button" disabled={running}
variant={scope === 'all' ? 'default' : 'outline'} />
size="sm" <span className="text-xs text-stat-value">{t.label}</span>
disabled={running} </label>
onClick={() => setScope('all')} ))}
className="rounded-none border-0 h-8 px-3 text-xs"
>
All unused
</Button>
</div>
<p className="mt-1.5 text-[11px] text-stat-subtitle">
{scope === 'managed'
? 'Restricts to resources owned by stacks Sencho manages.'
: 'Removes every unused resource, including workloads Sencho does not manage.'}
</p>
</div> </div>
</SheetSection>
<div className="flex items-center gap-2"> <SheetSection title="Scope" meta={scope === 'managed' ? 'sencho-owned only' : 'all unused'}>
<div className="inline-flex rounded-md border border-card-border/60 overflow-hidden">
<Button <Button
variant="outline" type="button"
variant={scope === 'managed' ? 'default' : 'outline'}
size="sm" size="sm"
disabled={running || targetCount === 0} disabled={running}
onClick={() => setConfirmOpen(true)} onClick={() => setScope('managed')}
className="gap-2" className="rounded-none border-0 h-8 px-3 text-xs"
> >
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.5} /> : <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />} Managed only
Prune across fleet </Button>
<Button
type="button"
variant={scope === 'all' ? 'default' : 'outline'}
size="sm"
disabled={running}
onClick={() => setScope('all')}
className="rounded-none border-0 h-8 px-3 text-xs"
>
All unused
</Button> </Button>
{!running && results.length > 0 && (
<button
type="button"
onClick={() => setResults([])}
className="text-xs text-stat-subtitle hover:text-stat-value"
>
Clear results
</button>
)}
</div> </div>
<p className="mt-2 text-[11px] text-stat-subtitle">
{scope === 'managed'
? 'Restricts to resources owned by stacks Sencho manages.'
: 'Removes every unused resource, including workloads Sencho does not manage.'}
</p>
</SheetSection>
{results.length === 0 && !running && ( {targets.size > 0 && <EstimateSection estimate={estimate} />}
<div className="rounded-md border border-card-border/40 bg-glass-highlight/30 p-3 text-xs text-stat-subtitle">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={1.5} />
<span>
Prune is destructive and cannot be undone. Each target is run serially per node; reclaimed bytes appear per node and per target below.
</span>
</div>
</div>
)}
{results.length > 0 && ( {results.length > 0 && (
<ResultsList title="Per-node breakdown" results={results} /> <SheetSection title="Per-node breakdown">
)} <ResultsList results={results} />
</div> </SheetSection>
)}
</FleetActionCard>
<ConfirmModal <ConfirmModal
open={confirmOpen} open={confirmOpen}
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }} onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="destructive" variant="destructive"
kicker="Fleet prune" kicker="Fleet prune"
title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'} title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'}
description={ description={
isAllScope 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.' ? '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.' : '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'} confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'}
confirming={running} confirming={running}
onConfirm={run} onConfirm={() => run({ dryRun: false })}
/> />
</CardContent> </>
</Card> );
}
function EstimateSection({ estimate }: { estimate: EstimateState }) {
if (estimate.kind === 'idle' || estimate.kind === 'loading') {
return (
<SheetSection title="Estimate · per node" meta={estimate.kind === 'loading' ? 'computing…' : undefined}>
<div className={cn(KICKER, 'text-stat-icon')}>walking each node's docker daemon</div>
</SheetSection>
);
}
if (estimate.kind === 'unavailable') {
return (
<SheetSection title="Estimate · per node" meta="unavailable">
<div className={cn(KICKER, 'text-stat-icon')}>estimate endpoint did not respond</div>
</SheetSection>
);
}
const { perNode } = estimate.data;
const visible = perNode.slice(0, ESTIMATE_ROW_LIMIT);
const remaining = perNode.length - visible.length;
return (
<SheetSection title="Estimate · per node" meta={`${perNode.length} node${perNode.length === 1 ? '' : 's'}`}>
<div className="rounded border border-card-border/60 bg-card/40 shadow-[inset_0_2px_4px_0_oklch(0_0_0_/_0.35)] p-2">
<ul className="space-y-1">
{visible.map((n) => (
<li key={n.nodeId} className="flex items-center gap-2">
<span className={cn(
KICKER,
'inline-flex items-center px-1 py-0.5 rounded-sm border shrink-0',
n.reachable
? 'border-success/40 bg-success/10 text-success'
: 'border-stat-subtitle/40 bg-card text-stat-subtitle',
)}>
{n.reachable ? 'OK' : '--'}
</span>
<span className="flex-1 min-w-0 truncate font-mono text-[11px] text-stat-value">{n.nodeName}</span>
<span className={cn(KICKER, 'shrink-0 tabular-nums', n.reachable ? 'text-stat-subtitle' : 'text-stat-icon')}>
{n.reachable ? formatBytes(n.reclaimableBytes) : (n.error ?? 'unreachable')}
</span>
</li>
))}
{remaining > 0 && (
<li className={cn(KICKER, 'text-stat-icon pt-1')}>
+ {remaining} more node{remaining === 1 ? '' : 's'}
</li>
)}
</ul>
</div>
</SheetSection>
); );
} }
@@ -1,19 +1,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useRef, 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 { ConfirmModal } from '@/components/ui/modal'; import { ConfirmModal } from '@/components/ui/modal';
import { Input } from '@/components/ui/input'; 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 { apiFetch, fetchForNode } from '@/lib/api';
import { toast } from '@/components/ui/toast-store'; import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { FleetNode } from '@/components/FleetView/types'; import type { FleetNode } from '@/components/FleetView/types';
import type { Label } from '@/components/label-types'; import type { Label } from '@/components/label-types';
import { ResultsList, type ResultRow } from '../ResultsList'; 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 { interface FleetStopNodeResult {
nodeId: number; nodeId: number;
nodeName: string; nodeName: string;
@@ -21,21 +18,31 @@ interface FleetStopNodeResult {
stackResults: NodeStackResult[]; 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 { interface Props {
nodes: FleetNode[]; 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 [labelName, setLabelName] = useState('');
const [knownLabelNames, setKnownLabelNames] = useState<string[]>([]); const [knownLabelNames, setKnownLabelNames] = useState<string[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultRow[]>([]); const [results, setResults] = useState<ResultRow[]>([]);
const [preview, setPreview] = useState<PreviewState>({ kind: 'idle' });
// Aggregate label names across reachable nodes for autocomplete. Offline // Aggregate label names across reachable nodes for autocomplete.
// nodes are skipped so the page-load fanout doesn't hang on dead remotes.
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function loadSuggestions() { async function loadSuggestions() {
@@ -48,7 +55,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
const list = (await res.json()) as Label[]; const list = (await res.json()) as Label[];
for (const l of list) names.add(l.name); for (const l of list) names.add(l.name);
} catch { } catch {
/* ignore — this node is unreachable, not user-facing */ /* unreachable node, not user-facing */
} }
})); }));
if (!cancelled) setKnownLabelNames(Array.from(names).sort()); if (!cancelled) setKnownLabelNames(Array.from(names).sort());
@@ -57,16 +64,68 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [nodes]); }, [nodes]);
async function run() { // Debounced live preview. The blast-radius readout and the preview section
// both read from the same state.
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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(); const trimmed = labelName.trim();
if (!trimmed) return; 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); setRunning(true);
setResults([]); setResults([]);
try { try {
const res = await apiFetch('/fleet/labels/fleet-stop', { const res = await apiFetch('/fleet/labels/fleet-stop', {
method: 'POST', method: 'POST',
body: JSON.stringify({ labelName: trimmed }), body: JSON.stringify({ labelName: trimmed, dryRun: opts.dryRun }),
}); });
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
toast.dismiss(toastId); toast.dismiss(toastId);
@@ -78,7 +137,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
const rows: ResultRow[] = apiResults.map((node) => ({ const rows: ResultRow[] = apiResults.map((node) => ({
key: `node-${node.nodeId}`, key: `node-${node.nodeId}`,
label: node.matched 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)`, : `${node.nodeName} (no matching label)`,
success: node.matched && node.stackResults.every(s => s.success), success: node.matched && node.stackResults.every(s => s.success),
error: node.matched ? undefined : 'Label not present', 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 ok = stacksTouched.filter(s => s.success).length;
const failed = stacksTouched.length - ok; const failed = stacksTouched.length - ok;
if (matchedNodes === 0) toast.info('No nodes have a label by that name.'); 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 (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 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.`); 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 ( return (
<Card className="relative overflow-hidden bg-card shadow-card-bevel"> <>
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', TONE_RAIL[accentTone])} /> <FleetActionCard
<CardContent className="p-6"> crumb={['Fleet', 'Actions', 'Stop by label']}
<div className="flex items-start gap-3 mb-4"> name="Stop by label."
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}> meta="label-match · per-node fan-out · reports per-stack results"
<Icon className="h-5 w-5" strokeWidth={1.5} /> actionClass="destructive"
</span> blastRadius={{ value: blastValue, tone: blastTone }}
<div className="flex-1"> secondaryAction={{
<h3 className="text-base font-medium text-stat-value">Stop fleet by label</h3> label: running ? 'Running…' : 'Dry run',
<p className="mt-1 text-xs text-stat-subtitle"> onClick: () => run({ dryRun: true }),
Stop every stack labeled with this name on every node. Labels are matched by name across the fleet. disabled: running || trimmed.length === 0,
</p> }}
</div> primaryAction={{
</div> label: 'Stop fleet',
onClick: () => setConfirmOpen(true),
variant: 'destructive',
disabled: running || trimmed.length === 0,
}}
footerContext={footerContext}
>
<SheetSection
title="Label · target"
meta={`auto-suggested · ${knownLabelNames.length} known`}
>
<Input
id="fleet-stop-label-input"
list="fleet-stop-label-suggestions"
value={labelName}
onChange={(e) => setLabelName(e.target.value)}
placeholder="e.g. production"
className="h-9 text-sm"
disabled={running}
/>
<datalist id="fleet-stop-label-suggestions">
{knownLabelNames.map(n => <option key={n} value={n} />)}
</datalist>
</SheetSection>
<div className="space-y-3"> {previewSection}
<div>
<label htmlFor="fleet-stop-label-input" className="block text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">
Label name
</label>
<Input
id="fleet-stop-label-input"
list="fleet-stop-label-suggestions"
value={labelName}
onChange={(e) => setLabelName(e.target.value)}
placeholder="e.g. production"
className="h-9 text-sm"
disabled={running}
/>
<datalist id="fleet-stop-label-suggestions">
{knownLabelNames.map(n => <option key={n} value={n} />)}
</datalist>
</div>
<div className="flex items-center gap-2"> {results.length > 0 && (
<Button <SheetSection title="Per-node breakdown">
variant="outline" <ResultsList results={results} />
size="sm" </SheetSection>
disabled={running || labelName.trim().length === 0} )}
onClick={() => setConfirmOpen(true)} </FleetActionCard>
className="gap-2"
>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.5} /> : <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />}
Stop matching stacks
</Button>
{!running && results.length > 0 && (
<button
type="button"
onClick={() => setResults([])}
className="text-xs text-stat-subtitle hover:text-stat-value"
>
Clear results
</button>
)}
</div>
{results.length === 0 && !running && ( <ConfirmModal
<div className="rounded-md border border-card-border/40 bg-glass-highlight/30 p-3 text-xs text-stat-subtitle"> open={confirmOpen}
<div className="flex items-start gap-2"> onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={1.5} /> variant="destructive"
<span> kicker="Fleet stop"
Different nodes can have their own label rows. Stops are dispatched per node and report title={`Stop all stacks labeled "${trimmed}"?`}
per-stack results below. description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted."
</span> confirmLabel="Stop fleet"
</div> confirming={running}
</div> onConfirm={() => run({ dryRun: false })}
)} />
</>
{results.length > 0 && ( );
<ResultsList title="Per-node breakdown" results={results} /> }
)}
</div> function renderPreviewSection(preview: PreviewState, trimmed: string) {
if (trimmed.length === 0) return null;
<ConfirmModal if (preview.kind === 'loading') {
open={confirmOpen} return (
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }} <SheetSection title="Preview" meta="resolving…">
variant="destructive" <div className={cn(KICKER, 'text-stat-icon')}>looking up label across the fleet</div>
kicker="Fleet stop" </SheetSection>
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" if (preview.kind === 'unavailable') {
confirming={running} return (
onConfirm={run} <SheetSection title="Preview" meta="unavailable">
/> <div className={cn(KICKER, 'text-stat-icon')}>preview endpoint did not respond</div>
</CardContent> </SheetSection>
</Card> );
}
if (preview.kind === 'ready') {
const { matchedStacks, matchedNodes, perNode } = preview.data;
if (matchedStacks === 0) {
return (
<SheetSection title="Preview" meta="0 stacks">
<div className={cn(KICKER, 'text-stat-icon')}>no node has a label by that name</div>
</SheetSection>
);
}
return (
<SheetSection
title={`Preview · ${matchedStacks} stack${matchedStacks === 1 ? '' : 's'}`}
meta={`across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}`}
>
<PreviewWell perNode={perNode} />
</SheetSection>
);
}
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 (
<div className="rounded border border-card-border/60 bg-card/40 shadow-[inset_0_2px_4px_0_oklch(0_0_0_/_0.35)] p-2">
<ul className="space-y-1">
{visible.map((row, i) => (
<li key={`${row.node}-${row.stack}-${i}`} className="flex items-center gap-2">
<span className={cn(KICKER, 'inline-flex items-center px-1 py-0.5 rounded-sm border border-success/40 bg-success/10 text-success shrink-0')}>
UP
</span>
<span className="flex-1 min-w-0 truncate font-mono text-[11px] text-stat-value">{row.stack}</span>
<span className={cn(KICKER, 'shrink-0 text-stat-subtitle')}>{row.node}</span>
</li>
))}
{remaining > 0 && (
<li className={cn(KICKER, 'text-stat-icon pt-1')}>
+ {remaining} more across {remainingNodes} node{remainingNodes === 1 ? '' : 's'}
</li>
)}
</ul>
</div>
); );
} }
@@ -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<AccentTone, string> = {
rose: 'bg-[var(--label-rose)]',
purple: 'bg-[var(--label-purple)]',
amber: 'bg-[var(--label-amber)]',
};
export const TONE_BG: Record<AccentTone, string> = {
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)]',
};
@@ -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;
/** `<SheetSection>` blocks. */
children: React.ReactNode;
}
const CLASS_LABEL: Record<FleetActionClass, string> = {
destructive: 'Destructive',
transformative: 'Transformative',
maintenance: 'Maintenance',
};
const CHIP_CLASS: Record<FleetActionClass, string> = {
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<FleetActionClass, string> = {
destructive: 'bg-destructive',
transformative: 'bg-action-transformative',
maintenance: 'bg-warning',
};
const TONE_TEXT_CLASS: Record<BlastRadiusTone, string> = {
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 (
<Card className="relative overflow-hidden p-0">
<span
aria-hidden="true"
className="absolute inset-y-0 left-0 w-[3px] bg-brand"
/>
<header className="px-6 pt-4 pb-3">
<div className={cn(KICKER, 'flex flex-wrap items-baseline gap-1 leading-none')}>
{headCrumbs.map((segment, idx) => (
<React.Fragment key={`${segment}-${idx}`}>
<span className="text-stat-subtitle">{segment}</span>
<span className="text-stat-icon" aria-hidden="true">{''}</span>
</React.Fragment>
))}
<span className="text-stat-title">{lastCrumb}</span>
</div>
<h3 className="mt-2 font-display italic text-[22px] leading-[28px] text-stat-value">
{name}
</h3>
<div className={cn('mt-1.5 font-mono text-[11px] leading-none text-stat-subtitle tracking-[0.04em]')}>
{meta}
</div>
</header>
<div className="flex items-center gap-2 px-6 py-2 border-y border-card-border/40 bg-card/60">
<span
className={cn(
KICKER,
'inline-flex items-center px-1.5 py-0.5 rounded-sm border shrink-0',
CHIP_CLASS[actionClass],
)}
>
{CLASS_LABEL[actionClass]}
</span>
<div className={cn(KICKER, 'flex items-center gap-1.5 leading-none min-w-0', blastTextClass)}>
<span
aria-hidden="true"
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', dotClass)}
/>
<span className="truncate">{blastRadius.value}</span>
</div>
<div className="flex-1" />
{secondaryAction && (
<Button
variant="outline"
size="sm"
onClick={secondaryAction.onClick}
disabled={secondaryAction.disabled || forceDisable}
className={cn(KICKER, 'h-7 px-3')}
>
{secondaryAction.label}
</Button>
)}
<PrimaryButton
action={primaryAction}
disabled={primaryAction.disabled || forceDisable}
/>
</div>
<div className="px-6">
{children}
</div>
{footerContext && (
<div className="px-6 py-2 border-t border-card-border/40 bg-card/60">
<div className={cn(KICKER, 'text-stat-subtitle leading-none truncate')}>
{footerContext}
</div>
</div>
)}
</Card>
);
}
interface PrimaryButtonProps {
action: { label: string; onClick: () => void; variant: PrimaryActionVariant };
disabled: boolean;
}
// Primary button kept inline to encode the §18.3 invariant: cyan-filled for
// transformative/maintenance (when reversible), ghost-rose for destructive.
// Never solid rose on the card; solid rose is reserved for AlertDialog (§10).
function PrimaryButton({ action, disabled }: PrimaryButtonProps) {
const cyanFilled = 'bg-brand text-brand-foreground hover:bg-brand/90 border border-transparent';
const ghostRose = 'border border-destructive/40 text-destructive bg-transparent hover:bg-destructive/15 hover:text-destructive hover:border-destructive/60';
return (
<button
type="button"
onClick={action.onClick}
disabled={disabled}
className={cn(
KICKER,
'h-7 rounded-md px-3 whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
action.variant === 'primary' ? cyanFilled : ghostRose,
)}
>
{action.label}
</button>
);
}
+9 -2
View File
@@ -299,15 +299,22 @@ export interface SheetSectionProps {
title: string; title: string;
/** Hide the title rule + label (useful when a section is the only one in a tab). */ /** Hide the title rule + label (useful when a section is the only one in a tab). */
hideHeader?: boolean; hideHeader?: boolean;
/** Optional right-aligned mono meta on the section header row (count, scope, freshness). */
meta?: string;
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
} }
export function SheetSection({ title, hideHeader, className, children }: SheetSectionProps) { export function SheetSection({ title, hideHeader, meta, className, children }: SheetSectionProps) {
return ( return (
<section className={cn('first:pt-0 first:border-t-0 -mx-6 px-6 py-4 border-t border-card-border/40', className)}> <section className={cn('first:pt-0 first:border-t-0 -mx-6 px-6 py-4 border-t border-card-border/40', className)}>
{!hideHeader && ( {!hideHeader && (
<h3 className={cn(KICKER_CLASS, 'text-stat-subtitle mb-3 leading-none')}>{title}</h3> <div className="mb-3 flex items-baseline justify-between gap-3 leading-none">
<h3 className={cn(KICKER_CLASS, 'text-stat-subtitle')}>{title}</h3>
{meta && (
<span className={cn(KICKER_CLASS, 'text-stat-subtitle/80 shrink-0')}>{meta}</span>
)}
</div>
)} )}
{children} {children}
</section> </section>
+10
View File
@@ -122,6 +122,12 @@
--label-custom: var(--label-amber); --label-custom: var(--label-amber);
--label-custom-bg: var(--label-amber-bg); --label-custom-bg: var(--label-amber-bg);
/* Fleet Action card action-class chip / blast-dot colors.
Closed set per audit §18.3: destructive (rose) / transformative (purple) / maintenance (amber).
Two of the three reuse existing semantic tokens; transformative gets its own
alias so component code does not reach for --label-purple directly. */
--action-transformative: var(--label-purple);
/* Chart grid & axis */ /* Chart grid & axis */
--chart-grid: oklch(0 0 0 / 0.06); --chart-grid: oklch(0 0 0 / 0.06);
--chart-tick: oklch(0 0 0 / 0.45); --chart-tick: oklch(0 0 0 / 0.45);
@@ -299,6 +305,9 @@
--label-custom: var(--label-amber); --label-custom: var(--label-amber);
--label-custom-bg: var(--label-amber-bg); --label-custom-bg: var(--label-amber-bg);
/* Fleet Action card action-class chip / blast-dot colors (dark theme). */
--action-transformative: var(--label-purple);
/* Chart grid & axis */ /* Chart grid & axis */
--chart-grid: oklch(1 0 0 / 0.05); --chart-grid: oklch(1 0 0 / 0.05);
--chart-tick: oklch(1 0 0 / 0.40); --chart-tick: oklch(1 0 0 / 0.40);
@@ -407,6 +416,7 @@
--color-update-foreground: var(--update-foreground); --color-update-foreground: var(--update-foreground);
--color-update-muted: var(--update-muted); --color-update-muted: var(--update-muted);
--color-destructive-muted: var(--destructive-muted); --color-destructive-muted: var(--destructive-muted);
--color-action-transformative: var(--action-transformative);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-mono); --font-mono: var(--font-mono);