diff --git a/decnet_web/src/components/CanaryTokens.tsx b/decnet_web/src/components/CanaryTokens.tsx index 53ef7c99..f212a813 100644 --- a/decnet_web/src/components/CanaryTokens.tsx +++ b/decnet_web/src/components/CanaryTokens.tsx @@ -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([]); - const [blobs, setBlobs] = useState([]); - const [deckies, setDeckies] = useState([]); - const [topologies, setTopologies] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [tab, setTab] = useState<'tokens' | 'blobs' | 'filedrops'>('tokens'); - const [fileDrops, setFileDrops] = useState(() => 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('tokens'); + const [fileDrops, setFileDrops] = useState(() => loadFileDrops()); + const [filter, setFilter] = useState(''); + const [stateFilter, setStateFilter] = useState('all'); + const [scopeFilter, setScopeFilter] = useState('all'); const [showCreate, setShowCreate] = useState(false); const [showUpload, setShowUpload] = useState(false); + const [showFileDrop, setShowFileDrop] = useState(false); const [drawerToken, setDrawerToken] = useState(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('/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 = () => { {tab === 'tokens' && ( - <> -
-
- - setFilter(e.target.value)} - placeholder="Filter by decky / path / slug / generator…" - style={{ ...INPUT_STYLE, paddingLeft: '32px', marginBottom: 0 }} - /> -
- - -
- - {loading &&
loading…
} - {error &&
{error}
} - {!loading && visibleTokens.length === 0 && ( -
- {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.'} -
- )} -
- {visibleTokens.map((t) => ( - - ))} -
- + )} {tab === 'blobs' && ( - <> - {blobs.length === 0 && ( -
- No uploaded artifacts. Click UPLOAD ARTIFACT to add one. -
- )} -
- {blobs.map((b) => ( -
- - {b.filename} - - {b.content_type} - {fmtBytes(b.size_bytes)} - {fmt(b.uploaded_at)} - -
- ))} -
- + )} {tab === 'filedrops' && ( - <> -
-
- Local log only — the server doesn't persist file drops. - Cleared when you clear browser storage. -
- {fileDrops.length > 0 && ( - - )} -
- {fileDrops.length === 0 && ( -
- No file drops in this browser yet. Click DROP FILE to send bytes to a decky. -
- )} -
- {fileDrops.map((fd) => ( -
- - {fd.topology_id ? 'topology' : 'fleet'} - - {fd.decky_name} - - {fd.path} - - {fmtBytes(fd.size_bytes)} - {fd.mode.toString(8)} - {fmt(fd.dropped_at)} -
- ))} -
- + )} {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 = () => { 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); }} /> diff --git a/decnet_web/vite.config.ts b/decnet_web/vite.config.ts index 316031e9..618a28d7 100644 --- a/decnet_web/vite.config.ts +++ b/decnet_web/vite.config.ts @@ -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, }, }, },