deploy-ease-platform/frontend/src/pages/Dashboard/components/DeployFlowGraphModal.tsx
2025-11-06 18:34:57 +08:00

676 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useMemo } from 'react';
import { ReactFlowProvider, ReactFlow, Background, Node, Edge, Handle, Position, BackgroundVariant } from '@xyflow/react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Loader2,
AlertCircle,
User,
Building2,
Layers,
Calendar,
Clock,
CheckCircle2,
XCircle,
AlertTriangle
} from 'lucide-react';
import { cn } from '@/lib/utils';
import '@xyflow/react/dist/style.css';
import { getDeployRecordFlowGraph } from '../service';
import type { DeployRecordFlowGraph, WorkflowNodeInstance } from '../types';
import {
getStatusIcon,
getStatusText,
getNodeStatusText,
getNodeStatusColor,
formatTime,
formatDuration,
calculateRunningDuration
} from '../utils/dashboardUtils';
interface DeployFlowGraphModalProps {
open: boolean;
deployRecordId: number | null;
onOpenChange: (open: boolean) => void;
}
/**
* 自定义流程节点组件
*/
const CustomFlowNode: React.FC<any> = ({ data }) => {
const { nodeName, nodeType, status, startTime, endTime, duration, errorMessage, isUnreachable } = data;
const statusColor = getNodeStatusColor(status);
const isNotStarted = status === 'NOT_STARTED';
const isRunning = status === 'RUNNING';
const hasFailed = status === 'FAILED';
// 计算显示的时长
const displayDuration = useMemo(() => {
if (duration !== null && duration !== undefined) {
// 后端返回的 duration 单位已经是毫秒
return formatDuration(duration);
}
if (isRunning && startTime) {
const runningDuration = calculateRunningDuration(startTime);
return formatDuration(runningDuration);
}
return null;
}, [duration, isRunning, startTime]);
const nodeContent = (
<div
className={cn(
'px-4 py-3 rounded-md min-w-[160px] transition-all',
isNotStarted && 'border-2 border-dashed',
!isNotStarted && 'border-2 border-solid shadow-sm',
isRunning && 'animate-pulse',
isUnreachable && 'opacity-40' // 不可达节点半透明
)}
style={{
borderColor: statusColor,
backgroundColor: isNotStarted ? '#f9fafb' : '#ffffff',
}}
>
{/* 节点名称 */}
<div className="font-medium text-sm mb-1">{nodeName}</div>
{/* 节点状态 */}
<div
className="text-xs font-medium mb-1"
style={{ color: statusColor }}
>
{getNodeStatusText(status)}
</div>
{/* 时间信息 */}
{!isNotStarted && (
<div className="text-xs text-muted-foreground space-y-0.5">
{startTime && <div>: {formatTime(startTime)}</div>}
{endTime && <div>: {formatTime(endTime)}</div>}
{displayDuration && (
<div className="font-medium">
{isRunning ? '运行中: ' : '时长: '}
{displayDuration}
</div>
)}
</div>
)}
{/* 错误提示 */}
{hasFailed && errorMessage && (
<div className="mt-2 flex items-center gap-1 text-red-600">
<AlertCircle className="h-3 w-3" />
<span className="text-xs"></span>
</div>
)}
</div>
);
return (
<>
{/* 输入连接点 */}
{nodeType !== 'START_EVENT' && (
<Handle type="target" position={Position.Left} />
)}
{/* 节点内容 - 如果有错误信息,包装在 Tooltip 中 */}
{hasFailed && errorMessage ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{nodeContent}
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm">
<p className="font-medium mb-1">:</p>
<p className="text-xs">{errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
nodeContent
)}
{/* 输出连接点 */}
{nodeType !== 'END_EVENT' && (
<Handle type="source" position={Position.Right} />
)}
</>
);
};
const nodeTypes = {
custom: CustomFlowNode,
};
/**
* 业务信息卡片
*/
const BusinessInfoCard: React.FC<{ flowData: DeployRecordFlowGraph }> = ({ flowData }) => {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Building2 className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{flowData.applicationName}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-mono text-xs">{flowData.applicationCode}</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{flowData.environmentName}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{flowData.teamName}</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<User className="h-3 w-3" />
:
</span>
<span className="font-medium">{flowData.deployBy}</span>
</div>
{flowData.deployRemark && (
<>
<Separator />
<div>
<span className="text-muted-foreground">:</span>
<p className="text-xs mt-1 text-muted-foreground">{flowData.deployRemark}</p>
</div>
</>
)}
</CardContent>
</Card>
);
};
/**
* 进度信息卡片
*/
const ProgressCard: React.FC<{ flowData: DeployRecordFlowGraph }> = ({ flowData }) => {
const { executedNodeCount, totalNodeCount, successNodeCount, failedNodeCount, runningNodeCount } = flowData;
const progress = totalNodeCount > 0 ? (executedNodeCount / totalNodeCount) * 100 : 0;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Layers className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{executedNodeCount} / {totalNodeCount}
</span>
</div>
<Progress value={progress} className="h-2" />
<div className="text-xs text-muted-foreground mt-1 text-right">
{Math.round(progress)}%
</div>
</div>
<Separator />
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex flex-col items-center p-2 rounded-md bg-green-50">
<CheckCircle2 className="h-4 w-4 text-green-600 mb-1" />
<span className="text-muted-foreground"></span>
<span className="font-medium text-green-600">{successNodeCount}</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-red-50">
<XCircle className="h-4 w-4 text-red-600 mb-1" />
<span className="text-muted-foreground"></span>
<span className="font-medium text-red-600">{failedNodeCount}</span>
</div>
<div className="flex flex-col items-center p-2 rounded-md bg-blue-50">
<Loader2 className="h-4 w-4 text-blue-600 mb-1" />
<span className="text-muted-foreground"></span>
<span className="font-medium text-blue-600">{runningNodeCount}</span>
</div>
</div>
</CardContent>
</Card>
);
};
/**
* 时长统计卡片
*/
const DurationCard: React.FC<{ flowData: DeployRecordFlowGraph }> = ({ flowData }) => {
const { deployStartTime, deployDuration, nodeInstances, runningNodeCount } = flowData;
const isRunning = runningNodeCount > 0;
// 计算当前总时长
const currentDuration = useMemo(() => {
if (deployDuration) return deployDuration; // 后端返回的已经是毫秒
if (isRunning) return calculateRunningDuration(deployStartTime);
return 0;
}, [deployDuration, isRunning, deployStartTime]);
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Clock className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" />
:
</span>
<span className="font-medium">
{deployStartTime ? formatTime(deployStartTime) : '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">
{formatDuration(currentDuration)}
{isRunning && <span className="text-blue-600 ml-1">(...)</span>}
</span>
</div>
</div>
<Separator />
<div>
<div className="text-xs text-muted-foreground mb-2">:</div>
<div className="space-y-1.5 text-xs max-h-32 overflow-y-auto">
{nodeInstances
.filter(ni => ni.status !== 'NOT_STARTED')
.map((ni) => {
// 后端返回的 duration 单位已经是毫秒
const nodeDuration = ni.duration
? ni.duration
: (ni.status === 'RUNNING' ? calculateRunningDuration(ni.startTime) : 0);
return (
<div key={ni.nodeId} className="flex justify-between items-center">
<span className="truncate flex-1" title={ni.nodeName}>
{ni.nodeName}
</span>
<span className="font-mono ml-2" style={{ color: getNodeStatusColor(ni.status) }}>
{formatDuration(nodeDuration)}
{ni.status === 'RUNNING' && '...'}
</span>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
);
};
/**
* 实时监控提示
*/
const MonitoringAlert: React.FC<{ flowData: DeployRecordFlowGraph }> = ({ flowData }) => {
const { runningNodeCount } = flowData;
if (runningNodeCount === 0) return null;
return (
<Alert className="border-blue-200 bg-blue-50">
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
<AlertDescription className="text-sm text-blue-900">
<span className="font-medium"></span>
<span className="block text-xs mt-1">
{runningNodeCount} ...
</span>
</AlertDescription>
</Alert>
);
};
/**
* 信息面板(左侧边栏)
*/
const DeployInfoPanel: React.FC<{ flowData: DeployRecordFlowGraph }> = ({ flowData }) => {
return (
<div className="w-80 flex-shrink-0 border-r bg-muted/30 p-4 space-y-4 overflow-y-auto">
<MonitoringAlert flowData={flowData} />
<BusinessInfoCard flowData={flowData} />
<ProgressCard flowData={flowData} />
<DurationCard flowData={flowData} />
</div>
);
};
/**
* 部署流程图模态框(主组件)
*/
export const DeployFlowGraphModal: React.FC<DeployFlowGraphModalProps> = ({
open,
deployRecordId,
onOpenChange,
}) => {
const [loading, setLoading] = useState(false);
const [flowData, setFlowData] = useState<DeployRecordFlowGraph | null>(null);
// 加载流程图数据
useEffect(() => {
if (open && deployRecordId) {
setLoading(true);
getDeployRecordFlowGraph(deployRecordId)
.then((data) => {
setFlowData(data);
})
.catch((error) => {
console.error('加载部署流程图失败:', error);
})
.finally(() => {
setLoading(false);
});
} else {
setFlowData(null);
}
}, [open, deployRecordId]);
// 创建节点ID到实例的映射
const nodeInstanceMap = useMemo(() => {
if (!flowData?.nodeInstances) return new Map<string, WorkflowNodeInstance>();
const map = new Map<string, WorkflowNodeInstance>();
flowData.nodeInstances.forEach((instance) => {
map.set(instance.nodeId, instance);
});
return map;
}, [flowData]);
// 计算可达节点(从已执行节点出发能到达的节点)
const reachableNodes = useMemo(() => {
if (!flowData?.graph?.edges || !flowData?.nodeInstances) return new Set<string>();
const executedNodeIds = flowData.nodeInstances
.filter(ni => ni.status !== 'NOT_STARTED')
.map(ni => ni.nodeId);
if (executedNodeIds.length === 0) return new Set<string>();
const reachable = new Set<string>(executedNodeIds);
const queue = [...executedNodeIds];
// BFS 遍历所有可达节点
while (queue.length > 0) {
const currentId = queue.shift()!;
const outgoingEdges = flowData.graph.edges.filter(e => e.from === currentId);
for (const edge of outgoingEdges) {
if (!reachable.has(edge.to)) {
reachable.add(edge.to);
queue.push(edge.to);
}
}
}
return reachable;
}, [flowData]);
// 转换为 React Flow 节点(显示所有节点,但对不可达节点置灰)
const flowNodes: Node[] = useMemo(() => {
if (!flowData?.graph?.nodes || !flowData?.nodeInstances) return [];
const isRunning = flowData.runningNodeCount > 0;
// 按执行顺序排序已执行的节点
const executedInstances = flowData.nodeInstances
.filter(ni => ni.status !== 'NOT_STARTED')
.sort((a, b) => {
const timeA = a.startTime ? new Date(a.startTime.replace(' ', 'T')).getTime() : 0;
const timeB = b.startTime ? new Date(b.startTime.replace(' ', 'T')).getTime() : 0;
return timeA - timeB;
});
// 线性布局:从左到右排列
const nodePositionMap = new Map<string, { x: number; y: number }>();
const horizontalSpacing = 300; // 水平间距
const verticalSpacing = 150; // 垂直间距(用于分支)
const startX = 100; // 起始X坐标
const startY = 150; // 起始Y坐标
// 已执行的节点:从左到右线性排列
executedInstances.forEach((instance, index) => {
nodePositionMap.set(instance.nodeId, {
x: startX + index * horizontalSpacing,
y: startY,
});
});
// 未执行的节点:根据在图中的位置和层级布局
const notStartedNodes = flowData.graph.nodes.filter(
node => !nodePositionMap.has(node.id)
);
// 简单布局按节点在edges中的出现顺序从左到右、上到下排列
let currentX = startX + executedInstances.length * horizontalSpacing;
let currentRow = 0;
const nodesPerRow = 3;
notStartedNodes.forEach((node, index) => {
const row = Math.floor(index / nodesPerRow);
const col = index % nodesPerRow;
nodePositionMap.set(node.id, {
x: currentX + col * horizontalSpacing,
y: startY + row * verticalSpacing,
});
});
// 生成所有节点
return flowData.graph.nodes.map((node) => {
const instance = nodeInstanceMap.get(node.id);
const position = nodePositionMap.get(node.id) || { x: 0, y: 0 };
const isReachable = reachableNodes.has(node.id);
const isNotStarted = !instance || instance.status === 'NOT_STARTED';
return {
id: node.id,
type: 'custom',
position,
data: {
nodeName: node.nodeName,
nodeType: node.nodeType,
status: instance?.status || 'NOT_STARTED',
startTime: instance?.startTime,
endTime: instance?.endTime,
duration: instance?.duration,
errorMessage: instance?.errorMessage,
// 新增:不可达且未执行的节点标记为置灰
isUnreachable: isRunning && isNotStarted && !isReachable,
},
};
});
}, [flowData, nodeInstanceMap, reachableNodes]);
// 转换为 React Flow 边
const flowEdges: Edge[] = useMemo(() => {
if (!flowData?.graph?.edges) return [];
const isRunning = flowData.runningNodeCount > 0;
const displayedNodeIds = new Set(flowNodes.map(n => n.id));
return flowData.graph.edges
.filter(edge => {
// 只显示连接已显示节点的边
return displayedNodeIds.has(edge.from) && displayedNodeIds.has(edge.to);
})
.map((edge, index) => {
const source = edge.from;
const target = edge.to;
const sourceInstance = nodeInstanceMap.get(source);
const targetInstance = nodeInstanceMap.get(target);
const sourceStatus = sourceInstance?.status || 'NOT_STARTED';
const targetStatus = targetInstance?.status || 'NOT_STARTED';
// 判断这条边是否在可达路径上
const isReachableEdge = reachableNodes.has(source) && reachableNodes.has(target);
// 根据节点状态确定边的样式
let strokeColor = '#d1d5db'; // 默认灰色
let strokeWidth = 2;
let animated = false;
let opacity = 1;
// 源节点已完成 + 目标节点也已完成/运行中 = 绿色实线
if (sourceStatus === 'COMPLETED' && (targetStatus === 'COMPLETED' || targetStatus === 'RUNNING')) {
strokeColor = '#10b981'; // 绿色
}
// 源节点失败 = 红色
else if (sourceStatus === 'FAILED') {
strokeColor = '#ef4444'; // 红色
}
// 源节点运行中 = 蓝色动画
else if (sourceStatus === 'RUNNING') {
strokeColor = '#3b82f6'; // 蓝色
animated = true;
}
// 源节点完成 + 目标节点未开始
else if (sourceStatus === 'COMPLETED' && targetStatus === 'NOT_STARTED') {
if (isReachableEdge && isRunning) {
strokeColor = '#9ca3af'; // 浅灰色(即将执行)
} else {
strokeColor = '#d1d5db'; // 默认灰色
opacity = 0.3; // 不可达路径半透明
}
}
// 两端都未执行
else if (sourceStatus === 'NOT_STARTED' && targetStatus === 'NOT_STARTED') {
opacity = isRunning && !isReachableEdge ? 0.3 : 0.5;
}
return {
id: edge.id || `edge-${source}-${target}-${index}`,
source,
target,
type: 'smoothstep',
animated,
style: {
stroke: strokeColor,
strokeWidth,
opacity,
strokeDasharray: (sourceStatus === 'COMPLETED' && targetStatus === 'NOT_STARTED' && isReachableEdge) ? '5,5' : undefined,
},
markerEnd: {
type: 'arrowclosed',
color: strokeColor,
width: 20,
height: 20,
},
};
});
}, [flowData, nodeInstanceMap, flowNodes, reachableNodes]);
// 获取部署状态信息
const deployStatusInfo = flowData
? (() => {
const { icon: StatusIcon, color } = getStatusIcon(flowData.deployStatus);
return {
icon: StatusIcon,
color,
text: getStatusText(flowData.deployStatus),
};
})()
: null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-7xl w-[90vw] h-[85vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
{flowData && (
<span className="text-muted-foreground">
#{flowData.deployRecordId}
</span>
)}
<span></span>
{deployStatusInfo && (
<Badge
variant="outline"
className={cn('flex items-center gap-1', deployStatusInfo.color)}
>
<deployStatusInfo.icon
className={cn(
'h-3 w-3',
flowData?.deployStatus === 'RUNNING' && 'animate-spin'
)}
/>
{deployStatusInfo.text}
</Badge>
)}
</DialogTitle>
</DialogHeader>
<div className="flex flex-1 overflow-hidden min-h-0">
{loading ? (
<div className="flex items-center justify-center h-full w-full">
<div className="text-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
) : flowData ? (
<>
{/* 左侧信息面板 */}
<DeployInfoPanel flowData={flowData} />
{/* 右侧流程图可视化 */}
<div className="flex-1 relative">
<ReactFlowProvider>
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
nodeTypes={nodeTypes}
fitView
className="bg-muted/10"
fitViewOptions={{ padding: 0.2, maxZoom: 1, minZoom: 0.5 }}
panOnScroll={true}
zoomOnScroll={true}
zoomOnPinch={true}
preventScrolling={false}
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
className="opacity-30"
/>
</ReactFlow>
</ReactFlowProvider>
</div>
</>
) : (
<div className="flex items-center justify-center h-full w-full">
<div className="text-center">
<AlertTriangle className="h-12 w-12 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};