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:
co-authored by
Claude Opus 4.8
parent
5cd55382ed
commit
a4e26f880a
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user