diff --git a/decnet_web/src/components/CanaryTokens.tsx b/decnet_web/src/components/CanaryTokens.tsx index d1a77b45..e2cfac0a 100644 --- a/decnet_web/src/components/CanaryTokens.tsx +++ b/decnet_web/src/components/CanaryTokens.tsx @@ -8,295 +8,12 @@ import { useFocusTrap } from '../hooks/useFocusTrap'; import CanaryTokenDrawer from './CanaryTokenDrawer'; import type { CanaryTokenRow } from './CanaryTokenDrawer'; import { - KNOWN_GENERATORS, KIND_OPTIONS, STATE_COLOR, - type BlobRow, type DeckyOption, type TopologyOption, type Scope, type GeneratorName, + STATE_COLOR, + type BlobRow, type DeckyOption, type TopologyOption, type Scope, } from './CanaryTokens/types'; import { extractError, fmt, fmtBytes } from './CanaryTokens/helpers'; import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field, Stat } from './CanaryTokens/ui'; - -// ─── CREATE MODAL ────────────────────────────────────────────────────────── - -interface CreateModalProps { - blobs: BlobRow[]; - deckies: DeckyOption[]; - topologies: TopologyOption[]; - onClose: () => void; - onCreated: (token: CanaryTokenRow) => void; -} - -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'); - 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}
- )} - -
- - -
-
-
- ); -}; +import { CreateTokenModal } from './CanaryTokens/CreateTokenModal'; // ─── BLOB UPLOAD MODAL ───────────────────────────────────────────────────── @@ -1165,7 +882,7 @@ const CanaryTokens: React.FC = () => { )} {showCreate && ( - ({ useFocusTrap: () => {} })); + +const deckies: DeckyOption[] = [{ name: 'decoy-01', ip: '10.0.0.1' }]; +const topologies: TopologyOption[] = [{ id: 't-1', name: 'corp-net', status: 'active' }]; + +describe('CreateTokenModal', () => { + it('renders the title and the Fleet/MazeNET scope toggle', () => { + render( + {}} + onCreated={() => {}} + />, + ); + expect(screen.getByText('NEW CANARY TOKEN')).toBeInTheDocument(); + expect(screen.getByText('Fleet')).toBeInTheDocument(); + expect(screen.getByText('MazeNET topology')).toBeInTheDocument(); + }); + + it('CANCEL invokes onClose', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render( + {}} + />, + ); + await user.click(screen.getByText('CANCEL')); + expect(onClose).toHaveBeenCalled(); + }); + + it('shows the empty-deckies message when fleet has no deckies', () => { + render( + {}} + onCreated={() => {}} + />, + ); + expect( + screen.getByText('No fleet deckies running. Deploy a fleet first.'), + ).toBeInTheDocument(); + }); + + it('switching to Operator upload reveals the no-blobs hint', async () => { + const user = userEvent.setup(); + render( + {}} + onCreated={() => {}} + />, + ); + await user.click(screen.getByText('Operator upload')); + expect( + screen.getByText(/No blobs uploaded yet/), + ).toBeInTheDocument(); + }); +}); diff --git a/decnet_web/src/components/CanaryTokens/CreateTokenModal.tsx b/decnet_web/src/components/CanaryTokens/CreateTokenModal.tsx new file mode 100644 index 00000000..8d8e1228 --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/CreateTokenModal.tsx @@ -0,0 +1,298 @@ +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}
+ )} + +
+ + +
+
+
+ ); +};