import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import DownloadIcon from '@mui/icons-material/GetApp'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import WarningIcon from '@mui/icons-material/Warning'; import { Box, Button, Checkbox, Grid, IconButton, MenuItem, TextField, styled } from '@mui/material'; import { API } from 'api/app'; import { fetchLogES, readLogSettings, updateLogSettings } from 'api/system'; import { useRequest, useSSE } from 'alova/client'; import { BlockFormControlLabel, BlockNavigation, FormLoader, SectionContent, useLayoutTitle } from 'components'; import { useI18nContext } from 'i18n/i18n-react'; import type { LogEntry, LogSettings } from 'types'; import { LogLevel } from 'types'; import { updateValueDirty, useRest } from 'utils'; const MAX_LOG_ENTRIES = 1000; // Limit log entries to prevent memory issues const TextColors: Record = { [LogLevel.ERROR]: '#ff0000', // red [LogLevel.WARNING]: '#ff0000', // red [LogLevel.NOTICE]: '#ffffff', // white [LogLevel.INFO]: '#ffcc00', // yellow [LogLevel.DEBUG]: '#00ffff', // cyan [LogLevel.TRACE]: '#00ffff', // cyan [LogLevel.ALL]: '#ffffff' // white }; const LogEntryLine = styled('span')( ({ details: { level } }: { details: { level: LogLevel } }) => ({ color: TextColors[level] }) ); const levelLabel = (level: LogLevel) => { switch (level) { case LogLevel.ERROR: return 'ERROR'; case LogLevel.WARNING: return 'WARNING'; case LogLevel.NOTICE: return 'NOTICE'; case LogLevel.INFO: return 'INFO'; case LogLevel.DEBUG: return 'DEBUG'; case LogLevel.TRACE: return 'TRACE'; default: return ''; } }; 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 }) => { return (
{entry.t} {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 = () => { const { LL } = useI18nContext(); useLayoutTitle(LL.LOG_OF(LL.SYSTEM(0))); const { loadData, data, updateDataValue, origData, dirtyFlags, setDirtyFlags, blocker, saveData, errorMessage } = useRest({ read: readLogSettings, update: updateLogSettings }); const { send } = useRequest( (data: string) => API({ device: 'system', cmd: 'read', id: 0, data: data }), { immediate: false } ); const [readValue, setReadValue] = useState(''); const [readOpen, setReadOpen] = useState(false); const [logEntries, setLogEntries] = useState([]); const [autoscroll, setAutoscroll] = useState(true); const [boxPosition, setBoxPosition] = useState({ top: 0, left: 0 }); const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/; const updateFormValue = updateValueDirty( origData as unknown as Record, dirtyFlags, setDirtyFlags, 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(handleLogMessage) .onError(() => { toast.error('No connection to Log service'); }); const onDownload = useCallback(() => { const result = logEntries .map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`) .join('\n'); const a = document.createElement('a'); a.setAttribute( 'href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(result) ); a.setAttribute('download', 'log.txt'); document.body.appendChild(a); a.click(); document.body.removeChild(a); }, [logEntries]); const saveSettings = useCallback(async () => { await saveData(); }, [saveData]); // handle scrolling - optimized to only scroll when needed const ref = useRef(null); const logWindowRef = useRef(null); useEffect(() => { if (logEntries.length && autoscroll) { const container = logWindowRef.current; if (container) { requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); } } }, [logEntries.length, autoscroll]); const sendReadCommand = useCallback(() => { if (readValue === '') { setReadOpen(!readOpen); return; } if (readValue.split(' ').filter((word) => word !== '').length > 1) { void send(readValue); setReadOpen(false); setReadValue(''); } }, [readValue, readOpen, send]); const content = () => { if (!data) { return ; } return ( <> OFF ERROR WARNING NOTICE INFO ALL {data.psram && ( 25 50 75 100 )} } label={LL.COMPACT()} /> setAutoscroll(!autoscroll)} name="autoscroll" /> } label={LL.AUTO_SCROLL()} /> {readOpen ? ( { e.preventDefault(); sendReadCommand(); }} > { setReadOpen(false); setReadValue(''); }} > { const value = e.target.value; if (value !== '' && !ALPHA_NUMERIC_DASH_REGEX.test(value)) { return; } setReadValue(value); }} focused={true} size="small" label="Send Read command" // doesn't need translating - developer only helperText=" [offset] [len]" /> ) : ( <> {data.developer_mode && ( )} )} {dirtyFlags && dirtyFlags.length !== 0 && ( )} {logEntries.map((e) => ( ))}
); }; return ( {blocker ? : null} {content()} ); }; export default SystemLog;