Networks and Images are now per-host, rendered as a section for the local host
plus one per registered agent (like the Stacks page).
- agent_app.py: new /agent/networks (list/inspect/containers/connect/disconnect/
create/delete/prune) and /agent/images (list/updates/check), reusing
network_service and a new image_service; DockerError mapped to HTTP status
(forbidden -> 400 so the proxy doesn't treat it as a token failure).
- routers/agents.py: proxy routes at /api/agents/{id}/networks/* and
/api/agents/{id}/images/*, audit-logging mutations.
- services/image_service.py: extracted the image-listing logic so the central
router and the agent share it.
- Frontend: networksApi/imagesApi take an optional agentId; Networks/Images
pages render NetworksSection/ImagesSection per host with a shared HostHeader.
Remote "Prune unused" networks resolves the address-pool-exhaustion deploy
error from the UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
143 lines
4.8 KiB
TypeScript
143 lines
4.8 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button, Card, Spinner } from "@/components/ui";
|
|
import { HostHeader } from "@/components/hosts/HostHeader";
|
|
import { imagesApi, type ImageRow } from "@/api/images";
|
|
import { agentsApi } from "@/api/agents";
|
|
import { apiErrorMessage } from "@/api/client";
|
|
import { formatBytes, relativeTime } from "@/lib/utils";
|
|
import { useAuthStore } from "@/store/auth";
|
|
import type { Agent } from "@/types";
|
|
|
|
function UpdateBadge({ row }: { row: ImageRow }) {
|
|
const u = row.update;
|
|
if (!u) return <span className="inline-flex items-center gap-1 text-xs text-slate-400"><HelpCircle className="h-3.5 w-3.5" /> not checked</span>;
|
|
if (u.error) return <span className="text-xs text-amber-500">⚠ {u.error}</span>;
|
|
if (u.update_available)
|
|
return (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
|
|
<ArrowUpCircle className="h-3.5 w-3.5" /> update available
|
|
</span>
|
|
);
|
|
return (
|
|
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
|
<CheckCircle2 className="h-3.5 w-3.5" /> up to date
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function Images() {
|
|
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 (
|
|
<div className="space-y-8">
|
|
<ImagesSection isAdmin={isAdmin} showHostLabel={hasAgents} />
|
|
{agents.data?.map((agent) => (
|
|
<ImagesSection key={agent.id} agent={agent} isAdmin={isAdmin} showHostLabel />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ImagesSection({
|
|
agent,
|
|
isAdmin,
|
|
showHostLabel,
|
|
}: {
|
|
agent?: Agent;
|
|
isAdmin: boolean;
|
|
showHostLabel: boolean;
|
|
}) {
|
|
const agentId = agent?.id;
|
|
const online = !agent || agent.status === "online";
|
|
const qc = useQueryClient();
|
|
const [checking, setChecking] = useState(false);
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["images", agentId ?? "local"],
|
|
queryFn: () => imagesApi.list(agentId),
|
|
enabled: online,
|
|
});
|
|
|
|
const check = async () => {
|
|
setChecking(true);
|
|
const t = toast.loading("Checking for updates…");
|
|
try {
|
|
await imagesApi.check(agentId);
|
|
await qc.invalidateQueries({ queryKey: ["images", agentId ?? "local"] });
|
|
toast.success("Update check complete", { id: t });
|
|
} catch (e) {
|
|
toast.error(apiErrorMessage(e), { id: t });
|
|
} finally {
|
|
setChecking(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section>
|
|
{(showHostLabel || (isAdmin && online)) && (
|
|
<HostHeader agent={agent}>
|
|
{isAdmin && online && (
|
|
<Button onClick={check} loading={checking}>
|
|
<RefreshCw className="h-4 w-4" /> Check updates
|
|
</Button>
|
|
)}
|
|
</HostHeader>
|
|
)}
|
|
|
|
{!online ? (
|
|
<Card>
|
|
<p className="text-sm text-slate-500">
|
|
Host is {agent?.status}. Check it under Settings → Remote hosts.
|
|
</p>
|
|
</Card>
|
|
) : isLoading ? (
|
|
<Spinner />
|
|
) : (
|
|
<Card className="overflow-x-auto p-0">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-slate-200 text-left text-xs text-slate-500 dark:border-slate-700">
|
|
<tr>
|
|
<th className="p-3">Image</th>
|
|
<th className="p-3">Used by</th>
|
|
<th className="p-3">Size</th>
|
|
<th className="p-3">Created</th>
|
|
<th className="p-3">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data?.map((row) => (
|
|
<tr key={row.tag} className="border-b border-slate-100 dark:border-slate-700/50">
|
|
<td className="p-3 font-mono text-xs">{row.tag}</td>
|
|
<td className="p-3 text-xs text-slate-500">
|
|
{row.stacks.length ? row.stacks.join(", ") : "—"}
|
|
</td>
|
|
<td className="p-3 text-xs">{formatBytes(row.size)}</td>
|
|
<td className="p-3 text-xs text-slate-500">
|
|
{row.created ? relativeTime(row.created) : "—"}
|
|
</td>
|
|
<td className="p-3"><UpdateBadge row={row} /></td>
|
|
</tr>
|
|
))}
|
|
{data?.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="p-6 text-center text-sm text-slate-500">
|
|
No images.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|