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

+8 -7
View File
@@ -112,10 +112,9 @@ def svc(monkeypatch):
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()
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):
@@ -167,7 +166,9 @@ def test_background_prune_reports_progress_and_blocks_a_second_one(svc):
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):
from services.prune_job import PruneBusy
with pytest.raises(PruneBusy):
image_service.start_prune(True)
api.gate.set()
@@ -194,7 +195,7 @@ def test_background_prune_error_lands_in_the_status(svc):
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()
assert not image_service._prune._lock.locked()
def test_endpoint_waits_by_default_and_is_audited(svc, as_admin):
@@ -229,12 +230,12 @@ def test_endpoint_background_returns_202_and_status_is_pollable(svc, as_admin):
def test_endpoint_rejects_a_second_prune(svc, as_admin):
image_service, _, _ = svc
image_service._prune_lock.acquire()
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()
image_service._prune._lock.release()
def test_prune_is_admin_only(svc, as_user):