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