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}
-
- )}
-
+
))}