import { useEffect, useState, useMemo, useRef, useCallback } 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 { RefreshCw, Download, Trash2, Search, Filter } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; interface LogEntry { stackName: string; containerName: string; source: string; level: string; message: string; timestampMs: number; } export function GlobalObservabilityView() { const { activeNode } = useNodes(); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [allStacks, setAllStacks] = useState([]); // 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 [clearedAt, setClearedAt] = useState(0); const bottomRef = useRef(null); const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true); // SSE throttle buffer const bufferRef = useRef([]); // Fetch settings on mount useEffect(() => { const fetchSettings = 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); } }; 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) useEffect(() => { if (devMode) { // SSE mode const activeNodeId = localStorage.getItem('sencho-active-node') || ''; const eventSource = new EventSource(`/api/logs/global/stream?nodeId=${activeNodeId}`); eventSource.onmessage = (event) => { try { const entry: LogEntry = JSON.parse(event.data); bufferRef.current.push(entry); } catch (e) { /* ignore parse errors */ } }; eventSource.onerror = () => { // SSE will auto-reconnect, no action needed }; // 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(-10000); }); } }, 500); setLoading(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) { setLogs(await logsRes.json()); } } catch (error) { console.error('Failed to fetch global logs:', error); } finally { setLoading(false); } }; fetchData(); const interval = setInterval(fetchData, pollRate * 1000); return () => clearInterval(interval); } }, [devMode, pollRate]); 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 (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, searchQuery, clearedAt]); useEffect(() => { if (isAutoScrollEnabled) { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); } }, [filteredLogs, isAutoScrollEnabled]); const handleScroll = useCallback((e: React.UIEvent) => { const target = e.currentTarget; const isAtBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 50; setIsAutoScrollEnabled(isAtBottom); }, []); const handleDownload = () => { if (filteredLogs.length === 0) return; const blob = new Blob([filteredLogs.map(l => `[${new Date(l.timestampMs).toLocaleTimeString([], { hour12: true })}] [${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 (
{/* Node Context Indicator */} {activeNode?.type === 'remote' && (
{activeNode.name}
)} {/* Floating Action Bar */}
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
)}
{loading && logs.length === 0 && (
)}
{filteredLogs.length > 0 ? ( <> {filteredLogs.map((log, idx) => (
[{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."}
)}
); }