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
+55 -3
View File
@@ -1,7 +1,13 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Play, Square, RotateCw, Pencil } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { Play, Square, RotateCw, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card, StatusDot, Badge } from "@/components/ui";
import { relativeTime } from "@/lib/utils";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { cn, relativeTime } from "@/lib/utils";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import type { StackSummary } from "@/types";
interface Props {
@@ -25,6 +31,26 @@ export function StackCard({
linkBase = "/stacks",
showEdit = true,
}: Props) {
const qc = useQueryClient();
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
const isLocal = !stack.agent_id;
const remove = async () => {
setDeleting(true);
const t = toast.loading(`Deleting ${stack.id}`);
try {
await stacksApi.remove(stack.id, true);
toast.success(`Deleted ${stack.id}`, { id: t });
qc.invalidateQueries({ queryKey: ["stacks"] });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
} finally {
setDeleting(false);
setConfirming(false);
}
};
return (
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
<div className="flex items-start justify-between">
@@ -72,8 +98,29 @@ export function StackCard({
<Pencil className="h-4 w-4 text-slate-500" />
</Link>
)}
{isLocal && (
<IconBtn
title="Delete"
onClick={() => setConfirming(true)}
className={showEdit ? "" : "ml-auto"}
>
<Trash2 className="h-4 w-4 text-red-500" />
</IconBtn>
)}
</div>
)}
{confirming && (
<ConfirmDialog
title={`Delete stack “${stack.id}”?`}
message="The stack is stopped and its compose files are removed. This cannot be undone."
confirmLabel="Delete stack"
danger
busy={deleting}
onConfirm={remove}
onCancel={() => setConfirming(false)}
/>
)}
</Card>
);
}
@@ -83,18 +130,23 @@ function IconBtn({
title,
onClick,
disabled,
className,
}: {
children: React.ReactNode;
title: string;
onClick: () => void;
disabled?: boolean;
className?: string;
}) {
return (
<button
title={title}
onClick={onClick}
disabled={disabled}
className="rounded-lg p-2 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700"
className={cn(
"rounded-lg p-2 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700",
className
)}
>
{children}
</button>
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import { Button } from "@/components/ui";
export function ConfirmDialog({
title,
message,
confirmLabel = "Confirm",
danger = false,
busy = false,
onConfirm,
onCancel,
children,
}: {
title: string;
message?: string;
confirmLabel?: string;
danger?: boolean;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
children?: ReactNode;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => !busy && 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="text-lg font-semibold">{title}</h2>
{message && <p className="mt-2 text-sm text-slate-500">{message}</p>}
{children && <div className="mt-3">{children}</div>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={onCancel} disabled={busy}>
Cancel
</Button>
<Button variant={danger ? "danger" : "primary"} onClick={onConfirm} loading={busy}>
{confirmLabel}
</Button>
</div>
</div>
</div>
);
}