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
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 41 additions & 29 deletions frontend/src/components/LogPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<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<UI_LogMessage[]>([])
const [autoScroll, setAutoScroll] = useState(true)
const [filter, setFilter] = useState<string>('all')
const logContainerRef = useRef<HTMLDivElement>(null)
Expand All @@ -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,
Expand All @@ -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())
Expand Down Expand Up @@ -122,23 +149,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'

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 (
<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) => {
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<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