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
@@ -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)
|
||||
Reference in New Issue
Block a user