Skip to content
Open
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
127 changes: 127 additions & 0 deletions frontend/src/components/LiveLogViewer.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(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 (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{/* Live indicator */}
{isLive && (
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-rag-green opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-rag-green"></span>
</span>
<span className="text-[10px] font-black uppercase tracking-[0.28em] text-rag-green italic">
Live_Stream
</span>
</div>
)}
{/* Stderr warning */}
{stderrCount > 0 && (
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-rag-amber px-2 py-0.5 border border-rag-amber/30 bg-rag-amber/10">
{stderrCount} Stderr
</span>
)}
<span className="text-[10px] uppercase tracking-[0.2em] text-silver/40">
{filteredLines.length} lines
</span>
</div>

<div className="flex flex-wrap gap-2">
<input
value={filter}
onChange={e => 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"
/>
<button
onClick={() => setPaused(p => !p)}
className={`border px-3 py-2 text-[10px] uppercase tracking-[0.2em] font-black transition-colors ${
paused
? 'border-rag-amber/40 text-rag-amber bg-rag-amber/10'
: 'border-white/10 text-silver/75 hover:bg-white/[0.04]'
}`}
>
{paused ? 'Resume_Scroll' : 'Pause_Scroll'}
</button>
<button
onClick={onCopy}
className="border border-white/10 px-3 py-2 text-[10px] uppercase tracking-[0.2em] text-silver/75 font-black hover:bg-white/[0.04] transition-colors"
>
{copied ? 'Copied' : 'Copy_Log'}
</button>
</div>
</div>

{/* Log window */}
<div
ref={containerRef}
className="border border-white/6 bg-black/40 p-4 h-[420px] overflow-y-auto font-mono text-[11px] leading-6 custom-scrollbar"
>
{filteredLines.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-silver/30 uppercase tracking-[0.3em] text-[10px] italic">
{isLive ? 'Awaiting_Output...' : 'No_Output_Available'}
</p>
</div>
) : (
filteredLines.map((l, i) => (
<div key={i} className="flex gap-3 group hover:bg-white/[0.02] px-1">
{/* Line number */}
<span className="text-silver/15 select-none w-8 text-right shrink-0 group-hover:text-silver/30 transition-colors">
{i + 1}
</span>
{/* Stream badge */}
<span className={`shrink-0 text-[9px] font-black uppercase w-10 pt-[2px] ${
l.stream === 'stderr' ? 'text-rag-amber/70' : 'text-silver/20'
}`}>
{l.stream === 'stderr' ? 'ERR' : 'OUT'}
</span>
{/* Content */}
<span className={`break-all whitespace-pre-wrap ${
l.stream === 'stderr'
? 'text-rag-amber/85'
: 'text-silver/80'
}`}>
{l.line}
</span>
</div>
))
)}
<div ref={bottomRef} />
</div>
</div>
)
}
149 changes: 106 additions & 43 deletions frontend/src/pages/TaskDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
CartesianGrid
} from 'recharts'
import { useToast } from '../components/ToastContext'
import LiveLogViewer, { LogLine } from '../components/LiveLogViewer'

interface Task {
task_id: string
Expand Down Expand Up @@ -201,6 +202,9 @@ export default function TaskDetails() {
const [result, setResult] = useState<TaskResult | null>(null)
const [schema, setSchema] = useState<PluginSchemaResponse | null>(null)
const [rawOutput, setRawOutput] = useState<string>('')
const [logLines, setLogLines] = useState<LogLine[]>([])
const [isStreaming, setIsStreaming] = useState(false)
const [copiedLog, setCopiedLog] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [scanPhase, setScanPhase] = useState<string | null>(null)
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
})

Expand All @@ -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() {
Expand Down Expand Up @@ -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 }) => (
<div className="bg-charcoal border border-white/5 p-5 shadow-[0_0_0_1px_rgba(255,255,255,0.02)] min-h-[118px] flex flex-col justify-between">
Expand Down Expand Up @@ -1155,46 +1204,60 @@ export default function TaskDetails() {
className="space-y-6"
>
<motion.div variants={itemVariants} className="border border-white/8 bg-charcoal p-6">
<div className="flex flex-col gap-4 mb-5">
<div className="flex items-center gap-4">
<h3 className="text-xs font-black text-silver-bright uppercase tracking-[0.36em] italic">Raw Output</h3>
<div className="h-px flex-1 bg-white/8" />
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
<input
value={rawSearch}
onChange={(e) => 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]"
/>
<span className="text-[10px] uppercase tracking-[0.2em] text-silver/40">
{filteredRawLines.length} lines
</span>
<div className="flex items-center gap-4 mb-5">
<h3 className="text-xs font-black text-silver-bright uppercase tracking-[0.36em] italic">
Raw Output
</h3>
<div className="h-px flex-1 bg-white/8" />
</div>

{/* Live log viewer — shown when streaming or log lines exist */}
{(isStreaming || logLines.length > 0) ? (
<LiveLogViewer
lines={logLines}
isLive={isStreaming}
onCopy={copyLog}
copied={copiedLog}
/>
) : (
/* Fallback static viewer for completed tasks loaded from result */
<div className="space-y-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
<input
value={rawSearch}
onChange={(e) => 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]"
/>
<span className="text-[10px] uppercase tracking-[0.2em] text-silver/40">
{filteredRawLines.length} lines
</span>
</div>
<div className="flex gap-3">
<button
onClick={() => setWrapRawOutput(prev => !prev)}
className="border border-white/10 px-3 py-2 text-[10px] uppercase tracking-[0.2em] text-silver/75 font-black"
>
{wrapRawOutput ? 'Disable Wrap' : 'Enable Wrap'}
</button>
<button
onClick={copyRaw}
className="border border-white/10 px-3 py-2 text-[10px] uppercase tracking-[0.2em] text-silver/75 font-black"
>
{copiedRawOutput ? 'Copied' : 'Copy Output'}
</button>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setWrapRawOutput(prev => !prev)}
className="border border-white/10 px-3 py-2 text-[10px] uppercase tracking-[0.2em] text-silver/75 font-black"
>
{wrapRawOutput ? 'Disable Wrap' : 'Enable Wrap'}
</button>
<button
onClick={copyRaw}
className="border border-white/10 px-3 py-2 text-[10px] uppercase tracking-[0.2em] text-silver/75 font-black"
>
{copiedRawOutput ? 'Copied' : 'Copy Output'}
</button>
<div className="border border-white/6 bg-black/30 p-4 max-h-[720px] overflow-auto">
<pre className={`${wrapRawOutput ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'} text-[11px] leading-6 font-mono text-silver/75`}>
{filteredRawLines.length > 0
? filteredRawLines.join('\n')
: 'No matching raw output lines.'}
</pre>
</div>
</div>
</div>
<div className="border border-white/6 bg-black/30 p-4 max-h-[720px] overflow-auto">
<pre className={`${wrapRawOutput ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'} text-[11px] leading-6 font-mono text-silver/75`}>
{filteredRawLines.length > 0
? filteredRawLines.join('\n')
: 'No matching raw output lines.'}
</pre>
</div>
)}
</motion.div>
</motion.section>
)}
Expand Down
Loading
Loading