feat: add interactive ProcessDiagram SVG component (sub-project 1/3)
New interactive route diagram component with SVG rendering using server-computed ELK layout coordinates. TIBCO BW5-inspired top-bar card node style with zoom/pan, hover toolbars, config badges, and error handler sections below the main flow. Backend: add direction query parameter (LR/TB) to diagram render endpoints, defaulting to left-to-right layout. Frontend: 14-file ProcessDiagram component in ui/src/components/ with DiagramNode, CompoundNode, DiagramEdge, ConfigBadge, NodeToolbar, ErrorSection, ZoomControls, and supporting hooks. Dev test page at /dev/diagram for validation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -12,19 +12,32 @@ export interface DiagramNode {
|
||||
children?: DiagramNode[];
|
||||
}
|
||||
|
||||
interface DiagramLayout {
|
||||
export interface DiagramEdge {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
label?: string;
|
||||
points: number[][];
|
||||
}
|
||||
|
||||
export interface DiagramLayout {
|
||||
width?: number;
|
||||
height?: number;
|
||||
nodes?: DiagramNode[];
|
||||
edges?: Array<{ from?: string; to?: string }>;
|
||||
edges?: DiagramEdge[];
|
||||
}
|
||||
|
||||
export function useDiagramLayout(contentHash: string | null) {
|
||||
export function useDiagramLayout(
|
||||
contentHash: string | null,
|
||||
direction: 'LR' | 'TB' = 'LR',
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['diagrams', 'layout', contentHash],
|
||||
queryKey: ['diagrams', 'layout', contentHash, direction],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/diagrams/{contentHash}/render', {
|
||||
params: { path: { contentHash: contentHash! } },
|
||||
params: {
|
||||
path: { contentHash: contentHash! },
|
||||
query: { direction },
|
||||
},
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (error) throw new Error('Failed to load diagram layout');
|
||||
@@ -34,15 +47,19 @@ export function useDiagramLayout(contentHash: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiagramByRoute(application: string | undefined, routeId: string | undefined) {
|
||||
export function useDiagramByRoute(
|
||||
application: string | undefined,
|
||||
routeId: string | undefined,
|
||||
direction: 'LR' | 'TB' = 'LR',
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['diagrams', 'byRoute', application, routeId],
|
||||
queryKey: ['diagrams', 'byRoute', application, routeId, direction],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/diagrams', {
|
||||
params: { query: { application: application!, routeId: routeId! } },
|
||||
params: { query: { application: application!, routeId: routeId!, direction } },
|
||||
});
|
||||
if (error) throw new Error('Failed to load diagram for route');
|
||||
return data!;
|
||||
return data as DiagramLayout;
|
||||
},
|
||||
enabled: !!application && !!routeId,
|
||||
});
|
||||
|
||||
7
ui/src/api/schema.d.ts
vendored
7
ui/src/api/schema.d.ts
vendored
@@ -3642,6 +3642,8 @@ export interface operations {
|
||||
query: {
|
||||
application: string;
|
||||
routeId: string;
|
||||
/** @description Layout direction: LR (left-to-right) or TB (top-to-bottom) */
|
||||
direction?: "LR" | "TB";
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3671,7 +3673,10 @@ export interface operations {
|
||||
};
|
||||
renderDiagram: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
/** @description Layout direction: LR (left-to-right) or TB (top-to-bottom) */
|
||||
direction?: "LR" | "TB";
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
contentHash: string;
|
||||
|
||||
82
ui/src/components/ProcessDiagram/CompoundNode.tsx
Normal file
82
ui/src/components/ProcessDiagram/CompoundNode.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
|
||||
import type { NodeConfig } from './types';
|
||||
import { colorForType } from './node-colors';
|
||||
import { DiagramNode } from './DiagramNode';
|
||||
|
||||
const HEADER_HEIGHT = 22;
|
||||
const CORNER_RADIUS = 4;
|
||||
|
||||
interface CompoundNodeProps {
|
||||
node: DiagramNodeType;
|
||||
selectedNodeId?: string;
|
||||
hoveredNodeId: string | null;
|
||||
nodeConfigs?: Map<string, NodeConfig>;
|
||||
onNodeClick: (nodeId: string) => void;
|
||||
onNodeEnter: (nodeId: string) => void;
|
||||
onNodeLeave: () => void;
|
||||
}
|
||||
|
||||
export function CompoundNode({
|
||||
node, selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||
onNodeClick, onNodeEnter, onNodeLeave,
|
||||
}: CompoundNodeProps) {
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
const w = node.width ?? 200;
|
||||
const h = node.height ?? 100;
|
||||
const color = colorForType(node.type);
|
||||
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
|
||||
const label = node.label ? `${typeName}: ${node.label}` : typeName;
|
||||
|
||||
return (
|
||||
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
|
||||
{/* Container body */}
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={w}
|
||||
height={h}
|
||||
rx={CORNER_RADIUS}
|
||||
fill="white"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
|
||||
{/* Colored header bar */}
|
||||
<rect x={0} y={0} width={w} height={HEADER_HEIGHT} rx={CORNER_RADIUS} fill={color} />
|
||||
<rect x={CORNER_RADIUS} y={CORNER_RADIUS} width={w - CORNER_RADIUS * 2} height={HEADER_HEIGHT - CORNER_RADIUS} fill={color} />
|
||||
|
||||
{/* Header label */}
|
||||
<text
|
||||
x={w / 2}
|
||||
y={HEADER_HEIGHT / 2 + 4}
|
||||
fill="white"
|
||||
fontSize={10}
|
||||
fontWeight={600}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
|
||||
{/* Children nodes (positioned relative to compound) */}
|
||||
{node.children?.map(child => (
|
||||
<DiagramNode
|
||||
key={child.id}
|
||||
node={{
|
||||
...child,
|
||||
// Children have absolute coordinates from the backend,
|
||||
// but since we're inside the compound's translate, subtract parent offset
|
||||
x: (child.x ?? 0) - x,
|
||||
y: (child.y ?? 0) - y,
|
||||
}}
|
||||
isHovered={hoveredNodeId === child.id}
|
||||
isSelected={selectedNodeId === child.id}
|
||||
config={child.id ? nodeConfigs?.get(child.id) : undefined}
|
||||
onClick={() => child.id && onNodeClick(child.id)}
|
||||
onMouseEnter={() => child.id && onNodeEnter(child.id)}
|
||||
onMouseLeave={onNodeLeave}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
48
ui/src/components/ProcessDiagram/ConfigBadge.tsx
Normal file
48
ui/src/components/ProcessDiagram/ConfigBadge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { NodeConfig } from './types';
|
||||
|
||||
const BADGE_HEIGHT = 14;
|
||||
const BADGE_RADIUS = 7;
|
||||
const BADGE_FONT_SIZE = 8;
|
||||
const BADGE_GAP = 4;
|
||||
|
||||
interface ConfigBadgeProps {
|
||||
nodeWidth: number;
|
||||
config: NodeConfig;
|
||||
}
|
||||
|
||||
export function ConfigBadge({ nodeWidth, config }: ConfigBadgeProps) {
|
||||
const badges: { label: string; color: string }[] = [];
|
||||
if (config.tapExpression) badges.push({ label: 'TAP', color: '#7C3AED' });
|
||||
if (config.traceEnabled) badges.push({ label: 'TRACE', color: '#1A7F8E' });
|
||||
if (badges.length === 0) return null;
|
||||
|
||||
let xOffset = nodeWidth;
|
||||
return (
|
||||
<g className="config-badges">
|
||||
{badges.map((badge, i) => {
|
||||
const textWidth = badge.label.length * 5.5 + 8;
|
||||
xOffset -= textWidth + (i > 0 ? BADGE_GAP : 0);
|
||||
return (
|
||||
<g key={badge.label} transform={`translate(${xOffset}, ${-BADGE_HEIGHT / 2 - 2})`}>
|
||||
<rect
|
||||
width={textWidth}
|
||||
height={BADGE_HEIGHT}
|
||||
rx={BADGE_RADIUS}
|
||||
fill={badge.color}
|
||||
/>
|
||||
<text
|
||||
x={textWidth / 2}
|
||||
y={BADGE_HEIGHT / 2 + 3}
|
||||
fill="white"
|
||||
fontSize={BADGE_FONT_SIZE}
|
||||
fontWeight={600}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{badge.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
49
ui/src/components/ProcessDiagram/DiagramEdge.tsx
Normal file
49
ui/src/components/ProcessDiagram/DiagramEdge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
|
||||
|
||||
interface DiagramEdgeProps {
|
||||
edge: DiagramEdgeType;
|
||||
offsetY?: number;
|
||||
}
|
||||
|
||||
export function DiagramEdge({ edge, offsetY = 0 }: DiagramEdgeProps) {
|
||||
const pts = edge.points;
|
||||
if (!pts || pts.length < 2) return null;
|
||||
|
||||
// Build SVG path: move to first point, then cubic bezier or line to rest
|
||||
let d = `M ${pts[0][0]} ${pts[0][1] + offsetY}`;
|
||||
|
||||
if (pts.length === 2) {
|
||||
d += ` L ${pts[1][0]} ${pts[1][1] + offsetY}`;
|
||||
} else if (pts.length === 4) {
|
||||
// 4 points: start, control1, control2, end → cubic bezier
|
||||
d += ` C ${pts[1][0]} ${pts[1][1] + offsetY}, ${pts[2][0]} ${pts[2][1] + offsetY}, ${pts[3][0]} ${pts[3][1] + offsetY}`;
|
||||
} else {
|
||||
// Multiple points: connect with line segments through intermediate points
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
d += ` L ${pts[i][0]} ${pts[i][1] + offsetY}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<g className="diagram-edge">
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="#9CA3AF"
|
||||
strokeWidth={1.5}
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
{edge.label && pts.length >= 2 && (
|
||||
<text
|
||||
x={(pts[0][0] + pts[pts.length - 1][0]) / 2}
|
||||
y={(pts[0][1] + pts[pts.length - 1][1]) / 2 + offsetY - 6}
|
||||
fill="#9C9184"
|
||||
fontSize={9}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{edge.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
93
ui/src/components/ProcessDiagram/DiagramNode.tsx
Normal file
93
ui/src/components/ProcessDiagram/DiagramNode.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
|
||||
import type { NodeConfig } from './types';
|
||||
import { colorForType, iconForType } from './node-colors';
|
||||
import { ConfigBadge } from './ConfigBadge';
|
||||
|
||||
const TOP_BAR_HEIGHT = 6;
|
||||
const CORNER_RADIUS = 4;
|
||||
|
||||
interface DiagramNodeProps {
|
||||
node: DiagramNodeType;
|
||||
isHovered: boolean;
|
||||
isSelected: boolean;
|
||||
config?: NodeConfig;
|
||||
onClick: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
|
||||
export function DiagramNode({
|
||||
node, isHovered, isSelected, config, onClick, onMouseEnter, onMouseLeave,
|
||||
}: DiagramNodeProps) {
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
const w = node.width ?? 120;
|
||||
const h = node.height ?? 40;
|
||||
const color = colorForType(node.type);
|
||||
const icon = iconForType(node.type);
|
||||
|
||||
// Extract label parts: type name and detail
|
||||
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
|
||||
const detail = node.label || '';
|
||||
|
||||
return (
|
||||
<g
|
||||
data-node-id={node.id}
|
||||
transform={`translate(${x}, ${y})`}
|
||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{/* Selection ring */}
|
||||
{isSelected && (
|
||||
<rect
|
||||
x={-2}
|
||||
y={-2}
|
||||
width={w + 4}
|
||||
height={h + 4}
|
||||
rx={CORNER_RADIUS + 2}
|
||||
fill="none"
|
||||
stroke="#C6820E"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Card background */}
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={w}
|
||||
height={h}
|
||||
rx={CORNER_RADIUS}
|
||||
fill={isHovered ? '#F5F0EA' : 'white'}
|
||||
stroke={isHovered || isSelected ? color : '#E4DFD8'}
|
||||
strokeWidth={isHovered || isSelected ? 1.5 : 1}
|
||||
/>
|
||||
|
||||
{/* Colored top bar */}
|
||||
<rect x={0} y={0} width={w} height={TOP_BAR_HEIGHT} rx={CORNER_RADIUS} fill={color} />
|
||||
<rect x={CORNER_RADIUS} y={0} width={w - CORNER_RADIUS * 2} height={TOP_BAR_HEIGHT} fill={color} />
|
||||
|
||||
{/* Icon */}
|
||||
<text x={14} y={h / 2 + 6} fill={color} fontSize={14}>
|
||||
{icon}
|
||||
</text>
|
||||
|
||||
{/* Type name */}
|
||||
<text x={32} y={h / 2 + 1} fill="#1A1612" fontSize={11} fontWeight={600}>
|
||||
{typeName}
|
||||
</text>
|
||||
|
||||
{/* Detail label (truncated) */}
|
||||
{detail && detail !== typeName && (
|
||||
<text x={32} y={h / 2 + 14} fill="#5C5347" fontSize={10}>
|
||||
{detail.length > 22 ? detail.slice(0, 20) + '...' : detail}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Config badges */}
|
||||
{config && <ConfigBadge nodeWidth={w} config={config} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
93
ui/src/components/ProcessDiagram/ErrorSection.tsx
Normal file
93
ui/src/components/ProcessDiagram/ErrorSection.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { DiagramSection } from './types';
|
||||
import type { NodeConfig } from './types';
|
||||
import { DiagramEdge } from './DiagramEdge';
|
||||
import { DiagramNode } from './DiagramNode';
|
||||
import { CompoundNode } from './CompoundNode';
|
||||
import { isCompoundType } from './node-colors';
|
||||
|
||||
interface ErrorSectionProps {
|
||||
section: DiagramSection;
|
||||
totalWidth: number;
|
||||
selectedNodeId?: string;
|
||||
hoveredNodeId: string | null;
|
||||
nodeConfigs?: Map<string, NodeConfig>;
|
||||
onNodeClick: (nodeId: string) => void;
|
||||
onNodeEnter: (nodeId: string) => void;
|
||||
onNodeLeave: () => void;
|
||||
}
|
||||
|
||||
export function ErrorSection({
|
||||
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||
onNodeClick, onNodeEnter, onNodeLeave,
|
||||
}: ErrorSectionProps) {
|
||||
return (
|
||||
<g transform={`translate(0, ${section.offsetY})`}>
|
||||
{/* Divider line */}
|
||||
<line
|
||||
x1={0}
|
||||
y1={0}
|
||||
x2={totalWidth}
|
||||
y2={0}
|
||||
stroke="#C0392B"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="6 3"
|
||||
opacity={0.5}
|
||||
/>
|
||||
|
||||
{/* Section label */}
|
||||
<text x={8} y={-6} fill="#C0392B" fontSize={11} fontWeight={600}>
|
||||
{section.label}
|
||||
</text>
|
||||
|
||||
{/* Subtle red tint background */}
|
||||
<rect
|
||||
x={0}
|
||||
y={4}
|
||||
width={totalWidth}
|
||||
height={300}
|
||||
fill="#C0392B"
|
||||
opacity={0.03}
|
||||
rx={4}
|
||||
/>
|
||||
|
||||
{/* Edges */}
|
||||
<g className="edges">
|
||||
{section.edges.map((edge, i) => (
|
||||
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Nodes */}
|
||||
<g className="nodes">
|
||||
{section.nodes.map(node => {
|
||||
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
|
||||
return (
|
||||
<CompoundNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedNodeId={selectedNodeId}
|
||||
hoveredNodeId={hoveredNodeId}
|
||||
nodeConfigs={nodeConfigs}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeEnter={onNodeEnter}
|
||||
onNodeLeave={onNodeLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DiagramNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
isHovered={hoveredNodeId === node.id}
|
||||
isSelected={selectedNodeId === node.id}
|
||||
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
||||
onClick={() => node.id && onNodeClick(node.id)}
|
||||
onMouseEnter={() => node.id && onNodeEnter(node.id)}
|
||||
onMouseLeave={onNodeLeave}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
118
ui/src/components/ProcessDiagram/NodeToolbar.tsx
Normal file
118
ui/src/components/ProcessDiagram/NodeToolbar.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { NodeAction } from './types';
|
||||
|
||||
const TOOLBAR_HEIGHT = 28;
|
||||
const TOOLBAR_WIDTH = 140;
|
||||
const HIDE_DELAY = 150;
|
||||
|
||||
interface NodeToolbarProps {
|
||||
nodeId: string;
|
||||
nodeX: number;
|
||||
nodeY: number;
|
||||
nodeWidth: number;
|
||||
onAction: (nodeId: string, action: NodeAction) => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
|
||||
const ACTIONS: { label: string; icon: string; action: NodeAction; title: string }[] = [
|
||||
{ label: 'Inspect', icon: '\uD83D\uDD0D', action: 'inspect', title: 'Inspect node' },
|
||||
{ label: 'Trace', icon: 'T', action: 'toggle-trace', title: 'Toggle tracing' },
|
||||
{ label: 'Tap', icon: '\u270E', action: 'configure-tap', title: 'Configure tap' },
|
||||
{ label: 'More', icon: '\u22EF', action: 'copy-id', title: 'Copy processor ID' },
|
||||
];
|
||||
|
||||
export function NodeToolbar({
|
||||
nodeId, nodeX, nodeY, nodeWidth, onAction, onMouseEnter, onMouseLeave,
|
||||
}: NodeToolbarProps) {
|
||||
const x = nodeX + (nodeWidth - TOOLBAR_WIDTH) / 2;
|
||||
const y = nodeY - TOOLBAR_HEIGHT - 6;
|
||||
|
||||
return (
|
||||
<foreignObject
|
||||
x={x}
|
||||
y={y}
|
||||
width={TOOLBAR_WIDTH}
|
||||
height={TOOLBAR_HEIGHT}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
height: TOOLBAR_HEIGHT,
|
||||
background: 'rgba(26, 22, 18, 0.92)',
|
||||
borderRadius: '6px',
|
||||
padding: '0 8px',
|
||||
}}
|
||||
>
|
||||
{ACTIONS.map(a => (
|
||||
<button
|
||||
key={a.action}
|
||||
title={a.title}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction(nodeId, a.action);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{a.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</foreignObject>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hook to manage toolbar visibility with hide delay */
|
||||
export function useToolbarHover() {
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const onNodeEnter = useCallback((nodeId: string) => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
setHoveredNodeId(nodeId);
|
||||
}, []);
|
||||
|
||||
const onNodeLeave = useCallback(() => {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setHoveredNodeId(null);
|
||||
hideTimer.current = null;
|
||||
}, HIDE_DELAY);
|
||||
}, []);
|
||||
|
||||
const onToolbarEnter = useCallback(() => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onToolbarLeave = useCallback(() => {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setHoveredNodeId(null);
|
||||
hideTimer.current = null;
|
||||
}, HIDE_DELAY);
|
||||
}, []);
|
||||
|
||||
return { hoveredNodeId, onNodeEnter, onNodeLeave, onToolbarEnter, onToolbarLeave };
|
||||
}
|
||||
79
ui/src/components/ProcessDiagram/ProcessDiagram.module.css
Normal file
79
ui/src/components/ProcessDiagram/ProcessDiagram.module.css
Normal file
@@ -0,0 +1,79 @@
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface, #FFFFFF);
|
||||
border: 1px solid var(--border, #E4DFD8);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
color: var(--text-muted, #9C9184);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
color: var(--error, #C0392B);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.zoomControls {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--bg-surface, #FFFFFF);
|
||||
border: 1px solid var(--border, #E4DFD8);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-md, 0 2px 8px rgba(44, 37, 32, 0.08));
|
||||
}
|
||||
|
||||
.zoomBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #1A1612);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
}
|
||||
|
||||
.zoomBtn:hover {
|
||||
background: var(--bg-hover, #F5F0EA);
|
||||
}
|
||||
|
||||
.zoomLevel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #9C9184);
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
221
ui/src/components/ProcessDiagram/ProcessDiagram.tsx
Normal file
221
ui/src/components/ProcessDiagram/ProcessDiagram.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import type { ProcessDiagramProps } from './types';
|
||||
import { useDiagramData } from './useDiagramData';
|
||||
import { useZoomPan } from './useZoomPan';
|
||||
import { useToolbarHover, NodeToolbar } from './NodeToolbar';
|
||||
import { DiagramNode } from './DiagramNode';
|
||||
import { DiagramEdge } from './DiagramEdge';
|
||||
import { CompoundNode } from './CompoundNode';
|
||||
import { ErrorSection } from './ErrorSection';
|
||||
import { ZoomControls } from './ZoomControls';
|
||||
import { isCompoundType } from './node-colors';
|
||||
import styles from './ProcessDiagram.module.css';
|
||||
|
||||
const PADDING = 40;
|
||||
|
||||
export function ProcessDiagram({
|
||||
application,
|
||||
routeId,
|
||||
direction = 'LR',
|
||||
selectedNodeId,
|
||||
onNodeSelect,
|
||||
onNodeAction,
|
||||
nodeConfigs,
|
||||
className,
|
||||
}: ProcessDiagramProps) {
|
||||
const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData(
|
||||
application, routeId, direction,
|
||||
);
|
||||
|
||||
const zoom = useZoomPan();
|
||||
const toolbar = useToolbarHover();
|
||||
|
||||
const contentWidth = totalWidth + PADDING * 2;
|
||||
const contentHeight = totalHeight + PADDING * 2;
|
||||
|
||||
// Fit to view on first data load
|
||||
useEffect(() => {
|
||||
if (totalWidth > 0 && totalHeight > 0) {
|
||||
zoom.fitToView(contentWidth, contentHeight);
|
||||
}
|
||||
}, [totalWidth, totalHeight]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(nodeId: string) => { onNodeSelect?.(nodeId); },
|
||||
[onNodeSelect],
|
||||
);
|
||||
|
||||
const handleNodeAction = useCallback(
|
||||
(nodeId: string, action: import('./types').NodeAction) => {
|
||||
if (action === 'inspect') onNodeSelect?.(nodeId);
|
||||
onNodeAction?.(nodeId, action);
|
||||
},
|
||||
[onNodeSelect, onNodeAction],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onNodeSelect?.('');
|
||||
return;
|
||||
}
|
||||
zoom.onKeyDown(e, contentWidth, contentHeight);
|
||||
},
|
||||
[onNodeSelect, zoom, contentWidth, contentHeight],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`${styles.container} ${className ?? ''}`}>
|
||||
<div className={styles.loading}>Loading diagram...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`${styles.container} ${className ?? ''}`}>
|
||||
<div className={styles.error}>Failed to load diagram</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<div className={`${styles.container} ${className ?? ''}`}>
|
||||
<div className={styles.loading}>No diagram data available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mainSection = sections[0];
|
||||
const errorSections = sections.slice(1);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={zoom.containerRef}
|
||||
className={`${styles.container} ${className ?? ''}`}
|
||||
>
|
||||
<svg
|
||||
className={styles.svg}
|
||||
viewBox={zoom.viewBox(contentWidth, contentHeight)}
|
||||
onWheel={zoom.onWheel}
|
||||
onPointerDown={zoom.onPointerDown}
|
||||
onPointerMove={zoom.onPointerMove}
|
||||
onPointerUp={zoom.onPointerUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
onClick={() => onNodeSelect?.('')}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="8"
|
||||
markerHeight="6"
|
||||
refX="7"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 8 3, 0 6" fill="#9CA3AF" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(${PADDING}, ${PADDING})`}>
|
||||
{/* Main section edges */}
|
||||
<g className="edges">
|
||||
{mainSection.edges.map((edge, i) => (
|
||||
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Main section nodes */}
|
||||
<g className="nodes">
|
||||
{mainSection.nodes.map(node => {
|
||||
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
|
||||
return (
|
||||
<CompoundNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedNodeId={selectedNodeId}
|
||||
hoveredNodeId={toolbar.hoveredNodeId}
|
||||
nodeConfigs={nodeConfigs}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeEnter={toolbar.onNodeEnter}
|
||||
onNodeLeave={toolbar.onNodeLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DiagramNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
isHovered={toolbar.hoveredNodeId === node.id}
|
||||
isSelected={selectedNodeId === node.id}
|
||||
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
||||
onClick={() => node.id && handleNodeClick(node.id)}
|
||||
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
|
||||
onMouseLeave={toolbar.onNodeLeave}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Toolbar for hovered node */}
|
||||
{toolbar.hoveredNodeId && onNodeAction && (() => {
|
||||
const hNode = findNodeById(sections, toolbar.hoveredNodeId!);
|
||||
if (!hNode) return null;
|
||||
return (
|
||||
<NodeToolbar
|
||||
nodeId={toolbar.hoveredNodeId!}
|
||||
nodeX={hNode.x ?? 0}
|
||||
nodeY={hNode.y ?? 0}
|
||||
nodeWidth={hNode.width ?? 120}
|
||||
onAction={handleNodeAction}
|
||||
onMouseEnter={toolbar.onToolbarEnter}
|
||||
onMouseLeave={toolbar.onToolbarLeave}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Error handler sections */}
|
||||
{errorSections.map((section, i) => (
|
||||
<ErrorSection
|
||||
key={`error-${i}`}
|
||||
section={section}
|
||||
totalWidth={totalWidth}
|
||||
selectedNodeId={selectedNodeId}
|
||||
hoveredNodeId={toolbar.hoveredNodeId}
|
||||
nodeConfigs={nodeConfigs}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeEnter={toolbar.onNodeEnter}
|
||||
onNodeLeave={toolbar.onNodeLeave}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<ZoomControls
|
||||
onZoomIn={zoom.zoomIn}
|
||||
onZoomOut={zoom.zoomOut}
|
||||
onFitToView={() => zoom.fitToView(contentWidth, contentHeight)}
|
||||
scale={zoom.state.scale}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findNodeById(
|
||||
sections: import('./types').DiagramSection[],
|
||||
nodeId: string,
|
||||
): import('../../api/queries/diagrams').DiagramNode | undefined {
|
||||
for (const section of sections) {
|
||||
for (const node of section.nodes) {
|
||||
if (node.id === nodeId) return node;
|
||||
if (node.children) {
|
||||
const child = node.children.find(c => c.id === nodeId);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
19
ui/src/components/ProcessDiagram/ZoomControls.tsx
Normal file
19
ui/src/components/ProcessDiagram/ZoomControls.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import styles from './ProcessDiagram.module.css';
|
||||
|
||||
interface ZoomControlsProps {
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onFitToView: () => void;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function ZoomControls({ onZoomIn, onZoomOut, onFitToView, scale }: ZoomControlsProps) {
|
||||
return (
|
||||
<div className={styles.zoomControls}>
|
||||
<button className={styles.zoomBtn} onClick={onZoomIn} title="Zoom in (+)">+</button>
|
||||
<span className={styles.zoomLevel}>{Math.round(scale * 100)}%</span>
|
||||
<button className={styles.zoomBtn} onClick={onZoomOut} title="Zoom out (-)">−</button>
|
||||
<button className={styles.zoomBtn} onClick={onFitToView} title="Fit to view (0)">⊡</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
ui/src/components/ProcessDiagram/index.ts
Normal file
2
ui/src/components/ProcessDiagram/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ProcessDiagram } from './ProcessDiagram';
|
||||
export type { ProcessDiagramProps, NodeAction, NodeConfig } from './types';
|
||||
93
ui/src/components/ProcessDiagram/node-colors.ts
Normal file
93
ui/src/components/ProcessDiagram/node-colors.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/** Maps backend NodeType strings to CSS color values using design system tokens. */
|
||||
|
||||
const ENDPOINT_COLOR = '#1A7F8E'; // --running
|
||||
const PROCESSOR_COLOR = '#C6820E'; // --amber
|
||||
const TARGET_COLOR = '#3D7C47'; // --success
|
||||
const EIP_COLOR = '#7C3AED'; // --purple
|
||||
const ERROR_COLOR = '#C0392B'; // --error
|
||||
const CROSS_ROUTE_COLOR = '#06B6D4';
|
||||
const DEFAULT_COLOR = '#9C9184'; // --text-muted
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
ENDPOINT: ENDPOINT_COLOR,
|
||||
|
||||
PROCESSOR: PROCESSOR_COLOR,
|
||||
BEAN: PROCESSOR_COLOR,
|
||||
LOG: PROCESSOR_COLOR,
|
||||
SET_HEADER: PROCESSOR_COLOR,
|
||||
SET_BODY: PROCESSOR_COLOR,
|
||||
TRANSFORM: PROCESSOR_COLOR,
|
||||
MARSHAL: PROCESSOR_COLOR,
|
||||
UNMARSHAL: PROCESSOR_COLOR,
|
||||
|
||||
TO: TARGET_COLOR,
|
||||
TO_DYNAMIC: TARGET_COLOR,
|
||||
DIRECT: TARGET_COLOR,
|
||||
SEDA: TARGET_COLOR,
|
||||
|
||||
EIP_CHOICE: EIP_COLOR,
|
||||
EIP_WHEN: EIP_COLOR,
|
||||
EIP_OTHERWISE: EIP_COLOR,
|
||||
EIP_SPLIT: EIP_COLOR,
|
||||
EIP_MULTICAST: EIP_COLOR,
|
||||
EIP_LOOP: EIP_COLOR,
|
||||
EIP_AGGREGATE: EIP_COLOR,
|
||||
EIP_FILTER: EIP_COLOR,
|
||||
EIP_RECIPIENT_LIST: EIP_COLOR,
|
||||
EIP_ROUTING_SLIP: EIP_COLOR,
|
||||
EIP_DYNAMIC_ROUTER: EIP_COLOR,
|
||||
EIP_LOAD_BALANCE: EIP_COLOR,
|
||||
EIP_THROTTLE: EIP_COLOR,
|
||||
EIP_DELAY: EIP_COLOR,
|
||||
EIP_IDEMPOTENT_CONSUMER: EIP_COLOR,
|
||||
EIP_CIRCUIT_BREAKER: EIP_COLOR,
|
||||
EIP_PIPELINE: EIP_COLOR,
|
||||
|
||||
ERROR_HANDLER: ERROR_COLOR,
|
||||
ON_EXCEPTION: ERROR_COLOR,
|
||||
TRY_CATCH: ERROR_COLOR,
|
||||
DO_TRY: ERROR_COLOR,
|
||||
DO_CATCH: ERROR_COLOR,
|
||||
DO_FINALLY: ERROR_COLOR,
|
||||
|
||||
EIP_WIRE_TAP: CROSS_ROUTE_COLOR,
|
||||
EIP_ENRICH: CROSS_ROUTE_COLOR,
|
||||
EIP_POLL_ENRICH: CROSS_ROUTE_COLOR,
|
||||
};
|
||||
|
||||
const COMPOUND_TYPES = new Set([
|
||||
'EIP_CHOICE', 'EIP_SPLIT', 'TRY_CATCH', 'DO_TRY',
|
||||
'EIP_LOOP', 'EIP_MULTICAST', 'EIP_AGGREGATE',
|
||||
'ON_EXCEPTION', 'ERROR_HANDLER',
|
||||
]);
|
||||
|
||||
const ERROR_COMPOUND_TYPES = new Set([
|
||||
'ON_EXCEPTION', 'ERROR_HANDLER',
|
||||
]);
|
||||
|
||||
export function colorForType(type: string | undefined): string {
|
||||
if (!type) return DEFAULT_COLOR;
|
||||
return TYPE_MAP[type] ?? DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
export function isCompoundType(type: string | undefined): boolean {
|
||||
return !!type && COMPOUND_TYPES.has(type);
|
||||
}
|
||||
|
||||
export function isErrorCompoundType(type: string | undefined): boolean {
|
||||
return !!type && ERROR_COMPOUND_TYPES.has(type);
|
||||
}
|
||||
|
||||
/** Icon character for a node type */
|
||||
export function iconForType(type: string | undefined): string {
|
||||
if (!type) return '\u2699'; // gear
|
||||
const t = type.toUpperCase();
|
||||
if (t === 'ENDPOINT') return '\u25B6'; // play
|
||||
if (t === 'TO' || t === 'TO_DYNAMIC' || t === 'DIRECT' || t === 'SEDA') return '\u25A0'; // square
|
||||
if (t.startsWith('EIP_CHOICE') || t === 'EIP_WHEN' || t === 'EIP_OTHERWISE') return '\u25C6'; // diamond
|
||||
if (t === 'ON_EXCEPTION' || t === 'ERROR_HANDLER' || t.startsWith('TRY') || t.startsWith('DO_')) return '\u26A0'; // warning
|
||||
if (t === 'EIP_SPLIT' || t === 'EIP_MULTICAST') return '\u2442'; // fork
|
||||
if (t === 'EIP_LOOP') return '\u21BA'; // loop arrow
|
||||
if (t === 'EIP_WIRE_TAP' || t === 'EIP_ENRICH' || t === 'EIP_POLL_ENRICH') return '\u2197'; // arrow
|
||||
return '\u2699'; // gear
|
||||
}
|
||||
27
ui/src/components/ProcessDiagram/types.ts
Normal file
27
ui/src/components/ProcessDiagram/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams';
|
||||
|
||||
export type NodeAction = 'inspect' | 'toggle-trace' | 'configure-tap' | 'copy-id';
|
||||
|
||||
export interface NodeConfig {
|
||||
traceEnabled?: boolean;
|
||||
tapExpression?: string;
|
||||
}
|
||||
|
||||
export interface DiagramSection {
|
||||
label: string;
|
||||
nodes: DiagramNode[];
|
||||
edges: DiagramEdge[];
|
||||
offsetY: number;
|
||||
variant?: 'error';
|
||||
}
|
||||
|
||||
export interface ProcessDiagramProps {
|
||||
application: string;
|
||||
routeId: string;
|
||||
direction?: 'LR' | 'TB';
|
||||
selectedNodeId?: string;
|
||||
onNodeSelect?: (nodeId: string) => void;
|
||||
onNodeAction?: (nodeId: string, action: NodeAction) => void;
|
||||
nodeConfigs?: Map<string, NodeConfig>;
|
||||
className?: string;
|
||||
}
|
||||
115
ui/src/components/ProcessDiagram/useDiagramData.ts
Normal file
115
ui/src/components/ProcessDiagram/useDiagramData.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||
import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams';
|
||||
import type { DiagramSection } from './types';
|
||||
import { isErrorCompoundType } from './node-colors';
|
||||
|
||||
const SECTION_GAP = 40;
|
||||
|
||||
export function useDiagramData(
|
||||
application: string,
|
||||
routeId: string,
|
||||
direction: 'LR' | 'TB' = 'LR',
|
||||
) {
|
||||
const { data: layout, isLoading, error } = useDiagramByRoute(application, routeId, direction);
|
||||
|
||||
const result = useMemo(() => {
|
||||
if (!layout?.nodes) {
|
||||
return { sections: [], totalWidth: 0, totalHeight: 0 };
|
||||
}
|
||||
|
||||
const allEdges = layout.edges ?? [];
|
||||
|
||||
// Separate main nodes from error handler compound sections
|
||||
const mainNodes: DiagramNode[] = [];
|
||||
const errorSections: { label: string; nodes: DiagramNode[] }[] = [];
|
||||
|
||||
for (const node of layout.nodes) {
|
||||
if (isErrorCompoundType(node.type) && node.children && node.children.length > 0) {
|
||||
errorSections.push({
|
||||
label: node.label || 'Error Handler',
|
||||
nodes: node.children,
|
||||
});
|
||||
} else {
|
||||
mainNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect node IDs for edge filtering
|
||||
const mainNodeIds = new Set<string>();
|
||||
collectNodeIds(mainNodes, mainNodeIds);
|
||||
|
||||
const mainEdges = allEdges.filter(
|
||||
e => mainNodeIds.has(e.sourceId) && mainNodeIds.has(e.targetId),
|
||||
);
|
||||
|
||||
// Compute main section bounding box
|
||||
const mainBounds = computeBounds(mainNodes);
|
||||
|
||||
const sections: DiagramSection[] = [
|
||||
{
|
||||
label: 'Main Route',
|
||||
nodes: mainNodes,
|
||||
edges: mainEdges,
|
||||
offsetY: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let currentY = mainBounds.maxY + SECTION_GAP;
|
||||
|
||||
for (const es of errorSections) {
|
||||
const errorNodeIds = new Set<string>();
|
||||
collectNodeIds(es.nodes, errorNodeIds);
|
||||
const errorEdges = allEdges.filter(
|
||||
e => errorNodeIds.has(e.sourceId) && errorNodeIds.has(e.targetId),
|
||||
);
|
||||
|
||||
sections.push({
|
||||
label: es.label,
|
||||
nodes: es.nodes,
|
||||
edges: errorEdges,
|
||||
offsetY: currentY,
|
||||
variant: 'error',
|
||||
});
|
||||
|
||||
const errorBounds = computeBounds(es.nodes);
|
||||
currentY += (errorBounds.maxY - errorBounds.minY) + SECTION_GAP;
|
||||
}
|
||||
|
||||
const totalWidth = layout.width ?? mainBounds.maxX;
|
||||
const totalHeight = currentY;
|
||||
|
||||
return { sections, totalWidth, totalHeight };
|
||||
}, [layout]);
|
||||
|
||||
return { ...result, isLoading, error };
|
||||
}
|
||||
|
||||
function collectNodeIds(nodes: DiagramNode[], set: Set<string>) {
|
||||
for (const n of nodes) {
|
||||
if (n.id) set.add(n.id);
|
||||
if (n.children) collectNodeIds(n.children, set);
|
||||
}
|
||||
}
|
||||
|
||||
function computeBounds(nodes: DiagramNode[]): {
|
||||
minX: number; minY: number; maxX: number; maxY: number;
|
||||
} {
|
||||
let minX = Infinity, minY = Infinity, maxX = 0, maxY = 0;
|
||||
for (const n of nodes) {
|
||||
const x = n.x ?? 0;
|
||||
const y = n.y ?? 0;
|
||||
const w = n.width ?? 80;
|
||||
const h = n.height ?? 40;
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x + w > maxX) maxX = x + w;
|
||||
if (y + h > maxY) maxY = y + h;
|
||||
if (n.children) {
|
||||
const childBounds = computeBounds(n.children);
|
||||
if (childBounds.maxX > maxX) maxX = childBounds.maxX;
|
||||
if (childBounds.maxY > maxY) maxY = childBounds.maxY;
|
||||
}
|
||||
}
|
||||
return { minX: minX === Infinity ? 0 : minX, minY: minY === Infinity ? 0 : minY, maxX, maxY };
|
||||
}
|
||||
171
ui/src/components/ProcessDiagram/useZoomPan.ts
Normal file
171
ui/src/components/ProcessDiagram/useZoomPan.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
interface ZoomPanState {
|
||||
scale: number;
|
||||
translateX: number;
|
||||
translateY: number;
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.25;
|
||||
const MAX_SCALE = 4.0;
|
||||
const ZOOM_STEP = 0.15;
|
||||
const FIT_PADDING = 40;
|
||||
|
||||
export function useZoomPan() {
|
||||
const [state, setState] = useState<ZoomPanState>({
|
||||
scale: 1,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
});
|
||||
const isPanning = useRef(false);
|
||||
const panStart = useRef({ x: 0, y: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const clampScale = (s: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
|
||||
|
||||
const viewBox = useCallback(
|
||||
(contentWidth: number, contentHeight: number) => {
|
||||
const vw = contentWidth / state.scale;
|
||||
const vh = contentHeight / state.scale;
|
||||
const vx = -state.translateX / state.scale;
|
||||
const vy = -state.translateY / state.scale;
|
||||
return `${vx} ${vy} ${vw} ${vh}`;
|
||||
},
|
||||
[state],
|
||||
);
|
||||
|
||||
const onWheel = useCallback(
|
||||
(e: React.WheelEvent<SVGSVGElement>) => {
|
||||
e.preventDefault();
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
const factor = 1 + direction * ZOOM_STEP;
|
||||
|
||||
setState(prev => {
|
||||
const newScale = clampScale(prev.scale * factor);
|
||||
// Zoom centered on cursor
|
||||
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
|
||||
const cursorX = e.clientX - rect.left;
|
||||
const cursorY = e.clientY - rect.top;
|
||||
const scaleRatio = newScale / prev.scale;
|
||||
const newTx = cursorX - scaleRatio * (cursorX - prev.translateX);
|
||||
const newTy = cursorY - scaleRatio * (cursorY - prev.translateY);
|
||||
return { scale: newScale, translateX: newTx, translateY: newTy };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent<SVGSVGElement>) => {
|
||||
// Only pan on background click (not on nodes)
|
||||
if ((e.target as Element).closest('[data-node-id]')) return;
|
||||
isPanning.current = true;
|
||||
panStart.current = { x: e.clientX - state.translateX, y: e.clientY - state.translateY };
|
||||
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[state.translateX, state.translateY],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (!isPanning.current) return;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
translateX: e.clientX - panStart.current.x,
|
||||
translateY: e.clientY - panStart.current.y,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(e: React.PointerEvent<SVGSVGElement>) => {
|
||||
isPanning.current = false;
|
||||
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setState(prev => {
|
||||
const newScale = clampScale(prev.scale * (1 + ZOOM_STEP));
|
||||
const container = containerRef.current;
|
||||
if (!container) return { ...prev, scale: newScale };
|
||||
const cx = container.clientWidth / 2;
|
||||
const cy = container.clientHeight / 2;
|
||||
const ratio = newScale / prev.scale;
|
||||
return {
|
||||
scale: newScale,
|
||||
translateX: cx - ratio * (cx - prev.translateX),
|
||||
translateY: cy - ratio * (cy - prev.translateY),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setState(prev => {
|
||||
const newScale = clampScale(prev.scale * (1 - ZOOM_STEP));
|
||||
const container = containerRef.current;
|
||||
if (!container) return { ...prev, scale: newScale };
|
||||
const cx = container.clientWidth / 2;
|
||||
const cy = container.clientHeight / 2;
|
||||
const ratio = newScale / prev.scale;
|
||||
return {
|
||||
scale: newScale,
|
||||
translateX: cx - ratio * (cx - prev.translateX),
|
||||
translateY: cy - ratio * (cy - prev.translateY),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fitToView = useCallback(
|
||||
(contentWidth: number, contentHeight: number) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const cw = container.clientWidth - FIT_PADDING * 2;
|
||||
const ch = container.clientHeight - FIT_PADDING * 2;
|
||||
const scaleX = cw / contentWidth;
|
||||
const scaleY = ch / contentHeight;
|
||||
const newScale = clampScale(Math.min(scaleX, scaleY));
|
||||
const tx = (container.clientWidth - contentWidth * newScale) / 2;
|
||||
const ty = (container.clientHeight - contentHeight * newScale) / 2;
|
||||
setState({ scale: newScale, translateX: tx, translateY: ty });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, contentWidth: number, contentHeight: number) => {
|
||||
switch (e.key) {
|
||||
case '+':
|
||||
case '=':
|
||||
e.preventDefault();
|
||||
zoomIn();
|
||||
break;
|
||||
case '-':
|
||||
e.preventDefault();
|
||||
zoomOut();
|
||||
break;
|
||||
case '0':
|
||||
e.preventDefault();
|
||||
fitToView(contentWidth, contentHeight);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[zoomIn, zoomOut, fitToView],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
containerRef,
|
||||
viewBox,
|
||||
onWheel,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
fitToView,
|
||||
onKeyDown,
|
||||
};
|
||||
}
|
||||
131
ui/src/pages/DevDiagram/DevDiagram.module.css
Normal file
131
ui/src/pages/DevDiagram/DevDiagram.module.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary, #1A1612);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controls select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border, #E4DFD8);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--bg-surface, #FFFFFF);
|
||||
color: var(--text-primary, #1A1612);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.diagramPane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
border: 1px dashed var(--border, #E4DFD8);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: var(--text-muted, #9C9184);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidePanel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border, #E4DFD8);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--bg-surface, #FFFFFF);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidePanel h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #5C5347);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nodeInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #9C9184);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field code, .field pre {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #1A1612);
|
||||
background: var(--bg-inset, #F0EDE8);
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted, #9C9184);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.logEntry {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #5C5347);
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid var(--border-subtle, #EDE9E3);
|
||||
}
|
||||
127
ui/src/pages/DevDiagram/DevDiagram.tsx
Normal file
127
ui/src/pages/DevDiagram/DevDiagram.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ProcessDiagram } from '../../components/ProcessDiagram';
|
||||
import type { NodeConfig, NodeAction } from '../../components/ProcessDiagram';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import styles from './DevDiagram.module.css';
|
||||
|
||||
export default function DevDiagram() {
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
|
||||
const [selectedApp, setSelectedApp] = useState('');
|
||||
const [selectedRoute, setSelectedRoute] = useState('');
|
||||
const [selectedNodeId, setSelectedNodeId] = useState('');
|
||||
const [direction, setDirection] = useState<'LR' | 'TB'>('LR');
|
||||
const [actionLog, setActionLog] = useState<string[]>([]);
|
||||
|
||||
// Extract unique applications and routes from catalog
|
||||
const { apps, routes } = useMemo(() => {
|
||||
if (!catalog) return { apps: [] as string[], routes: [] as string[] };
|
||||
const appSet = new Set<string>();
|
||||
const routeList: { app: string; routeId: string }[] = [];
|
||||
for (const entry of catalog as Array<{ application?: string; routeId?: string }>) {
|
||||
if (entry.application) appSet.add(entry.application);
|
||||
if (entry.application && entry.routeId) {
|
||||
routeList.push({ app: entry.application, routeId: entry.routeId });
|
||||
}
|
||||
}
|
||||
const appArr = Array.from(appSet).sort();
|
||||
const filtered = selectedApp
|
||||
? routeList.filter(r => r.app === selectedApp).map(r => r.routeId)
|
||||
: [];
|
||||
return { apps: appArr, routes: filtered };
|
||||
}, [catalog, selectedApp]);
|
||||
|
||||
// Mock node configs for testing
|
||||
const nodeConfigs = useMemo(() => {
|
||||
const map = new Map<string, NodeConfig>();
|
||||
// We'll add some mock configs if we have a route loaded
|
||||
map.set('log1', { traceEnabled: true });
|
||||
map.set('to1', { tapExpression: '${header.orderId}' });
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
const handleNodeAction = (nodeId: string, action: NodeAction) => {
|
||||
const msg = `[${new Date().toLocaleTimeString()}] ${action}: ${nodeId}`;
|
||||
setActionLog(prev => [msg, ...prev.slice(0, 19)]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<h2>Process Diagram (Dev)</h2>
|
||||
<div className={styles.controls}>
|
||||
<select
|
||||
value={selectedApp}
|
||||
onChange={e => { setSelectedApp(e.target.value); setSelectedRoute(''); }}
|
||||
>
|
||||
<option value="">Select application...</option>
|
||||
{apps.map(app => <option key={app} value={app}>{app}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={selectedRoute}
|
||||
onChange={e => setSelectedRoute(e.target.value)}
|
||||
disabled={!selectedApp}
|
||||
>
|
||||
<option value="">Select route...</option>
|
||||
{routes.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<select value={direction} onChange={e => setDirection(e.target.value as 'LR' | 'TB')}>
|
||||
<option value="LR">Left → Right</option>
|
||||
<option value="TB">Top → Bottom</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.diagramPane}>
|
||||
{selectedApp && selectedRoute ? (
|
||||
<ProcessDiagram
|
||||
application={selectedApp}
|
||||
routeId={selectedRoute}
|
||||
direction={direction}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
onNodeAction={handleNodeAction}
|
||||
nodeConfigs={nodeConfigs}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.placeholder}>
|
||||
Select an application and route to view the diagram
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.sidePanel}>
|
||||
<h3>Selected Node</h3>
|
||||
{selectedNodeId ? (
|
||||
<div className={styles.nodeInfo}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Node ID</span>
|
||||
<code>{selectedNodeId}</code>
|
||||
</div>
|
||||
{nodeConfigs.has(selectedNodeId) && (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Config</span>
|
||||
<pre>{JSON.stringify(nodeConfigs.get(selectedNodeId), null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className={styles.hint}>Click a node to inspect it</p>
|
||||
)}
|
||||
|
||||
<h3>Action Log</h3>
|
||||
<div className={styles.log}>
|
||||
{actionLog.length === 0 ? (
|
||||
<p className={styles.hint}>Hover a node and use the toolbar</p>
|
||||
) : (
|
||||
actionLog.map((msg, i) => (
|
||||
<div key={i} className={styles.logEntry}>{msg}</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||
const OpenSearchAdminPage = lazy(() => import('./pages/Admin/OpenSearchAdminPage'));
|
||||
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
|
||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||
const DevDiagram = lazy(() => import('./pages/DevDiagram/DevDiagram'));
|
||||
|
||||
function SuspenseWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -63,6 +64,7 @@ export const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: 'api-docs', element: <SuspenseWrapper><SwaggerPage /></SuspenseWrapper> },
|
||||
{ path: 'dev/diagram', element: <SuspenseWrapper><DevDiagram /></SuspenseWrapper> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user