From 56450efd82559ec0327c3872cbd337cb8d0778c4 Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 13:07:07 +0000 Subject: [PATCH] Volumes page: on-demand volume sizes (0.18.0) Docker's volume list has no size, so add a "Compute sizes" button that runs `docker system df` (via client.df()) and shows per-volume size in a new Size column. The df walk is expensive (seconds), so results are cached ~60s and loaded on demand instead of on every poll. - volume_service.volume_sizes(force) with a 60s TTL cache; GET /api/volumes/sizes + agent /agent/volumes/sizes + proxy /api/agents/{id}/volumes/sizes. - Frontend: volumesApi.sizes(force, agentId); Volumes page gained a Size column and a Compute sizes button (per host) that triggers the lookup. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +++ backend/agent_app.py | 7 ++++++- backend/main.py | 2 +- backend/routers/agents.py | 13 ++++++++++++ backend/routers/volumes.py | 9 +++++++++ backend/services/volume_service.py | 29 +++++++++++++++++++++++++++ frontend/package.json | 2 +- frontend/src/api/volumes.ts | 4 ++++ frontend/src/pages/Volumes.tsx | 32 ++++++++++++++++++++++++++++-- 9 files changed, 96 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d862c9f..d3c0207 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,9 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. - Admin actions: delete a volume (with an in-use warning + force option) and **Prune unused**; an *Only unused* filter. New agent endpoints `/agent/volumes` (list/delete/prune), proxied at `/api/agents/{id}/volumes/*`. +- **Volume sizes** are loaded on demand via a *Compute sizes* button (runs + `docker system df`, which walks volume contents and can take a few seconds); + results are cached ~60s. Endpoint `GET /api/volumes/sizes` (+ per-agent). - The Volume **Wizard** in the stack editor (bind/named/NFS/SMB/tmpfs YAML generation) is unchanged — the new page is for managing/cleaning up volumes. diff --git a/backend/agent_app.py b/backend/agent_app.py index 9488589..a2de9d0 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -60,7 +60,7 @@ def _map_docker(exc: DockerError): raise HTTPException(status_code=code, detail=exc.detail or exc.error) raise exc # falls through to the global 502 DockerError handler -AGENT_VERSION = "0.17.0" +AGENT_VERSION = "0.18.0" # --------------------------------------------------------------------------- # @@ -447,6 +447,11 @@ def list_volumes() -> list[dict]: return volume_service.list_volumes() +@app.get("/agent/volumes/sizes", dependencies=[Depends(verify_token)]) +def volume_sizes(force: bool = Query(False)) -> dict: + return volume_service.volume_sizes(force=force) + + @app.delete("/agent/volumes/{name}", dependencies=[Depends(verify_token)]) def delete_volume(name: str, force: bool = Query(False)) -> dict: vols = {v["name"]: v for v in volume_service.list_volumes()} diff --git a/backend/main.py b/backend/main.py index eccedf2..fbc5108 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,7 +55,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.17.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.18.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/routers/agents.py b/backend/routers/agents.py index da63a4a..77fd6fb 100644 --- a/backend/routers/agents.py +++ b/backend/routers/agents.py @@ -660,6 +660,19 @@ async def agent_volumes( return await _proxy(session, agent, "GET", "/agent/volumes") or [] +@router.get("/{agent_id}/volumes/sizes") +async def agent_volume_sizes( + agent_id: int, + force: bool = Query(False), + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + agent = _get_or_404(session, agent_id) + return await _proxy( + session, agent, "GET", "/agent/volumes/sizes", params={"force": force} + ) + + @router.post("/{agent_id}/volumes/prune") async def agent_volumes_prune( agent_id: int, diff --git a/backend/routers/volumes.py b/backend/routers/volumes.py index da77cb9..ea44ee1 100644 --- a/backend/routers/volumes.py +++ b/backend/routers/volumes.py @@ -31,6 +31,15 @@ def orphaned(_user: User = Depends(get_current_user)) -> list[dict]: return volume_service.orphaned_volumes() +@router.get("/api/volumes/sizes") +def volume_sizes( + force: bool = Query(False), + _user: User = Depends(get_current_user), +) -> dict: + """Volume sizes in bytes ({name: size|null}). Expensive; cached ~60s.""" + return volume_service.volume_sizes(force=force) + + @router.delete("/api/volumes/{name}") def delete_volume( name: str, diff --git a/backend/services/volume_service.py b/backend/services/volume_service.py index 65ea159..99b71e7 100644 --- a/backend/services/volume_service.py +++ b/backend/services/volume_service.py @@ -1,6 +1,8 @@ """Docker volume management + Compose volume YAML generation.""" from __future__ import annotations +import threading +import time from typing import Literal, Optional import yaml @@ -9,6 +11,33 @@ from docker_client import get_client, safe_call COMPOSE_PROJECT_LABEL = "com.docker.compose.project" +# `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. +_SIZE_TTL = 60.0 +_size_cache: dict = {"at": 0.0, "data": {}} +_size_lock = threading.Lock() + + +def volume_sizes(force: bool = False) -> dict: + """Return {volume_name: size_bytes|None}. Cached (~60s) since it's expensive.""" + now = time.time() + with _size_lock: + if not force and _size_cache["data"] and now - _size_cache["at"] < _SIZE_TTL: + return _size_cache["data"] + + client = get_client() + df = safe_call(client.df) + sizes: dict[str, Optional[int]] = {} + for v in df.get("Volumes") or []: + ud = v.get("UsageData") or {} + size = ud.get("Size") + sizes[v.get("Name")] = size if isinstance(size, int) and size >= 0 else None + + with _size_lock: + _size_cache["at"] = time.time() + _size_cache["data"] = sizes + return sizes + # --------------------------------------------------------------------------- # # Listing / pruning diff --git a/frontend/package.json b/frontend/package.json index 87b22a0..494c98a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.17.0", + "version": "0.18.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/volumes.ts b/frontend/src/api/volumes.ts index bb7afe9..4ca3d84 100644 --- a/frontend/src/api/volumes.ts +++ b/frontend/src/api/volumes.ts @@ -8,6 +8,10 @@ const base = (agentId?: number) => export const volumesApi = { list: (agentId?: number) => api.get(base(agentId)).then((r) => r.data), + sizes: (force = false, agentId?: number) => + api + .get>(`${base(agentId)}/sizes`, { params: { force } }) + .then((r) => r.data), remove: (name: string, force = false, agentId?: number) => api.delete(`${base(agentId)}/${name}?force=${force}`).then((r) => r.data), prune: (agentId?: number) => diff --git a/frontend/src/pages/Volumes.tsx b/frontend/src/pages/Volumes.tsx index b18d5ac..ff2d39a 100644 --- a/frontend/src/pages/Volumes.tsx +++ b/frontend/src/pages/Volumes.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Database, Trash2, Eraser } from "lucide-react"; +import { Database, Trash2, Eraser, HardDrive } from "lucide-react"; import { toast } from "sonner"; import { Badge, Button, Card, Spinner } from "@/components/ui"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; @@ -9,6 +9,7 @@ import { volumesApi } from "@/api/volumes"; import { agentsApi } from "@/api/agents"; import { apiErrorMessage } from "@/api/client"; import { useAuthStore } from "@/store/auth"; +import { formatBytes } from "@/lib/utils"; import type { Agent, VolumeInfo } from "@/types"; export function Volumes() { @@ -52,6 +53,13 @@ function VolumesSection({ refetchInterval: 10000, enabled: online, }); + // Sizes are expensive (docker system df walks volume contents), so they are + // loaded on demand via the "Compute sizes" button rather than polled. + const sizes = useQuery({ + queryKey: ["volume-sizes", agentId ?? "local"], + queryFn: () => volumesApi.sizes(false, agentId), + enabled: false, + }); const invalidate = () => qc.invalidateQueries({ queryKey: ["volumes", agentId ?? "local"] }); const prune = useMutation({ @@ -75,7 +83,7 @@ function VolumesSection({ }); const rows = (data ?? []).filter((v) => (onlyUnused ? !v.in_use : true)); - const colSpan = isAdmin ? 5 : 4; + const colSpan = isAdmin ? 6 : 5; return (
@@ -90,6 +98,16 @@ function VolumesSection({ /> Only unused + {online && ( + + )} {isAdmin && online && (