mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* feat: add web run-flow viewer * docs: document run-flow web viewer contracts * fix: add market review run flow entry
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
||||
TaskStatus,
|
||||
TaskListResponse,
|
||||
} from '../types/analysis';
|
||||
import type { RunFlowSnapshot } from '../types/runFlow';
|
||||
|
||||
// ============ API Interfaces ============
|
||||
|
||||
@@ -161,6 +162,18 @@ export const analysisApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a run-flow snapshot for an active analysis task.
|
||||
* @param taskId Task ID
|
||||
*/
|
||||
getTaskFlow: async (taskId: string): Promise<RunFlowSnapshot> => {
|
||||
const response = await apiClient.get<Record<string, unknown>>(
|
||||
`/api/v1/analysis/tasks/${encodeURIComponent(taskId)}/flow`
|
||||
);
|
||||
|
||||
return toCamelCase<RunFlowSnapshot>(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the SSE stream URL.
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
RunDiagnosticSummary,
|
||||
StockBarResponse,
|
||||
} from '../types/analysis';
|
||||
import type { RunFlowSnapshot } from '../types/runFlow';
|
||||
|
||||
// ============ API 接口 ============
|
||||
|
||||
@@ -90,6 +91,15 @@ export const historyApi = {
|
||||
return toCamelCase<RunDiagnosticSummary>(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取历史报告运行流快照
|
||||
* @param recordId 分析历史记录主键 ID
|
||||
*/
|
||||
getRecordFlow: async (recordId: number): Promise<RunFlowSnapshot> => {
|
||||
const response = await apiClient.get<Record<string, unknown>>(`/api/v1/history/${recordId}/flow`);
|
||||
return toCamelCase<RunFlowSnapshot>(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量删除历史记录
|
||||
* @param recordIds 分析历史记录主键 ID 列表
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3, Clipboard, FileText, Gauge, Layers, ShieldAlert, TrendingUp, WalletCards } from 'lucide-react';
|
||||
import { BarChart3, Clipboard, FileText, Gauge, Layers, ShieldAlert, TrendingUp, WalletCards, Workflow } from 'lucide-react';
|
||||
import { historyApi } from '../../api/history';
|
||||
import { formatUiText, UI_TEXT } from '../../i18n/uiText';
|
||||
import type {
|
||||
AnalysisReport,
|
||||
MarketReviewPayload,
|
||||
@@ -21,6 +22,7 @@ interface MarketReviewReportViewProps {
|
||||
payload?: MarketReviewPayload | null;
|
||||
reportLanguage?: ReportLanguage;
|
||||
className?: string;
|
||||
onOpenRunFlow?: (recordId: number) => void;
|
||||
}
|
||||
|
||||
type CopyType = 'markdown' | 'text';
|
||||
@@ -262,9 +264,11 @@ export const MarketReviewReportView: React.FC<MarketReviewReportViewProps> = ({
|
||||
payload: providedPayload,
|
||||
reportLanguage = 'zh',
|
||||
className = '',
|
||||
onOpenRunFlow,
|
||||
}) => {
|
||||
const normalizedReportLanguage = normalizeReportLanguage(reportLanguage);
|
||||
const text = getReportText(normalizedReportLanguage);
|
||||
const runFlowText = UI_TEXT[normalizedReportLanguage];
|
||||
const marketReviewText = MARKET_REVIEW_TEXT[normalizedReportLanguage];
|
||||
const [loadedMarkdown, setLoadedMarkdown] = useState<LoadedMarkdown | null>(null);
|
||||
const [loadError, setLoadError] = useState<LoadError | null>(null);
|
||||
@@ -295,6 +299,7 @@ export const MarketReviewReportView: React.FC<MarketReviewReportViewProps> = ({
|
||||
[marketReviewPayload],
|
||||
);
|
||||
const showStructuredMarketTitles = Boolean(marketReviewPayload?.markets);
|
||||
const canOpenRunFlow = recordId !== undefined && onOpenRunFlow;
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordId || providedContent || hasStructuredContent) {
|
||||
@@ -384,6 +389,20 @@ export const MarketReviewReportView: React.FC<MarketReviewReportViewProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canOpenRunFlow ? (
|
||||
<Tooltip content={runFlowText['runFlow.open']}>
|
||||
<span className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenRunFlow(recordId)}
|
||||
className="home-surface-button flex h-10 w-10 items-center justify-center rounded-lg text-secondary-text hover:text-foreground"
|
||||
aria-label={formatUiText(runFlowText['runFlow.openHistoryAria'], { recordId })}
|
||||
>
|
||||
<Workflow className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip content={text.copyMarkdownSource}>
|
||||
<span className="inline-flex">
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type React from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Activity, Check, ChevronDown, Copy } from 'lucide-react';
|
||||
import { Activity, Check, ChevronDown, Copy, Workflow } from 'lucide-react';
|
||||
import { historyApi } from '../../api/history';
|
||||
import { formatUiText, UI_TEXT } from '../../i18n/uiText';
|
||||
import type {
|
||||
ReportLanguage,
|
||||
RunDiagnosticComponent,
|
||||
@@ -16,6 +17,7 @@ interface ReportDiagnosticsProps {
|
||||
recordId?: number;
|
||||
summary?: RunDiagnosticSummary;
|
||||
language?: ReportLanguage;
|
||||
onOpenRunFlow?: (recordId: number) => void;
|
||||
}
|
||||
|
||||
type BadgeVariant = NonNullable<React.ComponentProps<typeof Badge>['variant']>;
|
||||
@@ -134,9 +136,11 @@ export const ReportDiagnostics: React.FC<ReportDiagnosticsProps> = ({
|
||||
recordId,
|
||||
summary,
|
||||
language = 'zh',
|
||||
onOpenRunFlow,
|
||||
}) => {
|
||||
const reportLanguage = normalizeReportLanguage(language);
|
||||
const text = TEXT[reportLanguage];
|
||||
const runFlowText = UI_TEXT[reportLanguage];
|
||||
const [fetchState, setFetchState] = useState<{
|
||||
recordId?: number;
|
||||
summary: RunDiagnosticSummary | null;
|
||||
@@ -325,17 +329,30 @@ export const ReportDiagnostics: React.FC<ReportDiagnosticsProps> = ({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xsm"
|
||||
disabled={!hasCopyText}
|
||||
onClick={() => void copyDiagnostics()}
|
||||
aria-label={copied ? text.copied : text.copy}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? text.copied : text.copy}
|
||||
</Button>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{recordId !== undefined && onOpenRunFlow ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xsm"
|
||||
onClick={() => onOpenRunFlow(recordId)}
|
||||
aria-label={formatUiText(runFlowText['runFlow.openHistoryAria'], { recordId })}
|
||||
>
|
||||
<Workflow className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{runFlowText['runFlow.open']}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xsm"
|
||||
disabled={!hasCopyText}
|
||||
onClick={() => void copyDiagnostics()}
|
||||
aria-label={copied ? text.copied : text.copy}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? text.copied : text.copy}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -19,6 +19,7 @@ interface ReportSummaryProps {
|
||||
isActioning: boolean;
|
||||
actionMessage: string | null;
|
||||
};
|
||||
onOpenRunFlow?: (recordId: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,7 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
|
||||
data,
|
||||
isHistory = false,
|
||||
watchlist,
|
||||
onOpenRunFlow,
|
||||
}) => {
|
||||
// 兼容 AnalysisResult 和 AnalysisReport 两种数据格式
|
||||
const report: AnalysisReport = 'report' in data ? data.report : data;
|
||||
@@ -50,6 +52,7 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
|
||||
report={report}
|
||||
recordId={recordId}
|
||||
reportLanguage={reportLanguage}
|
||||
onOpenRunFlow={onOpenRunFlow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +85,7 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
|
||||
recordId={recordId}
|
||||
summary={diagnosticSummary}
|
||||
language={reportLanguage}
|
||||
onOpenRunFlow={onOpenRunFlow}
|
||||
/>
|
||||
|
||||
{/* 透明度与追溯区 */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AnalysisReport, MarketReviewPayload } from '../../../types/analysis';
|
||||
import { MarketReviewReportView } from '../MarketReviewReportView';
|
||||
@@ -174,4 +174,22 @@ describe('MarketReviewReportView', () => {
|
||||
expect(screen.queryByText('Advancers')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Decliners')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens run flow for historical market review records', () => {
|
||||
const onOpenRunFlow = vi.fn();
|
||||
|
||||
render(
|
||||
<MarketReviewReportView
|
||||
payload={combinedMarketReviewPayload}
|
||||
content="# 大盘复盘"
|
||||
recordId={7}
|
||||
reportLanguage="zh"
|
||||
onOpenRunFlow={onOpenRunFlow}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看历史记录 7 运行流' }));
|
||||
|
||||
expect(onOpenRunFlow).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,18 @@ describe('ReportDiagnostics', () => {
|
||||
expect(screen.getByText('Fetch / LLM / save / notification path')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens historical run flow from the diagnostics body', async () => {
|
||||
const onOpenRunFlow = vi.fn();
|
||||
vi.mocked(historyApi.getDiagnostics).mockResolvedValue(diagnosticSummary);
|
||||
|
||||
render(<ReportDiagnostics recordId={1} onOpenRunFlow={onOpenRunFlow} />);
|
||||
|
||||
fireEvent.click(await screen.findByText('运行状态'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看历史记录 1 运行流' }));
|
||||
|
||||
expect(onOpenRunFlow).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('refetches diagnostics after StrictMode cleans up the first effect run', async () => {
|
||||
vi.mocked(historyApi.getDiagnostics).mockResolvedValue(diagnosticSummary);
|
||||
|
||||
|
||||
167
apps/dsa-web/src/components/run-flow/RunFlowEventList.tsx
Normal file
167
apps/dsa-web/src/components/run-flow/RunFlowEventList.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import type React from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { AlertTriangle, Filter, GitBranch, ListFilter, OctagonAlert, XCircle } from 'lucide-react';
|
||||
import { Badge, Button, StatusDot } from '../common';
|
||||
import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
||||
import type { UiTextKey } from '../../i18n/uiText';
|
||||
import type { RunFlowEvent } from '../../types/runFlow';
|
||||
import {
|
||||
compactText,
|
||||
formatDateTime,
|
||||
formatMetadataValue,
|
||||
getRunFlowSeverityLabel,
|
||||
RUN_FLOW_SEVERITY_STYLE,
|
||||
} from './utils';
|
||||
|
||||
interface RunFlowEventListProps {
|
||||
events: RunFlowEvent[];
|
||||
selectedNodeId?: string | null;
|
||||
onSelectNode?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
type EventFilter = 'all' | 'important' | 'problems' | 'fallback' | 'cancelled';
|
||||
|
||||
const FILTER_ICONS = {
|
||||
all: ListFilter,
|
||||
important: AlertTriangle,
|
||||
problems: OctagonAlert,
|
||||
fallback: GitBranch,
|
||||
cancelled: XCircle,
|
||||
} as const;
|
||||
|
||||
const eventText = (event: RunFlowEvent): string =>
|
||||
`${event.type} ${event.title} ${event.message || ''}`.toLowerCase();
|
||||
|
||||
const matchesFilter = (event: RunFlowEvent, filter: EventFilter): boolean => {
|
||||
if (filter === 'all') return true;
|
||||
const text = eventText(event);
|
||||
if (filter === 'important') {
|
||||
return event.severity === 'warning'
|
||||
|| event.severity === 'danger'
|
||||
|| /fallback|retry|cancel|failed|error|timeout/.test(text);
|
||||
}
|
||||
if (filter === 'problems') {
|
||||
return event.severity === 'warning'
|
||||
|| event.severity === 'danger'
|
||||
|| /failed|error|timeout/.test(text);
|
||||
}
|
||||
if (filter === 'fallback') {
|
||||
return /fallback|retry|降级|重试/.test(text);
|
||||
}
|
||||
return /cancel|取消/.test(text);
|
||||
};
|
||||
|
||||
export const RunFlowEventList: React.FC<RunFlowEventListProps> = ({
|
||||
events,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}) => {
|
||||
const { language, t } = useUiLanguage();
|
||||
const [filter, setFilter] = useState<EventFilter>('all');
|
||||
const sortedEvents = useMemo(() => (
|
||||
[...events].sort((left, right) => {
|
||||
const leftTime = left.timestamp ? Date.parse(left.timestamp) : 0;
|
||||
const rightTime = right.timestamp ? Date.parse(right.timestamp) : 0;
|
||||
return leftTime - rightTime;
|
||||
})
|
||||
), [events]);
|
||||
const visibleEvents = useMemo(
|
||||
() => sortedEvents.filter((event) => matchesFilter(event, filter)),
|
||||
[filter, sortedEvents],
|
||||
);
|
||||
const filters: EventFilter[] = ['all', 'important', 'problems', 'fallback', 'cancelled'];
|
||||
|
||||
return (
|
||||
<div className="home-subpanel flex min-h-0 flex-col overflow-hidden p-3" data-testid="run-flow-events">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="label-uppercase">{t('runFlow.events.title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-text">
|
||||
{t('runFlow.events.count', { count: visibleEvents.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5" aria-label={t('runFlow.events.filters')}>
|
||||
{filters.map((item) => {
|
||||
const Icon = FILTER_ICONS[item];
|
||||
return (
|
||||
<Button
|
||||
key={item}
|
||||
type="button"
|
||||
variant={filter === item ? 'outline' : 'ghost'}
|
||||
size="xsm"
|
||||
onClick={() => setFilter(item)}
|
||||
aria-pressed={filter === item}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(`runFlow.events.filter.${item}` as UiTextKey)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 min-h-0 space-y-2 overflow-y-auto pr-1">
|
||||
{visibleEvents.length > 0 ? visibleEvents.map((event) => {
|
||||
const style = RUN_FLOW_SEVERITY_STYLE[event.severity] || RUN_FLOW_SEVERITY_STYLE.info;
|
||||
const selected = Boolean(event.nodeId && event.nodeId === selectedNodeId);
|
||||
const metadata = Object.entries(event.metadata || {})
|
||||
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||
.slice(0, 3);
|
||||
const content = (
|
||||
<div
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${
|
||||
selected ? 'border-primary/70 bg-primary/10' : 'border-subtle bg-base/30 hover:bg-hover/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={style.badge} className="gap-1.5 shadow-none">
|
||||
<StatusDot tone={style.tone} className="h-1.5 w-1.5" />
|
||||
{getRunFlowSeverityLabel(event.severity, t)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-text">
|
||||
{formatDateTime(event.timestamp, language, t)}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-muted-text">{compactText(event.type, 28)}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-medium text-foreground">{event.title}</p>
|
||||
{event.message ? (
|
||||
<p className="mt-1 text-xs leading-5 text-secondary-text">{event.message}</p>
|
||||
) : null}
|
||||
{metadata.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{metadata.map(([key, value]) => (
|
||||
<span key={key} className="home-accent-chip px-2 py-0.5 text-[11px] text-muted-text">
|
||||
{key}: {formatMetadataValue(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!event.nodeId || !onSelectNode) {
|
||||
return <div key={event.id}>{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="block w-full"
|
||||
onClick={() => onSelectNode(event.nodeId || '')}
|
||||
aria-label={t('runFlow.events.openNode', { title: event.title })}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}) : (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center rounded-lg border border-dashed border-subtle px-4 py-8 text-center text-sm text-secondary-text">
|
||||
<Filter className="mb-2 h-5 w-5 text-muted-text" aria-hidden="true" />
|
||||
{t('runFlow.events.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
416
apps/dsa-web/src/components/run-flow/RunFlowGraph.tsx
Normal file
416
apps/dsa-web/src/components/run-flow/RunFlowGraph.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import type React from 'react';
|
||||
import { useMemo, useId } from 'react';
|
||||
import { Badge, StatusDot, Tooltip } from '../common';
|
||||
import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
||||
import type { RunFlowEdge, RunFlowLane, RunFlowNode, RunFlowStatus } from '../../types/runFlow';
|
||||
import {
|
||||
compactText,
|
||||
formatDuration,
|
||||
getNodeDisplayOrder,
|
||||
getRunFlowEdgeKindLabel,
|
||||
getRunFlowStatusLabel,
|
||||
RUN_FLOW_STATUS_STYLE,
|
||||
} from './utils';
|
||||
|
||||
interface RunFlowGraphProps {
|
||||
lanes: RunFlowLane[];
|
||||
nodes: RunFlowNode[];
|
||||
edges: RunFlowEdge[];
|
||||
selectedNodeId?: string | null;
|
||||
onSelectNode?: (node: RunFlowNode) => void;
|
||||
}
|
||||
|
||||
type PositionedNode = RunFlowNode & {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
row: number;
|
||||
laneIndex: number;
|
||||
};
|
||||
|
||||
const LANE_WIDTH = 292;
|
||||
const NODE_WIDTH = 244;
|
||||
const NODE_HEIGHT = 108;
|
||||
const HEADER_HEIGHT = 42;
|
||||
const ROW_HEIGHT = 126;
|
||||
const LEFT_PADDING = 20;
|
||||
const TOP_PADDING = 18;
|
||||
const BOTTOM_PADDING = 30;
|
||||
|
||||
const getEdgeStroke = (status: RunFlowStatus): string => {
|
||||
if (status === 'failed' || status === 'timeout') return 'hsl(var(--destructive))';
|
||||
if (status === 'fallback' || status === 'degraded' || status === 'cancel_requested') return 'hsl(var(--warning))';
|
||||
if (status === 'success') return 'hsl(var(--success))';
|
||||
if (status === 'running') return 'hsl(var(--primary))';
|
||||
return 'hsl(var(--muted-text))';
|
||||
};
|
||||
|
||||
const findAvailableRow = (occupiedRows: Set<number>, preferredRow: number): number => {
|
||||
const safePreferred = Math.max(0, preferredRow);
|
||||
for (let distance = 0; distance < 1000; distance += 1) {
|
||||
const lower = safePreferred - distance;
|
||||
const upper = safePreferred + distance;
|
||||
if (lower >= 0 && !occupiedRows.has(lower)) {
|
||||
return lower;
|
||||
}
|
||||
if (!occupiedRows.has(upper)) {
|
||||
return upper;
|
||||
}
|
||||
}
|
||||
return occupiedRows.size;
|
||||
};
|
||||
|
||||
const getAnchorOffset = (total: number, index: number, height: number): number => {
|
||||
if (total <= 1) {
|
||||
return height / 2;
|
||||
}
|
||||
const step = height / (total + 1);
|
||||
return step * (index + 1);
|
||||
};
|
||||
|
||||
const getCenteredTrackOffset = (total: number, index: number, step = 12): number => (
|
||||
(index - (total - 1) / 2) * step
|
||||
);
|
||||
|
||||
export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
|
||||
lanes,
|
||||
nodes,
|
||||
edges,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}) => {
|
||||
const arrowId = useId().replace(/:/g, '-');
|
||||
const { t } = useUiLanguage();
|
||||
const laneList = useMemo(() => {
|
||||
const sortedLanes = [...lanes].sort((left, right) => left.order - right.order);
|
||||
const knownLaneIds = new Set(sortedLanes.map((lane) => lane.id));
|
||||
const extraLanes = nodes
|
||||
.map((node) => node.lane)
|
||||
.filter((laneId, index, values) => !knownLaneIds.has(laneId) && values.indexOf(laneId) === index)
|
||||
.map((laneId, index) => ({
|
||||
id: laneId,
|
||||
label: laneId,
|
||||
order: sortedLanes.length + index + 1,
|
||||
}));
|
||||
return [...sortedLanes, ...extraLanes];
|
||||
}, [lanes, nodes]);
|
||||
const layout = useMemo(() => {
|
||||
const grouped = new Map<string, RunFlowNode[]>();
|
||||
const originalIndex = new Map<string, number>();
|
||||
const nodeById = new Map<string, RunFlowNode>();
|
||||
const laneIndexById = new Map<string, number>();
|
||||
laneList.forEach((lane, index) => {
|
||||
laneIndexById.set(lane.id, index);
|
||||
});
|
||||
nodes.forEach((node, index) => {
|
||||
const items = grouped.get(node.lane) || [];
|
||||
items.push(node);
|
||||
grouped.set(node.lane, items);
|
||||
originalIndex.set(node.id, index);
|
||||
nodeById.set(node.id, node);
|
||||
});
|
||||
|
||||
const validEdges = edges.filter((edge) => nodeById.has(edge.from) && nodeById.has(edge.to));
|
||||
const incomingByNode = new Map<string, RunFlowEdge[]>();
|
||||
const outgoingByNode = new Map<string, RunFlowEdge[]>();
|
||||
validEdges.forEach((edge) => {
|
||||
incomingByNode.set(edge.to, [...(incomingByNode.get(edge.to) || []), edge]);
|
||||
outgoingByNode.set(edge.from, [...(outgoingByNode.get(edge.from) || []), edge]);
|
||||
});
|
||||
|
||||
const laneOrderByNode = new Map<string, number>();
|
||||
laneList.forEach((lane) => {
|
||||
const laneNodes = [...(grouped.get(lane.id) || [])].sort((left, right) => (
|
||||
getNodeDisplayOrder(left, originalIndex.get(left.id) ?? 0)
|
||||
- getNodeDisplayOrder(right, originalIndex.get(right.id) ?? 0)
|
||||
));
|
||||
laneNodes.forEach((node, index) => {
|
||||
laneOrderByNode.set(node.id, index);
|
||||
});
|
||||
});
|
||||
|
||||
const preferredRows = new Map<string, number>();
|
||||
const visiting = new Set<string>();
|
||||
const resolvePreferredRow = (nodeId: string): number => {
|
||||
if (preferredRows.has(nodeId)) {
|
||||
return preferredRows.get(nodeId) || 0;
|
||||
}
|
||||
if (visiting.has(nodeId)) {
|
||||
return laneOrderByNode.get(nodeId) || 0;
|
||||
}
|
||||
visiting.add(nodeId);
|
||||
const node = nodeById.get(nodeId);
|
||||
const baseRow = laneOrderByNode.get(nodeId) || 0;
|
||||
if (!node) {
|
||||
visiting.delete(nodeId);
|
||||
return baseRow;
|
||||
}
|
||||
const nodeLaneIndex = laneIndexById.get(node.lane) || 0;
|
||||
const parentRows = (incomingByNode.get(nodeId) || [])
|
||||
.map((edge) => nodeById.get(edge.from))
|
||||
.filter((parent): parent is RunFlowNode => Boolean(parent))
|
||||
.filter((parent) => (laneIndexById.get(parent.lane) || 0) <= nodeLaneIndex)
|
||||
.map((parent) => {
|
||||
const parentRow = resolvePreferredRow(parent.id);
|
||||
return parent.lane === node.lane ? parentRow + 1 : parentRow;
|
||||
});
|
||||
const preferredRow = parentRows.length > 0
|
||||
? Math.max(0, Math.round(parentRows.reduce((sum, row) => sum + row, 0) / parentRows.length))
|
||||
: baseRow;
|
||||
const resolvedRow = Math.max(0, Math.max(baseRow - 1, preferredRow));
|
||||
preferredRows.set(nodeId, resolvedRow);
|
||||
visiting.delete(nodeId);
|
||||
return resolvedRow;
|
||||
};
|
||||
|
||||
const positioned = new Map<string, PositionedNode>();
|
||||
let maxRow = 0;
|
||||
laneList.forEach((lane, lanePosition) => {
|
||||
const laneNodes = [...(grouped.get(lane.id) || [])].sort((left, right) => (
|
||||
resolvePreferredRow(left.id) - resolvePreferredRow(right.id)
|
||||
|| getNodeDisplayOrder(left, originalIndex.get(left.id) ?? 0)
|
||||
- getNodeDisplayOrder(right, originalIndex.get(right.id) ?? 0)
|
||||
));
|
||||
const occupiedRows = new Set<number>();
|
||||
laneNodes.forEach((node) => {
|
||||
const row = findAvailableRow(occupiedRows, resolvePreferredRow(node.id));
|
||||
occupiedRows.add(row);
|
||||
maxRow = Math.max(maxRow, row);
|
||||
positioned.set(node.id, {
|
||||
...node,
|
||||
x: lanePosition * LANE_WIDTH + LEFT_PADDING,
|
||||
y: HEADER_HEIGHT + TOP_PADDING + row * ROW_HEIGHT,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
row,
|
||||
laneIndex: lanePosition,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const sortEdgesForAnchors = (edgeItems: RunFlowEdge[], fromNodeId: string) => [...edgeItems].sort((left, right) => {
|
||||
const leftTarget = nodeById.get(left.to);
|
||||
const rightTarget = nodeById.get(right.to);
|
||||
const leftSource = nodeById.get(left.from);
|
||||
const rightSource = nodeById.get(right.from);
|
||||
const leftOther = left.from === fromNodeId ? leftTarget : leftSource;
|
||||
const rightOther = right.from === fromNodeId ? rightTarget : rightSource;
|
||||
const leftPosition = leftOther ? positioned.get(leftOther.id) : null;
|
||||
const rightPosition = rightOther ? positioned.get(rightOther.id) : null;
|
||||
return (leftPosition?.laneIndex ?? 0) - (rightPosition?.laneIndex ?? 0)
|
||||
|| (leftPosition?.row ?? 0) - (rightPosition?.row ?? 0)
|
||||
|| left.id.localeCompare(right.id);
|
||||
});
|
||||
|
||||
const outgoingAnchors = new Map<string, number>();
|
||||
outgoingByNode.forEach((nodeEdges, nodeId) => {
|
||||
const node = positioned.get(nodeId);
|
||||
if (!node) return;
|
||||
const sortedEdges = sortEdgesForAnchors(nodeEdges, nodeId);
|
||||
sortedEdges.forEach((edge, index) => {
|
||||
outgoingAnchors.set(edge.id, node.y + getAnchorOffset(sortedEdges.length, index, node.height));
|
||||
});
|
||||
});
|
||||
|
||||
const incomingAnchors = new Map<string, number>();
|
||||
incomingByNode.forEach((nodeEdges, nodeId) => {
|
||||
const node = positioned.get(nodeId);
|
||||
if (!node) return;
|
||||
const sortedEdges = sortEdgesForAnchors(nodeEdges, nodeId);
|
||||
sortedEdges.forEach((edge, index) => {
|
||||
incomingAnchors.set(edge.id, node.y + getAnchorOffset(sortedEdges.length, index, node.height));
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
positioned,
|
||||
incomingAnchors,
|
||||
outgoingAnchors,
|
||||
width: Math.max(laneList.length * LANE_WIDTH + LEFT_PADDING, LANE_WIDTH),
|
||||
height: HEADER_HEIGHT + TOP_PADDING + (maxRow + 1) * ROW_HEIGHT + BOTTOM_PADDING,
|
||||
};
|
||||
}, [edges, laneList, nodes]);
|
||||
|
||||
const edgePaths = edges
|
||||
.map((edge, edgeIndex) => {
|
||||
const from = layout.positioned.get(edge.from);
|
||||
const to = layout.positioned.get(edge.to);
|
||||
if (!from || !to) {
|
||||
return null;
|
||||
}
|
||||
const sameLane = from.lane === to.lane;
|
||||
const sameLaneVertical = sameLane && Math.abs(to.y - from.y) >= ROW_HEIGHT / 2;
|
||||
if (sameLaneVertical) {
|
||||
const downward = to.y >= from.y;
|
||||
const trackOffset = getCenteredTrackOffset(edges.length, edgeIndex, 2);
|
||||
const startX = from.x + from.width / 2 + trackOffset;
|
||||
const endX = to.x + to.width / 2 + trackOffset;
|
||||
const startY = downward ? from.y + from.height : from.y;
|
||||
const endY = downward ? to.y : to.y + to.height;
|
||||
const routeY = (startY + endY) / 2;
|
||||
const path = `M ${startX} ${startY} C ${startX} ${routeY}, ${endX} ${routeY}, ${endX} ${endY}`;
|
||||
return {
|
||||
edge,
|
||||
path,
|
||||
labelX: (startX + endX) / 2,
|
||||
labelY: routeY - 6,
|
||||
};
|
||||
}
|
||||
|
||||
const forward = to.x >= from.x;
|
||||
const startX = forward ? from.x + from.width : from.x;
|
||||
const endX = forward ? to.x : to.x + to.width;
|
||||
const startY = layout.outgoingAnchors.get(edge.id) ?? from.y + from.height / 2;
|
||||
const endY = layout.incomingAnchors.get(edge.id) ?? to.y + to.height / 2;
|
||||
const direction = forward ? 1 : -1;
|
||||
const trackOffset = getCenteredTrackOffset(edges.length, edgeIndex, 2);
|
||||
const laneGap = Math.abs(endX - startX);
|
||||
const routeX = sameLane
|
||||
? startX + direction * (34 + Math.abs(trackOffset))
|
||||
: startX + direction * Math.max(44, laneGap / 2) + trackOffset;
|
||||
const path = `M ${startX} ${startY} C ${routeX} ${startY}, ${routeX} ${endY}, ${endX} ${endY}`;
|
||||
return {
|
||||
edge,
|
||||
path,
|
||||
labelX: routeX,
|
||||
labelY: (startY + endY) / 2 - 6,
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||
|
||||
return (
|
||||
<div className="home-subpanel overflow-hidden p-3" data-testid="run-flow-graph">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="label-uppercase">{t('runFlow.graph.title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-text">{t('runFlow.graph.description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(['data', 'control', 'fallback', 'retry'] as const).map((kind) => (
|
||||
<Badge key={kind} variant={kind === 'fallback' || kind === 'retry' ? 'warning' : 'default'} className="shadow-none">
|
||||
{getRunFlowEdgeKindLabel(kind, t)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: layout.width, minHeight: layout.height }}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-10"
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id={`${arrowId}-arrow`}
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" fill="currentColor" />
|
||||
</marker>
|
||||
</defs>
|
||||
{edgePaths.map(({ edge, path, labelX, labelY }) => (
|
||||
<g key={edge.id} style={{ color: getEdgeStroke(edge.status) }}>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={edge.kind === 'fallback' || edge.kind === 'retry' ? 2.5 : 1.75}
|
||||
strokeDasharray={edge.kind === 'retry' ? '7 5' : edge.kind === 'fallback' ? '4 4' : undefined}
|
||||
markerEnd={`url(#${arrowId}-arrow)`}
|
||||
opacity={0.78}
|
||||
/>
|
||||
{edge.label ? (
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-text text-[10px]"
|
||||
style={{ paintOrder: 'stroke', stroke: 'hsl(var(--card))', strokeWidth: 4 }}
|
||||
>
|
||||
{compactText(edge.label, 22)}
|
||||
</text>
|
||||
) : null}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{laneList.map((lane, index) => (
|
||||
<div
|
||||
key={`${lane.id}-band`}
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 z-0 rounded-lg border border-subtle/70 bg-base/20"
|
||||
style={{
|
||||
left: index * LANE_WIDTH + LEFT_PADDING - 8,
|
||||
width: NODE_WIDTH + 16,
|
||||
height: layout.height,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{laneList.map((lane, index) => (
|
||||
<div
|
||||
key={lane.id}
|
||||
className="absolute top-0 z-20 rounded-lg border border-subtle bg-base/75 px-3 py-2 text-xs font-medium text-secondary-text backdrop-blur-sm"
|
||||
style={{ left: index * LANE_WIDTH + LEFT_PADDING, width: NODE_WIDTH }}
|
||||
>
|
||||
{lane.label}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Array.from(layout.positioned.values()).map((node) => {
|
||||
const style = RUN_FLOW_STATUS_STYLE[node.status] || RUN_FLOW_STATUS_STYLE.unknown;
|
||||
const selected = selectedNodeId === node.id;
|
||||
const statusLabel = getRunFlowStatusLabel(node.status, t);
|
||||
return (
|
||||
<Tooltip key={node.id} content={node.message || statusLabel} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`run-flow-node-${node.id}`}
|
||||
onClick={() => onSelectNode?.(node)}
|
||||
aria-pressed={selected}
|
||||
aria-label={t('runFlow.graph.nodeAria', { label: node.label, status: statusLabel })}
|
||||
data-layout-lane={node.laneIndex}
|
||||
data-layout-row={node.row}
|
||||
className={`absolute z-30 flex flex-col items-start rounded-lg border bg-elevated/92 px-3 py-2 text-left shadow-soft-card backdrop-blur-sm transition-all hover:-translate-y-0.5 hover:border-primary/60 hover:shadow-lg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan/15 ${
|
||||
selected ? 'border-primary/70 ring-2 ring-primary/20' : 'border-subtle'
|
||||
}`}
|
||||
style={{ left: node.x, top: node.y, width: node.width, minHeight: node.height }}
|
||||
>
|
||||
<span className="flex w-full min-w-0 items-start justify-between gap-2">
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-foreground">{node.label}</span>
|
||||
{node.provider ? (
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-text">{node.provider}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<StatusDot tone={style.tone} pulse={style.pulse} className="mt-1 h-2 w-2" />
|
||||
</span>
|
||||
<span className="mt-2 flex w-full flex-wrap items-center gap-1.5">
|
||||
<Badge variant={style.badge} className="shadow-none">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{typeof node.durationMs === 'number' ? (
|
||||
<span className="text-[11px] text-muted-text">{formatDuration(node.durationMs, t)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
100
apps/dsa-web/src/components/run-flow/RunFlowNodeDetails.tsx
Normal file
100
apps/dsa-web/src/components/run-flow/RunFlowNodeDetails.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type React from 'react';
|
||||
import { Info, X } from 'lucide-react';
|
||||
import { Badge, Button, StatusDot } from '../common';
|
||||
import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
||||
import type { RunFlowNode } from '../../types/runFlow';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatMetadataValue,
|
||||
getRunFlowNodeKindLabel,
|
||||
getRunFlowStatusLabel,
|
||||
RUN_FLOW_STATUS_STYLE,
|
||||
} from './utils';
|
||||
|
||||
interface RunFlowNodeDetailsProps {
|
||||
node?: RunFlowNode | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const RunFlowNodeDetails: React.FC<RunFlowNodeDetailsProps> = ({ node, onClose }) => {
|
||||
const { language, t } = useUiLanguage();
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<aside className="home-subpanel p-4 text-sm text-secondary-text" data-testid="run-flow-node-details-empty">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-cyan" aria-hidden="true" />
|
||||
{t('runFlow.nodeDetails.empty')}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const style = RUN_FLOW_STATUS_STYLE[node.status] || RUN_FLOW_STATUS_STYLE.unknown;
|
||||
const metadata = Object.entries(node.metadata || {}).filter(([, value]) => value !== null && value !== undefined && value !== '');
|
||||
const detailRows = [
|
||||
[t('runFlow.nodeDetails.kind'), getRunFlowNodeKindLabel(node.kind, t)],
|
||||
[t('runFlow.nodeDetails.provider'), node.provider || t('runFlow.valueUnavailable')],
|
||||
[t('runFlow.nodeDetails.duration'), formatDuration(node.durationMs, t)],
|
||||
[t('runFlow.nodeDetails.attempts'), node.attempts ? String(node.attempts) : t('runFlow.valueUnavailable')],
|
||||
[t('runFlow.nodeDetails.recordCount'), typeof node.recordCount === 'number' ? String(node.recordCount) : t('runFlow.valueUnavailable')],
|
||||
[t('runFlow.nodeDetails.startedAt'), formatDateTime(node.startedAt, language, t)],
|
||||
[t('runFlow.nodeDetails.endedAt'), formatDateTime(node.endedAt, language, t)],
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="home-subpanel p-4" data-testid="run-flow-node-details">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="label-uppercase">{t('runFlow.nodeDetails.title')}</p>
|
||||
<h3 className="mt-1 truncate text-base font-semibold text-foreground">{node.label}</h3>
|
||||
{node.message ? (
|
||||
<p className="mt-2 text-sm leading-6 text-secondary-text">{node.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={style.badge} className="gap-1.5 shadow-none">
|
||||
<StatusDot tone={style.tone} pulse={style.pulse} className="h-1.5 w-1.5" />
|
||||
{getRunFlowStatusLabel(node.status, t)}
|
||||
</Badge>
|
||||
{onClose ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xsm"
|
||||
onClick={onClose}
|
||||
aria-label={t('runFlow.nodeDetails.close')}
|
||||
className="h-7 w-7 px-0"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||
{detailRows.map(([label, value]) => (
|
||||
<div key={label} className="rounded-lg border border-subtle bg-base/35 px-3 py-2">
|
||||
<dt className="text-xs text-muted-text">{label}</dt>
|
||||
<dd className="mt-1 break-words text-foreground">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{metadata.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<p className="label-uppercase">{t('runFlow.nodeDetails.metadata')}</p>
|
||||
<dl className="mt-2 grid grid-cols-1 gap-2 text-sm">
|
||||
{metadata.map(([key, value]) => (
|
||||
<div key={key} className="rounded-lg border border-subtle bg-base/35 px-3 py-2">
|
||||
<dt className="font-mono text-xs text-muted-text">{key}</dt>
|
||||
<dd className="mt-1 break-words text-foreground">{formatMetadataValue(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
154
apps/dsa-web/src/components/run-flow/RunFlowPanel.tsx
Normal file
154
apps/dsa-web/src/components/run-flow/RunFlowPanel.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import type React from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { AlertCircle, RefreshCw, Workflow } from 'lucide-react';
|
||||
import { Button, EmptyState, InlineAlert } from '../common';
|
||||
import { useRunFlowSnapshot } from '../../hooks/useRunFlowSnapshot';
|
||||
import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
||||
import type { RunFlowNode, RunFlowSnapshotSource } from '../../types/runFlow';
|
||||
import { RunFlowEventList } from './RunFlowEventList';
|
||||
import { RunFlowGraph } from './RunFlowGraph';
|
||||
import { RunFlowNodeDetails } from './RunFlowNodeDetails';
|
||||
import { RunFlowSummaryBar } from './RunFlowSummaryBar';
|
||||
|
||||
interface RunFlowPanelProps {
|
||||
source: RunFlowSnapshotSource | null;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const RunFlowPanel: React.FC<RunFlowPanelProps> = ({ source, title }) => {
|
||||
const { t } = useUiLanguage();
|
||||
const { snapshot, isLoading, error, refetch } = useRunFlowSnapshot({
|
||||
source,
|
||||
enabled: Boolean(source),
|
||||
});
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null | false>(null);
|
||||
const defaultNodeId = useMemo(() => {
|
||||
if (!snapshot?.nodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const notable = snapshot.nodes.find((node) => (
|
||||
node.status === 'failed'
|
||||
|| node.status === 'fallback'
|
||||
|| node.status === 'degraded'
|
||||
|| node.status === 'running'
|
||||
|| node.status === 'cancel_requested'
|
||||
));
|
||||
return notable?.id || snapshot.nodes[0].id;
|
||||
}, [snapshot]);
|
||||
const resolvedSelectedNodeId = useMemo(() => {
|
||||
if (selectedNodeId === false) {
|
||||
return null;
|
||||
}
|
||||
if (selectedNodeId && snapshot?.nodes.some((node) => node.id === selectedNodeId)) {
|
||||
return selectedNodeId;
|
||||
}
|
||||
return defaultNodeId;
|
||||
}, [defaultNodeId, selectedNodeId, snapshot]);
|
||||
const selectedNode = useMemo(
|
||||
() => snapshot?.nodes.find((node) => node.id === resolvedSelectedNodeId) || null,
|
||||
[resolvedSelectedNodeId, snapshot],
|
||||
);
|
||||
|
||||
const selectNode = (node: RunFlowNode) => setSelectedNodeId(node.id);
|
||||
const selectNodeById = (nodeId: string) => {
|
||||
if (snapshot?.nodes.some((node) => node.id === nodeId)) {
|
||||
setSelectedNodeId(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !snapshot) {
|
||||
return (
|
||||
<div className="flex min-h-[22rem] flex-col items-center justify-center text-center" data-testid="run-flow-panel-loading">
|
||||
<div className="home-spinner h-10 w-10 animate-spin border-[3px]" aria-hidden="true" />
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">{t('runFlow.loadingTitle')}</h3>
|
||||
<p className="mt-2 max-w-sm text-sm text-secondary-text">{t('runFlow.loadingDescription')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !snapshot) {
|
||||
return (
|
||||
<div className="space-y-4" data-testid="run-flow-panel-error">
|
||||
<InlineAlert
|
||||
variant="danger"
|
||||
title={error.title || t('runFlow.errorTitle')}
|
||||
message={error.message}
|
||||
className="rounded-xl px-3 py-2 text-sm shadow-none"
|
||||
/>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void refetch()}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
{t('runFlow.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('runFlow.emptyTitle')}
|
||||
description={t('runFlow.emptyDescription')}
|
||||
icon={<Workflow className="h-6 w-6" aria-hidden="true" />}
|
||||
className="border-dashed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDetails = snapshot.nodes.length > 0 || snapshot.events.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="run-flow-panel">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="label-uppercase">{t('runFlow.eyebrow')}</p>
|
||||
<h2 className="mt-1 truncate text-lg font-semibold text-foreground">
|
||||
{title || t('runFlow.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refetch()}
|
||||
isLoading={isLoading}
|
||||
loadingText={t('runFlow.refreshing')}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
{t('runFlow.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RunFlowSummaryBar snapshot={snapshot} />
|
||||
|
||||
{!hasDetails ? (
|
||||
<EmptyState
|
||||
title={t('runFlow.emptySnapshotTitle')}
|
||||
description={t('runFlow.emptySnapshotDescription')}
|
||||
icon={<AlertCircle className="h-6 w-6" aria-hidden="true" />}
|
||||
className="border-dashed"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 2xl:grid-cols-[minmax(0,1fr)_24rem]">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<RunFlowGraph
|
||||
lanes={snapshot.lanes}
|
||||
nodes={snapshot.nodes}
|
||||
edges={snapshot.edges}
|
||||
selectedNodeId={resolvedSelectedNodeId}
|
||||
onSelectNode={selectNode}
|
||||
/>
|
||||
<RunFlowNodeDetails node={selectedNode} onClose={() => setSelectedNodeId(false)} />
|
||||
</div>
|
||||
<div className="min-h-[20rem] 2xl:max-h-[calc(100vh-18rem)]">
|
||||
<RunFlowEventList
|
||||
events={snapshot.events}
|
||||
selectedNodeId={resolvedSelectedNodeId}
|
||||
onSelectNode={selectNodeById}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
92
apps/dsa-web/src/components/run-flow/RunFlowSummaryBar.tsx
Normal file
92
apps/dsa-web/src/components/run-flow/RunFlowSummaryBar.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type React from 'react';
|
||||
import { Clock, Database, GitBranch, MessageSquareText, Workflow } from 'lucide-react';
|
||||
import { Badge, StatusDot } from '../common';
|
||||
import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
||||
import type { RunFlowSnapshot } from '../../types/runFlow';
|
||||
import { compactText, formatDateTime, formatDuration, getRunFlowStatusLabel, RUN_FLOW_STATUS_STYLE } from './utils';
|
||||
|
||||
interface RunFlowSummaryBarProps {
|
||||
snapshot: RunFlowSnapshot;
|
||||
}
|
||||
|
||||
export const RunFlowSummaryBar: React.FC<RunFlowSummaryBarProps> = ({ snapshot }) => {
|
||||
const { language, t } = useUiLanguage();
|
||||
const style = RUN_FLOW_STATUS_STYLE[snapshot.status] || RUN_FLOW_STATUS_STYLE.unknown;
|
||||
const title = snapshot.stockName || snapshot.stockCode || t('runFlow.valueUnavailable');
|
||||
const taskId = compactText(snapshot.taskId, 32);
|
||||
const traceId = compactText(snapshot.traceId || '', 32);
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'elapsed',
|
||||
icon: Clock,
|
||||
label: t('runFlow.summary.elapsed'),
|
||||
value: formatDuration(snapshot.summary.elapsedMs, t),
|
||||
},
|
||||
{
|
||||
key: 'fallback',
|
||||
icon: GitBranch,
|
||||
label: t('runFlow.summary.fallbackCount'),
|
||||
value: String(snapshot.summary.fallbackCount ?? 0),
|
||||
},
|
||||
{
|
||||
key: 'failed',
|
||||
icon: MessageSquareText,
|
||||
label: t('runFlow.summary.failedAttempts'),
|
||||
value: String(snapshot.summary.failedAttempts ?? 0),
|
||||
},
|
||||
{
|
||||
key: 'sources',
|
||||
icon: Database,
|
||||
label: t('runFlow.summary.dataSources'),
|
||||
value: String(snapshot.summary.dataSourceCount ?? 0),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="home-subpanel p-3" data-testid="run-flow-summary">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Workflow className="h-4 w-4 shrink-0 text-cyan" aria-hidden="true" />
|
||||
<h3 className="truncate text-base font-semibold text-foreground">{title}</h3>
|
||||
<Badge variant={style.badge} className="gap-1.5 shadow-none">
|
||||
<StatusDot tone={style.tone} pulse={style.pulse} className="h-1.5 w-1.5" />
|
||||
{getRunFlowStatusLabel(snapshot.status, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-text">
|
||||
<span className="home-accent-chip px-2 py-0.5 font-mono">
|
||||
{t('runFlow.summary.task')}: {taskId || t('runFlow.valueUnavailable')}
|
||||
</span>
|
||||
{traceId ? (
|
||||
<span className="home-accent-chip px-2 py-0.5 font-mono">
|
||||
{t('runFlow.summary.trace')}: {traceId}
|
||||
</span>
|
||||
) : null}
|
||||
{snapshot.summary.model ? (
|
||||
<span className="home-accent-chip px-2 py-0.5">
|
||||
{t('runFlow.summary.model')}: {compactText(snapshot.summary.model, 36)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="home-accent-chip px-2 py-0.5">
|
||||
{t('runFlow.summary.generatedAt')}: {formatDateTime(snapshot.generatedAt, language, t)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:min-w-[28rem]">
|
||||
{items.map(({ key, icon: Icon, label, value }) => (
|
||||
<div key={key} className="rounded-lg border border-subtle bg-surface/40 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-text">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground tabular-nums">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RunFlowEvent } from '../../../types/runFlow';
|
||||
import { RunFlowEventList } from '../RunFlowEventList';
|
||||
|
||||
const events: RunFlowEvent[] = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
timestamp: '2026-06-08T08:00:01Z',
|
||||
severity: 'info',
|
||||
type: 'task_created',
|
||||
nodeId: 'request',
|
||||
title: '任务创建',
|
||||
},
|
||||
{
|
||||
id: 'evt-2',
|
||||
timestamp: '2026-06-08T08:00:02Z',
|
||||
severity: 'warning',
|
||||
type: 'provider_fallback',
|
||||
nodeId: 'daily_data',
|
||||
title: '日线降级',
|
||||
message: 'Tushare 失败后切换 AkShare',
|
||||
},
|
||||
{
|
||||
id: 'evt-3',
|
||||
timestamp: '2026-06-08T08:00:03Z',
|
||||
severity: 'danger',
|
||||
type: 'task_cancelled',
|
||||
nodeId: 'queue',
|
||||
title: '任务取消',
|
||||
},
|
||||
];
|
||||
|
||||
describe('RunFlowEventList', () => {
|
||||
it('filters fallback and cancellation events with visible text labels', () => {
|
||||
render(<RunFlowEventList events={events} />);
|
||||
|
||||
expect(screen.getByText('任务创建')).toBeInTheDocument();
|
||||
expect(screen.getByText('日线降级')).toBeInTheDocument();
|
||||
expect(screen.getByText('任务取消')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '降级/重试' }));
|
||||
|
||||
expect(screen.getByText('日线降级')).toBeInTheDocument();
|
||||
expect(screen.queryByText('任务创建')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('任务取消')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
|
||||
expect(screen.getByText('任务取消')).toBeInTheDocument();
|
||||
expect(screen.queryByText('日线降级')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('危险')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects the event node when an event row is clicked', () => {
|
||||
const onSelectNode = vi.fn();
|
||||
render(<RunFlowEventList events={events} onSelectNode={onSelectNode} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看事件 日线降级 关联节点' }));
|
||||
|
||||
expect(onSelectNode).toHaveBeenCalledWith('daily_data');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RunFlowEdge, RunFlowLane, RunFlowNode } from '../../../types/runFlow';
|
||||
import { RunFlowGraph } from '../RunFlowGraph';
|
||||
|
||||
const lanes: RunFlowLane[] = [
|
||||
{ id: 'entry', label: '入口', order: 1 },
|
||||
{ id: 'data_source', label: '数据来源', order: 2 },
|
||||
{ id: 'analysis', label: '分析引擎', order: 3 },
|
||||
];
|
||||
|
||||
const nodes: RunFlowNode[] = [
|
||||
{
|
||||
id: 'request',
|
||||
lane: 'entry',
|
||||
kind: 'entry',
|
||||
label: '用户请求',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'news',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '新闻舆情',
|
||||
status: 'fallback',
|
||||
provider: 'AkShare',
|
||||
},
|
||||
];
|
||||
|
||||
const edges: RunFlowEdge[] = [
|
||||
{
|
||||
id: 'request-news',
|
||||
from: 'request',
|
||||
to: 'news',
|
||||
kind: 'fallback',
|
||||
status: 'fallback',
|
||||
label: '降级输入',
|
||||
},
|
||||
];
|
||||
|
||||
describe('RunFlowGraph', () => {
|
||||
it('renders auto-layered lanes, edge legend labels, and clickable nodes', () => {
|
||||
const onSelectNode = vi.fn();
|
||||
render(
|
||||
<RunFlowGraph
|
||||
lanes={lanes}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onSelectNode={onSelectNode}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('入口')).toBeInTheDocument();
|
||||
expect(screen.getByText('数据来源')).toBeInTheDocument();
|
||||
expect(screen.getByText('降级')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '新闻舆情 节点,状态 Fallback' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新闻舆情 节点,状态 Fallback' }));
|
||||
|
||||
expect(onSelectNode).toHaveBeenCalledWith(expect.objectContaining({ id: 'news' }));
|
||||
});
|
||||
|
||||
it('distributes fan-out edge anchors instead of routing every line through the node center', () => {
|
||||
const fanOutNodes: RunFlowNode[] = [
|
||||
{
|
||||
id: 'request',
|
||||
lane: 'entry',
|
||||
kind: 'entry',
|
||||
label: '用户请求',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'daily',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '日线K线',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'quote',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'llm',
|
||||
lane: 'analysis',
|
||||
kind: 'model',
|
||||
label: 'LLM 生成',
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
const fanOutEdges: RunFlowEdge[] = [
|
||||
{
|
||||
id: 'request-daily',
|
||||
from: 'request',
|
||||
to: 'daily',
|
||||
kind: 'control',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'request-quote',
|
||||
from: 'request',
|
||||
to: 'quote',
|
||||
kind: 'control',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'daily-llm',
|
||||
from: 'daily',
|
||||
to: 'llm',
|
||||
kind: 'data',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'quote-llm',
|
||||
from: 'quote',
|
||||
to: 'llm',
|
||||
kind: 'data',
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
<RunFlowGraph
|
||||
lanes={lanes}
|
||||
nodes={fanOutNodes}
|
||||
edges={fanOutEdges}
|
||||
/>,
|
||||
);
|
||||
|
||||
const pathData = Array.from(container.querySelectorAll('svg g path'))
|
||||
.map((path) => path.getAttribute('d') || '');
|
||||
const fanOutStartYs = pathData
|
||||
.slice(0, 2)
|
||||
.map((path) => Number(path.match(/^M\s+\S+\s+(\S+)/)?.[1]));
|
||||
|
||||
expect(new Set(fanOutStartYs).size).toBe(2);
|
||||
expect(screen.getByTestId('run-flow-node-daily')).toHaveAttribute('data-layout-row');
|
||||
expect(screen.getByTestId('run-flow-node-quote')).toHaveAttribute('data-layout-row');
|
||||
});
|
||||
|
||||
it('routes same-lane vertical edges from card bottom to the next card top', () => {
|
||||
const verticalNodes: RunFlowNode[] = [
|
||||
{
|
||||
id: 'daily',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '日线K线',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'quote',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情',
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
const verticalEdges: RunFlowEdge[] = [
|
||||
{
|
||||
id: 'daily-quote',
|
||||
from: 'daily',
|
||||
to: 'quote',
|
||||
kind: 'control',
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
<RunFlowGraph
|
||||
lanes={lanes}
|
||||
nodes={verticalNodes}
|
||||
edges={verticalEdges}
|
||||
/>,
|
||||
);
|
||||
|
||||
const pathData = container.querySelector('svg g path')?.getAttribute('d') || '';
|
||||
const pathNumbers = pathData.match(/-?\d+(?:\.\d+)?/g)?.map(Number) || [];
|
||||
const [startX, startY, curveStartX, , curveEndX, , endX, endY] = pathNumbers;
|
||||
const dailyNode = screen.getByTestId('run-flow-node-daily');
|
||||
const quoteNode = screen.getByTestId('run-flow-node-quote');
|
||||
const dailyBottom = parseFloat(dailyNode.style.top) + parseFloat(dailyNode.style.minHeight);
|
||||
const quoteTop = parseFloat(quoteNode.style.top);
|
||||
|
||||
expect(startX).toBe(endX);
|
||||
expect(curveStartX).toBe(startX);
|
||||
expect(curveEndX).toBe(endX);
|
||||
expect(startY).toBeLessThan(endY);
|
||||
expect(startY).toBe(dailyBottom);
|
||||
expect(endY).toBe(quoteTop);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { analysisApi } from '../../../api/analysis';
|
||||
import { historyApi } from '../../../api/history';
|
||||
import type { RunFlowSnapshot } from '../../../types/runFlow';
|
||||
import { RunFlowPanel } from '../RunFlowPanel';
|
||||
|
||||
vi.mock('../../../api/analysis', () => ({
|
||||
analysisApi: {
|
||||
getTaskFlow: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../api/history', () => ({
|
||||
historyApi: {
|
||||
getRecordFlow: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const snapshot: RunFlowSnapshot = {
|
||||
taskId: 'task-1',
|
||||
traceId: 'trace-1',
|
||||
stockCode: '600519',
|
||||
stockName: '贵州茅台',
|
||||
status: 'degraded',
|
||||
generatedAt: '2026-06-08T08:00:00Z',
|
||||
summary: {
|
||||
elapsedMs: 3250,
|
||||
failedAttempts: 1,
|
||||
fallbackCount: 1,
|
||||
model: 'DeepSeek',
|
||||
dataSourceCount: 2,
|
||||
eventCount: 3,
|
||||
},
|
||||
lanes: [
|
||||
{ id: 'entry', label: '入口', order: 1 },
|
||||
{ id: 'data_source', label: '数据来源', order: 2 },
|
||||
{ id: 'analysis', label: '分析引擎', order: 3 },
|
||||
{ id: 'artifact', label: '产物', order: 4 },
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
id: 'request',
|
||||
lane: 'entry',
|
||||
kind: 'entry',
|
||||
label: '用户请求',
|
||||
status: 'success',
|
||||
message: '任务请求已创建',
|
||||
},
|
||||
{
|
||||
id: 'news',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '新闻舆情',
|
||||
provider: 'AkShare',
|
||||
status: 'fallback',
|
||||
durationMs: 1200,
|
||||
attempts: 2,
|
||||
recordCount: 8,
|
||||
message: '主数据源失败后降级成功',
|
||||
metadata: {
|
||||
fallbackFrom: 'Tushare',
|
||||
fallbackTo: 'AkShare',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'llm',
|
||||
lane: 'analysis',
|
||||
kind: 'model',
|
||||
label: 'LLM 生成',
|
||||
provider: 'DeepSeek',
|
||||
status: 'success',
|
||||
durationMs: 1800,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'request-news',
|
||||
from: 'request',
|
||||
to: 'news',
|
||||
kind: 'control',
|
||||
status: 'success',
|
||||
label: '调度',
|
||||
},
|
||||
{
|
||||
id: 'news-llm',
|
||||
from: 'news',
|
||||
to: 'llm',
|
||||
kind: 'fallback',
|
||||
status: 'fallback',
|
||||
label: '降级输入',
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
timestamp: '2026-06-08T08:00:01Z',
|
||||
severity: 'info',
|
||||
type: 'task_created',
|
||||
nodeId: 'request',
|
||||
title: '任务创建',
|
||||
},
|
||||
{
|
||||
id: 'evt-2',
|
||||
timestamp: '2026-06-08T08:00:02Z',
|
||||
severity: 'warning',
|
||||
type: 'provider_fallback',
|
||||
nodeId: 'news',
|
||||
title: '新闻数据源降级',
|
||||
message: '重试后切换数据源',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('RunFlowPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state while the snapshot request is pending', () => {
|
||||
vi.mocked(analysisApi.getTaskFlow).mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
render(<RunFlowPanel source={{ type: 'task', taskId: 'task-1' }} />);
|
||||
|
||||
expect(screen.getByTestId('run-flow-panel-loading')).toBeInTheDocument();
|
||||
expect(screen.getByText('正在加载运行流')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an error state and reload action when the request fails', async () => {
|
||||
vi.mocked(analysisApi.getTaskFlow).mockRejectedValue({
|
||||
response: {
|
||||
status: 404,
|
||||
data: { message: '运行流不存在' },
|
||||
},
|
||||
});
|
||||
|
||||
render(<RunFlowPanel source={{ type: 'task', taskId: 'missing-task' }} />);
|
||||
|
||||
expect(await screen.findByTestId('run-flow-panel-error')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '重新加载' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an empty snapshot state when there are no nodes or events', async () => {
|
||||
vi.mocked(historyApi.getRecordFlow).mockResolvedValue({
|
||||
...snapshot,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
events: [],
|
||||
summary: { ...snapshot.summary, eventCount: 0 },
|
||||
});
|
||||
|
||||
render(<RunFlowPanel source={{ type: 'history', recordId: 1 }} />);
|
||||
|
||||
expect(await screen.findByText('暂无运行流细节')).toBeInTheDocument();
|
||||
expect(historyApi.getRecordFlow).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('renders a successful graph, event stream, and selectable node details', async () => {
|
||||
vi.mocked(analysisApi.getTaskFlow).mockResolvedValue(snapshot);
|
||||
|
||||
render(<RunFlowPanel source={{ type: 'task', taskId: 'task-1' }} title="贵州茅台运行流" />);
|
||||
|
||||
expect(await screen.findByTestId('run-flow-panel')).toBeInTheDocument();
|
||||
expect(screen.getByText('贵州茅台运行流')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('run-flow-graph')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('run-flow-events')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('run-flow-node-details')).toHaveTextContent('新闻舆情');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'LLM 生成 节点,状态 成功' }));
|
||||
|
||||
expect(screen.getByTestId('run-flow-node-details')).toHaveTextContent('LLM 生成');
|
||||
expect(screen.getByTestId('run-flow-node-details')).toHaveTextContent('DeepSeek');
|
||||
});
|
||||
|
||||
it('does not update state after a pending request is cleaned up', async () => {
|
||||
let resolveSnapshot: (value: RunFlowSnapshot) => void = () => undefined;
|
||||
vi.mocked(analysisApi.getTaskFlow).mockReturnValue(new Promise((resolve) => {
|
||||
resolveSnapshot = resolve;
|
||||
}));
|
||||
|
||||
const { unmount } = render(<RunFlowPanel source={{ type: 'task', taskId: 'task-1' }} />);
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
resolveSnapshot(snapshot);
|
||||
});
|
||||
|
||||
expect(analysisApi.getTaskFlow).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
});
|
||||
5
apps/dsa-web/src/components/run-flow/index.ts
Normal file
5
apps/dsa-web/src/components/run-flow/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { RunFlowPanel } from './RunFlowPanel';
|
||||
export { RunFlowSummaryBar } from './RunFlowSummaryBar';
|
||||
export { RunFlowGraph } from './RunFlowGraph';
|
||||
export { RunFlowEventList } from './RunFlowEventList';
|
||||
export { RunFlowNodeDetails } from './RunFlowNodeDetails';
|
||||
149
apps/dsa-web/src/components/run-flow/utils.ts
Normal file
149
apps/dsa-web/src/components/run-flow/utils.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { UiTextKey } from '../../i18n/uiText';
|
||||
import type {
|
||||
RunFlowEdgeKind,
|
||||
RunFlowEventSeverity,
|
||||
RunFlowNode,
|
||||
RunFlowNodeKind,
|
||||
RunFlowStatus,
|
||||
} from '../../types/runFlow';
|
||||
|
||||
export type RunFlowT = (key: UiTextKey, params?: Record<string, string | number>) => string;
|
||||
|
||||
export const RUN_FLOW_STATUS_STYLE: Record<RunFlowStatus, {
|
||||
badge: 'default' | 'success' | 'warning' | 'danger' | 'info';
|
||||
tone: 'success' | 'warning' | 'danger' | 'info' | 'neutral';
|
||||
pulse?: boolean;
|
||||
}> = {
|
||||
pending: { badge: 'default', tone: 'neutral' },
|
||||
running: { badge: 'info', tone: 'info', pulse: true },
|
||||
success: { badge: 'success', tone: 'success' },
|
||||
failed: { badge: 'danger', tone: 'danger' },
|
||||
degraded: { badge: 'warning', tone: 'warning' },
|
||||
fallback: { badge: 'warning', tone: 'warning' },
|
||||
timeout: { badge: 'danger', tone: 'danger' },
|
||||
cancel_requested: { badge: 'warning', tone: 'warning', pulse: true },
|
||||
cancelled: { badge: 'default', tone: 'neutral' },
|
||||
skipped: { badge: 'default', tone: 'neutral' },
|
||||
unknown: { badge: 'default', tone: 'neutral' },
|
||||
};
|
||||
|
||||
export const RUN_FLOW_SEVERITY_STYLE: Record<RunFlowEventSeverity, {
|
||||
badge: 'default' | 'success' | 'warning' | 'danger' | 'info';
|
||||
tone: 'success' | 'warning' | 'danger' | 'info' | 'neutral';
|
||||
}> = {
|
||||
info: { badge: 'info', tone: 'info' },
|
||||
success: { badge: 'success', tone: 'success' },
|
||||
warning: { badge: 'warning', tone: 'warning' },
|
||||
danger: { badge: 'danger', tone: 'danger' },
|
||||
};
|
||||
|
||||
const STATUS_LABEL_KEYS: Record<RunFlowStatus, UiTextKey> = {
|
||||
pending: 'runFlow.status.pending',
|
||||
running: 'runFlow.status.running',
|
||||
success: 'runFlow.status.success',
|
||||
failed: 'runFlow.status.failed',
|
||||
degraded: 'runFlow.status.degraded',
|
||||
fallback: 'runFlow.status.fallback',
|
||||
timeout: 'runFlow.status.timeout',
|
||||
cancel_requested: 'runFlow.status.cancelRequested',
|
||||
cancelled: 'runFlow.status.cancelled',
|
||||
skipped: 'runFlow.status.skipped',
|
||||
unknown: 'runFlow.status.unknown',
|
||||
};
|
||||
|
||||
const SEVERITY_LABEL_KEYS: Record<RunFlowEventSeverity, UiTextKey> = {
|
||||
info: 'runFlow.severity.info',
|
||||
success: 'runFlow.severity.success',
|
||||
warning: 'runFlow.severity.warning',
|
||||
danger: 'runFlow.severity.danger',
|
||||
};
|
||||
|
||||
const EDGE_KIND_LABEL_KEYS: Record<RunFlowEdgeKind, UiTextKey> = {
|
||||
data: 'runFlow.edge.data',
|
||||
control: 'runFlow.edge.control',
|
||||
fallback: 'runFlow.edge.fallback',
|
||||
retry: 'runFlow.edge.retry',
|
||||
};
|
||||
|
||||
const NODE_KIND_LABEL_KEYS: Record<RunFlowNodeKind, UiTextKey> = {
|
||||
entry: 'runFlow.nodeKind.entry',
|
||||
queue: 'runFlow.nodeKind.queue',
|
||||
data_source: 'runFlow.nodeKind.dataSource',
|
||||
analysis: 'runFlow.nodeKind.analysis',
|
||||
model: 'runFlow.nodeKind.model',
|
||||
artifact: 'runFlow.nodeKind.artifact',
|
||||
notification: 'runFlow.nodeKind.notification',
|
||||
};
|
||||
|
||||
export const getRunFlowStatusLabel = (status: RunFlowStatus, t: RunFlowT): string =>
|
||||
t(STATUS_LABEL_KEYS[status] || 'runFlow.status.unknown');
|
||||
|
||||
export const getRunFlowSeverityLabel = (severity: RunFlowEventSeverity, t: RunFlowT): string =>
|
||||
t(SEVERITY_LABEL_KEYS[severity] || 'runFlow.severity.info');
|
||||
|
||||
export const getRunFlowEdgeKindLabel = (kind: RunFlowEdgeKind, t: RunFlowT): string =>
|
||||
t(EDGE_KIND_LABEL_KEYS[kind] || 'runFlow.edge.control');
|
||||
|
||||
export const getRunFlowNodeKindLabel = (kind: RunFlowNodeKind, t: RunFlowT): string =>
|
||||
t(NODE_KIND_LABEL_KEYS[kind] || 'runFlow.nodeKind.analysis');
|
||||
|
||||
export const formatDuration = (value: number | null | undefined, t: RunFlowT): string => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return t('runFlow.valueUnavailable');
|
||||
}
|
||||
if (value < 1000) {
|
||||
return t('runFlow.durationMs', { value });
|
||||
}
|
||||
if (value < 60000) {
|
||||
return t('runFlow.durationSeconds', { value: (value / 1000).toFixed(1) });
|
||||
}
|
||||
return t('runFlow.durationMinutes', { value: (value / 60000).toFixed(1) });
|
||||
};
|
||||
|
||||
export const formatDateTime = (
|
||||
value: string | null | undefined,
|
||||
language: 'zh' | 'en',
|
||||
t: RunFlowT,
|
||||
): string => {
|
||||
if (!value) {
|
||||
return t('runFlow.valueUnavailable');
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString(language === 'en' ? 'en-US' : 'zh-CN');
|
||||
};
|
||||
|
||||
export const compactText = (value: string | null | undefined, maxLength = 64): string => {
|
||||
const text = (value || '').trim();
|
||||
if (!text || text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, Math.max(8, maxLength - 12))}...${text.slice(-8)}`;
|
||||
};
|
||||
|
||||
export const getNodeDisplayOrder = (node: RunFlowNode, index: number): number => {
|
||||
const explicitOrder = node.metadata?.order;
|
||||
if (typeof explicitOrder === 'number' && Number.isFinite(explicitOrder)) {
|
||||
return explicitOrder;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
export const formatMetadataValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return compactText(value, 120);
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return compactText(JSON.stringify(value), 120);
|
||||
} catch {
|
||||
return compactText(String(value), 120);
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type React from 'react';
|
||||
import { ChevronDown, RefreshCw } from 'lucide-react';
|
||||
import { Badge, Card, StatusDot } from '../common';
|
||||
import { ChevronDown, RefreshCw, Workflow } from 'lucide-react';
|
||||
import { Badge, Button, Card, StatusDot, Tooltip } from '../common';
|
||||
import { DashboardPanelHeader } from '../dashboard';
|
||||
import type { TaskInfo } from '../../types/analysis';
|
||||
import { getRequestedPhaseLabel } from '../../utils/marketPhase';
|
||||
@@ -11,12 +11,13 @@ import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
||||
*/
|
||||
interface TaskItemProps {
|
||||
task: TaskInfo;
|
||||
onOpenRunFlow?: (task: TaskInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个任务项
|
||||
*/
|
||||
const TaskItem: React.FC<TaskItemProps> = ({ task }) => {
|
||||
const TaskItem: React.FC<TaskItemProps> = ({ task, onOpenRunFlow }) => {
|
||||
const { language, t } = useUiLanguage();
|
||||
const isPending = task.status === 'pending';
|
||||
const isProcessing = task.status === 'processing';
|
||||
@@ -92,7 +93,28 @@ const TaskItem: React.FC<TaskItemProps> = ({ task }) => {
|
||||
</div>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{onOpenRunFlow ? (
|
||||
<Tooltip content={t('taskPanel.openRunFlow')}>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xsm"
|
||||
className="h-8 w-8 px-0"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenRunFlow(task);
|
||||
}}
|
||||
aria-label={t('taskPanel.openRunFlowAria', {
|
||||
stock: task.stockName || task.stockCode,
|
||||
})}
|
||||
>
|
||||
<Workflow className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Badge
|
||||
variant={statusVariant}
|
||||
className="min-w-[4.75rem] justify-center gap-1.5 shadow-none"
|
||||
@@ -118,6 +140,8 @@ interface TaskPanelProps {
|
||||
title?: string;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 打开运行流面板 */
|
||||
onOpenRunFlow?: (task: TaskInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,6 +153,7 @@ export const TaskPanel: React.FC<TaskPanelProps> = ({
|
||||
visible = true,
|
||||
title,
|
||||
className = '',
|
||||
onOpenRunFlow,
|
||||
}) => {
|
||||
const { t } = useUiLanguage();
|
||||
// 筛选活跃任务(pending 和 processing)
|
||||
@@ -181,7 +206,7 @@ export const TaskPanel: React.FC<TaskPanelProps> = ({
|
||||
<div className="max-h-64 overflow-y-auto p-2">
|
||||
<div className="space-y-2">
|
||||
{activeTasks.map((task) => (
|
||||
<TaskItem key={task.taskId} task={task} />
|
||||
<TaskItem key={task.taskId} task={task} onOpenRunFlow={onOpenRunFlow} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TaskPanel } from '../TaskPanel';
|
||||
import type { TaskInfo } from '../../../types/analysis';
|
||||
|
||||
@@ -72,6 +72,20 @@ describe('TaskPanel', () => {
|
||||
expect(container.querySelector('.home-subpanel')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens the run-flow view from an active task icon button', () => {
|
||||
const onOpenRunFlow = vi.fn();
|
||||
render(
|
||||
<TaskPanel
|
||||
tasks={[baseTask]}
|
||||
onOpenRunFlow={onOpenRunFlow}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 运行流' }));
|
||||
|
||||
expect(onOpenRunFlow).toHaveBeenCalledWith(baseTask);
|
||||
});
|
||||
|
||||
it('does not render when there are no active tasks', () => {
|
||||
const { container } = render(
|
||||
<TaskPanel
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { useAuth } from './useAuth';
|
||||
export { useDashboardLifecycle } from './useDashboardLifecycle';
|
||||
export { useHomeDashboardState } from './useHomeDashboardState';
|
||||
export { useRunFlowSnapshot } from './useRunFlowSnapshot';
|
||||
export { useTaskStream } from './useTaskStream';
|
||||
export { useSystemConfig } from './useSystemConfig';
|
||||
export type {
|
||||
|
||||
109
apps/dsa-web/src/hooks/useRunFlowSnapshot.ts
Normal file
109
apps/dsa-web/src/hooks/useRunFlowSnapshot.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { analysisApi } from '../api/analysis';
|
||||
import { getParsedApiError, type ParsedApiError } from '../api/error';
|
||||
import { historyApi } from '../api/history';
|
||||
import type { RunFlowSnapshot, RunFlowSnapshotSource } from '../types/runFlow';
|
||||
|
||||
interface UseRunFlowSnapshotOptions {
|
||||
source?: RunFlowSnapshotSource | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseRunFlowSnapshotResult {
|
||||
snapshot: RunFlowSnapshot | null;
|
||||
isLoading: boolean;
|
||||
error: ParsedApiError | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
type RunFlowRequestState = {
|
||||
requestKey: string;
|
||||
snapshot: RunFlowSnapshot | null;
|
||||
error: ParsedApiError | null;
|
||||
};
|
||||
|
||||
const getSourceKey = (source?: RunFlowSnapshotSource | null): string => {
|
||||
if (!source) {
|
||||
return 'none';
|
||||
}
|
||||
return source.type === 'task'
|
||||
? `task:${source.taskId}`
|
||||
: `history:${source.recordId}`;
|
||||
};
|
||||
|
||||
const isUsableSource = (source?: RunFlowSnapshotSource | null): source is RunFlowSnapshotSource => {
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
if (source.type === 'task') {
|
||||
return Boolean(source.taskId.trim());
|
||||
}
|
||||
return Number.isFinite(source.recordId);
|
||||
};
|
||||
|
||||
export function useRunFlowSnapshot({
|
||||
source,
|
||||
enabled = true,
|
||||
}: UseRunFlowSnapshotOptions): UseRunFlowSnapshotResult {
|
||||
const [requestState, setRequestState] = useState<RunFlowRequestState>({
|
||||
requestKey: 'none',
|
||||
snapshot: null,
|
||||
error: null,
|
||||
});
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const sourceKey = useMemo(() => getSourceKey(source), [source]);
|
||||
const sourceType = source?.type;
|
||||
const taskId = source?.type === 'task' ? source.taskId : '';
|
||||
const recordId = source?.type === 'history' ? source.recordId : null;
|
||||
const requestKey = `${sourceKey}:${reloadToken}`;
|
||||
const shouldLoad = enabled && isUsableSource(source);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setReloadToken((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLoad || !sourceType) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
const request = sourceType === 'task'
|
||||
? analysisApi.getTaskFlow(taskId)
|
||||
: historyApi.getRecordFlow(recordId ?? 0);
|
||||
|
||||
request
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
setRequestState({
|
||||
requestKey,
|
||||
snapshot: result,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setRequestState({
|
||||
requestKey,
|
||||
snapshot: null,
|
||||
error: getParsedApiError(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [recordId, requestKey, shouldLoad, sourceType, taskId]);
|
||||
|
||||
const hasFreshState = shouldLoad && requestState.requestKey === requestKey;
|
||||
|
||||
return {
|
||||
snapshot: hasFreshState ? requestState.snapshot : null,
|
||||
isLoading: shouldLoad && !hasFreshState,
|
||||
error: hasFreshState ? requestState.error : null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -211,9 +211,91 @@ const zh = {
|
||||
'taskPanel.processingAria': '任务进行中',
|
||||
'taskPanel.processingTasks': '{count} 进行中',
|
||||
'taskPanel.pendingAria': '任务等待中',
|
||||
'taskPanel.openRunFlow': '查看运行流',
|
||||
'taskPanel.openRunFlowAria': '查看 {stock} 运行流',
|
||||
'taskPanel.statusAria': '任务状态:{status}',
|
||||
'taskPanel.title': '分析任务',
|
||||
|
||||
'runFlow.drawerTitle': '运行流',
|
||||
'runFlow.eyebrow': '运行流',
|
||||
'runFlow.title': '数据流与信息流',
|
||||
'runFlow.open': '查看运行流',
|
||||
'runFlow.openHistoryAria': '查看历史记录 {recordId} 运行流',
|
||||
'runFlow.taskDrawerTitle': '{stock} 运行流',
|
||||
'runFlow.historyDrawerTitle': '{stock} 历史运行流',
|
||||
'runFlow.loadingTitle': '正在加载运行流',
|
||||
'runFlow.loadingDescription': '正在读取任务快照、运行诊断和事件链路。',
|
||||
'runFlow.errorTitle': '运行流加载失败',
|
||||
'runFlow.retry': '重新加载',
|
||||
'runFlow.refresh': '刷新',
|
||||
'runFlow.refreshing': '刷新中',
|
||||
'runFlow.emptyTitle': '暂无运行流',
|
||||
'runFlow.emptyDescription': '请选择一个活跃任务或历史报告查看运行流。',
|
||||
'runFlow.emptySnapshotTitle': '暂无运行流细节',
|
||||
'runFlow.emptySnapshotDescription': '当前快照没有节点或事件;缺少 diagnostics 时会显示骨架或空状态。',
|
||||
'runFlow.valueUnavailable': '未记录',
|
||||
'runFlow.durationMs': '{value} ms',
|
||||
'runFlow.durationSeconds': '{value} 秒',
|
||||
'runFlow.durationMinutes': '{value} 分钟',
|
||||
'runFlow.status.pending': '等待中',
|
||||
'runFlow.status.running': '运行中',
|
||||
'runFlow.status.success': '成功',
|
||||
'runFlow.status.failed': '失败',
|
||||
'runFlow.status.degraded': '降级',
|
||||
'runFlow.status.fallback': 'Fallback',
|
||||
'runFlow.status.timeout': '超时',
|
||||
'runFlow.status.cancelRequested': '请求取消',
|
||||
'runFlow.status.cancelled': '已取消',
|
||||
'runFlow.status.skipped': '已跳过',
|
||||
'runFlow.status.unknown': '未知',
|
||||
'runFlow.severity.info': '信息',
|
||||
'runFlow.severity.success': '成功',
|
||||
'runFlow.severity.warning': '告警',
|
||||
'runFlow.severity.danger': '危险',
|
||||
'runFlow.edge.data': '数据',
|
||||
'runFlow.edge.control': '控制',
|
||||
'runFlow.edge.fallback': '降级',
|
||||
'runFlow.edge.retry': '重试',
|
||||
'runFlow.nodeKind.entry': '入口',
|
||||
'runFlow.nodeKind.queue': '队列',
|
||||
'runFlow.nodeKind.dataSource': '数据源',
|
||||
'runFlow.nodeKind.analysis': '分析',
|
||||
'runFlow.nodeKind.model': '模型',
|
||||
'runFlow.nodeKind.artifact': '产物',
|
||||
'runFlow.nodeKind.notification': '通知',
|
||||
'runFlow.summary.elapsed': '总耗时',
|
||||
'runFlow.summary.fallbackCount': '降级/重试',
|
||||
'runFlow.summary.failedAttempts': '失败尝试',
|
||||
'runFlow.summary.dataSources': '数据源',
|
||||
'runFlow.summary.task': 'Task',
|
||||
'runFlow.summary.trace': 'Trace',
|
||||
'runFlow.summary.model': '模型',
|
||||
'runFlow.summary.generatedAt': '生成时间',
|
||||
'runFlow.graph.title': '运行拓扑',
|
||||
'runFlow.graph.description': '自动分层展示入口、数据来源、分析引擎和产物链路。',
|
||||
'runFlow.graph.nodeAria': '{label} 节点,状态 {status}',
|
||||
'runFlow.events.title': '事件流',
|
||||
'runFlow.events.count': '{count} 条事件',
|
||||
'runFlow.events.filters': '事件筛选',
|
||||
'runFlow.events.filter.all': '全部',
|
||||
'runFlow.events.filter.important': '关键',
|
||||
'runFlow.events.filter.problems': '失败/告警',
|
||||
'runFlow.events.filter.fallback': '降级/重试',
|
||||
'runFlow.events.filter.cancelled': '取消',
|
||||
'runFlow.events.openNode': '查看事件 {title} 关联节点',
|
||||
'runFlow.events.empty': '当前筛选下暂无事件。',
|
||||
'runFlow.nodeDetails.empty': '选择一个节点查看详情。',
|
||||
'runFlow.nodeDetails.title': '节点详情',
|
||||
'runFlow.nodeDetails.close': '关闭节点详情',
|
||||
'runFlow.nodeDetails.kind': '类型',
|
||||
'runFlow.nodeDetails.provider': 'Provider',
|
||||
'runFlow.nodeDetails.duration': '耗时',
|
||||
'runFlow.nodeDetails.attempts': '尝试次数',
|
||||
'runFlow.nodeDetails.recordCount': '记录数',
|
||||
'runFlow.nodeDetails.startedAt': '开始时间',
|
||||
'runFlow.nodeDetails.endedAt': '结束时间',
|
||||
'runFlow.nodeDetails.metadata': '元数据',
|
||||
|
||||
'report.addToWatchlist': '加入自选',
|
||||
'report.removeFromWatchlist': '从自选删除',
|
||||
'report.watchlist': '自选',
|
||||
@@ -607,9 +689,91 @@ const en: Record<UiTextKey, string> = {
|
||||
'taskPanel.processingAria': 'Task processing',
|
||||
'taskPanel.processingTasks': '{count} processing',
|
||||
'taskPanel.pendingAria': 'Task pending',
|
||||
'taskPanel.openRunFlow': 'View run flow',
|
||||
'taskPanel.openRunFlowAria': 'View {stock} run flow',
|
||||
'taskPanel.statusAria': 'Task status: {status}',
|
||||
'taskPanel.title': 'Analysis tasks',
|
||||
|
||||
'runFlow.drawerTitle': 'Run Flow',
|
||||
'runFlow.eyebrow': 'RUN FLOW',
|
||||
'runFlow.title': 'Data and information flow',
|
||||
'runFlow.open': 'View run flow',
|
||||
'runFlow.openHistoryAria': 'View run flow for history record {recordId}',
|
||||
'runFlow.taskDrawerTitle': '{stock} run flow',
|
||||
'runFlow.historyDrawerTitle': '{stock} history run flow',
|
||||
'runFlow.loadingTitle': 'Loading run flow',
|
||||
'runFlow.loadingDescription': 'Reading the task snapshot, run diagnostics, and event path.',
|
||||
'runFlow.errorTitle': 'Run flow failed to load',
|
||||
'runFlow.retry': 'Reload',
|
||||
'runFlow.refresh': 'Refresh',
|
||||
'runFlow.refreshing': 'Refreshing',
|
||||
'runFlow.emptyTitle': 'No run flow',
|
||||
'runFlow.emptyDescription': 'Select an active task or historical report to inspect its run flow.',
|
||||
'runFlow.emptySnapshotTitle': 'No run-flow details yet',
|
||||
'runFlow.emptySnapshotDescription': 'This snapshot has no nodes or events; missing diagnostics show as a skeleton or empty state.',
|
||||
'runFlow.valueUnavailable': 'Not recorded',
|
||||
'runFlow.durationMs': '{value} ms',
|
||||
'runFlow.durationSeconds': '{value} sec',
|
||||
'runFlow.durationMinutes': '{value} min',
|
||||
'runFlow.status.pending': 'Pending',
|
||||
'runFlow.status.running': 'Running',
|
||||
'runFlow.status.success': 'Success',
|
||||
'runFlow.status.failed': 'Failed',
|
||||
'runFlow.status.degraded': 'Degraded',
|
||||
'runFlow.status.fallback': 'Fallback',
|
||||
'runFlow.status.timeout': 'Timeout',
|
||||
'runFlow.status.cancelRequested': 'Cancel requested',
|
||||
'runFlow.status.cancelled': 'Cancelled',
|
||||
'runFlow.status.skipped': 'Skipped',
|
||||
'runFlow.status.unknown': 'Unknown',
|
||||
'runFlow.severity.info': 'Info',
|
||||
'runFlow.severity.success': 'Success',
|
||||
'runFlow.severity.warning': 'Warning',
|
||||
'runFlow.severity.danger': 'Danger',
|
||||
'runFlow.edge.data': 'Data',
|
||||
'runFlow.edge.control': 'Control',
|
||||
'runFlow.edge.fallback': 'Fallback',
|
||||
'runFlow.edge.retry': 'Retry',
|
||||
'runFlow.nodeKind.entry': 'Entry',
|
||||
'runFlow.nodeKind.queue': 'Queue',
|
||||
'runFlow.nodeKind.dataSource': 'Data source',
|
||||
'runFlow.nodeKind.analysis': 'Analysis',
|
||||
'runFlow.nodeKind.model': 'Model',
|
||||
'runFlow.nodeKind.artifact': 'Artifact',
|
||||
'runFlow.nodeKind.notification': 'Notification',
|
||||
'runFlow.summary.elapsed': 'Elapsed',
|
||||
'runFlow.summary.fallbackCount': 'Fallback/retry',
|
||||
'runFlow.summary.failedAttempts': 'Failed attempts',
|
||||
'runFlow.summary.dataSources': 'Data sources',
|
||||
'runFlow.summary.task': 'Task',
|
||||
'runFlow.summary.trace': 'Trace',
|
||||
'runFlow.summary.model': 'Model',
|
||||
'runFlow.summary.generatedAt': 'Generated',
|
||||
'runFlow.graph.title': 'Run topology',
|
||||
'runFlow.graph.description': 'Auto-layered lanes show entry, data sources, analysis engines, and artifact paths.',
|
||||
'runFlow.graph.nodeAria': '{label} node, status {status}',
|
||||
'runFlow.events.title': 'Event stream',
|
||||
'runFlow.events.count': '{count} events',
|
||||
'runFlow.events.filters': 'Event filters',
|
||||
'runFlow.events.filter.all': 'All',
|
||||
'runFlow.events.filter.important': 'Key',
|
||||
'runFlow.events.filter.problems': 'Failures/warnings',
|
||||
'runFlow.events.filter.fallback': 'Fallback/retry',
|
||||
'runFlow.events.filter.cancelled': 'Cancel',
|
||||
'runFlow.events.openNode': 'View node linked to event {title}',
|
||||
'runFlow.events.empty': 'No events match this filter.',
|
||||
'runFlow.nodeDetails.empty': 'Select a node to inspect details.',
|
||||
'runFlow.nodeDetails.title': 'Node details',
|
||||
'runFlow.nodeDetails.close': 'Close node details',
|
||||
'runFlow.nodeDetails.kind': 'Kind',
|
||||
'runFlow.nodeDetails.provider': 'Provider',
|
||||
'runFlow.nodeDetails.duration': 'Duration',
|
||||
'runFlow.nodeDetails.attempts': 'Attempts',
|
||||
'runFlow.nodeDetails.recordCount': 'Records',
|
||||
'runFlow.nodeDetails.startedAt': 'Started',
|
||||
'runFlow.nodeDetails.endedAt': 'Ended',
|
||||
'runFlow.nodeDetails.metadata': 'Metadata',
|
||||
|
||||
'report.addToWatchlist': 'Add to watchlist',
|
||||
'report.removeFromWatchlist': 'Remove from watchlist',
|
||||
'report.watchlist': 'Watchlist',
|
||||
|
||||
@@ -7,20 +7,22 @@ import { analysisApi } from '../api/analysis';
|
||||
import { historyApi } from '../api/history';
|
||||
import { agentApi, type SkillInfo } from '../api/agent';
|
||||
import { systemConfigApi } from '../api/systemConfig';
|
||||
import { ApiErrorAlert, Button, EmptyState, InlineAlert } from '../components/common';
|
||||
import { ApiErrorAlert, Button, Drawer, EmptyState, InlineAlert } from '../components/common';
|
||||
import { DashboardStateBlock } from '../components/dashboard';
|
||||
import { StockAutocomplete } from '../components/StockAutocomplete';
|
||||
import { StockHistoryTrendDrawer, StockBar } from '../components/history';
|
||||
import { ReportMarkdownDrawer } from '../components/report/ReportMarkdownDrawer';
|
||||
import { MarketReviewReportView } from '../components/report/MarketReviewReportView';
|
||||
import { ReportSummary } from '../components/report/ReportSummary';
|
||||
import { RunFlowPanel } from '../components/run-flow';
|
||||
import { TaskPanel } from '../components/tasks';
|
||||
import { useDashboardLifecycle, useHomeDashboardState } from '../hooks';
|
||||
import { useWatchlist } from '../hooks/useWatchlist';
|
||||
import { useUiLanguage } from '../contexts/UiLanguageContext';
|
||||
import type { SetupStatusResponse } from '../types/systemConfig';
|
||||
import { normalizeReportLanguage } from '../utils/reportLanguage';
|
||||
import type { MarketReviewPayload, StockBarItem } from '../types/analysis';
|
||||
import type { MarketReviewPayload, StockBarItem, TaskInfo } from '../types/analysis';
|
||||
import type { RunFlowSnapshotSource } from '../types/runFlow';
|
||||
|
||||
type MarketReviewNotice = {
|
||||
variant: 'success' | 'warning' | 'danger';
|
||||
@@ -28,6 +30,10 @@ type MarketReviewNotice = {
|
||||
message: string;
|
||||
} | null;
|
||||
|
||||
type RunFlowDrawerState =
|
||||
| { open: false }
|
||||
| { open: true; source: RunFlowSnapshotSource; title: string };
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { language: uiLanguage, t } = useUiLanguage();
|
||||
@@ -40,6 +46,7 @@ const HomePage: React.FC = () => {
|
||||
const [analysisSkills, setAnalysisSkills] = useState<SkillInfo[]>([]);
|
||||
const [selectedStrategyId, setSelectedStrategyId] = useState('');
|
||||
const [strategyMenuOpen, setStrategyMenuOpen] = useState(false);
|
||||
const [runFlowDrawer, setRunFlowDrawer] = useState<RunFlowDrawerState>({ open: false });
|
||||
const marketReviewPollTimer = useRef<number | null>(null);
|
||||
const dashboardScrollRef = useRef<HTMLElement | null>(null);
|
||||
const strategyMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -399,6 +406,29 @@ const HomePage: React.FC = () => {
|
||||
});
|
||||
}, [selectedAnalysisSkills, selectedReport, submitAnalysis]);
|
||||
|
||||
const openTaskRunFlow = useCallback((task: TaskInfo) => {
|
||||
const stock = task.stockName || task.stockCode || task.taskId;
|
||||
setRunFlowDrawer({
|
||||
open: true,
|
||||
source: { type: 'task', taskId: task.taskId },
|
||||
title: t('runFlow.taskDrawerTitle', { stock }),
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const openHistoryRunFlow = useCallback((recordId: number) => {
|
||||
const meta = selectedReport?.meta.id === recordId ? selectedReport.meta : null;
|
||||
const stock = meta?.stockName || meta?.stockCode || String(recordId);
|
||||
setRunFlowDrawer({
|
||||
open: true,
|
||||
source: { type: 'history', recordId },
|
||||
title: t('runFlow.historyDrawerTitle', { stock }),
|
||||
});
|
||||
}, [selectedReport, t]);
|
||||
|
||||
const closeRunFlowDrawer = useCallback(() => {
|
||||
setRunFlowDrawer({ open: false });
|
||||
}, []);
|
||||
|
||||
const pollMarketReviewStatus = useCallback(
|
||||
async (taskId: string) => {
|
||||
stopMarketReviewPolling();
|
||||
@@ -575,7 +605,7 @@ const HomePage: React.FC = () => {
|
||||
const sidebarContent = useMemo(
|
||||
() => (
|
||||
<div className="flex min-h-0 h-full flex-col gap-3 overflow-hidden">
|
||||
<TaskPanel tasks={activeTasks} />
|
||||
<TaskPanel tasks={activeTasks} onOpenRunFlow={openTaskRunFlow} />
|
||||
<StockBar
|
||||
items={mergedStockBarItems}
|
||||
isLoading={isLoadingStockBar}
|
||||
@@ -595,6 +625,7 @@ const HomePage: React.FC = () => {
|
||||
handleHistoryItemClick,
|
||||
handleDeleteStock,
|
||||
isDeletingStock,
|
||||
openTaskRunFlow,
|
||||
selectedReport?.meta.stockCode,
|
||||
selectedReport?.meta.id,
|
||||
],
|
||||
@@ -926,6 +957,7 @@ const HomePage: React.FC = () => {
|
||||
<ReportSummary
|
||||
data={selectedReport}
|
||||
isHistory
|
||||
onOpenRunFlow={openHistoryRunFlow}
|
||||
watchlist={{
|
||||
isInWatchlist: watchlistState.isInWatchlist,
|
||||
onToggle: watchlistState.toggleWatchlist,
|
||||
@@ -964,6 +996,22 @@ const HomePage: React.FC = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{runFlowDrawer.open ? (
|
||||
<Drawer
|
||||
isOpen={runFlowDrawer.open}
|
||||
onClose={closeRunFlowDrawer}
|
||||
title={t('runFlow.drawerTitle')}
|
||||
width="max-w-[96vw]"
|
||||
zIndex={80}
|
||||
>
|
||||
<RunFlowPanel
|
||||
key={`${runFlowDrawer.source.type}-${runFlowDrawer.source.type === 'task' ? runFlowDrawer.source.taskId : runFlowDrawer.source.recordId}`}
|
||||
source={runFlowDrawer.source}
|
||||
title={runFlowDrawer.title}
|
||||
/>
|
||||
</Drawer>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { historyApi } from '../../api/history';
|
||||
import { systemConfigApi } from '../../api/systemConfig';
|
||||
import { UiLanguageProvider } from '../../contexts/UiLanguageContext';
|
||||
import { useStockPoolStore } from '../../stores';
|
||||
import type { RunFlowSnapshot } from '../../types/runFlow';
|
||||
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
|
||||
import { UI_LANGUAGE_STORAGE_KEY } from '../../utils/uiLanguage';
|
||||
import HomePage from '../HomePage';
|
||||
@@ -28,6 +29,7 @@ vi.mock('../../api/history', () => ({
|
||||
getNews: vi.fn().mockResolvedValue({ total: 0, items: [] }),
|
||||
getMarkdown: vi.fn().mockResolvedValue('# report'),
|
||||
getDiagnostics: vi.fn(),
|
||||
getRecordFlow: vi.fn(),
|
||||
getStockBarList: vi.fn().mockResolvedValue({ total: 0, items: [] }),
|
||||
deleteByCode: vi.fn(),
|
||||
},
|
||||
@@ -42,6 +44,7 @@ vi.mock('../../api/analysis', async () => {
|
||||
triggerMarketReview: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
getTasks: vi.fn(),
|
||||
getTaskFlow: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -118,6 +121,62 @@ const marketReviewHistoryReport = {
|
||||
},
|
||||
};
|
||||
|
||||
const runFlowSnapshot: RunFlowSnapshot = {
|
||||
taskId: 'task-1',
|
||||
traceId: 'trace-1',
|
||||
stockCode: '600519',
|
||||
stockName: '贵州茅台',
|
||||
status: 'running',
|
||||
generatedAt: '2026-06-08T08:00:00Z',
|
||||
summary: {
|
||||
elapsedMs: 1200,
|
||||
failedAttempts: 0,
|
||||
fallbackCount: 0,
|
||||
dataSourceCount: 1,
|
||||
eventCount: 1,
|
||||
},
|
||||
lanes: [
|
||||
{ id: 'entry', label: '入口', order: 1 },
|
||||
{ id: 'analysis', label: '分析引擎', order: 2 },
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
id: 'request',
|
||||
lane: 'entry',
|
||||
kind: 'entry',
|
||||
label: '用户请求',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
lane: 'analysis',
|
||||
kind: 'analysis',
|
||||
label: '分析流程',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'request-analysis',
|
||||
from: 'request',
|
||||
to: 'analysis',
|
||||
kind: 'control',
|
||||
status: 'running',
|
||||
label: '调度',
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
timestamp: '2026-06-08T08:00:00Z',
|
||||
severity: 'info',
|
||||
type: 'task_started',
|
||||
nodeId: 'analysis',
|
||||
title: '任务开始',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('HomePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -138,6 +197,8 @@ describe('HomePage', () => {
|
||||
components: {},
|
||||
copyText: 'data_status: unknown',
|
||||
});
|
||||
vi.mocked(historyApi.getRecordFlow).mockResolvedValue(runFlowSnapshot);
|
||||
vi.mocked(analysisApi.getTaskFlow).mockResolvedValue(runFlowSnapshot);
|
||||
vi.mocked(systemConfigApi.getSetupStatus).mockResolvedValue({
|
||||
isComplete: true,
|
||||
readyForSmoke: true,
|
||||
@@ -232,6 +293,72 @@ describe('HomePage', () => {
|
||||
expect(screen.getByText('暂无个股记录')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the run-flow drawer from an active task in TaskPanel', async () => {
|
||||
vi.mocked(historyApi.getList).mockResolvedValue({
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(analysisApi.getTasks).mockResolvedValue({
|
||||
total: 1,
|
||||
pending: 0,
|
||||
processing: 1,
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
traceId: 'trace-1',
|
||||
stockCode: '600519',
|
||||
stockName: '贵州茅台',
|
||||
status: 'processing',
|
||||
progress: 35,
|
||||
message: '分析中',
|
||||
reportType: 'detailed',
|
||||
createdAt: '2026-06-08T08:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看 贵州茅台 运行流' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(analysisApi.getTaskFlow).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
expect(await screen.findByTestId('run-flow-panel')).toBeInTheDocument();
|
||||
expect(screen.getByText('贵州茅台 运行流')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the run-flow drawer from completed report diagnostics', async () => {
|
||||
vi.mocked(historyApi.getList).mockResolvedValue({
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
items: [historyItem],
|
||||
});
|
||||
vi.mocked(historyApi.getDetail).mockResolvedValue(historyReport);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByText('运行状态'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看历史记录 1 运行流' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(historyApi.getRecordFlow).toHaveBeenCalledWith(1);
|
||||
});
|
||||
expect(await screen.findByTestId('run-flow-panel')).toBeInTheDocument();
|
||||
expect(screen.getByText('贵州茅台 历史运行流')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows market review history in the stock bar', async () => {
|
||||
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
|
||||
total: 1,
|
||||
|
||||
97
apps/dsa-web/src/types/runFlow.ts
Normal file
97
apps/dsa-web/src/types/runFlow.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export type RunFlowStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'degraded'
|
||||
| 'fallback'
|
||||
| 'timeout'
|
||||
| 'cancel_requested'
|
||||
| 'cancelled'
|
||||
| 'skipped'
|
||||
| 'unknown';
|
||||
|
||||
export type RunFlowNodeKind =
|
||||
| 'entry'
|
||||
| 'queue'
|
||||
| 'data_source'
|
||||
| 'analysis'
|
||||
| 'model'
|
||||
| 'artifact'
|
||||
| 'notification';
|
||||
|
||||
export type RunFlowEdgeKind = 'data' | 'control' | 'fallback' | 'retry';
|
||||
|
||||
export type RunFlowEventSeverity = 'info' | 'success' | 'warning' | 'danger';
|
||||
|
||||
export interface RunFlowLane {
|
||||
id: string;
|
||||
label: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface RunFlowNode {
|
||||
id: string;
|
||||
lane: string;
|
||||
kind: RunFlowNodeKind;
|
||||
label: string;
|
||||
status: RunFlowStatus;
|
||||
provider?: string | null;
|
||||
startedAt?: string | null;
|
||||
endedAt?: string | null;
|
||||
durationMs?: number | null;
|
||||
attempts?: number | null;
|
||||
recordCount?: number | null;
|
||||
message?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RunFlowEdge {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
kind: RunFlowEdgeKind;
|
||||
status: RunFlowStatus;
|
||||
label?: string | null;
|
||||
message?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RunFlowEvent {
|
||||
id: string;
|
||||
timestamp?: string | null;
|
||||
severity: RunFlowEventSeverity;
|
||||
type: string;
|
||||
nodeId?: string | null;
|
||||
title: string;
|
||||
message?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RunFlowSummary {
|
||||
elapsedMs?: number | null;
|
||||
bottleneckNodeId?: string | null;
|
||||
failedAttempts: number;
|
||||
fallbackCount: number;
|
||||
model?: string | null;
|
||||
dataSourceCount: number;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
export interface RunFlowSnapshot {
|
||||
taskId: string;
|
||||
traceId?: string | null;
|
||||
stockCode: string;
|
||||
stockName?: string | null;
|
||||
status: RunFlowStatus;
|
||||
summary: RunFlowSummary;
|
||||
lanes: RunFlowLane[];
|
||||
nodes: RunFlowNode[];
|
||||
edges: RunFlowEdge[];
|
||||
events: RunFlowEvent[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export type RunFlowSnapshotSource =
|
||||
| { type: 'task'; taskId: string }
|
||||
| { type: 'history'; recordId: number };
|
||||
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [修复] #1390 收紧建议动作 legacy fallback:英文 `not to ...` 与 `avoid selling/reducing/trimming ...` 等否定/回避表达不再误判为买卖动作,Web 旧记录不再把中文金融上下文、`buy or sell`、多 guard 歧义文本或 `buyback` / `buy-back` / `buy back` / `selloff` / `sell-off` / `sell off` 等英文复合词渲染成 action badge,并在有结构化 `action` 时让回测/历史趋势等入口按界面语言显示 action 标签。
|
||||
- [改进] 完善运行时日志上下文,补充 logger name、触发来源、市场统计与实时行情预取链路状态,便于排查调度、API、Bot 和数据源降级路径。
|
||||
- [新功能] 新增分析任务与历史报告运行流快照 API,提供 lanes、nodes、edges、events、summary 等统一契约,并从任务队列、运行诊断和 AnalysisContextPack overview 构建脱敏数据流/信息流。
|
||||
- [新功能] Web 端为活跃任务、历史报告和大盘复盘报告补充运行流视图入口,支持查看运行摘要、拓扑节点、事件流和基础排障详情。
|
||||
- [修复] 修复历史报告运行流快照在混合时区事件时间戳下返回 500 的问题。
|
||||
- [改进] #1459 持仓管理页新增持仓账户删除入口,复用现有账户软删除接口,误建账户会从默认列表、快照、风险、录入入口和事件列表隐藏且不物理清理历史流水。
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
{
|
||||
"name": "status",
|
||||
"in": "query",
|
||||
"description": "筛选状态:pending, processing, completed, failed(支持逗号分隔多个)",
|
||||
"description": "筛选状态:pending, processing, completed, failed, cancel_requested, cancelled(支持逗号分隔多个)",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"example": "pending,processing"
|
||||
@@ -1486,6 +1486,112 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/analysis/tasks/{task_id}/flow": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Analysis"
|
||||
],
|
||||
"summary": "获取分析任务运行流",
|
||||
"description": "根据 task_id 查询活跃任务或已落库任务的运行流快照。活跃任务缺少诊断时返回 skeleton flow;完成任务可按同一 task_id/query_id 尝试读取历史诊断。",
|
||||
"operationId": "getTaskRunFlow",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "task_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "分析任务 ID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "任务运行流快照",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RunFlowSnapshot"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "任务不存在或已过期",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/history/{record_id}/flow": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"History"
|
||||
],
|
||||
"summary": "获取历史报告运行流",
|
||||
"description": "根据历史记录 ID 或可解析的 query_id 获取历史报告运行流快照。旧历史缺少诊断时返回 skeleton/unknown 状态,不影响报告详情读取。",
|
||||
"operationId": "getHistoryRunFlow",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "record_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "历史记录主键 ID 或 query_id",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "历史报告运行流快照",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RunFlowSnapshot"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "报告不存在",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -3484,6 +3590,379 @@
|
||||
"error": "not_found",
|
||||
"message": "资源不存在"
|
||||
}
|
||||
},
|
||||
"RunFlowLane": {
|
||||
"type": "object",
|
||||
"description": "运行流泳道定义",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "稳定泳道 ID"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "展示标签"
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"description": "展示顺序"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"order"
|
||||
]
|
||||
},
|
||||
"RunFlowNode": {
|
||||
"type": "object",
|
||||
"description": "运行流中的一个节点",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "稳定节点 ID"
|
||||
},
|
||||
"lane": {
|
||||
"type": "string",
|
||||
"description": "所属泳道 ID"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"entry",
|
||||
"queue",
|
||||
"data_source",
|
||||
"analysis",
|
||||
"model",
|
||||
"artifact",
|
||||
"notification"
|
||||
],
|
||||
"description": "节点类型"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "展示标签"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"running",
|
||||
"success",
|
||||
"failed",
|
||||
"degraded",
|
||||
"fallback",
|
||||
"timeout",
|
||||
"cancel_requested",
|
||||
"cancelled",
|
||||
"skipped",
|
||||
"unknown"
|
||||
],
|
||||
"description": "节点状态"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Provider、模型或通知渠道名称"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "开始时间 ISO 字符串"
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "结束时间 ISO 字符串"
|
||||
},
|
||||
"duration_ms": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0,
|
||||
"description": "耗时毫秒数"
|
||||
},
|
||||
"attempts": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 1,
|
||||
"description": "节点代表的尝试次数"
|
||||
},
|
||||
"record_count": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0,
|
||||
"description": "返回记录数"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "短脱敏状态说明"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "脱敏后的低敏元数据",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"lane",
|
||||
"kind",
|
||||
"label",
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"RunFlowEdge": {
|
||||
"type": "object",
|
||||
"description": "运行流中的有向连线",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "稳定连线 ID"
|
||||
},
|
||||
"from": {
|
||||
"type": "string",
|
||||
"description": "来源节点 ID"
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "目标节点 ID"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"data",
|
||||
"control",
|
||||
"fallback",
|
||||
"retry"
|
||||
],
|
||||
"description": "连线类型"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"running",
|
||||
"success",
|
||||
"failed",
|
||||
"degraded",
|
||||
"fallback",
|
||||
"timeout",
|
||||
"cancel_requested",
|
||||
"cancelled",
|
||||
"skipped",
|
||||
"unknown"
|
||||
],
|
||||
"description": "连线状态"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "短展示标签"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "短脱敏说明"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "脱敏后的低敏元数据",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"from",
|
||||
"to",
|
||||
"kind",
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"RunFlowEvent": {
|
||||
"type": "object",
|
||||
"description": "运行流事件",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "稳定事件 ID"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "事件时间 ISO 字符串"
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"info",
|
||||
"success",
|
||||
"warning",
|
||||
"danger"
|
||||
],
|
||||
"description": "事件严重级别"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "稳定事件类型"
|
||||
},
|
||||
"node_id": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "关联节点 ID"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "短标题"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "短脱敏说明"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "脱敏后的低敏元数据",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"severity",
|
||||
"type",
|
||||
"title"
|
||||
]
|
||||
},
|
||||
"RunFlowSummary": {
|
||||
"type": "object",
|
||||
"description": "运行流摘要指标",
|
||||
"properties": {
|
||||
"elapsed_ms": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0,
|
||||
"description": "观测到的总耗时毫秒数"
|
||||
},
|
||||
"bottleneck_node_id": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "耗时最长节点 ID"
|
||||
},
|
||||
"failed_attempts": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "失败尝试次数"
|
||||
},
|
||||
"fallback_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "fallback 或 retry 转换次数"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "诊断中观测到的脱敏模型名"
|
||||
},
|
||||
"data_source_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "数据来源节点数量"
|
||||
},
|
||||
"event_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "事件数量"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"failed_attempts",
|
||||
"fallback_count",
|
||||
"data_source_count",
|
||||
"event_count"
|
||||
]
|
||||
},
|
||||
"RunFlowSnapshot": {
|
||||
"type": "object",
|
||||
"description": "任务或历史报告运行流快照",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "任务 ID 或 query_id"
|
||||
},
|
||||
"trace_id": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "诊断 trace ID"
|
||||
},
|
||||
"stock_code": {
|
||||
"type": "string",
|
||||
"description": "股票代码"
|
||||
},
|
||||
"stock_name": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "股票名称"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"running",
|
||||
"success",
|
||||
"failed",
|
||||
"degraded",
|
||||
"fallback",
|
||||
"timeout",
|
||||
"cancel_requested",
|
||||
"cancelled",
|
||||
"skipped",
|
||||
"unknown"
|
||||
],
|
||||
"description": "整体运行流状态"
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/components/schemas/RunFlowSummary"
|
||||
},
|
||||
"lanes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunFlowLane"
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunFlowNode"
|
||||
}
|
||||
},
|
||||
"edges": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunFlowEdge"
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RunFlowEvent"
|
||||
}
|
||||
},
|
||||
"generated_at": {
|
||||
"type": "string",
|
||||
"description": "快照生成时间 ISO 字符串"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"task_id",
|
||||
"stock_code",
|
||||
"status",
|
||||
"summary",
|
||||
"lanes",
|
||||
"nodes",
|
||||
"edges",
|
||||
"events",
|
||||
"generated_at"
|
||||
]
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
|
||||
@@ -16,6 +16,35 @@ GET /api/v1/history/{record_id}/diagnostics
|
||||
- 诊断面板支持复制后端生成的脱敏 `copy_text`,用于 issue 或部署排障。
|
||||
- 分析链路在保存历史后会补齐任务/Provider/LLM/通知诊断到 `context_snapshot.diagnostics`,历史诊断接口统一聚合为用户可读摘要。
|
||||
|
||||
## 运行流视图
|
||||
|
||||
运行流视图是在运行诊断摘要之上的可视化排障入口,用于串联一次分析从触发、数据获取、ContextPack 组装、LLM 生成到保存/通知的大致链路。它不替代诊断摘要的 `copy_text`,而是把同一批脱敏诊断证据组织为节点、连线、事件和摘要指标,方便从 Web 首页快速定位异常或降级环节。
|
||||
|
||||
后端提供两个只读快照接口:
|
||||
|
||||
```http
|
||||
GET /api/v1/analysis/tasks/{task_id}/flow
|
||||
GET /api/v1/history/{record_id}/flow
|
||||
```
|
||||
|
||||
- `tasks/{task_id}/flow` 面向活跃任务。任务仍在内存队列中时优先返回当前任务快照;任务已完成时可按同一 `task_id/query_id` 尝试读取历史诊断。缺少诊断时返回 skeleton flow,不伪造 provider、LLM 或通知事件。
|
||||
- `history/{record_id}/flow` 面向历史报告,支持历史记录主键 ID 或可解析的 `query_id`。普通个股分析与 `MARKET/market_review` 大盘复盘复用同一 `RunFlowSnapshot` 契约。
|
||||
- 快照顶层包含 `summary`、`lanes`、`nodes`、`edges`、`events` 和 `generated_at`。节点状态使用 `pending/running/success/failed/degraded/fallback/timeout/cancel_requested/cancelled/skipped/unknown`,其中用户取消类状态不会被映射成 `failed`。
|
||||
- 旧历史、缺失 `context_snapshot.diagnostics` 或证据不足时,后端返回 `unknown` 或 skeleton 节点;Web 端按空/未知状态展示,不影响报告详情读取。
|
||||
|
||||
Web 入口:
|
||||
|
||||
- 首页活跃任务卡片提供运行流入口,打开抽屉后按 `task_id` 拉取任务快照。
|
||||
- 历史报告摘要和运行诊断区域提供运行流入口,打开抽屉后按历史记录 ID 拉取历史快照。
|
||||
- 面板展示摘要、基础拓扑、事件流和节点详情;复杂拓扑聚合、实时增量事件和布局 polish 会在后续阶段继续收敛。
|
||||
|
||||
脱敏与兼容边界:
|
||||
|
||||
- 运行流只读取既有任务信息、历史结果和 `context_snapshot.diagnostics` 中的低敏诊断字段,不新增配置项、不改数据库结构、不迁移旧历史。
|
||||
- `model`、`provider`、`fallback_model` 仅用于展示实际诊断到的调用信息;不参与模型选择、请求路由、Base URL 解析或配置保存。
|
||||
- `metadata`、错误信息和本地路径会经过后端裁剪与脱敏,避免暴露 API key、token、cookie、webhook、prompt/raw response、代理头和本地绝对路径。
|
||||
- 回滚时可移除 Web 入口和查询路径;后端新增只读快照接口不改变原有分析、历史、通知或诊断摘要接口的成功/失败语义。
|
||||
|
||||
## 状态文案
|
||||
|
||||
总体状态:
|
||||
|
||||
Reference in New Issue
Block a user