import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { X, Server, Cpu, FileText, Sparkles, Check } from 'lucide-react'; import api from '../../utils/api'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { useFocusTrap } from '../../hooks/useFocusTrap'; import './CreateTopologyWizard.css'; /* Shape of GET /swarm/hosts rows (mirrors SwarmHostView). */ interface SwarmHost { uuid: string; name: string; address: string; agent_port: number; status: string; last_heartbeat: string | null; } 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; } type Kind = 'blank' | 'seeded'; interface Props { open: boolean; onClose: () => void; onCreated: (row: TopologySummary) => void; } const LOCAL_CARD_ID = '__local__'; const CreateTopologyWizard: React.FC = ({ open, onClose, onCreated }) => { const panelRef = useRef(null); useEscapeKey(onClose, open); useFocusTrap(panelRef, open); useEffect(() => { if (!open) return; const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, [open]); const [step, setStep] = useState<0 | 1>(0); const [targetId, setTargetId] = useState(null); // LOCAL_CARD_ID or host uuid const [kind, setKind] = useState(null); const [name, setName] = useState(''); const [depth, setDepth] = useState(2); const [branchingFactor, setBranchingFactor] = useState(2); const [minDeckies, setMinDeckies] = useState(1); const [maxDeckies, setMaxDeckies] = useState(3); const [seed, setSeed] = useState(''); const [hosts, setHosts] = useState([]); const [hostsLoaded, setHostsLoaded] = useState(false); const [submitting, setSubmitting] = useState(false); const [err, setErr] = useState(null); /* Reset state whenever the modal opens so a cancelled run doesn't * leak into the next attempt. */ useEffect(() => { if (!open) return; setStep(0); setTargetId(null); setKind(null); setName(''); setDepth(2); setBranchingFactor(2); setMinDeckies(1); setMaxDeckies(3); setSeed(''); setErr(null); setSubmitting(false); }, [open]); const fetchHosts = useCallback(async () => { try { const { data } = await api.get('/swarm/hosts'); setHosts(data ?? []); } catch (e) { /* Non-fatal: the user can still pick LOCAL. */ setHosts([]); } finally { setHostsLoaded(true); } }, []); useEffect(() => { if (open) fetchHosts(); }, [open, fetchHosts]); const selectedHost = useMemo( () => (targetId && targetId !== LOCAL_CARD_ID ? hosts.find((h) => h.uuid === targetId) ?? null : null), [targetId, hosts], ); const canNext = step === 0 ? !!targetId : !!kind && name.trim().length > 0; const handleCreate = async () => { if (!targetId || !kind) return; setSubmitting(true); setErr(null); const isAgent = targetId !== LOCAL_CARD_ID; const targetHostUuid = isAgent ? targetId : null; const mode = isAgent ? 'agent' : 'unihost'; try { if (kind === 'blank') { const { data } = await api.post('/topologies/blank', { name: name.trim(), mode, target_host_uuid: targetHostUuid, }); onCreated(data); } else { const body: Record = { name: name.trim(), mode, target_host_uuid: targetHostUuid, depth, branching_factor: branchingFactor, deckies_per_lan_min: minDeckies, deckies_per_lan_max: maxDeckies, randomize_services: true, }; const parsedSeed = seed.trim(); if (parsedSeed !== '') { const n = Number(parsedSeed); if (Number.isFinite(n) && n >= 0) body.seed = Math.floor(n); } const { data } = await api.post('/topologies/', body); onCreated(data); } } catch (e) { const msg = // axios response shape // eslint-disable-next-line @typescript-eslint/no-explicit-any ((e as any)?.response?.data?.detail as string | undefined) ?? (e as Error)?.message ?? 'create failed'; setErr(msg); } finally { setSubmitting(false); } }; if (!open) return null; /* The two cards in step-0 grid: LOCAL first, then each enrolled agent. */ const step0Cards = ( <>
setTargetId(LOCAL_CARD_ID)} className={`ctw-card ${targetId === LOCAL_CARD_ID ? 'selected' : ''}`} >
RUN LOCALLY
master
Topology materialises on this master host via the local docker daemon.
{hosts.map((h) => { const routable = h.status === 'active' || h.status === 'enrolled'; return (
routable && setTargetId(h.uuid)} className={`ctw-card ${targetId === h.uuid ? 'selected' : ''} ${routable ? '' : 'disabled'}`} title={routable ? undefined : `host is ${h.status}`} >
{h.name}
{h.address}:{h.agent_port} · {h.status}
Topology pushed over mTLS to this swarm worker. {h.last_heartbeat && ( <>
last seen {new Date(h.last_heartbeat).toLocaleTimeString()} )}
); })} {hostsLoaded && hosts.length === 0 && (
No agents enrolled yet. Only local deployment is available.
)} ); const step1Cards = ( <>
setKind('blank')} className={`ctw-card ${kind === 'blank' ? 'selected' : ''}`} >
BLANK
start from scratch
Creates an empty topology with a single DMZ LAN and its gateway decky. Build out the rest in the editor.
setKind('seeded')} className={`ctw-card ${kind === 'seeded' ? 'selected' : ''}`} >
SEED-BASED
procedurally generated
Runs the MazeNET generator with depth/branching/deckies parameters. Seed is optional — omit for a fresh roll.
); const targetLabel = targetId === LOCAL_CARD_ID ? 'RUN LOCALLY' : selectedHost ? selectedHost.name : '—'; return (
e.stopPropagation()}>

NEW TOPOLOGY

{['TARGET', 'TYPE'].map((label, i) => (
{i + 1}. {label} {i < step && }
))}
{step === 0 && ( <>
Where should this topology run?
{step0Cards}
HEADS UP: the gateway decky publishes its service ports on the target host (e.g. 0.0.0.0:22{' '} for SSH). Move any host-side daemons off collision ports BEFORE deploying — otherwise docker will fail with{' '} address already in use. On a fresh VPS this usually means relocating sshd to 2222.
)} {step === 1 && ( <>
Target: {targetLabel} · pick a starting point.
{step1Cards}
setName(e.target.value)} placeholder="e.g. honeynet-dev" maxLength={64} />
{kind === 'seeded' && (
setDepth(+e.target.value)} />
setBranchingFactor(+e.target.value)} />
{ const v = +e.target.value; setMinDeckies(v); if (v > maxDeckies) setMaxDeckies(v); }} />
setMaxDeckies(+e.target.value)} />
setSeed(e.target.value)} placeholder="leave blank for random" />
)} )} {err &&
{err}
}
{step > 0 && !submitting && ( )} {step === 0 && ( )} {step === 1 && ( )}
); }; export default CreateTopologyWizard;