feat(web): two-step topology creation wizard pinned to target host

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.
This commit is contained in:
2026-04-21 01:48:05 -04:00
parent 12e18b75db
commit 050607e00d
3 changed files with 593 additions and 37 deletions

View File

@@ -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<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);
@@ -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<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 onCreated = (row: TopologySummary) => {
setCreating(false);
navigate(`/mazenet?topology=${row.id}`);
};
const onDelete = async (id: string) => {
@@ -140,33 +131,17 @@ const TopologyList: React.FC = () => {
<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)}>
<button type="button" className="tlist-btn" onClick={() => setCreating(true)}>
<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>
)}
<CreateTopologyWizard
open={creating}
onClose={() => setCreating(false)}
onCreated={onCreated}
/>
<div className="tlist-grid">
{rows.map((r) => (