// SPDX-License-Identifier: AGPL-3.0-or-later import React, { useEffect, useRef, useState } from 'react'; import { X } from '../../icons'; import api from '../../utils/api'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { useFocusTrap } from '../../hooks/useFocusTrap'; import type { CanaryTokenRow } from '../CanaryTokenDrawer'; import { KNOWN_GENERATORS, KIND_OPTIONS, type BlobRow, type DeckyOption, type TopologyOption, type Scope, type GeneratorName, } from './types'; import { extractError, fmtBytes } from './helpers'; import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field } from './ui'; interface Props { blobs: BlobRow[]; deckies: DeckyOption[]; topologies: TopologyOption[]; onClose: () => void; onCreated: (token: CanaryTokenRow) => void; } /** Modal for planting a new canary token. Lets the operator pick * fleet vs. topology scope, target decky, callback kind, placement * path, and either a built-in template generator or a previously * uploaded blob as the artifact source. */ export const CreateTokenModal: 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'); const [generator, setGenerator] = useState('aws_creds'); const [blobUuid, setBlobUuid] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); 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.'); setSubmitting(true); try { const body: Record = { decky_name: decky.trim(), 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); onCreated(res.data); } catch (err) { setError(extractError(err, 'Create failed.')); } finally { setSubmitting(false); } }; return (
{ if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000, }} >
NEW CANARY TOKEN
{(['fleet', 'topology'] as const).map((s) => ( ))}
{scope === 'topology' && ( {topologies.length === 0 ? (
No active topologies. Deploy one from MazeNET first.
) : ( )}
)} {topoLoading ? (
loading topology deckies…
) : activeDeckies.length === 0 ? (
{scope === 'topology' ? 'This topology has no deckies.' : 'No fleet deckies running. Deploy a fleet first.'}
) : ( )}
setPath(e.target.value)} placeholder="/home/admin/.aws/credentials" style={{ ...INPUT_STYLE, fontFamily: 'monospace' }} />
{(['generator', 'blob'] as const).map((s) => ( ))}
{source === 'generator' && ( )} {source === 'blob' && ( {blobs.length === 0 ? (
No blobs uploaded yet. Use "Upload artifact" on the main page first.
) : ( )}
)} {error && (
{error}
)}
); };