Phase 13: multi-host networks & images (0.14.0)

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>
This commit is contained in:
menzelj
2026-06-08 11:52:53 +00:00
co-authored by Claude Opus 4.8
parent 4bc0fb8901
commit 012614f5fb
13 changed files with 683 additions and 212 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ export function Dashboard() {
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
const updates = useQuery({ queryKey: ["image-updates"], queryFn: imagesApi.updates, refetchInterval: 60000 });
const updates = useQuery({ queryKey: ["image-updates"], queryFn: () => imagesApi.updates(), refetchInterval: 60000 });
const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length;
+95 -44
View File
@@ -1,12 +1,15 @@
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 { useState } from "react";
import { toast } from "sonner";
import type { Agent } from "@/types";
function UpdateBadge({ row }: { row: ImageRow }) {
const u = row.update;
@@ -27,16 +30,48 @@ function UpdateBadge({ row }: { row: ImageRow }) {
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"], queryFn: imagesApi.list });
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();
await qc.invalidateQueries({ queryKey: ["images"] });
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 });
@@ -45,47 +80,63 @@ export function Images() {
}
};
if (isLoading) return <Spinner />;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">{data?.length ?? 0} image tags</p>
{isAdmin && (
<Button onClick={check} loading={checking}>
<RefreshCw className="h-4 w-4" /> Check updates
</Button>
)}
</div>
<section>
{(showHostLabel || (isAdmin && online)) && (
<HostHeader agent={agent}>
{isAdmin && online && (
<Button onClick={check} loading={checking}>
<RefreshCw className="h-4 w-4" /> Check updates
</Button>
)}
</HostHeader>
)}
<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>
{!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>
))}
</tbody>
</table>
</Card>
</div>
</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>
);
}
+172 -103
View File
@@ -13,29 +13,61 @@ import {
import { toast } from "sonner";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { HostHeader } from "@/components/hosts/HostHeader";
import { networksApi, type NetworkInfo } from "@/api/networks";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import type { Agent } from "@/types";
const selectClass =
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800";
export function Networks() {
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">
<NetworksSection isAdmin={isAdmin} showHostLabel={hasAgents} />
{agents.data?.map((agent) => (
<NetworksSection key={agent.id} agent={agent} isAdmin={isAdmin} showHostLabel />
))}
</div>
);
}
function NetworksSection({
agent,
isAdmin,
showHostLabel,
}: {
agent?: Agent;
isAdmin: boolean;
showHostLabel: boolean;
}) {
const agentId = agent?.id;
const online = !agent || agent.status === "online";
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["networks"],
queryFn: networksApi.list,
queryKey: ["networks", agentId ?? "local"],
queryFn: () => networksApi.list(agentId),
refetchInterval: 10000,
enabled: online,
});
const [creating, setCreating] = useState(false);
const [toDelete, setToDelete] = useState<NetworkInfo | null>(null);
const [expanded, setExpanded] = useState<string | null>(null);
const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] });
const invalidate = () => qc.invalidateQueries({ queryKey: ["networks", agentId ?? "local"] });
const colSpan = isAdmin ? 6 : 5;
const prune = useMutation({
mutationFn: networksApi.prune,
mutationFn: () => networksApi.prune(agentId),
onSuccess: (r) => {
const n = r.NetworksDeleted?.length ?? 0;
toast.success(n ? `Pruned ${n} network(s)` : "No unused networks");
@@ -44,7 +76,7 @@ export function Networks() {
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: (id: string) => networksApi.remove(id),
mutationFn: (id: string) => networksApi.remove(id, agentId),
onSuccess: () => {
toast.success("Network deleted");
setToDelete(null);
@@ -53,102 +85,120 @@ export function Networks() {
onError: (e) => toast.error(apiErrorMessage(e)),
});
if (isLoading) return <Spinner />;
return (
<div className="space-y-4">
{isAdmin && (
<div className="flex flex-wrap justify-end gap-2">
const header = (
<HostHeader agent={agent}>
{isAdmin && online && (
<>
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
<Eraser className="h-4 w-4" /> Prune unused
</Button>
<Button onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" /> Create network
</Button>
</div>
</>
)}
</HostHeader>
);
<Card className="overflow-x-auto p-0">
<table className="w-full text-left text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
<tr>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Driver</th>
<th className="px-4 py-2">Scope</th>
<th className="px-4 py-2">Subnet</th>
<th className="px-4 py-2">In use</th>
{isAdmin && <th className="px-4 py-2"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{data?.map((n) => (
<Fragment key={n.id}>
<tr
onClick={() => setExpanded((e) => (e === n.id ? null : n.id))}
className="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{expanded === n.id ? (
<ChevronDown className="h-4 w-4 text-slate-400" />
) : (
<ChevronRight className="h-4 w-4 text-slate-400" />
)}
<NetworkIcon className="h-4 w-4 text-slate-400" />
<span className="font-medium">{n.name}</span>
{n.is_default && <Badge>default</Badge>}
{n.stack && <Badge>{n.stack}</Badge>}
{n.internal && <span className="text-xs text-slate-400">internal</span>}
</div>
</td>
<td className="px-4 py-2 text-slate-500">{n.driver}</td>
<td className="px-4 py-2 text-slate-500">{n.scope}</td>
<td className="px-4 py-2 font-mono text-xs text-slate-500">{n.subnet ?? "—"}</td>
<td className="px-4 py-2">
{n.in_use ? (
<span title={n.containers.join(", ")} className="text-slate-600 dark:text-slate-300">
{n.containers.length} container{n.containers.length > 1 ? "s" : ""}
</span>
) : (
<span className="text-slate-400"></span>
)}
</td>
{isAdmin && (
<td className="px-4 py-2 text-right">
{!n.is_default && (
<button
title={n.in_use ? "In use — disconnect containers first" : "Delete"}
onClick={(e) => { e.stopPropagation(); setToDelete(n); }}
className="rounded-lg p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Trash2 className="h-4 w-4 text-red-500" />
</button>
)}
</td>
)}
return (
<section>
{(showHostLabel || (isAdmin && online)) && header}
{!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-left text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
<tr>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Driver</th>
<th className="px-4 py-2">Scope</th>
<th className="px-4 py-2">Subnet</th>
<th className="px-4 py-2">In use</th>
{isAdmin && <th className="px-4 py-2"></th>}
</tr>
{expanded === n.id && (
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{data?.map((n) => (
<Fragment key={n.id}>
<tr
onClick={() => setExpanded((e) => (e === n.id ? null : n.id))}
className="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{expanded === n.id ? (
<ChevronDown className="h-4 w-4 text-slate-400" />
) : (
<ChevronRight className="h-4 w-4 text-slate-400" />
)}
<NetworkIcon className="h-4 w-4 text-slate-400" />
<span className="font-medium">{n.name}</span>
{n.is_default && <Badge>default</Badge>}
{n.stack && <Badge>{n.stack}</Badge>}
{n.internal && <span className="text-xs text-slate-400">internal</span>}
</div>
</td>
<td className="px-4 py-2 text-slate-500">{n.driver}</td>
<td className="px-4 py-2 text-slate-500">{n.scope}</td>
<td className="px-4 py-2 font-mono text-xs text-slate-500">{n.subnet ?? "—"}</td>
<td className="px-4 py-2">
{n.in_use ? (
<span title={n.containers.join(", ")} className="text-slate-600 dark:text-slate-300">
{n.containers.length} container{n.containers.length > 1 ? "s" : ""}
</span>
) : (
<span className="text-slate-400"></span>
)}
</td>
{isAdmin && (
<td className="px-4 py-2 text-right">
{!n.is_default && (
<button
title={n.in_use ? "In use — disconnect containers first" : "Delete"}
onClick={(e) => { e.stopPropagation(); setToDelete(n); }}
className="rounded-lg p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Trash2 className="h-4 w-4 text-red-500" />
</button>
)}
</td>
)}
</tr>
{expanded === n.id && (
<tr>
<td colSpan={colSpan} className="bg-slate-50 px-4 py-3 dark:bg-slate-800/40">
<NetworkDetail network={n} isAdmin={isAdmin} agentId={agentId} />
</td>
</tr>
)}
</Fragment>
))}
{data?.length === 0 && (
<tr>
<td colSpan={colSpan} className="bg-slate-50 px-4 py-3 dark:bg-slate-800/40">
<NetworkDetail network={n} isAdmin={isAdmin} />
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-slate-500">
No networks.
</td>
</tr>
)}
</Fragment>
))}
{data?.length === 0 && (
<tr>
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-slate-500">
No networks.
</td>
</tr>
)}
</tbody>
</table>
</Card>
</tbody>
</table>
</Card>
)}
{creating && (
<CreateNetworkDialog onDone={() => { setCreating(false); invalidate(); }} onCancel={() => setCreating(false)} />
<CreateNetworkDialog
agentId={agentId}
onDone={() => { setCreating(false); invalidate(); }}
onCancel={() => setCreating(false)}
/>
)}
{toDelete && (
<ConfirmDialog
@@ -165,30 +215,38 @@ export function Networks() {
onCancel={() => setToDelete(null)}
/>
)}
</div>
</section>
);
}
function NetworkDetail({ network, isAdmin }: { network: NetworkInfo; isAdmin: boolean }) {
function NetworkDetail({
network,
isAdmin,
agentId,
}: {
network: NetworkInfo;
isAdmin: boolean;
agentId?: number;
}) {
const qc = useQueryClient();
const [pick, setPick] = useState("");
const { data, isLoading } = useQuery({
queryKey: ["network-containers", network.id],
queryFn: () => networksApi.containers(network.id),
queryKey: ["network-containers", agentId ?? "local", network.id],
queryFn: () => networksApi.containers(network.id, agentId),
refetchInterval: 10000,
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["network-containers", network.id] });
qc.invalidateQueries({ queryKey: ["networks"] });
qc.invalidateQueries({ queryKey: ["network-containers", agentId ?? "local", network.id] });
qc.invalidateQueries({ queryKey: ["networks", agentId ?? "local"] });
};
const connect = useMutation({
mutationFn: (container: string) => networksApi.connect(network.id, container),
mutationFn: (container: string) => networksApi.connect(network.id, container, undefined, agentId),
onSuccess: () => { toast.success("Container connected"); setPick(""); refresh(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const disconnect = useMutation({
mutationFn: (container: string) => networksApi.disconnect(network.id, container),
mutationFn: (container: string) => networksApi.disconnect(network.id, container, false, agentId),
onSuccess: () => { toast.success("Container disconnected"); refresh(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
@@ -277,7 +335,15 @@ function Meta({ label, value, mono }: { label: string; value: string; mono?: boo
);
}
function CreateNetworkDialog({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
function CreateNetworkDialog({
agentId,
onDone,
onCancel,
}: {
agentId?: number;
onDone: () => void;
onCancel: () => void;
}) {
const [form, setForm] = useState({
name: "",
driver: "bridge",
@@ -290,14 +356,17 @@ function CreateNetworkDialog({ onDone, onCancel }: { onDone: () => void; onCance
const create = useMutation({
mutationFn: () =>
networksApi.create({
name: form.name,
driver: form.driver,
subnet: form.subnet.trim() || null,
gateway: form.gateway.trim() || null,
internal: form.internal,
attachable: form.attachable,
}),
networksApi.create(
{
name: form.name,
driver: form.driver,
subnet: form.subnet.trim() || null,
gateway: form.gateway.trim() || null,
internal: form.internal,
attachable: form.attachable,
},
agentId
),
onSuccess: () => { toast.success("Network created"); onDone(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});