diff --git a/frontend/src/components/LiveLogViewer.tsx b/frontend/src/components/LiveLogViewer.tsx new file mode 100644 index 00000000..92716c0b --- /dev/null +++ b/frontend/src/components/LiveLogViewer.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react' + +export interface LogLine { + line: string + stream: 'stdout' | 'stderr' + ts: number +} + +interface LiveLogViewerProps { + lines: LogLine[] + isLive: boolean + onCopy: () => void + copied: boolean +} + +export default function LiveLogViewer({ lines, isLive, onCopy, copied }: LiveLogViewerProps) { + const bottomRef = useRef(null) + const containerRef = useRef(null) + const [paused, setPaused] = useState(false) + const [filter, setFilter] = useState('') + + // Auto-scroll when new lines arrive unless paused + useEffect(() => { + if (!paused && bottomRef.current && typeof bottomRef.current.scrollIntoView === 'function') { + bottomRef.current.scrollIntoView({ behavior: 'smooth' }) + } + }, [lines, paused]) + const filteredLines = filter + ? lines.filter(l => l.line.toLowerCase().includes(filter.toLowerCase())) + : lines + + const stderrCount = lines.filter(l => l.stream === 'stderr').length + + return ( +
+ {/* Toolbar */} +
+
+ {/* Live indicator */} + {isLive && ( +
+ + + + + + Live_Stream + +
+ )} + {/* Stderr warning */} + {stderrCount > 0 && ( + + {stderrCount} Stderr + + )} + + {filteredLines.length} lines + +
+ +
+ setFilter(e.target.value)} + placeholder="Filter output..." + className="bg-black/30 border border-white/10 px-3 py-2 text-sm text-silver-bright outline-none min-w-[180px] placeholder:text-silver/30" + /> + + +
+
+ + {/* Log window */} +
+ {filteredLines.length === 0 ? ( +
+

+ {isLive ? 'Awaiting_Output...' : 'No_Output_Available'} +

+
+ ) : ( + filteredLines.map((l, i) => ( +
+ {/* Line number */} + + {i + 1} + + {/* Stream badge */} + + {l.stream === 'stderr' ? 'ERR' : 'OUT'} + + {/* Content */} + + {l.line} + +
+ )) + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/TaskDetails.tsx b/frontend/src/pages/TaskDetails.tsx index 2389983a..8589c05e 100644 --- a/frontend/src/pages/TaskDetails.tsx +++ b/frontend/src/pages/TaskDetails.tsx @@ -27,6 +27,7 @@ import { CartesianGrid } from 'recharts' import { useToast } from '../components/ToastContext' +import LiveLogViewer, { LogLine } from '../components/LiveLogViewer' interface Task { task_id: string @@ -201,6 +202,9 @@ export default function TaskDetails() { const [result, setResult] = useState(null) const [schema, setSchema] = useState(null) const [rawOutput, setRawOutput] = useState('') + const [logLines, setLogLines] = useState([]) + const [isStreaming, setIsStreaming] = useState(false) + const [copiedLog, setCopiedLog] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [scanPhase, setScanPhase] = useState(null) @@ -363,6 +367,7 @@ export default function TaskDetails() { loadTask() const es = new EventSource(`${API_BASE}/task/${taskId}/stream`) + setIsStreaming(true) es.addEventListener('status', (e) => { try { @@ -371,12 +376,22 @@ export default function TaskDetails() { if (data.scan_phase) { setScanPhase(data.scan_phase) } + + // Surface sandbox violation as a distinct status label + if (data.termination_reason) { + setTask((prev: Task | null) => prev + ? { ...prev, status: `terminated:${data.termination_reason}` } + : null + ) + } + if (['completed', 'failed', 'cancelled'].includes(data.status)) { es.close() + setIsStreaming(false) loadTask() } } catch (err) { - console.error("Status stream error", err) + console.error('Status stream error', err) } }) @@ -392,18 +407,41 @@ export default function TaskDetails() { es.addEventListener('output', (e) => { try { const data = JSON.parse(e.data) - setRawOutput(prev => prev + data.chunk) + // Support both legacy chunk format and structured line format + const line: string = data.line ?? data.chunk ?? '' + const stream: 'stdout' | 'stderr' = data.stream === 'stderr' ? 'stderr' : 'stdout' + const ts: number = data.ts ?? Date.now() + + if (line) { + setLogLines(prev => [...prev, { line, stream, ts }]) + // Keep rawOutput in sync for the existing copy/filter logic + setRawOutput(prev => prev + line + '\n') + } } catch (err) { - console.error("Output stream error", err) + console.error('Output stream error', err) } }) - es.onerror = (err) => { - console.error("EventSource error:", err) + es.addEventListener('done', () => { + es.close() + setIsStreaming(false) + loadTask() + }) + + es.addEventListener('error_event', () => { + es.close() + setIsStreaming(false) + }) + + es.onerror = () => { es.close() + setIsStreaming(false) } - return () => es.close() + return () => { + es.close() + setIsStreaming(false) + } }, [taskId]) async function loadTask() { @@ -678,6 +716,17 @@ export default function TaskDetails() { } } + const copyLog = async () => { + try { + const text = logLines.map(l => l.line).join('\n') || rawOutput || result?.raw_output || '' + await navigator.clipboard.writeText(text) + setCopiedLog(true) + window.setTimeout(() => setCopiedLog(false), 1500) + } catch (err) { + console.error('Failed to copy log', err) + } + } + const DetailCard = ({ label, value, subValue }: { label: string, value: string, subValue?: string }) => (
@@ -1155,46 +1204,60 @@ export default function TaskDetails() { className="space-y-6" > -
-
-

Raw Output

-
-
-
-
- setRawSearch(e.target.value)} - placeholder="Filter raw output" - className="bg-black/30 border border-white/10 px-3 py-2 text-sm text-silver-bright outline-none min-w-[240px]" - /> - - {filteredRawLines.length} lines - +
+

+ Raw Output +

+
+
+ + {/* Live log viewer — shown when streaming or log lines exist */} + {(isStreaming || logLines.length > 0) ? ( + + ) : ( + /* Fallback static viewer for completed tasks loaded from result */ +
+
+
+ setRawSearch(e.target.value)} + placeholder="Filter raw output" + className="bg-black/30 border border-white/10 px-3 py-2 text-sm text-silver-bright outline-none min-w-[240px]" + /> + + {filteredRawLines.length} lines + +
+
+ + +
-
- - +
+
+                                                    {filteredRawLines.length > 0
+                                                        ? filteredRawLines.join('\n')
+                                                        : 'No matching raw output lines.'}
+                                                
-
-
-
-                                            {filteredRawLines.length > 0
-                                                ? filteredRawLines.join('\n')
-                                                : 'No matching raw output lines.'}
-                                        
-
+ )} )} diff --git a/frontend/testing/unit/components/LiveLogViewer.test.tsx b/frontend/testing/unit/components/LiveLogViewer.test.tsx new file mode 100644 index 00000000..0d6f59c3 --- /dev/null +++ b/frontend/testing/unit/components/LiveLogViewer.test.tsx @@ -0,0 +1,169 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import LiveLogViewer, { LogLine } from '../../../src/components/LiveLogViewer' + +const stdoutLines: LogLine[] = [ + { line: 'Starting scan...', stream: 'stdout', ts: 1000 }, + { line: 'Connecting to target', stream: 'stdout', ts: 2000 }, +] + +const stderrLines: LogLine[] = [ + { line: 'Warning: timeout approaching', stream: 'stderr', ts: 3000 }, +] + +const mixedLines: LogLine[] = [...stdoutLines, ...stderrLines] + +describe('LiveLogViewer', () => { + it('renders stdout lines', () => { + render( + + ) + expect(screen.getByText('Starting scan...')).toBeInTheDocument() + expect(screen.getByText('Connecting to target')).toBeInTheDocument() + }) + + it('renders stderr lines with ERR badge', () => { + render( + + ) + expect(screen.getByText('Warning: timeout approaching')).toBeInTheDocument() + expect(screen.getByText('ERR')).toBeInTheDocument() + }) + + it('shows stderr count badge when stderr lines exist', () => { + render( + + ) + expect(screen.getByText('1 Stderr')).toBeInTheDocument() + }) + + it('shows Live_Stream indicator when isLive is true', () => { + render( + + ) + expect(screen.getByText('Live_Stream')).toBeInTheDocument() + }) + + it('does not show Live_Stream indicator when not live', () => { + render( + + ) + expect(screen.queryByText('Live_Stream')).not.toBeInTheDocument() + }) + + it('shows awaiting output message when live with no lines', () => { + render( + + ) + expect(screen.getByText('Awaiting_Output...')).toBeInTheDocument() + }) + + it('shows no output message when not live with no lines', () => { + render( + + ) + expect(screen.getByText('No_Output_Available')).toBeInTheDocument() + }) + + it('filters lines by search input', async () => { + render( + + ) + const input = screen.getByPlaceholderText('Filter output...') + fireEvent.change(input, { target: { value: 'Warning' } }) + expect(screen.getByText('Warning: timeout approaching')).toBeInTheDocument() + expect(screen.queryByText('Starting scan...')).not.toBeInTheDocument() + }) + + it('calls onCopy when copy button clicked', () => { + const onCopy = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('Copy_Log')) + expect(onCopy).toHaveBeenCalledOnce() + }) + + it('shows Copied label when copied is true', () => { + render( + + ) + expect(screen.getByText('Copied')).toBeInTheDocument() + }) + + it('shows correct line count', () => { + render( + + ) + expect(screen.getByText('3 lines')).toBeInTheDocument() + }) + + it('toggles pause scroll button label', () => { + render( + + ) + const pauseBtn = screen.getByText('Pause_Scroll') + fireEvent.click(pauseBtn) + expect(screen.getByText('Resume_Scroll')).toBeInTheDocument() + }) +}) \ No newline at end of file