feat(ui): scope canary tokens to MazeNET topology deckies

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.
This commit is contained in:
2026-04-28 23:04:13 -04:00
parent 6ac8cac908
commit c942d4d333
2 changed files with 159 additions and 8 deletions

View File

@@ -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<Props> = ({ token, onClose, onRevoked }) => {
</h3>
<Row label="UUID" value={<code>{token.uuid}</code>} />
<Row label="Decky" value={token.decky_name} />
<Row
label="Scope"
value={token.topology_id ? (
<a
href={`/mazenet?topology=${encodeURIComponent(token.topology_id)}`}
style={{ color: 'var(--accent-color, #00ff88)' }}
>
topology · {token.topology_id.slice(0, 8)}
</a>
) : (
<span style={{ opacity: 0.6 }}>fleet</span>
)}
/>
<Row label="Kind" value={KIND_LABEL[token.kind]} />
<Row label="Source" value={token.generator ? `generator: ${token.generator}` : token.instrumenter ? `instrumenter: ${token.instrumenter}` : '—'} />
<Row label="Slug" value={<code>{token.callback_token}</code>} />

View File

@@ -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<CreateModalProps> = ({ blobs, deckies, onClose, onCreated }) => {
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');
@@ -89,6 +139,7 @@ const CreateModal: React.FC<CreateModalProps> = ({ 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<CreateModalProps> = ({ 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<CreateModalProps> = ({ blobs, deckies, onClose, onCr
</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">
{deckies.length === 0 ? (
{topoLoading ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
No deckies running. Deploy a fleet first.
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
@@ -150,7 +250,7 @@ const CreateModal: React.FC<CreateModalProps> = ({ blobs, deckies, onClose, onCr
autoFocus
style={INPUT_STYLE}
>
{deckies.map((d) => (
{activeDeckies.map((d) => (
<option key={d.name} value={d.name}>
{d.name}{d.ip ? ` (${d.ip})` : ''}
</option>
@@ -391,11 +491,13 @@ const CanaryTokens: React.FC = () => {
const [tokens, setTokens] = useState<CanaryTokenRow[]>([]);
const [blobs, setBlobs] = useState<BlobRow[]>([]);
const [deckies, setDeckies] = useState<DeckyOption[]>([]);
const [topologies, setTopologies] = useState<TopologyOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<'tokens' | 'blobs'>('tokens');
const [filter, setFilter] = useState('');
const [stateFilter, setStateFilter] = useState<'all' | 'planted' | 'revoked' | 'failed'>('all');
const [scopeFilter, setScopeFilter] = useState<'all' | 'fleet' | 'topology'>('all');
const [showCreate, setShowCreate] = useState(false);
const [showUpload, setShowUpload] = useState(false);
@@ -405,14 +507,20 @@ const CanaryTokens: React.FC = () => {
setLoading(true);
setError(null);
try {
const [t, b, d] = await Promise.all([
const [t, b, d, topos] = await Promise.all([
api.get('/canary/tokens'),
api.get('/canary/blobs').catch(() => ({ data: { blobs: [] } })), // viewers can't list blobs
api.get<DeckyOption[]>('/deckies').catch(() => ({ data: [] })),
// Active topologies only — planting on a torn-down or pending
// topology would 422/404 anyway. Endpoint shape: { data: [...] }
api.get('/topologies?status=active').catch(() => ({ data: { data: [] } })),
]);
setTokens(t.data.tokens || []);
setBlobs(b.data.blobs || []);
setDeckies(Array.isArray(d.data) ? d.data : []);
const topoRows: Array<{ id: string; name: string; status: string }> =
topos.data?.data ?? [];
setTopologies(topoRows.map((r) => ({ id: r.id, name: r.name, status: r.status })));
} catch (err) {
setError(extractError(err, 'Failed to load canary tokens.'));
} finally {
@@ -437,6 +545,8 @@ const CanaryTokens: React.FC = () => {
const visibleTokens = useMemo(() => {
return tokens.filter((t) => {
if (stateFilter !== 'all' && t.state !== stateFilter) return false;
if (scopeFilter === 'fleet' && t.topology_id) return false;
if (scopeFilter === 'topology' && !t.topology_id) return false;
if (!filter) return true;
const f = filter.toLowerCase();
return (
@@ -444,10 +554,11 @@ const CanaryTokens: React.FC = () => {
t.placement_path.toLowerCase().includes(f) ||
t.callback_token.toLowerCase().includes(f) ||
(t.generator || '').toLowerCase().includes(f) ||
(t.instrumenter || '').toLowerCase().includes(f)
(t.instrumenter || '').toLowerCase().includes(f) ||
(t.topology_id || '').toLowerCase().includes(f)
);
});
}, [tokens, filter, stateFilter]);
}, [tokens, filter, stateFilter, scopeFilter]);
const counts = useMemo(() => {
const c = { planted: 0, revoked: 0, failed: 0, hits: 0 };
@@ -535,6 +646,15 @@ const CanaryTokens: React.FC = () => {
<option value="revoked">revoked</option>
<option value="failed">failed</option>
</select>
<select
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as typeof scopeFilter)}
style={{ ...INPUT_STYLE, marginBottom: 0, width: 'auto' }}
>
<option value="all">all scopes</option>
<option value="fleet">fleet only</option>
<option value="topology">topology only</option>
</select>
</div>
{loading && <div style={{ opacity: 0.6 }}>loading</div>}
@@ -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()}
</span>
<span
title={t.topology_id ? `topology ${t.topology_id}` : 'fleet'}
style={{
fontSize: '0.65rem', letterSpacing: '0.05em',
padding: '2px 6px',
border: `1px solid ${t.topology_id ? 'var(--accent-color, #00ff88)' : 'var(--dim-color)'}`,
color: t.topology_id ? 'var(--accent-color, #00ff88)' : 'var(--dim-color)',
textAlign: 'center',
textTransform: 'uppercase',
}}
>
{t.topology_id ? 'topology' : 'fleet'}
</span>
<span style={{ fontFamily: 'monospace' }}>{t.decky_name}</span>
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.placement_path}
@@ -640,6 +773,7 @@ const CanaryTokens: React.FC = () => {
<CreateModal
blobs={blobs}
deckies={deckies}
topologies={topologies}
onClose={() => setShowCreate(false)}
onCreated={(t) => {
setTokens((prev) => [t, ...prev]);