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:
217
decnet_web/src/components/TopologyList/CreateTopologyWizard.css
Normal file
217
decnet_web/src/components/TopologyList/CreateTopologyWizard.css
Normal file
@@ -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); }
|
||||
364
decnet_web/src/components/TopologyList/CreateTopologyWizard.tsx
Normal file
364
decnet_web/src/components/TopologyList/CreateTopologyWizard.tsx
Normal file
@@ -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<Props> = ({ open, onClose, onCreated }) => {
|
||||
const [step, setStep] = useState<0 | 1>(0);
|
||||
const [targetId, setTargetId] = useState<string | null>(null); // LOCAL_CARD_ID or host uuid
|
||||
const [kind, setKind] = useState<Kind | null>(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<string>('');
|
||||
|
||||
const [hosts, setHosts] = useState<SwarmHost[]>([]);
|
||||
const [hostsLoaded, setHostsLoaded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(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<SwarmHost[]>('/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<TopologySummary>('/topologies/blank', {
|
||||
name: name.trim(),
|
||||
mode,
|
||||
target_host_uuid: targetHostUuid,
|
||||
});
|
||||
onCreated(data);
|
||||
} else {
|
||||
const body: Record<string, unknown> = {
|
||||
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<TopologySummary>('/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 = (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setTargetId(LOCAL_CARD_ID)}
|
||||
className={`ctw-card ${targetId === LOCAL_CARD_ID ? 'selected' : ''}`}
|
||||
>
|
||||
<div className="ctw-card-head">
|
||||
<Cpu size={16} className="ctw-violet" />
|
||||
<span className="ctw-card-name">RUN LOCALLY</span>
|
||||
</div>
|
||||
<div className="ctw-card-sub">master</div>
|
||||
<div className="ctw-card-desc">Topology materialises on this master host via the local docker daemon.</div>
|
||||
</div>
|
||||
|
||||
{hosts.map((h) => {
|
||||
const routable = h.status === 'active' || h.status === 'enrolled';
|
||||
return (
|
||||
<div
|
||||
key={h.uuid}
|
||||
onClick={() => routable && setTargetId(h.uuid)}
|
||||
className={`ctw-card ${targetId === h.uuid ? 'selected' : ''} ${routable ? '' : 'disabled'}`}
|
||||
title={routable ? undefined : `host is ${h.status}`}
|
||||
>
|
||||
<div className="ctw-card-head">
|
||||
<Server size={16} className="ctw-violet" />
|
||||
<span className="ctw-card-name">{h.name}</span>
|
||||
</div>
|
||||
<div className="ctw-card-sub">
|
||||
{h.address}:{h.agent_port} · {h.status}
|
||||
</div>
|
||||
<div className="ctw-card-desc">
|
||||
Topology pushed over mTLS to this swarm worker.
|
||||
{h.last_heartbeat && (
|
||||
<>
|
||||
<br />
|
||||
<span className="ctw-dim">last seen {new Date(h.last_heartbeat).toLocaleTimeString()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{hostsLoaded && hosts.length === 0 && (
|
||||
<div className="ctw-card-note">
|
||||
No agents enrolled yet. Only local deployment is available.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const step1Cards = (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setKind('blank')}
|
||||
className={`ctw-card ${kind === 'blank' ? 'selected' : ''}`}
|
||||
>
|
||||
<div className="ctw-card-head">
|
||||
<FileText size={16} className="ctw-violet" />
|
||||
<span className="ctw-card-name">BLANK</span>
|
||||
</div>
|
||||
<div className="ctw-card-sub">start from scratch</div>
|
||||
<div className="ctw-card-desc">
|
||||
Creates an empty topology with a single DMZ LAN and its gateway decky. Build out the rest in the editor.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setKind('seeded')}
|
||||
className={`ctw-card ${kind === 'seeded' ? 'selected' : ''}`}
|
||||
>
|
||||
<div className="ctw-card-head">
|
||||
<Sparkles size={16} className="ctw-violet" />
|
||||
<span className="ctw-card-name">SEED-BASED</span>
|
||||
</div>
|
||||
<div className="ctw-card-sub">procedurally generated</div>
|
||||
<div className="ctw-card-desc">
|
||||
Runs the MazeNET generator with depth/branching/deckies parameters. Seed is optional — omit for a fresh roll.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const targetLabel =
|
||||
targetId === LOCAL_CARD_ID ? 'RUN LOCALLY' : selectedHost ? selectedHost.name : '—';
|
||||
|
||||
return (
|
||||
<div className="ctw-backdrop" onClick={onClose}>
|
||||
<div className="ctw-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ctw-head">
|
||||
<h3>
|
||||
<Sparkles size={14} style={{ marginRight: 8 }} /> NEW TOPOLOGY
|
||||
</h3>
|
||||
<button className="ctw-close" onClick={onClose} aria-label="close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ctw-steps">
|
||||
{['TARGET', 'TYPE'].map((label, i) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`ctw-step ${i === step ? 'active' : i < step ? 'done' : ''}`}
|
||||
>
|
||||
{i + 1}. {label}
|
||||
{i < step && <Check size={11} style={{ marginLeft: 6 }} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ctw-body">
|
||||
{step === 0 && (
|
||||
<>
|
||||
<div className="ctw-label">Where should this topology run?</div>
|
||||
<div className="ctw-grid-3">{step0Cards}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="ctw-label">
|
||||
Target: <span className="ctw-violet">{targetLabel}</span> · pick a starting point.
|
||||
</div>
|
||||
<div className="ctw-grid-2">{step1Cards}</div>
|
||||
|
||||
<div className="ctw-field">
|
||||
<label>NAME</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. honeynet-dev"
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{kind === 'seeded' && (
|
||||
<div className="ctw-grid-2">
|
||||
<div className="ctw-field">
|
||||
<label>DEPTH ({depth})</label>
|
||||
<input type="range" min={1} max={6} value={depth} onChange={(e) => setDepth(+e.target.value)} />
|
||||
</div>
|
||||
<div className="ctw-field">
|
||||
<label>BRANCHING ({branchingFactor})</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={4}
|
||||
value={branchingFactor}
|
||||
onChange={(e) => setBranchingFactor(+e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ctw-field">
|
||||
<label>DECKIES / LAN MIN ({minDeckies})</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={8}
|
||||
value={minDeckies}
|
||||
onChange={(e) => {
|
||||
const v = +e.target.value;
|
||||
setMinDeckies(v);
|
||||
if (v > maxDeckies) setMaxDeckies(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="ctw-field">
|
||||
<label>DECKIES / LAN MAX ({maxDeckies})</label>
|
||||
<input
|
||||
type="range"
|
||||
min={Math.max(1, minDeckies)}
|
||||
max={12}
|
||||
value={maxDeckies}
|
||||
onChange={(e) => setMaxDeckies(+e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ctw-field" style={{ gridColumn: '1 / -1' }}>
|
||||
<label>SEED (optional, integer)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={seed}
|
||||
onChange={(e) => setSeed(e.target.value)}
|
||||
placeholder="leave blank for random"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{err && <div className="ctw-error">{err}</div>}
|
||||
</div>
|
||||
|
||||
<div className="ctw-foot">
|
||||
<button className="ctw-btn ghost" onClick={onClose}>
|
||||
CANCEL
|
||||
</button>
|
||||
<div className="ctw-foot-right">
|
||||
{step > 0 && !submitting && (
|
||||
<button className="ctw-btn ghost" onClick={() => setStep(0)}>
|
||||
← BACK
|
||||
</button>
|
||||
)}
|
||||
{step === 0 && (
|
||||
<button className="ctw-btn" disabled={!canNext} onClick={() => setStep(1)}>
|
||||
NEXT →
|
||||
</button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<button className="ctw-btn" disabled={!canNext || submitting} onClick={handleCreate}>
|
||||
{submitting ? 'CREATING…' : 'CREATE'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTopologyWizard;
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user