From c942d4d333e74b308ef99bd2abac85401c125fd8 Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 28 Apr 2026 23:04:13 -0400 Subject: [PATCH] feat(ui): scope canary tokens to MazeNET topology deckies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CanaryTokens.tsx grows a Fleet/MazeNET toggle in the create modal. In topology mode we hydrate /topologies?status=active for the topology picker, then GET /topologies/{id} on selection to repopulate the decky picker — topology deckies have a different shape than fleet's /deckies endpoint. The tokens table gains a SCOPE column (chip: 'fleet' / 'topology'), and a third filter dropdown alongside state. The drawer's metadata section shows a Scope row with a clickable jump-link back to the MazeNET view at the right topology. CanaryTokenRow grows a topology_id field so the drawer/list can discriminate without re-fetching. --- .../src/components/CanaryTokenDrawer.tsx | 17 ++ decnet_web/src/components/CanaryTokens.tsx | 150 +++++++++++++++++- 2 files changed, 159 insertions(+), 8 deletions(-) diff --git a/decnet_web/src/components/CanaryTokenDrawer.tsx b/decnet_web/src/components/CanaryTokenDrawer.tsx index eccd86e6..6d247d26 100644 --- a/decnet_web/src/components/CanaryTokenDrawer.tsx +++ b/decnet_web/src/components/CanaryTokenDrawer.tsx @@ -8,6 +8,10 @@ export interface CanaryTokenRow { uuid: string; kind: 'http' | 'dns' | 'aws_passive'; decky_name: string; + // Set when the token targets a MazeNET topology decky. Null/absent + // for fleet tokens. Drives the "scope" badge in the list and the + // topology jump-link in the drawer. + topology_id: string | null; blob_uuid: string | null; instrumenter: string | null; generator: string | null; @@ -247,6 +251,19 @@ const CanaryTokenDrawer: React.FC = ({ token, onClose, onRevoked }) => { {token.uuid}} /> + + topology · {token.topology_id.slice(0, 8)}… + + ) : ( + fleet + )} + /> {token.callback_token}} /> diff --git a/decnet_web/src/components/CanaryTokens.tsx b/decnet_web/src/components/CanaryTokens.tsx index 83f3e5bc..2daf072b 100644 --- a/decnet_web/src/components/CanaryTokens.tsx +++ b/decnet_web/src/components/CanaryTokens.tsx @@ -66,19 +66,69 @@ interface DeckyOption { ip?: string; } +interface TopologyOption { + id: string; + name: string; + status: string; +} + +type Scope = 'fleet' | 'topology'; + interface CreateModalProps { blobs: BlobRow[]; deckies: DeckyOption[]; + topologies: TopologyOption[]; onClose: () => void; onCreated: (token: CanaryTokenRow) => void; } -const CreateModal: React.FC = ({ blobs, deckies, onClose, onCreated }) => { +const CreateModal: React.FC = ({ blobs, deckies, topologies, onClose, onCreated }) => { const panelRef = useRef(null); useEscapeKey(onClose, true); useFocusTrap(panelRef, true); + const [scope, setScope] = useState('fleet'); + const [topologyId, setTopologyId] = useState(topologies[0]?.id ?? ''); + const [topoDeckies, setTopoDeckies] = useState([]); + const [topoLoading, setTopoLoading] = useState(false); + + // When scope flips to topology (or topology selection changes) we + // hydrate the chosen topology's decky list — different shape than the + // /deckies endpoint, so the picker must repopulate. + useEffect(() => { + if (scope !== 'topology' || !topologyId) { + setTopoDeckies([]); + return; + } + let cancelled = false; + setTopoLoading(true); + api.get(`/topologies/${encodeURIComponent(topologyId)}`) + .then((res) => { + if (cancelled) return; + const list: DeckyOption[] = (res.data?.deckies ?? []).map( + (d: { name: string; ip?: string }) => ({ name: d.name, ip: d.ip }), + ); + setTopoDeckies(list); + }) + .catch(() => { if (!cancelled) setTopoDeckies([]); }) + .finally(() => { if (!cancelled) setTopoLoading(false); }); + return () => { cancelled = true; }; + }, [scope, topologyId]); + + const activeDeckies = scope === 'topology' ? topoDeckies : deckies; const [decky, setDecky] = useState(deckies[0]?.name ?? ''); + + // Reset the decky selection when the active list changes — otherwise + // a fleet decky name lingers as a stale value when the user flips to + // a topology that doesn't have that decky. + useEffect(() => { + if (activeDeckies.length === 0) { + setDecky(''); + } else if (!activeDeckies.some((d) => d.name === decky)) { + setDecky(activeDeckies[0].name); + } + }, [activeDeckies]); // eslint-disable-line react-hooks/exhaustive-deps + const [kind, setKind] = useState<'http' | 'dns' | 'aws_passive'>('http'); const [path, setPath] = useState('/home/admin/.aws/credentials'); const [source, setSource] = useState<'generator' | 'blob'>('generator'); @@ -89,6 +139,7 @@ const CreateModal: React.FC = ({ blobs, deckies, onClose, onCr const handleSubmit = async () => { setError(null); + if (scope === 'topology' && !topologyId) return setError('Pick a topology.'); if (!decky.trim()) return setError('Pick a decky.'); if (!path.trim().startsWith('/')) return setError('placement_path must be absolute.'); if (source === 'blob' && !blobUuid) return setError('Pick a blob or switch to Generator.'); @@ -99,6 +150,7 @@ const CreateModal: React.FC = ({ blobs, deckies, onClose, onCr kind, placement_path: path.trim(), }; + if (scope === 'topology') body.topology_id = topologyId; if (source === 'generator') body.generator = generator; else body.blob_uuid = blobUuid; const res = await api.post('/canary/tokens', body); @@ -138,10 +190,58 @@ const CreateModal: React.FC = ({ blobs, deckies, onClose, onCr +
+ {(['fleet', 'topology'] as const).map((s) => ( + + ))} +
+ + {scope === 'topology' && ( + + {topologies.length === 0 ? ( +
+ No active topologies. Deploy one from MazeNET first. +
+ ) : ( + + )} +
+ )} + - {deckies.length === 0 ? ( + {topoLoading ? (
- No deckies running. Deploy a fleet first. + loading topology deckies… +
+ ) : activeDeckies.length === 0 ? ( +
+ {scope === 'topology' + ? 'This topology has no deckies.' + : 'No fleet deckies running. Deploy a fleet first.'}
) : ( + {loading &&
loading…
} @@ -553,7 +673,7 @@ const CanaryTokens: React.FC = () => { onClick={() => setDrawerToken(t)} style={{ display: 'grid', - gridTemplateColumns: '110px 140px 1fr 100px 110px 80px', + gridTemplateColumns: '110px 80px 140px 1fr 100px 110px 80px', alignItems: 'center', gap: '12px', padding: '10px 14px', border: '1px solid var(--border-color, #30363d)', @@ -570,6 +690,19 @@ const CanaryTokens: React.FC = () => { }}> ● {t.state.toUpperCase()} + + {t.topology_id ? 'topology' : 'fleet'} + {t.decky_name} {t.placement_path} @@ -640,6 +773,7 @@ const CanaryTokens: React.FC = () => { setShowCreate(false)} onCreated={(t) => { setTokens((prev) => [t, ...prev]);