diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 0000000..29f4e3f --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,4 @@ + +## 2024-05-19 - [Optimize WebSocket list rendering performance] +**Learning:** In React list rendering updated by high-frequency WebSocket events, using array indexes as keys (for `LogPanel`) causes expensive O(N) re-renders and potential DOM state bugs. Using `React.memo` for list items must be accompanied by a custom primitive-level equality check when list item state incorporates nested objects or function references. For fallback client-side IDs, using `Date.now() - Math.random()` string pattern provides stable uniqueness when UUID isn't available in non-secure HTTP environments typical in self-hosted deployments. +**Action:** Extract list items to `React.memo` components with custom primitive equality checks early on in development of WebSocket-dependent UIs. Always implement stable UUID alternatives for client-generated list items to ensure performant virtual DOM diffing. diff --git a/frontend/src/components/LogPanel.tsx b/frontend/src/components/LogPanel.tsx index f963077..0a33f81 100644 --- a/frontend/src/components/LogPanel.tsx +++ b/frontend/src/components/LogPanel.tsx @@ -3,8 +3,44 @@ import { Terminal, Trash2 } from 'lucide-react' import { logsWS } from '@/services/websocket' import type { LogMessage } from '@/types/api' +// Extended type to include stable client-side ID +interface UI_LogMessage extends LogMessage { + id: string; +} + +const getLogColor = (level: string): string => { + switch (level.toUpperCase()) { + case 'ERROR': return '#f44336' + case 'WARNING': return '#ff9800' + case 'INFO': return '#2196F3' + case 'SUCCESS': return '#4caf50' + default: return '#999' + } +} + +const LogItem = React.memo(({ log }: { log: UI_LogMessage }) => { + return ( +
+ + {new Date(log.timestamp).toLocaleTimeString()} + + {' '} + + [{log.level.toUpperCase()}] + + + {log.message} + +
+ ) +}) + const LogPanel: React.FC = () => { - const [logs, setLogs] = useState([]) + const [logs, setLogs] = useState([]) const [autoScroll, setAutoScroll] = useState(true) const [filter, setFilter] = useState('all') const logContainerRef = useRef(null) @@ -13,7 +49,8 @@ const LogPanel: React.FC = () => { // Listen for log messages via WebSocket const handleLogMessage = (data: any) => { if (data.type === 'log') { - const logMessage: LogMessage = { + const logMessage: UI_LogMessage = { + id: `${Date.now()}-${Math.random()}`, timestamp: data.timestamp, level: data.level, message: data.message, @@ -40,16 +77,6 @@ const LogPanel: React.FC = () => { setLogs([]) } - const getLogColor = (level: string): string => { - switch (level.toUpperCase()) { - case 'ERROR': return '#f44336' - case 'WARNING': return '#ff9800' - case 'INFO': return '#2196F3' - case 'SUCCESS': return '#4caf50' - default: return '#999' - } - } - const filteredLogs = filter === 'all' ? logs : logs.filter(log => log.level.toLowerCase() === filter.toLowerCase()) @@ -122,23 +149,8 @@ const LogPanel: React.FC = () => { No logs to display ) : ( - filteredLogs.map((log, index) => ( -
- - {new Date(log.timestamp).toLocaleTimeString()} - - {' '} - - [{log.level.toUpperCase()}] - - - {log.message} - -
+ filteredLogs.map((log) => ( + )) )} diff --git a/frontend/src/components/ProgressPanel.tsx b/frontend/src/components/ProgressPanel.tsx index 5581d24..07b0b23 100644 --- a/frontend/src/components/ProgressPanel.tsx +++ b/frontend/src/components/ProgressPanel.tsx @@ -4,6 +4,124 @@ import { downloadsApi } from '@/services/api' import { progressWS } from '@/services/websocket' import type { DownloadStatusResponse, ProgressUpdate } from '@/types/api' +const formatSpeed = (bytesPerSecond: number): string => { + if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` + if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` + return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s` +} + +const formatETA = (seconds?: number): string => { + if (!seconds) return 'Calculating...' + if (seconds < 60) return `${Math.round(seconds)}s` + if (seconds < 3600) return `${Math.round(seconds / 60)}m` + return `${Math.round(seconds / 3600)}h` +} + +const getStatusColor = (status: string): string => { + switch (status) { + case 'downloading': return '#667eea' + case 'completed': return '#4caf50' + case 'failed': return '#f44336' + case 'cancelled': return '#999' + default: return '#ff9800' + } +} + +const DownloadItem = React.memo( + ({ download }: { download: DownloadStatusResponse }) => { + return ( +
+
+
+
+ {download.url} +
+
+ {download.current_file || 'Preparing...'} +
+
+ +
+ + {download.status.toUpperCase()} + + {download.completed_files} / {download.total_files} files +
+
+ +
+
+
+ +
+ {download.progress.toFixed(1)}% + + {formatSpeed(download.download_speed)} • ETA: {formatETA(download.eta_seconds)} + +
+ + {download.error_message && ( +
+ Error: {download.error_message} +
+ )} +
+ ) + }, + (prevProps, nextProps) => { + return ( + prevProps.download.status === nextProps.download.status && + prevProps.download.progress === nextProps.download.progress && + prevProps.download.current_file === nextProps.download.current_file && + prevProps.download.download_speed === nextProps.download.download_speed && + prevProps.download.eta_seconds === nextProps.download.eta_seconds && + prevProps.download.completed_files === nextProps.download.completed_files && + prevProps.download.total_files === nextProps.download.total_files && + prevProps.download.error_message === nextProps.download.error_message + ) + } +) + const ProgressPanel: React.FC = () => { const [downloads, setDownloads] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -47,29 +165,6 @@ const ProgressPanel: React.FC = () => { } }, []) - const formatSpeed = (bytesPerSecond: number): string => { - if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` - if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` - return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s` - } - - const formatETA = (seconds?: number): string => { - if (!seconds) return 'Calculating...' - if (seconds < 60) return `${Math.round(seconds)}s` - if (seconds < 3600) return `${Math.round(seconds / 60)}m` - return `${Math.round(seconds / 3600)}h` - } - - const getStatusColor = (status: string): string => { - switch (status) { - case 'downloading': return '#667eea' - case 'completed': return '#4caf50' - case 'failed': return '#f44336' - case 'cancelled': return '#999' - default: return '#ff9800' - } - } - if (isLoading) { return (
@@ -99,83 +194,7 @@ const ProgressPanel: React.FC = () => {
{downloads.map((download) => ( -
-
-
-
- {download.url} -
-
- {download.current_file || 'Preparing...'} -
-
- -
- - {download.status.toUpperCase()} - - {download.completed_files} / {download.total_files} files -
-
- -
-
-
- -
- {download.progress.toFixed(1)}% - - {formatSpeed(download.download_speed)} • ETA: {formatETA(download.eta_seconds)} - -
- - {download.error_message && ( -
- Error: {download.error_message} -
- )} -
+ ))}