Phase 9: network management + stack delete in UI (0.9.0)

- 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>
This commit is contained in:
menzelj
2026-06-07 22:37:30 +00:00
co-authored by Claude Opus 4.8
parent 5cd55382ed
commit a4e26f880a
12 changed files with 641 additions and 10 deletions
+220
View File
@@ -0,0 +1,220 @@
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>
);
}