mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
feat(resources): bind prune to fingerprinted itemized plans (#1611)
* feat(resources): bind prune to fingerprinted itemized plans * fix(resources): repair prune plan volume usage and preview list Source volume RefCount from docker df, keep preview rows from flex-shrinking, tighten managed image attribution and becomesFree, and stop audit summaries from claiming success on rejected prunes.
This commit is contained in:
@@ -103,6 +103,69 @@ 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;
|
||||
|
||||
function PrunePlanPreview({
|
||||
plan,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
plan: PrunePlan | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
if (loading) {
|
||||
return <p className="text-sm text-stat-subtitle">Building prune plan...</p>;
|
||||
}
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>;
|
||||
}
|
||||
if (!plan) return null;
|
||||
if (plan.items.length === 0) {
|
||||
return <p className="text-sm text-stat-subtitle">Nothing eligible to prune right now.</p>;
|
||||
}
|
||||
const shown = plan.items.slice(0, PLAN_PREVIEW_CAP);
|
||||
const remaining = plan.items.length - shown.length;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-mono text-stat-subtitle/90">
|
||||
{plan.items.length} {plan.items.length === 1 ? 'item' : 'items'}
|
||||
{plan.reclaimableBytes > 0 ? ` · ${formatBytes(plan.reclaimableBytes)}` : ''}
|
||||
</p>
|
||||
<ul className="max-h-40 overflow-y-auto space-y-1 font-mono text-[12px] text-stat-subtitle/90">
|
||||
{shown.map((item) => (
|
||||
<li key={`${item.target}:${item.id}`} className="block truncate">
|
||||
<span className="text-stat-subtitle/60">{item.target}</span>
|
||||
{' · '}
|
||||
{item.name}
|
||||
{item.sizeBytes != null && item.sizeBytes > 0 ? ` · ${formatBytes(item.sizeBytes)}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{remaining > 0 && (
|
||||
<p className="text-xs text-stat-subtitle/70">and {remaining} more</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Per-node, per-browser snooze for the reclaim banner. We store the reclaimable
|
||||
// byte total at the moment of dismissal; the banner returns only once the node's
|
||||
// reclaimable total grows past that snapshot, so a stable residue stays hidden.
|
||||
@@ -352,6 +415,10 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
const [confirmPrune, setConfirmPrune] = useState<{ target: PruneTarget; scope: PruneScope } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ type: 'images' | 'volumes' | 'networks'; id: string; name?: string } | null>(null);
|
||||
const [confirmReclaim, setConfirmReclaim] = useState(false);
|
||||
const [prunePlan, setPrunePlan] = useState<PrunePlan | null>(null);
|
||||
const [planLoading, setPlanLoading] = useState(false);
|
||||
const [planError, setPlanError] = useState<string | null>(null);
|
||||
const planFetchGenRef = useRef(0);
|
||||
|
||||
// Reclaim banner visibility: the per-node opt-out setting (loaded in
|
||||
// fetchAllData) and the per-browser dismiss snapshot for the active node.
|
||||
@@ -447,34 +514,107 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
// view is gone cannot run state setters or surface a load-error toast.
|
||||
useEffect(() => () => { fetchGenerationRef.current += 1; }, []);
|
||||
|
||||
const handlePrune = async () => {
|
||||
type PrunePlanRequest = { target?: PruneTarget; targets?: PruneTarget[]; scope: PruneScope };
|
||||
|
||||
const fetchPrunePlan = async (body: PrunePlanRequest) => {
|
||||
const generation = ++planFetchGenRef.current;
|
||||
setPlanLoading(true);
|
||||
setPlanError(null);
|
||||
setPrunePlan(null);
|
||||
try {
|
||||
const res = await apiFetch('/system/prune/plan', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (planFetchGenRef.current !== generation) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || 'Failed to build prune plan');
|
||||
}
|
||||
setPrunePlan(data as PrunePlan);
|
||||
return data as PrunePlan;
|
||||
} catch (error) {
|
||||
if (planFetchGenRef.current !== generation) return null;
|
||||
const err = error as { message?: string };
|
||||
const message = err?.message || 'Failed to build prune plan';
|
||||
setPlanError(message);
|
||||
console.error('Failed to build prune plan', error);
|
||||
return null;
|
||||
} finally {
|
||||
if (planFetchGenRef.current === generation) setPlanLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** POST /prune/system with fingerprint. On stale 409, refresh the plan and
|
||||
* require another confirm rather than executing a set the user never saw. */
|
||||
const executeFingerprintPrune = async (body: PrunePlanRequest, fingerprint: string) => {
|
||||
const res = await apiFetch('/system/prune/system', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...body, planFingerprint: fingerprint }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.status === 409 && data?.code === 'PRUNE_PLAN_STALE') {
|
||||
await fetchPrunePlan(body);
|
||||
throw new Error('Prune plan changed; review the updated list and confirm again');
|
||||
}
|
||||
return { res, data };
|
||||
};
|
||||
|
||||
// Fetch an itemized plan whenever a prune confirm dialog opens.
|
||||
useEffect(() => {
|
||||
if (!confirmPrune) return;
|
||||
void fetchPrunePlan({ target: confirmPrune.target, scope: confirmPrune.scope });
|
||||
}, [confirmPrune]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmReclaim) return;
|
||||
void fetchPrunePlan({ targets: ['volumes', 'containers', 'images'], scope: 'all' });
|
||||
}, [confirmReclaim]);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmPrune || confirmReclaim) return;
|
||||
planFetchGenRef.current += 1;
|
||||
setPrunePlan(null);
|
||||
setPlanLoading(false);
|
||||
setPlanError(null);
|
||||
}, [confirmPrune, confirmReclaim]);
|
||||
|
||||
const handlePrune = async () => {
|
||||
if (!confirmPrune || !prunePlan) return;
|
||||
setIsActioning(true);
|
||||
const loadingId = toast.loading(`Pruning ${confirmPrune.target}...`);
|
||||
try {
|
||||
const res = await apiFetch('/system/prune/system', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target: confirmPrune.target, scope: confirmPrune.scope })
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
const { res, data } = await executeFingerprintPrune(
|
||||
{ target: confirmPrune.target, scope: confirmPrune.scope },
|
||||
prunePlan.fingerprint,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || `Failed to prune ${confirmPrune.target}`);
|
||||
}
|
||||
const scopeLabel = confirmPrune.scope === 'managed' ? 'Sencho-managed' : 'all';
|
||||
toast.success(
|
||||
data?.reclaimedBytes !== undefined
|
||||
? `Pruned ${scopeLabel} ${confirmPrune.target}. Reclaimed ${formatBytes(data.reclaimedBytes)}.`
|
||||
: `Pruned ${scopeLabel} ${confirmPrune.target}.`
|
||||
);
|
||||
const reclaimed = typeof data?.reclaimedBytes === 'number' ? data.reclaimedBytes : undefined;
|
||||
const outcomes = Array.isArray(data?.outcomes) ? data.outcomes : [];
|
||||
const failed = outcomes.filter((o: { status?: string }) => o.status === 'failed');
|
||||
const reclaimedLabel = reclaimed !== undefined
|
||||
? ` Reclaimed ${formatBytes(reclaimed)}.`
|
||||
: '';
|
||||
if (failed.length > 0 && failed.length === outcomes.length) {
|
||||
toast.error(`Failed to prune ${confirmPrune.target}.`);
|
||||
} else if (failed.length > 0) {
|
||||
toast.warning(`Some ${confirmPrune.target} could not be pruned.${reclaimedLabel}`);
|
||||
} else {
|
||||
toast.success(`Pruned ${scopeLabel} ${confirmPrune.target}.${reclaimedLabel}`);
|
||||
}
|
||||
await fetchAllData();
|
||||
setConfirmPrune(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to prune', error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(err?.message || `Failed to prune ${confirmPrune.target}`);
|
||||
// Keep the dialog open on stale-plan so the operator can re-confirm.
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setIsActioning(false);
|
||||
setConfirmPrune(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -684,39 +824,38 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
// false success); the reclaimed figure is shown only when the daemon reports
|
||||
// one (the containerd image store returns 0).
|
||||
const handleReclaimAll = async () => {
|
||||
if (!prunePlan) return;
|
||||
setIsActioning(true);
|
||||
const loadingId = toast.loading('Reclaiming disk space...');
|
||||
const order: PruneTarget[] = ['volumes', 'containers', 'images'];
|
||||
let reclaimed = 0;
|
||||
const failed: PruneTarget[] = [];
|
||||
try {
|
||||
for (const target of order) {
|
||||
try {
|
||||
const res = await apiFetch('/system/prune/system', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target, scope: 'all' }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(data?.error || `Failed to prune ${target}`);
|
||||
if (typeof data?.reclaimedBytes === 'number') reclaimed += data.reclaimedBytes;
|
||||
} catch (err) {
|
||||
console.error(`Failed to prune ${target}`, err);
|
||||
failed.push(target);
|
||||
}
|
||||
const { res, data } = await executeFingerprintPrune(
|
||||
{ targets: ['volumes', 'containers', 'images'], scope: 'all' },
|
||||
prunePlan.fingerprint,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || 'Failed to reclaim disk space');
|
||||
}
|
||||
const reclaimed = typeof data?.reclaimedBytes === 'number' ? data.reclaimedBytes : 0;
|
||||
const reclaimedLabel = reclaimed > 0 ? ` Freed ${formatBytes(reclaimed)}.` : '';
|
||||
if (failed.length === order.length) {
|
||||
const outcomes = Array.isArray(data?.outcomes) ? data.outcomes : [];
|
||||
const failed = outcomes.filter((o: { status?: string }) => o.status === 'failed');
|
||||
if (failed.length > 0 && failed.length === outcomes.length) {
|
||||
toast.error('Failed to reclaim disk space.');
|
||||
} else if (failed.length > 0) {
|
||||
toast.warning(`Could not prune: ${failed.join(', ')}.${reclaimedLabel}`);
|
||||
toast.warning(`Some items could not be pruned.${reclaimedLabel}`);
|
||||
} else {
|
||||
toast.success(`Reclaimed unused images, stopped containers, and dangling volumes.${reclaimedLabel}`);
|
||||
}
|
||||
await fetchAllData();
|
||||
setConfirmReclaim(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to reclaim', error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(err?.message || 'Failed to reclaim disk space.');
|
||||
// Keep the dialog open on stale-plan so the operator can re-confirm.
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setIsActioning(false);
|
||||
setConfirmReclaim(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1398,23 +1537,35 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
: `Prune Sencho-managed ${confirmPrune?.target}`
|
||||
}
|
||||
hint={confirmPrune?.scope === 'all' ? 'AFFECTS external Docker resources' : 'KEEPS external resources'}
|
||||
confirmLabel={isActioning ? 'Pruning...' : (confirmPrune?.scope === 'all' ? 'Prune all' : 'Prune')}
|
||||
confirmLabel={
|
||||
isActioning
|
||||
? 'Pruning...'
|
||||
: planLoading
|
||||
? 'Preparing...'
|
||||
: prunePlan && prunePlan.reclaimableBytes > 0
|
||||
? `Prune · ${formatBytes(prunePlan.reclaimableBytes)}`
|
||||
: (confirmPrune?.scope === 'all' ? 'Prune all' : 'Prune')
|
||||
}
|
||||
confirming={isActioning}
|
||||
confirmDisabled={planLoading || !prunePlan || !!planError || (prunePlan?.items.length ?? 0) === 0}
|
||||
onConfirm={handlePrune}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
{confirmPrune?.scope === 'all' ? (
|
||||
<>
|
||||
Prunes <span className="font-medium text-stat-value">all</span> unused {confirmPrune?.target} from the Docker daemon, including those from{' '}
|
||||
<span className="font-medium text-stat-value">external projects not managed by Sencho</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Removes only unused {confirmPrune?.target} belonging to your Sencho stacks. External Docker resources are{' '}
|
||||
<span className="font-medium text-stat-value">not affected</span>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-3 text-sm text-stat-subtitle">
|
||||
<p>
|
||||
{confirmPrune?.scope === 'all' ? (
|
||||
<>
|
||||
Prunes <span className="font-medium text-stat-value">all</span> unused {confirmPrune?.target} from the Docker daemon, including those from{' '}
|
||||
<span className="font-medium text-stat-value">external projects not managed by Sencho</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Removes only unused {confirmPrune?.target} belonging to your Sencho stacks. External Docker resources are{' '}
|
||||
<span className="font-medium text-stat-value">not affected</span>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<PrunePlanPreview plan={prunePlan} loading={planLoading} error={planError} />
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
|
||||
{/* Reclaim Confirm (banner "Review & prune") */}
|
||||
@@ -1425,8 +1576,15 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
kicker="RESOURCES · PRUNE · IRREVERSIBLE"
|
||||
title="Reclaim disk space"
|
||||
hint="AFFECTS external Docker resources"
|
||||
confirmLabel={isActioning ? 'Reclaiming...' : `Reclaim ${formatBytes(totalReclaimableBytes)}`}
|
||||
confirmLabel={
|
||||
isActioning
|
||||
? 'Reclaiming...'
|
||||
: planLoading
|
||||
? 'Preparing...'
|
||||
: `Reclaim ${formatBytes(prunePlan?.reclaimableBytes ?? totalReclaimableBytes)}`
|
||||
}
|
||||
confirming={isActioning}
|
||||
confirmDisabled={planLoading || !prunePlan || !!planError || (prunePlan?.items.length ?? 0) === 0}
|
||||
onConfirm={handleReclaimAll}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-stat-subtitle">
|
||||
@@ -1434,19 +1592,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
Removes every unused image, stopped container, and dangling volume on this node, including those from{' '}
|
||||
<span className="font-medium text-stat-value">external projects not managed by Sencho</span>.
|
||||
</p>
|
||||
{usage && (
|
||||
<ul className="flex flex-col gap-1 font-mono text-[12px] text-stat-subtitle/90">
|
||||
{usage.reclaimableImageCount > 0 && (
|
||||
<li>{usage.reclaimableImageCount} {usage.reclaimableImageCount === 1 ? 'unused image' : 'unused images'} · {formatBytes(usage.reclaimableImages)}</li>
|
||||
)}
|
||||
{usage.reclaimableContainerCount > 0 && (
|
||||
<li>{usage.reclaimableContainerCount} {usage.reclaimableContainerCount === 1 ? 'stopped container' : 'stopped containers'} · {formatBytes(usage.reclaimableContainers)}</li>
|
||||
)}
|
||||
{usage.reclaimableVolumeCount > 0 && (
|
||||
<li>{usage.reclaimableVolumeCount} {usage.reclaimableVolumeCount === 1 ? 'dangling volume' : 'dangling volumes'} · {formatBytes(usage.reclaimableVolumes)}</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<PrunePlanPreview plan={prunePlan} loading={planLoading} error={planError} />
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
|
||||
|
||||
@@ -115,6 +115,33 @@ function reclaimableUsage(images: number, volumes: number) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function samplePrunePlan(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
scope: 'managed',
|
||||
targets: ['images'],
|
||||
items: [{ target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000 }],
|
||||
reclaimableBytes: 1000,
|
||||
fingerprint: 'fp-test',
|
||||
createdAt: Date.now(),
|
||||
nodeId: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function reclaimPlan() {
|
||||
return samplePrunePlan({
|
||||
scope: 'all',
|
||||
targets: ['volumes', 'containers', 'images'],
|
||||
items: [
|
||||
{ target: 'volumes', id: 'v1', name: 'v1', sizeBytes: 500 },
|
||||
{ target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000 },
|
||||
],
|
||||
reclaimableBytes: 1500,
|
||||
fingerprint: 'fp-reclaim',
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('ResourcesView', () => {
|
||||
@@ -170,8 +197,46 @@ describe('ResourcesView', () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches a prune plan before enabling confirm, then sends the fingerprint', async () => {
|
||||
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse(samplePrunePlan()));
|
||||
}
|
||||
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ success: true, reclaimedBytes: 1000, outcomes: [] }));
|
||||
}
|
||||
if (url === '/system/resources') {
|
||||
return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesView />);
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith('/system/resources'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Prune Unused Images/ }));
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith(
|
||||
'/system/prune/plan',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
));
|
||||
expect(await screen.findByText(/old:v1/)).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Prune/ }));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
const pruneCall = mockedFetch.mock.calls.find(
|
||||
([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST',
|
||||
);
|
||||
expect(pruneCall).toBeTruthy();
|
||||
const body = JSON.parse(String((pruneCall![1] as RequestInit).body));
|
||||
expect(body.planFingerprint).toBe('fp-test');
|
||||
});
|
||||
|
||||
it('surfaces the server error on a failed prune instead of a false success (M-2)', async () => {
|
||||
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse(samplePrunePlan()));
|
||||
}
|
||||
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ error: 'Prune blew up' }, { ok: false, status: 500 }));
|
||||
}
|
||||
@@ -186,7 +251,8 @@ describe('ResourcesView', () => {
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith('/system/resources'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Prune Unused Images/ }));
|
||||
await user.click(await screen.findByRole('button', { name: /^Prune$/ }));
|
||||
await waitFor(() => expect(screen.getByText(/old:v1/)).toBeInTheDocument());
|
||||
await user.click(await screen.findByRole('button', { name: /Prune/ }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Prune blew up'));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
@@ -194,12 +260,11 @@ describe('ResourcesView', () => {
|
||||
|
||||
it('reports partial failure from "Review & prune" without a false success', async () => {
|
||||
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse(reclaimPlan()));
|
||||
}
|
||||
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
||||
const target = (JSON.parse(String(opts.body)) as { target: string }).target;
|
||||
if (target === 'volumes') {
|
||||
return Promise.resolve(jsonResponse({ error: 'volume prune failed' }, { ok: false, status: 500 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ reclaimedBytes: 100 }));
|
||||
return Promise.resolve(jsonResponse({ error: 'volume prune failed' }, { ok: false, status: 500 }));
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
@@ -210,22 +275,24 @@ describe('ResourcesView', () => {
|
||||
render(<ResourcesView />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
||||
await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument());
|
||||
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
||||
|
||||
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
|
||||
const warningMsg = (toast.warning as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
||||
expect(warningMsg).toMatch(/volumes/);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
// Volumes are pruned first (while stopped containers still protect their
|
||||
// named volumes), then containers, then images.
|
||||
const pruned = mockedFetch.mock.calls
|
||||
.filter(([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST')
|
||||
.map(([, o]) => (JSON.parse(String((o as RequestInit).body)) as { target: string }).target);
|
||||
expect(pruned).toEqual(['volumes', 'containers', 'images']);
|
||||
.filter(([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST');
|
||||
expect(pruned).toHaveLength(1);
|
||||
const body = JSON.parse(String((pruned[0][1] as RequestInit).body));
|
||||
expect(body.targets).toEqual(['volumes', 'containers', 'images']);
|
||||
expect(body.planFingerprint).toBe('fp-reclaim');
|
||||
});
|
||||
|
||||
it('reports an error when every prune fails, with no success or warning', async () => {
|
||||
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse(reclaimPlan()));
|
||||
}
|
||||
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ error: 'daemon down' }, { ok: false, status: 500 }));
|
||||
}
|
||||
@@ -237,17 +304,21 @@ describe('ResourcesView', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesView />);
|
||||
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
||||
await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument());
|
||||
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to reclaim disk space.'));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('daemon down'));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits the reclaimed figure on full success when the daemon reports zero bytes', async () => {
|
||||
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse(reclaimPlan()));
|
||||
}
|
||||
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ reclaimedBytes: 0 }));
|
||||
return Promise.resolve(jsonResponse({ reclaimedBytes: 0, outcomes: [] }));
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
@@ -257,6 +328,7 @@ describe('ResourcesView', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesView />);
|
||||
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
||||
await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument());
|
||||
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
@@ -268,8 +340,11 @@ describe('ResourcesView', () => {
|
||||
|
||||
it('shows the reclaimed figure on full success when the daemon reports bytes', async () => {
|
||||
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse(reclaimPlan()));
|
||||
}
|
||||
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ reclaimedBytes: 1048576 }));
|
||||
return Promise.resolve(jsonResponse({ reclaimedBytes: 1048576, outcomes: [] }));
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
@@ -279,6 +354,7 @@ describe('ResourcesView', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesView />);
|
||||
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
||||
await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument());
|
||||
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
|
||||
@@ -208,6 +208,8 @@ interface ConfirmModalProps {
|
||||
confirmLabel: React.ReactNode;
|
||||
cancelLabel?: React.ReactNode;
|
||||
confirming?: boolean;
|
||||
/** When true, the confirm action stays disabled (e.g. plan still loading). */
|
||||
confirmDisabled?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
children?: React.ReactNode;
|
||||
@@ -225,6 +227,7 @@ export function ConfirmModal({
|
||||
confirmLabel,
|
||||
cancelLabel = 'Cancel',
|
||||
confirming = false,
|
||||
confirmDisabled = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
children,
|
||||
@@ -235,6 +238,7 @@ export function ConfirmModal({
|
||||
variant: variant === 'destructive' ? 'destructive' : 'default',
|
||||
size: 'sm',
|
||||
});
|
||||
const actionDisabled = confirming || confirmDisabled;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -255,7 +259,7 @@ export function ConfirmModal({
|
||||
primary={
|
||||
<AlertDialogAction
|
||||
className={actionClass}
|
||||
disabled={confirming}
|
||||
disabled={actionDisabled}
|
||||
onClick={(e) => {
|
||||
const result = onConfirm();
|
||||
// Async confirms keep the dialog open so the caller can render
|
||||
|
||||
Reference in New Issue
Block a user