import { useEffect, useState, useMemo, useRef, useCallback, useLayoutEffect, memo } from 'react'; import type { ReactNode } from 'react'; 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 { PageMasthead } from '@/components/ui/PageMasthead'; import { SignalRail, type SignalTile } from '@/components/ui/SignalRail'; import { SegmentedControl, type SegmentedControlOption } from '@/components/ui/segmented-control'; import { Download, Trash2, Search, Filter, AlertCircle, Pause, Play } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { cn } from '@/lib/utils'; const MAX_LOG_ENTRIES = 2000; const MAX_DISPLAY_ROWS = 300; const POLL_FALLBACK_MS = 5000; const LIVE_WINDOW_MS = 10_000; const SPARK_BUCKET_MS = 1_000; const SPARK_BUCKETS = 60; type StreamFilter = 'ALL' | 'STDOUT' | 'STDERR'; type LevelFilter = 'ALL' | 'ERROR' | 'WARN' | 'INFO'; interface LogEntry { stackName: string; containerName: string; source: 'STDOUT' | 'STDERR'; level: 'INFO' | 'WARN' | 'ERROR'; message: string; timestampMs: number; // Client-assigned so the slice window can shift without re-keying existing rows. _id: number; } function timestampBandLabel(ms: number, now: number): string { const diff = now - ms; if (diff < 60_000) return 'NOW'; const mins = Math.floor(diff / 60_000); if (mins < 60) return `${mins}M AGO`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}H AGO`; const date = new Date(ms); return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } function formatUptime(ms: number): string { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); if (h > 0) return `${h}H ${m.toString().padStart(2, '0')}M`; const s = totalSeconds % 60; return `${m}M ${s.toString().padStart(2, '0')}S`; } const levelDotClass: Record = { ERROR: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_22%,transparent)]', WARN: 'bg-warning', INFO: 'bg-success/80', }; const levelRowTint: Record = { ERROR: 'bg-destructive/[0.08]', WARN: 'bg-warning/[0.06]', INFO: '', }; const STREAM_OPTIONS: SegmentedControlOption[] = [ { value: 'ALL', label: 'All' }, { value: 'STDOUT', label: 'Out' }, { value: 'STDERR', label: 'Err' }, ]; const LEVEL_OPTIONS: SegmentedControlOption[] = [ { value: 'ALL', label: 'All' }, { value: 'INFO', label: 'Info' }, { value: 'WARN', label: 'Warn' }, { value: 'ERROR', label: 'Error' }, ]; export function GlobalObservabilityView() { const { activeNode } = useNodes(); const [logs, setLogs] = useState([]); const [allStacks, setAllStacks] = useState([]); const [fetchError, setFetchError] = useState(false); const [lastEventAt, setLastEventAt] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [selectedStacks, setSelectedStacks] = useState([]); const [streamFilter, setStreamFilter] = useState('ALL'); const [levelFilter, setLevelFilter] = useState('ALL'); const [clearedAt, setClearedAt] = useState(0); const [isPaused, setIsPaused] = useState(false); const [pendingCount, setPendingCount] = useState(0); const bottomRef = useRef(null); const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true); const viewportRef = useRef(null); const bufferRef = useRef([]); const logIdRef = useRef(0); // Mirrors isPaused for use inside stable interval callbacks. const pausedRef = useRef(isPaused); useEffect(() => { pausedRef.current = isPaused; }, [isPaused]); const [mountedAt, setMountedAt] = useState(null); const [tick, setTick] = useState(0); useEffect(() => { const run = () => { const now = Date.now(); setTick(now); setMountedAt(prev => prev ?? now); }; const init = setTimeout(run, 0); const id = setInterval(run, 1000); return () => { clearTimeout(init); clearInterval(id); }; }, []); 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(); }, []); const activeNodeId = activeNode?.id; useEffect(() => { const nodeParam = activeNodeId != null ? String(activeNodeId) : ''; const ingest = (entries: LogEntry[]) => { entries.forEach(entry => { entry._id = ++logIdRef.current; }); bufferRef.current.push(...entries); // Bound the buffer so a long pause doesn't grow memory unbounded. const excess = bufferRef.current.length - MAX_LOG_ENTRIES; if (excess > 0) bufferRef.current.splice(0, excess); setLastEventAt(Date.now()); }; const flush = () => { if (bufferRef.current.length === 0) return; if (pausedRef.current) { setPendingCount(bufferRef.current.length); return; } const batch = bufferRef.current.splice(0); setLogs(prev => { const lastPrev = prev[prev.length - 1]?.timestampMs ?? -Infinity; const firstBatch = batch[0]?.timestampMs ?? Infinity; const merged = [...prev, ...batch]; // Fast path: SSE delivers monotonically, so skip the sort when the batch // is already ordered after prev's tail. if (firstBatch < lastPrev) { merged.sort((a, b) => a.timestampMs - b.timestampMs); } return merged.slice(-MAX_LOG_ENTRIES); }); setPendingCount(0); }; let eventSource: EventSource | null = null; let pollTimer: ReturnType | null = null; let flushTimer: ReturnType | null = null; let didOpen = false; let cancelled = false; const startPolling = () => { if (pollTimer || cancelled) return; const fetchBatch = async () => { try { const res = await apiFetch('/logs/global'); if (!res.ok) { setFetchError(true); return; } const data: LogEntry[] = await res.json(); // Polling returns the current snapshot; replace rather than append // to avoid duplicates across intervals. data.forEach(entry => { entry._id = ++logIdRef.current; }); if (pausedRef.current) { setPendingCount(data.length); } else { setLogs(data); } setLastEventAt(Date.now()); setFetchError(false); } catch (err) { console.error('Failed to fetch global logs:', err); setFetchError(true); } }; fetchBatch(); pollTimer = setInterval(fetchBatch, POLL_FALLBACK_MS); }; try { eventSource = new EventSource(`/api/logs/global/stream?nodeId=${nodeParam}`); eventSource.onopen = () => { didOpen = true; setFetchError(false); }; eventSource.onmessage = (event) => { try { const entry: LogEntry = JSON.parse(event.data); ingest([entry]); } catch { /* ignore parse errors */ } }; eventSource.onerror = () => { if (!didOpen) { eventSource?.close(); eventSource = null; startPolling(); } else if (eventSource?.readyState === EventSource.CLOSED) { setFetchError(true); } }; flushTimer = setInterval(flush, 500); } catch { startPolling(); } return () => { cancelled = true; eventSource?.close(); if (pollTimer) clearInterval(pollTimer); if (flushTimer) clearInterval(flushTimer); bufferRef.current = []; }; }, [activeNodeId]); const handleStackToggle = (stack: string) => { setSelectedStacks(prev => prev.includes(stack) ? prev.filter(s => s !== stack) : [...prev, stack] ); }; const handleClearLogs = () => { setClearedAt(Date.now()); }; const handleResume = () => { setIsPaused(false); }; 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]); // Counters don't depend on tick, so they don't recompute each second. const counts = useMemo(() => { let errors = 0; let warns = 0; const containers = new Set(); for (const log of logs) { if (log.timestampMs < clearedAt) continue; containers.add(log.containerName); if (log.level === 'ERROR') errors += 1; else if (log.level === 'WARN') warns += 1; } return { errors, warns, containers: containers.size }; }, [logs, clearedAt]); // Rolling 60s sparkline buckets; shifts with tick. const buckets = useMemo(() => { const windowStart = tick - SPARK_BUCKETS * SPARK_BUCKET_MS; const b = new Array(SPARK_BUCKETS).fill(0); for (const log of logs) { if (log.timestampMs < clearedAt) continue; if (log.timestampMs >= windowStart) { const idx = Math.min(SPARK_BUCKETS - 1, Math.floor((log.timestampMs - windowStart) / SPARK_BUCKET_MS)); b[idx] += 1; } } return b; }, [logs, clearedAt, tick]); const signals = useMemo(() => { const eventsPerMin = buckets.reduce((a, b) => a + b, 0); return [ { kicker: 'EVENTS / MIN', value: String(eventsPerMin), tone: 'value', spark: buckets }, { kicker: 'ERRORS', value: String(counts.errors), tone: counts.errors > 0 ? 'error' : 'subtitle' }, { kicker: 'WARNINGS', value: String(counts.warns), tone: counts.warns > 0 ? 'warn' : 'subtitle' }, { kicker: 'CONTAINERS', value: String(counts.containers), tone: 'value' }, ]; }, [buckets, counts]); const firstEventReceived = lastEventAt != null; const liveTone: 'live' | 'idle' = lastEventAt != null && (tick - lastEventAt) < LIVE_WINDOW_MS ? 'live' : 'idle'; const masterTone = fetchError ? 'error' : liveTone; const stateWord = fetchError ? 'Offline' : masterTone === 'live' ? 'Streaming' : 'Idle'; // The Logs tab is hub-only (hidden and redirected when a remote node is // active), so the feed always reflects the local hub. const kicker = 'LIVE LOGS · NODE · LOCAL'; const mastheadMetadata = useMemo(() => { const uptime = mountedAt != null ? formatUptime(tick - mountedAt) : '—'; return [ { label: 'LAST EVENT', value: lastEventAt ? timestampBandLabel(lastEventAt, tick) : '—', tone: 'subtitle' as const }, { label: 'SESSION', value: uptime, tone: 'subtitle' as const }, ]; }, [tick, lastEventAt, mountedAt]); useEffect(() => { if (isAutoScrollEnabled && bottomRef.current) { // Instant scroll avoids stacking smooth-scroll animations on every flush, // which wastes layout work and renderer memory. bottomRef.current.scrollIntoView({ behavior: 'instant' }); } }, [filteredLogs, isAutoScrollEnabled]); 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).toISOString()}] [${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); }; const displayRows = filteredLogs.slice(-MAX_DISPLAY_ROWS); const overflow = Math.max(0, filteredLogs.length - displayRows.length); return (
setSearchQuery(e.target.value)} className="h-8 w-56 bg-transparent pl-8 text-sm focus-visible:ring-brand/50" />
{allStacks.map(stack => ( handleStackToggle(stack)} > {stack} ))} {allStacks.length === 0 && (
No stacks found
)}
{fetchError && (
Failed to fetch logs. Retrying...
)}
{!firstEventReceived && logs.length === 0 && (
Awaiting events Logs will appear here as containers emit them.
)} {displayRows.length > 0 ? ( <> {overflow > 0 && (
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length}
)}
) : (firstEventReceived && logs.length > 0) ? (
No matches Try a broader filter to see logs again.
) : null}
{isPaused && pendingCount > 0 && ( )}
); } function LogBandedList({ rows, now }: { rows: LogEntry[]; now: number }) { const nodes: ReactNode[] = []; let prevBand: string | null = null; for (const log of rows) { const band = timestampBandLabel(log.timestampMs, now); if (band !== prevBand) { nodes.push(
{band}
, ); prevBand = band; } nodes.push(); } return <>{nodes}; } const LogRow = memo(function LogRow({ log }: { log: LogEntry }) { return (
); });