Files
sencho/frontend/src/components/GlobalObservabilityView.tsx
T
SaelixCode 753b0c3539 fix(logs): use monotonic _id key to prevent O(n) DOM mutations on scroll
key={idx} with a shifting .slice(-300) window meant every new log arrival
caused React to update all 300 existing DOM nodes (index 1 became 0, 2
became 1, etc.), even though only one entry actually changed.

The proposed content-hash fix (timestamp + containerName + message.substring)
carries a real collision risk: chatty containers emitting identical lines at
the same millisecond produce duplicate keys, triggering undefined React
reconciliation behaviour.

Fix: add a _id: number field to LogEntry, stamped client-side with a
monotonic counter (logIdRef) at the point of ingestion — once in the SSE
onmessage handler and once in the polling setLogs path. React now tracks
data identity rather than position, so the slice window can shift without
touching the 299 unchanged DOM nodes.
2026-03-20 12:06:36 -04:00

304 lines
14 KiB
TypeScript

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';
// 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: string;
level: string;
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<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [allStacks, setAllStacks] = useState<string[]>([]);
// Settings state
const [devMode, setDevMode] = useState(false);
const [pollRate, setPollRate] = useState(5);
// Filters
const [searchQuery, setSearchQuery] = useState('');
const [selectedStacks, setSelectedStacks] = useState<string[]>([]);
const [streamFilter, setStreamFilter] = useState<'ALL' | 'STDOUT' | 'STDERR'>('ALL');
const [clearedAt, setClearedAt] = useState<number>(0);
const bottomRef = useRef<HTMLDivElement>(null);
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
// SSE throttle buffer
const bufferRef = useRef<LogEntry[]>([]);
// 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
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);
entry._id = ++logIdRef.current;
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(-MAX_LOG_ENTRIES);
});
}
}, 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) {
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);
}
} 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) {
// 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]);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
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 (
<div className="flex flex-col h-full w-full relative group bg-[#0A0A0A] text-gray-300">
{/* Node Context Indicator */}
{activeNode?.type === 'remote' && (
<div className="absolute top-2 left-4 z-10 flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md px-2.5 py-1 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
{activeNode.name}
</div>
)}
{/* Floating Action Bar */}
<div className="absolute top-2 right-6 z-10 flex gap-2 transition-opacity duration-200 opacity-0 group-hover:opacity-100 focus-within:opacity-100 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md p-1 pr-1">
<div className="relative flex items-center">
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm w-48 bg-transparent"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-sm">
<Filter className="w-3.5 h-3.5 mr-2" />
Stacks ({selectedStacks.length === 0 ? 'All' : selectedStacks.length})
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{allStacks.map(stack => (
<DropdownMenuCheckboxItem
key={stack}
checked={selectedStacks.includes(stack)}
onCheckedChange={() => handleStackToggle(stack)}
>
{stack}
</DropdownMenuCheckboxItem>
))}
{allStacks.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">No stacks found</div>
)}
</DropdownMenuContent>
</DropdownMenu>
<Select value={streamFilter} onValueChange={(val: any) => setStreamFilter(val)}>
<SelectTrigger className="w-[110px] h-8 text-sm">
<SelectValue placeholder="Stream" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Streams</SelectItem>
<SelectItem value="STDOUT">STDOUT</SelectItem>
<SelectItem value="STDERR">STDERR</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={handleClearLogs} className="h-8 text-sm px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleDownload} disabled={filteredLogs.length === 0} className="h-8 text-sm px-2">
<Download className="w-3.5 h-3.5" />
</Button>
{devMode && (
<div className="flex items-center px-2 text-xs text-emerald-400 font-mono animate-pulse">
LIVE
</div>
)}
</div>
{loading && logs.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-20">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
</div>
)}
<div className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent" onScroll={handleScroll}>
{filteredLogs.length > 0 ? (
<>
{filteredLogs.length > MAX_DISPLAY_ROWS && (
<div className="text-gray-600 italic text-xs text-center mb-3 py-1 border-b border-gray-800">
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
</div>
)}
{filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => (
<div key={log._id} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
<span className="text-gray-500 mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
<span className="text-blue-400 font-semibold mr-2">[{log.containerName}]</span>
<span className={`mr-2 font-bold ${log.level === 'ERROR' ? 'text-red-500' : log.level === 'WARN' ? 'text-yellow-500' : 'text-green-500'}`}>{log.level}:</span>
<span className={log.source === 'STDERR' ? 'text-red-300' : 'text-gray-300'}>{log.message}</span>
</div>
))}
<div ref={bottomRef} />
</>
) : (
<div className="text-gray-500 italic p-4 text-center mt-10">
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
</div>
)}
</div>
</div>
);
}