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

+37 -123
View File
@@ -2,11 +2,11 @@
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
from services.prune_job import PruneJob, PrunePlan
logger = logging.getLogger(__name__)
@@ -57,31 +57,11 @@ def list_images() -> list[dict]:
# 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."""
_prune = PruneJob("image")
def prune_status() -> dict:
return {**_pruning, "errors": list(_pruning["errors"])}
return _prune.status()
def _layers_size(client) -> Optional[int]:
@@ -131,65 +111,38 @@ def _remove(client, image: dict) -> list[dict]:
return entries
def _begin(all_unused: bool) -> tuple:
def _plan(all_unused: bool) -> PrunePlan:
client = get_long_client()
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,
}
measured: dict = {}
def prepare() -> None:
measured["before"] = _layers_size(client)
def remove(image: dict) -> tuple[list, bool]:
entries = _remove(client, image)
return entries, any("Deleted" in e for e in entries)
def finish(entries: list) -> tuple[dict, Optional[int]]:
# 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}
sweep = safe_call(client.images.prune, filters=filters)
entries = entries + (sweep.get("ImagesDeleted") or [])
before, after = measured.get("before"), _layers_size(client)
if before is not None and after is not None:
reclaimed = max(before - after, 0)
else:
reclaimed = sweep.get("SpaceReclaimed", 0)
return {"ImagesDeleted": entries, "SpaceReclaimed": reclaimed}, reclaimed
return PrunePlan(
all=all_unused,
items=_candidates(client, all_unused),
label=_label,
remove=remove,
finish=finish,
prepare=prepare,
)
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}
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:
@@ -199,51 +152,12 @@ def prune_images(all_unused: bool = False) -> dict:
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)
return _prune.run(lambda: _plan(all_unused))
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()
"""Start a prune in the background and return its initial status; the UI
polls prune_status(). ``on_done`` gets what prune_images() would return."""
return _prune.start(lambda: _plan(all_unused), on_done)