import React, { useEffect, useState, useRef, useMemo } from 'react'; import { useFocusSearch } from '../hooks/useFocusSearch'; import { useSearchParams } from 'react-router-dom'; import { Terminal, Search, BarChart3, ChevronLeft, ChevronRight, Play, Pause, Paperclip, Download, Radio, X as XIcon, } from '../icons'; import api from '../utils/api'; import { parseEventBody } from '../utils/parseEventBody'; import ArtifactDrawer from './ArtifactDrawer'; import EmptyState from './EmptyState/EmptyState'; import './Dashboard.css'; import './LiveLogs.css'; interface LogEntry { id: number; timestamp: string; decky: string; service: string; event_type: string; attacker_ip: string; raw_line: string; fields: string; msg: string; is_bounty?: boolean; } const LIMIT = 50; const LiveLogs: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const query = searchParams.get('q') || ''; const timeRange = searchParams.get('time') || '1h'; const isLive = searchParams.get('live') !== 'false'; const page = parseInt(searchParams.get('page') || '1'); const [logs, setLogs] = useState([]); const [totalLogs, setTotalLogs] = useState(0); const [loading, setLoading] = useState(true); const [streaming, setStreaming] = useState(isLive); const [searchInput, setSearchInput] = useState(query); const searchRef = useRef(null); useFocusSearch(searchRef); const [selectedHour, setSelectedHour] = useState(null); const eventSourceRef = useRef(null); const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); useEffect(() => { setSearchInput(query); }, [query]); const startTimeParam = (): string | null => { if (timeRange === 'all') return null; const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0; if (!minutes) return null; return new Date(Date.now() - minutes * 60000).toISOString().replace('T', ' ').substring(0, 19); }; const fetchData = async () => { setLoading(true); try { const offset = (page - 1) * LIMIT; let url = `/logs?limit=${LIMIT}&offset=${offset}&search=${encodeURIComponent(query)}`; const startTime = startTimeParam(); if (startTime) url += `&start_time=${startTime}`; const res = await api.get(url); setLogs(res.data.data); setTotalLogs(res.data.total); } catch (err) { console.error('Failed to fetch logs', err); } finally { setLoading(false); } }; const setupSSE = () => { if (eventSourceRef.current) eventSourceRef.current.close(); const token = localStorage.getItem('token'); const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1'; let url = `${baseUrl}/stream?token=${token}&search=${encodeURIComponent(query)}`; const startTime = startTimeParam(); if (startTime) url += `&start_time=${startTime}`; const es = new EventSource(url); eventSourceRef.current = es; es.onmessage = (event) => { try { const payload = JSON.parse(event.data); if (payload.type === 'logs') { setLogs(prev => [...payload.data, ...prev].slice(0, 500)); } else if (payload.type === 'stats') { setTotalLogs(payload.data.total_logs); } } catch (err) { console.error('Failed to parse SSE payload', err); } }; es.onerror = () => console.error('SSE connection lost, reconnecting...'); }; // Always seed with REST backlog on mount / filter changes. useEffect(() => { fetchData(); }, [query, timeRange, page]); // SSE follows the streaming toggle independently. useEffect(() => { if (streaming) { setupSSE(); } else if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } return () => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } }; }, [streaming, query, timeRange]); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); setSearchParams({ q: searchInput, time: timeRange, live: streaming.toString(), page: '1' }); }; const handleToggleLive = () => { const newStreaming = !streaming; setStreaming(newStreaming); setSearchParams({ q: query, time: timeRange, live: newStreaming.toString(), page: '1' }); }; const handleTimeChange = (newTime: string) => { setSearchParams({ q: query, time: newTime, live: streaming.toString(), page: '1' }); }; const changePage = (newPage: number) => { setSearchParams({ q: query, time: timeRange, live: 'false', page: newPage.toString() }); }; const buckets = useMemo(() => { const b = Array.from({ length: 24 }, (_, i) => ({ i, count: 0, bounties: 0 })); logs.forEach(l => { const h = parseInt(String(l.timestamp).slice(11, 13), 10); if (!isNaN(h) && h >= 0 && h < 24) { b[h].count++; if (l.is_bounty) b[h].bounties++; } }); return b; }, [logs]); const maxBar = Math.max(...buckets.map(b => b.count), 1); const peakHour = buckets.findIndex(b => b.count === maxBar); const filteredLogs = useMemo(() => { if (selectedHour == null) return logs; return logs.filter(l => parseInt(String(l.timestamp).slice(11, 13), 10) === selectedHour); }, [logs, selectedHour]); const handleExport = () => { const header = 'timestamp,decky,service,attacker_ip,event_type,msg'; const rows = filteredLogs.map(l => [l.timestamp, l.decky, l.service, l.attacker_ip, l.event_type, (l.msg || '').replace(/"/g, '""')] .map(v => `"${v ?? ''}"`).join(',') ); const blob = new Blob([[header, ...rows].join('\n')], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `decnet-logs-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.csv`; a.click(); URL.revokeObjectURL(url); }; const totalPages = Math.max(1, Math.ceil(totalLogs / LIMIT)); return (

LOGS

{filteredLogs.length.toLocaleString()} SHOWN · {totalLogs.toLocaleString()} MATCHES · STREAM {streaming ? 'LIVE' : 'PAUSED'}
setSearchInput(e.target.value)} /> {searchInput && ( )}
ATTACK VOLUME — PAST 24 HOURS {selectedHour != null && ( · {String(selectedHour).padStart(2, '0')}:00 SELECTED — setSelectedHour(null)}>clear )}
PEAK: {maxBar} @ HOUR {String(peakHour).padStart(2, '0')}
{buckets.map(b => (
0 ? 'has-bounty' : ''}`} style={{ height: `${(b.count / maxBar) * 100}%` }} title={`${String(b.i).padStart(2, '0')}:00 — ${b.count} events${b.bounties ? `, ${b.bounties} bounties` : ''}`} onClick={() => setSelectedHour(selectedHour === b.i ? null : b.i)} /> ))}
00:0006:0012:0018:0023:59
LOG EXPLORER
SHOWING {filteredLogs.length} OF {totalLogs.toLocaleString()} {!streaming && (
Page {page} of {totalPages}
)}
{filteredLogs.length > 0 ? filteredLogs.map(log => { let parsedFields: Record = {}; if (log.fields) { try { parsedFields = JSON.parse(log.fields); } catch { /* noop */ } } let msgHead: string | null = null; let msgTail: string | null = null; if (Object.keys(parsedFields).length === 0) { const parsed = parseEventBody(log.msg); parsedFields = parsed.fields; msgHead = parsed.head; msgTail = parsed.tail; } else if (log.msg && log.msg !== '-') { msgTail = log.msg; } const et = log.event_type && log.event_type !== '-' ? log.event_type : null; const headParts = [et, msgHead].filter(Boolean) as string[]; const hasBadges = Object.keys(parsedFields).length > 0 || parsedFields.stored_as; return ( ); }) : ( )}
TIME DECKY SVC ATTACKER EVENT
{new Date(log.timestamp).toLocaleString()} {log.decky} {log.service} {log.attacker_ip}
{headParts.join(' · ')} {msgTail && ( {headParts.length ? ' — ' : ''}{msgTail} )}
{hasBadges && (
{parsedFields.stored_as && ( )} {Object.entries(parsedFields) .filter(([k]) => k !== 'meta_json_b64' && k !== 'stored_as') .map(([k, v]) => ( {k}: {typeof v === 'object' ? JSON.stringify(v) : String(v)} ))}
)}
{artifact && ( setArtifact(null)} /> )}
); }; export default LiveLogs;