From 8f6e354b3fd2a99ba4dc25b5603cf973b2a47dcc Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 12:49:59 +0000 Subject: [PATCH] Phase 16: volumes page, multi-host (0.17.0) Adds a dedicated Volumes page (sidebar) with per-host sections (local + each online agent), matching the Networks/Images layout. Lists volumes with driver, owning stack, in-use containers and mountpoint; admins can delete (with an in-use warning + force option) and prune unused, plus an "only unused" filter. - agent_app.py: /agent/volumes (list/delete with in-use 409 guard/prune) reusing volume_service. - routers/agents.py: proxy routes /api/agents/{id}/volumes/* (audit-logged delete/prune). - Frontend: volumesApi list/remove/prune take an optional agentId; new pages/Volumes.tsx (VolumesSection per host) + sidebar entry + /volumes route. The volume wizard (generate-yaml/host paths) stays local and unchanged. Co-Authored-By: Claude Opus 4.8 --- README.md | 13 +- backend/agent_app.py | 33 +++- backend/main.py | 2 +- backend/routers/agents.py | 51 +++++ frontend/package.json | 2 +- frontend/src/App.tsx | 2 + frontend/src/api/volumes.ts | 16 +- frontend/src/components/layout/Sidebar.tsx | 2 + frontend/src/pages/Volumes.tsx | 206 +++++++++++++++++++++ 9 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 frontend/src/pages/Volumes.tsx diff --git a/README.md b/README.md index ccab944..d862c9f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX & > network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks & > images) + Phase 14 (Multi-host file browser) + Phase 15 (Dashboard stack -> resource usage) complete. +> resource usage) + Phase 16 (Volumes page, multi-host) complete. ## What works today (Phase 1) @@ -147,6 +147,17 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. container or connect any container on the host (`POST /api/networks/{id}/connect` / `/disconnect`). +### Phase 16 — Volumes page (multi-host) + +- **New Volumes page** (sidebar) with per-host sections (local + each online + agent, like Networks/Images). Lists Docker volumes with driver, owning stack, + in-use containers and mountpoint. +- 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/*`. +- 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. + ### Phase 15 — Dashboard stack resource usage - **The dashboard now lists stacks in a table** (status, services) with live diff --git a/backend/agent_app.py b/backend/agent_app.py index 5093471..9488589 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -44,6 +44,7 @@ from services import ( image_service, network_service, update_service, + volume_service, ) logger = logging.getLogger("stackpilot.agent") @@ -59,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.16.0" +AGENT_VERSION = "0.17.0" # --------------------------------------------------------------------------- # @@ -436,6 +437,36 @@ async def image_check() -> dict: return await update_service.check_all() +# --------------------------------------------------------------------------- # +# Volumes +# --------------------------------------------------------------------------- # + + +@app.get("/agent/volumes", dependencies=[Depends(verify_token)]) +def list_volumes() -> list[dict]: + return volume_service.list_volumes() + + +@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()} + if name in vols and vols[name]["in_use"] and not force: + raise HTTPException( + status_code=409, + detail={ + "error": "volume_in_use", + "detail": f"Volume '{name}' is used by: {', '.join(vols[name]['used_by'])}", + }, + ) + volume_service.remove_volume(name, force=force) + return {"ok": True} + + +@app.post("/agent/volumes/prune", dependencies=[Depends(verify_token)]) +def prune_volumes() -> dict: + return volume_service.prune_volumes() + + # --------------------------------------------------------------------------- # # File browser (sandboxed by this agent's ALLOWED_BROWSE_ROOTS/HOST_ROOT_PREFIX) # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index c5a4535..eccedf2 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.16.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.17.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/routers/agents.py b/backend/routers/agents.py index 1c7a2c2..da63a4a 100644 --- a/backend/routers/agents.py +++ b/backend/routers/agents.py @@ -645,6 +645,57 @@ async def agent_image_check( return result +# --------------------------------------------------------------------------- # +# Volumes (proxied) +# --------------------------------------------------------------------------- # + + +@router.get("/{agent_id}/volumes") +async def agent_volumes( + agent_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[dict]: + agent = _get_or_404(session, agent_id) + return await _proxy(session, agent, "GET", "/agent/volumes") or [] + + +@router.post("/{agent_id}/volumes/prune") +async def agent_volumes_prune( + agent_id: int, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "POST", "/agent/volumes/prune") + audit_service.record( + session, user=user.username, action="agent.volume.prune", target=agent.name, + detail=str(result.get("VolumesDeleted") or []), ip=_ip(request), + ) + return result + + +@router.delete("/{agent_id}/volumes/{name}") +async def agent_volume_delete( + agent_id: int, + name: str, + request: Request, + force: bool = Query(False), + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy( + session, agent, "DELETE", f"/agent/volumes/{name}", params={"force": force} + ) + audit_service.record( + session, user=user.username, action="agent.volume.delete", + target=f"{agent.name}/{name}", ip=_ip(request), + ) + return result + + # --------------------------------------------------------------------------- # # File browser (proxied) # --------------------------------------------------------------------------- # diff --git a/frontend/package.json b/frontend/package.json index 184f7e3..87b22a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.16.0", + "version": "0.17.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 61a0faf..4517bd7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { StackEditor } from "@/pages/StackEditor"; import { RemoteStackDetail } from "@/pages/RemoteStackDetail"; import { Images } from "@/pages/Images"; import { Files } from "@/pages/Files"; +import { Volumes } from "@/pages/Volumes"; import { Templates } from "@/pages/Templates"; import { Settings } from "@/pages/Settings"; import { Audit } from "@/pages/Audit"; @@ -48,6 +49,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/volumes.ts b/frontend/src/api/volumes.ts index bbab39b..bb7afe9 100644 --- a/frontend/src/api/volumes.ts +++ b/frontend/src/api/volumes.ts @@ -1,13 +1,17 @@ import api from "./client"; import type { HostPathResult, VolumeInfo } from "@/types"; +// Base path for the local host or, when agentId is given, a remote agent. +const base = (agentId?: number) => + agentId != null ? `/api/agents/${agentId}/volumes` : "/api/volumes"; + export const volumesApi = { - list: () => api.get("/api/volumes").then((r) => r.data), - orphaned: () => - api.get("/api/volumes/orphaned").then((r) => r.data), - remove: (name: string, force = false) => - api.delete(`/api/volumes/${name}?force=${force}`).then((r) => r.data), - prune: () => api.post("/api/volumes/prune").then((r) => r.data), + list: (agentId?: number) => + api.get(base(agentId)).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) => + api.post<{ VolumesDeleted: string[] | null }>(`${base(agentId)}/prune`).then((r) => r.data), generateYaml: (spec: Record) => api .post<{ yaml: string }>("/api/volumes/generate-yaml", spec) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index df39b3f..89e75f2 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { Boxes, Network, Image, + Database, FolderTree, LayoutTemplate, ScrollText, @@ -23,6 +24,7 @@ const nav = [ { to: "/stacks", label: "Stacks", icon: Boxes }, { to: "/networks", label: "Networks", icon: Network }, { to: "/images", label: "Images", icon: Image }, + { to: "/volumes", label: "Volumes", icon: Database }, { to: "/files", label: "Files", icon: FolderTree }, { to: "/templates", label: "Templates", icon: LayoutTemplate }, { to: "/audit", label: "Audit log", icon: ScrollText }, diff --git a/frontend/src/pages/Volumes.tsx b/frontend/src/pages/Volumes.tsx new file mode 100644 index 0000000..b18d5ac --- /dev/null +++ b/frontend/src/pages/Volumes.tsx @@ -0,0 +1,206 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Database, Trash2, Eraser } from "lucide-react"; +import { toast } from "sonner"; +import { Badge, Button, Card, Spinner } from "@/components/ui"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; +import { HostHeader } from "@/components/hosts/HostHeader"; +import { volumesApi } from "@/api/volumes"; +import { agentsApi } from "@/api/agents"; +import { apiErrorMessage } from "@/api/client"; +import { useAuthStore } from "@/store/auth"; +import type { Agent, VolumeInfo } from "@/types"; + +export function Volumes() { + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const agents = useQuery({ + queryKey: ["agents"], + queryFn: () => agentsApi.list(), + refetchInterval: 15000, + }); + const hasAgents = (agents.data?.length ?? 0) > 0; + + return ( +
+ + {agents.data?.map((agent) => ( + + ))} +
+ ); +} + +function VolumesSection({ + agent, + isAdmin, + showHostLabel, +}: { + agent?: Agent; + isAdmin: boolean; + showHostLabel: boolean; +}) { + const agentId = agent?.id; + const online = !agent || agent.status === "online"; + const qc = useQueryClient(); + const [onlyUnused, setOnlyUnused] = useState(false); + const [toDelete, setToDelete] = useState(null); + const [force, setForce] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: ["volumes", agentId ?? "local"], + queryFn: () => volumesApi.list(agentId), + refetchInterval: 10000, + enabled: online, + }); + const invalidate = () => qc.invalidateQueries({ queryKey: ["volumes", agentId ?? "local"] }); + + const prune = useMutation({ + mutationFn: () => volumesApi.prune(agentId), + onSuccess: (r) => { + const n = r.VolumesDeleted?.length ?? 0; + toast.success(n ? `Pruned ${n} volume(s)` : "No unused volumes"); + invalidate(); + }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + const remove = useMutation({ + mutationFn: (v: VolumeInfo) => volumesApi.remove(v.name, force, agentId), + onSuccess: () => { + toast.success("Volume deleted"); + setToDelete(null); + setForce(false); + invalidate(); + }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + const rows = (data ?? []).filter((v) => (onlyUnused ? !v.in_use : true)); + const colSpan = isAdmin ? 5 : 4; + + return ( +
+ {(showHostLabel || (isAdmin && online)) && ( + + + {isAdmin && online && ( + + )} + + )} + + {!online ? ( + +

+ Host is {agent?.status}. Check it under Settings → Remote hosts. +

+
+ ) : isLoading ? ( + + ) : ( + + + + + + + + + {isAdmin && } + + + + {rows.map((v) => ( + + + + + + {isAdmin && ( + + )} + + ))} + {rows.length === 0 && ( + + + + )} + +
NameDriverIn useMountpoint
+
+ + {v.name} + {v.stack && {v.stack}} +
+
{v.driver} + {v.in_use ? ( + + {v.used_by.length} container{v.used_by.length > 1 ? "s" : ""} + + ) : ( + + )} + + + {v.mountpoint} + + + +
+ {onlyUnused ? "No unused volumes." : "No volumes."} +
+
+ )} + + {toDelete && ( + remove.mutate(toDelete)} + onCancel={() => { + setToDelete(null); + setForce(false); + }} + > + {toDelete.in_use && ( + + )} + + )} +
+ ); +}