From a4e26f880a69bc47a687fd94819e594ee1519660 Mon Sep 17 00:00:00 2001 From: menzelj Date: Sun, 7 Jun 2026 22:37:30 +0000 Subject: [PATCH] Phase 9: network management + stack delete in UI (0.9.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 20 +- backend/agent_app.py | 2 +- backend/main.py | 4 +- backend/routers/networks.py | 96 ++++++++ backend/services/network_service.py | 100 +++++++++ frontend/package.json | 2 +- frontend/src/App.tsx | 2 +- frontend/src/api/networks.ts | 36 +++ frontend/src/components/stacks/StackCard.tsx | 58 ++++- frontend/src/components/ui/ConfirmDialog.tsx | 46 ++++ frontend/src/pages/Networks.tsx | 220 +++++++++++++++++++ frontend/src/pages/StackDetail.tsx | 65 +++++- 12 files changed, 641 insertions(+), 10 deletions(-) create mode 100644 backend/routers/networks.py create mode 100644 backend/services/network_service.py create mode 100644 frontend/src/api/networks.ts create mode 100644 frontend/src/components/ui/ConfirmDialog.tsx create mode 100644 frontend/src/pages/Networks.tsx diff --git a/README.md b/README.md index 65a6087..4039245 100644 --- a/README.md +++ b/README.md @@ -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---…`) 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 diff --git a/backend/agent_app.py b/backend/agent_app.py index 7df4d8a..6c04087 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -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" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index cc491d1..9de3fc2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/routers/networks.py b/backend/routers/networks.py new file mode 100644 index 0000000..3fd77c0 --- /dev/null +++ b/backend/routers/networks.py @@ -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 diff --git a/backend/services/network_service.py b/backend/services/network_service.py new file mode 100644 index 0000000..b535acc --- /dev/null +++ b/backend/services/network_service.py @@ -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) diff --git a/frontend/package.json b/frontend/package.json index f8d715b..c146a39 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.8.0", + "version": "0.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ad93a21..e530a43 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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"; diff --git a/frontend/src/api/networks.ts b/frontend/src/api/networks.ts new file mode 100644 index 0000000..51b1d37 --- /dev/null +++ b/frontend/src/api/networks.ts @@ -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; + 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("/api/networks").then((r) => r.data), + create: (body: NetworkCreate) => + api.post("/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), +}; diff --git a/frontend/src/components/stacks/StackCard.tsx b/frontend/src/components/stacks/StackCard.tsx index 6b1884f..f82a0ee 100644 --- a/frontend/src/components/stacks/StackCard.tsx +++ b/frontend/src/components/stacks/StackCard.tsx @@ -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 (
@@ -72,8 +98,29 @@ export function StackCard({ )} + {isLocal && ( + setConfirming(true)} + className={showEdit ? "" : "ml-auto"} + > + + + )}
)} + + {confirming && ( + setConfirming(false)} + /> + )}
); } @@ -83,18 +130,23 @@ function IconBtn({ title, onClick, disabled, + className, }: { children: React.ReactNode; title: string; onClick: () => void; disabled?: boolean; + className?: string; }) { return ( diff --git a/frontend/src/components/ui/ConfirmDialog.tsx b/frontend/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..a319078 --- /dev/null +++ b/frontend/src/components/ui/ConfirmDialog.tsx @@ -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 ( +
!busy && onCancel()} + > +
e.stopPropagation()} + > +

{title}

+ {message &&

{message}

} + {children &&
{children}
} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/Networks.tsx b/frontend/src/pages/Networks.tsx new file mode 100644 index 0000000..a85dfb9 --- /dev/null +++ b/frontend/src/pages/Networks.tsx @@ -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(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 ; + + return ( +
+ {isAdmin && ( +
+ + +
+ )} + + + + + + + + + + + {isAdmin && } + + + + {data?.map((n) => ( + + + + + + + {isAdmin && ( + + )} + + ))} + {data?.length === 0 && ( + + + + )} + +
NameDriverScopeSubnetIn use
+
+ + {n.name} + {n.is_default && default} + {n.stack && {n.stack}} + {n.internal && internal} +
+
{n.driver}{n.scope}{n.subnet ?? "—"} + {n.in_use ? ( + + {n.containers.length} container{n.containers.length > 1 ? "s" : ""} + + ) : ( + + )} + + {!n.is_default && ( + + )} +
+ No networks. +
+
+ + {creating && ( + { setCreating(false); invalidate(); }} onCancel={() => setCreating(false)} /> + )} + {toDelete && ( + remove.mutate(toDelete.id)} + onCancel={() => setToDelete(null)} + /> + )} +
+ ); +} + +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 ( +
+
e.stopPropagation()} + > +

Create network

+
+ + +
+ + +
+
+ + +
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/StackDetail.tsx b/frontend/src/pages/StackDetail.tsx index 0d65a87..c85d102 100644 --- a/frontend/src/pages/StackDetail.tsx +++ b/frontend/src/pages/StackDetail.tsx @@ -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() { Edit + )} @@ -161,3 +166,59 @@ function ComposeView({ yaml }: { yaml: string }) { ); } + +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 ( + <> + + {open && ( + setOpen(false)} + > + + + )} + + ); +}