Skip to content

Commit

Permalink
ui: improve graph
Browse files Browse the repository at this point in the history
  • Loading branch information
yohamta committed Dec 27, 2024
1 parent ff64bef commit 638d4a6
Show file tree
Hide file tree
Showing 6 changed files with 739 additions and 136 deletions.
9 changes: 5 additions & 4 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,17 @@
"@emotion/styled": "^11.8.1",
"@fontsource/inter": "^5.0.8",
"@fontsource/roboto": "^4.5.7",
"@fortawesome/fontawesome-svg-core": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.1.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@fortawesome/fontawesome-free": "^6.7.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@mui/icons-material": "^6.1.0",
"@mui/material": "^6.1.0",
"@tanstack/react-table": "^8.5.11",
"@types/lodash": "^4.17.7",
"cron-parser": "^4.5.0",
"fontsource-roboto": "^4.0.0",
"mermaid": "^9.1.1",
"mermaid": "^11.4.1",
"moment": "^2.29.3",
"moment-duration-format": "^2.3.2",
"moment-timezone": "^0.5.46",
Expand Down
81 changes: 50 additions & 31 deletions ui/src/components/atoms/Mermaid.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,82 @@
import React, { CSSProperties } from 'react';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import mermaidAPI, { Mermaid } from 'mermaid';
import mermaid from 'mermaid';
import '@fortawesome/fontawesome-free/css/all.min.css';

type Props = {
def: string;
style?: CSSProperties;
};

declare global {
interface Mermaid {
securityLevel: string;
}
}

mermaidAPI.initialize({
securityLevel: 'loose' as Mermaid['mermaidAPI']['SecurityLevel']['Loose'],
// Mermaidの初期設定
mermaid.initialize({
securityLevel: 'loose',
startOnLoad: false,
maxTextSize: 99999999,
flowchart: {
curve: 'basis',
useMaxWidth: false,
htmlLabels: true,
nodeSpacing: 50,
rankSpacing: 50,
},
fontFamily: 'Arial',
logLevel: 4, // ERROR
});

function Mermaid({ def, style = {} }: Props) {
const ref = React.useRef<HTMLDivElement>(null);
const [uniqueId] = React.useState(
() => `mermaid-${Math.random().toString(36).substr(2, 9)}`
);

const mStyle = {
...style,
};

const dStyle: CSSProperties = {
overflowX: 'auto',
padding: '2em',
};
function render() {

const render = async () => {
if (!ref.current) {
return;
}
if (def.startsWith('<')) {
console.error('invalid definition!!');
return;
}
mermaidAPI.render(
'mermaid',
def,
(svgCode, bindFunc) => {
if (ref.current) {
ref.current.innerHTML = svgCode;
}

try {
// Clear previous content
ref.current.innerHTML = '';

// Generate SVG
const { svg, bindFunctions } = await mermaid.render(uniqueId, def);

if (ref.current) {
ref.current.innerHTML = svg;

// Bind event handlers
setTimeout(() => {
if (ref.current) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
bindFunc(ref.current);
if (ref.current && bindFunctions) {
bindFunctions(ref.current);
}
}, 500);
},
ref.current
);
}
function renderWithRetry() {
}
} catch (error: unknown) {
console.error('Mermaid render error:', error);
if (ref.current) {
ref.current.innerHTML = `
<div style="color: red; padding: 10px;">
Error rendering diagram: ${error}
</div>
`;
}
}
};

const renderWithRetry = () => {
try {
render();
} catch (error) {
Expand All @@ -69,17 +85,20 @@ function Mermaid({ def, style = {} }: Props) {
console.error(def);
setTimeout(renderWithRetry, 1);
}
}
};

React.useEffect(() => {
renderWithRetry();
}, [def, ref.current]);
}, [def]);

return (
<div style={dStyle}>
<div className="mermaid" ref={ref} style={mStyle} />
</div>
);
}

// メモ化の条件を維持
export default React.memo(Mermaid, (prev, next) => {
return prev.def == next.def;
return prev.def === next.def;
});
181 changes: 161 additions & 20 deletions ui/src/components/molecules/Graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { Step } from '../../models';
import Mermaid from '../atoms/Mermaid';

type onClickNode = (name: string) => void;

export type FlowchartType = 'TD' | 'LR';

type Props = {
type: 'status' | 'config';
flowchart?: FlowchartType;
steps?: Step[] | Node[];
onClickNode?: onClickNode;
showIcons?: boolean;
};

declare global {
Expand All @@ -20,66 +20,207 @@ declare global {
}
}

function Graph({
const Graph: React.FC<Props> = ({
steps,
flowchart = 'TD',
type = 'status',
onClickNode,
}: Props) {
showIcons = true,
}) => {
// Calculate width based on flowchart type and graph breadth
const width = React.useMemo(() => {
if (!steps) return '100%';

if (flowchart === 'LR') {
return `${steps.length * 240}px`;
} else {
// For TD layout, calculate based on maximum breadth
const maxBreadth = calculateGraphBreadth(steps);
// Assuming each node needs about 200px of width, plus some padding
return `${Math.max(maxBreadth * 300, 600)}px`;
}
}, [steps, flowchart]);

const mermaidStyle = {
display: 'flex',
alignItems: 'flex-center',
justifyContent: 'flex-start',
width: flowchart == 'LR' && steps ? steps.length * 240 + 'px' : '100%',
width: width,
minWidth: '100%',
minHeight: '200px',
padding: '2em',
borderRadius: '0.5em',
backgroundSize: '20px 20px',
};

// Define FontAwesome icons for each status with colors and animations
const statusIcons = {
[NodeStatus.None]:
"<i class='fas fa-circle-notch fa-spin' style='color: #3b82f6; animation: spin 2s linear infinite'></i>",
[NodeStatus.Running]:
"<i class='fas fa-spinner fa-spin' style='color: #22c55e; animation: spin 1s linear infinite'></i>",
[NodeStatus.Error]:
"<i class='fas fa-exclamation-circle' style='color: #ef4444'></i>",
[NodeStatus.Cancel]: "<i class='fas fa-ban' style='color: #ec4899'></i>",
[NodeStatus.Success]:
"<i class='fas fa-check-circle' style='color: #16a34a'></i>",
[NodeStatus.Skipped]:
"<i class='fas fa-forward' style='color: #64748b'></i>",
};

const graph = React.useMemo(() => {
if (!steps) {
return '';
}
const dat = flowchart == 'TD' ? ['flowchart TD;'] : ['flowchart LR;'];
if (!steps) return '';

const dat: string[] = [];
dat.push(`flowchart ${flowchart};`);

if (onClickNode) {
window.onClickMermaidNode = onClickNode;
}

// Track link style indices for individual arrow styling
let linkIndex = 0;
const linkStyles: string[] = [];

const addNodeFn = (step: Step, status: NodeStatus) => {
const id = step.Name.replace(/\s/g, '_');
const c = graphStatusMap[status] || '';
dat.push(`${id}[${step.Name}]${c};`);

// Construct node label with icon if enabled
const icon = showIcons ? statusIcons[status] || '' : '';
const label = `${icon} ${step.Name}`;

// Add node definition
dat.push(`${id}[${label}]${c};`);

// Process dependencies and add connections
if (step.Depends) {
step.Depends.forEach((d) => {
const depId = d.replace(/\s/g, '_');
dat.push(`${depId} --> ${id};`);
if (status === NodeStatus.Error) {
// Dashed line for error state
dat.push(`${depId} -.- ${id};`);
linkStyles.push(
`linkStyle ${linkIndex} stroke:#ef4444,stroke-width:1px,stroke-dasharray:3`
);
} else if (status === NodeStatus.Success) {
// Solid line with success color
dat.push(`${depId} --> ${id};`);
linkStyles.push(
`linkStyle ${linkIndex} stroke:#16a34a,stroke-width:1px`
);
} else {
// Default connection style
dat.push(`${depId} --> ${id};`);
linkStyles.push(
`linkStyle ${linkIndex} stroke:#64748b,stroke-width:1px`
);
}
linkIndex++;
});
}

// Add click handler if onClickNode is provided
if (onClickNode) {
dat.push(`click ${id} onClickMermaidNode`);
}
};
if (type == 'status') {

// Process nodes based on type
if (type === 'status') {
(steps as Node[]).forEach((s) => addNodeFn(s.Step, s.Status));
} else {
(steps as Step[]).forEach((s) => addNodeFn(s, NodeStatus.None));
}

// Define node styles for different states with refined colors
dat.push(
'classDef none color:#4a5568,fill:#f8fafc,stroke:#3b82f6,stroke-width:1.2px,white-space:nowrap,line-height:1.5'
);
dat.push(
'classDef running color:#4a5568,fill:#aaf2aa,stroke:#22c55e,stroke-width:1.2px,white-space:nowrap,line-height:1.5'
);
dat.push(
'classDef error color:#4a5568,fill:#fee2e2,stroke:#ef4444,stroke-width:1.2px,white-space:nowrap,line-height:1.5'
);
dat.push(
'classDef cancel color:#4a5568,fill:#fdf2f8,stroke:#ec4899,stroke-width:1.2px,white-space:nowrap,line-height:1.5'
);
dat.push(
'linkStyle default stroke:#999,stroke-width:1px,fill:none,color:#333'
'classDef done color:#4a5568,fill:#f0fdf4,stroke:#16a34a,stroke-width:1.2px,white-space:nowrap,line-height:1.5'
);
dat.push('classDef none color:#333,fill:white,stroke:lightblue,stroke-width:1.2px');
dat.push('classDef running color:#333,fill:white,stroke:lime,stroke-width:1.2px');
dat.push('classDef error color:#333,fill:white,stroke:red,stroke-width:1.2px');
dat.push('classDef cancel color:#333,fill:white,stroke:pink,stroke-width:1.2px');
dat.push('classDef done color:#333,fill:white,stroke:green,stroke-width:1.2px');
dat.push('classDef skipped color:#333,fill:white,stroke:gray,stroke-width:1.2px');
dat.push(
'classDef skipped color:#4a5568,fill:#f8fafc,stroke:#64748b,stroke-width:1.2px,white-space:nowrap,line-height:1.5'
);

// Add custom link styles
dat.push(...linkStyles);

return dat.join('\n');
}, [steps, onClickNode, flowchart]);
}, [steps, onClickNode, flowchart, showIcons]);

return <Mermaid style={mermaidStyle} def={graph} />;
}
};

// Function to calculate the maximum breadth of the graph
const calculateGraphBreadth = (steps: Step[] | Node[]) => {
// Create a map of nodes and their dependencies
const nodeMap = new Map<string, string[]>();
const parentMap = new Map<string, string[]>();

// Initialize maps
steps.forEach((node) => {
const step = 'Step' in node ? node.Step : node;
nodeMap.set(step.Name, step.Depends || []);
step.Depends?.forEach((dep) => {
if (!parentMap.has(dep)) {
parentMap.set(dep, []);
}
parentMap.get(dep)?.push(step.Name);
});
});

// Calculate levels for each node
const nodeLevels = new Map<string, number>();
const visited = new Set<string>();

const calculateLevel = (nodeName: string, level = 0) => {
if (visited.has(nodeName)) return;
visited.add(nodeName);

nodeLevels.set(nodeName, Math.max(level, nodeLevels.get(nodeName) || 0));

// Process children
const children = parentMap.get(nodeName) || [];
children.forEach((child) => calculateLevel(child, level + 1));
};

// Start from nodes with no dependencies
steps.forEach((node) => {
const step = 'Step' in node ? node.Step : node;
if (!step.Depends || step.Depends.length === 0) {
calculateLevel(step.Name);
}
});

// Count nodes at each level
const levelCounts = new Map<number, number>();
nodeLevels.forEach((level, _) => {
levelCounts.set(level, (levelCounts.get(level) || 0) + 1);
});

// Find maximum breadth
let maxBreadth = 0;
levelCounts.forEach((count) => {
maxBreadth = Math.max(maxBreadth, count);
});

return maxBreadth;
};

export default Graph;

// Map node status to CSS classes for styling
const graphStatusMap = {
[NodeStatus.None]: ':::none',
[NodeStatus.Running]: ':::running',
Expand Down
Loading

0 comments on commit 638d4a6

Please sign in to comment.