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:
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