From 050607e00d0e64e974a595083df01d960ae08622 Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 21 Apr 2026 01:48:05 -0400 Subject: [PATCH] feat(web): two-step topology creation wizard pinned to target host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single-line name input with a modal that mirrors the design-handoff DeployWizard shape (backdrop + violet-bordered panel, wizard-step tabs, card-picker body): - Step 1 — TARGET: a RUN LOCALLY card plus one card per enrolled swarm host. Non-routable hosts render disabled with their status as the tooltip. Selecting an agent pins the topology via target_host_uuid; local stays unihost. - Step 2 — TYPE: BLANK (POST /topologies/blank) or SEED-BASED (POST /topologies/ with depth, branching, deckies-per-LAN, optional seed). Name is required on both. Existing navigate-to-editor-on-create behavior is preserved. --- .../TopologyList/CreateTopologyWizard.css | 217 +++++++++++ .../TopologyList/CreateTopologyWizard.tsx | 364 ++++++++++++++++++ .../components/TopologyList/TopologyList.tsx | 49 +-- 3 files changed, 593 insertions(+), 37 deletions(-) create mode 100644 decnet_web/src/components/TopologyList/CreateTopologyWizard.css create mode 100644 decnet_web/src/components/TopologyList/CreateTopologyWizard.tsx diff --git a/decnet_web/src/components/TopologyList/CreateTopologyWizard.css b/decnet_web/src/components/TopologyList/CreateTopologyWizard.css new file mode 100644 index 00000000..54e8a6c3 --- /dev/null +++ b/decnet_web/src/components/TopologyList/CreateTopologyWizard.css @@ -0,0 +1,217 @@ +/* Mirrors the design-handoff DeployWizard: backdrop + bordered modal, + * wizard-step tabs, two-column card picker, matrix/violet palette. + * Scoped with .ctw- prefix so nothing leaks into other views. */ + +.ctw-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.78); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(1px); +} + +.ctw-modal { + width: 880px; + max-width: 96vw; + max-height: 90vh; + background: var(--panel); + border: 1px solid var(--violet); + box-shadow: 0 0 30px rgba(238, 130, 238, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; + font-family: var(--font-mono); + color: var(--text-color); +} + +.ctw-head { + padding: 14px 22px; + border-bottom: 1px solid var(--border, var(--panel-border)); + display: flex; + justify-content: space-between; + align-items: center; +} +.ctw-head h3 { + margin: 0; + font-size: 0.82rem; + letter-spacing: 3px; + display: inline-flex; + align-items: center; +} +.ctw-close { + background: transparent; + border: none; + color: var(--text-color); + cursor: pointer; + padding: 4px; +} +.ctw-close:hover { color: var(--violet); } + +.ctw-steps { + display: flex; + border-bottom: 1px solid var(--border, var(--panel-border)); +} +.ctw-step { + flex: 1; + padding: 12px 14px; + font-size: 0.65rem; + letter-spacing: 1.5px; + opacity: 0.4; + border-bottom: 2px solid transparent; + text-align: center; + display: flex; + align-items: center; + justify-content: center; +} +.ctw-step.active { + opacity: 1; + border-bottom-color: var(--violet); + color: var(--violet); +} +.ctw-step.done { opacity: 0.8; color: var(--matrix, #33ff66); } + +.ctw-body { + padding: 20px 22px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 18px; +} + +.ctw-label { + font-size: 0.75rem; + letter-spacing: 1.5px; + color: var(--dim-color); +} +.ctw-violet { color: var(--violet); } +.ctw-dim { color: var(--dim-color); } + +.ctw-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} +.ctw-grid-3 { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 14px; +} + +.ctw-card { + padding: 14px; + border: 1px solid var(--border, var(--panel-border)); + background: var(--panel); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 8px; + transition: border-color 0.15s, background 0.15s; +} +.ctw-card:hover { border-color: var(--violet); } +.ctw-card.selected { + border-color: var(--violet); + background: var(--violet-tint-10, rgba(238, 130, 238, 0.1)); +} +.ctw-card.disabled { + opacity: 0.45; + cursor: not-allowed; +} +.ctw-card-head { + display: flex; + align-items: center; + gap: 8px; +} +.ctw-card-name { + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 1px; +} +.ctw-card-sub { + font-size: 0.62rem; + letter-spacing: 1px; + color: var(--dim-color); + text-transform: uppercase; +} +.ctw-card-desc { + font-size: 0.72rem; + line-height: 1.4; + color: var(--text-color); + opacity: 0.85; +} +.ctw-card-note { + grid-column: 1 / -1; + padding: 14px; + border: 1px dashed var(--border, var(--panel-border)); + color: var(--dim-color); + font-size: 0.72rem; + letter-spacing: 1px; +} + +.ctw-field { + display: flex; + flex-direction: column; + gap: 6px; +} +.ctw-field label { + font-size: 0.62rem; + letter-spacing: 1.5px; + color: var(--dim-color); +} +.ctw-field input[type='text'] { + padding: 8px 10px; + background: #000; + border: 1px solid var(--border, var(--panel-border)); + color: var(--text-color); + font-family: var(--font-mono); + font-size: 0.8rem; +} +.ctw-field input[type='text']:focus { + outline: none; + border-color: var(--violet); +} +.ctw-field input[type='range'] { + accent-color: var(--violet); +} + +.ctw-error { + padding: 10px 12px; + border: 1px solid var(--alert, #e74c3c); + color: var(--alert, #e74c3c); + font-size: 0.72rem; + letter-spacing: 0.5px; +} + +.ctw-foot { + padding: 14px 22px; + border-top: 1px solid var(--border, var(--panel-border)); + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} +.ctw-foot-right { + display: flex; + gap: 8px; +} + +.ctw-btn { + padding: 6px 14px; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 1.5px; + background: var(--violet); + color: #000; + border: none; + cursor: pointer; +} +.ctw-btn:hover { filter: brightness(1.15); } +.ctw-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.ctw-btn.ghost { + background: transparent; + color: var(--text-color); + border: 1px solid var(--border, var(--panel-border)); +} +.ctw-btn.ghost:hover { border-color: var(--violet); color: var(--violet); } diff --git a/decnet_web/src/components/TopologyList/CreateTopologyWizard.tsx b/decnet_web/src/components/TopologyList/CreateTopologyWizard.tsx new file mode 100644 index 00000000..6569dcc5 --- /dev/null +++ b/decnet_web/src/components/TopologyList/CreateTopologyWizard.tsx @@ -0,0 +1,364 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { X, Server, Cpu, FileText, Sparkles, Check } from 'lucide-react'; +import api from '../../utils/api'; +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 [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}
+ + )} + + {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; diff --git a/decnet_web/src/components/TopologyList/TopologyList.tsx b/decnet_web/src/components/TopologyList/TopologyList.tsx index e7d79c26..bf516e30 100644 --- a/decnet_web/src/components/TopologyList/TopologyList.tsx +++ b/decnet_web/src/components/TopologyList/TopologyList.tsx @@ -3,14 +3,17 @@ import { useNavigate } from 'react-router-dom'; import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw } from 'lucide-react'; import api from '../../utils/api'; import { clearLayout } from '../MazeNET/useMazeLayoutStore'; +import CreateTopologyWizard from './CreateTopologyWizard'; 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; } @@ -42,7 +45,6 @@ const TopologyList: React.FC = () => { const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [creating, setCreating] = useState(false); - const [newName, setNewName] = useState(''); const [busy, setBusy] = useState(null); const [armed, setArmed] = useState(null); @@ -71,20 +73,9 @@ const TopologyList: React.FC = () => { 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('/topologies/blank', { name }); - navigate(`/mazenet?topology=${created.id}`); - } catch (e) { - setErr((e as Error)?.message ?? 'create failed'); - } finally { - setBusy(null); - setCreating(false); - setNewName(''); - } + const onCreated = (row: TopologySummary) => { + setCreating(false); + navigate(`/mazenet?topology=${row.id}`); }; const onDelete = async (id: string) => { @@ -140,33 +131,17 @@ const TopologyList: React.FC = () => { - - {creating && ( -
- setNewName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') onCreate(); - if (e.key === 'Escape') { setCreating(false); setNewName(''); } - }} - /> - - -
- )} + setCreating(false)} + onCreated={onCreated} + />
{rows.map((r) => (