* -------------------------------------------------------------------------
* This file is part of the MindStudio project.
* Copyright (c) 2025 Huawei Technologies Co.,Ltd.
*
* MindStudio is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
* -------------------------------------------------------------------------
*/
import React, { useEffect, useRef, useState } from 'react';
import { MIChart } from '@insight/lib/components';
import type { ChartsHandle } from '@insight/lib';
import {
queryFwpBwdTimeline,
type QueryFwpBwdTimelineRes,
} from '../../utils/RequestUtils';
import type { EChartsOption, CustomSeriesRenderItem } from 'echarts';
import { merge } from 'lodash';
import { type Theme, useTheme } from '@emotion/react';
import { clamp, safeStr } from '@insight/lib/utils';
const TEXT_PADDING = 4;
const TEXT_PADDING_X = TEXT_PADDING * 2;
const RANK_HEIGHT = 50;
const CHART_BASE_HEIGHT = 200;
type Ranks = string[];
interface SeriesDataItem {
name: string;
value: Array<string | number>;
itemStyle: {
color: string;
};
}
enum OPERATOR_TYPE {
FP_BP = 'FP/BP',
P2P_OP = 'P2P Op',
}
const PREFIX = 'prefix_';
const addPrefix = (str: string, prefix = PREFIX): string => {
return prefix + str;
};
const removePrefix = (str: string, prefix = PREFIX): string => {
if (str.startsWith(prefix)) {
return str.slice(prefix.length);
}
return str;
};
const nsToMs = (ns: number): number => {
return ns / 1000000;
};
export const colorPalette: Array<keyof Theme['colorPalette']> = [
'deepBlue',
'limeGreen',
'sunsetOrange',
'coralRed',
'tealGreen',
'royalPurple',
'aquaBlue',
'raspberryPink',
'vividBlue',
'vividRed',
'skyBlue',
'amethystPurple',
];
const colorMapping = new Map<string, number>();
export const hashToNumber = (input: string, maxIndex: number): number => {
if (!colorMapping.has(input)) {
colorMapping.set(input, colorMapping.size);
}
if (maxIndex === 0) {
return 0;
}
return (colorMapping.get(input) ?? 0) % maxIndex;
};
const valueMap = {
rank: 0,
start: 1,
end: 2,
duration: 3,
component: 4,
name: 5,
};
const formatData = (dataSource: QueryFwpBwdTimelineRes, theme: Theme): {ranks: Ranks; data: SeriesDataItem[]; flowData: SeriesDataItem[]} => {
const { rankList, flowList } = dataSource ?? {};
const data: SeriesDataItem[] = [];
const ranks = rankList?.map(rank => rank.rank).reverse() ?? [];
rankList?.forEach((rank) => {
rank.componentList.forEach((component) => {
component.traceList.forEach((trace) => {
const { name, cname, start, duration } = trace;
data.push({
name: `${rank.rank}_${name}`,
value: [rank.rank, nsToMs(start), nsToMs((start + duration)), nsToMs(duration), component.component, addPrefix(name)],
itemStyle: {
color: theme.colorPalette[colorPalette[hashToNumber(cname, colorPalette.length)]],
},
});
});
});
});
const flowData: SeriesDataItem[] = flowList.map((flow) => {
return {
name: '',
value: [flow[0].rankId, nsToMs(flow[0].startTime), flow[1].rankId, nsToMs(flow[1].startTime), flow[0].opName, flow[1].opName],
itemStyle: {
color: theme.colorPalette.tealGreen,
},
};
});
return { ranks, data, flowData };
};
const renderLine: CustomSeriesRenderItem = (params, api) => {
const start = api.coord([api.value(1), api.value(0)]);
const end = api.coord([api.value(3), api.value(2)]);
const height = ((api.size?.([0, 1]) as number[])?.[1] ?? 0) * 0.3;
const offset = height / 2;
const startRectY = Math.round(start[1] + offset);
const endRectY = Math.round(end[1] + offset);
const controlPoint = [
(start[0] + end[0]) / 2,
Math.min(start[1], end[1]) - height / 1.5,
];
const bezierCurve = {
x1: start[0],
y1: startRectY,
x2: end[0],
y2: endRectY,
cpx1: controlPoint[0],
cpy1: controlPoint[1],
};
return {
type: 'group',
children: [
{
type: 'bezierCurve',
transition: ['shape'],
shape: bezierCurve,
style: {
stroke: api.visual('color'),
fill: 'none',
lineWidth: 1,
},
},
],
};
};
const renderRect: CustomSeriesRenderItem = (params, api) => {
const { start: vStart, end: vEnd, component: vComponent, name: vName } = valueMap;
const categoryIndex = api.value(0);
const name = removePrefix(api.value(vName) as string);
const start = api.coord([api.value(vStart), categoryIndex]);
const end = api.coord([api.value(vEnd), categoryIndex]);
const gridItemHeight = (api.size?.([0, 1]) as number[])[1];
const offset = gridItemHeight * 0.04;
const rectWidth = end[0] - start[0];
const rectHeight = gridItemHeight * 0.3;
const rectY = api.value(vComponent) === OPERATOR_TYPE.FP_BP ? Math.round(start[1] - rectHeight - offset) : Math.round(start[1] + offset);
const textWidth = rectWidth > TEXT_PADDING_X ? Math.floor(rectWidth - TEXT_PADDING_X) : rectWidth;
return {
type: 'rect',
transition: ['shape'],
shape: {
x: start[0],
y: rectY,
width: rectWidth,
height: rectHeight,
},
textContent: {
type: 'text',
style: {
text: name,
fill: '#ffffff',
overflow: 'truncate',
width: textWidth,
},
},
textConfig: {
position: 'inside',
inside: true,
local: true,
},
style: {
fill: api.visual('color'),
},
emphasis: {
style: {
shadowBlur: 16,
shadowColor: '#9d9da9',
},
},
};
};
const baseOptions: EChartsOption = {
tooltip: {
formatter: function (params: any): string {
const name = removePrefix(params.value[5]);
return `
<div class="formatter">
<div class="row">${params.marker} ${safeStr(name)}</div>
<div class="row">
<div class="label">Rank ID</div>
<div class="value">${safeStr(params.value[0])}</div>
</div>
<div class="row">
<div class="label">Start Time</div>
<div class="value">${safeStr(params.value[1])} ms</div>
</div>
<div class="row">
<div class="label">Duration</div>
<div class="value">${safeStr(params.value[3])} ms</div>
</div>
</div>
`;
},
},
dataZoom: [
{
type: 'slider',
filterMode: 'weakFilter',
bottom: 10,
labelFormatter: '',
height: 20,
},
{
type: 'inside',
filterMode: 'weakFilter',
zoomOnMouseWheel: 'ctrl',
moveOnMouseMove: 'ctrl',
moveOnMouseWheel: 'shift',
},
],
xAxis: {
name: 'Time(ms)',
scale: true,
},
yAxis: {
name: 'Rank ID',
},
series: [
{
z: 0,
type: 'custom',
renderItem: renderRect,
itemStyle: {
opacity: 0.8,
},
encode: {
x: [1, 2],
y: 0,
},
clip: true,
},
{
z: 1,
type: 'custom',
renderItem: renderLine,
itemStyle: {
opacity: 0.8,
},
encode: {
x: [1, 3],
y: [0, 2],
},
clip: true,
tooltip: {
formatter: function (params: any): string {
return `
<div class="formatter">
<div class="row">${params.marker} ${safeStr(params.name)}</div>
<div class="row">
<div class="label">Send Op</div>
<div class="value">${safeStr(params.value[4])}</div>
</div>
<div class="row">
<div class="label">Receive Op</div>
<div class="value">${safeStr(params.value[5])}</div>
</div>
</div>
`;
},
},
},
],
};
interface FlowChartProps {
step: string;
stage: string;
clusterPath: string;
}
export const FlowChart = (props: FlowChartProps): JSX.Element => {
const { step, stage, clusterPath } = props;
const chartRef = useRef<ChartsHandle>(null);
const [chartOptions, setChartOptions] = useState<EChartsOption>({});
const theme = useTheme();
const scrollContainer = document.querySelector('.mi-page-content');
const canvasEl = chartRef.current?.getChartDom()?.querySelector('canvas');
const [loading, setLoading] = useState(true);
const [chartHeight, setChartHeight] = useState('400px');
const syncScroll = (e: WheelEvent): void => {
if ((e.target as HTMLElement).tagName !== 'CANVAS') {
return;
}
if (!e.ctrlKey && !e.shiftKey) {
scrollContainer?.scrollBy(0, e.deltaY);
}
};
if (canvasEl?.width === 100) {
chartRef.current?.getInstance()?.resize();
}
useEffect(() => {
const fetchData = async (): Promise<void> => {
setLoading(true);
if (!step || !stage) {
return;
}
const dataSource = await queryFwpBwdTimeline({
stepId: step,
stageId: stage,
clusterPath,
}).finally(() => {
setLoading(false);
});
const { data, ranks, flowData } = formatData(dataSource, theme);
const height = Math.round(clamp((ranks.length * RANK_HEIGHT) + CHART_BASE_HEIGHT, 200, 1000));
setChartHeight(`${height}px`);
setChartOptions(merge({}, baseOptions, {
yAxis: {
data: ranks,
},
series: [
{ data },
{ data: flowData },
],
}));
};
fetchData();
}, [step, stage, clusterPath]);
useEffect(() => {
const chartDom = chartRef.current?.getChartDom();
chartDom?.addEventListener('wheel', syncScroll, true);
return (): void => {
chartDom?.removeEventListener('wheel', syncScroll, true);
};
}, []);
return <MIChart ref={chartRef} height={chartHeight} loading={loading} options={chartOptions}
onEvents={
{
mouseover(params): void {
// 如果是节点 则直接返回
if (params.seriesIndex === 0) {
return;
}
const sourceName = `${params.value[0]}_${params.value[4]}`;
const targetName = `${params.value[2]}_${params.value[5]}`;
// 节点高亮
chartRef.current?.chartInstance?.dispatchAction({
type: 'highlight',
seriesIndex: 0,
name: [sourceName, targetName],
});
},
mouseout(params): void {
if (params.seriesIndex === 0) {
return;
}
chartRef.current?.chartInstance?.dispatchAction({
type: 'downplay',
});
},
}
}
/>;
};