- Networks: network_service (list w/ subnet/containers/in-use/owning-stack, create bridge/macvlan/ipvlan/overlay + optional subnet/gateway/internal, delete with default-network guard, prune) + routers/networks.py; real Networks page replaces the placeholder. - Fix: local stacks can now be deleted from the UI — Delete button on stack detail (with optional keep-files-on-disk) and a trash action on stack cards, via a shared ConfirmDialog. (Backend DELETE existed; no UI surfaced it.) Verified: py_compile, frontend tsc build, live network list smoke test (defaults flagged, compose nets + in-use detected); main 104 routes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
221 lines
8.9 KiB
TypeScript
221 lines
8.9 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Network as NetworkIcon, Plus, Trash2, Eraser } 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");
|
|
const qc = useQueryClient();
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["networks"],
|
|
queryFn: networksApi.list,
|
|
refetchInterval: 10000,
|
|
});
|
|
const [creating, setCreating] = useState(false);
|
|
const [toDelete, setToDelete] = useState<NetworkInfo | null>(null);
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] });
|
|
|
|
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)),
|
|
});
|
|
|
|
if (isLoading) return <Spinner />;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{isAdmin && (
|
|
<div className="flex flex-wrap 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>
|
|
)}
|
|
|
|
<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) => (
|
|
<tr key={n.id}>
|
|
<td className="px-4 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<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={() => 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>
|
|
))}
|
|
{data?.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} 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)}
|
|
/>
|
|
)}
|
|
</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 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>
|
|
);
|
|
}
|