Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## 2024-03-22 - React Re-rendering Bottleneck with Frequent WebSocket Updates
**Learning:** Extracting list items into `React.memo` components is not enough when lists are updated frequently via polling/WebSockets. For `LogPanel`, new logs caused full list re-renders because `index` was used as the key. For `ProgressPanel`, the 5-second API polling fallback replaced the entire `downloads` array, generating new object references that bypassed standard `React.memo` shallow comparisons.
**Action:** When memoizing items fed by WebSockets or polling:
1. Ensure items have stable IDs (e.g. generating a fallback client ID `Date.now() + Math.random().toString()` when `crypto.randomUUID` is unavailable).
2. For objects that are fully replaced by API polling, implement a custom equality function for `React.memo` that explicitly compares primitive fields (`status`, `progress`, `speed`, etc.) instead of relying on object references.
72 changes: 43 additions & 29 deletions frontend/src/components/LogPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,45 @@ import { Terminal, Trash2 } from 'lucide-react'
import { logsWS } from '@/services/websocket'
import type { LogMessage } from '@/types/api'

// Extended log message interface with stable client ID
interface ClientLogMessage extends LogMessage {
id: string;
}

// ⚡ Bolt: Memoized log item to prevent re-rendering entire log history on every new log
const LogItem = React.memo(({ log }: { log: ClientLogMessage }) => {
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'
}
}

return (
<div style={{ marginBottom: '4px' }}>
<span style={{ color: '#666' }}>
{new Date(log.timestamp).toLocaleTimeString()}
</span>
{' '}
<span style={{
color: getLogColor(log.level),
fontWeight: 'bold',
marginRight: '8px'
}}>
[{log.level.toUpperCase()}]
</span>
<span style={{ color: '#e0e0e0' }}>
{log.message}
</span>
</div>
)
})

const LogPanel: React.FC = () => {
const [logs, setLogs] = useState<LogMessage[]>([])
const [logs, setLogs] = useState<ClientLogMessage[]>([])
const [autoScroll, setAutoScroll] = useState(true)
const [filter, setFilter] = useState<string>('all')
const logContainerRef = useRef<HTMLDivElement>(null)
Expand All @@ -13,7 +50,9 @@ const LogPanel: React.FC = () => {
// Listen for log messages via WebSocket
const handleLogMessage = (data: any) => {
if (data.type === 'log') {
const logMessage: LogMessage = {
const logMessage: ClientLogMessage = {
// ⚡ Bolt: Generate safe stable ID for list keying
id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
timestamp: data.timestamp,
level: data.level,
message: data.message,
Expand All @@ -40,16 +79,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())
Expand Down Expand Up @@ -122,23 +151,8 @@ const LogPanel: React.FC = () => {
No logs to display
</div>
) : (
filteredLogs.map((log, index) => (
<div key={index} style={{ marginBottom: '4px' }}>
<span style={{ color: '#666' }}>
{new Date(log.timestamp).toLocaleTimeString()}
</span>
{' '}
<span style={{
color: getLogColor(log.level),
fontWeight: 'bold',
marginRight: '8px'
}}>
[{log.level.toUpperCase()}]
</span>
<span style={{ color: '#e0e0e0' }}>
{log.message}
</span>
</div>
filteredLogs.map(log => (
<LogItem key={log.id} log={log} />
))
)}
</div>
Expand Down
219 changes: 119 additions & 100 deletions frontend/src/components/ProgressPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,124 @@ import { downloadsApi } from '@/services/api'
import { progressWS } from '@/services/websocket'
import type { DownloadStatusResponse, ProgressUpdate } from '@/types/api'

// ⚡ Bolt: Memoized download item. Compares primitive fields to prevent re-renders
// caused by object reference changes during the 5-second polling fallback.
const DownloadItem = React.memo(({ download }: { download: DownloadStatusResponse }) => {
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'
}
}

return (
<div
style={{
padding: '16px',
background: 'rgba(255,255,255,0.03)',
borderRadius: '6px',
border: '1px solid var(--border-color)',
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
flexWrap: 'wrap',
gap: '8px'
}}>
<div style={{ flex: 1, minWidth: '200px' }}>
<div style={{
fontSize: '14px',
fontWeight: 500,
marginBottom: '4px',
wordBreak: 'break-all'
}}>
{download.url}
</div>
<div style={{ fontSize: '12px', color: '#999' }}>
{download.current_file || 'Preparing...'}
</div>
</div>

<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
fontSize: '14px'
}}>
<span style={{ color: getStatusColor(download.status) }}>
{download.status.toUpperCase()}
</span>
<span>{download.completed_files} / {download.total_files} files</span>
</div>
</div>

<div className="progress-bar" style={{ marginBottom: '8px' }}>
<div
className="progress-fill"
style={{ width: `${download.progress}%` }}
/>
</div>

<div style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '12px',
color: '#999'
}}>
<span>{download.progress.toFixed(1)}%</span>
<span>
{formatSpeed(download.download_speed)} • ETA: {formatETA(download.eta_seconds)}
</span>
</div>

{download.error_message && (
<div style={{
marginTop: '8px',
padding: '8px',
background: 'rgba(244, 67, 54, 0.1)',
border: '1px solid rgba(244, 67, 54, 0.3)',
borderRadius: '4px',
fontSize: '12px',
color: '#f44336'
}}>
Error: {download.error_message}
</div>
)}
</div>
)
}, (prevProps, nextProps) => {
const prev = prevProps.download
const next = nextProps.download
return (
prev.status === next.status &&
prev.progress === next.progress &&
prev.download_speed === next.download_speed &&
prev.eta_seconds === next.eta_seconds &&
prev.current_file === next.current_file &&
prev.completed_files === next.completed_files &&
prev.error_message === next.error_message
)
})

const ProgressPanel: React.FC = () => {
const [downloads, setDownloads] = useState<DownloadStatusResponse[]>([])
const [isLoading, setIsLoading] = useState(true)
Expand Down Expand Up @@ -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 (
<div className="card">
Expand Down Expand Up @@ -99,83 +194,7 @@ const ProgressPanel: React.FC = () => {

<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{downloads.map((download) => (
<div
key={download.task_id}
style={{
padding: '16px',
background: 'rgba(255,255,255,0.03)',
borderRadius: '6px',
border: '1px solid var(--border-color)',
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
flexWrap: 'wrap',
gap: '8px'
}}>
<div style={{ flex: 1, minWidth: '200px' }}>
<div style={{
fontSize: '14px',
fontWeight: 500,
marginBottom: '4px',
wordBreak: 'break-all'
}}>
{download.url}
</div>
<div style={{ fontSize: '12px', color: '#999' }}>
{download.current_file || 'Preparing...'}
</div>
</div>

<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
fontSize: '14px'
}}>
<span style={{ color: getStatusColor(download.status) }}>
{download.status.toUpperCase()}
</span>
<span>{download.completed_files} / {download.total_files} files</span>
</div>
</div>

<div className="progress-bar" style={{ marginBottom: '8px' }}>
<div
className="progress-fill"
style={{ width: `${download.progress}%` }}
/>
</div>

<div style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '12px',
color: '#999'
}}>
<span>{download.progress.toFixed(1)}%</span>
<span>
{formatSpeed(download.download_speed)} • ETA: {formatETA(download.eta_seconds)}
</span>
</div>

{download.error_message && (
<div style={{
marginTop: '8px',
padding: '8px',
background: 'rgba(244, 67, 54, 0.1)',
border: '1px solid rgba(244, 67, 54, 0.3)',
borderRadius: '4px',
fontSize: '12px',
color: '#f44336'
}}>
Error: {download.error_message}
</div>
)}
</div>
<DownloadItem key={download.task_id} download={download} />
))}
</div>
</div>
Expand Down
Loading