From 1f673073ca4f91ae69d39f84d6397250b6c9bc20 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 19 May 2026 00:13:32 -0400 Subject: [PATCH] feat(fleet): add fleet-wide Docker prune to Fleet Actions (#1104) Adds a third card to the Fleet Actions tab that fans out Docker prune (images, volumes, networks) across every node in one submit. Local nodes call DockerController under a bulk-prune lock; remote nodes receive one POST /api/system/prune/system per target. Per-node + per-target results with reclaimed bytes are surfaced inline via ResultsList. Tier: Skipper / Admiral (requirePaid + requireAdmin), matching the rest of Fleet Actions. The frontend card is mounted inside the existing isPaid branch at FleetActionsTab; no new frontend gate is required. The card uses an amber accent rail and the Eraser icon so it reads as 'cleanup' rather than 'destructive stop'. Scope toggle defaults to Managed only (Sencho-tagged resources) with an All unused option that escalates the destructive-confirm copy. --- backend/src/__tests__/fleet-prune.test.ts | 240 ++++++++++++++++++ backend/src/routes/fleet.ts | 136 ++++++++++ docs/features/fleet-actions.mdx | 20 ++ .../fleet/FleetActions/FleetActionsTab.tsx | 11 +- .../FleetActions/cards/FleetPruneCard.tsx | 237 +++++++++++++++++ .../fleet/FleetActions/cards/tone.ts | 10 +- 6 files changed, 645 insertions(+), 9 deletions(-) create mode 100644 backend/src/__tests__/fleet-prune.test.ts create mode 100644 frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx diff --git a/backend/src/__tests__/fleet-prune.test.ts b/backend/src/__tests__/fleet-prune.test.ts new file mode 100644 index 00000000..5c0f7d3d --- /dev/null +++ b/backend/src/__tests__/fleet-prune.test.ts @@ -0,0 +1,240 @@ +/** + * Tests for the fleet-wide Docker prune endpoint. Covers auth, tier gating, + * input validation, local node orchestration with mocked DockerController, + * remote-node fan-out with mocked fetch, lock contention, and partial failures. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let DockerController: typeof import('../services/DockerController').default; +let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let activeBulkActions: typeof import('../routes/labels').activeBulkActions; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ LicenseService } = await import('../services/LicenseService')); + ({ default: DockerController } = await import('../services/DockerController')); + ({ FileSystemService } = await import('../services/FileSystemService')); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ activeBulkActions } = await import('../routes/labels')); + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + authHeader = `Bearer ${token}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +afterEach(() => { + vi.restoreAllMocks(); + activeBulkActions.clear(); +}); + +function mockTier(tier: 'paid' | 'community') { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier); +} + +function mockLocalPrune(opts: { managedBytes?: Partial>; allBytes?: Partial>; throwOn?: string } = {}) { + const fake = { + pruneManagedOnly: vi.fn(async (target: string) => { + if (opts.throwOn === target) throw new Error(`mock pruneManagedOnly threw for ${target}`); + return { success: true, reclaimedBytes: opts.managedBytes?.[target] ?? 0 }; + }), + pruneSystem: vi.fn(async (target: string) => { + if (opts.throwOn === target) throw new Error(`mock pruneSystem threw for ${target}`); + return { success: true, reclaimedBytes: opts.allBytes?.[target] ?? 0 }; + }), + }; + vi.spyOn(DockerController, 'getInstance').mockReturnValue(fake as unknown as ReturnType); + // Spy on the prototype so the mock applies to whichever FileSystemService + // instance the route creates for the local node id, not a throwaway one. + vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue(['stack-a', 'stack-b']); + return fake; +} + +describe('POST /api/fleet/labels/fleet-prune', () => { + it('returns 401 without auth', async () => { + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .send({ targets: ['images'], scope: 'managed' }); + expect(res.status).toBe(401); + }); + + it('returns 403 PAID_REQUIRED on community tier', async () => { + mockTier('community'); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'managed' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('returns 400 when body is missing', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send(); + expect(res.status).toBe(400); + }); + + it('returns 400 when targets is empty', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: [], scope: 'managed' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/non-empty/); + }); + + it('returns 400 when a target is unrecognized', async () => { + mockTier('paid'); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images', 'containers'], scope: 'managed' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid target/); + }); + + it('runs pruneManagedOnly per target on the local node and returns aggregated bytes', async () => { + mockTier('paid'); + const fake = mockLocalPrune({ managedBytes: { images: 1500, volumes: 320 } }); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images', 'volumes'], scope: 'managed' }); + expect(res.status).toBe(200); + expect(res.body.results).toHaveLength(1); + const node = res.body.results[0]; + expect(node.reachable).toBe(true); + expect(node.targets).toEqual([ + { target: 'images', success: true, reclaimedBytes: 1500 }, + { target: 'volumes', success: true, reclaimedBytes: 320 }, + ]); + expect(fake.pruneManagedOnly).toHaveBeenCalledTimes(2); + expect(fake.pruneSystem).not.toHaveBeenCalled(); + }); + + it('runs pruneSystem when scope is "all" and dedupes targets', async () => { + mockTier('paid'); + const fake = mockLocalPrune({ allBytes: { networks: 0, images: 2048 } }); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images', 'networks', 'images'], scope: 'all' }); + expect(res.status).toBe(200); + expect(fake.pruneManagedOnly).not.toHaveBeenCalled(); + expect(fake.pruneSystem).toHaveBeenCalledTimes(2); + const node = res.body.results[0]; + expect(node.targets.map((t: { target: string }) => t.target).sort()).toEqual(['images', 'networks']); + }); + + it('records per-target failure when DockerController throws but continues remaining targets', async () => { + mockTier('paid'); + mockLocalPrune({ managedBytes: { images: 100 }, throwOn: 'volumes' }); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images', 'volumes'], scope: 'managed' }); + expect(res.status).toBe(200); + const node = res.body.results[0]; + expect(node.targets.find((t: { target: string }) => t.target === 'images').success).toBe(true); + const volumes = node.targets.find((t: { target: string }) => t.target === 'volumes'); + expect(volumes.success).toBe(false); + expect(volumes.reclaimedBytes).toBe(0); + expect(volumes.error).toMatch(/pruneManagedOnly threw/); + }); + + it('reports lock contention when bulk-prune lock is already held', async () => { + mockTier('paid'); + mockLocalPrune(); + const db = DatabaseService.getInstance(); + const localId = db.getNodes().find(n => n.type === 'local')!.id; + activeBulkActions.add(`bulk-prune:${localId}`); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'managed' }); + expect(res.status).toBe(200); + const node = res.body.results.find((n: { nodeId: number }) => n.nodeId === localId); + expect(node.targets[0].success).toBe(false); + expect(node.targets[0].error).toMatch(/already running/); + }); + + it('marks a remote node unreachable when fetch throws and short-circuits later targets', async () => { + mockTier('paid'); + mockLocalPrune(); + const db = DatabaseService.getInstance(); + const remoteId = db.addNode({ + name: 'remote-test', + type: 'remote', + api_url: 'http://remote.example:1852', + api_token: 'tok', + compose_dir: '/app/compose', + is_default: false, + }); + try { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED')); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images', 'volumes', 'networks'], scope: 'managed' }); + expect(res.status).toBe(200); + const remote = res.body.results.find((n: { nodeId: number }) => n.nodeId === remoteId); + expect(remote.reachable).toBe(false); + expect(remote.error).toMatch(/ECONNREFUSED/); + expect(remote.targets).toHaveLength(3); + for (const t of remote.targets) expect(t.success).toBe(false); + // Only the first target attempts the fetch; the rest short-circuit. + expect(fetchSpy).toHaveBeenCalledTimes(1); + } finally { + db.deleteNode(remoteId); + } + }); + + it('parses remote node responses into per-target reclaimed bytes', async () => { + mockTier('paid'); + mockLocalPrune(); + const db = DatabaseService.getInstance(); + const remoteId = db.addNode({ + name: 'remote-ok', + type: 'remote', + api_url: 'http://remote-ok.example:1852/', + api_token: 'tok', + compose_dir: '/app/compose', + is_default: false, + }); + try { + const responses = new Map([['images', 4096], ['volumes', 512]]); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const body = JSON.parse((init?.body as string) ?? '{}') as { target: string }; + const reclaimedBytes = responses.get(body.target) ?? 0; + return new Response(JSON.stringify({ message: 'ok', success: true, reclaimedBytes }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + }); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images', 'volumes'], scope: 'all' }); + expect(res.status).toBe(200); + const remote = res.body.results.find((n: { nodeId: number }) => n.nodeId === remoteId); + expect(remote.reachable).toBe(true); + expect(remote.targets).toEqual([ + { target: 'images', success: true, reclaimedBytes: 4096 }, + { target: 'volumes', success: true, reclaimedBytes: 512 }, + ]); + } finally { + db.deleteNode(remoteId); + } + }); +}); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 535c95e1..cb58b9fd 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -1124,6 +1124,142 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: } }); +// Fleet-wide Docker prune. Fans out to every node, running per-target prune +// (images/volumes/networks) under the chosen scope. Local nodes call +// DockerController directly under a per-node bulk-prune lock; remote nodes +// receive one POST /api/system/prune/system per target via the standard +// Bearer-token path. Concurrent execution against the per-node prune route in +// systemMaintenance.ts is safe because Docker's prune API is internally +// serialized and idempotent (the worst case is a duplicate call returning 0 +// reclaimed bytes). +// Tier: requirePaid + requireAdmin. +const FLEET_PRUNE_TARGETS = ['images', 'volumes', 'networks'] as const; +type FleetPruneTarget = (typeof FLEET_PRUNE_TARGETS)[number]; + +fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePaid(req, res)) return; + if (!requireAdmin(req, res)) return; + + const body = req.body as { targets?: unknown; scope?: unknown } | undefined; + if (!body || typeof body !== 'object') { + res.status(400).json({ error: 'Request body is required' }); + return; + } + const rawTargets = Array.isArray(body.targets) ? body.targets : null; + if (!rawTargets || rawTargets.length === 0) { + res.status(400).json({ error: 'targets must be a non-empty array' }); + return; + } + const dedup = new Set(); + for (const t of rawTargets) { + if (typeof t !== 'string' || !(FLEET_PRUNE_TARGETS as readonly string[]).includes(t)) { + res.status(400).json({ error: `Invalid target: ${typeof t === 'string' ? t : typeof t}` }); + return; + } + dedup.add(t as FleetPruneTarget); + } + const targets: FleetPruneTarget[] = Array.from(dedup); + const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed'; + + type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string }; + type NodeResult = { + nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[]; + }; + + try { + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + + const results: NodeResult[] = await Promise.all(nodes.map(async (node): Promise => { + if (node.type === 'local') { + const lockKey = `bulk-prune:${node.id}`; + if (activeBulkActions.has(lockKey)) { + return { + nodeId: node.id, nodeName: node.name, reachable: true, + targets: targets.map(t => ({ target: t, success: false, reclaimedBytes: 0, error: 'A prune is already running on this node' })), + }; + } + activeBulkActions.add(lockKey); + try { + const knownStacks = scope === 'managed' ? await FileSystemService.getInstance(node.id).getStacks() : []; + const dockerController = DockerController.getInstance(node.id); + const targetResults: TargetResult[] = []; + let anySuccess = false; + for (const target of targets) { + try { + const result = scope === 'managed' + ? await dockerController.pruneManagedOnly(target, knownStacks) + : await dockerController.pruneSystem(target); + targetResults.push({ target, success: true, reclaimedBytes: result.reclaimedBytes }); + if (result.reclaimedBytes > 0 || result.success) anySuccess = true; + } catch (err) { + targetResults.push({ target, success: false, reclaimedBytes: 0, error: getErrorMessage(err, 'Prune failed') }); + } + } + if (anySuccess) invalidateNodeCaches(node.id); + return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targetResults }; + } finally { + activeBulkActions.delete(lockKey); + } + } + + // Remote node: POST /api/system/prune/system per target, short-circuiting + // on the first transport-level failure so we don't hammer a dead node. + if (!node.api_url || !node.api_token) { + return { + nodeId: node.id, nodeName: node.name, reachable: false, error: 'Remote node not configured', + targets: targets.map(t => ({ target: t, success: false, reclaimedBytes: 0, error: 'Remote node not configured' })), + }; + } + const baseUrl = node.api_url.replace(/\/$/, ''); + const targetResults: TargetResult[] = []; + let nodeUnreachable: string | null = null; + for (const target of targets) { + if (nodeUnreachable) { + targetResults.push({ target, success: false, reclaimedBytes: 0, error: nodeUnreachable }); + continue; + } + try { + 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 }), + signal: AbortSignal.timeout(120000), + }); + if (!response.ok) { + const errBody = (await response.json().catch(() => ({}))) as { error?: string }; + const message = errBody.error || `Remote returned ${response.status}`; + nodeUnreachable = message; + targetResults.push({ target, success: false, reclaimedBytes: 0, error: message }); + continue; + } + const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number } | 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 }); + } catch (err) { + const message = getErrorMessage(err, 'Failed to reach remote node'); + nodeUnreachable = message; + targetResults.push({ target, success: false, reclaimedBytes: 0, error: message }); + } + } + return { + nodeId: node.id, nodeName: node.name, + reachable: nodeUnreachable === null, + error: nodeUnreachable ?? undefined, + targets: targetResults, + }; + })); + + res.json({ results }); + } catch (error) { + console.error('[Fleet] fleet-prune error:', error); + res.status(500).json({ error: getErrorMessage(error, 'Failed to run fleet prune') }); + } +}); + // ─── Fleet Snapshots (manual: Community; scheduled: Skipper+) ─── fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise => { diff --git a/docs/features/fleet-actions.mdx b/docs/features/fleet-actions.mdx index e5702ca4..d0b21c1e 100644 --- a/docs/features/fleet-actions.mdx +++ b/docs/features/fleet-actions.mdx @@ -13,6 +13,7 @@ Fleet Actions covers the operations that aren't already exposed elsewhere in Sen - **Stop fleet by label** dispatches a stop to every stack labeled with a given name across every node in the fleet. - **Bulk label assign** applies the same label set to many stacks on one node in a single round trip. +- **Prune Docker resources fleet-wide** reclaims disk space on every node by removing unused images, volumes, and networks in one submit. Other bulk actions you may be looking for live in their natural homes: @@ -45,6 +46,19 @@ This card replaces the label set on many stacks at once on a single node. Selecting no labels and applying clears existing label assignments on the chosen stacks. The selected label set always **replaces** the existing one rather than appending to it. +## Prune Docker resources fleet-wide + +This card reclaims disk space on every reachable node by running Docker prune across the targets you select. Each target runs serially on a node; nodes are processed in parallel. + +1. Check the targets you want to prune: **Images**, **Volumes**, and **Networks**. At least one is required. +2. Pick a **Scope**: + - **Managed only** (default): removes only resources owned by stacks Sencho manages. Safe for shared Docker hosts. + - **All unused**: runs `docker prune --all` on every reachable node. Removes every unused image, volume, or network, including resources from workloads Sencho does not manage. +3. Click **Prune across fleet** and confirm. The confirmation copy escalates when **All unused** is selected. +4. Results appear inline below the card, grouped by node with one sub-row per target showing approximate bytes reclaimed. + +Reclaimed-byte totals are best-effort. Docker does not report bytes for network removals, so the networks row always reports `0 B`. + ## Permissions Both cards require the **admin** role and a **Skipper** or **Admiral** license. Community-tier users see a calm explainer card in this tab instead of the action surface. @@ -62,3 +76,9 @@ Stack names must be alphanumeric with dashes and underscores. The endpoint valid **The Fleet Actions tab is missing from Fleet.** Confirm the active license is **Skipper** or **Admiral** under **Settings > License**. The tab itself is always visible, but the action cards only render at paid tiers. + +**A node row reports "unreachable" after a fleet prune.** +Sencho was unable to dispatch the prune to that remote node within the per-target timeout. Open the node detail and confirm it is online and that its API token is still valid. Sencho short-circuits later targets on the same node once one fails so a dead remote does not slow the whole operation. + +**A target row shows `0 B` reclaimed.** +That target had nothing to prune at the time of the run. For networks this is also the expected reading on every successful run, because Docker does not report bytes for network removals. diff --git a/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx b/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx index 33a5b746..dc234eee 100644 --- a/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx +++ b/frontend/src/components/fleet/FleetActions/FleetActionsTab.tsx @@ -1,8 +1,9 @@ -import { Square, Tags } from 'lucide-react'; +import { Square, Tags, Eraser } from 'lucide-react'; import { useLicense } from '@/context/LicenseContext'; import type { FleetNode } from '@/components/FleetView/types'; import { LabelFleetStopCard } from './cards/LabelFleetStopCard'; import { BulkLabelAssignCard } from './cards/BulkLabelAssignCard'; +import { FleetPruneCard } from './cards/FleetPruneCard'; interface Props { nodes: FleetNode[]; @@ -18,7 +19,7 @@ export function FleetActionsTab({ nodes }: Props) { } if (!isPaid) { - // Both actions are Skipper+. Community users see a calm empty state with + // Every action is Skipper+. Community users see a calm empty state with // upgrade context rather than a stripped-down launcher. return ( @@ -26,9 +27,10 @@ export function FleetActionsTab({ nodes }: Props) { } return ( -
+
+
); } @@ -41,8 +43,7 @@ function EmptyState() {

Fleet-wide bulk actions

- Stop stacks across every node by label name, and assign labels to many - stacks in one shot. Available on Skipper and Admiral. + Stop stacks across every node by label name, assign labels to many stacks at once, and reclaim Docker disk space fleet-wide. Available on Skipper and Admiral.

); diff --git a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx new file mode 100644 index 00000000..ea047db7 --- /dev/null +++ b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx @@ -0,0 +1,237 @@ +import { 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 { Checkbox } from '@/components/ui/checkbox'; +import { ConfirmModal } from '@/components/ui/modal'; +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'; + +const ALL_TARGETS: ReadonlyArray<{ id: PruneTarget; label: string }> = [ + { id: 'images', label: 'Images' }, + { id: 'volumes', label: 'Volumes' }, + { id: 'networks', label: 'Networks' }, +]; + +interface TargetResult { target: PruneTarget; success: boolean; reclaimedBytes: number; error?: string } +interface FleetPruneNodeResult { + nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[]; +} + +interface Props { + nodes: FleetNode[]; + icon: LucideIcon; + accentTone: AccentTone; +} + +export function FleetPruneCard({ nodes, icon: Icon, accentTone }: Props) { + const nodeCount = nodes.length; + const [targets, setTargets] = useState>(new Set(['images'])); + const [scope, setScope] = useState('managed'); + const [confirmOpen, setConfirmOpen] = useState(false); + const [running, setRunning] = useState(false); + const [results, setResults] = useState([]); + + const toggleTarget = (target: PruneTarget) => { + setTargets(prev => { + const next = new Set(prev); + if (next.has(target)) next.delete(target); + else next.add(target); + return next; + }); + }; + + async function run() { + if (targets.size === 0) return; + const selected = Array.from(targets); + const toastId = toast.loading(`Pruning ${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 }), + }); + const body = await res.json().catch(() => ({})); + toast.dismiss(toastId); + if (!res.ok) { + toast.error(body.error || 'Fleet prune failed'); + return; + } + const apiResults = (body.results as FleetPruneNodeResult[]) ?? []; + const rows: ResultRow[] = apiResults.map((node) => { + const totalBytes = node.targets.reduce((sum, t) => sum + (t.reclaimedBytes ?? 0), 0); + const allOk = node.reachable && node.targets.every(t => t.success); + return { + key: `node-${node.nodeId}`, + label: node.reachable + ? `${node.nodeName} · ${formatBytes(totalBytes)}` + : `${node.nodeName} (unreachable)`, + success: allOk, + error: node.reachable ? undefined : node.error, + sub: node.targets.map((t, i) => ({ + key: `${node.nodeId}-${t.target}-${i}`, + label: `${t.target} · ${formatBytes(t.reclaimedBytes ?? 0)}`, + success: t.success, + error: t.error, + })), + }; + }); + setResults(rows); + const totalNodes = apiResults.length; + const okNodes = apiResults.filter(n => n.reachable && n.targets.every(t => t.success)).length; + const totalReclaimed = apiResults.reduce( + (sum, n) => sum + n.targets.reduce((s, t) => s + (t.reclaimedBytes ?? 0), 0), + 0, + ); + 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.'); + } else { + toast.warning(`${okNodes}/${totalNodes} nodes succeeded · ${formatBytes(totalReclaimed)} reclaimed. See results below.`); + } + } catch (err) { + toast.dismiss(toastId); + toast.error(err instanceof Error ? err.message : 'Network error'); + } finally { + setRunning(false); + setConfirmOpen(false); + } + } + + const targetCount = targets.size; + const isAllScope = scope === 'all'; + + return ( + + + +
+ + + +
+

Prune Docker resources fleet-wide

+

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

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

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

+
+ +
+ + {!running && results.length > 0 && ( + + )} +
+ + {results.length === 0 && !running && ( +
+
+ + + Prune is destructive and cannot be undone. Each target is run serially per node; reclaimed bytes appear per node and per target below. + +
+
+ )} + + {results.length > 0 && ( + + )} +
+ + { if (!open) setConfirmOpen(false); }} + variant="destructive" + kicker="Fleet prune" + title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'} + description={ + isAllScope + ? 'This runs docker prune --all on every reachable node. Any image, volume, or network not currently in use will be deleted, including resources from workloads Sencho does not manage. This cannot be undone.' + : 'Sencho will remove unused Docker resources owned by stacks known to this fleet on every reachable node. Active resources are not touched.' + } + confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'} + confirming={running} + onConfirm={run} + /> +
+
+ ); +} diff --git a/frontend/src/components/fleet/FleetActions/cards/tone.ts b/frontend/src/components/fleet/FleetActions/cards/tone.ts index 24246426..962f6b2e 100644 --- a/frontend/src/components/fleet/FleetActions/cards/tone.ts +++ b/frontend/src/components/fleet/FleetActions/cards/tone.ts @@ -1,15 +1,17 @@ -// Tone palette shared by the Fleet Actions cards. The two cards live as -// siblings under `cards/`, so the lookup tables sit next to them rather than -// hoisting to a global tokens module. +// 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'; +export type AccentTone = 'rose' | 'purple' | 'amber'; export const TONE_RAIL: Record = { rose: 'bg-[var(--label-rose)]', purple: 'bg-[var(--label-purple)]', + amber: 'bg-[var(--label-amber)]', }; export const TONE_BG: Record = { rose: 'bg-[var(--label-rose-bg)] text-[var(--label-rose)]', purple: 'bg-[var(--label-purple-bg)] text-[var(--label-purple)]', + amber: 'bg-[var(--label-amber-bg)] text-[var(--label-amber)]', };