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

+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)
# --------------------------------------------------------------------------- #