refactor(decnet_web/CanaryTokens): move CreateTokenModal out

Verbatim move of the canary-token creation modal (~280 LOC) into
its own file. Renamed from CreateModal to CreateTokenModal so the
component name carries scope across the package boundary.

- New CanaryTokens/CreateTokenModal.tsx
- CreateTokenModal.test.tsx covers title rendering, CANCEL ->
  onClose, empty-deckies hint, and the Operator-upload mode
  switch revealing the no-blobs message. useFocusTrap is
  vi.mock'd to avoid jsdom focus shenanigans.
- CanaryTokens.tsx loses the inline modal + its now-unused
  imports (KNOWN_GENERATORS, KIND_OPTIONS, GeneratorName).
This commit is contained in:
2026-05-09 05:10:27 -04:00
parent a35048b174
commit e30455551d
3 changed files with 378 additions and 287 deletions

View File

@@ -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<CreateModalProps> = ({ blobs, deckies, topologies, onClose, onCreated }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
const [scope, setScope] = useState<Scope>('fleet');
const [topologyId, setTopologyId] = useState<string>(topologies[0]?.id ?? '');
const [topoDeckies, setTopoDeckies] = useState<DeckyOption[]>([]);
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<GeneratorName>('aws_creds');
const [blobUuid, setBlobUuid] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<string, unknown> = {
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 (
<div
onClick={(e) => { 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,
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
style={{
width: 'min(560px, 100%)', maxHeight: '90vh', overflowY: 'auto',
backgroundColor: 'var(--bg-color, #0d1117)',
border: '1px solid var(--border-color, #30363d)',
padding: '24px', color: 'var(--text-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div style={{ fontSize: '1rem', fontWeight: 'bold' }}>NEW CANARY TOKEN</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
{(['fleet', 'topology'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
style={{
flex: 1,
padding: '8px',
background: scope === s ? 'var(--accent-color, #00ff88)' : 'transparent',
color: scope === s ? 'var(--bg-color, #0d1117)' : 'var(--text-color)',
border: '1px solid var(--border-color, #30363d)',
cursor: 'pointer', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{s === 'fleet' ? 'Fleet' : 'MazeNET topology'}
</button>
))}
</div>
{scope === 'topology' && (
<Field label="Topology">
{topologies.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
No active topologies. Deploy one from MazeNET first.
</div>
) : (
<select
value={topologyId}
onChange={(e) => setTopologyId(e.target.value)}
style={INPUT_STYLE}
>
{topologies.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.status})
</option>
))}
</select>
)}
</Field>
)}
<Field label="Decky">
{topoLoading ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
loading topology deckies
</div>
) : activeDeckies.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
{scope === 'topology'
? 'This topology has no deckies.'
: 'No fleet deckies running. Deploy a fleet first.'}
</div>
) : (
<select
value={decky}
onChange={(e) => setDecky(e.target.value)}
autoFocus
style={INPUT_STYLE}
>
{activeDeckies.map((d) => (
<option key={d.name} value={d.name}>
{d.name}{d.ip ? ` (${d.ip})` : ''}
</option>
))}
</select>
)}
</Field>
<Field label="Kind">
<select
value={kind}
onChange={(e) => setKind(e.target.value as typeof kind)}
style={INPUT_STYLE}
>
{KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
<Field label="Placement path (inside the container)">
<input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/home/admin/.aws/credentials"
style={{ ...INPUT_STYLE, fontFamily: 'monospace' }}
/>
</Field>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
{(['generator', 'blob'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setSource(s)}
style={{
flex: 1,
padding: '8px',
background: source === s ? 'var(--accent-color, #00ff88)' : 'transparent',
color: source === s ? 'var(--bg-color, #0d1117)' : 'var(--text-color)',
border: '1px solid var(--border-color, #30363d)',
cursor: 'pointer', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{s === 'generator' ? 'Built-in template' : 'Operator upload'}
</button>
))}
</div>
{source === 'generator' && (
<Field label="Generator">
<select
value={generator}
onChange={(e) => setGenerator(e.target.value as GeneratorName)}
style={INPUT_STYLE}
>
{KNOWN_GENERATORS.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
</Field>
)}
{source === 'blob' && (
<Field label="Uploaded artifact">
{blobs.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
No blobs uploaded yet. Use "Upload artifact" on the main page first.
</div>
) : (
<select
value={blobUuid}
onChange={(e) => setBlobUuid(e.target.value)}
style={INPUT_STYLE}
>
<option value=""> select </option>
{blobs.map((b) => (
<option key={b.uuid} value={b.uuid}>
{b.filename} ({b.content_type}, {fmtBytes(b.size_bytes)})
</option>
))}
</select>
)}
</Field>
)}
{error && (
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '12px' }}>{error}</div>
)}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '20px' }}>
<button onClick={onClose} style={BTN_GHOST}>CANCEL</button>
<button
onClick={handleSubmit}
disabled={submitting}
style={{ ...BTN_PRIMARY, opacity: submitting ? 0.5 : 1, cursor: submitting ? 'wait' : 'pointer' }}
>
{submitting ? 'PLANTING…' : 'PLANT TOKEN'}
</button>
</div>
</div>
</div>
);
};
import { CreateTokenModal } from './CanaryTokens/CreateTokenModal';
// ─── BLOB UPLOAD MODAL ─────────────────────────────────────────────────────
@@ -1165,7 +882,7 @@ const CanaryTokens: React.FC = () => {
)}
{showCreate && (
<CreateModal
<CreateTokenModal
blobs={blobs}
deckies={deckies}
topologies={topologies}

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CreateTokenModal } from './CreateTokenModal';
import type { DeckyOption, TopologyOption } from './types';
// useFocusTrap depends on focus-trap-react that yells at jsdom.
vi.mock('../../hooks/useFocusTrap', () => ({ 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(
<CreateTokenModal
blobs={[]}
deckies={deckies}
topologies={topologies}
onClose={() => {}}
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(
<CreateTokenModal
blobs={[]}
deckies={deckies}
topologies={topologies}
onClose={onClose}
onCreated={() => {}}
/>,
);
await user.click(screen.getByText('CANCEL'));
expect(onClose).toHaveBeenCalled();
});
it('shows the empty-deckies message when fleet has no deckies', () => {
render(
<CreateTokenModal
blobs={[]}
deckies={[]}
topologies={topologies}
onClose={() => {}}
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(
<CreateTokenModal
blobs={[]}
deckies={deckies}
topologies={topologies}
onClose={() => {}}
onCreated={() => {}}
/>,
);
await user.click(screen.getByText('Operator upload'));
expect(
screen.getByText(/No blobs uploaded yet/),
).toBeInTheDocument();
});
});

View File

@@ -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<Props> = ({ blobs, deckies, topologies, onClose, onCreated }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
const [scope, setScope] = useState<Scope>('fleet');
const [topologyId, setTopologyId] = useState<string>(topologies[0]?.id ?? '');
const [topoDeckies, setTopoDeckies] = useState<DeckyOption[]>([]);
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<GeneratorName>('aws_creds');
const [blobUuid, setBlobUuid] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<string, unknown> = {
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 (
<div
onClick={(e) => { 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,
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
style={{
width: 'min(560px, 100%)', maxHeight: '90vh', overflowY: 'auto',
backgroundColor: 'var(--bg-color, #0d1117)',
border: '1px solid var(--border-color, #30363d)',
padding: '24px', color: 'var(--text-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div style={{ fontSize: '1rem', fontWeight: 'bold' }}>NEW CANARY TOKEN</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
{(['fleet', 'topology'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
style={{
flex: 1,
padding: '8px',
background: scope === s ? 'var(--accent-color, #00ff88)' : 'transparent',
color: scope === s ? 'var(--bg-color, #0d1117)' : 'var(--text-color)',
border: '1px solid var(--border-color, #30363d)',
cursor: 'pointer', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{s === 'fleet' ? 'Fleet' : 'MazeNET topology'}
</button>
))}
</div>
{scope === 'topology' && (
<Field label="Topology">
{topologies.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
No active topologies. Deploy one from MazeNET first.
</div>
) : (
<select
value={topologyId}
onChange={(e) => setTopologyId(e.target.value)}
style={INPUT_STYLE}
>
{topologies.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.status})
</option>
))}
</select>
)}
</Field>
)}
<Field label="Decky">
{topoLoading ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
loading topology deckies
</div>
) : activeDeckies.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
{scope === 'topology'
? 'This topology has no deckies.'
: 'No fleet deckies running. Deploy a fleet first.'}
</div>
) : (
<select
value={decky}
onChange={(e) => setDecky(e.target.value)}
autoFocus
style={INPUT_STYLE}
>
{activeDeckies.map((d) => (
<option key={d.name} value={d.name}>
{d.name}{d.ip ? ` (${d.ip})` : ''}
</option>
))}
</select>
)}
</Field>
<Field label="Kind">
<select
value={kind}
onChange={(e) => setKind(e.target.value as typeof kind)}
style={INPUT_STYLE}
>
{KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
<Field label="Placement path (inside the container)">
<input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/home/admin/.aws/credentials"
style={{ ...INPUT_STYLE, fontFamily: 'monospace' }}
/>
</Field>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
{(['generator', 'blob'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setSource(s)}
style={{
flex: 1,
padding: '8px',
background: source === s ? 'var(--accent-color, #00ff88)' : 'transparent',
color: source === s ? 'var(--bg-color, #0d1117)' : 'var(--text-color)',
border: '1px solid var(--border-color, #30363d)',
cursor: 'pointer', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{s === 'generator' ? 'Built-in template' : 'Operator upload'}
</button>
))}
</div>
{source === 'generator' && (
<Field label="Generator">
<select
value={generator}
onChange={(e) => setGenerator(e.target.value as GeneratorName)}
style={INPUT_STYLE}
>
{KNOWN_GENERATORS.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
</Field>
)}
{source === 'blob' && (
<Field label="Uploaded artifact">
{blobs.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
No blobs uploaded yet. Use "Upload artifact" on the main page first.
</div>
) : (
<select
value={blobUuid}
onChange={(e) => setBlobUuid(e.target.value)}
style={INPUT_STYLE}
>
<option value=""> select </option>
{blobs.map((b) => (
<option key={b.uuid} value={b.uuid}>
{b.filename} ({b.content_type}, {fmtBytes(b.size_bytes)})
</option>
))}
</select>
)}
</Field>
)}
{error && (
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '12px' }}>{error}</div>
)}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '20px' }}>
<button onClick={onClose} style={BTN_GHOST}>CANCEL</button>
<button
onClick={handleSubmit}
disabled={submitting}
style={{ ...BTN_PRIMARY, opacity: submitting ? 0.5 : 1, cursor: submitting ? 'wait' : 'pointer' }}
>
{submitting ? 'PLANTING…' : 'PLANT TOKEN'}
</button>
</div>
</div>
</div>
);
};