diff --git a/decnet_web/src/components/CanaryTokens.tsx b/decnet_web/src/components/CanaryTokens.tsx index 7b4db33a..53ef7c99 100644 --- a/decnet_web/src/components/CanaryTokens.tsx +++ b/decnet_web/src/components/CanaryTokens.tsx @@ -1,373 +1,20 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { - Plus, Upload, X, AlertTriangle, Search, Target, -} from '../icons'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Plus, Upload, Search, Target } from '../icons'; import api from '../utils/api'; -import { useEscapeKey } from '../hooks/useEscapeKey'; -import { useFocusTrap } from '../hooks/useFocusTrap'; import CanaryTokenDrawer from './CanaryTokenDrawer'; import type { CanaryTokenRow } from './CanaryTokenDrawer'; import { STATE_COLOR, - type BlobRow, type DeckyOption, type TopologyOption, type Scope, + type BlobRow, type DeckyOption, type TopologyOption, } from './CanaryTokens/types'; import { extractError, fmt, fmtBytes } from './CanaryTokens/helpers'; -import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field, Stat } from './CanaryTokens/ui'; +import { INPUT_STYLE, Stat } from './CanaryTokens/ui'; import { CreateTokenModal } from './CanaryTokens/CreateTokenModal'; import { UploadModal } from './CanaryTokens/UploadModal'; - -// ─── FILE DROP MODAL ─────────────────────────────────────────────────────── - -// File drops aren't persisted server-side (W2 backend is fire-and-forget), -// so we keep a local log per admin uuid. This is informational only — -// the server has no record of what an admin dropped, by design (the -// endpoint exists to let operators stage payloads, not as an audit trail). -const FILEDROP_LS_KEY = 'decnet:canary:filedrops'; - -interface FileDropEntry { - id: string; // local-only uuid - decky_name: string; - topology_id: string | null; - path: string; - size_bytes: number; - filename: string; - mode: number; - mtime_offset: number; - dropped_at: string; // ISO -} - -function loadFileDrops(): FileDropEntry[] { - try { - const raw = localStorage.getItem(FILEDROP_LS_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function saveFileDrops(rows: FileDropEntry[]): void { - try { - localStorage.setItem(FILEDROP_LS_KEY, JSON.stringify(rows.slice(0, 200))); - } catch { - // localStorage may be full or disabled; the list is best-effort. - } -} - -interface FileDropModalProps { - deckies: DeckyOption[]; - topologies: TopologyOption[]; - onClose: () => void; - onDropped: (entry: FileDropEntry) => void; -} - -const FileDropModal: React.FC = ({ deckies, topologies, onClose, onDropped }) => { - const panelRef = useRef(null); - useEscapeKey(onClose, true); - useFocusTrap(panelRef, true); - - const [scope, setScope] = useState('fleet'); - const [topologyId, setTopologyId] = useState(topologies[0]?.id ?? ''); - const [topoDeckies, setTopoDeckies] = useState([]); - const [topoLoading, setTopoLoading] = useState(false); - - useEffect(() => { - if (scope !== 'topology' || !topologyId) { - setTopoDeckies([]); - return; - } - let cancelled = false; - setTopoLoading(true); - api.get(`/topologies/${encodeURIComponent(topologyId)}`) - .then((res) => { - if (cancelled) return; - setTopoDeckies( - (res.data?.deckies ?? []).map((d: { name: string; ip?: string }) => ({ - name: d.name, ip: d.ip, - })), - ); - }) - .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 ?? ''); - - 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 [path, setPath] = useState('/root/payload.bin'); - const [mode, setMode] = useState('644'); - const [mtimeOffset, setMtimeOffset] = useState('0'); - const [file, setFile] = useState(null); - const [dragOver, setDragOver] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - const validatePath = (p: string): string | null => { - if (!p.startsWith('/')) return 'path must be absolute (start with /)'; - if (p.split('/').includes('..')) return 'path must not contain .. segments'; - return null; - }; - - const handleSubmit = async () => { - setError(null); - if (scope === 'topology' && !topologyId) return setError('Pick a topology.'); - if (!decky.trim()) return setError('Pick a decky.'); - if (!file) return setError('Pick a file.'); - const pathErr = validatePath(path.trim()); - if (pathErr) return setError(pathErr); - const modeNum = parseInt(mode, 8); - if (Number.isNaN(modeNum) || modeNum < 0 || modeNum > 0o7777) { - return setError('mode must be a 3- or 4-digit octal (e.g. 644, 0755).'); - } - const offsetNum = parseInt(mtimeOffset, 10); - if (Number.isNaN(offsetNum)) return setError('mtime offset must be an integer (seconds).'); - - setSubmitting(true); - try { - // FileReader → base64. We strip the data: prefix from the - // result; the backend wants raw base64 only. - const reader = new FileReader(); - const b64: string = await new Promise((resolve, reject) => { - reader.onerror = () => reject(reader.error); - reader.onload = () => { - const r = reader.result; - if (typeof r !== 'string') return reject(new Error('FileReader did not return a string')); - const comma = r.indexOf(','); - resolve(comma >= 0 ? r.slice(comma + 1) : r); - }; - reader.readAsDataURL(file); - }); - - const body: Record = { - decky_name: decky.trim(), - path: path.trim(), - content_b64: b64, - mode: modeNum, - mtime_offset: offsetNum, - }; - if (scope === 'topology') body.topology_id = topologyId; - await api.post('/deckies/files', body); - - const entry: FileDropEntry = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - decky_name: decky.trim(), - topology_id: scope === 'topology' ? topologyId : null, - path: path.trim(), - size_bytes: file.size, - filename: file.name, - mode: modeNum, - mtime_offset: offsetNum, - dropped_at: new Date().toISOString(), - }; - onDropped(entry); - } catch (err) { - setError(extractError(err, 'File drop failed.')); - } finally { - setSubmitting(false); - } - }; - - return ( -
{ 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, - }} - > -
-
-
DROP FILE ON DECKY
- -
- -
- {(['fleet', 'topology'] as const).map((s) => ( - - ))} -
- - {scope === 'topology' && ( - - {topologies.length === 0 ? ( -
- No active topologies. -
- ) : ( - - )} -
- )} - - - {topoLoading ? ( -
loading…
- ) : activeDeckies.length === 0 ? ( -
- {scope === 'topology' ? 'This topology has no deckies.' : 'No fleet deckies running.'} -
- ) : ( - - )} -
- - - setPath(e.target.value)} - placeholder="/root/payload.bin" - style={{ ...INPUT_STYLE, fontFamily: 'monospace' }} - /> - - -
- - setMode(e.target.value)} - placeholder="644" - style={{ ...INPUT_STYLE, fontFamily: 'monospace' }} - /> - - -
- setMtimeOffset(e.target.value)} - placeholder="0" - style={{ ...INPUT_STYLE, fontFamily: 'monospace', flex: 1 }} - /> - -
-
-
- -
{ e.preventDefault(); setDragOver(true); }} - onDragLeave={() => setDragOver(false)} - onDrop={(e) => { - e.preventDefault(); - setDragOver(false); - const f = e.dataTransfer.files?.[0]; - if (f) setFile(f); - }} - onClick={() => document.getElementById('canary-filedrop-input')?.click()} - style={{ - border: `2px dashed ${dragOver ? 'var(--accent-color, #00ff88)' : 'var(--border-color, #30363d)'}`, - padding: '20px', - textAlign: 'center', - marginBottom: '16px', - cursor: 'pointer', - background: dragOver ? 'rgba(0, 255, 136, 0.05)' : 'transparent', - }} - > - -
- {file ? `${file.name} (${fmtBytes(file.size)})` : 'Drop a file here or click to browse'} -
- setFile(e.target.files?.[0] || null)} - /> -
- -
- - File drops bypass canary instrumentation — bytes land verbatim. The list below is local only. -
- - {error && ( -
{error}
- )} - -
- - -
-
-
- ); -}; +import { + FileDropModal, loadFileDrops, saveFileDrops, + type FileDropEntry, +} from './CanaryTokens/FileDropModal'; // ─── MAIN PAGE ───────────────────────────────────────────────────────────── diff --git a/decnet_web/src/components/CanaryTokens/FileDropModal.test.tsx b/decnet_web/src/components/CanaryTokens/FileDropModal.test.tsx new file mode 100644 index 00000000..e907af0f --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/FileDropModal.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + FileDropModal, loadFileDrops, saveFileDrops, + FILEDROP_LS_KEY, type FileDropEntry, +} from './FileDropModal'; +import type { DeckyOption, TopologyOption } from './types'; + +vi.mock('../../hooks/useFocusTrap', () => ({ useFocusTrap: () => {} })); + +const deckies: DeckyOption[] = [{ name: 'decoy-01' }]; +const topologies: TopologyOption[] = [{ id: 't-1', name: 'corp', status: 'active' }]; + +beforeEach(() => { + localStorage.clear(); +}); + +const sampleEntry = (): FileDropEntry => ({ + id: 'fd-1', + decky_name: 'd', + topology_id: null, + path: '/tmp/x', + size_bytes: 1, + filename: 'x', + mode: 0o644, + mtime_offset: 0, + dropped_at: '2026-05-09T11:00:00Z', +}); + +describe('loadFileDrops / saveFileDrops', () => { + it('returns [] when localStorage is empty', () => { + expect(loadFileDrops()).toEqual([]); + }); + + it('round-trips through localStorage', () => { + saveFileDrops([sampleEntry()]); + const out = loadFileDrops(); + expect(out).toHaveLength(1); + expect(out[0].id).toBe('fd-1'); + }); + + it('caps to 200 entries on save', () => { + const many: FileDropEntry[] = Array.from({ length: 250 }, (_, i) => ({ + ...sampleEntry(), id: `fd-${i}`, + })); + saveFileDrops(many); + const stored = JSON.parse(localStorage.getItem(FILEDROP_LS_KEY) ?? '[]'); + expect(stored).toHaveLength(200); + }); + + it('returns [] on malformed JSON in storage', () => { + localStorage.setItem(FILEDROP_LS_KEY, '{not-an-array'); + expect(loadFileDrops()).toEqual([]); + }); +}); + +describe('FileDropModal', () => { + it('renders the title and the Fleet/MazeNET toggle', () => { + render( + {}} + onDropped={() => {}} + />, + ); + expect(screen.getByText('DROP FILE ON DECKY')).toBeInTheDocument(); + expect(screen.getByText('Fleet')).toBeInTheDocument(); + }); + + it('renders the bypass-warning banner', () => { + render( + {}} + onDropped={() => {}} + />, + ); + expect( + screen.getByText(/File drops bypass canary instrumentation/), + ).toBeInTheDocument(); + }); + + it('CANCEL invokes onClose', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render( + {}} + />, + ); + await user.click(screen.getByText('CANCEL')); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/decnet_web/src/components/CanaryTokens/FileDropModal.tsx b/decnet_web/src/components/CanaryTokens/FileDropModal.tsx new file mode 100644 index 00000000..705703bb --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/FileDropModal.tsx @@ -0,0 +1,363 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Upload, X, AlertTriangle } from '../../icons'; +import api from '../../utils/api'; +import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; +import type { DeckyOption, TopologyOption, Scope } from './types'; +import { extractError, fmtBytes } from './helpers'; +import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field } from './ui'; + +// File drops aren't persisted server-side (W2 backend is fire-and-forget), +// so we keep a local log per admin uuid. This is informational only — +// the server has no record of what an admin dropped, by design (the +// endpoint exists to let operators stage payloads, not as an audit trail). +export const FILEDROP_LS_KEY = 'decnet:canary:filedrops'; + +export interface FileDropEntry { + id: string; // local-only uuid + decky_name: string; + topology_id: string | null; + path: string; + size_bytes: number; + filename: string; + mode: number; + mtime_offset: number; + dropped_at: string; // ISO +} + +export function loadFileDrops(): FileDropEntry[] { + try { + const raw = localStorage.getItem(FILEDROP_LS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function saveFileDrops(rows: FileDropEntry[]): void { + try { + localStorage.setItem(FILEDROP_LS_KEY, JSON.stringify(rows.slice(0, 200))); + } catch { + // localStorage may be full or disabled; the list is best-effort. + } +} + +interface Props { + deckies: DeckyOption[]; + topologies: TopologyOption[]; + onClose: () => void; + onDropped: (entry: FileDropEntry) => void; +} + +/** Modal that POSTs raw bytes to /deckies/files. The browser reads + * the picked File via FileReader and ships it as base64. The list + * view that follows is local-only — the backend doesn't keep an + * audit trail of file drops by design. */ +export const FileDropModal: React.FC = ({ deckies, topologies, onClose, onDropped }) => { + const panelRef = useRef(null); + useEscapeKey(onClose, true); + useFocusTrap(panelRef, true); + + const [scope, setScope] = useState('fleet'); + const [topologyId, setTopologyId] = useState(topologies[0]?.id ?? ''); + const [topoDeckies, setTopoDeckies] = useState([]); + const [topoLoading, setTopoLoading] = useState(false); + + useEffect(() => { + if (scope !== 'topology' || !topologyId) { + setTopoDeckies([]); + return; + } + let cancelled = false; + setTopoLoading(true); + api.get(`/topologies/${encodeURIComponent(topologyId)}`) + .then((res) => { + if (cancelled) return; + setTopoDeckies( + (res.data?.deckies ?? []).map((d: { name: string; ip?: string }) => ({ + name: d.name, ip: d.ip, + })), + ); + }) + .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 ?? ''); + + 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 [path, setPath] = useState('/root/payload.bin'); + const [mode, setMode] = useState('644'); + const [mtimeOffset, setMtimeOffset] = useState('0'); + const [file, setFile] = useState(null); + const [dragOver, setDragOver] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const validatePath = (p: string): string | null => { + if (!p.startsWith('/')) return 'path must be absolute (start with /)'; + if (p.split('/').includes('..')) return 'path must not contain .. segments'; + return null; + }; + + const handleSubmit = async () => { + setError(null); + if (scope === 'topology' && !topologyId) return setError('Pick a topology.'); + if (!decky.trim()) return setError('Pick a decky.'); + if (!file) return setError('Pick a file.'); + const pathErr = validatePath(path.trim()); + if (pathErr) return setError(pathErr); + const modeNum = parseInt(mode, 8); + if (Number.isNaN(modeNum) || modeNum < 0 || modeNum > 0o7777) { + return setError('mode must be a 3- or 4-digit octal (e.g. 644, 0755).'); + } + const offsetNum = parseInt(mtimeOffset, 10); + if (Number.isNaN(offsetNum)) return setError('mtime offset must be an integer (seconds).'); + + setSubmitting(true); + try { + // FileReader → base64. We strip the data: prefix from the + // result; the backend wants raw base64 only. + const reader = new FileReader(); + const b64: string = await new Promise((resolve, reject) => { + reader.onerror = () => reject(reader.error); + reader.onload = () => { + const r = reader.result; + if (typeof r !== 'string') return reject(new Error('FileReader did not return a string')); + const comma = r.indexOf(','); + resolve(comma >= 0 ? r.slice(comma + 1) : r); + }; + reader.readAsDataURL(file); + }); + + const body: Record = { + decky_name: decky.trim(), + path: path.trim(), + content_b64: b64, + mode: modeNum, + mtime_offset: offsetNum, + }; + if (scope === 'topology') body.topology_id = topologyId; + await api.post('/deckies/files', body); + + const entry: FileDropEntry = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + decky_name: decky.trim(), + topology_id: scope === 'topology' ? topologyId : null, + path: path.trim(), + size_bytes: file.size, + filename: file.name, + mode: modeNum, + mtime_offset: offsetNum, + dropped_at: new Date().toISOString(), + }; + onDropped(entry); + } catch (err) { + setError(extractError(err, 'File drop failed.')); + } finally { + setSubmitting(false); + } + }; + + return ( +
{ 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, + }} + > +
+
+
DROP FILE ON DECKY
+ +
+ +
+ {(['fleet', 'topology'] as const).map((s) => ( + + ))} +
+ + {scope === 'topology' && ( + + {topologies.length === 0 ? ( +
+ No active topologies. +
+ ) : ( + + )} +
+ )} + + + {topoLoading ? ( +
loading…
+ ) : activeDeckies.length === 0 ? ( +
+ {scope === 'topology' ? 'This topology has no deckies.' : 'No fleet deckies running.'} +
+ ) : ( + + )} +
+ + + setPath(e.target.value)} + placeholder="/root/payload.bin" + style={{ ...INPUT_STYLE, fontFamily: 'monospace' }} + /> + + +
+ + setMode(e.target.value)} + placeholder="644" + style={{ ...INPUT_STYLE, fontFamily: 'monospace' }} + /> + + +
+ setMtimeOffset(e.target.value)} + placeholder="0" + style={{ ...INPUT_STYLE, fontFamily: 'monospace', flex: 1 }} + /> + +
+
+
+ +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) setFile(f); + }} + onClick={() => document.getElementById('canary-filedrop-input')?.click()} + style={{ + border: `2px dashed ${dragOver ? 'var(--accent-color, #00ff88)' : 'var(--border-color, #30363d)'}`, + padding: '20px', + textAlign: 'center', + marginBottom: '16px', + cursor: 'pointer', + background: dragOver ? 'rgba(0, 255, 136, 0.05)' : 'transparent', + }} + > + +
+ {file ? `${file.name} (${fmtBytes(file.size)})` : 'Drop a file here or click to browse'} +
+ setFile(e.target.files?.[0] || null)} + /> +
+ +
+ + File drops bypass canary instrumentation — bytes land verbatim. The list below is local only. +
+ + {error && ( +
{error}
+ )} + +
+ + +
+
+
+ ); +};