Show live progress while pruning volumes, and ask first (0.62.0)
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:
1 parent
faff2be944
commit
57231e4a67
18 files changed
+816
-286
No files matched your search
@@ -11,6 +11,7 @@ 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
|
||||
from services.prune_job import PruneBusy
|
||||
|
||||
router = APIRouter(prefix="/api/images", tags=["images"])
|
||||
|
||||
@@ -69,7 +70,7 @@ def prune(
|
||||
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:
|
||||
except PruneBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
audit(result)
|
||||
return result
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Volume management, NFS/SMB YAML generation, and host path browser."""
|
||||
from __future__ import annotations
|
||||
|
||||
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.user import User
|
||||
from services import audit_service, device_service, volume_service
|
||||
from services.prune_job import PruneBusy
|
||||
|
||||
router = APIRouter(tags=["volumes"])
|
||||
|
||||
@@ -67,17 +68,45 @@ def delete_volume(
|
||||
@router.post("/api/volumes/prune")
|
||||
def prune(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
response: Response,
|
||||
all_unused: bool = Query(False, alias="all"),
|
||||
background: bool = Query(False),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
result = volume_service.prune_volumes()
|
||||
audit_service.record(
|
||||
session, user=user.username, action="volume.prune", target="*",
|
||||
detail=str(result.get("VolumesDeleted")), ip=_ip(request),
|
||||
)
|
||||
"""Remove unused volumes: anonymous ones, or with ``all=true`` named ones
|
||||
too (``-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:
|
||||
# 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="volume.prune", target="*",
|
||||
detail=f"all={all_unused} deleted={result['VolumesDeleted']}", ip=ip,
|
||||
)
|
||||
|
||||
try:
|
||||
if background:
|
||||
response.status_code = 202
|
||||
return volume_service.start_prune(all_unused, on_done=audit)
|
||||
result = volume_service.prune_volumes(all_unused)
|
||||
except PruneBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
audit(result)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/volumes/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 volume_service.prune_status()
|
||||
|
||||
|
||||
@router.post("/api/volumes/generate-yaml")
|
||||
def generate_yaml(
|
||||
spec: dict,
|
||||
|
||||
Reference in new issue
Block a user