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)
+170
View File
@@ -0,0 +1,170 @@
"""Prunes with visible progress, shared by images and volumes.
``docker image prune`` / ``docker volume prune`` are single blocking calls that
report nothing until they are done, which on a host with a lot to delete means
minutes of a spinner and, past the proxy's read timeout, an error while the
daemon carries on. Instead, a service lists what the prune would remove and a
PruneJob deletes it one item at a time, publishing how far it is so the UI can
poll it. One prune of each kind runs at a time.
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass
from typing import Any, Callable, Optional
from docker_client import DockerError
logger = logging.getLogger(__name__)
_MAX_ERRORS = 10
class PruneBusy(Exception):
"""A prune of this kind is already running."""
@dataclass
class PrunePlan:
"""What one prune will do, built by the owning service.
``remove`` deletes one item and returns the entries it contributes to the
result plus whether the item is actually gone (removing one of several
image tags is not). It raises DockerError to mark just that item failed.
``prepare`` runs before the first removal (e.g. measuring disk usage) and
``finish`` after the last; it gets every collected entry and returns the
API result together with the space reclaimed, or None if unknown.
"""
all: bool
items: list
label: Callable[[Any], str]
remove: Callable[[Any], tuple[list, bool]]
finish: Callable[[list], tuple[dict, Optional[int]]]
prepare: Optional[Callable[[], None]] = None
class PruneJob:
def __init__(self, what: str):
self._what = what
self._lock = threading.Lock()
self._status: dict = {
"active": False,
"run": 0,
"all": False,
# preparing -> removing -> finishing, so the UI can say what the
# parts without a count are doing.
"phase": None,
"total": 0,
"done": 0,
"current": None,
"deleted": 0,
"failed": 0,
"errors": [],
"reclaimed": None,
"error": None,
}
def status(self) -> dict:
return {**self._status, "errors": list(self._status["errors"])}
def run(self, build: Callable[[], PrunePlan]) -> dict:
"""Prune and wait for the result."""
plan = self._begin(build)
error = None
try:
return self._execute(plan)
except DockerError as exc:
error = exc.detail or exc.error
raise
finally:
self._finish(error)
def start(
self,
build: Callable[[], PrunePlan],
on_done: Optional[Callable[[dict], None]] = None,
) -> dict:
"""Prune in a background thread and return the initial status.
The plan is still built here, so a daemon that cannot even list the
candidates fails the request instead of a status nobody polls yet.
``on_done`` gets the result from the worker thread.
"""
plan = self._begin(build)
def work() -> None:
error = None
result = None
try:
result = self._execute(plan)
except Exception as exc: # noqa: BLE001 - reported through the status
logger.warning("%s prune failed: %s", self._what.capitalize(), exc)
error = exc.detail if isinstance(exc, DockerError) else str(exc)
finally:
self._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("%s prune follow-up failed: %s", self._what, exc)
threading.Thread(target=work, name=f"{self._what}-prune", daemon=True).start()
return self.status()
def _begin(self, build: Callable[[], PrunePlan]) -> PrunePlan:
if not self._lock.acquire(blocking=False):
raise PruneBusy(f"{self._what.capitalize()} prune is already running")
try:
plan = build()
except BaseException:
self._lock.release()
raise
self._status.update(
{
"active": True,
"run": self._status["run"] + 1,
"all": plan.all,
"phase": "preparing",
"total": len(plan.items),
"done": 0,
"current": None,
"deleted": 0,
"failed": 0,
"errors": [],
"reclaimed": None,
"error": None,
}
)
return plan
def _execute(self, plan: PrunePlan) -> dict:
status = self._status
if plan.prepare:
plan.prepare()
status["phase"] = "removing"
entries: list = []
for item in plan.items:
status["current"] = plan.label(item)
try:
produced, gone = plan.remove(item)
entries.extend(produced)
if gone:
status["deleted"] += 1
except DockerError as exc:
# One item must not stop the rest, e.g. something started
# using it since the candidates were listed.
logger.warning("Prune: could not remove %s: %s", status["current"], exc)
status["failed"] += 1
if len(status["errors"]) < _MAX_ERRORS:
status["errors"].append(f"{status['current']}: {exc.detail or exc.error}")
status["done"] += 1
status.update({"phase": "finishing", "current": None})
result, reclaimed = plan.finish(entries)
status["reclaimed"] = reclaimed
return result
def _finish(self, error: Optional[str]) -> None:
self._status.update({"active": False, "phase": None, "current": None, "error": error})
self._lock.release()
+102 -4
View File
@@ -1,15 +1,22 @@
"""Docker volume management + Compose volume YAML generation."""
from __future__ import annotations
import logging
import threading
import time
from typing import Literal, Optional
from typing import Callable, Literal, Optional
import yaml
from docker_client import get_client, get_long_client, safe_call
from services.prune_job import PruneJob, PrunePlan
logger = logging.getLogger(__name__)
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
#: Set by the daemon (Engine 23+) on volumes created without a name. A plain
#: `docker volume prune` only removes these; `-a` removes named ones too.
ANONYMOUS_LABEL = "com.docker.volume.anonymous"
# `docker system df -v` walks every volume's contents, so it can take many
# seconds. Cache the result so the (polled) UI and repeated requests reuse it.
@@ -25,7 +32,9 @@ def volume_sizes(force: bool = False) -> dict:
if not force and _size_cache["data"] and now - _size_cache["at"] < _SIZE_TTL:
return _size_cache["data"]
client = get_client()
# The slow call this cache exists for; the 60s client would time out on a
# host with large volumes.
client = get_long_client()
df = safe_call(client.df)
sizes: dict[str, Optional[int]] = {}
for v in df.get("Volumes") or []:
@@ -85,9 +94,98 @@ def remove_volume(name: str, force: bool = False) -> None:
safe_call(volume.remove, force=force)
def prune_volumes() -> dict:
_prune = PruneJob("volume")
def prune_status() -> dict:
return _prune.status()
def _candidates(client, all_unused: bool) -> list[dict]:
"""The volumes ``docker volume prune [-a]`` would remove.
Unused means no container, running or stopped, mounts it. Without -a only
anonymous volumes qualify. Volumes created before Engine 23 carry no
anonymous label and so are only removed with -a, which errs on the side of
keeping data.
"""
in_use = set()
for c in safe_call(client.api.containers, all=True):
for mount in c.get("Mounts") or []:
if mount.get("Type") == "volume" and mount.get("Name"):
in_use.add(mount["Name"])
volumes = (safe_call(client.api.volumes) or {}).get("Volumes") or []
return [
v
for v in volumes
if v["Name"] not in in_use
# Cluster (swarm) volumes are not the local daemon's to prune.
and v.get("Scope", "local") == "local"
and (all_unused or ANONYMOUS_LABEL in (v.get("Labels") or {}))
]
def _label(volume: dict) -> str:
name = volume["Name"]
# Anonymous volumes are 64 hex characters; the first 12 identify them.
return name[:12] if ANONYMOUS_LABEL in (volume.get("Labels") or {}) else name
def _plan(all_unused: bool) -> PrunePlan:
client = get_long_client()
return safe_call(client.volumes.prune)
measured: dict = {}
def prepare() -> None:
# Volumes share no data, so the freed space is simply the sum of what
# was deleted, provided their sizes are known up front. Unused volumes
# cannot change, so the cached figures are good enough.
try:
measured["sizes"] = volume_sizes()
except Exception as exc: # noqa: BLE001 - the size is a nicety, not the job
logger.warning("Could not measure volume sizes: %s", exc)
def remove(volume: dict) -> tuple[list, bool]:
# No force: a volume mounted since the listing makes the daemon refuse.
safe_call(client.api.remove_volume, volume["Name"])
return [volume["Name"]], True
def finish(names: list) -> tuple[dict, Optional[int]]:
# No closing daemon prune as for images: nothing is left behind, and
# on a pre-23 daemon an unfiltered one would delete named volumes too.
sizes = measured.get("sizes")
reclaimed = (
sum(sizes.get(name) or 0 for name in names) if sizes is not None else None
)
with _size_lock:
_size_cache["at"] = 0.0 # deleted volumes must not linger in it
return {"VolumesDeleted": names, "SpaceReclaimed": reclaimed or 0}, reclaimed
return PrunePlan(
all=all_unused,
items=_candidates(client, all_unused),
label=_label,
remove=remove,
finish=finish,
prepare=prepare,
)
def prune_volumes(all_unused: bool = False) -> dict:
"""Remove unused volumes and wait for the result.
By default only anonymous volumes; ``all_unused=True`` also removes named
ones (``docker volume prune -a``). Progress is visible through
prune_status() while it runs.
"""
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; the UI
polls prune_status(). ``on_done`` gets what prune_volumes() would return."""
return _prune.start(lambda: _plan(all_unused), on_done)
# --------------------------------------------------------------------------- #