- Sync updates for provider services (AlphaVantage, Finnhub, YFinance, Tushare) - Update Frontend components and pages for recent config changes - Update API Gateway and Registry - Include design docs and tasks status
225 lines
11 KiB
TypeScript
225 lines
11 KiB
TypeScript
import { 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 { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { WorkflowVisualizer } from '@/components/workflow/WorkflowVisualizer';
|
|
import { useWorkflowStore } from '@/stores/useWorkflowStore';
|
|
import { TaskStatus, schemas } from '@/api/schema.gen';
|
|
import { Terminal, Loader2, Sparkles, CheckCircle2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { FinancialTable } from '@/components/report/FinancialTable';
|
|
import { useAnalysisTemplates } from "@/hooks/useConfig"
|
|
|
|
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 {
|
|
initialize,
|
|
handleEvent,
|
|
status,
|
|
tasks,
|
|
dag,
|
|
activeTab,
|
|
setActiveTab
|
|
} = useWorkflowStore();
|
|
|
|
const { data: templates } = useAnalysisTemplates();
|
|
const templateName = templates && templateId ? templates[templateId]?.name : templateId;
|
|
|
|
// SSE Connection Logic
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
initialize(id);
|
|
|
|
// Connect to real backend SSE
|
|
const eventSource = new EventSource(`/api/v1/workflow/events/${id}`);
|
|
|
|
eventSource.onmessage = (event) => {
|
|
try {
|
|
const parsedEvent = JSON.parse(event.data);
|
|
handleEvent(parsedEvent);
|
|
} catch (e) {
|
|
console.error("Failed to parse SSE event:", e);
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = (err) => {
|
|
console.error("SSE Connection Error:", err);
|
|
// Optional: Retry logic or error state update
|
|
// eventSource.close();
|
|
};
|
|
|
|
return () => {
|
|
eventSource.close();
|
|
};
|
|
}, [id, initialize, handleEvent]);
|
|
|
|
// Combine logs from all tasks for the "Global Log" view
|
|
const allLogs = Object.entries(tasks).flatMap(([taskId, state]) =>
|
|
state.logs.map(log => ({ taskId, log }))
|
|
);
|
|
|
|
const tabNodes = dag?.nodes.filter(n => n.type === schemas.TaskType.enum.Analysis) || [];
|
|
|
|
return (
|
|
<div className="container py-6 space-y-6 h-[calc(100vh-4rem)] flex flex-col">
|
|
{/* 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} />
|
|
</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">
|
|
<Button size="sm" variant="outline">Export PDF</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content Grid */}
|
|
<div className="grid grid-cols-12 gap-6 flex-1 min-h-0">
|
|
{/* Left Col: Visualizer & Logs (4 cols) */}
|
|
<div className="col-span-4 flex flex-col gap-4 min-h-0 h-full">
|
|
<Card className="shrink-0">
|
|
<CardHeader className="py-3 px-4">
|
|
<CardTitle className="text-sm font-medium">Workflow Status</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<WorkflowVisualizer />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="flex-1 flex flex-col min-h-0 p-0 gap-0 overflow-hidden">
|
|
<CardHeader className="py-2 px-4 border-b bg-muted/50 space-y-0 shrink-0">
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
<Terminal className="h-4 w-4" />
|
|
Real-time Logs
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 min-h-0 p-0 relative">
|
|
<div className="absolute inset-0 overflow-auto p-4 font-mono text-xs bg-background">
|
|
<div className="space-y-1.5">
|
|
{allLogs.length === 0 && <span className="text-muted-foreground italic">Waiting for logs...</span>}
|
|
{allLogs.map((entry, i) => (
|
|
<div key={i} className="break-all">
|
|
<span className="text-blue-500 font-semibold">[{entry.taskId}]</span>{" "}
|
|
<span className="text-foreground">{entry.log}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Right Col: Detail Tabs (8 cols) */}
|
|
<div className="col-span-8 h-full min-h-0">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
|
<div className="w-full overflow-x-auto bg-background border-b shrink-0">
|
|
<TabsList className="h-auto p-0 bg-transparent gap-0">
|
|
<TabsTrigger value="overview" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3">
|
|
Overview
|
|
</TabsTrigger>
|
|
<TabsTrigger value="data" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3">
|
|
Fundamental Data
|
|
</TabsTrigger>
|
|
{tabNodes.map(node => (
|
|
<TabsTrigger
|
|
key={node.id}
|
|
value={node.id}
|
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3 gap-2"
|
|
>
|
|
{node.name}
|
|
<TaskStatusIndicator status={tasks[node.id]?.status || schemas.TaskStatus.enum.Pending} />
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div className="flex-1 min-h-0 bg-muted/5 relative">
|
|
<TabsContent value="overview" className="absolute inset-0 m-0 p-6 overflow-y-auto">
|
|
<div className="max-w-2xl mx-auto text-center py-10 space-y-4">
|
|
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto">
|
|
<Sparkles className="h-8 w-8 text-primary" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold">Analysis In Progress</h2>
|
|
<p className="text-muted-foreground">
|
|
Select a task tab above or click a node in the graph to view details.
|
|
</p>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="data" className="absolute inset-0 m-0 p-6 overflow-y-auto">
|
|
<FinancialTable />
|
|
</TabsContent>
|
|
|
|
{tabNodes.map(node => (
|
|
<TabsContent key={node.id} value={node.id} className="absolute inset-0 m-0 overflow-hidden flex flex-col">
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-8 max-w-4xl mx-auto">
|
|
<div className="prose dark:prose-invert max-w-none">
|
|
{tasks[node.id]?.content ? (
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
{tasks[node.id].content || ''}
|
|
</ReactMarkdown>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-[300px] text-muted-foreground space-y-4">
|
|
{tasks[node.id]?.status === schemas.TaskStatus.enum.Pending && <p>Waiting to start...</p>}
|
|
{tasks[node.id]?.status === schemas.TaskStatus.enum.Running && !tasks[node.id]?.content && <Loader2 className="h-8 w-8 animate-spin" />}
|
|
</div>
|
|
)}
|
|
{tasks[node.id]?.status === schemas.TaskStatus.enum.Running && (
|
|
<span className="inline-block w-2 h-4 ml-1 bg-primary animate-pulse"/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
))}
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkflowStatusBadge({ status }: { status: string }) {
|
|
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
|
'IDLE': 'outline',
|
|
'CONNECTING': 'secondary',
|
|
'RUNNING': 'default',
|
|
'COMPLETED': 'default',
|
|
'ERROR': 'destructive'
|
|
};
|
|
|
|
let colorClass = "";
|
|
if (status === 'RUNNING') colorClass = "bg-blue-500 hover:bg-blue-600 border-transparent";
|
|
if (status === 'COMPLETED') colorClass = "bg-green-600 hover:bg-green-600 border-transparent";
|
|
|
|
return <Badge variant={variants[status] || 'outline'} className={colorClass}>{status}</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;
|
|
}
|
|
}
|