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:
@@ -8,295 +8,12 @@ import { useFocusTrap } from '../hooks/useFocusTrap';
|
|||||||
import CanaryTokenDrawer from './CanaryTokenDrawer';
|
import CanaryTokenDrawer from './CanaryTokenDrawer';
|
||||||
import type { CanaryTokenRow } from './CanaryTokenDrawer';
|
import type { CanaryTokenRow } from './CanaryTokenDrawer';
|
||||||
import {
|
import {
|
||||||
KNOWN_GENERATORS, KIND_OPTIONS, STATE_COLOR,
|
STATE_COLOR,
|
||||||
type BlobRow, type DeckyOption, type TopologyOption, type Scope, type GeneratorName,
|
type BlobRow, type DeckyOption, type TopologyOption, type Scope,
|
||||||
} from './CanaryTokens/types';
|
} from './CanaryTokens/types';
|
||||||
import { extractError, fmt, fmtBytes } from './CanaryTokens/helpers';
|
import { extractError, fmt, fmtBytes } from './CanaryTokens/helpers';
|
||||||
import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field, Stat } from './CanaryTokens/ui';
|
import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field, Stat } from './CanaryTokens/ui';
|
||||||
|
import { CreateTokenModal } from './CanaryTokens/CreateTokenModal';
|
||||||
// ─── 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── BLOB UPLOAD MODAL ─────────────────────────────────────────────────────
|
// ─── BLOB UPLOAD MODAL ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1165,7 +882,7 @@ const CanaryTokens: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<CreateModal
|
<CreateTokenModal
|
||||||
blobs={blobs}
|
blobs={blobs}
|
||||||
deckies={deckies}
|
deckies={deckies}
|
||||||
topologies={topologies}
|
topologies={topologies}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
298
decnet_web/src/components/CanaryTokens/CreateTokenModal.tsx
Normal file
298
decnet_web/src/components/CanaryTokens/CreateTokenModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user