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
+19 -1
View File
@@ -6,7 +6,7 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup
> destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups)
> complete.
> + Phase 9 (Networks) complete.
## What works today (Phase 1)
@@ -112,6 +112,16 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
are namespaced per host (`backup-<host>-<stack>-…`) so retention never prunes
across hosts sharing a destination.
### Phase 9 — Networks
- **Network management**: the Networks page lists Docker networks (driver, scope,
subnet, attached containers / in-use, owning stack), with **create** (bridge /
macvlan / ipvlan / overlay, optional subnet+gateway, internal/attachable),
**delete** (default networks protected; in-use guarded by Docker), and **prune
unused**.
- **Stack delete**: local stacks can now be deleted from the UI (stack detail and
the stack card), with a confirm dialog and an optional "keep files on disk".
## Deploying an agent on another host
```bash
@@ -265,6 +275,14 @@ POST /api/agents/{id}/stacks/restore POST /api/agents/{id}/stacks/
backup schedules accept an optional agent_id to target a remote host.
```
### Phase 9 endpoints
```
GET /api/networks | /{id} POST /api/networks
DELETE /api/networks/{id} POST /api/networks/prune
DELETE /api/stacks/{id}?delete_files= (stack delete, now surfaced in the UI)
```
## Security notes
- The Docker socket is only ever touched by the backend process; it is never
+1 -1
View File
@@ -26,7 +26,7 @@ from services import backup_service, compose_service
logger = logging.getLogger("stackpilot.agent")
AGENT_VERSION = "0.8.0"
AGENT_VERSION = "0.9.0"
# --------------------------------------------------------------------------- #
+3 -1
View File
@@ -21,6 +21,7 @@ from routers import (
destinations,
editor,
images,
networks,
ports,
schedules,
settings as settings_router,
@@ -53,7 +54,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.8.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.9.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -85,6 +86,7 @@ app.include_router(settings_router.router)
app.include_router(backups.router)
app.include_router(destinations.router)
app.include_router(schedules.router)
app.include_router(networks.router)
app.include_router(agents.router)
app.include_router(ws.router)
+96
View File
@@ -0,0 +1,96 @@
"""Docker network management endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlmodel import Session
from auth import get_current_user, require_admin
from database import get_session
from docker_client import DockerError
from models.user import User
from services import audit_service, network_service
router = APIRouter(prefix="/api/networks", tags=["networks"])
_STATUS = {"invalid_request": 400, "forbidden": 403, "not_found": 404}
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _map(exc: DockerError):
code = _STATUS.get(exc.error)
if code:
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler
class NetworkCreate(BaseModel):
name: str
driver: str = "bridge"
subnet: str | None = None
gateway: str | None = None
internal: bool = False
attachable: bool = True
@router.get("")
def list_networks(_user: User = Depends(get_current_user)) -> list[dict]:
return network_service.list_networks()
@router.get("/{network_id}")
def inspect_network(network_id: str, _user: User = Depends(get_current_user)) -> dict:
return network_service.inspect_network(network_id)
@router.post("", status_code=201)
def create_network(
body: NetworkCreate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
try:
result = network_service.create_network(body.model_dump())
except DockerError as exc:
_map(exc)
audit_service.record(
session, user=user.username, action="network.create", target=body.name,
detail=body.driver, ip=_ip(request),
)
return result
@router.delete("/{network_id}")
def delete_network(
network_id: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
try:
network_service.delete_network(network_id)
except DockerError as exc:
_map(exc)
audit_service.record(
session, user=user.username, action="network.delete", target=network_id,
ip=_ip(request),
)
return {"ok": True}
@router.post("/prune")
def prune_networks(
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
result = network_service.prune_networks()
audit_service.record(
session, user=user.username, action="network.prune", target="networks",
detail=str(result.get("NetworksDeleted") or []), ip=_ip(request),
)
return result
+100
View File
@@ -0,0 +1,100 @@
"""Docker network management."""
from __future__ import annotations
from typing import Optional
from docker.errors import APIError
from docker.types import IPAMConfig, IPAMPool
from docker_client import DockerError, get_client, safe_call
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
DEFAULT_NETWORKS = {"bridge", "host", "none"}
def _summary(net) -> dict:
attrs = net.attrs
ipam = (attrs.get("IPAM") or {}).get("Config") or []
subnet = ipam[0].get("Subnet") if ipam else None
gateway = ipam[0].get("Gateway") if ipam else None
containers = attrs.get("Containers") or {}
names = [c.get("Name", cid[:12]) for cid, c in containers.items()]
labels = attrs.get("Labels") or {}
name = attrs.get("Name", net.name)
return {
"id": net.id[:12],
"name": name,
"driver": attrs.get("Driver"),
"scope": attrs.get("Scope"),
"internal": attrs.get("Internal", False),
"attachable": attrs.get("Attachable", False),
"subnet": subnet,
"gateway": gateway,
"containers": names,
"in_use": bool(names),
"stack": labels.get(COMPOSE_PROJECT_LABEL),
"labels": labels,
"created": attrs.get("Created"),
"is_default": name in DEFAULT_NETWORKS,
}
def list_networks() -> list[dict]:
client = get_client()
nets = safe_call(client.networks.list)
# list() entries are lightweight; reload for Containers/IPAM detail.
out = []
for n in nets:
try:
n.reload()
except APIError:
pass
out.append(_summary(n))
return sorted(out, key=lambda x: (x["is_default"] is False, x["name"]))
def inspect_network(network_id: str) -> dict:
client = get_client()
net = safe_call(client.networks.get, network_id)
return _summary(net)
def create_network(spec: dict) -> dict:
name = (spec.get("name") or "").strip()
if not name:
raise DockerError("invalid_request", "Network name is required")
driver = spec.get("driver") or "bridge"
ipam = None
subnet = (spec.get("subnet") or "").strip()
gateway = (spec.get("gateway") or "").strip()
if subnet:
pool = IPAMPool(subnet=subnet, gateway=gateway or None)
ipam = IPAMConfig(pool_configs=[pool])
client = get_client()
net = safe_call(
client.networks.create,
name=name,
driver=driver,
internal=bool(spec.get("internal")),
attachable=bool(spec.get("attachable", True)),
ipam=ipam,
options=spec.get("options") or None,
labels=spec.get("labels") or None,
)
net.reload()
return _summary(net)
def delete_network(network_id: str) -> None:
client = get_client()
net = safe_call(client.networks.get, network_id)
name = (net.attrs or {}).get("Name", net.name)
if name in DEFAULT_NETWORKS:
raise DockerError("forbidden", f"Cannot delete the default '{name}' network")
safe_call(net.remove)
def prune_networks() -> dict:
client = get_client()
return safe_call(client.networks.prune)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.8.0",
"version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -11,7 +11,7 @@ import { Images } from "@/pages/Images";
import { Templates } from "@/pages/Templates";
import { Settings } from "@/pages/Settings";
import { Audit } from "@/pages/Audit";
import { Networks } from "@/pages/Placeholder";
import { Networks } from "@/pages/Networks";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
+36
View File
@@ -0,0 +1,36 @@
import api from "./client";
export interface NetworkInfo {
id: string;
name: string;
driver: string;
scope: string;
internal: boolean;
attachable: boolean;
subnet: string | null;
gateway: string | null;
containers: string[];
in_use: boolean;
stack: string | null;
labels: Record<string, string>;
created: string | null;
is_default: boolean;
}
export interface NetworkCreate {
name: string;
driver: string;
subnet?: string | null;
gateway?: string | null;
internal: boolean;
attachable: boolean;
}
export const networksApi = {
list: () => api.get<NetworkInfo[]>("/api/networks").then((r) => r.data),
create: (body: NetworkCreate) =>
api.post<NetworkInfo>("/api/networks", body).then((r) => r.data),
remove: (id: string) => api.delete(`/api/networks/${id}`).then((r) => r.data),
prune: () =>
api.post<{ NetworksDeleted: string[] | null }>("/api/networks/prune").then((r) => r.data),
};
+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>
);
}
+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>
);
}
+63 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Play,
Square,
@@ -9,11 +9,15 @@ import {
ArrowUpCircle,
Pencil,
Power,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
@@ -74,6 +78,7 @@ export function StackDetail() {
<Pencil className="h-4 w-4" /> Edit
</Button>
</Link>
<DeleteStackButton stackId={id} />
</div>
)}
</div>
@@ -161,3 +166,59 @@ function ComposeView({ yaml }: { yaml: string }) {
</Card>
);
}
function DeleteStackButton({ stackId }: { stackId: string }) {
const navigate = useNavigate();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(true);
const [busy, setBusy] = useState(false);
const remove = async () => {
setBusy(true);
const t = toast.loading(`Deleting ${stackId}`);
try {
await stacksApi.remove(stackId, deleteFiles);
toast.success(`Deleted ${stackId}`, { id: t });
qc.invalidateQueries({ queryKey: ["stacks"] });
navigate("/stacks");
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
setBusy(false);
}
};
return (
<>
<Button variant="danger" onClick={() => setOpen(true)}>
<Trash2 className="h-4 w-4" /> Delete
</Button>
{open && (
<ConfirmDialog
title={`Delete stack “${stackId}”?`}
message="The stack is stopped and removed. This cannot be undone."
confirmLabel="Delete stack"
danger
busy={busy}
onConfirm={remove}
onCancel={() => setOpen(false)}
>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
className="mt-0.5 h-4 w-4"
/>
<span>
Also delete the compose files from disk
<span className="block text-xs text-slate-500">
Uncheck to keep <code>{stackId}/</code> on disk (it can be re-discovered later).
</span>
</span>
</label>
</ConfirmDialog>
)}
</>
);
}