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.
This commit is contained in:
2026-04-20 23:41:37 -04:00
parent c37d1f09c6
commit b261e8e5fa
4 changed files with 405 additions and 0 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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<TopologySummary[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [armed, setArmed] = useState<string | null>(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<ListResponse>('/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<TopologySummary>('/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 (
<div className="tlist-page">
<div className="tlist-header">
<div>
<h1>TOPOLOGIES</h1>
<div className="tlist-sub">
{loading ? 'loading…' : `${rows.length} topology${rows.length === 1 ? '' : 'ies'}`}
{err && <span className="alert-text"> · {err}</span>}
</div>
</div>
<div className="tlist-actions">
<button type="button" className="tlist-btn ghost" onClick={fetchRows} title="Refresh">
<RefreshCw size={12} /> REFRESH
</button>
<button type="button" className="tlist-btn" onClick={() => setCreating((v) => !v)}>
<Plus size={12} /> NEW TOPOLOGY
</button>
</div>
</div>
{creating && (
<div className="tlist-create-row">
<input
type="text"
autoFocus
placeholder="topology name (e.g. honeynet-dev)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button type="button" className="tlist-btn" disabled={!newName.trim() || busy === 'create'} onClick={onCreate}>
CREATE
</button>
<button type="button" className="tlist-btn ghost" onClick={() => { setCreating(false); setNewName(''); }}>
CANCEL
</button>
</div>
)}
<div className="tlist-grid">
{rows.map((r) => (
<div key={r.id} className="tlist-card" onClick={() => navigate(`/mazenet?topology=${r.id}`)}>
<div className="tlist-card-top">
<Network size={14} className="violet-accent" />
<div className="tlist-card-name">{r.name}</div>
<span className={`tlist-pill ${statusClass(r.status)}`}>{r.status}</span>
</div>
<div className="tlist-card-meta">
<span>mode: {r.mode}</span>
<span>v{r.version}</span>
<span>{new Date(r.created_at).toLocaleString()}</span>
</div>
<div className="tlist-card-id">{r.id}</div>
<div className="tlist-card-actions" onClick={(e) => e.stopPropagation()}>
{r.status === 'pending' && (
<button
type="button"
className="tlist-btn small"
disabled={busy === r.id}
onClick={() => onDeploy(r.id)}
title="Deploy this topology"
>
<UploadCloud size={10} /> DEPLOY
</button>
)}
{['active', 'degraded', 'failed', 'deploying'].includes(r.status) && (
<button
type="button"
className={`tlist-btn small warn ${armed === `td:${r.id}` ? 'armed' : ''}`}
disabled={busy === r.id}
onClick={() => armed === `td:${r.id}` ? onTeardown(r.id) : arm(`td:${r.id}`)}
title={armed === `td:${r.id}` ? 'Click again to confirm teardown' : 'Teardown this topology'}
>
<Power size={10} /> {armed === `td:${r.id}` ? 'CONFIRM?' : 'TEARDOWN'}
</button>
)}
<button
type="button"
className={`tlist-btn small danger ${armed === r.id ? 'armed' : ''}`}
disabled={busy === r.id}
onClick={() => armed === r.id ? onDelete(r.id) : arm(r.id)}
title={armed === r.id ? 'Click again to confirm' : 'Delete'}
>
<Trash2 size={10} /> {armed === r.id ? 'CONFIRM?' : 'DELETE'}
</button>
</div>
</div>
))}
{!loading && rows.length === 0 && (
<div className="tlist-empty">
No topologies yet. Click NEW TOPOLOGY to create one.
</div>
)}
</div>
</div>
);
};
export default TopologyList;