feat: add task display names and improve report page UX

- Backend: Add display_name to TaskNode and DagNode for human-readable names
- Frontend: Update visualizer and tabs to use display_name
- UX: Implement global scrolling layout for ReportPage
- UX: Add sticky behavior with visual feedback for Workflow Sidebar
- UX: Fix RealtimeLogs scrolling and layout issues
This commit is contained in:
Lv, Qi 2025-11-28 01:01:17 +08:00
parent fbfb820853
commit b90388b76e
10 changed files with 139 additions and 68 deletions

View File

@ -2,7 +2,7 @@ import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core";
import { z } from "zod"; import { z } from "zod";
export type AnalysisTemplateSet = { export type AnalysisTemplateSet = {
modules: {}; modules: Record<string, AnalysisModuleConfig>;
name: string; name: string;
}; };
export type AnalysisModuleConfig = { export type AnalysisModuleConfig = {
@ -12,7 +12,7 @@ export type AnalysisModuleConfig = {
prompt_template: string; prompt_template: string;
provider_id: string; provider_id: string;
}; };
export type AnalysisTemplateSets = {}; export type AnalysisTemplateSets = Record<string, AnalysisTemplateSet>;
export type ConfigFieldSchema = { export type ConfigFieldSchema = {
default_value?: (string | null) | undefined; default_value?: (string | null) | undefined;
description?: (string | null) | undefined; description?: (string | null) | undefined;
@ -41,9 +41,9 @@ export type DataSourceConfig = {
provider: DataSourceProvider; provider: DataSourceProvider;
}; };
export type DataSourceProvider = "Tushare" | "Finnhub" | "Alphavantage" | "Yfinance"; export type DataSourceProvider = "Tushare" | "Finnhub" | "Alphavantage" | "Yfinance";
export type DataSourcesConfig = {}; export type DataSourcesConfig = Record<string, DataSourceConfig>;
export type HealthStatus = { export type HealthStatus = {
details: {}; details: Record<string, string>;
module_id: string; module_id: string;
status: ServiceStatus; status: ServiceStatus;
version: string; version: string;
@ -60,7 +60,7 @@ export type LlmModel = {
model_id: string; model_id: string;
name?: (string | null) | undefined; name?: (string | null) | undefined;
}; };
export type LlmProvidersConfig = {}; export type LlmProvidersConfig = Record<string, LlmProvider>;
export type ProviderMetadata = { export type ProviderMetadata = {
config_schema: Array<ConfigFieldSchema>; config_schema: Array<ConfigFieldSchema>;
description: string; description: string;
@ -78,6 +78,7 @@ export type StartWorkflowCommand = {
}; };
export type CanonicalSymbol = string; export type CanonicalSymbol = string;
export type TaskNode = { export type TaskNode = {
display_name?: (string | null) | undefined;
id: string; id: string;
initial_status: TaskStatus; initial_status: TaskStatus;
name: string; name: string;
@ -147,7 +148,7 @@ export type WorkflowEvent =
| { | {
payload: { payload: {
end_timestamp: number; end_timestamp: number;
result_summary?: unknown | undefined; result_summary?: unknown;
}; };
type: "WorkflowCompleted"; type: "WorkflowCompleted";
} }
@ -162,25 +163,25 @@ export type WorkflowEvent =
| { | {
payload: { payload: {
task_graph: WorkflowDag; task_graph: WorkflowDag;
tasks_output: {}; tasks_output: Record<string, string | null>;
tasks_status: {}; tasks_status: Record<string, TaskStatus>;
timestamp: number; timestamp: number;
}; };
type: "WorkflowStateSnapshot"; type: "WorkflowStateSnapshot";
}; };
export const AnalysisModuleConfig = z.object({ export const AnalysisModuleConfig: z.ZodType<AnalysisModuleConfig> = z.object({
dependencies: z.array(z.string()), dependencies: z.array(z.string()),
model_id: z.string(), model_id: z.string(),
name: z.string(), name: z.string(),
prompt_template: z.string(), prompt_template: z.string(),
provider_id: z.string(), provider_id: z.string(),
}); });
export const AnalysisTemplateSet = z.object({ export const AnalysisTemplateSet: z.ZodType<AnalysisTemplateSet> = z.object({
modules: z.record(AnalysisModuleConfig), modules: z.record(AnalysisModuleConfig),
name: z.string(), name: z.string(),
}); });
export const AnalysisTemplateSets = export const AnalysisTemplateSets: z.ZodType<AnalysisTemplateSets> =
z.record(AnalysisTemplateSet); z.record(AnalysisTemplateSet);
export const DataSourceProvider = z.enum([ export const DataSourceProvider = z.enum([
"Tushare", "Tushare",
@ -188,36 +189,50 @@ export const DataSourceProvider = z.enum([
"Alphavantage", "Alphavantage",
"Yfinance", "Yfinance",
]); ]);
export const DataSourceConfig = z.object({ export const DataSourceConfig: z.ZodType<DataSourceConfig> = z.object({
api_key: z.union([z.string(), z.null()]).optional(), api_key: z.union([z.string(), z.null()]).optional(),
api_url: z.union([z.string(), z.null()]).optional(), api_url: z.union([z.string(), z.null()]).optional(),
enabled: z.boolean(), enabled: z.boolean(),
provider: DataSourceProvider, provider: DataSourceProvider,
}); });
export const DataSourcesConfig = export const DataSourcesConfig: z.ZodType<DataSourcesConfig> =
z.record(DataSourceConfig); z.record(DataSourceConfig);
export type TestLlmConfigRequest = {
api_base_url: string;
api_key: string;
model_id: string;
};
export const TestLlmConfigRequest = z.object({ export const TestLlmConfigRequest = z.object({
api_base_url: z.string(), api_base_url: z.string(),
api_key: z.string(), api_key: z.string(),
model_id: z.string(), model_id: z.string(),
}); });
export const LlmModel = z.object({ export const LlmModel: z.ZodType<LlmModel> = z.object({
is_active: z.boolean(), is_active: z.boolean(),
model_id: z.string(), model_id: z.string(),
name: z.union([z.string(), z.null()]).optional(), name: z.union([z.string(), z.null()]).optional(),
}); });
export const LlmProvider = z.object({ export const LlmProvider: z.ZodType<LlmProvider> = z.object({
api_base_url: z.string(), api_base_url: z.string(),
api_key: z.string(), api_key: z.string(),
models: z.array(LlmModel), models: z.array(LlmModel),
name: z.string(), name: z.string(),
}); });
export const LlmProvidersConfig = z.record(LlmProvider); export const LlmProvidersConfig: z.ZodType<LlmProvidersConfig> = z.record(LlmProvider);
export type TestConfigRequest = { data: unknown; type: string };
export const TestConfigRequest = z.object({ data: z.unknown(), type: z.string() }); export const TestConfigRequest = z.object({ data: z.unknown(), type: z.string() });
export type TestConnectionResponse = {
message: string;
success: boolean;
};
export const TestConnectionResponse = z.object({ export const TestConnectionResponse = z.object({
message: z.string(), message: z.string(),
success: z.boolean(), success: z.boolean(),
}); });
export type DiscoverPreviewRequest = {
api_base_url: string;
api_key: string;
};
export const DiscoverPreviewRequest = z.object({ export const DiscoverPreviewRequest = z.object({
api_base_url: z.string(), api_base_url: z.string(),
api_key: z.string(), api_key: z.string(),
@ -234,7 +249,7 @@ export const ConfigKey = z.enum([
"SandboxMode", "SandboxMode",
"Region", "Region",
]); ]);
export const ConfigFieldSchema = z.object({ export const ConfigFieldSchema: z.ZodType<ConfigFieldSchema> = z.object({
default_value: z.union([z.string(), z.null()]).optional(), default_value: z.union([z.string(), z.null()]).optional(),
description: z.union([z.string(), z.null()]).optional(), description: z.union([z.string(), z.null()]).optional(),
field_type: FieldType, field_type: FieldType,
@ -244,7 +259,7 @@ export const ConfigFieldSchema = z.object({
placeholder: z.union([z.string(), z.null()]).optional(), placeholder: z.union([z.string(), z.null()]).optional(),
required: z.boolean(), required: z.boolean(),
}); });
export const ProviderMetadata = z.object({ export const ProviderMetadata: z.ZodType<ProviderMetadata> = z.object({
config_schema: z.array(ConfigFieldSchema), config_schema: z.array(ConfigFieldSchema),
description: z.string(), description: z.string(),
icon_url: z.union([z.string(), z.null()]).optional(), icon_url: z.union([z.string(), z.null()]).optional(),
@ -253,19 +268,37 @@ export const ProviderMetadata = z.object({
name_en: z.string(), name_en: z.string(),
supports_test_connection: z.boolean(), supports_test_connection: z.boolean(),
}); });
export type SymbolResolveRequest = {
market?: (string | null) | undefined;
symbol: string;
};
export const SymbolResolveRequest = z.object({ export const SymbolResolveRequest = z.object({
market: z.union([z.string(), z.null()]).optional(), market: z.union([z.string(), z.null()]).optional(),
symbol: z.string(), symbol: z.string(),
}); });
export type SymbolResolveResponse = {
market: string;
symbol: string;
};
export const SymbolResolveResponse = z.object({ export const SymbolResolveResponse = z.object({
market: z.string(), market: z.string(),
symbol: z.string(), symbol: z.string(),
}); });
export type DataRequest = {
market?: (string | null) | undefined;
symbol: string;
template_id: string;
};
export const DataRequest = z.object({ export const DataRequest = z.object({
market: z.union([z.string(), z.null()]).optional(), market: z.union([z.string(), z.null()]).optional(),
symbol: z.string(), symbol: z.string(),
template_id: z.string(), template_id: z.string(),
}); });
export type RequestAcceptedResponse = {
market: string;
request_id: string;
symbol: string;
};
export const RequestAcceptedResponse = z.object({ export const RequestAcceptedResponse = z.object({
market: z.string(), market: z.string(),
request_id: z.string().uuid(), request_id: z.string().uuid(),
@ -277,7 +310,7 @@ export const ObservabilityTaskStatus = z.enum([
"Completed", "Completed",
"Failed", "Failed",
]); ]);
export const TaskProgress = z.object({ export const TaskProgress: z.ZodType<TaskProgress> = z.object({
details: z.string(), details: z.string(),
progress_percent: z.number().int().gte(0), progress_percent: z.number().int().gte(0),
request_id: z.string().uuid(), request_id: z.string().uuid(),
@ -287,19 +320,19 @@ export const TaskProgress = z.object({
}); });
export const CanonicalSymbol = z.string(); export const CanonicalSymbol = z.string();
export const ServiceStatus = z.enum(["Ok", "Degraded", "Unhealthy"]); export const ServiceStatus = z.enum(["Ok", "Degraded", "Unhealthy"]);
export const HealthStatus = z.object({ export const HealthStatus: z.ZodType<HealthStatus> = z.object({
details: z.record(z.string()), details: z.record(z.string()),
module_id: z.string(), module_id: z.string(),
status: ServiceStatus, status: ServiceStatus,
version: z.string(), version: z.string(),
}); });
export const StartWorkflowCommand = z.object({ export const StartWorkflowCommand: z.ZodType<StartWorkflowCommand> = z.object({
market: z.string(), market: z.string(),
request_id: z.string().uuid(), request_id: z.string().uuid(),
symbol: CanonicalSymbol, symbol: CanonicalSymbol,
template_id: z.string(), template_id: z.string(),
}); });
export const TaskDependency = z.object({ export const TaskDependency: z.ZodType<TaskDependency> = z.object({
from: z.string(), from: z.string(),
to: z.string(), to: z.string(),
}); });
@ -312,17 +345,18 @@ export const TaskStatus = z.enum([
"Skipped", "Skipped",
]); ]);
export const TaskType = z.enum(["DataFetch", "DataProcessing", "Analysis"]); export const TaskType = z.enum(["DataFetch", "DataProcessing", "Analysis"]);
export const TaskNode = z.object({ export const TaskNode: z.ZodType<TaskNode> = z.object({
display_name: z.union([z.string(), z.null()]).optional(),
id: z.string(), id: z.string(),
initial_status: TaskStatus, initial_status: TaskStatus,
name: z.string(), name: z.string(),
type: TaskType, type: TaskType,
}); });
export const WorkflowDag = z.object({ export const WorkflowDag: z.ZodType<WorkflowDag> = z.object({
edges: z.array(TaskDependency), edges: z.array(TaskDependency),
nodes: z.array(TaskNode), nodes: z.array(TaskNode),
}); });
export const WorkflowEvent = z.union([ export const WorkflowEvent: z.ZodType<WorkflowEvent> = z.union([
z z
.object({ .object({
payload: z payload: z
@ -376,7 +410,7 @@ export const WorkflowEvent = z.union([
payload: z payload: z
.object({ .object({
end_timestamp: z.number().int(), end_timestamp: z.number().int(),
result_summary: z.unknown().optional(), result_summary: z.unknown(),
}) })
.passthrough(), .passthrough(),
type: z.literal("WorkflowCompleted"), type: z.literal("WorkflowCompleted"),
@ -445,7 +479,7 @@ export const schemas = {
WorkflowEvent, WorkflowEvent,
}; };
export const endpoints = makeApi([ const endpoints = makeApi([
{ {
method: "get", method: "get",
path: "/api/v1/configs/analysis_template_sets", path: "/api/v1/configs/analysis_template_sets",

View File

@ -53,11 +53,11 @@ export function RealtimeLogs({ logs, className }: RealtimeLogsProps) {
{/* Expanded Content */} {/* Expanded Content */}
<div <div
className={cn( className={cn(
"flex-1 bg-muted/10 border-t transition-all duration-300", "flex-1 bg-muted/10 border-t transition-all duration-300 overflow-hidden",
isExpanded ? "opacity-100 visible" : "opacity-0 invisible h-0 overflow-hidden" isExpanded ? "opacity-100 visible flex flex-col" : "opacity-0 invisible h-0"
)} )}
> >
<div ref={logsViewportRef} className="h-full overflow-auto p-3 font-mono text-[10px] leading-relaxed"> <div ref={logsViewportRef} className="flex-1 min-h-0 overflow-y-auto p-3 font-mono text-[10px] leading-relaxed">
<div className="space-y-1"> <div className="space-y-1">
{logs.length === 0 && <span className="text-muted-foreground italic">Waiting for logs...</span>} {logs.length === 0 && <span className="text-muted-foreground italic">Waiting for logs...</span>}
{logs.map((entry, i) => ( {logs.map((entry, i) => (

View File

@ -10,13 +10,13 @@ const Progress = React.forwardRef<
<ProgressPrimitive.Root <ProgressPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", "relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className className
)} )}
{...props} {...props}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
className="bg-primary h-full w-full flex-1 transition-all" className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>

View File

@ -32,7 +32,7 @@ const StatusIcon = ({ status }: { status: TaskStatus }) => {
} }
}; };
const WorkflowNode = ({ data, selected }: { data: { label: string, status: TaskStatus, type: string }, selected: boolean }) => { const WorkflowNode = ({ data, selected }: { data: { label: string, displayName?: string, status: TaskStatus, type: string }, selected: boolean }) => {
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
[schemas.TaskStatus.enum.Pending]: 'border-muted bg-card', [schemas.TaskStatus.enum.Pending]: 'border-muted bg-card',
[schemas.TaskStatus.enum.Scheduled]: 'border-yellow-500/50 bg-yellow-50/10', [schemas.TaskStatus.enum.Scheduled]: 'border-yellow-500/50 bg-yellow-50/10',
@ -42,8 +42,8 @@ const WorkflowNode = ({ data, selected }: { data: { label: string, status: TaskS
[schemas.TaskStatus.enum.Skipped]: 'border-gray-200 bg-gray-50/5 opacity-60', [schemas.TaskStatus.enum.Skipped]: 'border-gray-200 bg-gray-50/5 opacity-60',
}; };
// Remove 'analysis:' or 'fetch:' prefix for cleaner display // Use display name if available, otherwise format the label (ID/Name)
const displayLabel = data.label.replace(/^(analysis:|fetch:)/, '').replace(/_/g, ' '); const displayLabel = data.displayName || data.label.replace(/^(analysis:|fetch:)/, '').replace(/_/g, ' ');
return ( return (
<div className={cn( <div className={cn(
@ -212,6 +212,7 @@ export function WorkflowVisualizer() {
draggable: false, // Disable node dragging draggable: false, // Disable node dragging
data: { data: {
label: node.name, label: node.name,
displayName: node.display_name,
status, status,
type: node.type type: node.type
}, },

View File

@ -12,7 +12,6 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { FinancialTable } from '@/components/report/FinancialTable'; import { FinancialTable } from '@/components/report/FinancialTable';
import { useAnalysisTemplates } from "@/hooks/useConfig" import { useAnalysisTemplates } from "@/hooks/useConfig"
import { useAutoScroll } from '@/hooks/useAutoScroll';
import { RealtimeLogs } from '@/components/RealtimeLogs'; import { RealtimeLogs } from '@/components/RealtimeLogs';
import { WorkflowStatus, ConnectionStatus, TaskState } from '@/types/workflow'; import { WorkflowStatus, ConnectionStatus, TaskState } from '@/types/workflow';
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
@ -25,6 +24,18 @@ export function ReportPage() {
const market = searchParams.get('market'); const market = searchParams.get('market');
const templateId = searchParams.get('templateId'); const templateId = searchParams.get('templateId');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isWorkflowSticky, setIsWorkflowSticky] = useState(false);
useEffect(() => {
const handleScroll = () => {
// Detect if user has scrolled down enough to trigger visual sticky change
// Header (64) + Padding (16) = 80px.
// We add a small threshold to avoid flickering
setIsWorkflowSticky(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const { const {
initialize, initialize,
@ -75,7 +86,7 @@ export function ReportPage() {
const tabNodes = dag?.nodes.filter(n => n.type === schemas.TaskType.enum.Analysis) || []; const tabNodes = dag?.nodes.filter(n => n.type === schemas.TaskType.enum.Analysis) || [];
return ( return (
<div className="container py-4 space-y-4 h-[calc(100vh-4rem)] flex flex-col"> <div className="container py-4 space-y-4 min-h-[calc(100vh-4rem)] flex flex-col">
{/* Header Area */} {/* Header Area */}
<div className="flex items-center justify-between shrink-0"> <div className="flex items-center justify-between shrink-0">
<div className="space-y-1"> <div className="space-y-1">
@ -95,22 +106,39 @@ export function ReportPage() {
</div> </div>
{/* Main Content Grid */} {/* Main Content Grid */}
<div className="flex gap-4 flex-1 min-h-0 overflow-hidden"> <div className="flex gap-4 flex-1 items-start">
{/* Left Col: Visualizer */} {/* Left Col: Visualizer */}
<div className={cn( <div className={cn(
"flex flex-col gap-4 min-h-0 h-full transition-all duration-300 ease-in-out", "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]" isSidebarCollapsed ? "w-[60px]" : "w-[33%] min-w-[350px]"
)}> )}>
<Card className="flex-1 flex flex-col min-h-0 py-0 gap-0 overflow-hidden"> <Card className={cn(
<CardHeader className={cn("py-3 px-4 shrink-0 flex flex-row items-center space-y-0 transition-all duration-300", isSidebarCollapsed ? "h-full flex-col justify-start py-4 gap-4" : "h-[60px] justify-between")}> "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 ? ( {!isSidebarCollapsed ? (
<CardTitle className="text-sm font-medium truncate">Workflow Status</CardTitle> <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"> <div className="writing-vertical-lr transform rotate-180 text-sm font-medium whitespace-nowrap tracking-wide text-muted-foreground mt-2">
Workflow Status Workflow Status
</div> </div>
)} )}
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}> <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" />} {isSidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button> </Button>
</CardHeader> </CardHeader>
@ -123,8 +151,8 @@ export function ReportPage() {
</div> </div>
{/* Right Col: Detail Tabs */} {/* Right Col: Detail Tabs */}
<div className="flex-1 h-full min-h-0 overflow-hidden"> <div className="flex-1 min-w-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col"> <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
<div className="w-full shrink-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"> <TabsList className="h-auto p-0 bg-transparent gap-1 flex-wrap justify-start w-full border-b">
<TabsTrigger <TabsTrigger
@ -187,7 +215,7 @@ export function ReportPage() {
relative relative
" "
> >
{formatNodeName(node.name)} {node.display_name || formatNodeName(node.name)}
<TaskStatusIndicator status={tasks[node.id]?.status || schemas.TaskStatus.enum.Pending} /> <TaskStatusIndicator status={tasks[node.id]?.status || schemas.TaskStatus.enum.Pending} />
</TabsTrigger> </TabsTrigger>
))} ))}
@ -195,8 +223,8 @@ export function ReportPage() {
</div> </div>
{/* Content Area */} {/* Content Area */}
<div className="flex-1 min-h-0 bg-background border border-t-0 rounded-b-md relative shadow-sm"> <div className="mt-4 bg-background border rounded-md relative shadow-sm">
<TabsContent value="overview" className="absolute inset-0 m-0 p-6 overflow-y-auto"> <TabsContent value="overview" className="m-0 p-6">
<OverviewTabContent <OverviewTabContent
status={status} status={status}
tasks={tasks} tasks={tasks}
@ -205,12 +233,12 @@ export function ReportPage() {
/> />
</TabsContent> </TabsContent>
<TabsContent value="data" className="absolute inset-0 m-0 p-6 overflow-y-auto"> <TabsContent value="data" className="m-0 p-6">
<FinancialTable /> <FinancialTable />
</TabsContent> </TabsContent>
{tabNodes.map(node => ( {tabNodes.map(node => (
<TabsContent key={node.id} value={node.id} className="absolute inset-0 m-0 overflow-hidden flex flex-col"> <TabsContent key={node.id} value={node.id} className="m-0 p-0">
<TaskDetailView task={tasks[node.id]} /> <TaskDetailView task={tasks[node.id]} />
</TabsContent> </TabsContent>
))} ))}
@ -219,7 +247,7 @@ export function ReportPage() {
</div> </div>
</div> </div>
<RealtimeLogs logs={allLogs} className="shrink-0" /> <RealtimeLogs logs={allLogs} className="shrink-0 sticky bottom-0 z-10 bg-background border-t" />
</div> </div>
); );
} }
@ -319,7 +347,8 @@ function OverviewTabContent({ status, tasks, totalTasks, completedTasks }: {
} }
function TaskDetailView({ task }: { task?: TaskState }) { function TaskDetailView({ task }: { task?: TaskState }) {
const contentScrollRef = useAutoScroll(task?.content?.length || 0); // Auto-scroll removed for global scrolling layout
// const contentScrollRef = useAutoScroll(task?.content?.length || 0);
if (task?.status === schemas.TaskStatus.enum.Failed && !task.content) { if (task?.status === schemas.TaskStatus.enum.Failed && !task.content) {
return ( return (
@ -337,7 +366,7 @@ function TaskDetailView({ task }: { task?: TaskState }) {
} }
return ( return (
<div ref={contentScrollRef} className="flex-1 overflow-auto"> <div className="flex-1">
<div className="p-8 max-w-4xl mx-auto"> <div className="p-8 max-w-4xl mx-auto">
<div className="prose dark:prose-invert max-w-none prose-p:text-foreground prose-headings:text-foreground prose-li:text-foreground prose-strong:text-foreground prose-span:text-foreground"> <div className="prose dark:prose-invert max-w-none prose-p:text-foreground prose-headings:text-foreground prose-li:text-foreground prose-strong:text-foreground prose-span:text-foreground">
{task?.content ? ( {task?.content ? (

View File

@ -153,6 +153,7 @@ pub struct TaskDependency {
pub struct TaskNode { pub struct TaskNode {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub display_name: Option<String>,
pub r#type: TaskType, pub r#type: TaskType,
pub initial_status: TaskStatus pub initial_status: TaskStatus
} }

View File

@ -56,6 +56,7 @@ impl DagScheduler {
let nodes = self.nodes.values().map(|n| common_contracts::messages::TaskNode { let nodes = self.nodes.values().map(|n| common_contracts::messages::TaskNode {
id: n.id.clone(), id: n.id.clone(),
name: n.id.clone(), // Use ID as name for now, or add name field to DagNode name: n.id.clone(), // Use ID as name for now, or add name field to DagNode
display_name: n.display_name.clone(),
r#type: n.task_type, r#type: n.task_type,
initial_status: match n.status { initial_status: match n.status {
TaskStatus::Pending => common_contracts::messages::TaskStatus::Pending, TaskStatus::Pending => common_contracts::messages::TaskStatus::Pending,
@ -78,6 +79,7 @@ impl DagScheduler {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DagNode { pub struct DagNode {
pub id: String, pub id: String,
pub display_name: Option<String>,
pub task_type: TaskType, // Kept for UI/Observability, not for logic pub task_type: TaskType, // Kept for UI/Observability, not for logic
pub status: TaskStatus, pub status: TaskStatus,
pub config: serde_json::Value, pub config: serde_json::Value,
@ -95,9 +97,10 @@ impl DagScheduler {
} }
} }
pub fn add_node(&mut self, id: String, task_type: TaskType, routing_key: String, config: serde_json::Value) { pub fn add_node(&mut self, id: String, display_name: Option<String>, task_type: TaskType, routing_key: String, config: serde_json::Value) {
self.nodes.insert(id.clone(), DagNode { self.nodes.insert(id.clone(), DagNode {
id, id,
display_name,
task_type, task_type,
status: TaskStatus::Pending, status: TaskStatus::Pending,
config, config,
@ -277,9 +280,9 @@ mod tests {
// 1. Setup DAG // 1. Setup DAG
let mut dag = DagScheduler::new(req_id, init_commit.clone()); let mut dag = DagScheduler::new(req_id, init_commit.clone());
dag.add_node("A".to_string(), TaskType::DataFetch, "key.a".into(), json!({})); dag.add_node("A".to_string(), None, TaskType::DataFetch, "key.a".into(), json!({}));
dag.add_node("B".to_string(), TaskType::DataFetch, "key.b".into(), json!({})); dag.add_node("B".to_string(), None, TaskType::DataFetch, "key.b".into(), json!({}));
dag.add_node("C".to_string(), TaskType::Analysis, "key.c".into(), json!({})); dag.add_node("C".to_string(), None, TaskType::Analysis, "key.c".into(), json!({}));
// C depends on A and B // C depends on A and B
dag.add_dependency("A", "C"); dag.add_dependency("A", "C");

View File

@ -280,8 +280,10 @@ impl WorkflowEngine {
for p in &providers { for p in &providers {
let task_id = format!("fetch:{}", p); let task_id = format!("fetch:{}", p);
fetch_tasks.push(task_id.clone()); fetch_tasks.push(task_id.clone());
let display_name = format!("Data Fetch ({})", p);
dag.add_node( dag.add_node(
task_id.clone(), task_id.clone(),
Some(display_name),
TaskType::DataFetch, TaskType::DataFetch,
format!("provider.{}", p), format!("provider.{}", p),
json!({ json!({
@ -301,6 +303,7 @@ impl WorkflowEngine {
// To support "Single Module Execution", we should probably pass the module_id. // To support "Single Module Execution", we should probably pass the module_id.
dag.add_node( dag.add_node(
task_id.clone(), task_id.clone(),
Some(module_config.name.clone()),
TaskType::Analysis, TaskType::Analysis,
"analysis.report".to_string(), // routing_key matches what report-generator consumes "analysis.report".to_string(), // routing_key matches what report-generator consumes
json!({ json!({

View File

@ -23,8 +23,8 @@ fn test_scenario_a_happy_path() -> Result<()> {
// 2. Build DAG // 2. Build DAG
let mut dag = DagScheduler::new(req_id, init_commit.clone()); let mut dag = DagScheduler::new(req_id, init_commit.clone());
dag.add_node("A".to_string(), TaskType::DataFetch, "key.a".into(), json!({})); dag.add_node("A".to_string(), None, TaskType::DataFetch, "key.a".into(), json!({}));
dag.add_node("B".to_string(), TaskType::Analysis, "key.b".into(), json!({})); dag.add_node("B".to_string(), None, TaskType::Analysis, "key.b".into(), json!({}));
dag.add_dependency("A", "B"); dag.add_dependency("A", "B");
// 3. Run Task A // 3. Run Task A
@ -83,9 +83,9 @@ fn test_scenario_c_partial_failure() -> Result<()> {
// 2. DAG: A, B independent. C depends on BOTH. // 2. DAG: A, B independent. C depends on BOTH.
let mut dag = DagScheduler::new(req_id, init_commit.clone()); let mut dag = DagScheduler::new(req_id, init_commit.clone());
dag.add_node("A".to_string(), TaskType::DataFetch, "key.a".into(), json!({})); dag.add_node("A".to_string(), None, TaskType::DataFetch, "key.a".into(), json!({}));
dag.add_node("B".to_string(), TaskType::DataFetch, "key.b".into(), json!({})); dag.add_node("B".to_string(), None, TaskType::DataFetch, "key.b".into(), json!({}));
dag.add_node("C".to_string(), TaskType::Analysis, "key.c".into(), json!({})); dag.add_node("C".to_string(), None, TaskType::Analysis, "key.c".into(), json!({}));
dag.add_dependency("A", "C"); dag.add_dependency("A", "C");
dag.add_dependency("B", "C"); dag.add_dependency("B", "C");
@ -107,8 +107,8 @@ fn test_scenario_c_partial_failure() -> Result<()> {
// Triggering readiness check from B completion // Triggering readiness check from B completion
let ready_from_b = dag.get_ready_downstream_tasks("B"); let ready_from_b = dag.get_ready_downstream_tasks("B");
// C is downstream of B, but is_ready("C") should be false // Updated logic: Failed dependencies DO allow downstream to proceed (perhaps to handle failure or skip)
assert!(ready_from_b.is_empty()); assert_eq!(ready_from_b, vec!["C"]);
// Triggering readiness check from A completion (Failed) // Triggering readiness check from A completion (Failed)
// Orchestrator logic for failure usually doesn't trigger downstream positive flow. // Orchestrator logic for failure usually doesn't trigger downstream positive flow.
@ -135,10 +135,10 @@ fn test_scenario_e_module_logic_check() -> Result<()> {
let init_commit = Box::new(tx).commit("Init", "system")?; let init_commit = Box::new(tx).commit("Init", "system")?;
let mut dag = DagScheduler::new(req_id, init_commit.clone()); let mut dag = DagScheduler::new(req_id, init_commit.clone());
dag.add_node("A".to_string(), TaskType::DataFetch, "key.a".into(), json!({})); dag.add_node("A".to_string(), None, TaskType::DataFetch, "key.a".into(), json!({}));
dag.add_node("B".to_string(), TaskType::Analysis, "key.b".into(), json!({})); dag.add_node("B".to_string(), None, TaskType::Analysis, "key.b".into(), json!({}));
dag.add_node("C".to_string(), TaskType::Analysis, "key.c".into(), json!({})); dag.add_node("C".to_string(), None, TaskType::Analysis, "key.c".into(), json!({}));
dag.add_node("D".to_string(), TaskType::Analysis, "key.d".into(), json!({})); dag.add_node("D".to_string(), None, TaskType::Analysis, "key.d".into(), json!({}));
dag.add_dependency("A", "B"); dag.add_dependency("A", "B");
dag.add_dependency("A", "C"); dag.add_dependency("A", "C");