Phase 18: image prune (dangling/unused), local + agent (0.24.0)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d46a6c3576
commit
34c5fffa85
@@ -499,6 +499,11 @@ async def image_check() -> dict:
|
|||||||
return await update_service.check_all()
|
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
|
# Volumes
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -655,6 +655,26 @@ async def agent_image_check(
|
|||||||
return result
|
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)
|
# Volumes (proxied)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
"""Image listing + update-check endpoints."""
|
"""Image listing + update-check endpoints."""
|
||||||
from __future__ import annotations
|
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 auth import get_current_user, require_admin
|
||||||
|
from database import get_session
|
||||||
from models.user import User
|
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"])
|
router = APIRouter(prefix="/api/images", tags=["images"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ip(request: Request) -> str:
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_images(_user: User = Depends(get_current_user)) -> list[dict]:
|
def list_images(_user: User = Depends(get_current_user)) -> list[dict]:
|
||||||
return image_service.list_images()
|
return image_service.list_images()
|
||||||
@@ -23,3 +29,19 @@ def updates(_user: User = Depends(get_current_user)) -> dict:
|
|||||||
@router.post("/check")
|
@router.post("/check")
|
||||||
async def check(_user: User = Depends(require_admin)) -> dict:
|
async def check(_user: User = Depends(require_admin)) -> dict:
|
||||||
return await update_service.check_all()
|
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
|
||||||
|
|||||||
@@ -45,3 +45,17 @@ def list_images() -> list[dict]:
|
|||||||
)
|
)
|
||||||
result.sort(key=lambda r: r["tag"])
|
result.sort(key=lambda r: r["tag"])
|
||||||
return result
|
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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,4 +28,12 @@ export const imagesApi = {
|
|||||||
api.get<Record<string, UpdateStatus>>(`${base(agentId)}/updates`).then((r) => r.data),
|
api.get<Record<string, UpdateStatus>>(`${base(agentId)}/updates`).then((r) => r.data),
|
||||||
check: (agentId?: number) =>
|
check: (agentId?: number) =>
|
||||||
api.post<Record<string, UpdateStatus>>(`${base(agentId)}/check`).then((r) => r.data),
|
api.post<Record<string, UpdateStatus>>(`${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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle } from "lucide-react";
|
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle, Eraser } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button, Card, Spinner } from "@/components/ui";
|
import { Button, Card, Spinner } from "@/components/ui";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
import { HostHeader } from "@/components/hosts/HostHeader";
|
import { HostHeader } from "@/components/hosts/HostHeader";
|
||||||
import { imagesApi, type ImageRow } from "@/api/images";
|
import { imagesApi, type ImageRow } from "@/api/images";
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
@@ -60,6 +61,8 @@ function ImagesSection({
|
|||||||
const online = !agent || agent.status === "online";
|
const online = !agent || agent.status === "online";
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [pruneOpen, setPruneOpen] = useState(false);
|
||||||
|
const [pruneAll, setPruneAll] = useState(false);
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["images", agentId ?? "local"],
|
queryKey: ["images", agentId ?? "local"],
|
||||||
queryFn: () => imagesApi.list(agentId),
|
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 (
|
return (
|
||||||
<section>
|
<section>
|
||||||
{(showHostLabel || (isAdmin && online)) && (
|
{(showHostLabel || (isAdmin && online)) && (
|
||||||
<HostHeader agent={agent}>
|
<HostHeader agent={agent}>
|
||||||
{isAdmin && online && (
|
{isAdmin && online && (
|
||||||
<Button onClick={check} loading={checking}>
|
<>
|
||||||
<RefreshCw className="h-4 w-4" /> Check updates
|
<Button variant="outline" onClick={() => setPruneOpen(true)}>
|
||||||
</Button>
|
<Eraser className="h-4 w-4" /> Prune
|
||||||
|
</Button>
|
||||||
|
<Button onClick={check} loading={checking}>
|
||||||
|
<RefreshCw className="h-4 w-4" /> Check updates
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</HostHeader>
|
</HostHeader>
|
||||||
)}
|
)}
|
||||||
@@ -137,6 +161,34 @@ function ImagesSection({
|
|||||||
</table>
|
</table>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pruneOpen && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Prune images"
|
||||||
|
message={
|
||||||
|
pruneAll
|
||||||
|
? "Remove ALL images not used by any container (including tagged images). They will be re-pulled on next deploy."
|
||||||
|
: "Remove dangling (untagged) image layers. Images in use are kept."
|
||||||
|
}
|
||||||
|
confirmLabel="Prune images"
|
||||||
|
danger
|
||||||
|
busy={prune.isPending}
|
||||||
|
onConfirm={() => prune.mutate()}
|
||||||
|
onCancel={() => {
|
||||||
|
setPruneOpen(false);
|
||||||
|
setPruneAll(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pruneAll}
|
||||||
|
onChange={(e) => setPruneAll(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Also remove unused tagged images (<code>-a</code>)
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user