diff --git a/README.md b/README.md index 8927671..19107a2 100644 --- a/README.md +++ b/README.md @@ -783,7 +783,16 @@ it is what your saved destination credentials are encrypted with. ### Phase 18 — Image prune - **Prune images** (dangling, or all unused) from the Images page, on the local - host. + host. The prune removes the images one by one in the background and shows + **live progress** above the table (x / y, the image being removed, failures), + then the space actually freed. A large `-a` no longer sits on a spinner until + the proxy times out. + +``` +POST /api/images/prune?all=… (waits for the result; admin) +POST /api/images/prune?all=…&background=true (202 at once, then poll ↓) +GET /api/images/prune-status (progress of the running / last prune) +``` ### Phase 16 — Volumes page diff --git a/backend/routers/images.py b/backend/routers/images.py index e2c6b8c..024728a 100644 --- a/backend/routers/images.py +++ b/backend/routers/images.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from sqlmodel import Session from auth import get_current_user, require_admin -from database import get_session +from database import engine, get_session from models.image_scan import ImageScan, ScanDetail, ScanRequest, ScanSummary from models.user import User from services import audit_service, image_service, scan_service, update_service @@ -37,19 +37,50 @@ async def check(_user: User = Depends(require_admin)) -> dict: @router.post("/prune") def prune( request: Request, + response: Response, all_unused: bool = Query(False, alias="all"), - session: Session = Depends(get_session), + background: bool = Query(False), user: User = Depends(require_admin), ) -> dict: - result = image_service.prune_images(all_unused) - audit_service.record( - session, user=user.username, action="image.prune", target="*", - detail=f"all={all_unused} reclaimed={result.get('SpaceReclaimed')}", - ip=_ip(request), - ) + """Remove unused images (``all=true``: also unused tagged ones, ``-a``). + + Waits for the result by default. ``background=true`` returns 202 with the + initial status at once and leaves the caller to poll /prune-status, which + is what the UI does: a large prune can outlast the proxy's read timeout. + """ + username, ip = user.username, _ip(request) + + def audit(result: dict) -> None: + # Docker lists every untagging too; only "Deleted" entries are images. + deleted = sum(1 for entry in result["ImagesDeleted"] if "Deleted" in entry) + # Its own session: in the background case the request's is long closed. + with Session(engine) as own: + audit_service.record( + own, user=username, action="image.prune", target="*", + detail=( + f"all={all_unused} deleted={deleted} " + f"reclaimed={result['SpaceReclaimed']}" + ), + ip=ip, + ) + + try: + if background: + response.status_code = 202 + return image_service.start_prune(all_unused, on_done=audit) + result = image_service.prune_images(all_unused) + except image_service.PruneBusy as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + audit(result) return result +@router.get("/prune-status") +def prune_status(_admin: User = Depends(require_admin)) -> dict: + """Progress of a running (or the last) prune. Admin-only like the prune.""" + return image_service.prune_status() + + # --------------------------------------------------------------------------- # # Vulnerability scanning # --------------------------------------------------------------------------- # diff --git a/backend/services/image_service.py b/backend/services/image_service.py index 4a18c38..610316d 100644 --- a/backend/services/image_service.py +++ b/backend/services/image_service.py @@ -1,9 +1,15 @@ -"""Image listing for the images router.""" +"""Image listing and pruning for the images router.""" from __future__ import annotations +import logging +import threading +from typing import Callable, Optional + from docker_client import DockerError, get_client, get_long_client, safe_call from services import update_service +logger = logging.getLogger(__name__) + COMPOSE_PROJECT_LABEL = "com.docker.compose.project" @@ -47,15 +53,197 @@ def list_images() -> list[dict]: return result -def prune_images(all_unused: bool = False) -> dict: - """Remove unused images. By default only dangling (untagged) images are - removed; ``all_unused=True`` removes every image not referenced by a - container (``docker image prune -a``).""" +# --------------------------------------------------------------------------- # +# Prune +# --------------------------------------------------------------------------- # + +#: One prune at a time: a second pass over the same images would only collect +#: errors for everything the first one already removed. +_prune_lock = threading.Lock() +_pruning: dict = { + "active": False, + "run": 0, + "all": False, + "total": 0, + "done": 0, + "current": None, + "deleted": 0, + "failed": 0, + "errors": [], + "reclaimed": None, + "error": None, +} +_MAX_ERRORS = 10 + + +class PruneBusy(Exception): + """A prune is already running.""" + + +def prune_status() -> dict: + return {**_pruning, "errors": list(_pruning["errors"])} + + +def _layers_size(client) -> Optional[int]: + """Total size of all image layers on the host, or None if unavailable. + + Taken before and after the prune, the difference is the space actually + freed. Summing the sizes of the deleted images would count base layers + they share with the images that stay. ``type=image`` keeps the daemon from + also walking every volume (API 1.42+; older daemons ignore it). + """ + try: + api = client.api + response = api._get(api._url("/system/df"), params={"type": "image"}) + return api._result(response, True).get("LayersSize") + except Exception as exc: # noqa: BLE001 - the size is a nicety, not the job + logger.warning("Could not read image disk usage: %s", exc) + return None + + +def _candidates(client, all_unused: bool) -> list[dict]: + """The images ``docker image prune [-a]`` would remove, largest first.""" + in_use = {c.get("ImageID") for c in safe_call(client.api.containers, all=True)} + filters = None if all_unused else {"dangling": True} + images = safe_call(client.api.images, filters=filters) + unused = [img for img in images if img["Id"] not in in_use] + return sorted(unused, key=lambda img: img.get("Size", 0), reverse=True) + + +def _label(image: dict) -> str: + tags = [t for t in image.get("RepoTags") or [] if t != ":"] + if tags: + return tags[0] if len(tags) == 1 else f"{tags[0]} (+{len(tags) - 1})" + return image["Id"].removeprefix("sha256:")[:12] + + +def _remove(client, image: dict) -> list[dict]: + """Delete one image the way the prune would, returning Docker's entries. + + Tags are removed one by one rather than with force=True: the last one takes + the image with it, and a container created since the candidates were listed + still makes the daemon refuse instead of losing its image. + """ + tags = [t for t in image.get("RepoTags") or [] if t != ":"] + entries: list[dict] = [] + for ref in tags or [image["Id"]]: + entries.extend(safe_call(client.api.remove_image, ref) or []) + return entries + + +def _begin(all_unused: bool) -> tuple: client = get_long_client() - # dangling=false tells the engine to also consider tagged-but-unused images. + candidates = _candidates(client, all_unused) + _pruning.update( + { + "active": True, + "run": _pruning["run"] + 1, + "all": all_unused, + "total": len(candidates), + "done": 0, + "current": None, + "deleted": 0, + "failed": 0, + "errors": [], + "reclaimed": None, + "error": None, + } + ) + return client, candidates + + +def _execute(client, candidates: list[dict], all_unused: bool) -> dict: + before = _layers_size(client) + deleted: list[dict] = [] + for image in candidates: + _pruning["current"] = _label(image) + try: + entries = _remove(client, image) + deleted.extend(entries) + if any("Deleted" in e for e in entries): + _pruning["deleted"] += 1 + except DockerError as exc: + # One image must not stop the rest, e.g. a container started from + # it since the list was taken. + logger.warning("Prune: could not remove %s: %s", _pruning["current"], exc) + _pruning["failed"] += 1 + if len(_pruning["errors"]) < _MAX_ERRORS: + _pruning["errors"].append(f"{_pruning['current']}: {exc.detail or exc.error}") + _pruning["done"] += 1 + _pruning["current"] = None + + # Let the daemon sweep up whatever the per-image pass left, such as + # untagged images referenced from several repositories. filters = {"dangling": False} if all_unused else {"dangling": True} - result = safe_call(client.images.prune, filters=filters) - return { - "ImagesDeleted": result.get("ImagesDeleted") or [], - "SpaceReclaimed": result.get("SpaceReclaimed", 0), - } + sweep = safe_call(client.images.prune, filters=filters) + deleted.extend(sweep.get("ImagesDeleted") or []) + + after = _layers_size(client) + if before is not None and after is not None: + reclaimed = max(before - after, 0) + else: + reclaimed = sweep.get("SpaceReclaimed", 0) + _pruning["reclaimed"] = reclaimed + return {"ImagesDeleted": deleted, "SpaceReclaimed": reclaimed} + + +def _finish(error: Optional[str] = None) -> None: + _pruning.update({"active": False, "current": None, "error": error}) + _prune_lock.release() + + +def prune_images(all_unused: bool = False) -> dict: + """Remove unused images and wait for the result. + + By default only dangling (untagged) images are removed; ``all_unused=True`` + removes every image not referenced by a container (``docker image prune + -a``). Progress is visible through prune_status() while it runs. + """ + if not _prune_lock.acquire(blocking=False): + raise PruneBusy("An image prune is already running") + error = None + try: + client, candidates = _begin(all_unused) + return _execute(client, candidates, all_unused) + except DockerError as exc: + error = exc.detail or exc.error + raise + finally: + _finish(error) + + +def start_prune( + all_unused: bool = False, on_done: Optional[Callable[[dict], None]] = None +) -> dict: + """Start a prune in the background and return its initial status. + + A prune removing gigabytes of layers can outlast the proxy's read timeout, + so the UI starts it this way and polls prune_status(). ``on_done`` gets + the same result prune_images() returns, from the worker thread. + """ + if not _prune_lock.acquire(blocking=False): + raise PruneBusy("An image prune is already running") + try: + client, candidates = _begin(all_unused) + except BaseException: + _prune_lock.release() + raise + + def work() -> None: + error = None + result = None + try: + result = _execute(client, candidates, all_unused) + except Exception as exc: # noqa: BLE001 - reported through the status + logger.warning("Image prune failed: %s", exc) + error = exc.detail if isinstance(exc, DockerError) else str(exc) + finally: + _finish(error) + if result is not None and on_done: + try: + on_done(result) + except Exception as exc: # noqa: BLE001 - the prune itself succeeded + logger.warning("Image prune follow-up failed: %s", exc) + + threading.Thread(target=work, name="image-prune", daemon=True).start() + return prune_status() diff --git a/backend/tests/test_docker_timeouts.py b/backend/tests/test_docker_timeouts.py index 7f2c169..57e2ff0 100644 --- a/backend/tests/test_docker_timeouts.py +++ b/backend/tests/test_docker_timeouts.py @@ -54,7 +54,6 @@ def test_volume_prune_timeout_is_a_502_with_detail(as_admin, monkeypatch): "module, fn, args", [ ("volume_service", "prune_volumes", ()), - ("image_service", "prune_images", (True,)), ("network_service", "prune_networks", ()), ], ) @@ -79,3 +78,7 @@ def test_prunes_use_the_long_timeout_client(monkeypatch, module, fn, args): monkeypatch.setattr(svc, "get_long_client", lambda: Client()) getattr(svc, fn)(*args) assert used == ["long"] + + +# The image prune goes image by image and has its own tests in +# test_image_prune.py, including that it uses the long-timeout client. diff --git a/backend/tests/test_image_prune.py b/backend/tests/test_image_prune.py new file mode 100644 index 0000000..5add72d --- /dev/null +++ b/backend/tests/test_image_prune.py @@ -0,0 +1,242 @@ +"""Image prune with visible progress. + +``docker image prune -a`` is one blocking call that reports nothing until it +is done; on a host with a lot of images that meant minutes of a spinner and +then, past the proxy timeout, an error while the daemon carried on. The prune +now removes the candidates one by one, publishes how far it is through +/api/images/prune-status, and the UI starts it in the background and polls. + +What these tests pin: the candidate set matches Docker's (in-use images are +never touched, dangling-only without -a), multi-tag images lose every tag, +one failing image does not stop the rest, reclaimed space is measured rather +than summed, and the endpoints keep the old synchronous contract for scripts. +""" +from __future__ import annotations + +import threading + +import pytest +from docker.errors import APIError + +MB = 1024 * 1024 + + +class FakeApi: + """Just enough of docker-py's low-level APIClient for the prune.""" + + def __init__(self, images, containers, layers=500 * MB): + self._images = images + self._containers = containers + self.layers = layers + self.removed: list[str] = [] + self.refuse: set[str] = set() + self.gate: threading.Event | None = None + + def containers(self, all=False): + return self._containers + + def images(self, filters=None): + if filters and filters.get("dangling"): + return [i for i in self._images if not i.get("RepoTags")] + return list(self._images) + + def remove_image(self, ref, force=False, noprune=False): + if self.gate: + self.gate.wait(5) + if ref in self.refuse: + raise APIError(f"conflict: unable to remove {ref}") + self.removed.append(ref) + for img in self._images: + if ref in (img.get("RepoTags") or []): + img["RepoTags"].remove(ref) + if img["RepoTags"]: + return [{"Untagged": ref}] + self._images.remove(img) + self.layers -= img["Size"] + return [{"Untagged": ref}, {"Deleted": img["Id"]}] + if img["Id"] == ref: + self._images.remove(img) + self.layers -= img["Size"] + return [{"Deleted": img["Id"]}] + raise APIError(f"No such image: {ref}") + + # /system/df?type=image + def _url(self, path): + return path + + def _get(self, url, params=None): + assert params == {"type": "image"} + return self + + def _result(self, response, json=False): + return {"LayersSize": self.layers} + + +class FakeImages: + def __init__(self, api): + self.api = api + self.prune_filters = None + + def prune(self, filters=None): + self.prune_filters = filters + return {"ImagesDeleted": None, "SpaceReclaimed": 0} + + +class FakeClient: + def __init__(self, api): + self.api = api + self.images = FakeImages(api) + + +def _host(): + images = [ + {"Id": "sha256:aaa", "RepoTags": ["nginx:1.25"], "Size": 100 * MB}, + {"Id": "sha256:bbb", "RepoTags": ["redis:7", "redis:latest"], "Size": 50 * MB}, + {"Id": "sha256:ccc", "RepoTags": [], "Size": 20 * MB}, + {"Id": "sha256:ddd", "RepoTags": ["postgres:16"], "Size": 200 * MB}, + ] + # postgres is used by a (possibly stopped) container and must survive. + containers = [{"ImageID": "sha256:ddd"}] + return FakeApi(images, containers) + + +@pytest.fixture +def svc(monkeypatch): + from services import image_service + + api = _host() + client = FakeClient(api) + + def short(): + raise AssertionError("prune must not use the 60s client") + + monkeypatch.setattr(image_service, "get_client", short) + monkeypatch.setattr(image_service, "get_long_client", lambda: client) + image_service._pruning.update({"active": False, "run": 0}) + yield image_service, api, client + if image_service._prune_lock.locked(): # a test that failed mid-prune + image_service._prune_lock.release() + + +def test_all_unused_removes_everything_no_container_uses(svc): + image_service, api, client = svc + + result = image_service.prune_images(all_unused=True) + + assert {i["Id"] for i in api._images} == {"sha256:ddd"} + # Every tag of a multi-tag image is removed, without force. + assert set(api.removed) == {"nginx:1.25", "redis:7", "redis:latest", "sha256:ccc"} + assert {e["Deleted"] for e in result["ImagesDeleted"] if "Deleted" in e} == { + "sha256:aaa", "sha256:bbb", "sha256:ccc", + } + assert result["SpaceReclaimed"] == 170 * MB + # The daemon's own prune still runs afterwards to sweep up leftovers. + assert client.images.prune_filters == {"dangling": False} + + status = image_service.prune_status() + assert status["active"] is False + assert (status["total"], status["done"], status["deleted"]) == (3, 3, 3) + assert status["reclaimed"] == 170 * MB + + +def test_default_only_touches_dangling_images(svc): + image_service, api, client = svc + + image_service.prune_images(all_unused=False) + + assert api.removed == ["sha256:ccc"] + assert client.images.prune_filters == {"dangling": True} + + +def test_one_refused_image_does_not_stop_the_rest(svc): + image_service, api, _ = svc + api.refuse.add("nginx:1.25") + + image_service.prune_images(all_unused=True) + + status = image_service.prune_status() + assert status["failed"] == 1 and status["done"] == 3 and status["deleted"] == 2 + assert status["errors"][0].startswith("nginx:1.25: ") + assert any(i["Id"] == "sha256:aaa" for i in api._images) + + +def test_background_prune_reports_progress_and_blocks_a_second_one(svc): + image_service, api, _ = svc + api.gate = threading.Event() + finished = threading.Event() + + started = image_service.start_prune(True, on_done=lambda r: finished.set()) + assert started["active"] is True and started["total"] == 3 + with pytest.raises(image_service.PruneBusy): + image_service.start_prune(True) + + api.gate.set() + assert finished.wait(5) + status = image_service.prune_status() + assert status["run"] == started["run"] + assert status["done"] == 3 and status["reclaimed"] == 170 * MB + + +def test_background_prune_error_lands_in_the_status(svc): + from docker_client import DockerError + + image_service, _, client = svc + + def broken(filters=None): + raise DockerError("docker_timeout", "Docker did not answer in time") + + client.images.prune = broken + image_service.start_prune(True) + for _ in range(100): + if not image_service.prune_status()["active"]: + break + threading.Event().wait(0.02) + status = image_service.prune_status() + assert status["active"] is False + assert status["error"] == "Docker did not answer in time" + assert not image_service._prune_lock.locked() + + +def test_endpoint_waits_by_default_and_is_audited(svc, as_admin): + from sqlmodel import Session, select + + from database import engine + from models.audit import AuditLog + + r = as_admin.post("/api/images/prune", params={"all": True}) + assert r.status_code == 200 + assert r.json()["SpaceReclaimed"] == 170 * MB + + with Session(engine) as session: + row = session.exec( + select(AuditLog).where(AuditLog.action == "image.prune").order_by(AuditLog.id.desc()) + ).first() + assert row.detail == f"all=True deleted=3 reclaimed={170 * MB}" + + +def test_endpoint_background_returns_202_and_status_is_pollable(svc, as_admin): + r = as_admin.post("/api/images/prune", params={"all": True, "background": True}) + assert r.status_code == 202 + assert r.json()["total"] == 3 + + for _ in range(100): + s = as_admin.get("/api/images/prune-status").json() + if not s["active"]: + break + threading.Event().wait(0.02) + assert s["done"] == 3 and s["reclaimed"] == 170 * MB + + +def test_endpoint_rejects_a_second_prune(svc, as_admin): + image_service, _, _ = svc + image_service._prune_lock.acquire() + try: + r = as_admin.post("/api/images/prune", params={"background": True}) + assert r.status_code == 409 + finally: + image_service._prune_lock.release() + + +def test_prune_is_admin_only(svc, as_user): + assert as_user.post("/api/images/prune").status_code == 403 + assert as_user.get("/api/images/prune-status").status_code == 403 diff --git a/backend/version.py b/backend/version.py index 51a2673..f86877c 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.60.1" +APP_VERSION = "0.61.0" diff --git a/frontend/package.json b/frontend/package.json index 4baa8d7..a2f2db3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.60.1", + "version": "0.61.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/images.ts b/frontend/src/api/images.ts index ab3f261..5a41582 100644 --- a/frontend/src/api/images.ts +++ b/frontend/src/api/images.ts @@ -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(base).then((r) => r.data), updates: () => api.get>(`${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(`${base}/prune`, null, { + params: { all: allUnused, background: true }, }) .then((r) => r.data), + pruneStatus: () => api.get(`${base}/prune-status`).then((r) => r.data), }; diff --git a/frontend/src/pages/Images.tsx b/frontend/src/pages/Images.tsx index 8443efd..94cf8c7 100644 --- a/frontend/src/pages/Images.tsx +++ b/frontend/src/pages/Images.tsx @@ -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(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 (
{isAdmin && (
-
)} + {pruning && } + {isLoading ? ( ) : ( @@ -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 ( + +
+ + Pruning {status.all ? "unused" : "dangling"} images… + + {measurable && ( + + {status.done} / {status.total} + + )} +
+
+ {measurable ? ( +
+ ) : ( +
+ )} +
+
+ {detail} + {status.failed > 0 && ( + + {status.failed} failed + + )} +
+ + ); +} + /** What an image group can say for itself: how many, how big, how stale. */ function groupMeta(group: StackGroup): string { const parts = [`${group.items.length} image${group.items.length > 1 ? "s" : ""}`];