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> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const body = req.body as { labelName?: unknown } | undefined;
const body = req.body as { labelName?: unknown; dryRun?: unknown } | undefined;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'Request body is required' });
return;
}
const { labelName } = body;
const { labelName, dryRun } = body;
if (typeof labelName !== 'string' || labelName.trim().length === 0) {
res.status(400).json({ error: 'labelName is required' });
return;
}
const trimmed = labelName.trim();
const isDryRun = dryRun === true;
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
@@ -1068,7 +1069,8 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
if (node.type === 'local') {
// Share the per-node bulk lock with `POST /api/labels/:id/action` so
// a fleet-stop and a per-label action cannot double-stop the same
// containers concurrently on the same local node.
// containers concurrently on the same local node. Dry run acquires
// the same lock so the rehearsal exercises the same contention path.
const lockKey = `bulk:${node.id}`;
if (activeBulkActions.has(lockKey)) {
return {
@@ -1081,14 +1083,18 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
const fsStacks = await FileSystemService.getInstance(node.id).getStacks();
const fsStackSet = new Set(fsStacks);
const validStacks = stackNames.filter(name => fsStackSet.has(name));
const stackResults: { stackName: string; success: boolean; error?: string }[] = [];
const stackResults: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] = [];
for (const stackName of validStacks) {
if (isDryRun) {
stackResults.push({ stackName, success: true, dryRun: true });
continue;
}
const outcome = await containerActionForStack(node.id, stackName, 'stop');
if (outcome.kind === 'ok') stackResults.push({ stackName, success: true });
else if (outcome.kind === 'no-containers') stackResults.push({ stackName, success: false, error: 'No containers found for this stack' });
else stackResults.push({ stackName, success: false, error: outcome.message });
}
if (stackResults.some(r => r.success)) invalidateNodeCaches(node.id);
if (!isDryRun && stackResults.some(r => r.success)) invalidateNodeCaches(node.id);
return { nodeId: node.id, nodeName: node.name, matched: true, stackResults };
} finally {
activeBulkActions.delete(lockKey);
@@ -1105,7 +1111,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/labels/${label.id}/action`, {
method: 'POST',
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'stop' }),
body: JSON.stringify({ action: 'stop', dryRun: isDryRun }),
signal: AbortSignal.timeout(60000),
});
if (!response.ok) {
@@ -1116,7 +1122,7 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
stackResults: stackNames.map(stackName => ({ stackName, success: false, error: message })),
};
}
const remote = (await response.json()) as { results?: { stackName: string; success: boolean; error?: string }[] };
const remote = (await response.json()) as { results?: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] };
return { nodeId: node.id, nodeName: node.name, matched: true, stackResults: remote.results ?? [] };
} catch (err) {
const errorMsg = getErrorMessage(err, 'Failed to reach remote node');
@@ -1149,7 +1155,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const body = req.body as { targets?: unknown; scope?: unknown } | undefined;
const body = req.body as { targets?: unknown; scope?: unknown; dryRun?: unknown } | undefined;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'Request body is required' });
return;
@@ -1169,8 +1175,9 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
}
const targets: FleetPruneTarget[] = Array.from(dedup);
const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed';
const isDryRun = body.dryRun === true;
type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string };
type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string; dryRun?: boolean };
type NodeResult = {
nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[];
};
@@ -1196,6 +1203,13 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
let anySuccess = false;
for (const target of targets) {
try {
if (isDryRun) {
const estimate = scope === 'managed'
? await dockerController.estimateManagedReclaim(target, knownStacks)
: await dockerController.estimateSystemReclaim(target, knownStacks);
targetResults.push({ target, success: true, reclaimedBytes: estimate.reclaimableBytes, dryRun: true });
continue;
}
const result = scope === 'managed'
? await dockerController.pruneManagedOnly(target, knownStacks)
: await dockerController.pruneSystem(target);
@@ -1205,7 +1219,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
targetResults.push({ target, success: false, reclaimedBytes: 0, error: getErrorMessage(err, 'Prune failed') });
}
}
if (anySuccess) invalidateNodeCaches(node.id);
if (anySuccess && !isDryRun) invalidateNodeCaches(node.id);
return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targetResults };
} finally {
activeBulkActions.delete(lockKey);
@@ -1232,7 +1246,7 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
const response = await fetch(`${baseUrl}/api/system/prune/system`, {
method: 'POST',
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ target, scope }),
body: JSON.stringify({ target, scope, dryRun: isDryRun }),
signal: AbortSignal.timeout(120000),
});
if (!response.ok) {
@@ -1242,12 +1256,14 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
targetResults.push({ target, success: false, reclaimedBytes: 0, error: message });
continue;
}
const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number } | null;
const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number; dryRun?: boolean } | null;
if (!remote || typeof remote.reclaimedBytes !== 'number') {
targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' });
continue;
}
targetResults.push({ target, success: remote.success !== false, reclaimedBytes: remote.reclaimedBytes });
const entry: TargetResult = { target, success: remote.success !== false, reclaimedBytes: remote.reclaimedBytes };
if (remote.dryRun) entry.dryRun = true;
targetResults.push(entry);
} catch (err) {
const message = getErrorMessage(err, 'Failed to reach remote node');
nodeUnreachable = message;
@@ -1269,6 +1285,158 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
}
});
// ─── Fleet Actions: blast-radius preview endpoints (non-destructive) ───
//
// Power the live readouts in the Fleet Action cards. Same auth gates as the
// destructive endpoints above so the surface stays uniform: an operator who
// can fire `fleet-stop` is also the operator who can ask how big it would be.
// Per-label fleet preview. Walks the central node list and looks up the label
// + assignments table for each node. No remote fan-out: stack-to-label
// assignments live in the central DB even for remote nodes, populated by the
// nodes' own UIs and synced via Distributed API.
fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, res: Response): Promise<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+) ───
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 {
const id = parseIntParam(req, res, 'id', 'label ID');
if (id === null) return;
const { action } = req.body;
const { action, dryRun } = req.body;
const validActions = ['deploy', 'stop', 'restart'];
if (!action || !validActions.includes(action)) {
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
return;
}
const isDryRun = dryRun === true;
const nodeId = req.nodeId ?? 0;
@@ -200,11 +201,17 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
const fsStackNames = new Set(fsStacks);
const validStacks = stackNames.filter(name => fsStackNames.has(name));
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length });
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length, dryRun: isDryRun });
const results: { stackName: string; success: boolean; error?: string }[] = [];
const results: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] = [];
for (const stackName of validStacks) {
if (isDryRun) {
// Rehearse the action under the same lock + label resolution + fs
// intersection. Skip the destructive leaf call.
results.push({ stackName, success: true, dryRun: true });
continue;
}
try {
if (action === 'deploy') {
const gate = await enforcePolicyPreDeploy(
@@ -235,10 +242,10 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
const succeeded = results.filter(r => r.success).length;
const failed = results.length - succeeded;
console.log(`[Labels] Bulk ${sanitizeForLog(action)} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`);
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action complete:', { id, action, total: results.length, succeeded, failed });
console.log(`[Labels] Bulk ${sanitizeForLog(action)}${isDryRun ? ' (dry run)' : ''} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`);
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action complete:', { id, action, total: results.length, succeeded, failed, dryRun: isDryRun });
if (succeeded > 0) {
if (succeeded > 0 && !isDryRun) {
invalidateNodeCaches(req.nodeId);
}
res.json({ results });
+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) => {
if (!requireAdmin(req, res)) return;
try {
const { target, scope } = req.body as { target: string; scope?: string };
const { target, scope, dryRun } = req.body as { target: string; scope?: string; dryRun?: boolean };
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
return res.status(400).json({ error: 'Invalid prune target' });
}
const pruneScope = scope === 'managed' ? 'managed' : 'all';
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`);
const isDryRun = dryRun === true;
const dockerController = DockerController.getInstance(req.nodeId);
if (isDryRun) {
// Rehearse the destructive path: same scope resolution, same Docker
// enumeration, no remove calls. Containers have no managed estimate
// helper because pruneManagedOnly does not handle them.
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
let estimate: { reclaimableBytes: number };
if (pruneScope === 'managed' && target !== 'containers') {
estimate = await dockerController.estimateManagedReclaim(
target as 'images' | 'volumes' | 'networks',
knownStacks,
);
} else {
estimate = await dockerController.estimateSystemReclaim(
target as 'containers' | 'images' | 'networks' | 'volumes',
knownStacks,
);
}
res.json({ message: 'Dry run', success: true, dryRun: true, reclaimedBytes: estimate.reclaimableBytes });
return;
}
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`);
let result: { success: boolean; reclaimedBytes: number };
if (pruneScope === 'managed' && target !== 'containers') {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
@@ -80,6 +102,41 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response
}
});
// Non-destructive size estimate for a prune target/scope. The Fleet Actions
// "Prune fleet-wide" card calls this on each remote node to populate its live
// blast-radius readout before the operator confirms. Reuses the same Docker
// enumeration as `/prune/system` so the estimate matches what the destructive
// path would reclaim.
systemMaintenanceRouter.post('/prune/estimate', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
try {
const { target, scope } = req.body as { target: string; scope?: string };
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
return res.status(400).json({ error: 'Invalid prune target' });
}
const pruneScope = scope === 'managed' ? 'managed' : 'all';
const dockerController = DockerController.getInstance(req.nodeId);
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
let result: { reclaimableBytes: number };
if (pruneScope === 'managed' && target !== 'containers') {
result = await dockerController.estimateManagedReclaim(
target as 'images' | 'volumes' | 'networks',
knownStacks,
);
} else {
result = await dockerController.estimateSystemReclaim(
target as 'containers' | 'images' | 'networks' | 'volumes',
knownStacks,
);
}
res.json({ reclaimableBytes: result.reclaimableBytes });
} catch (error: unknown) {
console.error('Prune estimate error:', error);
res.status(500).json({ error: 'Failed to estimate reclaimable bytes' });
}
});
systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) => {
try {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
+63
View File
@@ -386,6 +386,69 @@ class DockerController {
return { success: true, reclaimedBytes };
}
/**
* Non-destructive sibling of `pruneManagedOnly` used by the dry-run path and
* the `/api/system/prune/estimate` route. Walks the same filter rules but
* does not call `.remove()`. Kept structurally parallel to the destructive
* method so the two stay in lockstep when the enumeration logic changes.
*/
public async estimateManagedReclaim(
target: 'images' | 'volumes' | 'networks',
knownStackNames: string[],
): Promise<{ reclaimableBytes: number }> {
const knownSet = new Set(knownStackNames);
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
let reclaimableBytes = 0;
if (target === 'volumes') {
const rawVolumeData = await this.docker.listVolumes();
const rawVolumes: any[] = (this.validateApiData<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<{
reclaimableImages: 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 type { FleetNode } from '@/components/FleetView/types';
import { LabelFleetStopCard } from './cards/LabelFleetStopCard';
@@ -19,22 +19,24 @@ export function FleetActionsTab({ nodes }: Props) {
}
if (!isPaid) {
// Every action is Skipper+. Community users see a calm empty state with
// upgrade context rather than a stripped-down launcher.
return (
<EmptyState />
);
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 (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
<LabelFleetStopCard nodes={nodes} accentTone="rose" icon={Square} />
<BulkLabelAssignCard nodes={nodes} accentTone="purple" icon={Tags} />
<FleetPruneCard nodes={nodes} accentTone="amber" icon={Eraser} />
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-[18px] items-start auto-rows-min">
<LabelFleetStopCard nodes={nodes} />
<BulkLabelAssignCard nodes={nodes} />
<FleetPruneCard nodes={nodes} />
</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() {
return (
<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 type { LucideIcon } from 'lucide-react';
import { Loader2, Server } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ConfirmModal } from '@/components/ui/modal';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { FleetActionCard } from '@/components/ui/fleet-action-card';
import { SheetSection } from '@/components/ui/system-sheet';
import { LabelPill } from '@/components/LabelPill';
import { fetchForNode } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
@@ -13,20 +11,17 @@ import { cn } from '@/lib/utils';
import type { FleetNode } from '@/components/FleetView/types';
import type { Label } from '@/components/label-types';
import { ResultsList, type ResultRow } from '../ResultsList';
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
interface NodeStackResult { stackName: string; success: boolean; error?: string }
interface Props {
nodes: FleetNode[];
icon: LucideIcon;
accentTone: AccentTone;
}
export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
const [selectedNodeId, setSelectedNodeId] = useState<string>(() => {
export function BulkLabelAssignCard({ nodes }: Props) {
const [selectedNodeId, setSelectedNodeId] = useState<number>(() => {
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 [labels, setLabels] = useState<Label[]>([]);
@@ -37,12 +32,10 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultRow[]>([]);
const nodeId = useMemo(() => Number(selectedNodeId) || 0, [selectedNodeId]);
const selectedNode = useMemo(() => nodes.find(n => n.id === nodeId), [nodes, nodeId]);
const selectedNode = useMemo(() => nodes.find(n => n.id === selectedNodeId), [nodes, selectedNodeId]);
// Load stacks + labels whenever the node changes.
useEffect(() => {
if (!nodeId) return;
if (!selectedNodeId) return;
let cancelled = false;
async function load() {
setLoadingLists(true);
@@ -51,8 +44,8 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
setResults([]);
try {
const [stacksRes, labelsRes] = await Promise.all([
fetchForNode(`/fleet/node/${nodeId}/stacks`, nodeId),
fetchForNode('/labels', nodeId),
fetchForNode(`/fleet/node/${selectedNodeId}/stacks`, selectedNodeId),
fetchForNode('/labels', selectedNodeId),
]);
const stacksList = stacksRes.ok ? ((await stacksRes.json()) as string[]) : [];
const labelsList = labelsRes.ok ? ((await labelsRes.json()) as Label[]) : [];
@@ -71,7 +64,7 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
}
load();
return () => { cancelled = true; };
}, [nodeId]);
}, [selectedNodeId]);
function toggleStack(stackName: string) {
setSelectedStacks(prev => {
@@ -93,6 +86,11 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
if (selectedStacks.size === stacks.length) setSelectedStacks(new Set());
else setSelectedStacks(new Set(stacks));
}
function clearSelection() {
setSelectedStacks(new Set());
setSelectedLabels(new Set());
setResults([]);
}
async function run() {
if (selectedStacks.size === 0) return;
@@ -101,7 +99,7 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
const toastId = toast.loading(`Assigning labels to ${assignments.length} stack${assignments.length === 1 ? '' : 's'}`);
setRunning(true);
try {
const res = await fetchForNode('/fleet-actions/labels/bulk-assign', nodeId, {
const res = await fetchForNode('/fleet-actions/labels/bulk-assign', selectedNodeId, {
method: 'POST',
body: JSON.stringify({ assignments }),
});
@@ -131,147 +129,159 @@ export function BulkLabelAssignCard({ nodes, icon: Icon, accentTone }: Props) {
}
}
const blastValue = useMemo(() => {
if (selectedStacks.size === 0 || selectedLabels.size === 0 || !selectedNode) return 'awaiting target';
const stackLabel = `${selectedStacks.size} ${selectedStacks.size === 1 ? 'stack' : 'stacks'}`;
// "local · " prefix triggers the primitive's cyan-dot path per §18.5.
if (selectedNode.type === 'local') return `local · ${stackLabel}`;
return `${selectedNode.name} · ${stackLabel}`;
}, [selectedNode, selectedStacks.size, selectedLabels.size]);
return (
<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])} />
<CardContent className="p-6">
<div className="flex items-start gap-3 mb-4">
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
<Icon className="h-5 w-5" strokeWidth={1.5} />
</span>
<div className="flex-1">
<h3 className="text-base font-medium text-stat-value">Bulk label assign</h3>
<p className="mt-1 text-xs text-stat-subtitle">
Pick a node, multi-select stacks, and replace their labels in one shot.
</p>
</div>
</div>
<>
<FleetActionCard
crumb={['Fleet', 'Actions', 'Bulk label assign']}
name="Bulk label assign."
meta="one node · multi-stack · replaces existing label set"
actionClass="transformative"
blastRadius={{ value: blastValue }}
secondaryAction={{
label: 'Reset',
onClick: clearSelection,
disabled: running || (selectedStacks.size === 0 && selectedLabels.size === 0),
}}
primaryAction={{
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">
<div className="flex items-center gap-2">
<Server className="h-3.5 w-3.5 text-stat-subtitle" strokeWidth={1.5} />
<Select value={selectedNodeId} onValueChange={setSelectedNodeId} disabled={running}>
<SelectTrigger className="w-56 h-8 text-xs">
<SelectValue placeholder="Select a node" />
</SelectTrigger>
<SelectContent>
{nodes.map(n => (
<SelectItem key={n.id} value={String(n.id)}>
{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})
<SheetSection
title={`Stacks · ${selectedStacks.size} / ${stacks.length}`}
meta={stacks.length > 0
? (selectedStacks.size === stacks.length ? 'all selected' : 'multi-select')
: undefined}
>
<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.length > 0 && (
<button
type="button"
)}
{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}
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)}
/>
))}
</div>
<span className="text-xs font-mono text-stat-value">{stackName}</span>
</label>
))}
</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.
Selecting no labels clears assignments.
</p>
</SheetSection>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={running || selectedStacks.size === 0}
onClick={() => setConfirmOpen(true)}
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 && (
<SheetSection title="Per-stack results">
<ResultsList results={results} />
</SheetSection>
)}
</FleetActionCard>
{results.length > 0 && (
<ResultsList title="Per-stack results" results={results} />
)}
</div>
<ConfirmModal
open={confirmOpen}
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="default"
kicker="Bulk label assign"
title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`}
description={
selectedLabels.size === 0
? 'No labels selected, this will clear existing assignments on the selected stacks.'
: `Each selected stack's existing label set on ${selectedNode?.name ?? 'this node'} will be replaced with the chosen labels.`
}
confirmLabel="Apply"
confirming={running}
onConfirm={run}
/>
</CardContent>
</Card>
<ConfirmModal
open={confirmOpen}
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="default"
kicker="Bulk label assign"
title={`Apply ${selectedLabels.size} label${selectedLabels.size === 1 ? '' : 's'} to ${selectedStacks.size} stack${selectedStacks.size === 1 ? '' : 's'}?`}
description={
selectedLabels.size === 0
? 'No labels selected, this will clear existing assignments on the selected stacks.'
: `Each selected stack's existing label set on ${selectedNode?.name ?? 'this node'} will be replaced with the chosen labels.`
}
confirmLabel="Apply"
confirming={running}
onConfirm={run}
/>
</>
);
}
interface NodeSegmentedProps {
nodes: FleetNode[];
value: number;
onChange: (id: number) => void;
disabled: boolean;
}
function NodeSegmented({ nodes, value, onChange, disabled }: NodeSegmentedProps) {
return (
<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 type { LucideIcon } from 'lucide-react';
import { Loader2, AlertTriangle } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { ConfirmModal } from '@/components/ui/modal';
import { FleetActionCard } from '@/components/ui/fleet-action-card';
import { SheetSection } from '@/components/ui/system-sheet';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn, formatBytes } from '@/lib/utils';
import type { FleetNode } from '@/components/FleetView/types';
import { ResultsList, type ResultRow } from '../ResultsList';
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
type PruneTarget = 'images' | 'volumes' | 'networks';
type PruneScope = 'managed' | 'all';
@@ -21,24 +19,35 @@ const ALL_TARGETS: ReadonlyArray<{ id: PruneTarget; label: string }> = [
{ id: 'networks', label: 'Networks' },
];
interface TargetResult { target: PruneTarget; success: boolean; reclaimedBytes: number; error?: string }
interface TargetResult { target: PruneTarget; success: boolean; reclaimedBytes: number; error?: string; dryRun?: boolean }
interface FleetPruneNodeResult {
nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[];
}
interface PruneEstimateNode { nodeId: number; nodeName: string; reclaimableBytes: number; reachable: boolean; error?: string }
interface PruneEstimateResponse { totalBytes: number; perNode: PruneEstimateNode[] }
type EstimateState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'unavailable' }
| { kind: 'ready'; data: PruneEstimateResponse };
interface Props {
nodes: FleetNode[];
icon: LucideIcon;
accentTone: AccentTone;
}
export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
const ESTIMATE_ROW_LIMIT = 6;
export function FleetPruneCard({ nodes }: Props) {
const nodeCount = nodes.length;
const [targets, setTargets] = useState<Set<PruneTarget>>(new Set(['images']));
const [scope, setScope] = useState<PruneScope>('managed');
const [confirmOpen, setConfirmOpen] = useState(false);
const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultRow[]>([]);
const [estimate, setEstimate] = useState<EstimateState>({ kind: 'idle' });
const toggleTarget = (target: PruneTarget) => {
setTargets(prev => {
@@ -49,16 +58,53 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
});
};
async function run() {
// Re-estimate when the operator's choices change. Debounced because each
// tick fans out per-target HTTP to every remote node; back-to-back clicks
// on the target checkboxes would otherwise pile concurrent fleet-wide fans
// onto the backend.
const estimateDebounceRef = useRef<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;
const selected = Array.from(targets);
const toastId = toast.loading(`Pruning ${selected.join(', ')} across the fleet…`);
const verb = opts.dryRun ? 'Dry-running prune of' : 'Pruning';
const toastId = toast.loading(`${verb} ${selected.join(', ')} across the fleet…`);
setRunning(true);
setResults([]);
try {
const res = await apiFetch('/fleet/labels/fleet-prune', {
method: 'POST',
body: JSON.stringify({ targets: selected, scope }),
body: JSON.stringify({ targets: selected, scope, dryRun: opts.dryRun }),
});
const body = await res.json().catch(() => ({}));
toast.dismiss(toastId);
@@ -73,7 +119,7 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
return {
key: `node-${node.nodeId}`,
label: node.reachable
? `${node.nodeName} · ${formatBytes(totalBytes)}`
? `${node.nodeName} · ${formatBytes(totalBytes)}${opts.dryRun ? ' (dry run)' : ''}`
: `${node.nodeName} (unreachable)`,
success: allOk,
error: node.reachable ? undefined : node.error,
@@ -92,7 +138,9 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
(sum, n) => sum + n.targets.reduce((s, t) => s + (t.reclaimedBytes ?? 0), 0),
0,
);
if (okNodes === totalNodes && totalNodes > 0) {
if (opts.dryRun) {
toast.success(`Dry run: ${formatBytes(totalReclaimed)} would be reclaimed across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`);
} else if (okNodes === totalNodes && totalNodes > 0) {
toast.success(`Reclaimed ${formatBytes(totalReclaimed)} across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`);
} else if (okNodes === 0) {
toast.error('Prune failed on every node. See results below.');
@@ -108,130 +156,171 @@ export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) {
}
}
const targetCount = targets.size;
const isAllScope = scope === 'all';
const blastValue = useMemo(() => {
if (targets.size === 0) return 'awaiting target';
if (estimate.kind === 'loading') return '~ estimating…';
if (estimate.kind === 'unavailable') return '~ estimate unavailable';
if (estimate.kind === 'ready') {
const { totalBytes } = estimate.data;
if (totalBytes === 0) return '0 reclaimable';
return `~ ${formatBytes(totalBytes)} reclaimable`;
}
return 'awaiting target';
}, [targets.size, estimate]);
const blastTone = estimate.kind === 'loading' || estimate.kind === 'unavailable' ? 'muted' as const : undefined;
return (
<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])} />
<CardContent className="p-6">
<div className="flex items-start gap-3 mb-4">
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
<Icon className="h-5 w-5" strokeWidth={1.5} />
</span>
<div className="flex-1">
<h3 className="text-base font-medium text-stat-value">Prune Docker resources fleet-wide</h3>
<p className="mt-1 text-xs text-stat-subtitle">
Reclaim space on {nodeCount} node{nodeCount === 1 ? '' : 's'} by removing unused images, volumes, and networks. Reclaimed bytes are approximate.
</p>
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-[10px] uppercase tracking-wide text-stat-subtitle mb-1.5">Targets</div>
<div className="flex flex-wrap gap-3">
{ALL_TARGETS.map(t => (
<label
key={t.id}
className="flex items-center gap-2 py-1 px-2 rounded hover:bg-glass-highlight cursor-pointer"
>
<Checkbox
checked={targets.has(t.id)}
onCheckedChange={() => toggleTarget(t.id)}
disabled={running}
/>
<span className="text-xs text-stat-value">{t.label}</span>
</label>
))}
</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"
<>
<FleetActionCard
crumb={['Fleet', 'Actions', 'Prune resources']}
name="Prune fleet-wide."
meta="images · volumes · networks · serial per node"
actionClass="maintenance"
blastRadius={{ value: blastValue, tone: blastTone }}
secondaryAction={{
label: running ? 'Running…' : 'Dry run',
onClick: () => run({ dryRun: true }),
disabled: running || targets.size === 0,
}}
primaryAction={{
label: 'Prune fleet',
onClick: () => setConfirmOpen(true),
variant: 'destructive',
// Block the destructive confirm until the operator has actually
// seen what the readout says will be reclaimed. Falling back to a
// confirm modal with no estimate context is the audit §F20.3 problem.
disabled: running || targets.size === 0 || estimate.kind !== 'ready',
}}
footerContext={`Reversible · no · serial across ${nodeCount} node${nodeCount === 1 ? '' : 's'}`}
>
<SheetSection
title={`Targets · ${targets.size} / ${ALL_TARGETS.length}`}
meta={targets.size === 0 ? 'pick at least one' : undefined}
>
<div className="flex flex-wrap gap-3">
{ALL_TARGETS.map(t => (
<label
key={t.id}
className="flex items-center gap-2 py-1 px-2 rounded hover:bg-glass-highlight cursor-pointer"
>
Managed only
</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>
</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>
<Checkbox
checked={targets.has(t.id)}
onCheckedChange={() => toggleTarget(t.id)}
disabled={running}
/>
<span className="text-xs text-stat-value">{t.label}</span>
</label>
))}
</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
variant="outline"
type="button"
variant={scope === 'managed' ? 'default' : 'outline'}
size="sm"
disabled={running || targetCount === 0}
onClick={() => setConfirmOpen(true)}
className="gap-2"
disabled={running}
onClick={() => setScope('managed')}
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} />}
Prune across fleet
Managed only
</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>
{!running && results.length > 0 && (
<button
type="button"
onClick={() => setResults([])}
className="text-xs text-stat-subtitle hover:text-stat-value"
>
Clear results
</button>
)}
</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 && (
<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>
)}
{targets.size > 0 && <EstimateSection estimate={estimate} />}
{results.length > 0 && (
<ResultsList title="Per-node breakdown" results={results} />
)}
</div>
{results.length > 0 && (
<SheetSection title="Per-node breakdown">
<ResultsList results={results} />
</SheetSection>
)}
</FleetActionCard>
<ConfirmModal
open={confirmOpen}
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="destructive"
kicker="Fleet prune"
title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'}
description={
isAllScope
? 'This runs docker prune --all on every reachable node. Any image, volume, or network not currently in use will be deleted, including resources from workloads Sencho does not manage. This cannot be undone.'
: 'Sencho will remove unused Docker resources owned by stacks known to this fleet on every reachable node. Active resources are not touched.'
}
confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'}
confirming={running}
onConfirm={run}
/>
</CardContent>
</Card>
<ConfirmModal
open={confirmOpen}
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="destructive"
kicker="Fleet prune"
title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'}
description={
isAllScope
? 'This runs docker prune --all on every reachable node. Any image, volume, or network not currently in use will be deleted, including resources from workloads Sencho does not manage. This cannot be undone.'
: 'Sencho will remove unused Docker resources owned by stacks known to this fleet on every reachable node. Active resources are not touched.'
}
confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'}
confirming={running}
onConfirm={() => run({ dryRun: false })}
/>
</>
);
}
function EstimateSection({ estimate }: { estimate: EstimateState }) {
if (estimate.kind === 'idle' || estimate.kind === 'loading') {
return (
<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 type { LucideIcon } from 'lucide-react';
import { Loader2, AlertTriangle } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ConfirmModal } from '@/components/ui/modal';
import { Input } from '@/components/ui/input';
import { FleetActionCard } from '@/components/ui/fleet-action-card';
import { SheetSection } from '@/components/ui/system-sheet';
import { apiFetch, fetchForNode } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import type { FleetNode } from '@/components/FleetView/types';
import type { Label } from '@/components/label-types';
import { ResultsList, type ResultRow } from '../ResultsList';
import { TONE_RAIL, TONE_BG, type AccentTone } from './tone';
interface NodeStackResult { stackName: string; success: boolean; error?: string }
interface NodeStackResult { stackName: string; success: boolean; error?: string; dryRun?: boolean }
interface FleetStopNodeResult {
nodeId: number;
nodeName: string;
@@ -21,21 +18,31 @@ interface FleetStopNodeResult {
stackResults: NodeStackResult[];
}
interface MatchPreviewNode { nodeId: number; nodeName: string; stackCount: number; stackNames: string[] }
interface MatchPreviewResponse { matchedNodes: number; matchedStacks: number; perNode: MatchPreviewNode[] }
type PreviewState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'unavailable' }
| { kind: 'ready'; data: MatchPreviewResponse };
interface Props {
nodes: FleetNode[];
icon: LucideIcon;
accentTone: AccentTone;
}
export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
const PREVIEW_ROW_LIMIT = 6;
export function LabelFleetStopCard({ nodes }: Props) {
const [labelName, setLabelName] = useState('');
const [knownLabelNames, setKnownLabelNames] = useState<string[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false);
const [running, setRunning] = useState(false);
const [results, setResults] = useState<ResultRow[]>([]);
const [preview, setPreview] = useState<PreviewState>({ kind: 'idle' });
// Aggregate label names across reachable nodes for autocomplete. Offline
// nodes are skipped so the page-load fanout doesn't hang on dead remotes.
// Aggregate label names across reachable nodes for autocomplete.
useEffect(() => {
let cancelled = false;
async function loadSuggestions() {
@@ -48,7 +55,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
const list = (await res.json()) as Label[];
for (const l of list) names.add(l.name);
} catch {
/* ignore — this node is unreachable, not user-facing */
/* unreachable node, not user-facing */
}
}));
if (!cancelled) setKnownLabelNames(Array.from(names).sort());
@@ -57,16 +64,68 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
return () => { cancelled = true; };
}, [nodes]);
async function run() {
// Debounced live preview. The blast-radius readout and the preview section
// both read from the same state.
const debounceRef = useRef<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();
if (!trimmed) return;
const toastId = toast.loading(`Stopping stacks labeled "${trimmed}" across the fleet…`);
const verb = opts.dryRun ? 'Dry-running' : 'Stopping';
const toastId = toast.loading(`${verb} stacks labeled "${trimmed}" across the fleet…`);
setRunning(true);
setResults([]);
try {
const res = await apiFetch('/fleet/labels/fleet-stop', {
method: 'POST',
body: JSON.stringify({ labelName: trimmed }),
body: JSON.stringify({ labelName: trimmed, dryRun: opts.dryRun }),
});
const body = await res.json().catch(() => ({}));
toast.dismiss(toastId);
@@ -78,7 +137,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
const rows: ResultRow[] = apiResults.map((node) => ({
key: `node-${node.nodeId}`,
label: node.matched
? `${node.nodeName} · ${node.stackResults.length} stack${node.stackResults.length === 1 ? '' : 's'}`
? `${node.nodeName} · ${node.stackResults.length} stack${node.stackResults.length === 1 ? '' : 's'}${opts.dryRun ? ' (dry run)' : ''}`
: `${node.nodeName} (no matching label)`,
success: node.matched && node.stackResults.every(s => s.success),
error: node.matched ? undefined : 'Label not present',
@@ -95,6 +154,7 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
const ok = stacksTouched.filter(s => s.success).length;
const failed = stacksTouched.length - ok;
if (matchedNodes === 0) toast.info('No nodes have a label by that name.');
else if (opts.dryRun) toast.success(`Dry run: would stop ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`);
else if (failed === 0 && ok > 0) toast.success(`Stopped ${ok} stack${ok === 1 ? '' : 's'} across ${matchedNodes} node${matchedNodes === 1 ? '' : 's'}.`);
else if (ok === 0 && failed === 0) toast.info('Label matched but no stacks were assigned to it.');
else toast.warning(`${ok} stopped, ${failed} failed. See results below.`);
@@ -107,92 +167,144 @@ export function LabelFleetStopCard({ nodes, icon: Icon, accentTone }: Props) {
}
}
const trimmed = labelName.trim();
const previewSection = renderPreviewSection(preview, trimmed);
// Freshness placeholder: until a run-record store exists, the footer carries
// reversibility alone. Add freshness once a /fleet/labels/last-fleet-stop
// endpoint or equivalent ships.
// TODO(freshness): emit "last fleet-stop {age}" once the run-record source lands.
const footerContext = 'Reversible · no';
return (
<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])} />
<CardContent className="p-6">
<div className="flex items-start gap-3 mb-4">
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md', TONE_BG[accentTone])}>
<Icon className="h-5 w-5" strokeWidth={1.5} />
</span>
<div className="flex-1">
<h3 className="text-base font-medium text-stat-value">Stop fleet by label</h3>
<p className="mt-1 text-xs text-stat-subtitle">
Stop every stack labeled with this name on every node. Labels are matched by name across the fleet.
</p>
</div>
</div>
<>
<FleetActionCard
crumb={['Fleet', 'Actions', 'Stop by label']}
name="Stop by label."
meta="label-match · per-node fan-out · reports per-stack results"
actionClass="destructive"
blastRadius={{ value: blastValue, tone: blastTone }}
secondaryAction={{
label: running ? 'Running…' : 'Dry run',
onClick: () => run({ dryRun: true }),
disabled: running || trimmed.length === 0,
}}
primaryAction={{
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">
<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>
{previewSection}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={running || labelName.trim().length === 0}
onClick={() => setConfirmOpen(true)}
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 && (
<SheetSection title="Per-node breakdown">
<ResultsList results={results} />
</SheetSection>
)}
</FleetActionCard>
{results.length === 0 && !running && (
<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>
Different nodes can have their own label rows. Stops are dispatched per node and report
per-stack results below.
</span>
</div>
</div>
)}
{results.length > 0 && (
<ResultsList title="Per-node breakdown" results={results} />
)}
</div>
<ConfirmModal
open={confirmOpen}
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="destructive"
kicker="Fleet stop"
title={`Stop all stacks labeled "${labelName.trim()}"?`}
description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted."
confirmLabel="Stop fleet"
confirming={running}
onConfirm={run}
/>
</CardContent>
</Card>
<ConfirmModal
open={confirmOpen}
onOpenChange={(open) => { if (!open) setConfirmOpen(false); }}
variant="destructive"
kicker="Fleet stop"
title={`Stop all stacks labeled "${trimmed}"?`}
description="Sencho will stop every stack on every node that has a label with this name. Services will be unavailable until restarted."
confirmLabel="Stop fleet"
confirming={running}
onConfirm={() => run({ dryRun: false })}
/>
</>
);
}
function renderPreviewSection(preview: PreviewState, trimmed: string) {
if (trimmed.length === 0) return null;
if (preview.kind === 'loading') {
return (
<SheetSection title="Preview" meta="resolving…">
<div className={cn(KICKER, 'text-stat-icon')}>looking up label across the fleet</div>
</SheetSection>
);
}
if (preview.kind === 'unavailable') {
return (
<SheetSection title="Preview" meta="unavailable">
<div className={cn(KICKER, 'text-stat-icon')}>preview endpoint did not respond</div>
</SheetSection>
);
}
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;
/** Hide the title rule + label (useful when a section is the only one in a tab). */
hideHeader?: boolean;
/** Optional right-aligned mono meta on the section header row (count, scope, freshness). */
meta?: string;
className?: string;
children: React.ReactNode;
}
export function SheetSection({ title, hideHeader, className, children }: SheetSectionProps) {
export function SheetSection({ title, hideHeader, meta, className, children }: SheetSectionProps) {
return (
<section className={cn('first:pt-0 first:border-t-0 -mx-6 px-6 py-4 border-t border-card-border/40', className)}>
{!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}
</section>
+10
View File
@@ -122,6 +122,12 @@
--label-custom: var(--label-amber);
--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: oklch(0 0 0 / 0.06);
--chart-tick: oklch(0 0 0 / 0.45);
@@ -299,6 +305,9 @@
--label-custom: var(--label-amber);
--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: oklch(1 0 0 / 0.05);
--chart-tick: oklch(1 0 0 / 0.40);
@@ -407,6 +416,7 @@
--color-update-foreground: var(--update-foreground);
--color-update-muted: var(--update-muted);
--color-destructive-muted: var(--destructive-muted);
--color-action-transformative: var(--action-transformative);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);