From f7ac00bda9db2378c23fe566b98fb4e6a4c6b77e Mon Sep 17 00:00:00 2001 From: LouisHong <30621586+Activer007@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:08:55 +0800 Subject: [PATCH] feat:[issue #1652] add web run-flow viewer (#1654) * feat: add web run-flow viewer * docs: document run-flow web viewer contracts * fix: add market review run flow entry --- apps/dsa-web/src/api/analysis.ts | 13 + apps/dsa-web/src/api/history.ts | 10 + .../report/MarketReviewReportView.tsx | 21 +- .../components/report/ReportDiagnostics.tsx | 41 +- .../src/components/report/ReportSummary.tsx | 4 + .../__tests__/MarketReviewReportView.test.tsx | 20 +- .../__tests__/ReportDiagnostics.test.tsx | 12 + .../components/run-flow/RunFlowEventList.tsx | 167 ++++++ .../src/components/run-flow/RunFlowGraph.tsx | 416 +++++++++++++++ .../run-flow/RunFlowNodeDetails.tsx | 100 ++++ .../src/components/run-flow/RunFlowPanel.tsx | 154 ++++++ .../components/run-flow/RunFlowSummaryBar.tsx | 92 ++++ .../__tests__/RunFlowEventList.test.tsx | 63 +++ .../run-flow/__tests__/RunFlowGraph.test.tsx | 192 +++++++ .../run-flow/__tests__/RunFlowPanel.test.tsx | 190 +++++++ apps/dsa-web/src/components/run-flow/index.ts | 5 + apps/dsa-web/src/components/run-flow/utils.ts | 149 ++++++ .../src/components/tasks/TaskPanel.tsx | 35 +- .../tasks/__tests__/TaskPanel.test.tsx | 18 +- apps/dsa-web/src/hooks/index.ts | 1 + apps/dsa-web/src/hooks/useRunFlowSnapshot.ts | 109 ++++ apps/dsa-web/src/i18n/uiText.ts | 164 ++++++ apps/dsa-web/src/pages/HomePage.tsx | 54 +- .../src/pages/__tests__/HomePage.test.tsx | 127 +++++ apps/dsa-web/src/types/runFlow.ts | 97 ++++ docs/CHANGELOG.md | 1 + docs/architecture/api_spec.json | 481 +++++++++++++++++- docs/run-diagnostics-p3.md | 29 ++ 28 files changed, 2740 insertions(+), 25 deletions(-) create mode 100644 apps/dsa-web/src/components/run-flow/RunFlowEventList.tsx create mode 100644 apps/dsa-web/src/components/run-flow/RunFlowGraph.tsx create mode 100644 apps/dsa-web/src/components/run-flow/RunFlowNodeDetails.tsx create mode 100644 apps/dsa-web/src/components/run-flow/RunFlowPanel.tsx create mode 100644 apps/dsa-web/src/components/run-flow/RunFlowSummaryBar.tsx create mode 100644 apps/dsa-web/src/components/run-flow/__tests__/RunFlowEventList.test.tsx create mode 100644 apps/dsa-web/src/components/run-flow/__tests__/RunFlowGraph.test.tsx create mode 100644 apps/dsa-web/src/components/run-flow/__tests__/RunFlowPanel.test.tsx create mode 100644 apps/dsa-web/src/components/run-flow/index.ts create mode 100644 apps/dsa-web/src/components/run-flow/utils.ts create mode 100644 apps/dsa-web/src/hooks/useRunFlowSnapshot.ts create mode 100644 apps/dsa-web/src/types/runFlow.ts diff --git a/apps/dsa-web/src/api/analysis.ts b/apps/dsa-web/src/api/analysis.ts index 2099cdf27..1eb9fa009 100644 --- a/apps/dsa-web/src/api/analysis.ts +++ b/apps/dsa-web/src/api/analysis.ts @@ -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 => { + const response = await apiClient.get>( + `/api/v1/analysis/tasks/${encodeURIComponent(taskId)}/flow` + ); + + return toCamelCase(response.data); + }, + /** * Get the SSE stream URL. */ diff --git a/apps/dsa-web/src/api/history.ts b/apps/dsa-web/src/api/history.ts index dec84a8ee..c939811f3 100644 --- a/apps/dsa-web/src/api/history.ts +++ b/apps/dsa-web/src/api/history.ts @@ -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(response.data); }, + /** + * 获取历史报告运行流快照 + * @param recordId 分析历史记录主键 ID + */ + getRecordFlow: async (recordId: number): Promise => { + const response = await apiClient.get>(`/api/v1/history/${recordId}/flow`); + return toCamelCase(response.data); + }, + /** * 批量删除历史记录 * @param recordIds 分析历史记录主键 ID 列表 diff --git a/apps/dsa-web/src/components/report/MarketReviewReportView.tsx b/apps/dsa-web/src/components/report/MarketReviewReportView.tsx index 56de9679f..5d491a921 100644 --- a/apps/dsa-web/src/components/report/MarketReviewReportView.tsx +++ b/apps/dsa-web/src/components/report/MarketReviewReportView.tsx @@ -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 = ({ 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(null); const [loadError, setLoadError] = useState(null); @@ -295,6 +299,7 @@ export const MarketReviewReportView: React.FC = ({ [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 = ({
+ {canOpenRunFlow ? ( + + + + + + ) : null}
- +
+ {recordId !== undefined && onOpenRunFlow ? ( + + ) : null} + +
diff --git a/apps/dsa-web/src/components/report/ReportSummary.tsx b/apps/dsa-web/src/components/report/ReportSummary.tsx index 5ba0df0d7..52506d07c 100644 --- a/apps/dsa-web/src/components/report/ReportSummary.tsx +++ b/apps/dsa-web/src/components/report/ReportSummary.tsx @@ -19,6 +19,7 @@ interface ReportSummaryProps { isActioning: boolean; actionMessage: string | null; }; + onOpenRunFlow?: (recordId: number) => void; } /** @@ -29,6 +30,7 @@ export const ReportSummary: React.FC = ({ 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 = ({ report={report} recordId={recordId} reportLanguage={reportLanguage} + onOpenRunFlow={onOpenRunFlow} /> ); } @@ -82,6 +85,7 @@ export const ReportSummary: React.FC = ({ recordId={recordId} summary={diagnosticSummary} language={reportLanguage} + onOpenRunFlow={onOpenRunFlow} /> {/* 透明度与追溯区 */} diff --git a/apps/dsa-web/src/components/report/__tests__/MarketReviewReportView.test.tsx b/apps/dsa-web/src/components/report/__tests__/MarketReviewReportView.test.tsx index f1147a64c..24e6b1464 100644 --- a/apps/dsa-web/src/components/report/__tests__/MarketReviewReportView.test.tsx +++ b/apps/dsa-web/src/components/report/__tests__/MarketReviewReportView.test.tsx @@ -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( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '查看历史记录 7 运行流' })); + + expect(onOpenRunFlow).toHaveBeenCalledWith(7); + }); }); diff --git a/apps/dsa-web/src/components/report/__tests__/ReportDiagnostics.test.tsx b/apps/dsa-web/src/components/report/__tests__/ReportDiagnostics.test.tsx index ee629dc95..8f1cf0b06 100644 --- a/apps/dsa-web/src/components/report/__tests__/ReportDiagnostics.test.tsx +++ b/apps/dsa-web/src/components/report/__tests__/ReportDiagnostics.test.tsx @@ -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(); + + 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); diff --git a/apps/dsa-web/src/components/run-flow/RunFlowEventList.tsx b/apps/dsa-web/src/components/run-flow/RunFlowEventList.tsx new file mode 100644 index 000000000..f9e0fbe5d --- /dev/null +++ b/apps/dsa-web/src/components/run-flow/RunFlowEventList.tsx @@ -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 = ({ + events, + selectedNodeId, + onSelectNode, +}) => { + const { language, t } = useUiLanguage(); + const [filter, setFilter] = useState('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 ( +
+
+
+

{t('runFlow.events.title')}

+

+ {t('runFlow.events.count', { count: visibleEvents.length })} +

+
+
+ {filters.map((item) => { + const Icon = FILTER_ICONS[item]; + return ( + + ); + })} +
+
+ +
+ {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 = ( +
+
+ + + {getRunFlowSeverityLabel(event.severity, t)} + + + {formatDateTime(event.timestamp, language, t)} + + {compactText(event.type, 28)} +
+

{event.title}

+ {event.message ? ( +

{event.message}

+ ) : null} + {metadata.length > 0 ? ( +
+ {metadata.map(([key, value]) => ( + + {key}: {formatMetadataValue(value)} + + ))} +
+ ) : null} +
+ ); + + if (!event.nodeId || !onSelectNode) { + return
{content}
; + } + + return ( + + ); + }) : ( +
+
+ )} +
+
+ ); +}; diff --git a/apps/dsa-web/src/components/run-flow/RunFlowGraph.tsx b/apps/dsa-web/src/components/run-flow/RunFlowGraph.tsx new file mode 100644 index 000000000..c554bb4df --- /dev/null +++ b/apps/dsa-web/src/components/run-flow/RunFlowGraph.tsx @@ -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, 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 = ({ + 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(); + const originalIndex = new Map(); + const nodeById = new Map(); + const laneIndexById = new Map(); + 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(); + const outgoingByNode = new Map(); + 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(); + 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(); + const visiting = new Set(); + 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(); + 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(); + 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(); + 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(); + 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 => Boolean(item)); + + return ( +
+
+
+

{t('runFlow.graph.title')}

+

{t('runFlow.graph.description')}

+
+
+ {(['data', 'control', 'fallback', 'retry'] as const).map((kind) => ( + + {getRunFlowEdgeKindLabel(kind, t)} + + ))} +
+
+ +
+
+ + + {laneList.map((lane, index) => ( + +
+
+ ); +}; diff --git a/apps/dsa-web/src/components/run-flow/RunFlowNodeDetails.tsx b/apps/dsa-web/src/components/run-flow/RunFlowNodeDetails.tsx new file mode 100644 index 000000000..a667b75a2 --- /dev/null +++ b/apps/dsa-web/src/components/run-flow/RunFlowNodeDetails.tsx @@ -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 = ({ node, onClose }) => { + const { language, t } = useUiLanguage(); + + if (!node) { + return ( + + ); + } + + 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 ( + + ); +}; diff --git a/apps/dsa-web/src/components/run-flow/RunFlowPanel.tsx b/apps/dsa-web/src/components/run-flow/RunFlowPanel.tsx new file mode 100644 index 000000000..92b1e3d89 --- /dev/null +++ b/apps/dsa-web/src/components/run-flow/RunFlowPanel.tsx @@ -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 = ({ source, title }) => { + const { t } = useUiLanguage(); + const { snapshot, isLoading, error, refetch } = useRunFlowSnapshot({ + source, + enabled: Boolean(source), + }); + const [selectedNodeId, setSelectedNodeId] = useState(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 ( +
+ + ); + } + + if (error && !snapshot) { + return ( +
+ + +
+ ); + } + + if (!snapshot) { + return ( +
{/* 状态标签 */} -
+
+ {onOpenRunFlow ? ( + + + + + + ) : null} void; } /** @@ -129,6 +153,7 @@ export const TaskPanel: React.FC = ({ visible = true, title, className = '', + onOpenRunFlow, }) => { const { t } = useUiLanguage(); // 筛选活跃任务(pending 和 processing) @@ -181,7 +206,7 @@ export const TaskPanel: React.FC = ({
{activeTasks.map((task) => ( - + ))}
diff --git a/apps/dsa-web/src/components/tasks/__tests__/TaskPanel.test.tsx b/apps/dsa-web/src/components/tasks/__tests__/TaskPanel.test.tsx index 22daa3d0b..3fb018207 100644 --- a/apps/dsa-web/src/components/tasks/__tests__/TaskPanel.test.tsx +++ b/apps/dsa-web/src/components/tasks/__tests__/TaskPanel.test.tsx @@ -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( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 运行流' })); + + expect(onOpenRunFlow).toHaveBeenCalledWith(baseTask); + }); + it('does not render when there are no active tasks', () => { const { container } = render( Promise; +} + +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({ + 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, + }; +} diff --git a/apps/dsa-web/src/i18n/uiText.ts b/apps/dsa-web/src/i18n/uiText.ts index 2afcebcf8..8384f4aac 100644 --- a/apps/dsa-web/src/i18n/uiText.ts +++ b/apps/dsa-web/src/i18n/uiText.ts @@ -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 = { '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', diff --git a/apps/dsa-web/src/pages/HomePage.tsx b/apps/dsa-web/src/pages/HomePage.tsx index a3cfd0ed1..d83de5e4e 100644 --- a/apps/dsa-web/src/pages/HomePage.tsx +++ b/apps/dsa-web/src/pages/HomePage.tsx @@ -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([]); const [selectedStrategyId, setSelectedStrategyId] = useState(''); const [strategyMenuOpen, setStrategyMenuOpen] = useState(false); + const [runFlowDrawer, setRunFlowDrawer] = useState({ open: false }); const marketReviewPollTimer = useRef(null); const dashboardScrollRef = useRef(null); const strategyMenuRef = useRef(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( () => (
- + { handleHistoryItemClick, handleDeleteStock, isDeletingStock, + openTaskRunFlow, selectedReport?.meta.stockCode, selectedReport?.meta.id, ], @@ -926,6 +957,7 @@ const HomePage: React.FC = () => { { /> ) : null} + {runFlowDrawer.open ? ( + + + + ) : null} +
); }; diff --git a/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx b/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx index c91fe8e0e..4e1dc7c15 100644 --- a/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx +++ b/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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, diff --git a/apps/dsa-web/src/types/runFlow.ts b/apps/dsa-web/src/types/runFlow.ts new file mode 100644 index 000000000..391c7fc67 --- /dev/null +++ b/apps/dsa-web/src/types/runFlow.ts @@ -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; +} + +export interface RunFlowEdge { + id: string; + from: string; + to: string; + kind: RunFlowEdgeKind; + status: RunFlowStatus; + label?: string | null; + message?: string | null; + metadata?: Record; +} + +export interface RunFlowEvent { + id: string; + timestamp?: string | null; + severity: RunFlowEventSeverity; + type: string; + nodeId?: string | null; + title: string; + message?: string | null; + metadata?: Record; +} + +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 }; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c09069a24..5b8f5ad0f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 持仓管理页新增持仓账户删除入口,复用现有账户软删除接口,误建账户会从默认列表、快照、风险、录入入口和事件列表隐藏且不物理清理历史流水。 diff --git a/docs/architecture/api_spec.json b/docs/architecture/api_spec.json index abbb3e31c..a93aa65fb 100644 --- a/docs/architecture/api_spec.json +++ b/docs/architecture/api_spec.json @@ -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": { diff --git a/docs/run-diagnostics-p3.md b/docs/run-diagnostics-p3.md index 9f6cae47c..3e1617de0 100644 --- a/docs/run-diagnostics-p3.md +++ b/docs/run-diagnostics-p3.md @@ -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 入口和查询路径;后端新增只读快照接口不改变原有分析、历史、通知或诊断摘要接口的成功/失败语义。 + ## 状态文案 总体状态: