Show live progress while pruning volumes, and ask first (0.62.0)
CI / check (push) Successful in 13m19s
CI / build-and-push (push) Successful in 1m57s

The volume prune gets the same treatment as the image prune in 0.61.0:
the candidates are listed and removed one by one in the background, and
the Volumes page shows a progress bar (x / y, the volume being removed,
failures) followed by a toast with the space freed.

The button also used to delete without asking, and a bare docker volume
prune only removes anonymous volumes on Engine 23+ with no way to include
named ones. It now opens a confirmation dialog with an explicit -a option
that warns it also takes the data of every stack that is down.

- Candidates: volumes no container (running or stopped) mounts, local
  scope only, and anonymous (com.docker.volume.anonymous) unless -a.
  Volumes from pre-23 daemons carry no such label, so they only go with
  -a, which errs on the side of keeping data.
- Removed without force, so a volume mounted since the listing is
  refused; one refusal does not stop the rest.
- No closing daemon prune: nothing is left over, and on a pre-23 daemon an
  unfiltered one would delete named volumes too.
- Freed space is the sum of the deleted volumes' sizes (volumes share no
  data), from the size cache, which is invalidated afterwards.
- POST /api/volumes/prune takes all= and background=; without background
  it still waits and returns {VolumesDeleted, SpaceReclaimed}.
  GET /api/volumes/prune-status is admin-only.
- volume_sizes() ("Compute sizes") now uses the long-timeout client; it
  walks every volume and could hit the same 60s limit.

The progress machinery moved into services/prune_job.py (PruneJob,
PrunePlan) and, on the frontend, the usePruneJob hook and PruneProgress
component, shared by both pages. The status now carries a phase
(preparing / removing / finishing) so the parts without a count say what
they are doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzeljandClaude Opus 5 committed 2026-09-18 10:23:51 +02:00
1 parent faff2be944
commit 57231e4a67
18 files changed
+816 -286

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.61.0",
"version": "0.62.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -17
View File
@@ -1,4 +1,5 @@
import api from "./client";
import type { PruneStatus } from "@/types";
export interface UpdateStatus {
image: string;
@@ -54,23 +55,6 @@ 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),
+9 -3
View File
@@ -1,5 +1,5 @@
import api from "./client";
import type { HostPathResult, VolumeInfo } from "@/types";
import type { HostPathResult, PruneStatus, VolumeInfo } from "@/types";
const base = "/api/volumes";
@@ -11,8 +11,14 @@ export const volumesApi = {
.then((r) => r.data),
remove: (name: string, force = false) =>
api.delete(`${base}/${name}?force=${force}`).then((r) => r.data),
prune: () =>
api.post<{ VolumesDeleted: string[] | null }>(`${base}/prune`).then((r) => r.data),
// Runs in the background on the server; poll pruneStatus() for progress.
startPrune: (allUnused: boolean) =>
api
.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),
generateYaml: (spec: Record<string, unknown>) =>
api
.post<{ yaml: string }>("/api/volumes/generate-yaml", spec)
@@ -0,0 +1,53 @@
import { Card } from "@/components/ui";
import type { PruneStatus } from "@/types";
const PHASE_DETAIL = {
preparing: "Measuring disk usage…",
finishing: "Finishing up…",
} as const;
/** Live progress of a running prune, shown above the page's table. */
export function PruneProgress({ status, title }: { status: PruneStatus; title: string }) {
const measurable = status.total > 0;
const pct = measurable ? Math.min((status.done / status.total) * 100, 100) : 0;
const detail =
status.phase === "removing"
? status.current
? `Removing ${status.current}`
: ""
: status.phase
? PHASE_DETAIL[status.phase]
: "";
return (
<Card className="mb-3 p-3">
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="font-medium">{title}</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 && status.phase === "removing" ? (
<div
className="h-full rounded-full bg-accent transition-[width] duration-300 ease-out dark:bg-accent-dark"
style={{ width: `${pct}%` }}
/>
) : (
// Measuring and finishing have nothing to count — slide instead.
<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>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useRef } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiErrorMessage } from "@/api/client";
import { formatBytes } from "@/lib/utils";
import type { PruneStatus } from "@/types";
/**
* A server-side prune that runs in the background (images, volumes).
*
* Polls the status while a prune is active. It is fetched once on mount too,
* so a prune started in another tab, or before a reload, still shows up. The
* outcome of the run this page saw active is announced exactly once, and
* never the stale result of an older run on first load.
*/
export function usePruneJob({
noun,
queryKey,
fetchStatus,
start,
enabled,
onFinished,
}: {
/** Singular, for the toast: "image", "volume". */
noun: string;
queryKey: string;
fetchStatus: () => Promise<PruneStatus>;
start: (allUnused: boolean) => Promise<PruneStatus>;
enabled: boolean;
onFinished: () => void;
}) {
const qc = useQueryClient();
const status = useQuery({
queryKey: [queryKey],
queryFn: fetchStatus,
enabled,
refetchInterval: (q) => (q.state.data?.active ? 1000 : false),
});
const watchedRun = useRef<number | null>(null);
const finished = useRef(onFinished);
finished.current = onFinished;
useEffect(() => {
const s = status.data;
if (!s) return;
if (s.active) {
watchedRun.current = s.run;
return;
}
if (watchedRun.current !== s.run) return;
watchedRun.current = null;
finished.current();
announcePrune(s, noun);
}, [status.data, noun]);
const mutation = useMutation({
mutationFn: start,
onSuccess: (s) => {
watchedRun.current = s.run;
qc.setQueryData([queryKey], s);
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
return {
/** The running prune, or null. */
running: status.data?.active ? status.data : null,
start: mutation,
};
}
/** Toast for a finished prune: what went, what it freed, what refused to go. */
function announcePrune(s: PruneStatus, noun: string) {
if (s.error) {
toast.error(`Prune failed: ${s.error}`);
return;
}
if (!s.deleted && !s.failed) {
toast.success(`No unused ${noun}s`);
return;
}
const freed = s.reclaimed ? `, freed ${formatBytes(s.reclaimed)}` : "";
const summary = `Removed ${s.deleted} ${noun}${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);
}
}
+27 -106
View File
@@ -1,10 +1,12 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Fragment, useMemo, useState } from "react";
import { 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, type PruneStatus } from "@/api/images";
import { imagesApi, type ImageRow } from "@/api/images";
import { PruneProgress } from "@/components/ui/PruneProgress";
import { usePruneJob } from "@/hooks/usePruneJob";
import { ScanBadge, ScanDetailDialog } from "@/components/stacks/ScanFindings";
import { stacksApi } from "@/api/stacks";
import { groupByStack, type StackGroup } from "@/lib/stackGroups";
@@ -108,48 +110,20 @@ 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,
const prune = usePruneJob({
noun: "image",
queryKey: "image-prune-status",
fetchStatus: imagesApi.pruneStatus,
start: imagesApi.startPrune,
enabled: isAdmin,
refetchInterval: (q) => (q.state.data?.active ? 1000 : false),
onFinished: () => qc.invalidateQueries({ queryKey: ["images"] }),
});
// 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.startPrune(pruneAll),
onSuccess: (s) => {
watchedRun.current = s.run;
qc.setQueryData(["image-prune-status"], s);
setPruneOpen(false);
setPruneAll(false);
},
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)} loading={!!pruning}>
<Button variant="outline" onClick={() => setPruneOpen(true)} loading={!!prune.running}>
<Eraser className="h-4 w-4" /> Prune
</Button>
<Button variant="outline" onClick={scanAll} loading={scanning}>
@@ -161,7 +135,12 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
</div>
)}
{pruning && <PruneProgress status={pruning} />}
{prune.running && (
<PruneProgress
status={prune.running}
title={`Pruning ${prune.running.all ? "unused" : "dangling"} images…`}
/>
)}
{isLoading ? (
<Spinner />
@@ -236,8 +215,15 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
}
confirmLabel="Prune images"
danger
busy={prune.isPending}
onConfirm={() => prune.mutate()}
busy={prune.start.isPending}
onConfirm={() =>
prune.start.mutate(pruneAll, {
onSuccess: () => {
setPruneOpen(false);
setPruneAll(false);
},
})
}
onCancel={() => {
setPruneOpen(false);
setPruneAll(false);
@@ -257,71 +243,6 @@ 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" : ""}`];
+56 -7
View File
@@ -5,6 +5,8 @@ import { toast } from "sonner";
import { Button, Card, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { volumesApi } from "@/api/volumes";
import { PruneProgress } from "@/components/ui/PruneProgress";
import { usePruneJob } from "@/hooks/usePruneJob";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
@@ -23,6 +25,8 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
const [onlyUnused, setOnlyUnused] = useState(false);
const [toDelete, setToDelete] = useState<VolumeInfo | null>(null);
const [force, setForce] = useState(false);
const [pruneOpen, setPruneOpen] = useState(false);
const [pruneAll, setPruneAll] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["volumes"],
@@ -38,14 +42,17 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
});
const invalidate = () => qc.invalidateQueries({ queryKey: ["volumes"] });
const prune = useMutation({
mutationFn: () => volumesApi.prune(),
onSuccess: (r) => {
const n = r.VolumesDeleted?.length ?? 0;
toast.success(n ? `Pruned ${n} volume(s)` : "No unused volumes");
const prune = usePruneJob({
noun: "volume",
queryKey: "volume-prune-status",
fetchStatus: volumesApi.pruneStatus,
start: volumesApi.startPrune,
enabled: isAdmin,
onFinished: () => {
invalidate();
// Whatever was measured before includes volumes that are gone now.
if (sizes.data) sizes.refetch();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: (v: VolumeInfo) => volumesApi.remove(v.name, force),
@@ -104,12 +111,19 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
<HardDrive className="h-4 w-4" /> Compute sizes
</Button>
{isAdmin && (
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
<Button variant="outline" onClick={() => setPruneOpen(true)} loading={!!prune.running}>
<Eraser className="h-4 w-4" /> Prune unused
</Button>
)}
</div>
{prune.running && (
<PruneProgress
status={prune.running}
title={`Pruning ${prune.running.all ? "unused" : "anonymous"} volumes…`}
/>
)}
{isLoading ? (
<Spinner />
) : (
@@ -161,6 +175,41 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
</Card>
)}
{pruneOpen && (
<ConfirmDialog
title="Prune volumes"
message={
pruneAll
? "Remove ALL volumes no container uses, including named ones. That includes the data of every stack that is currently down (containers removed). It is deleted permanently."
: "Remove anonymous volumes no container uses. Named volumes are kept."
}
confirmLabel="Prune volumes"
danger
busy={prune.start.isPending}
onConfirm={() =>
prune.start.mutate(pruneAll, {
onSuccess: () => {
setPruneOpen(false);
setPruneAll(false);
},
})
}
onCancel={() => {
setPruneOpen(false);
setPruneAll(false);
}}
>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={pruneAll}
onChange={(e) => setPruneAll(e.target.checked)}
/>
Also remove unused named volumes (<code>-a</code>)
</label>
</ConfirmDialog>
)}
{toDelete && (
<ConfirmDialog
title={`Delete volume “${toDelete.name}”?`}
+18
View File
@@ -149,3 +149,21 @@ export interface TokenPair {
refresh_token: string;
token_type: string;
}
/** Progress of a running image/volume 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;
phase: "preparing" | "removing" | "finishing" | null;
total: number;
done: number;
current: string | null;
deleted: number;
failed: number;
errors: string[];
/** Measured once the prune is through; null while it runs or if unknown. */
reclaimed: number | null;
error: string | null;
}