From 34c5fffa8584177dd765b405c249ab07f529b6f0 Mon Sep 17 00:00:00 2001 From: menzelj Date: Tue, 9 Jun 2026 11:30:10 +0000 Subject: [PATCH] Phase 18: image prune (dangling/unused), local + agent (0.24.0) Co-Authored-By: Claude Opus 4.8 --- backend/agent_app.py | 5 +++ backend/routers/agents.py | 20 ++++++++++ backend/routers/images.py | 26 ++++++++++++- backend/services/image_service.py | 14 +++++++ frontend/src/api/images.ts | 8 ++++ frontend/src/pages/Images.tsx | 62 ++++++++++++++++++++++++++++--- 6 files changed, 128 insertions(+), 7 deletions(-) diff --git a/backend/agent_app.py b/backend/agent_app.py index 62c04d2..68abeaa 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -499,6 +499,11 @@ async def image_check() -> dict: return await update_service.check_all() +@app.post("/agent/images/prune", dependencies=[Depends(verify_token)]) +def image_prune(all_unused: bool = Query(False, alias="all")) -> dict: + return image_service.prune_images(all_unused) + + # --------------------------------------------------------------------------- # # Volumes # --------------------------------------------------------------------------- # diff --git a/backend/routers/agents.py b/backend/routers/agents.py index 38412c0..9be01e3 100644 --- a/backend/routers/agents.py +++ b/backend/routers/agents.py @@ -655,6 +655,26 @@ async def agent_image_check( return result +@router.post("/{agent_id}/images/prune") +async def agent_image_prune( + agent_id: int, + request: Request, + all_unused: bool = Query(False, alias="all"), + 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/images/prune", params={"all": all_unused} + ) + audit_service.record( + session, user=user.username, action="agent.image.prune", target=agent.name, + detail=f"all={all_unused} reclaimed={(result or {}).get('SpaceReclaimed')}", + ip=_ip(request), + ) + return result + + # --------------------------------------------------------------------------- # # Volumes (proxied) # --------------------------------------------------------------------------- # diff --git a/backend/routers/images.py b/backend/routers/images.py index 5624976..deb600c 100644 --- a/backend/routers/images.py +++ b/backend/routers/images.py @@ -1,15 +1,21 @@ """Image listing + update-check endpoints.""" from __future__ import annotations -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query, Request +from sqlmodel import Session from auth import get_current_user, require_admin +from database import get_session from models.user import User -from services import image_service, update_service +from services import audit_service, image_service, update_service router = APIRouter(prefix="/api/images", tags=["images"]) +def _ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + @router.get("") def list_images(_user: User = Depends(get_current_user)) -> list[dict]: return image_service.list_images() @@ -23,3 +29,19 @@ def updates(_user: User = Depends(get_current_user)) -> dict: @router.post("/check") async def check(_user: User = Depends(require_admin)) -> dict: return await update_service.check_all() + + +@router.post("/prune") +def prune( + request: Request, + all_unused: bool = Query(False, alias="all"), + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + result = image_service.prune_images(all_unused) + audit_service.record( + session, user=user.username, action="image.prune", target="*", + detail=f"all={all_unused} reclaimed={result.get('SpaceReclaimed')}", + ip=_ip(request), + ) + return result diff --git a/backend/services/image_service.py b/backend/services/image_service.py index fe94d32..05e2c17 100644 --- a/backend/services/image_service.py +++ b/backend/services/image_service.py @@ -45,3 +45,17 @@ def list_images() -> list[dict]: ) result.sort(key=lambda r: r["tag"]) return result + + +def prune_images(all_unused: bool = False) -> dict: + """Remove unused images. By default only dangling (untagged) images are + removed; ``all_unused=True`` removes every image not referenced by a + container (``docker image prune -a``).""" + client = get_client() + # dangling=false tells the engine to also consider tagged-but-unused images. + filters = {"dangling": False} if all_unused else {"dangling": True} + result = safe_call(client.images.prune, filters=filters) + return { + "ImagesDeleted": result.get("ImagesDeleted") or [], + "SpaceReclaimed": result.get("SpaceReclaimed", 0), + } diff --git a/frontend/src/api/images.ts b/frontend/src/api/images.ts index 2bae1e6..c046754 100644 --- a/frontend/src/api/images.ts +++ b/frontend/src/api/images.ts @@ -28,4 +28,12 @@ export const imagesApi = { api.get>(`${base(agentId)}/updates`).then((r) => r.data), check: (agentId?: number) => api.post>(`${base(agentId)}/check`).then((r) => r.data), + prune: (allUnused: boolean, agentId?: number) => + api + .post<{ ImagesDeleted: unknown[]; SpaceReclaimed: number }>( + `${base(agentId)}/prune`, + null, + { params: { all: allUnused } } + ) + .then((r) => r.data), }; diff --git a/frontend/src/pages/Images.tsx b/frontend/src/pages/Images.tsx index a0b825e..dbf00ce 100644 --- a/frontend/src/pages/Images.tsx +++ b/frontend/src/pages/Images.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle, Eraser } from "lucide-react"; import { toast } from "sonner"; import { Button, Card, Spinner } from "@/components/ui"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { HostHeader } from "@/components/hosts/HostHeader"; import { imagesApi, type ImageRow } from "@/api/images"; import { agentsApi } from "@/api/agents"; @@ -60,6 +61,8 @@ function ImagesSection({ const online = !agent || agent.status === "online"; const qc = useQueryClient(); const [checking, setChecking] = useState(false); + const [pruneOpen, setPruneOpen] = useState(false); + const [pruneAll, setPruneAll] = useState(false); const { data, isLoading } = useQuery({ queryKey: ["images", agentId ?? "local"], queryFn: () => imagesApi.list(agentId), @@ -80,14 +83,35 @@ function ImagesSection({ } }; + const prune = useMutation({ + mutationFn: () => imagesApi.prune(pruneAll, agentId), + onSuccess: (r) => { + const n = r.ImagesDeleted?.length ?? 0; + toast.success( + n + ? `Pruned ${n} image layer(s), freed ${formatBytes(r.SpaceReclaimed)}` + : "No unused images" + ); + setPruneOpen(false); + setPruneAll(false); + qc.invalidateQueries({ queryKey: ["images", agentId ?? "local"] }); + }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + return (
{(showHostLabel || (isAdmin && online)) && ( {isAdmin && online && ( - + <> + + + )} )} @@ -137,6 +161,34 @@ function ImagesSection({ )} + + {pruneOpen && ( + prune.mutate()} + onCancel={() => { + setPruneOpen(false); + setPruneAll(false); + }} + > + + + )}
); }