app/components/LogStream.tsx (223 lines of code) (raw):

import { useEffect, useState, useRef } from "react"; interface LogStreamProps { jobId: string; className?: string; } // Function to parse and colorize terminal output const parseTerminalOutput = (text: string) => { const lines = text.split("\n"); return lines.map((line, index) => { let className = "text-gray-200"; // Default color let content = line; // // Error patterns (red) // if (line.match(/error|Error|ERROR|fail|Failed|FAILED|exception|Exception|panic/i)) { // className = 'text-red-400'; // } // // Warning patterns (yellow) // else if (line.match(/warn|Warning|WARNING|deprecated|DEPRECATED/i)) { // className = 'text-yellow-400'; // } // // Success patterns (green) // else if (line.match(/success|Success|SUCCESS|complete|Complete|COMPLETE|done|Done|DONE|✓|✔/i)) { // className = 'text-green-400'; // } // // File paths (cyan) // else if (line.match(/\.(js|ts|tsx|jsx|py|go|rs|java|cpp|c|h|css|html|json|yaml|yml|md|txt|log)\b/)) { // className = 'text-cyan-400'; // } // // URLs and HTTP (blue) // else if (line.match(/https?:\/\/|HTTP|GET|POST|PUT|DELETE|PATCH/)) { // className = 'text-blue-400'; // } // // Numbers and values (magenta) // else if (line.match(/^\s*\d+\s|\b\d+\.\d+\b|\b\d+%\b|\b\d+ms\b|\b\d+s\b/)) { // className = 'text-purple-400'; // } // // Commands and executables (bright blue) // else if (line.match(/^\$\s|^>\s|npm |yarn |pnpm |git |docker |node |python |pip |cargo |go |rustc /)) { // className = 'text-blue-300'; // } // // Timestamps (gray) // else if (line.match(/\d{4}-\d{2}-\d{2}|\d{2}:\d{2}:\d{2}|\[\d+\]/)) { // className = 'text-gray-400'; // } // // Comments and info (dim) // else if (line.match(/^#|^\/\/|^\s*\*|info|Info|INFO/i)) { // className = 'text-gray-400'; // } const lineIsJson = line.startsWith("{") && line.endsWith("}"); if (lineIsJson) { try { const parsedLine = JSON.parse(line); // console.log("Parsed line:", parsedLine); // console.log("Parsed line:", parsedLine.type); const keys = Object.keys(parsedLine); // console.log("Parsed line keys:", keys); if (parsedLine.type) { // console.log(parsedLine.type); switch (parsedLine.type) { case "message": // console.log("Parsed message:", parsedLine); const concatText = parsedLine.content .map((t: string) => t.text) .join(" "); className = "text-blue-300"; content = concatText; // parsedLine.content; break; case "reasoning": className = "text-yellow-300"; content = parsedLine.summary; break; case "function_call": className = "text-green-300"; content = `${parsedLine.name}: ${parsedLine.arguments}`; break; case "function_call_output": className = "text-purple-300"; // console.log("Parsed function call output:", parsedLine); content = parsedLine.output; break; default: console.warn("Unknown line type:", parsedLine.type); className = "text-gray-200"; // Default for unknown types } } else { console.warn("Line does not have a type:", parsedLine); } // } catch (e) { console.error("Failed to parse line as JSON:", e); } } else { // console.log("Line is not JSON:", line); } return ( <div key={index} className={className}> {content} </div> ); }); }; interface LogMessage { type: "connected" | "logs" | "status" | "finished" | "error"; data?: string; status?: string; message?: string; timestamp?: string; } export function LogStream({ jobId, className = "" }: LogStreamProps) { const [logs, setLogs] = useState<string>(""); const [status, setStatus] = useState<string>("pending"); const [isConnected, setIsConnected] = useState(false); const [error, setError] = useState<string | null>(null); const logsEndRef = useRef<HTMLDivElement>(null); const eventSourceRef = useRef<EventSource | null>(null); // Auto-scroll to bottom when new logs arrive const scrollToBottom = () => { logsEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; useEffect(() => { scrollToBottom(); }, [logs]); useEffect(() => { if (!jobId) return; // Create EventSource for Server-Sent Events const eventSource = new EventSource(`/api/jobs/${jobId}/logs`); eventSourceRef.current = eventSource; eventSource.onopen = () => { console.log("Log stream connected"); setIsConnected(true); setError(null); }; eventSource.onmessage = (event) => { try { const message: LogMessage = JSON.parse(event.data); switch (message.type) { case "connected": console.log( "Connected to log stream for job:", message.jobId || jobId ); break; case "logs": if (message.data) { setLogs((prev) => prev + message.data); } break; case "status": if (message.status) { setStatus(message.status); } break; case "finished": console.log("Job finished with status:", message.status); setStatus(message.status || "completed"); setIsConnected(false); // Optionally trigger a page refresh after a short delay to show final results if (message.status === "completed") { setTimeout(() => { console.log( "Job completed - refreshing page to show final results" ); window.location.reload(); }, 3000); // Wait 3 seconds before refresh } break; case "error": console.error("Log stream error:", message.message); setError(message.message || "Unknown error"); break; } } catch (error) { console.error("Failed to parse log message:", error); } }; eventSource.onerror = (event) => { console.error("EventSource error:", event); setError("Connection to log stream failed"); setIsConnected(false); }; // Cleanup on unmount return () => { eventSource.close(); eventSourceRef.current = null; }; }, [jobId]); // Manual cleanup method const disconnect = () => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; setIsConnected(false); } }; return ( <div className={`flex flex-col ${className}`}> {/* Status Bar */} <div className="flex items-center justify-between border-b bg-gray-100 p-2"> <div className="flex items-center gap-2"> <div className={`h-2 w-2 rounded-full ${ isConnected ? "bg-green-500" : "bg-red-500" }`} /> <span className="text-sm font-medium"> Status:{" "} <span className={`${ status === "completed" ? "text-green-600" : status === "failed" ? "text-red-600" : status === "running" ? "text-blue-600" : "text-gray-600" }`} > {status} </span> </span> </div> <div className="flex items-center gap-2"> {isConnected && <span className="text-xs text-gray-500">Live</span>} {isConnected && ( <button onClick={disconnect} className="rounded bg-gray-200 px-2 py-1 text-xs hover:bg-gray-300" > Disconnect </button> )} </div> </div> {/* Error Display */} {error && ( <div className="border-b border-red-200 bg-red-50 p-2 text-sm text-red-700"> Error: {error} </div> )} {/* Logs Display */} <div className="max-h-96 flex-1 overflow-auto bg-gray-900 p-4 font-mono text-sm"> {logs ? ( <div className="whitespace-pre-wrap">{parseTerminalOutput(logs)}</div> ) : ( <div className="text-gray-500">Waiting for logs...</div> )} {/* Job completion indicator */} {!isConnected && status === "completed" && ( <div className="mt-4 rounded border border-green-500/30 bg-green-900/20 p-2 text-center text-green-300"> <i className="fas fa-check-circle mr-2"></i> Job completed successfully! Page will refresh shortly to show final results. </div> )} {!isConnected && status === "failed" && ( <div className="mt-4 rounded border border-red-500/30 bg-red-900/20 p-2 text-center text-red-300"> <i className="fas fa-times-circle mr-2"></i> Job failed. Check the logs above for error details. </div> )} <div ref={logsEndRef} /> </div> {/* Footer */} <div className="border-t bg-gray-50 p-2 text-xs text-gray-500"> Job ID: {jobId} | Logs: {logs.length} characters </div> </div> ); }