feat(ui): file drops tab on CanaryTokens
CanaryTokens.tsx grows a third tab — File drops — alongside Tokens and Blobs. The page now covers every 'admin landed bytes on a decky' operation in one place. FileDropModal mirrors the canary CreateModal's shape: Fleet/MazeNET toggle, topology+decky picker, absolute-path validation matching the backend (DeckyFileDropRequest rejects relative + ..-traversal), mode + mtime offset inputs, and a -1w preset for backdating. FileReader → data URL → strip prefix → POST /api/v1/deckies/files. The list is local-only (localStorage, capped at 200 entries). W2's backend doesn't persist drops by design — the endpoint is for staging payloads, not as an audit trail. CLEAR LIST button on the tab; no DELETE button on rows since the local entry doesn't track whether the file is still there (an attacker may have moved it). Alt+D shortcut joins Alt+C; alt-key only per the Linux-meta-key rule.
This commit is contained in:
@@ -485,6 +485,359 @@ const UploadModal: React.FC<UploadModalProps> = ({ 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<FileDropModalProps> = ({ deckies, topologies, onClose, onDropped }) => {
|
||||
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);
|
||||
|
||||
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<File | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<string, unknown> = {
|
||||
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 (
|
||||
<div
|
||||
onClick={(e) => { 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,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
width: 'min(560px, 100%)', maxHeight: '90vh', overflowY: 'auto',
|
||||
backgroundColor: 'var(--bg-color, #0d1117)',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
padding: '24px', color: 'var(--text-color)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 'bold' }}>DROP FILE ON DECKY</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
|
||||
<X size={20} />
|
||||
</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.
|
||||
</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">
|
||||
{topoLoading ? (
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>loading…</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.'}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={decky}
|
||||
onChange={(e) => setDecky(e.target.value)}
|
||||
style={INPUT_STYLE}
|
||||
>
|
||||
{activeDeckies.map((d) => (
|
||||
<option key={d.name} value={d.name}>
|
||||
{d.name}{d.ip ? ` (${d.ip})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Destination path (inside the container)">
|
||||
<input
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="/root/payload.bin"
|
||||
style={{ ...INPUT_STYLE, fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<Field label="Mode (octal)">
|
||||
<input
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
placeholder="644"
|
||||
style={{ ...INPUT_STYLE, fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Mtime offset (seconds)">
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'flex-start' }}>
|
||||
<input
|
||||
value={mtimeOffset}
|
||||
onChange={(e) => setMtimeOffset(e.target.value)}
|
||||
placeholder="0"
|
||||
style={{ ...INPUT_STYLE, fontFamily: 'monospace', flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMtimeOffset(String(-7 * 24 * 3600))}
|
||||
title="Backdate to one week ago"
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
background: 'transparent', color: 'var(--text-color)',
|
||||
fontSize: '0.7rem', cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
-1w
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => { 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',
|
||||
}}
|
||||
>
|
||||
<Upload size={24} style={{ opacity: 0.5, marginBottom: '6px' }} />
|
||||
<div style={{ fontSize: '0.85rem' }}>
|
||||
{file ? `${file.name} (${fmtBytes(file.size)})` : 'Drop a file here or click to browse'}
|
||||
</div>
|
||||
<input
|
||||
id="canary-filedrop-input"
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 12px', marginBottom: '16px',
|
||||
border: '1px solid rgba(255, 170, 0, 0.3)',
|
||||
backgroundColor: 'rgba(255, 170, 0, 0.05)',
|
||||
fontSize: '0.7rem', color: '#ffaa00',
|
||||
}}>
|
||||
<AlertTriangle size={14} />
|
||||
File drops bypass canary instrumentation — bytes land verbatim. The list below is local only.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '12px' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={onClose} style={BTN_GHOST}>CANCEL</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
style={{ ...BTN_PRIMARY, opacity: submitting ? 0.5 : 1, cursor: submitting ? 'wait' : 'pointer' }}
|
||||
>
|
||||
{submitting ? 'DROPPING…' : 'DROP FILE'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── MAIN PAGE ─────────────────────────────────────────────────────────────
|
||||
|
||||
const CanaryTokens: React.FC = () => {
|
||||
@@ -494,7 +847,9 @@ const CanaryTokens: React.FC = () => {
|
||||
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 [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');
|
||||
@@ -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 = () => {
|
||||
<button className="btn" onClick={() => setShowUpload(true)}>
|
||||
<Upload size={12} /> UPLOAD ARTIFACT
|
||||
</button>
|
||||
<button className="btn" onClick={() => setShowFileDrop(true)} title="Alt+D">
|
||||
<Upload size={12} /> DROP FILE
|
||||
</button>
|
||||
<button className="btn violet" onClick={() => setShowCreate(true)} title="Alt+C">
|
||||
<Plus size={12} /> NEW TOKEN
|
||||
</button>
|
||||
@@ -607,7 +971,7 @@ const CanaryTokens: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', borderBottom: '1px solid var(--border-color, #30363d)' }}>
|
||||
{(['tokens', 'blobs'] as const).map((t) => (
|
||||
{(['tokens', 'blobs', 'filedrops'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
@@ -619,7 +983,11 @@ const CanaryTokens: React.FC = () => {
|
||||
fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{t === 'tokens' ? `Tokens (${tokens.length})` : `Blobs (${blobs.length})`}
|
||||
{t === 'tokens'
|
||||
? `Tokens (${tokens.length})`
|
||||
: t === 'blobs'
|
||||
? `Blobs (${blobs.length})`
|
||||
: `File drops (${fileDrops.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -769,6 +1137,84 @@ const CanaryTokens: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{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: 'rgba(255,255,255,0.02)',
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<CreateModal
|
||||
blobs={blobs}
|
||||
@@ -802,6 +1248,22 @@ const CanaryTokens: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showFileDrop && (
|
||||
<FileDropModal
|
||||
deckies={deckies}
|
||||
topologies={topologies}
|
||||
onClose={() => setShowFileDrop(false)}
|
||||
onDropped={(entry) => {
|
||||
setFileDrops((prev) => {
|
||||
const next = [entry, ...prev].slice(0, 200);
|
||||
saveFileDrops(next);
|
||||
return next;
|
||||
});
|
||||
setShowFileDrop(false);
|
||||
setTab('filedrops');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user