import { Fragment, useMemo, 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 { PruneProgress } from "@/components/ui/PruneProgress"; import { usePruneJob } from "@/hooks/usePruneJob"; import { stacksApi } from "@/api/stacks"; import { groupByStack, stripStackPrefix, type StackGroup } from "@/lib/stackGroups"; import { StackGroupHeader } from "@/components/ui/StackGroupHeader"; 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 ; } 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(null); const [expanded, setExpanded] = useState(null); const [pruneOpen, setPruneOpen] = useState(false); const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] }); const colSpan = isAdmin ? 6 : 5; const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, staleTime: 30000, }); const stackById = useMemo( () => new Map((stacks.data ?? []).map((s) => [s.id, s])), [stacks.data] ); const groups = useMemo( () => groupByStack(data ?? [], { stacksOf: (n) => (n.stack ? [n.stack] : []), sortKey: (n) => n.name, stackById, // bridge / host / none belong to nobody but are not leftovers either, // so they get their own group at the very bottom. isBuiltIn: (n) => n.is_default, }), [data, stackById] ); const prune = usePruneJob({ noun: "network", queryKey: "network-prune-status", fetchStatus: networksApi.pruneStatus, start: networksApi.startPrune, enabled: isAdmin, onFinished: invalidate, }); const remove = useMutation({ mutationFn: (id: string) => networksApi.remove(id), onSuccess: () => { toast.success("Network deleted"); setToDelete(null); invalidate(); }, onError: (e) => toast.error(apiErrorMessage(e)), }); return (
{isAdmin && (
)} {prune.running && ( )} {isLoading ? ( ) : ( {isAdmin && } {groups.map((group) => ( {group.items.map((n) => ( setExpanded((e) => (e === n.id ? null : n.id))} onDelete={() => setToDelete(n)} /> ))} ))} {data?.length === 0 && ( )}
Name Driver Scope Subnet In use
No networks.
)} {creating && ( { setCreating(false); invalidate(); }} onCancel={() => setCreating(false)} /> )} {pruneOpen && ( prune.start.mutate(false, { onSuccess: () => setPruneOpen(false) }) } onCancel={() => setPruneOpen(false)} /> )} {toDelete && ( remove.mutate(toDelete.id)} onCancel={() => setToDelete(null)} /> )}
); } /** One network: its row, plus the detail row when it is expanded. */ function NetworkRows({ network: n, label, colSpan, isAdmin, expanded, onToggle, onDelete, }: { network: NetworkInfo; /** The name with the stack's prefix removed, when it had one. */ label: string; colSpan: number; isAdmin: boolean; expanded: boolean; onToggle: () => void; onDelete: () => void; }) { return ( <> {/* Indented under its group heading. */}
{expanded ? ( ) : ( )} {/* The stack is the heading now, so the row shows the part of the name that differs — compose calls a stack's own network "_default". */} {label} {n.internal && internal}
{n.driver} {n.scope} {n.subnet ?? "—"} {n.in_use ? ( {n.containers.length} container{n.containers.length > 1 ? "s" : ""} ) : ( — )} {isAdmin && ( {!n.is_default && ( // Disabled while in use: Docker refuses to remove it anyway, and // the red bin offered a delete that could only fail. )} )} {expanded && ( )} ); } 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 (

Connected containers

{isLoading ? ( ) : connected.length === 0 ? (

No containers connected.

) : (
    {connected.map((c) => (
  • {c.name} {c.stack && {c.stack}} {c.state} {isAdmin && !network.is_default && ( )}
  • ))}
)}
{isAdmin && (
)}
); } function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
{label}

{value}

); } 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 (
e.stopPropagation()} >

Create network

); } /** What a network group can say for itself. */ function groupMeta(group: StackGroup): string { const parts = [`${group.items.length} network${group.items.length > 1 ? "s" : ""}`]; const idle = group.items.filter((n) => !n.in_use).length; if (idle > 0) parts.push(`${idle} unused`); return parts.join(" · "); }