diff --git a/interface/src/app/status/SystemLog.tsx b/interface/src/app/status/SystemLog.tsx index fae5da6d5..43c6580e2 100644 --- a/interface/src/app/status/SystemLog.tsx +++ b/interface/src/app/status/SystemLog.tsx @@ -1,4 +1,11 @@ -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState +} from 'react'; import { toast } from 'react-toastify'; import DownloadIcon from '@mui/icons-material/GetApp'; @@ -68,34 +75,37 @@ const levelLabel = (level: LogLevel) => { } }; +const paddedLevelLabel = (level: LogLevel, compact: boolean) => { + const label = levelLabel(level); + return compact ? ' ' + label[0] : label.padStart(8, '\xa0'); +}; + +const paddedNameLabel = (name: string, compact: boolean) => { + const label = '[' + name + ']'; + return compact ? label : label.padEnd(12, '\xa0'); +}; + +const paddedIDLabel = (id: number, compact: boolean) => { + const label = id + ':'; + return compact ? label : label.padEnd(7, '\xa0'); +}; + // Memoized log entry component to prevent unnecessary re-renders const LogEntryItem = memo( ({ entry, compact }: { entry: LogEntry; compact: boolean }) => { - const paddedLevelLabel = (level: LogLevel) => { - const label = levelLabel(level); - return compact ? ' ' + label[0] : label.padStart(8, '\xa0'); - }; - - const paddedNameLabel = (name: string) => { - const label = '[' + name + ']'; - return compact ? label : label.padEnd(12, '\xa0'); - }; - - const paddedIDLabel = (id: number) => { - const label = id + ':'; - return compact ? label : label.padEnd(7, '\xa0'); - }; - return (
{entry.t} - {paddedLevelLabel(entry.l)}  - {paddedIDLabel(entry.i)} - {paddedNameLabel(entry.n)} + {paddedLevelLabel(entry.l, compact)}  + {paddedIDLabel(entry.i, compact)} + {paddedNameLabel(entry.n, compact)} {entry.m}
); - } + }, + (prevProps, nextProps) => + prevProps.entry.i === nextProps.entry.i && + prevProps.compact === nextProps.compact ); const SystemLog = () => { @@ -129,7 +139,7 @@ const SystemLog = () => { const [readOpen, setReadOpen] = useState(false); const [logEntries, setLogEntries] = useState([]); const [autoscroll, setAutoscroll] = useState(true); - const [lastId, setLastId] = useState(-1); + const [boxPosition, setBoxPosition] = useState({ top: 0, left: 0 }); const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/; @@ -140,24 +150,69 @@ const SystemLog = () => { updateDataValue as (value: unknown) => void ); + // Calculate box position after layout + useLayoutEffect(() => { + const logWindow = document.getElementById('log-window'); + if (!logWindow) { + return; + } + + const updatePosition = () => { + const windowElement = document.getElementById('log-window'); + if (!windowElement) { + return; + } + const rect = windowElement.getBoundingClientRect(); + setBoxPosition({ top: rect.bottom, left: rect.left }); + }; + + updatePosition(); + + // Debounce resize events with requestAnimationFrame + let rafId: number; + const handleResize = () => { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(updatePosition); + }; + + // Update position on window resize + window.addEventListener('resize', handleResize); + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(logWindow); + + return () => { + window.removeEventListener('resize', handleResize); + resizeObserver.disconnect(); + cancelAnimationFrame(rafId); + }; + }, [data]); // Recalculate when data changes (in case layout shifts) + + // Memoize message handler to avoid recreating on every render + const handleLogMessage = useCallback((message: { data: string }) => { + const rawData = message.data; + const logentry = JSON.parse(rawData) as LogEntry; + setLogEntries((log) => { + // Skip if this is a duplicate entry (check last entry id) + if (log.length > 0) { + const lastEntry = log[log.length - 1]; + if (lastEntry && logentry.i <= lastEntry.i) { + return log; + } + } + const newLog = [...log, logentry]; + // Limit log entries to prevent memory issues - only slice when necessary + if (newLog.length > MAX_LOG_ENTRIES) { + return newLog.slice(-MAX_LOG_ENTRIES); + } + return newLog; + }); + }, []); + useSSE(fetchLogES, { immediate: true, interceptByGlobalResponded: false }) - .onMessage((message: { data: string }) => { - const rawData = message.data; - const logentry = JSON.parse(rawData) as LogEntry; - if (lastId < logentry.i) { - setLogEntries((log) => { - const newLog = [...log, logentry]; - // Limit log entries to prevent memory issues - return newLog.length > MAX_LOG_ENTRIES - ? newLog.slice(-MAX_LOG_ENTRIES) - : newLog; - }); - setLastId(logentry.i); - } - }) + .onMessage(handleLogMessage) .onError(() => { toast.error('No connection to Log service'); }); @@ -182,14 +237,18 @@ const SystemLog = () => { await saveData(); }, [saveData]); - // handle scrolling + // handle scrolling - optimized to only scroll when needed const ref = useRef(null); + const logWindowRef = useRef(null); + useEffect(() => { if (logEntries.length && autoscroll) { - ref.current?.scrollIntoView({ - behavior: 'smooth', - block: 'end' - }); + const container = logWindowRef.current; + if (container) { + requestAnimationFrame(() => { + container.scrollTop = container.scrollHeight; + }); + } } }, [logEntries.length, autoscroll]); @@ -206,15 +265,6 @@ const SystemLog = () => { } }, [readValue, readOpen, send]); - const boxPosition = () => { - const logWindow = document.getElementById('log-window'); - if (!logWindow) { - return { top: 0, left: 0 }; - } - const rect = logWindow.getBoundingClientRect(); - return { top: rect.bottom, left: rect.left }; - }; - const content = () => { if (!data) { return ; @@ -352,14 +402,15 @@ const SystemLog = () => {