- Feat: Add Gotenberg service to docker-compose for headless PDF rendering - Feat: Implement /generate-pdf endpoint in report-generator-service - Feat: Add PDF generation proxy route in api-gateway - Refactor(frontend): Rewrite PDFExportButton to generate HTML with embedded styles and images - Feat(frontend): Auto-crop React Flow screenshots to remove whitespace - Style: Optimize report print layout with CSS (margins, image sizing) - Chore: Remove legacy react-pdf code and font files
568 lines
27 KiB
TypeScript
568 lines
27 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useParams, useSearchParams } from 'react-router-dom';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { WorkflowVisualizer } from '@/components/workflow/WorkflowVisualizer';
|
|
import { ContextExplorer } from '@/components/workflow/ContextExplorer';
|
|
import { useWorkflowStore } from '@/stores/useWorkflowStore';
|
|
import { TaskStatus, schemas } from '@/api/schema.gen';
|
|
import { Loader2, CheckCircle2, AlertCircle, Clock, PanelLeftClose, PanelLeftOpen, TerminalSquare, X } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { useAnalysisTemplates } from "@/hooks/useConfig"
|
|
import { WorkflowStatus, ConnectionStatus, TaskState } from '@/types/workflow';
|
|
import { Progress } from "@/components/ui/progress"
|
|
import { cn, formatNodeName } from '@/lib/utils';
|
|
|
|
import { RealtimeLogs } from '@/components/workflow/RealtimeLogs';
|
|
import { PDFExportButton } from '@/components/report/PDFExportButton';
|
|
|
|
export function ReportPage() {
|
|
const { id } = useParams();
|
|
const [searchParams] = useSearchParams();
|
|
const symbol = searchParams.get('symbol');
|
|
const market = searchParams.get('market');
|
|
const templateId = searchParams.get('templateId');
|
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
|
const [isWorkflowSticky, setIsWorkflowSticky] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
setIsWorkflowSticky(window.scrollY > 10);
|
|
};
|
|
window.addEventListener('scroll', handleScroll);
|
|
return () => window.removeEventListener('scroll', handleScroll);
|
|
}, []);
|
|
|
|
const {
|
|
initialize,
|
|
handleEvent,
|
|
status,
|
|
mode,
|
|
loadFromSnapshot,
|
|
tasks,
|
|
dag,
|
|
activeTab,
|
|
setActiveTab,
|
|
logs: globalLogs
|
|
} = useWorkflowStore();
|
|
|
|
const { data: templates } = useAnalysisTemplates();
|
|
const templateName = templates?.find(t => t.id === templateId)?.name || templateId;
|
|
|
|
// Initialization & Connection Logic
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
initialize(id);
|
|
|
|
let eventSource: EventSource | null = null;
|
|
|
|
// 1. Attempt to load snapshot (Parallel / Fallback)
|
|
// If the workflow is already finished, SSE might close immediately or 404.
|
|
const loadSnapshot = async () => {
|
|
try {
|
|
console.log(`[ReportPage] Fetching snapshot for ${id}...`);
|
|
const res = await fetch(`/api/v1/workflow/snapshot/${id}`);
|
|
if (res.ok) {
|
|
const snapshot = await res.json();
|
|
console.log(`[ReportPage] Snapshot loaded successfully for ${id}`, snapshot);
|
|
|
|
// Handle tagged enum wrapper (type/payload) if present
|
|
let rawPayload = snapshot.data_payload;
|
|
if (rawPayload && typeof rawPayload === 'object' && 'payload' in rawPayload && 'type' in rawPayload) {
|
|
rawPayload = rawPayload.payload;
|
|
}
|
|
|
|
loadFromSnapshot(rawPayload);
|
|
} else {
|
|
console.warn(`[ReportPage] Snapshot fetch failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
} catch (e) {
|
|
console.warn("[ReportPage] Snapshot load exception (normal for new tasks):", e);
|
|
}
|
|
};
|
|
|
|
loadSnapshot();
|
|
|
|
// 2. Connect to Real-time Stream
|
|
try {
|
|
console.log(`[ReportPage] Initializing EventSource for ${id}...`);
|
|
eventSource = new EventSource(`/api/v1/workflow/events/${id}`);
|
|
|
|
eventSource.onopen = () => {
|
|
console.log(`[ReportPage] SSE Connection Opened for ${id}`);
|
|
};
|
|
|
|
eventSource.onmessage = (event) => {
|
|
try {
|
|
// console.log(`[ReportPage] SSE Message received:`, event.data);
|
|
const parsedEvent = JSON.parse(event.data);
|
|
|
|
if (parsedEvent.type === schemas.WorkflowEventType.enum.WorkflowStateSnapshot) {
|
|
console.log(`[ReportPage] !!! Received WorkflowStateSnapshot !!!`, parsedEvent);
|
|
} else if (parsedEvent.type !== schemas.WorkflowEventType.enum.TaskStreamUpdate && parsedEvent.type !== schemas.WorkflowEventType.enum.TaskLog) {
|
|
// Suppress high-frequency logs to prevent browser lag
|
|
console.log(`[ReportPage] SSE Event: ${parsedEvent.type}`, parsedEvent);
|
|
}
|
|
|
|
handleEvent(parsedEvent);
|
|
} catch (e) {
|
|
console.error("[ReportPage] Failed to parse SSE event:", e);
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = (err) => {
|
|
// Standard behavior: if connection closes, it might be finished or failed.
|
|
// We rely on Snapshot for history if SSE fails.
|
|
console.warn("[ReportPage] SSE Connection Closed/Error", err);
|
|
eventSource?.close();
|
|
};
|
|
} catch (e) {
|
|
console.error("[ReportPage] Failed to init SSE:", e);
|
|
}
|
|
|
|
return () => {
|
|
eventSource?.close();
|
|
};
|
|
}, [id, initialize, handleEvent, loadFromSnapshot]);
|
|
|
|
// Include ALL nodes in tabs to allow debugging context for DataFetch tasks
|
|
const tabNodes = dag?.nodes || [];
|
|
|
|
// Use global raw logs directly
|
|
// const { tasks, logs: globalLogs } = useWorkflowStore();
|
|
|
|
return (
|
|
<div className="w-full px-6 py-4 space-y-4 min-h-[calc(100vh-4rem)] flex flex-col">
|
|
{/* Realtime Logs - Only in realtime mode */}
|
|
{mode === 'realtime' && (
|
|
<RealtimeLogs logs={globalLogs} className="fixed bottom-0 left-0 right-0 z-50 w-full border-l-0 border-t-4 border-t-primary rounded-none shadow-[0_-4px_12px_rgba(0,0,0,0.1)]" />
|
|
)}
|
|
|
|
{/* Header Area */}
|
|
<div className="flex items-center justify-between shrink-0">
|
|
<div className="space-y-1">
|
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
|
{symbol}
|
|
<Badge variant="outline" className="text-base font-normal">{market}</Badge>
|
|
<WorkflowStatusBadge status={status} mode={mode} />
|
|
</h1>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
<span>Request ID: <span className="font-mono">{id}</span></span>
|
|
{templateName && <span>Template: <span className="font-medium">{templateName}</span></span>}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<PDFExportButton
|
|
symbol={symbol}
|
|
market={market}
|
|
templateName={templateName}
|
|
requestId={id}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content Grid */}
|
|
<div className="flex gap-4 flex-1 items-start min-h-0">
|
|
{/* Left Col: Visualizer */}
|
|
<div className={cn(
|
|
"flex flex-col gap-4 transition-all duration-300 ease-in-out sticky top-20 h-[calc(100vh-6rem)]",
|
|
isSidebarCollapsed ? "w-[60px]" : "w-[33%] min-w-[350px]"
|
|
)}>
|
|
<Card className={cn(
|
|
"flex-1 flex flex-col min-h-0 py-0 gap-0 overflow-hidden transition-all duration-300",
|
|
isWorkflowSticky ? "shadow-lg border-primary/20" : "shadow-sm"
|
|
)}>
|
|
<CardHeader
|
|
className={cn(
|
|
"py-3 px-4 shrink-0 flex flex-row items-center space-y-0 transition-all duration-300 cursor-pointer hover:bg-muted/50",
|
|
isSidebarCollapsed ? "h-full flex-col justify-start py-3 gap-4" : "h-[60px] justify-between"
|
|
)}
|
|
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
|
>
|
|
{!isSidebarCollapsed ? (
|
|
<CardTitle className="text-sm font-medium truncate">Workflow Status</CardTitle>
|
|
) : (
|
|
<div className="writing-vertical-lr transform rotate-180 text-sm font-medium whitespace-nowrap tracking-wide text-muted-foreground mt-2">
|
|
Workflow Status
|
|
</div>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn("h-8 w-8 shrink-0", isSidebarCollapsed && "order-first")}
|
|
onClick={(e) => {
|
|
e.stopPropagation(); // Prevent double toggle if button is clicked
|
|
setIsSidebarCollapsed(!isSidebarCollapsed);
|
|
}}
|
|
>
|
|
{isSidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className={cn("p-0 flex-1 min-h-0 relative transition-opacity duration-200", isSidebarCollapsed ? "opacity-0 pointer-events-none" : "opacity-100")}>
|
|
<div className="absolute inset-0">
|
|
<WorkflowVisualizer />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Right Col: Detail Tabs */}
|
|
<div className="flex-1 min-w-0 h-[calc(100vh-6rem)] flex flex-col">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col flex-1 min-h-0">
|
|
<div className="w-full shrink-0">
|
|
<TabsList className="h-auto p-0 bg-transparent gap-1 flex-wrap justify-start w-full border-b">
|
|
<TabsTrigger
|
|
value="overview"
|
|
className="
|
|
rounded-t-md rounded-b-none
|
|
border border-b-0 border-border/50
|
|
bg-muted/60
|
|
data-[state=active]:bg-background
|
|
data-[state=active]:border-border
|
|
data-[state=active]:border-b-background
|
|
data-[state=active]:mb-[-1px]
|
|
data-[state=active]:shadow-sm
|
|
data-[state=active]:z-10
|
|
px-4 py-2.5
|
|
text-muted-foreground
|
|
data-[state=active]:text-foreground
|
|
relative
|
|
"
|
|
>
|
|
Overview
|
|
</TabsTrigger>
|
|
{tabNodes.map(node => (
|
|
<TabsTrigger
|
|
key={node.id}
|
|
value={node.id}
|
|
className="
|
|
rounded-t-md rounded-b-none
|
|
border border-b-0 border-border/50
|
|
bg-muted/60
|
|
data-[state=active]:bg-background
|
|
data-[state=active]:border-border
|
|
data-[state=active]:border-b-background
|
|
data-[state=active]:mb-[-1px]
|
|
data-[state=active]:shadow-sm
|
|
data-[state=active]:z-10
|
|
px-4 py-2.5 gap-2
|
|
text-muted-foreground
|
|
data-[state=active]:text-foreground
|
|
relative
|
|
"
|
|
>
|
|
{node.display_name || formatNodeName(node.name)}
|
|
<TaskStatusIndicator status={tasks[node.id]?.status || schemas.TaskStatus.enum.Pending} />
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div className="mt-4 bg-background border rounded-md relative shadow-sm flex-1 min-h-0 overflow-hidden">
|
|
<TabsContent value="overview" className="m-0 p-6 h-full overflow-auto">
|
|
<OverviewTabContent
|
|
status={status}
|
|
tasks={tasks}
|
|
totalTasks={dag?.nodes.length || 0}
|
|
completedTasks={Object.values(tasks).filter(t => t.status === schemas.TaskStatus.enum.Completed).length}
|
|
/>
|
|
</TabsContent>
|
|
|
|
{tabNodes.map(node => (
|
|
<TabsContent key={node.id} value={node.id} className="m-0 p-0 h-full">
|
|
<TaskDetailView taskId={node.id} task={tasks[node.id]} requestId={id} mode={mode} />
|
|
</TabsContent>
|
|
))}
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function OverviewTabContent({ status, tasks, totalTasks, completedTasks }: {
|
|
status: WorkflowStatus,
|
|
tasks: Record<string, TaskState>,
|
|
totalTasks: number,
|
|
completedTasks: number
|
|
}) {
|
|
// Count ALL tasks that have reached a terminal state (Completed, Skipped, Failed)
|
|
// This is more accurate for "progress" than just successful completions.
|
|
const processedCount = Object.values(tasks).filter(t =>
|
|
t.status === schemas.TaskStatus.enum.Completed ||
|
|
t.status === schemas.TaskStatus.enum.Skipped ||
|
|
t.status === schemas.TaskStatus.enum.Failed
|
|
).length;
|
|
|
|
const progress = totalTasks > 0 ? (processedCount / totalTasks) * 100 : 0;
|
|
|
|
// Find errors
|
|
const failedTasks = Object.entries(tasks).filter(([_, t]) => t.status === schemas.TaskStatus.enum.Failed);
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto space-y-8 py-6">
|
|
{/* Hero Status */}
|
|
<Card className="border-primary/10 shadow-md">
|
|
<CardHeader className="text-center pb-2">
|
|
<div className="mx-auto mb-4 bg-muted rounded-full p-3 w-fit">
|
|
{status === schemas.TaskStatus.enum.Completed ? (
|
|
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
|
) : status === schemas.TaskStatus.enum.Failed ? (
|
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
|
) : (
|
|
<Loader2 className="h-8 w-8 text-blue-500 animate-spin" />
|
|
)}
|
|
</div>
|
|
<CardTitle className="text-2xl">
|
|
{status === schemas.TaskStatus.enum.Completed ? "Analysis Completed" :
|
|
status === schemas.TaskStatus.enum.Failed ? "Analysis Failed" :
|
|
"Analysis In Progress"}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm text-muted-foreground">
|
|
<span>Overall Progress</span>
|
|
<span>{Math.round(progress)}% ({processedCount}/{totalTasks} tasks)</span>
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
|
|
{/* Failed Tasks Warning */}
|
|
{failedTasks.length > 0 && (
|
|
<div className="bg-destructive/10 text-destructive rounded-md p-4 text-sm flex items-start gap-2">
|
|
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
|
<div className="space-y-1">
|
|
<p className="font-semibold">Some tasks failed:</p>
|
|
<ul className="list-disc list-inside">
|
|
{failedTasks.map(([id, t]) => (
|
|
<li key={id}>
|
|
<span className="font-medium">{id}</span>: {t.message || "Unknown error"}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Total Tasks</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{totalTasks}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Completed</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-green-600">{completedTasks}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold flex items-center gap-2">
|
|
<Clock className="h-5 w-5 text-muted-foreground" />
|
|
<span>--:--</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TaskDetailView({ taskId, task, requestId, mode }: { taskId: string, task?: TaskState, requestId?: string, mode: 'realtime' | 'historical' }) {
|
|
const [isInspectorOpen, setIsInspectorOpen] = useState(false);
|
|
const { setTaskContent } = useWorkflowStore();
|
|
|
|
// Fetch content for historical tasks if missing
|
|
useEffect(() => {
|
|
if (!requestId || !task || !task.outputCommit) return;
|
|
|
|
// Only proceed if content is missing and task is finished (or has output commit)
|
|
if (task.content || (task.status !== schemas.TaskStatus.enum.Completed && task.status !== schemas.TaskStatus.enum.Failed)) {
|
|
return;
|
|
}
|
|
|
|
const fetchContent = async () => {
|
|
try {
|
|
let targetFile = null;
|
|
|
|
// Strategy 1: Use Metadata (Preferred)
|
|
// Now task.metadata is strongly typed as TaskMetadata from generated schema
|
|
if (task.metadata && task.metadata.output_path) {
|
|
targetFile = task.metadata.output_path;
|
|
}
|
|
// Strategy 2: Infer from Diff (Fallback)
|
|
else if (task.inputCommit) {
|
|
const diffRes = await fetch(`/api/context/${requestId}/diff/${task.inputCommit}/${task.outputCommit}`);
|
|
if (diffRes.ok) {
|
|
const changes = await diffRes.json();
|
|
const files: string[] = changes.map((c: any) => c.Added || c.Modified).filter(Boolean);
|
|
|
|
// Heuristic to find the "Main Report" or "Output"
|
|
const reportFile = files.find((f: string) => f.endsWith('.md') && !f.endsWith('_execution.md') && !f.endsWith('_trace.md'));
|
|
const execFile = files.find((f: string) => f.endsWith('_execution.md'));
|
|
const anyMd = files.find((f: string) => f.endsWith('.md'));
|
|
const anyJson = files.find((f: string) => f.endsWith('.json'));
|
|
|
|
targetFile = reportFile || execFile || anyMd || anyJson || files[0];
|
|
}
|
|
}
|
|
|
|
if (targetFile) {
|
|
const contentRes = await fetch(`/api/context/${requestId}/blob/${task.outputCommit}/${encodeURIComponent(targetFile)}`);
|
|
if (contentRes.ok) {
|
|
const text = await contentRes.text();
|
|
|
|
if (targetFile.endsWith('.json')) {
|
|
try {
|
|
const obj = JSON.parse(text);
|
|
setTaskContent(taskId, JSON.stringify(obj, null, 2));
|
|
} catch {
|
|
setTaskContent(taskId, text);
|
|
}
|
|
} else {
|
|
setTaskContent(taskId, text);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Auto-fetch content failed", e);
|
|
}
|
|
};
|
|
|
|
fetchContent();
|
|
|
|
}, [requestId, taskId, task?.outputCommit, task?.inputCommit, task?.status, task?.content, task?.metadata, setTaskContent]);
|
|
|
|
// Only show context if we have commits
|
|
const hasContext = task?.inputCommit || task?.outputCommit;
|
|
|
|
return (
|
|
<div className="relative h-full flex flex-col overflow-hidden">
|
|
{/* Main Report View */}
|
|
<div className="flex-1 overflow-auto p-8 bg-background">
|
|
<div className="w-full">
|
|
<div className="prose dark:prose-invert max-w-none">
|
|
{task?.content ? (
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
{task.content || ''}
|
|
</ReactMarkdown>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-[300px] text-muted-foreground space-y-4">
|
|
{task?.status === schemas.TaskStatus.enum.Pending && <p>Waiting to start...</p>}
|
|
{task?.status === schemas.TaskStatus.enum.Running && !task?.content && <Loader2 className="h-8 w-8 animate-spin" />}
|
|
{task?.status === schemas.TaskStatus.enum.Failed && (
|
|
<div className="text-center space-y-2">
|
|
<AlertCircle className="h-12 w-12 text-destructive mx-auto" />
|
|
<p className="font-medium text-destructive">Task Failed</p>
|
|
<p className="text-sm bg-destructive/10 p-2 rounded">{task.message}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{task?.status === schemas.TaskStatus.enum.Running && (
|
|
<span className="inline-block w-2 h-4 ml-1 bg-primary animate-pulse"/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Inspector Toggle (Floating) */}
|
|
<div className="absolute top-4 right-6 z-10">
|
|
<Button variant="outline" size="sm" className="shadow-sm bg-background" onClick={() => setIsInspectorOpen(!isInspectorOpen)}>
|
|
<TerminalSquare className="h-4 w-4 mr-2" />
|
|
Inspector
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Inspector Panel (Overlaid on Content) */}
|
|
<div className={cn(
|
|
"absolute top-0 right-0 h-full w-full bg-background border-l shadow-2xl transition-transform duration-300 transform z-20 flex flex-col",
|
|
isInspectorOpen ? "translate-x-0" : "translate-x-full"
|
|
)}>
|
|
<div className="flex items-center justify-between p-4 border-b shrink-0">
|
|
<h3 className="font-semibold flex items-center gap-2">
|
|
<TerminalSquare className="h-4 w-4" />
|
|
Task Inspector
|
|
</h3>
|
|
<Button variant="ghost" size="icon" onClick={() => setIsInspectorOpen(false)}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col min-h-0">
|
|
{hasContext && requestId && (task?.inputCommit || task?.outputCommit) ? (
|
|
<ContextExplorer
|
|
reqId={requestId}
|
|
commitHash={task.outputCommit || task.inputCommit!}
|
|
diffTargetHash={task.outputCommit ? task.inputCommit : undefined}
|
|
className="h-full p-4"
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
No context available
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkflowStatusBadge({ status, mode }: { status: WorkflowStatus, mode: 'realtime' | 'historical' }) {
|
|
// Map local store status to TaskStatus enum for consistency where possible
|
|
const content = (
|
|
<div className="flex items-center gap-2">
|
|
{status === schemas.TaskStatus.enum.Running && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
{status}
|
|
{mode === 'historical' && (
|
|
<span className="bg-muted-foreground/20 text-muted-foreground text-[10px] px-1.5 py-0.5 rounded uppercase tracking-wider font-semibold ml-1">
|
|
Historical
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (status === schemas.TaskStatus.enum.Running) {
|
|
return <Badge variant="default" className="bg-blue-500 hover:bg-blue-600 border-transparent">{content}</Badge>;
|
|
}
|
|
if (status === schemas.TaskStatus.enum.Completed) {
|
|
return <Badge variant="default" className="bg-green-600 hover:bg-green-600 border-transparent">{content}</Badge>;
|
|
}
|
|
if (status === schemas.TaskStatus.enum.Failed) {
|
|
return <Badge variant="destructive">{content}</Badge>;
|
|
}
|
|
if (status === ConnectionStatus.Connecting) {
|
|
return <Badge variant="secondary">CONNECTING...</Badge>;
|
|
}
|
|
|
|
return <Badge variant="outline">{content}</Badge>;
|
|
}
|
|
|
|
function TaskStatusIndicator({ status }: { status: TaskStatus }) {
|
|
switch (status) {
|
|
case schemas.TaskStatus.enum.Running: return <Loader2 className="h-3 w-3 animate-spin text-blue-500" />;
|
|
case schemas.TaskStatus.enum.Completed: return <CheckCircle2 className="h-3 w-3 text-green-500" />;
|
|
case schemas.TaskStatus.enum.Failed: return <div className="h-2 w-2 rounded-full bg-red-500" />;
|
|
default: return null;
|
|
}
|
|
}
|