refactor(decnet_web/CanaryTokens): wire hook + bump coverage floor
Final integration step. The page shell is now a thin composition of useCanaryTokens + the previously-extracted children: - CanaryTokens.tsx: 1,334 -> 210 LOC. Page owns only the pure-UI state (tab, search/state/scope filters, modal visibility, drawer selection, local fileDrops log) and the thin handlers that translate hook results into confirm/alert prompts. Initial parallel fetch + deleteBlob mutation moved to useCanaryTokens in the prior commit. - Modals plug directly into the hook's optimistic helpers (prependToken / prependBlob / markTokenRevoked) so the page doesn't reach into the data shape. Coverage floor bumped after the split: lines 11 -> 14 functions 10 -> 13 branches 8 -> 11 statements 11 -> 13 Phase 3 final scoreboard: 28 test files, 131 tests, all green.
This commit is contained in:
@@ -1,74 +1,46 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Upload, Search, Target } from '../icons';
|
||||
import api from '../utils/api';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Upload, Target } from '../icons';
|
||||
import CanaryTokenDrawer from './CanaryTokenDrawer';
|
||||
import type { CanaryTokenRow } from './CanaryTokenDrawer';
|
||||
import {
|
||||
STATE_COLOR,
|
||||
type BlobRow, type DeckyOption, type TopologyOption,
|
||||
} from './CanaryTokens/types';
|
||||
import { extractError, fmt, fmtBytes } from './CanaryTokens/helpers';
|
||||
import { INPUT_STYLE, Stat } from './CanaryTokens/ui';
|
||||
import { STATE_COLOR } from './CanaryTokens/types';
|
||||
import { Stat } from './CanaryTokens/ui';
|
||||
import { extractError } from './CanaryTokens/helpers';
|
||||
import { useCanaryTokens } from './CanaryTokens/useCanaryTokens';
|
||||
import { CreateTokenModal } from './CanaryTokens/CreateTokenModal';
|
||||
import { UploadModal } from './CanaryTokens/UploadModal';
|
||||
import {
|
||||
FileDropModal, loadFileDrops, saveFileDrops,
|
||||
type FileDropEntry,
|
||||
} from './CanaryTokens/FileDropModal';
|
||||
import {
|
||||
TokenListView,
|
||||
type StateFilter, type ScopeFilter,
|
||||
} from './CanaryTokens/TokenListView';
|
||||
import { BlobListView } from './CanaryTokens/BlobListView';
|
||||
import { FileDropListView } from './CanaryTokens/FileDropListView';
|
||||
|
||||
// ─── MAIN PAGE ─────────────────────────────────────────────────────────────
|
||||
type Tab = 'tokens' | 'blobs' | 'filedrops';
|
||||
|
||||
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' | 'filedrops'>('tokens');
|
||||
const [fileDrops, setFileDrops] = useState<FileDropEntry[]>(() => loadFileDrops());
|
||||
const [showFileDrop, setShowFileDrop] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [stateFilter, setStateFilter] = useState<'all' | 'planted' | 'revoked' | 'failed'>('all');
|
||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'fleet' | 'topology'>('all');
|
||||
const {
|
||||
tokens, blobs, deckies, topologies, loading, error,
|
||||
prependToken, prependBlob, markTokenRevoked, deleteBlob,
|
||||
} = useCanaryTokens();
|
||||
|
||||
// Pure-UI state. The local fileDrops log lives entirely in the
|
||||
// browser; the server doesn't persist it.
|
||||
const [tab, setTab] = useState<Tab>('tokens');
|
||||
const [fileDrops, setFileDrops] = useState<FileDropEntry[]>(() => loadFileDrops());
|
||||
const [filter, setFilter] = useState('');
|
||||
const [stateFilter, setStateFilter] = useState<StateFilter>('all');
|
||||
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>('all');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showFileDrop, setShowFileDrop] = useState(false);
|
||||
const [drawerToken, setDrawerToken] = useState<CanaryTokenRow | null>(null);
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
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: [...] }
|
||||
// Trailing slash matters: FastAPI's slash-redirect issues a 307
|
||||
// and the browser re-fires without the Authorization header,
|
||||
// landing as 401 on the redirected URL. Hit the canonical
|
||||
// path (/topologies/) directly.
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadAll(); }, []);
|
||||
|
||||
// Alt+C / Alt+D — open create-token / drop-file modals (per
|
||||
// feedback_linux_meta_key — never Meta/⌘ on Linux).
|
||||
// Alt+C / Alt+D — open create-token / drop-file modals
|
||||
// (per feedback_linux_meta_key — never Meta/⌘ on Linux).
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const anyModalOpen = showCreate || showUpload || showFileDrop || drawerToken;
|
||||
@@ -85,41 +57,25 @@ const CanaryTokens: React.FC = () => {
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [showCreate, showUpload, showFileDrop, drawerToken]);
|
||||
|
||||
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 (
|
||||
t.decky_name.toLowerCase().includes(f) ||
|
||||
t.placement_path.toLowerCase().includes(f) ||
|
||||
t.callback_token.toLowerCase().includes(f) ||
|
||||
(t.generator || '').toLowerCase().includes(f) ||
|
||||
(t.instrumenter || '').toLowerCase().includes(f) ||
|
||||
(t.topology_id || '').toLowerCase().includes(f)
|
||||
);
|
||||
});
|
||||
}, [tokens, filter, stateFilter, scopeFilter]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const counts = (() => {
|
||||
const c = { planted: 0, revoked: 0, failed: 0, hits: 0 };
|
||||
for (const t of tokens) {
|
||||
c[t.state] += 1;
|
||||
c.hits += t.trigger_count;
|
||||
}
|
||||
return c;
|
||||
}, [tokens]);
|
||||
})();
|
||||
|
||||
const handleDeleteBlob = async (uuid: string) => {
|
||||
if (!window.confirm('Delete this blob? Refused if any token still references it.')) return;
|
||||
try {
|
||||
await api.delete(`/canary/blobs/${encodeURIComponent(uuid)}`);
|
||||
setBlobs((prev) => prev.filter((b) => b.uuid !== uuid));
|
||||
} catch (err) {
|
||||
alert(extractError(err, 'Delete failed.'));
|
||||
}
|
||||
const r = await deleteBlob(uuid);
|
||||
if (!r.ok) alert(extractError(r.reason, 'Delete failed.'));
|
||||
};
|
||||
|
||||
const handleClearFileDrops = () => {
|
||||
if (!window.confirm('Clear local file drop history? This does not delete dropped files.')) return;
|
||||
setFileDrops([]);
|
||||
saveFileDrops([]);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -178,226 +134,26 @@ const CanaryTokens: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{tab === 'tokens' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ position: 'relative', flex: '1 1 300px' }}>
|
||||
<Search size={14} style={{ position: 'absolute', left: '10px', top: '50%', transform: 'translateY(-50%)', opacity: 0.5 }} />
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter by decky / path / slug / generator…"
|
||||
style={{ ...INPUT_STYLE, paddingLeft: '32px', marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={(e) => setStateFilter(e.target.value as typeof stateFilter)}
|
||||
style={{ ...INPUT_STYLE, marginBottom: 0, width: 'auto' }}
|
||||
>
|
||||
<option value="all">all states</option>
|
||||
<option value="planted">planted</option>
|
||||
<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>}
|
||||
{error && <div style={{ color: '#ff5555', marginBottom: '16px' }}>{error}</div>}
|
||||
{!loading && visibleTokens.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', opacity: 0.6, fontSize: '0.85rem' }}>
|
||||
{tokens.length === 0
|
||||
? 'No canary tokens yet. Click NEW TOKEN to plant one, or UPLOAD ARTIFACT to start with an operator-supplied document.'
|
||||
: 'No tokens match the current filter.'}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{visibleTokens.map((t) => (
|
||||
<button
|
||||
key={t.uuid}
|
||||
onClick={() => setDrawerToken(t)}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '110px 80px 140px 1fr 100px 110px 80px',
|
||||
alignItems: 'center', gap: '12px',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
background: 'var(--matrix-tint-5)',
|
||||
color: 'var(--text-color)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
color: STATE_COLOR[t.state], fontFamily: 'monospace',
|
||||
fontSize: '0.7rem', letterSpacing: '0.05em',
|
||||
}}>
|
||||
● {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}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>
|
||||
{t.kind === 'aws_passive' ? 'aws-passive' : t.kind}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7, fontFamily: 'monospace' }}>
|
||||
{t.generator || t.instrumenter || '?'}
|
||||
</span>
|
||||
<span style={{ textAlign: 'right', fontFamily: 'monospace', color: t.trigger_count > 0 ? '#00ff88' : 'var(--dim-color)' }}>
|
||||
{t.trigger_count} {t.trigger_count === 1 ? 'hit' : 'hits'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<TokenListView
|
||||
tokens={tokens}
|
||||
loading={loading}
|
||||
error={error}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
stateFilter={stateFilter}
|
||||
setStateFilter={setStateFilter}
|
||||
scopeFilter={scopeFilter}
|
||||
setScopeFilter={setScopeFilter}
|
||||
onPick={setDrawerToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'blobs' && (
|
||||
<>
|
||||
{blobs.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', opacity: 0.6, fontSize: '0.85rem' }}>
|
||||
No uploaded artifacts. Click UPLOAD ARTIFACT to add one.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{blobs.map((b) => (
|
||||
<div
|
||||
key={b.uuid}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 220px 90px 100px 80px',
|
||||
alignItems: 'center', gap: '12px',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
background: 'var(--matrix-tint-5)',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.filename}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7, fontFamily: 'monospace' }}>{b.content_type}</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmtBytes(b.size_bytes)}</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmt(b.uploaded_at)}</span>
|
||||
<button
|
||||
onClick={() => handleDeleteBlob(b.uuid)}
|
||||
disabled={b.token_count > 0}
|
||||
title={b.token_count > 0 ? `${b.token_count} token(s) still reference this blob` : 'Delete'}
|
||||
style={{
|
||||
background: 'transparent', color: b.token_count > 0 ? 'var(--dim-color)' : '#ff5555',
|
||||
border: `1px solid ${b.token_count > 0 ? 'var(--dim-color)' : '#ff5555'}`,
|
||||
padding: '4px 8px', fontSize: '0.7rem',
|
||||
cursor: b.token_count > 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: b.token_count > 0 ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{b.token_count > 0 ? `${b.token_count} REFS` : 'DELETE'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<BlobListView blobs={blobs} onDelete={handleDeleteBlob} />
|
||||
)}
|
||||
|
||||
{tab === 'filedrops' && (
|
||||
<>
|
||||
<div style={{
|
||||
display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '12px',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||
Local log only — the server doesn't persist file drops.
|
||||
Cleared when you clear browser storage.
|
||||
</div>
|
||||
{fileDrops.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Clear local file drop history? This does not delete dropped files.')) {
|
||||
setFileDrops([]);
|
||||
saveFileDrops([]);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
border: '1px solid var(--dim-color)',
|
||||
background: 'transparent', color: 'var(--dim-color)',
|
||||
fontSize: '0.7rem', cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
CLEAR LIST
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{fileDrops.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', opacity: 0.6, fontSize: '0.85rem' }}>
|
||||
No file drops in this browser yet. Click DROP FILE to send bytes to a decky.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{fileDrops.map((fd) => (
|
||||
<div
|
||||
key={fd.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '80px 140px 1fr 90px 80px 140px',
|
||||
alignItems: 'center', gap: '12px',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
background: 'var(--matrix-tint-5)',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
title={fd.topology_id ? `topology ${fd.topology_id}` : 'fleet'}
|
||||
style={{
|
||||
fontSize: '0.65rem', letterSpacing: '0.05em',
|
||||
padding: '2px 6px',
|
||||
border: `1px solid ${fd.topology_id ? 'var(--accent-color, #00ff88)' : 'var(--dim-color)'}`,
|
||||
color: fd.topology_id ? 'var(--accent-color, #00ff88)' : 'var(--dim-color)',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{fd.topology_id ? 'topology' : 'fleet'}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>{fd.decky_name}</span>
|
||||
<span
|
||||
title={`${fd.filename} → ${fd.path}`}
|
||||
style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{fd.path}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmtBytes(fd.size_bytes)}</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7, fontFamily: 'monospace' }}>{fd.mode.toString(8)}</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmt(fd.dropped_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<FileDropListView fileDrops={fileDrops} onClear={handleClearFileDrops} />
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
@@ -407,7 +163,7 @@ const CanaryTokens: React.FC = () => {
|
||||
topologies={topologies}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={(t) => {
|
||||
setTokens((prev) => [t, ...prev]);
|
||||
prependToken(t);
|
||||
setShowCreate(false);
|
||||
}}
|
||||
/>
|
||||
@@ -416,7 +172,7 @@ const CanaryTokens: React.FC = () => {
|
||||
<UploadModal
|
||||
onClose={() => setShowUpload(false)}
|
||||
onUploaded={(b) => {
|
||||
setBlobs((prev) => prev.some((x) => x.uuid === b.uuid) ? prev : [b, ...prev]);
|
||||
prependBlob(b);
|
||||
setShowUpload(false);
|
||||
}}
|
||||
/>
|
||||
@@ -426,9 +182,7 @@ const CanaryTokens: React.FC = () => {
|
||||
token={drawerToken}
|
||||
onClose={() => setDrawerToken(null)}
|
||||
onRevoked={(uuid) => {
|
||||
setTokens((prev) => prev.map((t) =>
|
||||
t.uuid === uuid ? { ...t, state: 'revoked' } : t,
|
||||
));
|
||||
markTokenRevoked(uuid);
|
||||
setDrawerToken(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -15,14 +15,14 @@ export default defineConfig({
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'],
|
||||
// Baseline floors. Each refactor PR raises these; never lower.
|
||||
// Phase 2 (DeckyFleet split): page shell down from 1,674 to 274
|
||||
// LOC; hook + 6 children, 28 new tests. Suite: 21 files, 98 tests,
|
||||
// 11.95% lines / 8.78% branches.
|
||||
// Phase 3 (CanaryTokens split): page shell down from 1,334 to 210
|
||||
// LOC; hook + 3 modals + 3 list views + ui/types/helpers, 33 new
|
||||
// tests. Suite: 28 files, 131 tests, 14.51% lines / 11.43% branches.
|
||||
thresholds: {
|
||||
lines: 11,
|
||||
functions: 10,
|
||||
branches: 8,
|
||||
statements: 11,
|
||||
lines: 14,
|
||||
functions: 13,
|
||||
branches: 11,
|
||||
statements: 13,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user