F16 — /ws/events was implemented and nothing consumed it, while thirty polling intervals across the pages asked for state that only changes when Docker does something. The endpoint was the answer; it just was not usable as it stood, so this is three fixes and a client, not a wiring job. The endpoint forwarded the whole firehose. Three exec_* events fire per web terminal session and top/attach fire whenever anything inspects a container, so a client invalidating on each would have been noisier than the polling it replaces. Now the daemon filters by resource type and the handler drops the actions that say nothing about rendered state — matching on the verb before the colon, since Docker reports these as "exec_create: /bin/sh". It never said *what* changed, so there was nothing to decide which caches to drop. The payload now carries the resource type. And it leaked its reader thread. Cancelling the executor future does not interrupt a thread already inside a blocking read; closing the underlying CancellableStream is what does. Every page load left one behind holding a socket open. A test asserts the close, because this is invisible until the process has been up for a week. Client side, useDockerEvents holds one connection for the session and maps resource types to query keys. Bursts are coalesced over 300ms — a ten-service compose up emits dozens of events in a second, and refetching per event would reintroduce exactly the load being removed. Reconnects back off to 30s, and any close reconnects including 4401, since the access token is short-lived and gets refreshed out from under the socket. Intervals drop from the mechanism to the safety net: 5s becomes 30-60s. Two deliberately stay fast. Live CPU/memory drifts continuously with no event to announce it, and that one is served from the 4s server-side cache added in 0.47.0, so it costs one sample per interval regardless of how many tabs are open. The audit feed polls because its entries come from people, not Docker. Net effect is both cheaper and faster: no fixed floor of requests per second against the daemon, and a stack that finishes starting shows up immediately rather than up to five seconds later. 23 new tests (758 total), driven against a fake daemon. Both nets were checked by reverting the fix: dropping the filter fails one, dropping the stream close fails the leak test. Not covered: the hook itself has no test — there is no frontend test runner yet. Its contract with the backend is tested; its own behaviour is only typechecked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
378 lines
14 KiB
TypeScript
378 lines
14 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 { networksApi, type NetworkInfo } from "@/api/networks";
|
|
import { apiErrorMessage } from "@/api/client";
|
|
import { useAuthStore } from "@/store/auth";
|
|
|
|
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");
|
|
return <NetworksSection isAdmin={isAdmin} />;
|
|
}
|
|
|
|
function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
|
const qc = useQueryClient();
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["networks"],
|
|
queryFn: () => networksApi.list(),
|
|
refetchInterval: 60000,
|
|
});
|
|
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 colSpan = isAdmin ? 6 : 5;
|
|
|
|
const prune = useMutation({
|
|
mutationFn: () => networksApi.prune(),
|
|
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),
|
|
onSuccess: () => {
|
|
toast.success("Network deleted");
|
|
setToDelete(null);
|
|
invalidate();
|
|
},
|
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
});
|
|
|
|
return (
|
|
<section>
|
|
{isAdmin && (
|
|
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
|
|
<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>
|
|
)}
|
|
|
|
{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} />
|
|
</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
|
|
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,
|
|
}: {
|
|
network: NetworkInfo;
|
|
isAdmin: boolean;
|
|
}) {
|
|
const qc = useQueryClient();
|
|
const [pick, setPick] = useState("");
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["network-containers", network.id],
|
|
queryFn: () => networksApi.containers(network.id),
|
|
refetchInterval: 60000,
|
|
});
|
|
const refresh = () => {
|
|
qc.invalidateQueries({ queryKey: ["network-containers", network.id] });
|
|
qc.invalidateQueries({ queryKey: ["networks"] });
|
|
};
|
|
|
|
const connect = useMutation({
|
|
mutationFn: (container: string) => networksApi.connect(network.id, container),
|
|
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),
|
|
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({
|
|
onDone,
|
|
onCancel,
|
|
}: {
|
|
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,
|
|
}),
|
|
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 sp-heading text-lg">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>
|
|
);
|
|
}
|