import { useEffect, useState, useMemo, useRef, useCallback, useLayoutEffect } from 'react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { ScrollArea } from '@/components/ui/scroll-area'; import { RefreshCw, Download, Trash2, Search, Filter, AlertCircle } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; // Max entries held in React state. Bounds SSE-mode memory growth. const MAX_LOG_ENTRIES = 2000; // Max rows rendered as DOM nodes at once. Prevents the renderer from // creating thousands of DOM nodes that OOM the browser on RAM-constrained hosts. const MAX_DISPLAY_ROWS = 300; interface LogEntry { stackName: string; containerName: string; source: 'STDOUT' | 'STDERR'; level: 'INFO' | 'WARN' | 'ERROR'; message: string; timestampMs: number; // Assigned client-side at ingestion. Gives React a stable, collision-free // key so the slice window can shift without touching existing DOM nodes. _id: number; } export function GlobalObservabilityView() { const { activeNode } = useNodes(); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [allStacks, setAllStacks] = useState([]); const [fetchError, setFetchError] = useState(false); // Settings state const [devMode, setDevMode] = useState(false); const [pollRate, setPollRate] = useState(5); // Filters const [searchQuery, setSearchQuery] = useState(''); const [selectedStacks, setSelectedStacks] = useState([]); const [streamFilter, setStreamFilter] = useState<'ALL' | 'STDOUT' | 'STDERR'>('ALL'); const [levelFilter, setLevelFilter] = useState<'ALL' | 'ERROR' | 'WARN' | 'INFO'>('ALL'); const [clearedAt, setClearedAt] = useState(0); const bottomRef = useRef(null); const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true); const viewportRef = useRef(null); // SSE throttle buffer const bufferRef = useRef([]); // Monotonic counter for stable React keys. Incremented once per log entry // at ingestion so duplicate-content lines never share a key. const logIdRef = useRef(0); // Fetch settings on mount const fetchSettings = useCallback(async () => { try { const res = await apiFetch('/settings'); if (res.ok) { const data = await res.json(); setDevMode(data.developer_mode === '1'); setPollRate(parseInt(data.global_logs_refresh || '5', 10)); } } catch (e) { console.error('Failed to fetch settings:', e); } }, []); useEffect(() => { fetchSettings(); }, [fetchSettings]); // Re-fetch settings when they change from the settings modal useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent<{ changedKeys: string[] }>).detail; if (detail.changedKeys.some(k => k === 'developer_mode' || k === 'global_logs_refresh')) { fetchSettings(); } }; window.addEventListener(SENCHO_SETTINGS_CHANGED, handler); return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler); }, [fetchSettings]); // Fetch definitive stack list from the filesystem, independent of log data useEffect(() => { const fetchStacks = async () => { try { const res = await apiFetch('/stacks'); if (res.ok) { const stacks: string[] = await res.json(); setAllStacks(stacks.sort()); } } catch (err) { console.error('Failed to fetch stacks:', err); } }; fetchStacks(); }, []); // Data fetching: Polling (standard) vs SSE (dev mode). // Depends on activeNode?.id so the stream reconnects on node switch. const activeNodeId = activeNode?.id; useEffect(() => { if (devMode) { // SSE mode: use the node ID from context (not localStorage) const nodeParam = activeNodeId != null ? String(activeNodeId) : ''; const eventSource = new EventSource(`/api/logs/global/stream?nodeId=${nodeParam}`); eventSource.onmessage = (event) => { try { const entry: LogEntry = JSON.parse(event.data); entry._id = ++logIdRef.current; bufferRef.current.push(entry); } catch { /* ignore parse errors */ } }; eventSource.onerror = () => { if (eventSource.readyState === EventSource.CLOSED) { setFetchError(true); } }; // 500ms throttle: flush buffer into React state const flushInterval = setInterval(() => { if (bufferRef.current.length > 0) { const batch = bufferRef.current.splice(0); setLogs(prev => { const merged = [...prev, ...batch]; merged.sort((a, b) => a.timestampMs - b.timestampMs); return merged.slice(-MAX_LOG_ENTRIES); }); } }, 500); setLoading(false); setFetchError(false); return () => { eventSource.close(); clearInterval(flushInterval); bufferRef.current = []; }; } else { // Standard polling mode const fetchData = async () => { setLoading(true); try { const logsRes = await apiFetch('/logs/global'); if (logsRes.ok) { const data: LogEntry[] = await logsRes.json(); // Stamp each entry with a monotonic _id at ingestion so React // has a stable, collision-free key for every log line. data.forEach(entry => { entry._id = ++logIdRef.current; }); setLogs(data); setFetchError(false); } else { setFetchError(true); } } catch (error) { console.error('Failed to fetch global logs:', error); setFetchError(true); } finally { setLoading(false); } }; fetchData(); const interval = setInterval(fetchData, pollRate * 1000); return () => clearInterval(interval); } }, [devMode, pollRate, activeNodeId]); const handleStackToggle = (stack: string) => { setSelectedStacks(prev => prev.includes(stack) ? prev.filter(s => s !== stack) : [...prev, stack] ); }; const handleClearLogs = () => { setClearedAt(Date.now()); }; const filteredLogs = useMemo(() => { return logs.filter(log => { if (log.timestampMs < clearedAt) return false; if (selectedStacks.length > 0 && !selectedStacks.includes(log.stackName)) return false; if (streamFilter !== 'ALL' && log.source !== streamFilter) return false; if (levelFilter !== 'ALL' && log.level !== levelFilter) return false; if (searchQuery) { const query = searchQuery.toLowerCase(); return log.message.toLowerCase().includes(query) || log.containerName.toLowerCase().includes(query) || log.stackName.toLowerCase().includes(query); } return true; }); }, [logs, selectedStacks, streamFilter, levelFilter, searchQuery, clearedAt]); useEffect(() => { if (isAutoScrollEnabled && bottomRef.current) { // Use instant scroll to avoid stacking smooth-scroll animations on every // 5-second poll cycle, which wastes layout work and renderer memory. bottomRef.current.scrollIntoView({ behavior: 'instant' }); } }, [filteredLogs, isAutoScrollEnabled]); // Wire scroll detection via the Radix viewport ref (ScrollArea does not // forward onScroll natively). const handleScroll = useCallback(() => { const el = viewportRef.current; if (!el) return; const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50; setIsAutoScrollEnabled(isAtBottom); }, []); useLayoutEffect(() => { const el = viewportRef.current; if (!el) return; el.addEventListener('scroll', handleScroll); return () => el.removeEventListener('scroll', handleScroll); }, [handleScroll]); const handleDownload = () => { if (filteredLogs.length === 0) return; const blob = new Blob([filteredLogs.map(l => `[${new Date(l.timestampMs).toLocaleTimeString([], { hour12: true })}] [${l.stackName}/${l.containerName}] ${l.level}: ${l.message}`).join('\n')], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `sencho-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; return (
{/* Permanent Toolbar */}
{activeNode?.type === 'remote' && (
{activeNode.name}
)}
setSearchQuery(e.target.value)} className="pl-8 h-8 text-sm w-48 bg-transparent" />
{allStacks.map(stack => ( handleStackToggle(stack)} > {stack} ))} {allStacks.length === 0 && (
No stacks found
)}
{devMode && (
● LIVE
)}
{fetchError && (
Failed to fetch logs. Retrying...
)}
{loading && logs.length === 0 && (
)} {filteredLogs.length > 0 ? ( <> {filteredLogs.length > MAX_DISPLAY_ROWS && (
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
)} {filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => (
[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}] [{log.containerName}] {log.level}: {log.message}
))}
) : (
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
)}
); }