Files
DECNET/decnet_web/src/components/TopologyList/TopologyList.tsx
anti a1bc8a878b feat(web/mazenet): show target host in topology list + war map
Renders the swarm host (or "master") that a topology is deployed to,
both as a meta line on each topology list card and in the war-map
header. Operators can now distinguish master-local from agent-targeted
topologies at a glance — previously the only signal was the abstract
"mode: agent" label, with no hint of which agent.

Adds useSwarmHosts() hook for the uuid → host lookup. Falls back to a
short uuid prefix when the hosts list is unavailable so the UI never
hard-fails on a missing /swarm/hosts response.

TopologySummary gains target_host_uuid in the frontend type so the
field actually narrows when checked.
2026-04-25 03:29:49 -04:00

269 lines
9.1 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw, Skull, Server, Cpu } from '../../icons';
import api from '../../utils/api';
import { useSwarmHosts } from '../../hooks/useSwarmHosts';
import { clearLayout } from '../MazeNET/useMazeLayoutStore';
import CreateTopologyWizard from './CreateTopologyWizard';
import EmptyState from '../EmptyState/EmptyState';
import './TopologyList.css';
interface TopologySummary {
id: string;
name: string;
mode: string;
target_host_uuid: string | null;
status: string;
version: number;
needs_resync?: boolean;
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 { byUuid: hostsByUuid } = useSwarmHosts();
const [rows, setRows] = useState<TopologySummary[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [armed, setArmed] = useState<string | null>(null);
const [reaping, setReaping] = useState(false);
const [reapMsg, setReapMsg] = 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 onCreated = (row: TopologySummary) => {
setCreating(false);
navigate(`/mazenet?topology=${row.id}`);
};
const onDelete = async (id: string) => {
setBusy(id);
try {
await api.delete(`/topologies/${id}`);
clearLayout(id);
await fetchRows();
} catch (e) {
setErr((e as Error)?.message ?? 'delete failed');
} finally {
setBusy(null);
setArmed(null);
}
};
const onReapOrphans = async () => {
setReaping(true);
setReapMsg(null);
try {
const { data } = await api.post<{
orphan_prefixes: string[];
containers_removed: string[];
networks_removed: string[];
errors: string[];
}>('/topologies/reap-orphans', {});
const c = data.containers_removed.length;
const n = data.networks_removed.length;
const e = data.errors.length;
if (c === 0 && n === 0 && e === 0) {
setReapMsg('no orphans found');
} else {
setReapMsg(`removed ${c} container(s), ${n} network(s)${e ? `, ${e} error(s)` : ''}`);
}
await fetchRows();
} catch (e) {
setReapMsg((e as Error)?.message ?? 'reap failed');
} finally {
setReaping(false);
setArmed(null);
setTimeout(() => setReapMsg(null), 6000);
}
};
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-root tlist-page">
<div className="page-header">
<div className="page-title-group">
<h1>TOPOLOGIES</h1>
<span className="page-sub">
{loading ? 'LOADING…' : `${rows.length} ${rows.length === 1 ? 'TOPOLOGY' : 'TOPOLOGIES'}`}
{err && <span className="alert-text"> · {err}</span>}
{reapMsg && <span className="alert-text"> · reap: {reapMsg}</span>}
</span>
</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 ghost warn ${armed === 'reap' ? 'armed' : ''}`}
disabled={reaping}
onClick={() => armed === 'reap' ? onReapOrphans() : arm('reap')}
title={armed === 'reap'
? 'Click again to force-remove Docker resources for deleted topologies'
: 'Reap orphan Docker resources (admin)'}
>
<Skull size={12} /> {reaping ? 'REAPING…' : armed === 'reap' ? 'CONFIRM?' : 'REAP ORPHANS'}
</button>
<button type="button" className="tlist-btn" onClick={() => setCreating(true)}>
<Plus size={12} /> NEW TOPOLOGY
</button>
</div>
</div>
<CreateTopologyWizard
open={creating}
onClose={() => setCreating(false)}
onCreated={onCreated}
/>
{!loading && rows.length === 0 ? (
<div className="tlist-empty-wrap">
<EmptyState
icon={Network}
title="NO TOPOLOGIES YET"
hint="spin one up to deploy a honeynet"
cta={{ label: 'NEW TOPOLOGY', icon: Plus, onClick: () => setCreating(true) }}
/>
</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">
{r.mode === 'agent' && r.target_host_uuid ? (
<span title={r.target_host_uuid}>
<Server size={11} style={{ marginRight: 4, verticalAlign: '-1px' }} />
{hostsByUuid.get(r.target_host_uuid)?.name ?? `host:${r.target_host_uuid.slice(0, 8)}`}
</span>
) : (
<span>
<Cpu size={11} style={{ marginRight: 4, verticalAlign: '-1px' }} />
master
</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>
)}
{!['active', 'degraded', 'deploying'].includes(r.status) && (
<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>
))}
</div>
)}
</div>
);
};
export default TopologyList;