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.
269 lines
9.1 KiB
TypeScript
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;
|