feat(fleet): show itemized prune plans (#1734)

* feat(fleet): itemize prune review plans

Build and display fingerprint-bound prune candidates for every reviewed
fleet node. Preflight all node plans before mutation and preserve detailed
removed, skipped, failed, and partial outcomes.

Add safe resource metadata projection, managed ownership attribution,
runtime contract validation, transport parity coverage, and operator docs.

Closes #1724

* fix(security): harden stack path lookup

Use a Map for Compose working-directory ownership resolution so untrusted
path strings cannot become object property writes.

* fix(fleet): harden prune execution safeguards
This commit is contained in:
Anso
2026-07-29 14:30:18 -04:00
committed by GitHub
parent 538a41b771
commit 44d6078241
21 changed files with 2582 additions and 869 deletions
+4 -21
View File
@@ -33,6 +33,7 @@ import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet';
import { VolumeNameLabel } from './resources/VolumeNameLabel';
import { useTableSort } from '@/hooks/useTableSort';
import { SortableTableHead } from '@/components/ui/sortable-table';
import { isPrunePlan, type PrunePlan, type PruneScope, type PruneTarget } from '@/lib/prunePlan';
// ── Interfaces ─────────────────────────────────────────────────────────────────
@@ -90,25 +91,6 @@ interface UnmanagedContainer {
}
type ResourceFilter = 'all' | 'managed' | 'unmanaged';
type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes';
type PruneScope = 'managed' | 'all';
interface PrunePlanItem {
target: PruneTarget;
id: string;
name: string;
sizeBytes?: number;
}
interface PrunePlan {
scope: PruneScope;
targets: PruneTarget[];
items: PrunePlanItem[];
reclaimableBytes: number;
fingerprint: string;
createdAt: number;
nodeId: number;
}
const PLAN_PREVIEW_CAP = 30;
@@ -530,8 +512,9 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
if (!res.ok) {
throw new Error(data?.error || 'Failed to build prune plan');
}
setPrunePlan(data as PrunePlan);
return data as PrunePlan;
if (!isPrunePlan(data)) throw new Error('The node returned a malformed prune plan');
setPrunePlan(data);
return data;
} catch (error) {
if (planFetchGenRef.current !== generation) return null;
const err = error as { message?: string };
@@ -120,7 +120,10 @@ function samplePrunePlan(overrides: Record<string, unknown> = {}) {
return {
scope: 'managed',
targets: ['images'],
items: [{ target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000 }],
items: [{
target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000,
managed: true, reason: 'Image is not used by any container', image: { references: ['old:v1'] },
}],
reclaimableBytes: 1000,
fingerprint: 'fp-test',
createdAt: Date.now(),
@@ -134,8 +137,14 @@ function reclaimPlan() {
scope: 'all',
targets: ['volumes', 'containers', 'images'],
items: [
{ target: 'volumes', id: 'v1', name: 'v1', sizeBytes: 500 },
{ target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000 },
{
target: 'volumes', id: 'v1', name: 'v1', sizeBytes: 500,
managed: true, reason: 'Volume is not referenced by any container', volume: {},
},
{
target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000,
managed: true, reason: 'Image is not used by any container', image: { references: ['old:v1'] },
},
],
reclaimableBytes: 1500,
fingerprint: 'fp-reclaim',
@@ -0,0 +1,60 @@
import { expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { FleetPruneNodeResult, PrunePlanItem } from '@/lib/prunePlan';
import { PrunePlanResults } from './PrunePlanResults';
const items: PrunePlanItem[] = [
{ target: 'images', id: 'removed-id', name: 'removed:latest', managed: true, reason: 'unused', image: { references: ['removed:latest'] } },
{ target: 'images', id: 'skipped-id', name: 'skipped:latest', managed: true, reason: 'unused', image: { references: ['skipped:latest'] } },
{ target: 'images', id: 'failed-id', name: 'failed:latest', managed: false, reason: 'unused', image: { references: ['failed:latest'] } },
];
const plan: FleetPruneNodeResult = {
nodeId: 1,
nodeName: 'central',
reachable: true,
fingerprint: 'plan',
items,
reclaimableBytes: 0,
targets: [{ target: 'images', success: true, reclaimedBytes: 0, dryRun: true }],
};
it('renders removed, skipped, and failed outcomes with reviewed item names', () => {
const execution: FleetPruneNodeResult = {
nodeId: 1,
nodeName: 'central',
reachable: true,
reclaimedBytes: 0,
outcomes: [
{ target: 'images', id: 'removed-id', status: 'removed' },
{ target: 'images', id: 'skipped-id', status: 'skipped', reason: 'Became active' },
{ target: 'images', id: 'failed-id', status: 'failed', error: 'Docker refused removal' },
],
targets: [{
target: 'images', success: false, reclaimedBytes: 0, dryRun: false,
removed: 1, skipped: 1, failed: 1,
}],
};
render(<PrunePlanResults planResults={[plan]} executeResults={[execution]} />);
expect(screen.getByText('removed:latest')).toBeInTheDocument();
expect(screen.getByText('skipped:latest')).toBeInTheDocument();
expect(screen.getByText('failed:latest')).toBeInTheDocument();
expect(screen.getByText('removed')).toBeInTheDocument();
expect(screen.getByText('skipped')).toBeInTheDocument();
expect(screen.getByText('failed')).toBeInTheDocument();
expect(screen.getByText('Became active')).toBeInTheDocument();
expect(screen.getByText('Docker refused removal')).toBeInTheDocument();
});
it('renders the total-only fallback when a remote omits outcomes', () => {
const execution: FleetPruneNodeResult = {
nodeId: 1,
nodeName: 'central',
reachable: true,
reclaimedBytes: 1024,
targets: [{ target: 'images', success: true, reclaimedBytes: 1024, dryRun: false }],
};
render(<PrunePlanResults planResults={[plan]} executeResults={[execution]} />);
expect(screen.getByText('This node reported 1 KB reclaimed without itemized outcomes.')).toBeInTheDocument();
});
@@ -0,0 +1,156 @@
import { cn, formatBytes } from '@/lib/utils';
import type {
FleetPruneNodeResult,
FleetPruneTarget,
PruneItemOutcome,
PrunePlanItem,
} from '@/lib/prunePlan';
const TARGET_LABELS: Record<FleetPruneTarget, string> = {
images: 'Images',
volumes: 'Volumes',
networks: 'Networks',
};
interface Props {
planResults: FleetPruneNodeResult[];
executeResults?: FleetPruneNodeResult[];
}
function shortId(id: string): string {
return id.replace(/^sha256:/, '').slice(0, 12);
}
function metadata(item: PrunePlanItem): string[] {
const values: string[] = [];
if (item.image?.references) {
values.push(...item.image.references.filter((reference) => reference !== item.name));
}
if (item.image?.digest) values.push(item.image.digest);
if (item.image?.createdAt) values.push(new Date(item.image.createdAt * 1000).toLocaleString());
if (item.volume?.driver) values.push(`driver ${item.volume.driver}`);
if (item.network?.driver) values.push(`driver ${item.network.driver}`);
if (item.network?.scope) values.push(`scope ${item.network.scope}`);
const labels = item.volume?.ownershipLabels ?? item.network?.ownershipLabels;
if (labels) values.push(...Object.entries(labels).map(([key, value]) => `${key}=${value}`));
return values;
}
function OutcomeBadge({ outcome }: { outcome?: PruneItemOutcome }) {
if (!outcome) return null;
const tone = outcome.status === 'removed'
? 'border-success/40 bg-success/10 text-success'
: outcome.status === 'skipped'
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-destructive/40 bg-destructive/10 text-destructive';
return (
<span className={cn('rounded-sm border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em]', tone)}>
{outcome.status}
</span>
);
}
function ItemRow({ item, outcome }: { item: PrunePlanItem; outcome?: PruneItemOutcome }) {
const detail = outcome?.status === 'skipped'
? outcome.reason
: outcome?.status === 'failed'
? outcome.error
: item.reason;
return (
<li className="rounded border border-card-border/50 bg-card/40 px-2.5 py-2">
<div className="flex items-start gap-2 max-md:flex-col">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="break-all font-mono text-xs text-stat-value">{item.name}</span>
<span className={cn(
'rounded-sm border px-1 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em]',
item.managed
? 'border-success/40 bg-success/10 text-success'
: 'border-warning/40 bg-warning/10 text-warning',
)}>
{item.managed ? 'managed' : 'unmanaged'}
</span>
<OutcomeBadge outcome={outcome} />
</div>
<p className="mt-1 break-all font-mono text-[10px] text-stat-subtitle">
{shortId(item.id)}{item.stackName ? ` · stack ${item.stackName}` : ''}
</p>
{metadata(item).map((value) => (
<p key={value} className="mt-0.5 break-all font-mono text-[10px] text-stat-icon">{value}</p>
))}
<p className="mt-1 text-[11px] text-stat-subtitle">{detail}</p>
</div>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-stat-value">
{item.sizeBytes == null ? 'size unavailable' : formatBytes(item.sizeBytes)}
</span>
</div>
</li>
);
}
function NodePlan({ plan, execution }: { plan: FleetPruneNodeResult; execution?: FleetPruneNodeResult }) {
if (!plan.reachable || !plan.fingerprint) {
return (
<div className="rounded border border-card-border/60 bg-card/30 p-2.5">
<div className="font-mono text-xs text-stat-value">{plan.nodeName} · excluded</div>
<p className="mt-1 text-[11px] text-stat-subtitle">{plan.error ?? 'Node was unreachable during the dry run.'}</p>
</div>
);
}
const items = plan.items ?? [];
const outcomesByItem = new Map<string, PruneItemOutcome>(
execution?.outcomes?.map((outcome) => [`${outcome.target}\0${outcome.id}`, outcome]) ?? [],
);
return (
<details open className="rounded border border-card-border/60 bg-card/30">
<summary className="cursor-pointer px-2.5 py-2 font-mono text-xs text-stat-value">
{plan.nodeName} · {formatBytes(execution?.reclaimedBytes ?? plan.reclaimableBytes ?? 0)}
</summary>
<div className="space-y-2 border-t border-card-border/50 p-2.5">
{execution?.error && (
<p className="rounded border border-destructive/40 bg-destructive/10 p-2 text-[11px] text-destructive">
{execution.error}
</p>
)}
{(['images', 'volumes', 'networks'] as FleetPruneTarget[]).map((target) => {
const targetItems = items.filter((item) => item.target === target);
const bytes = targetItems.reduce((sum, item) => sum + (item.sizeBytes ?? 0), 0);
return (
<details key={target} open={targetItems.length > 0} className="rounded border border-card-border/40">
<summary className="cursor-pointer px-2 py-1.5 font-mono text-[11px] text-stat-subtitle">
{TARGET_LABELS[target]} · {targetItems.length} · {formatBytes(bytes)}
</summary>
{targetItems.length > 0 && (
<ul className="space-y-1.5 border-t border-card-border/40 p-2">
{targetItems.map((item) => (
<ItemRow
key={`${item.target}:${item.id}`}
item={item}
outcome={outcomesByItem.get(`${item.target}\0${item.id}`)}
/>
))}
</ul>
)}
</details>
);
})}
{execution && !execution.error && !execution.outcomes && (
<p className="text-[11px] text-stat-subtitle">
This node reported {formatBytes(execution.reclaimedBytes ?? 0)} reclaimed without itemized outcomes.
</p>
)}
</div>
</details>
);
}
export function PrunePlanResults({ planResults, executeResults }: Props) {
const executionByNode = new Map(executeResults?.map((result) => [result.nodeId, result]));
return (
<div className="space-y-2">
{planResults.map((plan) => (
<NodePlan key={plan.nodeId} plan={plan} execution={executionByNode.get(plan.nodeId)} />
))}
</div>
);
}
@@ -1,11 +1,4 @@
/**
* Coverage for FleetPruneCard.
*
* The key safety property: the destructive "Prune fleet" confirm is blocked
* until the operator has seen a live reclaim estimate. Also locks dry-run
* payload shape, the all-scope confirm copy, and the failure toast.
*/
import { it, expect, vi, beforeEach } from 'vitest';
import { beforeEach, expect, it, vi } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -16,18 +9,18 @@ const toastSuccess = vi.fn();
const toastWarning = vi.fn();
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: (...a: unknown[]) => toastError(...a),
success: (...a: unknown[]) => toastSuccess(...a),
error: (...args: unknown[]) => toastError(...args),
success: (...args: unknown[]) => toastSuccess(...args),
warning: (...args: unknown[]) => toastWarning(...args),
info: vi.fn(),
warning: (...a: unknown[]) => toastWarning(...a),
loading: vi.fn(() => 'toast-id'),
dismiss: vi.fn(),
},
}));
import { apiFetch } from '@/lib/api';
import { FleetPruneCard } from './FleetPruneCard';
import type { FleetNode } from '@/components/FleetView/types';
import { FleetPruneCard } from './FleetPruneCard';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
@@ -37,241 +30,322 @@ function jsonResponse(status: number, body: unknown): Response {
const nodes = [{ id: 1, name: 'central', status: 'online' }] as unknown as FleetNode[];
const planResult = {
nodeId: 1,
nodeName: 'central',
reachable: true,
fingerprint: 'reviewed-fingerprint',
reclaimableBytes: 4096,
items: [{
target: 'images',
id: 'sha256:abcdef1234567890',
name: 'example/app:latest',
sizeBytes: 4096,
managed: true,
reason: 'Image is not used by any container',
stackName: 'app',
image: {
references: ['example/app:latest'],
digest: 'example/app@sha256:digest',
createdAt: 1_700_000_000,
},
}],
targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: true }],
};
function estimateResponse() {
return jsonResponse(200, {
totalBytes: 4096,
perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 4096, reachable: true }],
});
}
beforeEach(() => {
vi.clearAllMocks();
mockedFetch.mockResolvedValue(jsonResponse(404, {}));
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
return Promise.resolve(jsonResponse(404, {}));
});
});
it('blocks the prune confirm until a reclaim estimate is ready', async () => {
// Estimate endpoint never resolves to ready (404 -> unavailable).
it('keeps destructive prune disabled until an itemized dry run is reviewed', async () => {
render(<FleetPruneCard nodes={nodes} />);
// images is selected by default, so an estimate is requested but unavailable.
await waitFor(() => expect(screen.getByText('~ estimate unavailable')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('~ 4 KB reclaimable')).toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled();
});
it('enables the prune confirm once the estimate resolves', async () => {
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') {
return Promise.resolve(jsonResponse(200, { totalBytes: 1024, perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }] }));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
});
it('all-scope confirm spells out the irreversible all-unused prune', async () => {
it('renders item metadata and enables prune after a valid dry run', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') {
return Promise.resolve(jsonResponse(200, { totalBytes: 2048, perNode: [] }));
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, { results: [planResult] }));
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
expect(screen.getByText('example/app:latest')).toBeInTheDocument();
expect(screen.getByText('managed')).toBeInTheDocument();
expect(screen.getByText(/stack app/)).toBeInTheDocument();
expect(screen.getByText('example/app@sha256:digest')).toBeInTheDocument();
const dryRunCall = mockedFetch.mock.calls.find((call) => call[0] === '/fleet/labels/fleet-prune');
expect(JSON.parse(dryRunCall![1].body)).toEqual({ targets: ['images'], scope: 'managed', dryRun: true });
});
it('submits the reviewed roster and fingerprint then renders item outcomes', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') {
const body = JSON.parse(String(init?.body));
if (body.dryRun) return Promise.resolve(jsonResponse(200, { results: [planResult] }));
return Promise.resolve(jsonResponse(200, {
results: [{
nodeId: 1,
nodeName: 'central',
reachable: true,
reclaimedBytes: 4096,
outcomes: [{ id: 'sha256:abcdef1234567890', target: 'images', status: 'removed', sizeBytes: 4096 }],
targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: false, removed: 1, skipped: 0, failed: 0 }],
}],
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await user.click(screen.getByRole('button', { name: 'All unused' }));
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Prune fleet' }));
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('Prune ALL unused resources across the fleet?')).toBeInTheDocument();
expect(within(dialog).getByText(/This cannot be undone\./)).toBeInTheDocument();
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' }));
await waitFor(() => expect(screen.getByText('removed')).toBeInTheDocument());
const executeCall = mockedFetch.mock.calls
.filter((call) => call[0] === '/fleet/labels/fleet-prune')
.find((call) => JSON.parse(call[1].body).dryRun === false);
expect(JSON.parse(executeCall![1].body)).toEqual({
targets: ['images'],
scope: 'managed',
dryRun: false,
reviewedNodes: [{ nodeId: 1, reachable: true }],
plans: [{ nodeId: 1, fingerprint: 'reviewed-fingerprint' }],
});
expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled();
});
it('dry run sends dryRun:true and reports reclaimable bytes', async () => {
it('invalidates authorization when targets or scope change', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') {
return Promise.resolve(jsonResponse(200, { totalBytes: 0, perNode: [] }));
}
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, { results: [planResult] }));
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
await user.click(screen.getByRole('checkbox', { name: 'Volumes' }));
expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled();
expect(screen.queryByText('example/app:latest')).not.toBeInTheDocument();
});
it('invalidates authorization when the node roster or status changes', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, { results: [planResult] }));
return Promise.resolve(jsonResponse(404, {}));
});
const view = render(<FleetPruneCard nodes={nodes} />);
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
view.rerender(<FleetPruneCard nodes={[{ ...nodes[0], status: 'offline' }]} />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled());
});
it('shows unreachable nodes as excluded without treating them as empty plans', async () => {
const user = userEvent.setup();
const unreachable = {
nodeId: 1,
nodeName: 'central',
reachable: false,
error: 'Pilot tunnel is disconnected',
reclaimableBytes: 0,
targets: [{ target: 'images', success: false, reclaimedBytes: 0, dryRun: true, error: 'Pilot tunnel is disconnected' }],
};
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, { results: [unreachable] }));
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await user.click(screen.getByRole('button', { name: 'Dry run' }));
expect(await screen.findByText('central · excluded')).toBeInTheDocument();
expect(screen.getByText('Pilot tunnel is disconnected')).toBeInTheDocument();
});
it('accepts a valid empty plan', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, {
results: [{ ...planResult, items: [], reclaimableBytes: 0, targets: [{ ...planResult.targets[0], reclaimedBytes: 0 }] }],
}));
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
expect(screen.getByText('Images · 0 · 0 Bytes')).toBeInTheDocument();
});
it('uses the exact stale-plan toast and clears authorization', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') {
return Promise.resolve(jsonResponse(200, {
results: [{ nodeId: 1, nodeName: 'central', reachable: true, targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: true }] }],
const body = JSON.parse(String(init?.body));
if (body.dryRun) return Promise.resolve(jsonResponse(200, { results: [planResult] }));
return Promise.resolve(jsonResponse(409, {
code: 'PRUNE_PLAN_STALE',
nodeId: 1,
error: 'The prune plan changed on central after the dry run',
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => {
const call = mockedFetch.mock.calls.find(c => c[0] === '/fleet/labels/fleet-prune');
expect(call).toBeTruthy();
expect(JSON.parse(call![1].body)).toEqual({ targets: ['images'], scope: 'managed', dryRun: true });
});
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Prune fleet' }));
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith(
'The prune plan changed on “central” after the dry run. Run the dry run again before pruning.',
));
expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled();
expect(screen.queryByText('example/app:latest')).not.toBeInTheDocument();
});
it('surfaces an error toast when the prune returns non-ok', async () => {
it('reports an execution-time stale race from a partial result', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
mockedFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') {
return Promise.resolve(jsonResponse(500, { error: 'prune blew up' }));
const body = JSON.parse(String(init?.body));
if (body.dryRun) return Promise.resolve(jsonResponse(200, { results: [planResult] }));
return Promise.resolve(jsonResponse(200, {
results: [{
nodeId: 1,
nodeName: 'central',
reachable: true,
code: 'PRUNE_PLAN_STALE',
error: 'Prune plan changed',
reclaimedBytes: 0,
targets: [{ target: 'images', success: false, reclaimedBytes: 0, dryRun: false, error: 'Prune plan changed' }],
}],
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('prune blew up'));
});
function estimateCallCount(): number {
return mockedFetch.mock.calls.filter(c => c[0] === '/fleet/prune/estimate').length;
}
it('re-fetches the estimate after a partial-success real prune', async () => {
const user = userEvent.setup();
let estimatePhase: 'pre' | 'post' = 'pre';
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') {
if (estimatePhase === 'pre') {
return Promise.resolve(jsonResponse(200, {
totalBytes: 1024,
perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }],
}));
}
return Promise.resolve(jsonResponse(200, {
totalBytes: 0,
perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 0, reachable: true }],
}));
}
if (url === '/fleet/labels/fleet-prune') {
// One target succeeded before a later failure left the node unreachable.
// reachable:false must not suppress the refresh (target.success is the signal).
return Promise.resolve(jsonResponse(200, {
results: [{
nodeId: 1,
nodeName: 'central',
reachable: false,
error: 'transport failed after images',
targets: [
{ target: 'images', success: true, reclaimedBytes: 1024 },
{ target: 'volumes', success: false, reclaimedBytes: 0, error: 'unreachable' },
],
}],
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
expect(screen.getByText('~ 1 KB reclaimable')).toBeInTheDocument();
expect(screen.getByText('1 KB')).toBeInTheDocument();
const callsBeforePrune = estimateCallCount();
await user.click(screen.getByRole('button', { name: 'Prune fleet' }));
const dialog = await screen.findByRole('alertdialog');
estimatePhase = 'post';
await user.click(within(dialog).getByRole('button', { name: 'Prune managed' }));
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' }));
await waitFor(() => expect(estimateCallCount()).toBe(callsBeforePrune + 1));
const postEstimateCall = [...mockedFetch.mock.calls].reverse().find(c => c[0] === '/fleet/prune/estimate');
expect(postEstimateCall).toBeTruthy();
expect(JSON.parse(postEstimateCall![1].body)).toEqual({ targets: ['images'], scope: 'managed' });
await waitFor(() => expect(screen.getByText('0 reclaimable')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('0 Bytes')).toBeInTheDocument());
await waitFor(() => expect(toastError).toHaveBeenCalledWith(
'The prune plan changed on “central” after the dry run. Run the dry run again before pruning.',
));
expect(toastWarning).not.toHaveBeenCalled();
expect(screen.getByText('Prune plan changed')).toBeInTheDocument();
});
it('does not re-fetch the estimate after a dry run', async () => {
it('rejects an incomplete successful execute response', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') {
return Promise.resolve(jsonResponse(200, {
totalBytes: 1024,
perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }],
}));
}
mockedFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') {
return Promise.resolve(jsonResponse(200, {
results: [{
nodeId: 1,
nodeName: 'central',
reachable: true,
targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: true }],
}],
}));
const body = JSON.parse(String(init?.body));
if (body.dryRun) return Promise.resolve(jsonResponse(200, { results: [planResult] }));
return Promise.resolve(jsonResponse(200, { results: [] }));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
const callsBefore = estimateCallCount();
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
// Debounce window: a refresh would schedule another estimate within 350ms.
await new Promise(r => setTimeout(r, 500));
expect(estimateCallCount()).toBe(callsBefore);
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Prune fleet' }));
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('Fleet prune returned an incomplete node result set'));
expect(toastSuccess).not.toHaveBeenCalledWith(expect.stringContaining('Reclaimed'));
});
it('does not re-fetch the estimate when every target fails on a 2xx response', async () => {
it.each([
['unexpected node', [{ nodeId: 99, nodeName: 'other', reachable: true, reclaimedBytes: 0, targets: [] }]],
['malformed target', [{ nodeId: 1, nodeName: 'central', reachable: true, reclaimedBytes: 0, targets: [null] }]],
['negative total', [{
nodeId: 1, nodeName: 'central', reachable: true, reclaimedBytes: -1,
targets: [{ target: 'images', success: true, reclaimedBytes: 0, dryRun: false }],
}]],
['malformed outcomes', [{
nodeId: 1, nodeName: 'central', reachable: true, reclaimedBytes: 0,
outcomes: [{ target: 'images', id: 'sha256:abcdef1234567890', status: 'failed' }],
targets: [{ target: 'images', success: false, reclaimedBytes: 0, dryRun: false }],
}]],
])('rejects an execute response with %s', async (_label, results) => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') {
return Promise.resolve(jsonResponse(200, {
totalBytes: 1024,
perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }],
}));
}
mockedFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') {
const body = JSON.parse(String(init?.body));
return Promise.resolve(jsonResponse(200, body.dryRun ? { results: [planResult] } : { results }));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Prune fleet' }));
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('Fleet prune returned an incomplete node result set'));
expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled();
});
it('warns when another node mutated before an execution-time stale result', async () => {
const user = userEvent.setup();
const twoNodes = [...nodes, { id: 2, name: 'edge', status: 'online' }] as unknown as FleetNode[];
const edgePlan = { ...planResult, nodeId: 2, nodeName: 'edge', fingerprint: 'edge-fingerprint' };
mockedFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse());
if (url === '/fleet/labels/fleet-prune') {
const body = JSON.parse(String(init?.body));
if (body.dryRun) return Promise.resolve(jsonResponse(200, { results: [planResult, edgePlan] }));
return Promise.resolve(jsonResponse(200, {
results: [{
nodeId: 1,
nodeName: 'central',
reachable: true,
targets: [{ target: 'images', success: false, reclaimedBytes: 0, error: 'A prune is already running on this node' }],
}],
results: [
{
nodeId: 1, nodeName: 'central', reachable: true, reclaimedBytes: 4096,
outcomes: [{ id: 'sha256:abcdef1234567890', target: 'images', status: 'removed', sizeBytes: 4096 }],
targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: false }],
},
{
nodeId: 2, nodeName: 'edge', reachable: true, code: 'PRUNE_PLAN_STALE',
error: 'Prune plan changed', reclaimedBytes: 0,
targets: [{ target: 'images', success: false, reclaimedBytes: 0, dryRun: false, error: 'Prune plan changed' }],
},
],
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
const callsBefore = estimateCallCount();
await user.click(screen.getByRole('button', { name: 'Prune fleet' }));
const dialog = await screen.findByRole('alertdialog');
await user.click(within(dialog).getByRole('button', { name: 'Prune managed' }));
await waitFor(() => expect(toastError).toHaveBeenCalled());
await new Promise(r => setTimeout(r, 500));
expect(estimateCallCount()).toBe(callsBefore);
});
it('warns instead of claiming total failure when a reachable node has mixed per-target outcomes', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') {
return Promise.resolve(jsonResponse(200, {
totalBytes: 1024,
perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }],
}));
}
if (url === '/fleet/labels/fleet-prune') {
// Images succeed, volumes fail on the same reachable node. Pre-fix toast
// logic treated the node as fully failed (okNodes=0) and said every node
// failed even though Docker mutated for images.
return Promise.resolve(jsonResponse(200, {
results: [{
nodeId: 1,
nodeName: 'central',
reachable: true,
targets: [
{ target: 'images', success: true, reclaimedBytes: 1024 },
{ target: 'volumes', success: false, reclaimedBytes: 0, error: 'volume prune failed' },
],
}],
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
render(<FleetPruneCard nodes={twoNodes} />);
await user.click(screen.getByRole('button', { name: 'Dry run' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Prune fleet' }));
const dialog = await screen.findByRole('alertdialog');
await user.click(within(dialog).getByRole('button', { name: 'Prune managed' }));
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' }));
await waitFor(() => expect(toastWarning).toHaveBeenCalled());
expect(toastWarning.mock.calls[0][0]).toMatch(/1\/1 nodes reclaimed space/);
expect(toastError).not.toHaveBeenCalledWith('Prune failed on every node. See results below.');
await waitFor(() => expect(toastWarning).toHaveBeenCalledWith(
'Fleet prune partially completed: 4 KB reclaimed before the plan changed on “edge”. Run the dry run again before pruning.',
));
});
@@ -8,24 +8,32 @@ 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 type {
FleetPruneNodeResult,
FleetPruneTarget,
PruneScope,
} from '@/lib/prunePlan';
import { isPruneItemOutcome, isPrunePlanItem } from '@/lib/prunePlan';
import { PrunePlanResults } from '../PrunePlanResults';
type PruneTarget = 'images' | 'volumes' | 'networks';
type PruneScope = 'managed' | 'all';
const ALL_TARGETS: ReadonlyArray<{ id: PruneTarget; label: string }> = [
const ALL_TARGETS: ReadonlyArray<{ id: FleetPruneTarget; label: string }> = [
{ id: 'images', label: 'Images' },
{ id: 'volumes', label: 'Volumes' },
{ id: 'networks', label: 'Networks' },
];
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 PruneEstimateNode { nodeId: number; nodeName: string; reclaimableBytes: number; reachable: boolean; error?: string }
interface PruneEstimateResponse { totalBytes: number; perNode: PruneEstimateNode[] }
interface PruneEstimateResponse {
totalBytes: number;
perNode: PruneEstimateNode[];
}
type EstimateState =
| { kind: 'idle' }
@@ -33,6 +41,11 @@ type EstimateState =
| { kind: 'unavailable' }
| { kind: 'ready'; data: PruneEstimateResponse };
interface ReviewedPlanState {
key: string;
results: FleetPruneNodeResult[];
}
interface Props {
nodes: FleetNode[];
}
@@ -40,54 +53,128 @@ interface Props {
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
const ESTIMATE_ROW_LIMIT = 6;
function reviewStateIsValid(
reviewed: ReviewedPlanState | null,
reviewKey: string,
nodes: FleetNode[],
selectedTargets: FleetPruneTarget[],
): reviewed is ReviewedPlanState {
if (!reviewed || reviewed.key !== reviewKey || reviewed.results.length !== nodes.length) return false;
const currentIds = new Set(nodes.map((node) => node.id));
const resultIds = new Set(reviewed.results.map((result) => result.nodeId));
return resultIds.size === reviewed.results.length
&& reviewed.results.every((result) => currentIds.has(result.nodeId)
&& (result.reachable
? typeof result.fingerprint === 'string' && result.fingerprint.length > 0
&& Array.isArray(result.items) && result.items.every((item) => isPrunePlanItem(item) && selectedTargets.includes(item.target as FleetPruneTarget))
: !result.fingerprint));
}
function executeResultsAreValid(
value: unknown,
reviewedResults: FleetPruneNodeResult[],
selectedTargets: FleetPruneTarget[],
): value is FleetPruneNodeResult[] {
if (!Array.isArray(value)) return false;
const expectedIds = reviewedResults.filter((result) => result.reachable).map((result) => result.nodeId);
if (value.length !== expectedIds.length) return false;
const seen = new Set<number>();
return value.every((result) => {
if (!result || typeof result !== 'object') return false;
const entry = result as Partial<FleetPruneNodeResult>;
if (!Number.isInteger(entry.nodeId) || seen.has(entry.nodeId as number)) return false;
seen.add(entry.nodeId as number);
if (!expectedIds.includes(entry.nodeId as number)) return false;
if (typeof entry.nodeName !== 'string' || typeof entry.reachable !== 'boolean' || !Array.isArray(entry.targets)) return false;
const targetIds = new Set<FleetPruneTarget>();
for (const target of entry.targets) {
if (!target || typeof target !== 'object') return false;
const row = target as Partial<FleetPruneNodeResult['targets'][number]>;
if (!selectedTargets.includes(row.target as FleetPruneTarget) || targetIds.has(row.target as FleetPruneTarget)
|| typeof row.success !== 'boolean' || row.dryRun !== false
|| typeof row.reclaimedBytes !== 'number' || !Number.isFinite(row.reclaimedBytes) || row.reclaimedBytes < 0) return false;
for (const count of [row.removed, row.skipped, row.failed]) {
if (count !== undefined && (!Number.isInteger(count) || count < 0)) return false;
}
targetIds.add(row.target as FleetPruneTarget);
}
if (targetIds.size !== selectedTargets.length) return false;
if (entry.reclaimedBytes !== undefined
&& (typeof entry.reclaimedBytes !== 'number' || !Number.isFinite(entry.reclaimedBytes) || entry.reclaimedBytes < 0)) return false;
if (entry.outcomes !== undefined) {
if (!Array.isArray(entry.outcomes) || !entry.outcomes.every(isPruneItemOutcome)) return false;
const reviewed = reviewedResults.find((result) => result.nodeId === entry.nodeId);
const expectedItems = new Set((reviewed?.items ?? []).map((item) => `${item.target}\0${item.id}`));
const outcomeKeys = new Set(entry.outcomes.map((outcome) => `${outcome.target}\0${outcome.id}`));
if (outcomeKeys.size !== entry.outcomes.length || outcomeKeys.size !== expectedItems.size
|| [...outcomeKeys].some((key) => !expectedItems.has(key))) return false;
}
return true;
});
}
export function FleetPruneCard({ nodes }: Props) {
const nodeCount = nodes.length;
const [targets, setTargets] = useState<Set<PruneTarget>>(new Set(['images']));
const [targets, setTargets] = useState<Set<FleetPruneTarget>>(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' });
// Bumped after a real prune mutates at least one target so the estimate
// effect re-runs without changing targets/scope.
const [planResults, setPlanResults] = useState<FleetPruneNodeResult[]>([]);
const [displayKey, setDisplayKey] = useState('');
const [executeResults, setExecuteResults] = useState<FleetPruneNodeResult[] | undefined>();
const [reviewed, setReviewed] = useState<ReviewedPlanState | null>(null);
const [estimate, setEstimate] = useState<EstimateState>({ kind: 'loading' });
const [estimateEpoch, setEstimateEpoch] = useState(0);
const toggleTarget = (target: PruneTarget) => {
setTargets(prev => {
const next = new Set(prev);
const selectedTargets = useMemo(() => [...targets].sort(), [targets]);
const rosterKey = useMemo(
() => nodes.map((node) => `${node.id}:${node.status}`).sort().join(','),
[nodes],
);
const reviewKey = `${selectedTargets.join(',')}|${scope}|${rosterKey}`;
const reviewValid = reviewStateIsValid(reviewed, reviewKey, nodes, selectedTargets);
const toggleTarget = (target: FleetPruneTarget) => {
setReviewed(null);
setExecuteResults(undefined);
setEstimate({ kind: 'loading' });
setTargets((current) => {
const next = new Set(current);
if (next.has(target)) next.delete(target);
else next.add(target);
return next;
});
};
// 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 changeScope = (nextScope: PruneScope) => {
setReviewed(null);
setExecuteResults(undefined);
setEstimate({ kind: 'loading' });
setScope(nextScope);
};
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 () => {
if (!cancelled) setEstimate({ kind: 'loading' });
try {
const res = await apiFetch('/fleet/prune/estimate', {
const response = await apiFetch('/fleet/prune/estimate', {
method: 'POST',
body: JSON.stringify({ targets: Array.from(targets), scope }),
body: JSON.stringify({ targets: selectedTargets, scope }),
});
if (cancelled) return;
if (res.status === 404 || !res.ok) {
if (!response.ok) {
setEstimate({ kind: 'unavailable' });
return;
}
const data = (await res.json()) as PruneEstimateResponse;
const data = await response.json() as PruneEstimateResponse;
if (!cancelled) setEstimate({ kind: 'ready', data });
} catch {
} catch (error) {
console.error('Failed to estimate fleet prune', error);
if (!cancelled) setEstimate({ kind: 'unavailable' });
}
}, 350);
@@ -95,178 +182,173 @@ export function FleetPruneCard({ nodes }: Props) {
cancelled = true;
if (estimateDebounceRef.current) clearTimeout(estimateDebounceRef.current);
};
}, [targets, scope, estimateEpoch]);
}, [selectedTargets, scope, targets.size, estimateEpoch]);
async function run(opts: { dryRun: boolean }) {
if (targets.size === 0) return;
const selected = Array.from(targets);
const verb = opts.dryRun ? 'Dry-running prune of' : 'Pruning';
const toastId = toast.loading(`${verb} ${selected.join(', ')} across the fleet…`);
const runDryRun = async () => {
if (selectedTargets.length === 0) return;
const toastId = toast.loading(`Building prune plans for ${selectedTargets.join(', ')}`);
setRunning(true);
setResults([]);
setReviewed(null);
setExecuteResults(undefined);
try {
const res = await apiFetch('/fleet/labels/fleet-prune', {
const response = await apiFetch('/fleet/labels/fleet-prune', {
method: 'POST',
body: JSON.stringify({ targets: selected, scope, dryRun: opts.dryRun }),
body: JSON.stringify({ targets: selectedTargets, scope, dryRun: true }),
});
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[]) ?? [];
// HTTP 200 still arrives when every target fails; only a successful
// target means Docker state changed and the live estimate is stale.
// Scan targets independently of node.reachable (an early remote target
// can succeed before a later transport error marks the node unreachable).
if (!opts.dryRun && apiResults.some(node => node.targets.some(target => target.success))) {
setEstimateEpoch(e => e + 1);
}
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)}${opts.dryRun ? ' (dry run)' : ''}`
: `${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;
// Fully OK: every target on a reachable node succeeded. Partial: at
// least one target succeeded anywhere (mixed per-target on one node
// still counts). "Failed on every node" only when zero targets succeeded.
const fullyOkNodes = apiResults.filter(n => n.reachable && n.targets.every(t => t.success)).length;
const nodesWithAnySuccess = apiResults.filter(n => n.targets.some(t => t.success)).length;
const anyTargetSucceeded = nodesWithAnySuccess > 0;
const totalReclaimed = apiResults.reduce(
(sum, n) => sum + n.targets.reduce((s, t) => s + (t.reclaimedBytes ?? 0), 0),
0,
);
if (opts.dryRun) {
toast.success(`Dry run: ${formatBytes(totalReclaimed)} would be reclaimed across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`);
} else if (fullyOkNodes === totalNodes && totalNodes > 0) {
toast.success(`Reclaimed ${formatBytes(totalReclaimed)} across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`);
} else if (!anyTargetSucceeded) {
toast.error('Prune failed on every node. See results below.');
const data = await response.json().catch(() => null) as { error?: string; results?: FleetPruneNodeResult[] } | null;
if (!response.ok) throw new Error(data?.error || 'Failed to build fleet prune plans');
const results = Array.isArray(data?.results) ? data.results : [];
setPlanResults(results);
setDisplayKey(reviewKey);
const nextReview = { key: reviewKey, results };
if (reviewStateIsValid(nextReview, reviewKey, nodes, selectedTargets)) {
setReviewed(nextReview);
const total = results.reduce((sum, result) => sum + (result.reclaimableBytes ?? 0), 0);
toast.success(`Dry run ready: ${formatBytes(total)} across ${results.length} node${results.length === 1 ? '' : 's'}.`);
} else {
toast.warning(`${nodesWithAnySuccess}/${totalNodes} nodes reclaimed space · ${formatBytes(totalReclaimed)} reclaimed. See results below.`);
toast.error('Dry run did not return a valid plan for every reachable node.');
}
} catch (err) {
toast.dismiss(toastId);
toast.error(err instanceof Error ? err.message : 'Network error');
} catch (error) {
console.error('Failed to build fleet prune plans', error);
toast.error(error instanceof Error ? error.message : 'Failed to build fleet prune plans');
} finally {
toast.dismiss(toastId);
setRunning(false);
}
};
const runExecute = async () => {
if (!reviewValid) return;
const reviewedSnapshot = reviewed;
const toastId = toast.loading(`Pruning ${selectedTargets.join(', ')} across the fleet…`);
setRunning(true);
setExecuteResults(undefined);
try {
const reviewedNodes = reviewedSnapshot.results.map((result) => ({
nodeId: result.nodeId,
reachable: result.reachable,
}));
const plans = reviewedSnapshot.results
.filter((result) => result.reachable && result.fingerprint)
.map((result) => ({ nodeId: result.nodeId, fingerprint: result.fingerprint as string }));
const response = await apiFetch('/fleet/labels/fleet-prune', {
method: 'POST',
body: JSON.stringify({ targets: selectedTargets, scope, dryRun: false, reviewedNodes, plans }),
});
const data = await response.json().catch(() => null) as {
error?: string;
code?: string;
nodeId?: number;
results?: FleetPruneNodeResult[];
} | null;
if (!response.ok) {
if (data?.code === 'PRUNE_PLAN_STALE') {
setPlanResults([]);
setDisplayKey('');
const nodeName = reviewedSnapshot.results.find((result) => result.nodeId === data.nodeId)?.nodeName
?? data.error?.match(/on (.+) after/)?.[1]
?? 'a node';
toast.error(`The prune plan changed on “${nodeName}” after the dry run. Run the dry run again before pruning.`);
return;
}
throw new Error(data?.error || 'Fleet prune failed');
}
if (!executeResultsAreValid(data?.results, reviewedSnapshot.results, selectedTargets)) {
throw new Error('Fleet prune returned an incomplete node result set');
}
const results = data.results;
setExecuteResults(results);
const reclaimed = results.reduce((sum, result) => sum + (result.reclaimedBytes ?? 0), 0);
const staleResult = results.find((result) => result.code === 'PRUNE_PLAN_STALE');
const removedAny = results.some((result) => result.outcomes?.some((outcome) => outcome.status === 'removed'));
const failed = results.some((result) => result.targets.some((target) => !target.success));
if (staleResult && removedAny) {
toast.warning(`Fleet prune partially completed: ${formatBytes(reclaimed)} reclaimed before the plan changed on “${staleResult.nodeName}”. Run the dry run again before pruning.`);
} else if (staleResult) {
toast.error(`The prune plan changed on “${staleResult.nodeName}” after the dry run. Run the dry run again before pruning.`);
} else if (failed) toast.warning(`Fleet prune completed with item failures. ${formatBytes(reclaimed)} reclaimed.`);
else toast.success(`Reclaimed ${formatBytes(reclaimed)} across ${results.length} node${results.length === 1 ? '' : 's'}.`);
if (removedAny) {
setEstimateEpoch((epoch) => epoch + 1);
}
} catch (error) {
console.error('Fleet prune failed', error);
toast.error(error instanceof Error ? error.message : 'Fleet prune failed');
} finally {
setReviewed(null);
toast.dismiss(toastId);
setRunning(false);
setConfirmOpen(false);
}
}
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`;
if (estimate.data.totalBytes === 0) return '0 reclaimable';
return `~ ${formatBytes(estimate.data.totalBytes)} reclaimable`;
}
return 'awaiting target';
}, [targets.size, estimate]);
const blastTone = estimate.kind === 'loading' || estimate.kind === 'unavailable' ? 'muted' as const : undefined;
const isAllScope = scope === 'all';
return (
<>
<FleetActionCard
crumb={['Fleet', 'Actions', 'Prune resources']}
name="Prune fleet-wide."
meta="images · volumes · networks · serial per node"
meta="images · volumes · networks · reviewed per node"
actionClass="maintenance"
blastRadius={{ value: blastValue, tone: blastTone }}
secondaryAction={{
label: running ? 'Running…' : 'Dry run',
onClick: () => run({ dryRun: true }),
onClick: runDryRun,
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',
disabled: running || !reviewValid,
}}
footerContext={`Reversible · no · serial across ${nodeCount} node${nodeCount === 1 ? '' : 's'}`}
footerContext={`Reversible · no · reviewed across ${nodes.length} node${nodes.length === 1 ? '' : 's'}`}
>
<SheetSection
title={`Targets · ${targets.size} / ${ALL_TARGETS.length}`}
meta={targets.size === 0 ? 'pick at least one' : undefined}
>
<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"
>
<Checkbox
checked={targets.has(t.id)}
onCheckedChange={() => toggleTarget(t.id)}
disabled={running}
/>
<span className="text-xs text-stat-value">{t.label}</span>
{ALL_TARGETS.map((target) => (
<label key={target.id} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-glass-highlight">
<Checkbox checked={targets.has(target.id)} onCheckedChange={() => toggleTarget(target.id)} disabled={running} />
<span className="text-xs text-stat-value">{target.label}</span>
</label>
))}
</div>
</SheetSection>
<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
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"
>
<div className="inline-flex overflow-hidden rounded-md border border-card-border/60">
<Button type="button" variant={scope === 'managed' ? 'default' : 'outline'} size="sm" disabled={running} onClick={() => changeScope('managed')} className="h-8 rounded-none border-0 px-3 text-xs">
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"
>
<Button type="button" variant={scope === 'all' ? 'default' : 'outline'} size="sm" disabled={running} onClick={() => changeScope('all')} className="h-8 rounded-none border-0 px-3 text-xs">
All unused
</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.'}
? 'Restricts candidates to resources owned by stacks Sencho manages.'
: 'Includes unused resources from workloads Sencho does not manage.'}
</p>
</SheetSection>
{targets.size > 0 && <EstimateSection estimate={estimate} />}
{results.length > 0 && (
<SheetSection title="Per-node breakdown">
<ResultsList results={results} />
{displayKey === reviewKey && planResults.length > 0 && (
<SheetSection title={executeResults ? 'Prune outcomes' : 'Reviewed prune plan'}>
<PrunePlanResults planResults={planResults} executeResults={executeResults} />
</SheetSection>
)}
</FleetActionCard>
@@ -277,14 +359,12 @@ export function FleetPruneCard({ nodes }: Props) {
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.'
}
description={isAllScope
? 'This removes the reviewed unused images, volumes, and networks, including resources from workloads Sencho does not manage. This cannot be undone.'
: 'Sencho will remove only the reviewed unused resources owned by stacks known to this fleet. Active resources are not touched.'}
confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'}
confirming={running}
onConfirm={() => run({ dryRun: false })}
onConfirm={runExecute}
/>
</>
);
@@ -305,35 +385,28 @@ function EstimateSection({ estimate }: { estimate: EstimateState }) {
</SheetSection>
);
}
const { perNode } = estimate.data;
const visible = perNode.slice(0, ESTIMATE_ROW_LIMIT);
const remaining = perNode.length - visible.length;
const visible = estimate.data.perNode.slice(0, ESTIMATE_ROW_LIMIT);
const remaining = estimate.data.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">
<SheetSection title="Estimate · per node" meta={`${estimate.data.perNode.length} node${estimate.data.perNode.length === 1 ? '' : 's'}`}>
<div className="rounded border border-card-border/60 bg-card/40 p-2 shadow-[inset_0_2px_4px_0_oklch(0_0_0_/_0.35)]">
<ul className="space-y-1">
{visible.map((n) => (
<li key={n.nodeId} className="flex items-center gap-2">
{visible.map((node) => (
<li key={node.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',
'inline-flex shrink-0 items-center rounded-sm border px-1 py-0.5',
node.reachable ? 'border-success/40 bg-success/10 text-success' : 'border-stat-subtitle/40 bg-card text-stat-subtitle',
)}>
{n.reachable ? 'OK' : '--'}
{node.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 className="min-w-0 flex-1 truncate font-mono text-[11px] text-stat-value">{node.nodeName}</span>
<span className={cn(KICKER, 'shrink-0 tabular-nums', node.reachable ? 'text-stat-subtitle' : 'text-stat-icon')}>
{node.reachable ? formatBytes(node.reclaimableBytes) : (node.error ?? 'unreachable')}
</span>
</li>
))}
{remaining > 0 && (
<li className={cn(KICKER, 'text-stat-icon pt-1')}>
+ {remaining} more node{remaining === 1 ? '' : 's'}
</li>
)}
{remaining > 0 && <li className={cn(KICKER, 'pt-1 text-stat-icon')}>+ {remaining} more node{remaining === 1 ? '' : 's'}</li>}
</ul>
</div>
</SheetSection>
+135
View File
@@ -0,0 +1,135 @@
export type PruneTarget = 'containers' | 'images' | 'volumes' | 'networks';
export type FleetPruneTarget = Exclude<PruneTarget, 'containers'>;
export type PruneScope = 'managed' | 'all';
interface PrunePlanItemBase {
id: string;
name: string;
sizeBytes?: number;
managed: boolean;
reason: string;
stackName?: string;
}
export type PrunePlanItem =
| (PrunePlanItemBase & { target: 'containers'; image?: never; volume?: never; network?: never })
| (PrunePlanItemBase & {
target: 'images';
image: {
references: string[];
digest?: string;
createdAt?: number;
};
volume?: never;
network?: never;
})
| (PrunePlanItemBase & {
target: 'volumes';
volume: {
driver?: string;
ownershipLabels?: Record<string, string>;
};
image?: never;
network?: never;
})
| (PrunePlanItemBase & {
target: 'networks';
network: {
driver?: string;
scope?: string;
ownershipLabels?: Record<string, string>;
};
image?: never;
volume?: never;
});
export interface PrunePlan {
scope: PruneScope;
targets: PruneTarget[];
items: PrunePlanItem[];
reclaimableBytes: number;
fingerprint: string;
createdAt: number;
nodeId: number;
}
export type PruneItemOutcome =
| { id: string; target: PruneTarget; status: 'removed'; sizeBytes?: number }
| { id: string; target: PruneTarget; status: 'skipped'; reason: string }
| { id: string; target: PruneTarget; status: 'failed'; error: string };
export interface FleetPruneTargetResult {
target: FleetPruneTarget;
success: boolean;
reclaimedBytes: number;
dryRun: boolean;
removed?: number;
skipped?: number;
failed?: number;
error?: string;
}
export interface FleetPruneNodeResult {
nodeId: number;
nodeName: string;
reachable: boolean;
code?: string;
error?: string;
fingerprint?: string;
items?: PrunePlanItem[];
reclaimableBytes?: number;
reclaimedBytes?: number;
outcomes?: PruneItemOutcome[];
targets: FleetPruneTargetResult[];
}
function finiteNonnegative(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
export function isPrunePlanItem(value: unknown): value is PrunePlanItem {
if (!value || typeof value !== 'object') return false;
const item = value as Record<string, unknown>;
if (typeof item.id !== 'string' || typeof item.name !== 'string'
|| typeof item.managed !== 'boolean' || typeof item.reason !== 'string'
|| (item.sizeBytes !== undefined && !finiteNonnegative(item.sizeBytes))) return false;
if (item.target === 'containers') return true;
if (item.target === 'images') {
const image = item.image as Record<string, unknown> | undefined;
return Boolean(image && Array.isArray(image.references) && image.references.every((ref) => typeof ref === 'string'));
}
if (item.target === 'volumes') return Boolean(item.volume && typeof item.volume === 'object');
if (item.target === 'networks') return Boolean(item.network && typeof item.network === 'object');
return false;
}
export function isPruneItemOutcome(value: unknown): value is PruneItemOutcome {
if (!value || typeof value !== 'object') return false;
const outcome = value as Record<string, unknown>;
if (typeof outcome.id !== 'string' || typeof outcome.target !== 'string') return false;
if (outcome.status === 'removed') return outcome.sizeBytes === undefined || finiteNonnegative(outcome.sizeBytes);
if (outcome.status === 'skipped') return typeof outcome.reason === 'string';
if (outcome.status === 'failed') return typeof outcome.error === 'string';
return false;
}
export function isPrunePlan(value: unknown): value is PrunePlan {
if (!value || typeof value !== 'object') return false;
const plan = value as Partial<PrunePlan>;
if ((plan.scope !== 'managed' && plan.scope !== 'all') || !Array.isArray(plan.targets)
|| new Set(plan.targets).size !== plan.targets.length || !Array.isArray(plan.items)
|| !finiteNonnegative(plan.reclaimableBytes) || typeof plan.fingerprint !== 'string'
|| plan.fingerprint.length === 0 || !Number.isInteger(plan.nodeId)
|| !finiteNonnegative(plan.createdAt)) return false;
const targets = new Set<PruneTarget>(plan.targets);
const itemKeys = new Set<string>();
let total = 0;
for (const item of plan.items) {
if (!isPrunePlanItem(item) || !targets.has(item.target)) return false;
const key = `${item.target}\0${item.id}`;
if (itemKeys.has(key)) return false;
itemKeys.add(key);
total += item.sizeBytes ?? 0;
}
return total === plan.reclaimableBytes;
}