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
|
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of
|
||||||
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup
|
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup
|
||||||
> destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups)
|
> destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups)
|
||||||
> complete.
|
> + Phase 9 (Networks) complete.
|
||||||
|
|
||||||
## What works today (Phase 1)
|
## 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
|
are namespaced per host (`backup-<host>-<stack>-…`) so retention never prunes
|
||||||
across hosts sharing a destination.
|
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
|
## Deploying an agent on another host
|
||||||
|
|
||||||
```bash
|
```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.
|
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
|
## Security notes
|
||||||
|
|
||||||
- The Docker socket is only ever touched by the backend process; it is never
|
- 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")
|
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,
|
destinations,
|
||||||
editor,
|
editor,
|
||||||
images,
|
images,
|
||||||
|
networks,
|
||||||
ports,
|
ports,
|
||||||
schedules,
|
schedules,
|
||||||
settings as settings_router,
|
settings as settings_router,
|
||||||
@@ -53,7 +54,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -85,6 +86,7 @@ app.include_router(settings_router.router)
|
|||||||
app.include_router(backups.router)
|
app.include_router(backups.router)
|
||||||
app.include_router(destinations.router)
|
app.include_router(destinations.router)
|
||||||
app.include_router(schedules.router)
|
app.include_router(schedules.router)
|
||||||
|
app.include_router(networks.router)
|
||||||
app.include_router(agents.router)
|
app.include_router(agents.router)
|
||||||
app.include_router(ws.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",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Images } from "@/pages/Images";
|
|||||||
import { Templates } from "@/pages/Templates";
|
import { Templates } from "@/pages/Templates";
|
||||||
import { Settings } from "@/pages/Settings";
|
import { Settings } from "@/pages/Settings";
|
||||||
import { Audit } from "@/pages/Audit";
|
import { Audit } from "@/pages/Audit";
|
||||||
import { Networks } from "@/pages/Placeholder";
|
import { Networks } from "@/pages/Networks";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useThemeStore } from "@/store/theme";
|
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 { 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 { 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";
|
import type { StackSummary } from "@/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,6 +31,26 @@ export function StackCard({
|
|||||||
linkBase = "/stacks",
|
linkBase = "/stacks",
|
||||||
showEdit = true,
|
showEdit = true,
|
||||||
}: Props) {
|
}: 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 (
|
return (
|
||||||
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
|
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -72,8 +98,29 @@ export function StackCard({
|
|||||||
<Pencil className="h-4 w-4 text-slate-500" />
|
<Pencil className="h-4 w-4 text-slate-500" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{isLocal && (
|
||||||
|
<IconBtn
|
||||||
|
title="Delete"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
className={showEdit ? "" : "ml-auto"}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -83,18 +130,23 @@ function IconBtn({
|
|||||||
title,
|
title,
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
title={title}
|
title={title}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
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}
|
{children}
|
||||||
</button>
|
</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 { useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
Square,
|
Square,
|
||||||
@@ -9,11 +9,15 @@ import {
|
|||||||
ArrowUpCircle,
|
ArrowUpCircle,
|
||||||
Pencil,
|
Pencil,
|
||||||
Power,
|
Power,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useStackActions } from "@/hooks/useStackActions";
|
import { useStackActions } from "@/hooks/useStackActions";
|
||||||
|
|
||||||
@@ -74,6 +78,7 @@ export function StackDetail() {
|
|||||||
<Pencil className="h-4 w-4" /> Edit
|
<Pencil className="h-4 w-4" /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<DeleteStackButton stackId={id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,3 +166,59 @@ function ComposeView({ yaml }: { yaml: string }) {
|
|||||||
</Card>
|
</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