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>
426 lines
16 KiB
TypeScript
426 lines
16 KiB
TypeScript
import { Fragment, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Network as NetworkIcon,
|
|
Plus,
|
|
Trash2,
|
|
Eraser,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Link2,
|
|
Unplug,
|
|
} from "lucide-react";
|
|
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", 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", agentId ?? "local"] });
|
|
const colSpan = isAdmin ? 6 : 5;
|
|
|
|
const prune = useMutation({
|
|
mutationFn: () => networksApi.prune(agentId),
|
|
onSuccess: (r) => {
|
|
const n = r.NetworksDeleted?.length ?? 0;
|
|
toast.success(n ? `Pruned ${n} network(s)` : "No unused networks");
|
|
invalidate();
|
|
},
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: (id: string) => networksApi.remove(id, agentId),
|
|
onSuccess: () => {
|
|
toast.success("Network deleted");
|
|
setToDelete(null);
|
|
invalidate();
|
|
},
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
|
|
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>
|
|
</>
|
|
)}
|
|
</HostHeader>
|
|
);
|
|
|
|
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>
|
|
</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="px-4 py-8 text-center text-sm text-slate-500">
|
|
No networks.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
)}
|
|
|
|
{creating && (
|
|
<CreateNetworkDialog
|
|
agentId={agentId}
|
|
onDone={() => { setCreating(false); invalidate(); }}
|
|
onCancel={() => setCreating(false)}
|
|
/>
|
|
)}
|
|
{toDelete && (
|
|
<ConfirmDialog
|
|
title={`Delete network “${toDelete.name}”?`}
|
|
message={
|
|
toDelete.in_use
|
|
? "This network is in use; Docker will refuse unless containers are disconnected first."
|
|
: "This cannot be undone."
|
|
}
|
|
confirmLabel="Delete network"
|
|
danger
|
|
busy={remove.isPending}
|
|
onConfirm={() => remove.mutate(toDelete.id)}
|
|
onCancel={() => setToDelete(null)}
|
|
/>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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", agentId ?? "local", network.id],
|
|
queryFn: () => networksApi.containers(network.id, agentId),
|
|
refetchInterval: 10000,
|
|
});
|
|
const refresh = () => {
|
|
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, 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, false, agentId),
|
|
onSuccess: () => { toast.success("Container disconnected"); refresh(); },
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
|
|
const connected = (data ?? []).filter((c) => c.connected);
|
|
const available = (data ?? []).filter((c) => !c.connected);
|
|
|
|
return (
|
|
<div className="space-y-3 text-sm">
|
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-4">
|
|
<Meta label="Driver" value={network.driver} />
|
|
<Meta label="Scope" value={network.scope} />
|
|
<Meta label="Subnet" value={network.subnet ?? "—"} mono />
|
|
<Meta label="Gateway" value={network.gateway ?? "—"} mono />
|
|
<Meta label="Attachable" value={network.attachable ? "yes" : "no"} />
|
|
<Meta label="Internal" value={network.internal ? "yes" : "no"} />
|
|
<Meta label="ID" value={network.id} mono />
|
|
</div>
|
|
|
|
<div>
|
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
Connected containers
|
|
</p>
|
|
{isLoading ? (
|
|
<Spinner />
|
|
) : connected.length === 0 ? (
|
|
<p className="text-slate-400">No containers connected.</p>
|
|
) : (
|
|
<ul className="space-y-1">
|
|
{connected.map((c) => (
|
|
<li key={c.id} className="flex items-center gap-2">
|
|
<span className="font-medium">{c.name}</span>
|
|
{c.stack && <Badge>{c.stack}</Badge>}
|
|
<span className="text-xs text-slate-400">{c.state}</span>
|
|
{isAdmin && !network.is_default && (
|
|
<button
|
|
title="Disconnect"
|
|
onClick={() => disconnect.mutate(c.name)}
|
|
disabled={disconnect.isPending}
|
|
className="ml-1 rounded p-1 hover:bg-slate-200 dark:hover:bg-slate-700"
|
|
>
|
|
<Unplug className="h-3.5 w-3.5 text-red-500" />
|
|
</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{isAdmin && (
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
className={selectClass + " max-w-xs"}
|
|
value={pick}
|
|
onChange={(e) => setPick(e.target.value)}
|
|
>
|
|
<option value="">Connect a container…</option>
|
|
{available.map((c) => (
|
|
<option key={c.id} value={c.name}>
|
|
{c.name}
|
|
{c.stack ? ` (${c.stack})` : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<Button
|
|
variant="outline"
|
|
disabled={!pick || connect.isPending}
|
|
loading={connect.isPending}
|
|
onClick={() => connect.mutate(pick)}
|
|
>
|
|
<Link2 className="h-4 w-4" /> Connect
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
|
return (
|
|
<div>
|
|
<span className="text-xs text-slate-400">{label}</span>
|
|
<p className={mono ? "font-mono text-xs" : ""}>{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CreateNetworkDialog({
|
|
agentId,
|
|
onDone,
|
|
onCancel,
|
|
}: {
|
|
agentId?: number;
|
|
onDone: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const [form, setForm] = useState({
|
|
name: "",
|
|
driver: "bridge",
|
|
subnet: "",
|
|
gateway: "",
|
|
internal: false,
|
|
attachable: true,
|
|
});
|
|
const set = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
|
|
|
|
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,
|
|
},
|
|
agentId
|
|
),
|
|
onSuccess: () => { toast.success("Network created"); onDone(); },
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onCancel}>
|
|
<div
|
|
className="w-full max-w-md rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h2 className="mb-3 text-lg font-semibold">Create network</h2>
|
|
<div className="space-y-3">
|
|
<label className="block space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Name</span>
|
|
<Input value={form.name} onChange={(e) => set("name", e.target.value)} placeholder="my-net" />
|
|
</label>
|
|
<label className="block space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Driver</span>
|
|
<select className={selectClass} value={form.driver} onChange={(e) => set("driver", e.target.value)}>
|
|
<option value="bridge">bridge</option>
|
|
<option value="macvlan">macvlan</option>
|
|
<option value="ipvlan">ipvlan</option>
|
|
<option value="overlay">overlay</option>
|
|
</select>
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Subnet (optional)</span>
|
|
<Input value={form.subnet} onChange={(e) => set("subnet", e.target.value)} placeholder="172.20.0.0/16" />
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-medium text-slate-500">Gateway (optional)</span>
|
|
<Input value={form.gateway} onChange={(e) => set("gateway", e.target.value)} placeholder="172.20.0.1" />
|
|
</label>
|
|
</div>
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" checked={form.internal} onChange={(e) => set("internal", e.target.checked)} className="h-4 w-4" />
|
|
Internal (no external access)
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" checked={form.attachable} onChange={(e) => set("attachable", e.target.checked)} className="h-4 w-4" />
|
|
Attachable
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
|
<Button onClick={() => create.mutate()} loading={create.isPending} disabled={!form.name.trim()}>
|
|
Create
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|