fix: keep fleet prune estimate bytes after a target timeout (#1783)

* fix: keep fleet prune estimate bytes after a target timeout

Accumulate successful per-target reclaimable bytes on both the local
serial loop and the remote fan-out, mark partial nodes, and surface that
state in the Fleet prune card instead of zeroing the whole node.

* test: clarify zero-byte partial estimate card case

Rename the FleetPruneCard assertion so it matches fold semantics:
partial with zero bytes means a successful zero-byte target plus a failure,
not an all-failed node.

* fix: keep fleet prune estimate resilient on init and partial errors

Guard DockerController init so a deleted-node race cannot 500 the whole
fleet estimate, surface partial-node failure text in the card row title,
and pin the generic-rejection partial path with a fast route test.

* test: destroy reverse-route connect-ack sockets on teardown

Unguarded accepted sockets could emit a late ECONNRESET after assertions passed, failing the Backend Vitest job with zero assertion failures.
This commit is contained in:
Anso
2026-08-05 22:11:03 -04:00
committed by GitHub
parent 4e54a9272d
commit 575848e017
8 changed files with 386 additions and 37 deletions
@@ -71,10 +71,61 @@ beforeEach(() => {
it('keeps destructive prune disabled until an itemized dry run is reviewed', async () => {
render(<FleetPruneCard nodes={nodes} />);
await waitFor(() => expect(screen.getByText('~ 4 KB reclaimable')).toBeInTheDocument());
expect(screen.queryByText(/· partial/)).not.toBeInTheDocument();
expect(screen.getByText('OK')).toBeInTheDocument();
expect(screen.queryByText('PART')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled();
expect(screen.getByText(/Run Dry run to unlock Prune fleet/)).toBeInTheDocument();
});
it('marks partial estimates with a PART pill and blast suffix', async () => {
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') {
return Promise.resolve(jsonResponse(200, {
totalBytes: 500,
perNode: [{
nodeId: 1,
nodeName: 'central',
reclaimableBytes: 500,
reachable: true,
partial: true,
error: 'Docker daemon is busy. Please try again in a moment.',
}],
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await waitFor(() => expect(screen.getByText('~ 500 Bytes reclaimable · partial')).toBeInTheDocument());
expect(screen.getByText('PART')).toBeInTheDocument();
expect(screen.queryByText('OK')).not.toBeInTheDocument();
expect(screen.getByTitle('Docker daemon is busy. Please try again in a moment.')).toBeInTheDocument();
});
it('shows 0 reclaimable · partial when successful targets report zero bytes', async () => {
mockedFetch.mockImplementation((url: string) => {
if (url === '/fleet/prune/estimate') {
return Promise.resolve(jsonResponse(200, {
totalBytes: 0,
perNode: [{
nodeId: 1,
nodeName: 'central',
reclaimableBytes: 0,
reachable: true,
partial: true,
error: 'Docker daemon is busy. Please try again in a moment.',
}],
}));
}
return Promise.resolve(jsonResponse(404, {}));
});
render(<FleetPruneCard nodes={nodes} />);
await waitFor(() => expect(screen.getByText('0 reclaimable · partial')).toBeInTheDocument());
expect(screen.getByText('PART')).toBeInTheDocument();
});
it('drops the unlock footer once dry run review is valid', async () => {
const user = userEvent.setup();
mockedFetch.mockImplementation((url: string) => {
@@ -28,6 +28,7 @@ interface PruneEstimateNode {
reclaimableBytes: number;
reachable: boolean;
error?: string;
partial?: boolean;
}
interface PruneEstimateResponse {
@@ -287,8 +288,10 @@ export function FleetPruneCard({ nodes }: Props) {
if (estimate.kind === 'loading') return '~ estimating…';
if (estimate.kind === 'unavailable') return '~ estimate unavailable';
if (estimate.kind === 'ready') {
if (estimate.data.totalBytes === 0) return '0 reclaimable';
return `~ ${formatBytes(estimate.data.totalBytes)} reclaimable`;
const partial = estimate.data.perNode.some((node) => node.partial);
const suffix = partial ? ' · partial' : '';
if (estimate.data.totalBytes === 0) return `0 reclaimable${suffix}`;
return `~ ${formatBytes(estimate.data.totalBytes)} reclaimable${suffix}`;
}
return 'awaiting target';
}, [targets.size, estimate]);
@@ -396,13 +399,21 @@ function EstimateSection({ estimate }: { estimate: EstimateState }) {
<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((node) => (
<li key={node.nodeId} className="flex items-center gap-2">
<li
key={node.nodeId}
className="flex items-center gap-2"
title={node.error}
>
<span className={cn(
KICKER,
'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',
node.reachable
? (node.partial
? 'border-warning/40 bg-warning/10 text-warning'
: 'border-success/40 bg-success/10 text-success')
: 'border-stat-subtitle/40 bg-card text-stat-subtitle',
)}>
{node.reachable ? 'OK' : '--'}
{node.reachable ? (node.partial ? 'PART' : 'OK') : '--'}
</span>
<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')}>