diff --git a/frontend/src/pages/DagCanvas.tsx b/frontend/src/pages/DagCanvas.tsx new file mode 100644 index 0000000..a6f873e --- /dev/null +++ b/frontend/src/pages/DagCanvas.tsx @@ -0,0 +1,338 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ReactFlow, + Background, + Controls, + MiniMap, + addEdge, + useNodesState, + useEdgesState, + Handle, + Position, + MarkerType, +} from 'reactflow'; +import type { Node, Edge, Connection, NodeProps } from 'reactflow'; +import 'reactflow/dist/style.css'; +import { + Card, + Select, + Button, + Space, + message, + Modal, + Typography, + Tag, + Tooltip, + Empty, +} from 'antd'; +import { + ThunderboltOutlined, + ReloadOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +import { projectApi, dagApi } from '@/api'; +import type { Project, TaskStatus, DagGraphNode, DagGraphEdge } from '@/types'; +import { taskStatusConfig, priorityConfig } from '@/components/constants'; + +const { Text } = Typography; + +// ---------- Custom task node ---------- +function TaskNode({ data }: NodeProps) { + const status = data.status as TaskStatus; + const cfg = taskStatusConfig[status] || { label: status, color: '#8c8c8c' }; + const pcfg = priorityConfig[data.priority as keyof typeof priorityConfig]; + + return ( +
+ +
+ #{data.taskId} + {cfg.label} +
+
+
+ {data.title} +
+ + {pcfg && {pcfg.label}} + {data.requires_review && 需审核} + +
+ +
+ ); +} + +const nodeTypes = { taskNode: TaskNode }; + +// ---------- Layered auto-layout by topological depth ---------- +function layoutNodes(graphNodes: DagGraphNode[], graphEdges: DagGraphEdge[]) { + const deps: Record = {}; + graphNodes.forEach((n) => (deps[n.id] = [])); + graphEdges.forEach((e) => { + if (deps[e.target]) deps[e.target].push(e.source); + }); + + const depth: Record = {}; + const computeDepth = (id: number, visiting: Set): number => { + if (depth[id] !== undefined) return depth[id]; + if (visiting.has(id)) return 0; // cycle guard + visiting.add(id); + const parents = deps[id] || []; + const d = parents.length === 0 ? 0 : Math.max(...parents.map((p) => computeDepth(p, visiting))) + 1; + visiting.delete(id); + depth[id] = d; + return d; + }; + graphNodes.forEach((n) => computeDepth(n.id, new Set())); + + const byDepth: Record = {}; + graphNodes.forEach((n) => { + const d = depth[n.id] ?? 0; + if (!byDepth[d]) byDepth[d] = []; + byDepth[d].push(n.id); + }); + + const positions: Record = {}; + Object.entries(byDepth).forEach(([d, ids]) => { + ids.forEach((id, idx) => { + positions[id] = { x: Number(d) * 300, y: idx * 140 }; + }); + }); + return positions; +} + +export default function DagCanvas() { + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [loading, setLoading] = useState(false); + const [executing, setExecuting] = useState(false); + const [selectedEdge, setSelectedEdge] = useState(null); + + // Load projects + useEffect(() => { + projectApi.list().then((ps) => { + setProjects(ps); + if (ps.length > 0 && !projectId) setProjectId(ps[0].id); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const loadGraph = useCallback(async (pid: number) => { + setLoading(true); + try { + const graph = await dagApi.graph(pid); + const positions = layoutNodes(graph.nodes, graph.edges); + + const rfNodes: Node[] = graph.nodes.map((n) => ({ + id: String(n.id), + type: 'taskNode', + position: positions[n.id] || { x: 0, y: 0 }, + data: { + taskId: n.id, + title: n.title, + status: n.status, + priority: n.priority, + requires_review: n.requires_review, + }, + })); + + const rfEdges: Edge[] = graph.edges.map((e) => ({ + id: e.id, + source: String(e.source), + target: String(e.target), + markerEnd: { type: MarkerType.ArrowClosed }, + style: { strokeWidth: 2 }, + })); + + setNodes(rfNodes); + setEdges(rfEdges); + } catch { + message.error('加载DAG图失败'); + } finally { + setLoading(false); + } + }, [setNodes, setEdges]); + + useEffect(() => { + if (projectId) loadGraph(projectId); + }, [projectId, loadGraph]); + + // Connect: add dependency (drag from source(bottom) to target(top)) + const onConnect = useCallback( + async (conn: Connection) => { + if (!conn.source || !conn.target || !projectId) return; + try { + await dagApi.addDependency({ + task_id: Number(conn.target), + depends_on_id: Number(conn.source), + }); + setEdges((eds) => + addEdge( + { + ...conn, + id: `e${conn.source}-${conn.target}`, + markerEnd: { type: MarkerType.ArrowClosed }, + style: { strokeWidth: 2 }, + }, + eds + ) + ); + message.success('依赖已添加'); + } catch (err: unknown) { + const e = err as { response?: { data?: { detail?: string } } }; + message.error(e.response?.data?.detail || '添加依赖失败'); + } + }, + [projectId, setEdges] + ); + + // Delete selected edge + const removeSelectedEdge = useCallback(async () => { + if (!selectedEdge) return; + const source = Number(selectedEdge.source); + const target = Number(selectedEdge.target); + try { + await dagApi.removeDependency({ task_id: target, depends_on_id: source }); + setEdges((eds) => eds.filter((e) => e.id !== selectedEdge.id)); + message.success('依赖已移除'); + setSelectedEdge(null); + } catch { + message.error('移除依赖失败'); + } + }, [selectedEdge, setEdges]); + + // Execute DAG + const executeAll = useCallback(() => { + if (!projectId || nodes.length === 0) return; + Modal.confirm({ + title: '执行 DAG 编排', + content: `将按依赖顺序执行项目内全部 ${nodes.length} 个任务,确认执行?`, + okText: '开始执行', + cancelText: '取消', + onOk: async () => { + setExecuting(true); + try { + const taskIds = nodes.map((n) => Number(n.id)); + const res = await dagApi.execute({ project_id: projectId, task_ids: taskIds }); + const r = res.results as Record | undefined; + Modal.success({ + title: 'DAG 执行完成', + content: r + ? `成功 ${r.success ?? 0} / 失败 ${r.failed ?? 0} / 阻塞 ${r.blocked ?? 0} / 跳过 ${r.skipped ?? 0}` + : '执行完成', + }); + await loadGraph(projectId); + } catch { + message.error('DAG 执行失败'); + } finally { + setExecuting(false); + } + }, + }); + }, [projectId, nodes, loadGraph]); + + const minimap = useMemo( + () => ( + { + const s = (n.data as { status?: TaskStatus }).status; + return s ? taskStatusConfig[s].color : '#8c8c8c'; + }} + pannable + zoomable + /> + ), + [] + ); + + return ( +
+ +