207 lines
8.0 KiB
TypeScript
207 lines
8.0 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { Terminal, Activity, RefreshCw, CheckCircle2, Circle, XCircle, Clock, Loader2 } from 'lucide-react';
|
|
import apiClient from '../../api/client';
|
|
import type { WorkflowDetail, WorkflowStep } from '../../types';
|
|
|
|
interface RightPanelProps {
|
|
selectedWorkflow: string | null;
|
|
}
|
|
|
|
function stepStatusIcon(status: string) {
|
|
switch (status) {
|
|
case 'completed':
|
|
return <CheckCircle2 size={14} className="text-green-500" />;
|
|
case 'running':
|
|
return <Loader2 size={14} className="text-blue-500 animate-spin" />;
|
|
case 'failed':
|
|
return <XCircle size={14} className="text-red-500" />;
|
|
default:
|
|
return <Circle size={14} className="text-slate-300" />;
|
|
}
|
|
}
|
|
|
|
export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
|
const [detail, setDetail] = useState<WorkflowDetail | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [logs, setLogs] = useState<string[]>([]);
|
|
const [sseConnected, setSseConnected] = useState(false);
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
|
|
const fetchDetail = async (traceId: string) => {
|
|
setLoading(true);
|
|
setLogs([]);
|
|
try {
|
|
const response = await apiClient.get(`/api/v1/workflow/${traceId}`);
|
|
setDetail(response.data);
|
|
} catch {
|
|
setDetail(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!selectedWorkflow) {
|
|
setDetail(null);
|
|
setLogs([]);
|
|
return;
|
|
}
|
|
|
|
fetchDetail(selectedWorkflow);
|
|
|
|
const protocol = window.location.protocol;
|
|
const host = window.location.host;
|
|
const apiBase = import.meta.env.VITE_API_BASE_URL || `${protocol}//${host}`;
|
|
const es = new EventSource(`${apiBase}/api/v1/workflow/sse/${selectedWorkflow}`);
|
|
eventSourceRef.current = es;
|
|
|
|
es.onopen = () => {
|
|
setSseConnected(true);
|
|
};
|
|
|
|
es.onmessage = (event) => {
|
|
setLogs(prev => [...prev, event.data]);
|
|
};
|
|
|
|
es.onerror = () => {
|
|
setSseConnected(false);
|
|
};
|
|
|
|
const interval = setInterval(() => {
|
|
fetchDetail(selectedWorkflow);
|
|
}, 3000);
|
|
|
|
return () => {
|
|
es.close();
|
|
eventSourceRef.current = null;
|
|
clearInterval(interval);
|
|
};
|
|
}, [selectedWorkflow]);
|
|
|
|
const isActive = detail?.status === 'llm_working' || detail?.status === 'tool_working';
|
|
|
|
if (!selectedWorkflow) {
|
|
return (
|
|
<div className="w-80 bg-white border-l border-slate-200 flex flex-col z-0 justify-center items-center p-6 text-center">
|
|
<Activity size={32} className="text-slate-300 mb-4" />
|
|
<h3 className="text-sm font-semibold text-slate-600">No Workflow Selected</h3>
|
|
<p className="text-xs text-slate-400 mt-2">Select a workflow from the left panel to view its details.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-80 bg-white border-l border-slate-200 flex flex-col z-0">
|
|
<div className="h-14 border-b border-slate-100 flex items-center px-4 justify-between bg-slate-50/50">
|
|
<h2 className="font-semibold text-slate-800 text-sm flex items-center">
|
|
<Terminal size={16} className="mr-2 text-slate-500" />
|
|
Workflow Detail
|
|
</h2>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`px-2 py-1 text-xs rounded-md font-medium border ${sseConnected ? 'bg-green-100 text-green-700 border-green-200' : 'bg-slate-100 text-slate-500 border-slate-200'}`}>
|
|
{sseConnected ? 'Live' : '--'}
|
|
</span>
|
|
<button
|
|
onClick={() => selectedWorkflow && fetchDetail(selectedWorkflow)}
|
|
className="p-1 text-slate-400 hover:text-blue-600 rounded transition-colors"
|
|
title="Refresh"
|
|
>
|
|
<RefreshCw size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 p-4 overflow-y-auto">
|
|
{loading && !detail ? (
|
|
<div className="text-center text-slate-400 text-sm py-8">
|
|
<Loader2 size={24} className="animate-spin mx-auto mb-2" />
|
|
Loading...
|
|
</div>
|
|
) : !detail ? (
|
|
<div className="text-center text-slate-400 text-sm py-8">Failed to load workflow details</div>
|
|
) : (
|
|
<>
|
|
{/* Header */}
|
|
<div className="mb-4">
|
|
<h3 className="text-base font-bold text-slate-800">
|
|
{detail.workflow_title || 'Workflow'}
|
|
</h3>
|
|
<p className="text-xs text-slate-500 font-mono mt-1">ID: {detail.event_id}</p>
|
|
{detail.command && (
|
|
<p className="text-xs text-slate-500 mt-1 truncate">Command: {detail.command}</p>
|
|
)}
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
|
detail.status === 'failed' ? 'bg-red-100 text-red-700' :
|
|
isActive ? 'bg-blue-100 text-blue-700' :
|
|
detail.status === 'waiting_llm_working' || detail.status === 'waiting_tool_working' ? 'bg-yellow-100 text-yellow-700' :
|
|
'bg-green-100 text-green-700'
|
|
}`}>
|
|
{detail.status}
|
|
</span>
|
|
<span className="text-xs text-slate-400">
|
|
Step {detail.current_step}/{detail.steps.length}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Steps */}
|
|
{detail.steps.length > 0 && (
|
|
<div className="mb-4">
|
|
<h4 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Steps</h4>
|
|
<div className="space-y-1.5">
|
|
{detail.steps.map((step: WorkflowStep) => (
|
|
<div
|
|
key={step.step}
|
|
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs border ${
|
|
step.step === detail.current_step && isActive
|
|
? 'border-blue-200 bg-blue-50'
|
|
: step.status === 'completed'
|
|
? 'border-green-100 bg-green-50/50'
|
|
: step.status === 'failed'
|
|
? 'border-red-100 bg-red-50/50'
|
|
: 'border-slate-100 bg-white'
|
|
}`}
|
|
>
|
|
{stepStatusIcon(step.status)}
|
|
<span className="font-medium text-slate-700 w-5 text-right">{step.step}</span>
|
|
<span className="text-slate-600 truncate flex-1">{step.name}</span>
|
|
<span className="text-slate-400 text-[10px]">{step.node}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{detail.steps.length === 0 && (
|
|
<div className="text-center py-4">
|
|
<Clock size={24} className="text-slate-300 mx-auto mb-2" />
|
|
<p className="text-xs text-slate-400">Workflow is being generated...</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* SSE Logs */}
|
|
{logs.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Live Logs</h4>
|
|
<div className="relative border-l-2 border-slate-200 ml-3 pl-5 space-y-3">
|
|
{logs.map((msg, idx) => (
|
|
<div key={idx} className="relative">
|
|
<div className={`absolute -left-[27px] top-1 w-3 h-3 rounded-full border-2 border-white shadow-sm ${idx === logs.length - 1 && sseConnected ? 'bg-blue-500 animate-pulse' : 'bg-green-500'}`} />
|
|
<p className="text-[11px] font-mono text-slate-600 leading-relaxed break-all">{msg}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{logs.length === 0 && sseConnected && isActive && (
|
|
<div className="text-xs text-slate-400 italic mt-2">Waiting for live events...</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|