diff --git a/decnet_web/src/components/CanaryTokens.tsx b/decnet_web/src/components/CanaryTokens.tsx index 2daf072b..2511eb56 100644 --- a/decnet_web/src/components/CanaryTokens.tsx +++ b/decnet_web/src/components/CanaryTokens.tsx @@ -485,6 +485,359 @@ const UploadModal: React.FC = ({ onClose, onUploaded }) => { ); }; +// ─── 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}
+ )} + +
+ + +
+
+
+ ); +}; + // ─── MAIN PAGE ───────────────────────────────────────────────────────────── const CanaryTokens: React.FC = () => { @@ -494,7 +847,9 @@ const CanaryTokens: React.FC = () => { const [topologies, setTopologies] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [tab, setTab] = useState<'tokens' | 'blobs'>('tokens'); + 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'); @@ -530,17 +885,23 @@ const CanaryTokens: React.FC = () => { useEffect(() => { loadAll(); }, []); - // Alt+C — open the create modal (per feedback_linux_meta_key). + // Alt+C / Alt+D — open create-token / drop-file modals (per + // feedback_linux_meta_key — never Meta/⌘ on Linux). useEffect(() => { const handler = (e: KeyboardEvent) => { - if (e.altKey && e.key.toLowerCase() === 'c' && !showCreate && !showUpload && !drawerToken) { + const anyModalOpen = showCreate || showUpload || showFileDrop || drawerToken; + if (anyModalOpen) return; + if (e.altKey && e.key.toLowerCase() === 'c') { e.preventDefault(); setShowCreate(true); + } else if (e.altKey && e.key.toLowerCase() === 'd') { + e.preventDefault(); + setShowFileDrop(true); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [showCreate, showUpload, drawerToken]); + }, [showCreate, showUpload, showFileDrop, drawerToken]); const visibleTokens = useMemo(() => { return tokens.filter((t) => { @@ -592,6 +953,9 @@ const CanaryTokens: React.FC = () => { + @@ -607,7 +971,7 @@ const CanaryTokens: React.FC = () => {
- {(['tokens', 'blobs'] as const).map((t) => ( + {(['tokens', 'blobs', 'filedrops'] as const).map((t) => ( ))}
@@ -769,6 +1137,84 @@ const CanaryTokens: React.FC = () => { )} + {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 && ( { }} /> )} + {showFileDrop && ( + setShowFileDrop(false)} + onDropped={(entry) => { + setFileDrops((prev) => { + const next = [entry, ...prev].slice(0, 200); + saveFileDrops(next); + return next; + }); + setShowFileDrop(false); + setTab('filedrops'); + }} + /> + )} ); };