From b261e8e5fa7ab82ebf7a8ff1e50a8eaf4dc3e6cb Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 20 Apr 2026 23:41:37 -0400 Subject: [PATCH] feat(topology): add teardown endpoint + UI button Active/degraded/failed/deploying topologies cannot be deleted without first transitioning to torn_down, but the UI had no way to trigger that. Add POST /topologies/{id}/teardown mirroring the deploy endpoint (background task, 202 Accepted), and a click-to-arm TEARDOWN button on the topology list card that shows whenever the row is in a teardown-eligible state. --- decnet/web/router/topology/__init__.py | 4 + .../router/topology/api_teardown_topology.py | 79 ++++++ .../components/TopologyList/TopologyList.css | 94 ++++++++ .../components/TopologyList/TopologyList.tsx | 228 ++++++++++++++++++ 4 files changed, 405 insertions(+) create mode 100644 decnet/web/router/topology/api_teardown_topology.py create mode 100644 decnet_web/src/components/TopologyList/TopologyList.css create mode 100644 decnet_web/src/components/TopologyList/TopologyList.tsx diff --git a/decnet/web/router/topology/__init__.py b/decnet/web/router/topology/__init__.py index d869a54c..e93ccf6a 100644 --- a/decnet/web/router/topology/__init__.py +++ b/decnet/web/router/topology/__init__.py @@ -11,6 +11,7 @@ from fastapi import APIRouter from .api_catalog import router as _catalog_router from .api_create_topology import router as _create_router +from .api_create_blank_topology import router as _create_blank_router from .api_decky_crud import router as _decky_router from .api_delete_topology import router as _delete_router from .api_deploy_topology import router as _deploy_router @@ -19,6 +20,7 @@ from .api_get_topology import router as _get_router from .api_lan_crud import router as _lan_router from .api_list_topologies import router as _list_router from .api_mutations import router as _mutations_router +from .api_teardown_topology import router as _teardown_router topology_router = APIRouter(prefix="/topologies", tags=["topologies"]) @@ -29,8 +31,10 @@ topology_router = APIRouter(prefix="/topologies", tags=["topologies"]) # parameterized fallback. topology_router.include_router(_catalog_router) topology_router.include_router(_list_router) +topology_router.include_router(_create_blank_router) topology_router.include_router(_create_router) topology_router.include_router(_deploy_router) +topology_router.include_router(_teardown_router) topology_router.include_router(_delete_router) topology_router.include_router(_lan_router) topology_router.include_router(_decky_router) diff --git a/decnet/web/router/topology/api_teardown_topology.py b/decnet/web/router/topology/api_teardown_topology.py new file mode 100644 index 00000000..776fd4ad --- /dev/null +++ b/decnet/web/router/topology/api_teardown_topology.py @@ -0,0 +1,79 @@ +"""POST /topologies/{id}/teardown — transition an active/degraded/failed +topology to ``tearing_down`` and fire the background teardown. + +Mirrors :mod:`api_deploy_topology`: the real Docker work runs in a +BackgroundTask, the caller returns ``202 Accepted``, and +:func:`decnet.engine.deployer.teardown_topology` writes the terminal +``torn_down`` status when it finishes. +""" +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status + +from decnet.engine.deployer import teardown_topology +from decnet.telemetry import traced as _traced +from decnet.topology.status import TopologyStatus +from decnet.web.db.models import TopologySummary +from decnet.web.dependencies import repo, require_admin + +log = logging.getLogger(__name__) + +router = APIRouter() + +# Statuses that can legally transition to TEARING_DOWN (see +# decnet.topology.status._LEGAL). +_TEARDOWNABLE: frozenset[str] = frozenset( + { + TopologyStatus.ACTIVE, + TopologyStatus.DEGRADED, + TopologyStatus.FAILED, + TopologyStatus.DEPLOYING, + } +) + + +async def _run_teardown(topology_id: str) -> None: + try: + await teardown_topology(repo, topology_id) + except asyncio.CancelledError: # pragma: no cover — shutdown + raise + except Exception as exc: # noqa: BLE001 + log.error("background teardown of %s failed: %s", topology_id, exc) + + +@router.post( + "/{topology_id}/teardown", + tags=["MazeNET Topologies"], + response_model=TopologySummary, + status_code=status.HTTP_202_ACCEPTED, + responses={ + 400: {"description": "Malformed path parameters"}, + 401: {"description": "Missing or invalid credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Topology not found"}, + 409: {"description": "Topology cannot be torn down from its current status"}, + }, +) +@_traced("api.topology.teardown") +async def api_teardown_topology( + topology_id: str, + background: BackgroundTasks, + _admin: dict = Depends(require_admin), +) -> TopologySummary: + topo = await repo.get_topology(topology_id) + if topo is None: + raise HTTPException(status_code=404, detail="Topology not found") + if topo["status"] not in _TEARDOWNABLE: + raise HTTPException( + status_code=409, + detail=( + f"Topology is {topo['status']!r}; cannot teardown " + f"(allowed from: {sorted(_TEARDOWNABLE)})." + ), + ) + + background.add_task(_run_teardown, topology_id) + return TopologySummary(**topo) diff --git a/decnet_web/src/components/TopologyList/TopologyList.css b/decnet_web/src/components/TopologyList/TopologyList.css new file mode 100644 index 00000000..b49dd209 --- /dev/null +++ b/decnet_web/src/components/TopologyList/TopologyList.css @@ -0,0 +1,94 @@ +.tlist-page { + padding: 16px 20px; + color: var(--text-color); + font-family: var(--font-mono); +} + +.tlist-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 16px; + margin-bottom: 14px; + border-bottom: 1px solid var(--panel-border); + padding-bottom: 10px; +} +.tlist-header h1 { margin: 0; font-size: 18px; letter-spacing: 2px; } +.tlist-sub { font-size: 11px; color: var(--dim-color); margin-top: 3px; } +.tlist-actions { display: flex; gap: 8px; } + +.tlist-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px; font-family: var(--font-mono); font-size: 11px; + background: var(--violet); color: #000; border: none; + cursor: pointer; letter-spacing: 1px; +} +.tlist-btn:hover { filter: brightness(1.15); } +.tlist-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.tlist-btn.ghost { background: transparent; color: var(--text-color); border: 1px solid var(--panel-border); } +.tlist-btn.small { padding: 4px 8px; font-size: 10px; } +.tlist-btn.danger { background: transparent; border: 1px solid var(--alert, #e74c3c); color: var(--alert, #e74c3c); } +.tlist-btn.danger.armed { background: var(--alert, #e74c3c); color: #000; } +.tlist-btn.warn { background: transparent; border: 1px solid var(--warn, #e0a040); color: var(--warn, #e0a040); } +.tlist-btn.warn.armed { background: var(--warn, #e0a040); color: #000; } + +.tlist-create-row { + display: flex; gap: 8px; margin-bottom: 12px; +} +.tlist-create-row input { + flex: 1; padding: 6px 10px; font-family: var(--font-mono); font-size: 12px; + background: var(--panel); color: var(--text-color); + border: 1px solid var(--panel-border); +} +.tlist-create-row input:focus { outline: none; border-color: var(--violet); } + +.tlist-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 12px; +} + +.tlist-card { + border: 1px solid var(--panel-border); + background: var(--panel); + padding: 12px; + cursor: pointer; + transition: border-color 0.15s; +} +.tlist-card:hover { border-color: var(--violet); } +.tlist-card-top { + display: flex; align-items: center; gap: 8px; + margin-bottom: 8px; +} +.tlist-card-name { font-weight: 700; flex: 1; font-size: 13px; } +.tlist-card-meta { + display: flex; gap: 12px; flex-wrap: wrap; + font-size: 10px; color: var(--dim-color); + margin-bottom: 4px; +} +.tlist-card-id { + font-size: 9px; color: var(--dim-color); + margin-bottom: 10px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.tlist-card-actions { + display: flex; gap: 6px; justify-content: flex-end; +} + +.tlist-pill { + padding: 2px 8px; font-size: 10px; letter-spacing: 1px; + text-transform: uppercase; + border: 1px solid currentColor; +} +.tlist-pill.pill-ok { color: var(--matrix, #33ff66); } +.tlist-pill.pill-warn { color: #f39c12; } +.tlist-pill.pill-bad { color: var(--alert, #e74c3c); } +.tlist-pill.pill-dim { color: var(--dim-color); } + +.tlist-empty { + grid-column: 1 / -1; + padding: 40px; + text-align: center; + color: var(--dim-color); + border: 1px dashed var(--panel-border); +} diff --git a/decnet_web/src/components/TopologyList/TopologyList.tsx b/decnet_web/src/components/TopologyList/TopologyList.tsx new file mode 100644 index 00000000..2821c088 --- /dev/null +++ b/decnet_web/src/components/TopologyList/TopologyList.tsx @@ -0,0 +1,228 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw } from 'lucide-react'; +import api from '../../utils/api'; +import './TopologyList.css'; + +interface TopologySummary { + id: string; + name: string; + mode: string; + status: string; + version: number; + created_at: string; + status_changed_at: string | null; +} + +interface ListResponse { + total: number; + limit: number | null; + offset: number | null; + data: TopologySummary[]; +} + +const statusClass = (s: string): string => { + switch (s) { + case 'active': return 'pill-ok'; + case 'pending': return 'pill-dim'; + case 'deploying': + case 'tearing_down': return 'pill-warn'; + case 'degraded': return 'pill-warn'; + case 'failed': + case 'teardown_failed': return 'pill-bad'; + case 'torn_down': return 'pill-dim'; + default: return 'pill-dim'; + } +}; + +const TopologyList: React.FC = () => { + const navigate = useNavigate(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(''); + const [busy, setBusy] = useState(null); + const [armed, setArmed] = useState(null); + + const arm = (key: string) => { + setArmed(key); + setTimeout(() => setArmed((prev) => (prev === key ? null : prev)), 4000); + }; + + const fetchRows = useCallback(async () => { + try { + const { data } = await api.get('/topologies/'); + setRows(data.data ?? []); + setErr(null); + } catch (e) { + setErr((e as Error)?.message ?? 'failed to list topologies'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + let cancelled = false; + const tick = async () => { if (!cancelled) await fetchRows(); }; + tick(); + const iv = setInterval(tick, 5000); + return () => { cancelled = true; clearInterval(iv); }; + }, [fetchRows]); + + const onCreate = async () => { + const name = newName.trim(); + if (!name) return; + setBusy('create'); + try { + const { data: created } = await api.post('/topologies/blank', { name }); + navigate(`/mazenet?topology=${created.id}`); + } catch (e) { + setErr((e as Error)?.message ?? 'create failed'); + } finally { + setBusy(null); + setCreating(false); + setNewName(''); + } + }; + + const onDelete = async (id: string) => { + setBusy(id); + try { + await api.delete(`/topologies/${id}`); + await fetchRows(); + } catch (e) { + setErr((e as Error)?.message ?? 'delete failed'); + } finally { + setBusy(null); + setArmed(null); + } + }; + + const onDeploy = async (id: string) => { + setBusy(id); + try { + await api.post(`/topologies/${id}/deploy`, {}); + await fetchRows(); + } catch (e) { + setErr((e as Error)?.message ?? 'deploy failed'); + } finally { + setBusy(null); + } + }; + + const onTeardown = async (id: string) => { + setBusy(id); + try { + await api.post(`/topologies/${id}/teardown`, {}); + await fetchRows(); + } catch (e) { + setErr((e as Error)?.message ?? 'teardown failed'); + } finally { + setBusy(null); + setArmed(null); + } + }; + + return ( +
+
+
+

TOPOLOGIES

+
+ {loading ? 'loading…' : `${rows.length} topology${rows.length === 1 ? '' : 'ies'}`} + {err && · {err}} +
+
+
+ + +
+
+ + {creating && ( +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onCreate(); + if (e.key === 'Escape') { setCreating(false); setNewName(''); } + }} + /> + + +
+ )} + +
+ {rows.map((r) => ( +
navigate(`/mazenet?topology=${r.id}`)}> +
+ +
{r.name}
+ {r.status} +
+
+ mode: {r.mode} + v{r.version} + {new Date(r.created_at).toLocaleString()} +
+
{r.id}
+
e.stopPropagation()}> + {r.status === 'pending' && ( + + )} + {['active', 'degraded', 'failed', 'deploying'].includes(r.status) && ( + + )} + +
+
+ ))} + {!loading && rows.length === 0 && ( +
+ No topologies yet. Click NEW TOPOLOGY to create one. +
+ )} +
+
+ ); +}; + +export default TopologyList;