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:
@@ -11,6 +11,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from .api_catalog import router as _catalog_router
|
from .api_catalog import router as _catalog_router
|
||||||
from .api_create_topology import router as _create_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_decky_crud import router as _decky_router
|
||||||
from .api_delete_topology import router as _delete_router
|
from .api_delete_topology import router as _delete_router
|
||||||
from .api_deploy_topology import router as _deploy_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_lan_crud import router as _lan_router
|
||||||
from .api_list_topologies import router as _list_router
|
from .api_list_topologies import router as _list_router
|
||||||
from .api_mutations import router as _mutations_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"])
|
topology_router = APIRouter(prefix="/topologies", tags=["topologies"])
|
||||||
|
|
||||||
@@ -29,8 +31,10 @@ topology_router = APIRouter(prefix="/topologies", tags=["topologies"])
|
|||||||
# parameterized fallback.
|
# parameterized fallback.
|
||||||
topology_router.include_router(_catalog_router)
|
topology_router.include_router(_catalog_router)
|
||||||
topology_router.include_router(_list_router)
|
topology_router.include_router(_list_router)
|
||||||
|
topology_router.include_router(_create_blank_router)
|
||||||
topology_router.include_router(_create_router)
|
topology_router.include_router(_create_router)
|
||||||
topology_router.include_router(_deploy_router)
|
topology_router.include_router(_deploy_router)
|
||||||
|
topology_router.include_router(_teardown_router)
|
||||||
topology_router.include_router(_delete_router)
|
topology_router.include_router(_delete_router)
|
||||||
topology_router.include_router(_lan_router)
|
topology_router.include_router(_lan_router)
|
||||||
topology_router.include_router(_decky_router)
|
topology_router.include_router(_decky_router)
|
||||||
|
|||||||
79
decnet/web/router/topology/api_teardown_topology.py
Normal file
79
decnet/web/router/topology/api_teardown_topology.py
Normal 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)
|
||||||
94
decnet_web/src/components/TopologyList/TopologyList.css
Normal file
94
decnet_web/src/components/TopologyList/TopologyList.css
Normal 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);
|
||||||
|
}
|
||||||
228
decnet_web/src/components/TopologyList/TopologyList.tsx
Normal file
228
decnet_web/src/components/TopologyList/TopologyList.tsx
Normal 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;
|
||||||
Reference in New Issue
Block a user