Show live progress while pruning images (0.61.0)
CI / check (push) Successful in 13m53s
CI / build-and-push (push) Successful in 1m58s

docker image prune is a single blocking call that reports nothing until it
is done. With -a on a host with many images that meant minutes of a
spinner, and past the proxy timeout an error while the daemon carried on.

The prune now removes its candidates one by one and publishes how far it
is. The candidates are the ones Docker would pick: dangling only by
default, with -a every image no container (running or stopped) uses. The
UI starts the prune in the background (POST /api/images/prune?background=
true, 202) and polls GET /api/images/prune-status. A progress bar above
the image table shows x / y, the image being removed and any failures,
and a toast reports the outcome. It survives a reload and shows up in a
second tab.

- Multi-tag images lose their tags one by one instead of force=True, so a
  container created since the listing still makes the daemon refuse.
- One refused image does not stop the rest; up to ten errors are kept.
- Docker's own prune runs last to sweep up leftovers.
- Freed space is measured (image LayersSize before and after) rather than
  summed, which would count base layers shared with the images that stay.
- Without background=true the endpoint still waits and returns the old
  {ImagesDeleted, SpaceReclaimed} shape, so API-token scripts are
  unaffected. A second prune while one runs gets 409.
- The audit entry now counts deleted images, not Docker's untag entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzeljandClaude Opus 5 committed 2026-09-18 10:15:14 +02:00
1 parent f766ded528
commit faff2be944
9 files changed
+619 -39

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.60.1",
"version": "0.61.0",
"type": "module",
"scripts": {
"dev": "vite",
+22 -3
View File
@@ -54,6 +54,23 @@ export interface ScanDetail extends ScanSummary {
const base = "/api/images";
/** Progress of the running image prune, or the outcome of the last one. */
export interface PruneStatus {
active: boolean;
/** Increments per prune, so a client can tell "its" run from an older one. */
run: number;
all: boolean;
total: number;
done: number;
current: string | null;
deleted: number;
failed: number;
errors: string[];
/** Measured once the prune is through; null while it runs. */
reclaimed: number | null;
error: string | null;
}
export const imagesApi = {
list: () => api.get<ImageRow[]>(base).then((r) => r.data),
updates: () => api.get<Record<string, UpdateStatus>>(`${base}/updates`).then((r) => r.data),
@@ -69,10 +86,12 @@ export const imagesApi = {
api
.get<{ active: boolean; done: number; total: number }>(`${base}/scan-status`)
.then((r) => r.data),
prune: (allUnused: boolean) =>
// Runs in the background on the server; poll pruneStatus() for progress.
startPrune: (allUnused: boolean) =>
api
.post<{ ImagesDeleted: unknown[]; SpaceReclaimed: number }>(`${base}/prune`, null, {
params: { all: allUnused },
.post<PruneStatus>(`${base}/prune`, null, {
params: { all: allUnused, background: true },
})
.then((r) => r.data),
pruneStatus: () => api.get<PruneStatus>(`${base}/prune-status`).then((r) => r.data),
};
+100 -12
View File
@@ -1,10 +1,10 @@
import { Fragment, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle, Eraser, ShieldAlert } from "lucide-react";
import { toast } from "sonner";
import { Button, Card, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { imagesApi, type ImageRow } from "@/api/images";
import { imagesApi, type ImageRow, type PruneStatus } from "@/api/images";
import { ScanBadge, ScanDetailDialog } from "@/components/stacks/ScanFindings";
import { stacksApi } from "@/api/stacks";
import { groupByStack, type StackGroup } from "@/lib/stackGroups";
@@ -108,27 +108,48 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
}
};
// The prune runs on the server in the background; this polls its progress
// while it is active. Fetched once on mount too, so a prune started in
// another tab, or before a reload, still shows up here.
const pruneStatus = useQuery({
queryKey: ["image-prune-status"],
queryFn: imagesApi.pruneStatus,
enabled: isAdmin,
refetchInterval: (q) => (q.state.data?.active ? 1000 : false),
});
// The run this page has seen active, so its outcome is announced exactly
// once — and never the stale result of an older run on first load.
const watchedRun = useRef<number | null>(null);
useEffect(() => {
const s = pruneStatus.data;
if (!s) return;
if (s.active) {
watchedRun.current = s.run;
return;
}
if (watchedRun.current !== s.run) return;
watchedRun.current = null;
qc.invalidateQueries({ queryKey: ["images"] });
announcePrune(s);
}, [pruneStatus.data, qc]);
const prune = useMutation({
mutationFn: () => imagesApi.prune(pruneAll),
onSuccess: (r) => {
const n = r.ImagesDeleted?.length ?? 0;
toast.success(
n
? `Pruned ${n} image layer(s), freed ${formatBytes(r.SpaceReclaimed)}`
: "No unused images"
);
mutationFn: () => imagesApi.startPrune(pruneAll),
onSuccess: (s) => {
watchedRun.current = s.run;
qc.setQueryData(["image-prune-status"], s);
setPruneOpen(false);
setPruneAll(false);
qc.invalidateQueries({ queryKey: ["images"] });
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const pruning = pruneStatus.data?.active ? pruneStatus.data : null;
return (
<section>
{isAdmin && (
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" onClick={() => setPruneOpen(true)}>
<Button variant="outline" onClick={() => setPruneOpen(true)} loading={!!pruning}>
<Eraser className="h-4 w-4" /> Prune
</Button>
<Button variant="outline" onClick={scanAll} loading={scanning}>
@@ -140,6 +161,8 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
</div>
)}
{pruning && <PruneProgress status={pruning} />}
{isLoading ? (
<Spinner />
) : (
@@ -234,6 +257,71 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
);
}
/** Toast for a finished prune: what went, what it freed, what refused to go. */
function announcePrune(s: PruneStatus) {
if (s.error) {
toast.error(`Prune failed: ${s.error}`);
return;
}
if (!s.deleted && !s.failed) {
toast.success("No unused images");
return;
}
const freed = s.reclaimed ? `, freed ${formatBytes(s.reclaimed)}` : "";
const summary = `Removed ${s.deleted} image${s.deleted === 1 ? "" : "s"}${freed}`;
if (s.failed) {
toast.warning(`${summary}; ${s.failed} could not be removed`, {
description: s.errors.slice(0, 3).join("\n"),
});
} else {
toast.success(summary);
}
}
/** Live progress of a running prune, above the image table. */
function PruneProgress({ status }: { status: PruneStatus }) {
const measurable = status.total > 0;
const pct = measurable ? Math.min((status.done / status.total) * 100, 100) : 0;
const detail = status.current
? `Removing ${status.current}`
: status.done < status.total
? "Starting…"
: "Cleaning up and measuring freed space…";
return (
<Card className="mb-3 p-3">
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="font-medium">
Pruning {status.all ? "unused" : "dangling"} images…
</span>
{measurable && (
<span className="shrink-0 text-xs tabular-nums text-sp-text-2">
{status.done} / {status.total}
</span>
)}
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-sp-surface-2 ring-1 ring-inset ring-sp-border">
{measurable ? (
<div
className="h-full rounded-full bg-accent transition-[width] duration-300 ease-out dark:bg-accent-dark"
style={{ width: `${pct}%` }}
/>
) : (
<div className="h-full w-1/3 rounded-full bg-accent animate-indeterminate dark:bg-accent-dark" />
)}
</div>
<div className="mt-1 flex items-baseline gap-3 text-[11px] text-sp-text-3">
<span className="min-w-0 truncate">{detail}</span>
{status.failed > 0 && (
<span className="shrink-0 text-amber-600 dark:text-amber-400">
{status.failed} failed
</span>
)}
</div>
</Card>
);
}
/** What an image group can say for itself: how many, how big, how stale. */
function groupMeta(group: StackGroup<ImageRow>): string {
const parts = [`${group.items.length} image${group.items.length > 1 ? "s" : ""}`];