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