mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
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:
@@ -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')}>
|
||||
|
||||
Reference in New Issue
Block a user