Compare commits
2 Commits
9b7626f6ff
...
eb9c20e734
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb9c20e734 | ||
|
|
f6220a9f89 |
@@ -102,7 +102,8 @@ public class ElkDiagramRenderer implements DiagramRenderer {
|
|||||||
NodeType.EIP_SPLIT, NodeType.TRY_CATCH,
|
NodeType.EIP_SPLIT, NodeType.TRY_CATCH,
|
||||||
NodeType.DO_TRY, NodeType.DO_CATCH, NodeType.DO_FINALLY,
|
NodeType.DO_TRY, NodeType.DO_CATCH, NodeType.DO_FINALLY,
|
||||||
NodeType.EIP_LOOP, NodeType.EIP_MULTICAST,
|
NodeType.EIP_LOOP, NodeType.EIP_MULTICAST,
|
||||||
NodeType.EIP_AGGREGATE, NodeType.ON_EXCEPTION, NodeType.ERROR_HANDLER
|
NodeType.EIP_AGGREGATE, NodeType.ON_EXCEPTION, NodeType.ERROR_HANDLER,
|
||||||
|
NodeType.ON_COMPLETION
|
||||||
);
|
);
|
||||||
|
|
||||||
public ElkDiagramRenderer() {
|
public ElkDiagramRenderer() {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface CompoundNodeProps {
|
|||||||
hoveredNodeId: string | null;
|
hoveredNodeId: string | null;
|
||||||
nodeConfigs?: Map<string, NodeConfig>;
|
nodeConfigs?: Map<string, NodeConfig>;
|
||||||
onNodeClick: (nodeId: string) => void;
|
onNodeClick: (nodeId: string) => void;
|
||||||
|
onNodeDoubleClick?: (nodeId: string) => void;
|
||||||
onNodeEnter: (nodeId: string) => void;
|
onNodeEnter: (nodeId: string) => void;
|
||||||
onNodeLeave: () => void;
|
onNodeLeave: () => void;
|
||||||
}
|
}
|
||||||
@@ -25,7 +26,7 @@ interface CompoundNodeProps {
|
|||||||
export function CompoundNode({
|
export function CompoundNode({
|
||||||
node, edges, parentX = 0, parentY = 0,
|
node, edges, parentX = 0, parentY = 0,
|
||||||
selectedNodeId, hoveredNodeId, nodeConfigs,
|
selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||||
onNodeClick, onNodeEnter, onNodeLeave,
|
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
|
||||||
}: CompoundNodeProps) {
|
}: CompoundNodeProps) {
|
||||||
const x = (node.x ?? 0) - parentX;
|
const x = (node.x ?? 0) - parentX;
|
||||||
const y = (node.y ?? 0) - parentY;
|
const y = (node.y ?? 0) - parentY;
|
||||||
@@ -102,6 +103,7 @@ export function CompoundNode({
|
|||||||
hoveredNodeId={hoveredNodeId}
|
hoveredNodeId={hoveredNodeId}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
|
onNodeDoubleClick={onNodeDoubleClick}
|
||||||
onNodeEnter={onNodeEnter}
|
onNodeEnter={onNodeEnter}
|
||||||
onNodeLeave={onNodeLeave}
|
onNodeLeave={onNodeLeave}
|
||||||
/>
|
/>
|
||||||
@@ -119,6 +121,7 @@ export function CompoundNode({
|
|||||||
isSelected={selectedNodeId === child.id}
|
isSelected={selectedNodeId === child.id}
|
||||||
config={child.id ? nodeConfigs?.get(child.id) : undefined}
|
config={child.id ? nodeConfigs?.get(child.id) : undefined}
|
||||||
onClick={() => child.id && onNodeClick(child.id)}
|
onClick={() => child.id && onNodeClick(child.id)}
|
||||||
|
onDoubleClick={() => child.id && onNodeDoubleClick?.(child.id)}
|
||||||
onMouseEnter={() => child.id && onNodeEnter(child.id)}
|
onMouseEnter={() => child.id && onNodeEnter(child.id)}
|
||||||
onMouseLeave={onNodeLeave}
|
onMouseLeave={onNodeLeave}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ interface DiagramNodeProps {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
config?: NodeConfig;
|
config?: NodeConfig;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
onMouseEnter: () => void;
|
onMouseEnter: () => void;
|
||||||
onMouseLeave: () => void;
|
onMouseLeave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiagramNode({
|
export function DiagramNode({
|
||||||
node, isHovered, isSelected, config, onClick, onMouseEnter, onMouseLeave,
|
node, isHovered, isSelected, config, onClick, onDoubleClick, onMouseEnter, onMouseLeave,
|
||||||
}: DiagramNodeProps) {
|
}: DiagramNodeProps) {
|
||||||
const x = node.x ?? 0;
|
const x = node.x ?? 0;
|
||||||
const y = node.y ?? 0;
|
const y = node.y ?? 0;
|
||||||
@@ -35,6 +36,7 @@ export function DiagramNode({
|
|||||||
data-node-id={node.id}
|
data-node-id={node.id}
|
||||||
transform={`translate(${x}, ${y})`}
|
transform={`translate(${x}, ${y})`}
|
||||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||||
|
onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick?.(); }}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
|
|||||||
@@ -17,14 +17,21 @@ interface ErrorSectionProps {
|
|||||||
hoveredNodeId: string | null;
|
hoveredNodeId: string | null;
|
||||||
nodeConfigs?: Map<string, NodeConfig>;
|
nodeConfigs?: Map<string, NodeConfig>;
|
||||||
onNodeClick: (nodeId: string) => void;
|
onNodeClick: (nodeId: string) => void;
|
||||||
|
onNodeDoubleClick?: (nodeId: string) => void;
|
||||||
onNodeEnter: (nodeId: string) => void;
|
onNodeEnter: (nodeId: string) => void;
|
||||||
onNodeLeave: () => void;
|
onNodeLeave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VARIANT_COLORS: Record<string, string> = {
|
||||||
|
error: '#C0392B',
|
||||||
|
completion: '#1A7F8E',
|
||||||
|
};
|
||||||
|
|
||||||
export function ErrorSection({
|
export function ErrorSection({
|
||||||
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
|
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
|
||||||
onNodeClick, onNodeEnter, onNodeLeave,
|
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
|
||||||
}: ErrorSectionProps) {
|
}: ErrorSectionProps) {
|
||||||
|
const color = VARIANT_COLORS[section.variant ?? 'error'] ?? VARIANT_COLORS.error;
|
||||||
const boxHeight = useMemo(() => {
|
const boxHeight = useMemo(() => {
|
||||||
let maxY = 0;
|
let maxY = 0;
|
||||||
for (const n of section.nodes) {
|
for (const n of section.nodes) {
|
||||||
@@ -44,7 +51,7 @@ export function ErrorSection({
|
|||||||
return (
|
return (
|
||||||
<g transform={`translate(0, ${section.offsetY})`}>
|
<g transform={`translate(0, ${section.offsetY})`}>
|
||||||
{/* Section label */}
|
{/* Section label */}
|
||||||
<text x={8} y={-6} fill="#C0392B" fontSize={11} fontWeight={600}>
|
<text x={8} y={-6} fill={color} fontSize={11} fontWeight={600}>
|
||||||
{section.label}
|
{section.label}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ export function ErrorSection({
|
|||||||
y1={0}
|
y1={0}
|
||||||
x2={totalWidth}
|
x2={totalWidth}
|
||||||
y2={0}
|
y2={0}
|
||||||
stroke="#C0392B"
|
stroke={color}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
strokeDasharray="6 3"
|
strokeDasharray="6 3"
|
||||||
opacity={0.5}
|
opacity={0.5}
|
||||||
@@ -66,7 +73,7 @@ export function ErrorSection({
|
|||||||
y={4}
|
y={4}
|
||||||
width={totalWidth}
|
width={totalWidth}
|
||||||
height={boxHeight}
|
height={boxHeight}
|
||||||
fill="#C0392B"
|
fill={color}
|
||||||
opacity={0.03}
|
opacity={0.03}
|
||||||
rx={4}
|
rx={4}
|
||||||
/>
|
/>
|
||||||
@@ -93,6 +100,7 @@ export function ErrorSection({
|
|||||||
hoveredNodeId={hoveredNodeId}
|
hoveredNodeId={hoveredNodeId}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
|
onNodeDoubleClick={onNodeDoubleClick}
|
||||||
onNodeEnter={onNodeEnter}
|
onNodeEnter={onNodeEnter}
|
||||||
onNodeLeave={onNodeLeave}
|
onNodeLeave={onNodeLeave}
|
||||||
/>
|
/>
|
||||||
@@ -106,6 +114,7 @@ export function ErrorSection({
|
|||||||
isSelected={selectedNodeId === node.id}
|
isSelected={selectedNodeId === node.id}
|
||||||
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
||||||
onClick={() => node.id && onNodeClick(node.id)}
|
onClick={() => node.id && onNodeClick(node.id)}
|
||||||
|
onDoubleClick={() => node.id && onNodeDoubleClick?.(node.id)}
|
||||||
onMouseEnter={() => node.id && onNodeEnter(node.id)}
|
onMouseEnter={() => node.id && onNodeEnter(node.id)}
|
||||||
onMouseLeave={onNodeLeave}
|
onMouseLeave={onNodeLeave}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -78,6 +78,50 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.breadcrumbs {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--bg-surface, #FFFFFF);
|
||||||
|
border: 1px solid var(--border, #E4DFD8);
|
||||||
|
border-radius: var(--radius-sm, 5px);
|
||||||
|
box-shadow: var(--shadow-sm, 0 1px 2px rgba(44, 37, 32, 0.06));
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbSep {
|
||||||
|
margin: 0 6px;
|
||||||
|
color: var(--text-muted, #9C9184);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--running, #1A7F8E);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbCurrent {
|
||||||
|
color: var(--text-primary, #1A1612);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.nodeToolbar {
|
.nodeToolbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { ProcessDiagramProps } from './types';
|
import type { ProcessDiagramProps } from './types';
|
||||||
|
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
|
||||||
import { useDiagramData } from './useDiagramData';
|
import { useDiagramData } from './useDiagramData';
|
||||||
import { useZoomPan } from './useZoomPan';
|
import { useZoomPan } from './useZoomPan';
|
||||||
import { useToolbarHover, NodeToolbar } from './NodeToolbar';
|
import { useToolbarHover, NodeToolbar } from './NodeToolbar';
|
||||||
@@ -13,6 +14,33 @@ import styles from './ProcessDiagram.module.css';
|
|||||||
|
|
||||||
const PADDING = 40;
|
const PADDING = 40;
|
||||||
|
|
||||||
|
/** Types that support drill-down — double-click navigates to the target route */
|
||||||
|
const DRILLDOWN_TYPES = new Set(['DIRECT', 'SEDA']);
|
||||||
|
|
||||||
|
/** Extract the target endpoint name from a node's label */
|
||||||
|
function extractTargetEndpoint(node: DiagramNodeType): string | null {
|
||||||
|
// Labels like "to: direct:orderProcessing" or "direct:orderProcessing"
|
||||||
|
const label = node.label ?? '';
|
||||||
|
const match = label.match(/(?:to:\s*)?(?:direct|seda):(\S+)/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert camelCase to kebab-case: "callGetProduct" → "call-get-product" */
|
||||||
|
function camelToKebab(s: string): string {
|
||||||
|
return s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a direct/seda endpoint name to a routeId.
|
||||||
|
* Tries: exact match, kebab-case conversion, then gives up.
|
||||||
|
*/
|
||||||
|
function resolveRouteId(endpoint: string, knownRouteIds: Set<string>): string | null {
|
||||||
|
if (knownRouteIds.has(endpoint)) return endpoint;
|
||||||
|
const kebab = camelToKebab(endpoint);
|
||||||
|
if (knownRouteIds.has(kebab)) return kebab;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function ProcessDiagram({
|
export function ProcessDiagram({
|
||||||
application,
|
application,
|
||||||
routeId,
|
routeId,
|
||||||
@@ -21,10 +49,21 @@ export function ProcessDiagram({
|
|||||||
onNodeSelect,
|
onNodeSelect,
|
||||||
onNodeAction,
|
onNodeAction,
|
||||||
nodeConfigs,
|
nodeConfigs,
|
||||||
|
knownRouteIds,
|
||||||
className,
|
className,
|
||||||
}: ProcessDiagramProps) {
|
}: ProcessDiagramProps) {
|
||||||
|
// Route stack for drill-down navigation
|
||||||
|
const [routeStack, setRouteStack] = useState<string[]>([routeId]);
|
||||||
|
|
||||||
|
// Reset stack when the external routeId prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
setRouteStack([routeId]);
|
||||||
|
}, [routeId]);
|
||||||
|
|
||||||
|
const currentRouteId = routeStack[routeStack.length - 1];
|
||||||
|
|
||||||
const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData(
|
const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData(
|
||||||
application, routeId, direction,
|
application, currentRouteId, direction,
|
||||||
);
|
);
|
||||||
|
|
||||||
const zoom = useZoomPan();
|
const zoom = useZoomPan();
|
||||||
@@ -33,18 +72,43 @@ export function ProcessDiagram({
|
|||||||
const contentWidth = totalWidth + PADDING * 2;
|
const contentWidth = totalWidth + PADDING * 2;
|
||||||
const contentHeight = totalHeight + PADDING * 2;
|
const contentHeight = totalHeight + PADDING * 2;
|
||||||
|
|
||||||
// Reset to 100% at top-left on first data load
|
// Reset to 100% at top-left when route changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (totalWidth > 0 && totalHeight > 0) {
|
if (totalWidth > 0 && totalHeight > 0) {
|
||||||
zoom.resetView();
|
zoom.resetView();
|
||||||
}
|
}
|
||||||
}, [totalWidth, totalHeight]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleNodeClick = useCallback(
|
const handleNodeClick = useCallback(
|
||||||
(nodeId: string) => { onNodeSelect?.(nodeId); },
|
(nodeId: string) => { onNodeSelect?.(nodeId); },
|
||||||
[onNodeSelect],
|
[onNodeSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleNodeDoubleClick = useCallback(
|
||||||
|
(nodeId: string) => {
|
||||||
|
const node = findNodeById(sections, nodeId);
|
||||||
|
if (!node || !DRILLDOWN_TYPES.has(node.type ?? '')) return;
|
||||||
|
const endpoint = extractTargetEndpoint(node);
|
||||||
|
if (!endpoint) return;
|
||||||
|
const resolved = knownRouteIds
|
||||||
|
? resolveRouteId(endpoint, knownRouteIds)
|
||||||
|
: endpoint;
|
||||||
|
if (resolved) {
|
||||||
|
onNodeSelect?.('');
|
||||||
|
setRouteStack(prev => [...prev, resolved]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sections, onNodeSelect, knownRouteIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBreadcrumbClick = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
onNodeSelect?.('');
|
||||||
|
setRouteStack(prev => prev.slice(0, index + 1));
|
||||||
|
},
|
||||||
|
[onNodeSelect],
|
||||||
|
);
|
||||||
|
|
||||||
const handleNodeAction = useCallback(
|
const handleNodeAction = useCallback(
|
||||||
(nodeId: string, action: import('./types').NodeAction) => {
|
(nodeId: string, action: import('./types').NodeAction) => {
|
||||||
if (action === 'inspect') onNodeSelect?.(nodeId);
|
if (action === 'inspect') onNodeSelect?.(nodeId);
|
||||||
@@ -56,12 +120,17 @@ export function ProcessDiagram({
|
|||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
onNodeSelect?.('');
|
if (routeStack.length > 1) {
|
||||||
|
// Go back one level
|
||||||
|
setRouteStack(prev => prev.slice(0, -1));
|
||||||
|
} else {
|
||||||
|
onNodeSelect?.('');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
zoom.onKeyDown(e, contentWidth, contentHeight);
|
zoom.onKeyDown(e, contentWidth, contentHeight);
|
||||||
},
|
},
|
||||||
[onNodeSelect, zoom, contentWidth, contentHeight],
|
[onNodeSelect, zoom, contentWidth, contentHeight, routeStack.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -89,13 +158,34 @@ export function ProcessDiagram({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mainSection = sections[0];
|
const mainSection = sections[0];
|
||||||
const errorSections = sections.slice(1);
|
const handlerSections = sections.slice(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={zoom.containerRef}
|
ref={zoom.containerRef}
|
||||||
className={`${styles.container} ${className ?? ''}`}
|
className={`${styles.container} ${className ?? ''}`}
|
||||||
>
|
>
|
||||||
|
{/* Breadcrumb bar — only shown when drilled down */}
|
||||||
|
{routeStack.length > 1 && (
|
||||||
|
<div className={styles.breadcrumbs}>
|
||||||
|
{routeStack.map((route, i) => (
|
||||||
|
<span key={i} className={styles.breadcrumbItem}>
|
||||||
|
{i > 0 && <span className={styles.breadcrumbSep}>/</span>}
|
||||||
|
{i < routeStack.length - 1 ? (
|
||||||
|
<button
|
||||||
|
className={styles.breadcrumbLink}
|
||||||
|
onClick={() => handleBreadcrumbClick(i)}
|
||||||
|
>
|
||||||
|
{route}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={styles.breadcrumbCurrent}>{route}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
className={styles.svg}
|
className={styles.svg}
|
||||||
onWheel={zoom.onWheel}
|
onWheel={zoom.onWheel}
|
||||||
@@ -140,6 +230,7 @@ export function ProcessDiagram({
|
|||||||
hoveredNodeId={toolbar.hoveredNodeId}
|
hoveredNodeId={toolbar.hoveredNodeId}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeEnter={toolbar.onNodeEnter}
|
onNodeEnter={toolbar.onNodeEnter}
|
||||||
onNodeLeave={toolbar.onNodeLeave}
|
onNodeLeave={toolbar.onNodeLeave}
|
||||||
/>
|
/>
|
||||||
@@ -153,6 +244,7 @@ export function ProcessDiagram({
|
|||||||
isSelected={selectedNodeId === node.id}
|
isSelected={selectedNodeId === node.id}
|
||||||
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
||||||
onClick={() => node.id && handleNodeClick(node.id)}
|
onClick={() => node.id && handleNodeClick(node.id)}
|
||||||
|
onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)}
|
||||||
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
|
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
|
||||||
onMouseLeave={toolbar.onNodeLeave}
|
onMouseLeave={toolbar.onNodeLeave}
|
||||||
/>
|
/>
|
||||||
@@ -162,16 +254,17 @@ export function ProcessDiagram({
|
|||||||
|
|
||||||
{/* Toolbar rendered as HTML overlay below */}
|
{/* Toolbar rendered as HTML overlay below */}
|
||||||
|
|
||||||
{/* Error handler sections */}
|
{/* Handler sections (completion, then error) */}
|
||||||
{errorSections.map((section, i) => (
|
{handlerSections.map((section, i) => (
|
||||||
<ErrorSection
|
<ErrorSection
|
||||||
key={`error-${i}`}
|
key={`handler-${i}`}
|
||||||
section={section}
|
section={section}
|
||||||
totalWidth={totalWidth}
|
totalWidth={totalWidth}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
hoveredNodeId={toolbar.hoveredNodeId}
|
hoveredNodeId={toolbar.hoveredNodeId}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeEnter={toolbar.onNodeEnter}
|
onNodeEnter={toolbar.onNodeEnter}
|
||||||
onNodeLeave={toolbar.onNodeLeave}
|
onNodeLeave={toolbar.onNodeLeave}
|
||||||
/>
|
/>
|
||||||
@@ -183,7 +276,6 @@ export function ProcessDiagram({
|
|||||||
{toolbar.hoveredNodeId && onNodeAction && (() => {
|
{toolbar.hoveredNodeId && onNodeAction && (() => {
|
||||||
const hNode = findNodeById(sections, toolbar.hoveredNodeId!);
|
const hNode = findNodeById(sections, toolbar.hoveredNodeId!);
|
||||||
if (!hNode) return null;
|
if (!hNode) return null;
|
||||||
// Convert SVG coordinates to screen-space using zoom transform
|
|
||||||
const nodeCenter = (hNode.x ?? 0) + (hNode.width ?? 160) / 2;
|
const nodeCenter = (hNode.x ?? 0) + (hNode.width ?? 160) / 2;
|
||||||
const nodeTop = hNode.y ?? 0;
|
const nodeTop = hNode.y ?? 0;
|
||||||
const screenX = nodeCenter * zoom.state.scale + zoom.state.translateX;
|
const screenX = nodeCenter * zoom.state.scale + zoom.state.translateX;
|
||||||
@@ -213,7 +305,7 @@ export function ProcessDiagram({
|
|||||||
function findNodeById(
|
function findNodeById(
|
||||||
sections: import('./types').DiagramSection[],
|
sections: import('./types').DiagramSection[],
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
): import('../../api/queries/diagrams').DiagramNode | undefined {
|
): DiagramNodeType | undefined {
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
for (const node of section.nodes) {
|
for (const node of section.nodes) {
|
||||||
if (node.id === nodeId) return node;
|
if (node.id === nodeId) return node;
|
||||||
@@ -227,9 +319,9 @@ function findNodeById(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findInChildren(
|
function findInChildren(
|
||||||
nodes: import('../../api/queries/diagrams').DiagramNode[],
|
nodes: DiagramNodeType[],
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
): import('../../api/queries/diagrams').DiagramNode | undefined {
|
): DiagramNodeType | undefined {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.id === nodeId) return n;
|
if (n.id === nodeId) return n;
|
||||||
if (n.children) {
|
if (n.children) {
|
||||||
@@ -240,12 +332,10 @@ function findInChildren(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if the edge connects two top-level nodes (not inside any compound). */
|
|
||||||
function topLevelEdge(
|
function topLevelEdge(
|
||||||
edge: import('../../api/queries/diagrams').DiagramEdge,
|
edge: import('../../api/queries/diagrams').DiagramEdge,
|
||||||
nodes: import('../../api/queries/diagrams').DiagramNode[],
|
nodes: DiagramNodeType[],
|
||||||
): boolean {
|
): boolean {
|
||||||
// Collect all IDs that are children of compound nodes (at any depth)
|
|
||||||
const compoundChildIds = new Set<string>();
|
const compoundChildIds = new Set<string>();
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.children && n.children.length > 0) {
|
if (n.children && n.children.length > 0) {
|
||||||
@@ -255,10 +345,7 @@ function topLevelEdge(
|
|||||||
return !compoundChildIds.has(edge.sourceId) && !compoundChildIds.has(edge.targetId);
|
return !compoundChildIds.has(edge.sourceId) && !compoundChildIds.has(edge.targetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectDescendantIds(
|
function collectDescendantIds(nodes: DiagramNodeType[], set: Set<string>) {
|
||||||
nodes: import('../../api/queries/diagrams').DiagramNode[],
|
|
||||||
set: Set<string>,
|
|
||||||
) {
|
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.id) set.add(n.id);
|
if (n.id) set.add(n.id);
|
||||||
if (n.children) collectDescendantIds(n.children, set);
|
if (n.children) collectDescendantIds(n.children, set);
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ const TYPE_MAP: Record<string, string> = {
|
|||||||
DO_CATCH: ERROR_COLOR,
|
DO_CATCH: ERROR_COLOR,
|
||||||
DO_FINALLY: ERROR_COLOR,
|
DO_FINALLY: ERROR_COLOR,
|
||||||
|
|
||||||
|
ON_COMPLETION: '#1A7F8E', // --running (teal, lifecycle handler)
|
||||||
|
|
||||||
EIP_WIRE_TAP: CROSS_ROUTE_COLOR,
|
EIP_WIRE_TAP: CROSS_ROUTE_COLOR,
|
||||||
EIP_ENRICH: CROSS_ROUTE_COLOR,
|
EIP_ENRICH: CROSS_ROUTE_COLOR,
|
||||||
EIP_POLL_ENRICH: CROSS_ROUTE_COLOR,
|
EIP_POLL_ENRICH: CROSS_ROUTE_COLOR,
|
||||||
@@ -61,12 +63,17 @@ const COMPOUND_TYPES = new Set([
|
|||||||
'DO_TRY', 'DO_CATCH', 'DO_FINALLY',
|
'DO_TRY', 'DO_CATCH', 'DO_FINALLY',
|
||||||
'EIP_LOOP', 'EIP_MULTICAST', 'EIP_AGGREGATE',
|
'EIP_LOOP', 'EIP_MULTICAST', 'EIP_AGGREGATE',
|
||||||
'ON_EXCEPTION', 'ERROR_HANDLER',
|
'ON_EXCEPTION', 'ERROR_HANDLER',
|
||||||
|
'ON_COMPLETION',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ERROR_COMPOUND_TYPES = new Set([
|
const ERROR_COMPOUND_TYPES = new Set([
|
||||||
'ON_EXCEPTION', 'ERROR_HANDLER',
|
'ON_EXCEPTION', 'ERROR_HANDLER',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const COMPLETION_COMPOUND_TYPES = new Set([
|
||||||
|
'ON_COMPLETION',
|
||||||
|
]);
|
||||||
|
|
||||||
export function colorForType(type: string | undefined): string {
|
export function colorForType(type: string | undefined): string {
|
||||||
if (!type) return DEFAULT_COLOR;
|
if (!type) return DEFAULT_COLOR;
|
||||||
return TYPE_MAP[type] ?? DEFAULT_COLOR;
|
return TYPE_MAP[type] ?? DEFAULT_COLOR;
|
||||||
@@ -80,6 +87,10 @@ export function isErrorCompoundType(type: string | undefined): boolean {
|
|||||||
return !!type && ERROR_COMPOUND_TYPES.has(type);
|
return !!type && ERROR_COMPOUND_TYPES.has(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCompletionCompoundType(type: string | undefined): boolean {
|
||||||
|
return !!type && COMPLETION_COMPOUND_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
/** Icon character for a node type */
|
/** Icon character for a node type */
|
||||||
export function iconForType(type: string | undefined): string {
|
export function iconForType(type: string | undefined): string {
|
||||||
if (!type) return '\u2699'; // gear
|
if (!type) return '\u2699'; // gear
|
||||||
@@ -87,6 +98,7 @@ export function iconForType(type: string | undefined): string {
|
|||||||
if (t === 'ENDPOINT') return '\u25B6'; // play
|
if (t === 'ENDPOINT') return '\u25B6'; // play
|
||||||
if (t === 'TO' || t === 'TO_DYNAMIC' || t === 'DIRECT' || t === 'SEDA') return '\u25A0'; // square
|
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.startsWith('EIP_CHOICE') || t === 'EIP_WHEN' || t === 'EIP_OTHERWISE') return '\u25C6'; // diamond
|
||||||
|
if (t === 'ON_COMPLETION') return '\u2714'; // checkmark
|
||||||
if (t === 'ON_EXCEPTION' || t === 'ERROR_HANDLER' || t.startsWith('TRY') || t.startsWith('DO_')) return '\u26A0'; // warning
|
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_SPLIT' || t === 'EIP_MULTICAST') return '\u2442'; // fork
|
||||||
if (t === 'EIP_LOOP') return '\u21BA'; // loop arrow
|
if (t === 'EIP_LOOP') return '\u21BA'; // loop arrow
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface DiagramSection {
|
|||||||
nodes: DiagramNode[];
|
nodes: DiagramNode[];
|
||||||
edges: DiagramEdge[];
|
edges: DiagramEdge[];
|
||||||
offsetY: number;
|
offsetY: number;
|
||||||
variant?: 'error';
|
variant?: 'error' | 'completion';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessDiagramProps {
|
export interface ProcessDiagramProps {
|
||||||
@@ -23,5 +23,7 @@ export interface ProcessDiagramProps {
|
|||||||
onNodeSelect?: (nodeId: string) => void;
|
onNodeSelect?: (nodeId: string) => void;
|
||||||
onNodeAction?: (nodeId: string, action: NodeAction) => void;
|
onNodeAction?: (nodeId: string, action: NodeAction) => void;
|
||||||
nodeConfigs?: Map<string, NodeConfig>;
|
nodeConfigs?: Map<string, NodeConfig>;
|
||||||
|
/** Known route IDs for this application (enables drill-down resolution) */
|
||||||
|
knownRouteIds?: Set<string>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
|||||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||||
import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams';
|
import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams';
|
||||||
import type { DiagramSection } from './types';
|
import type { DiagramSection } from './types';
|
||||||
import { isErrorCompoundType } from './node-colors';
|
import { isErrorCompoundType, isCompletionCompoundType } from './node-colors';
|
||||||
|
|
||||||
const SECTION_GAP = 40;
|
const SECTION_GAP = 40;
|
||||||
|
|
||||||
@@ -20,12 +20,18 @@ export function useDiagramData(
|
|||||||
|
|
||||||
const allEdges = layout.edges ?? [];
|
const allEdges = layout.edges ?? [];
|
||||||
|
|
||||||
// Separate main nodes from error handler compound sections
|
// Separate main nodes from completion and error handler compound sections
|
||||||
const mainNodes: DiagramNode[] = [];
|
const mainNodes: DiagramNode[] = [];
|
||||||
|
const completionSections: { label: string; nodes: DiagramNode[] }[] = [];
|
||||||
const errorSections: { label: string; nodes: DiagramNode[] }[] = [];
|
const errorSections: { label: string; nodes: DiagramNode[] }[] = [];
|
||||||
|
|
||||||
for (const node of layout.nodes) {
|
for (const node of layout.nodes) {
|
||||||
if (isErrorCompoundType(node.type) && node.children && node.children.length > 0) {
|
if (isCompletionCompoundType(node.type) && node.children && node.children.length > 0) {
|
||||||
|
completionSections.push({
|
||||||
|
label: node.label || 'onCompletion',
|
||||||
|
nodes: node.children,
|
||||||
|
});
|
||||||
|
} else if (isErrorCompoundType(node.type) && node.children && node.children.length > 0) {
|
||||||
errorSections.push({
|
errorSections.push({
|
||||||
label: node.label || 'Error Handler',
|
label: node.label || 'Error Handler',
|
||||||
nodes: node.children,
|
nodes: node.children,
|
||||||
@@ -58,39 +64,41 @@ export function useDiagramData(
|
|||||||
let currentY = mainBounds.maxY + SECTION_GAP;
|
let currentY = mainBounds.maxY + SECTION_GAP;
|
||||||
let maxWidth = mainBounds.maxX;
|
let maxWidth = mainBounds.maxX;
|
||||||
|
|
||||||
for (const es of errorSections) {
|
const addHandlerSections = (
|
||||||
const errorBounds = computeBounds(es.nodes);
|
handlers: { label: string; nodes: DiagramNode[] }[],
|
||||||
const offX = errorBounds.minX;
|
variant: 'completion' | 'error',
|
||||||
const offY = errorBounds.minY;
|
) => {
|
||||||
|
for (const hs of handlers) {
|
||||||
|
const bounds = computeBounds(hs.nodes);
|
||||||
|
const offX = bounds.minX;
|
||||||
|
const offY = bounds.minY;
|
||||||
|
const shiftedNodes = shiftNodes(hs.nodes, offX, offY);
|
||||||
|
const nodeIds = new Set<string>();
|
||||||
|
collectNodeIds(hs.nodes, nodeIds);
|
||||||
|
const edges = allEdges
|
||||||
|
.filter(e => nodeIds.has(e.sourceId) && nodeIds.has(e.targetId))
|
||||||
|
.map(e => ({
|
||||||
|
...e,
|
||||||
|
points: e.points.map(p => [p[0] - offX, p[1] - offY]),
|
||||||
|
}));
|
||||||
|
const sectionHeight = bounds.maxY - bounds.minY;
|
||||||
|
const sectionWidth = bounds.maxX - bounds.minX;
|
||||||
|
sections.push({
|
||||||
|
label: hs.label,
|
||||||
|
nodes: shiftedNodes,
|
||||||
|
edges,
|
||||||
|
offsetY: currentY,
|
||||||
|
variant,
|
||||||
|
});
|
||||||
|
currentY += sectionHeight + SECTION_GAP;
|
||||||
|
if (sectionWidth > maxWidth) maxWidth = sectionWidth;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Normalize node coordinates relative to the section's own origin
|
// Completion handlers first (above error handlers)
|
||||||
const shiftedNodes = shiftNodes(es.nodes, offX, offY);
|
addHandlerSections(completionSections, 'completion');
|
||||||
|
// Then error handlers
|
||||||
const errorNodeIds = new Set<string>();
|
addHandlerSections(errorSections, 'error');
|
||||||
collectNodeIds(es.nodes, errorNodeIds);
|
|
||||||
|
|
||||||
// Shift edge points too
|
|
||||||
const errorEdges = allEdges
|
|
||||||
.filter(e => errorNodeIds.has(e.sourceId) && errorNodeIds.has(e.targetId))
|
|
||||||
.map(e => ({
|
|
||||||
...e,
|
|
||||||
points: e.points.map(p => [p[0] - offX, p[1] - offY]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sectionHeight = errorBounds.maxY - errorBounds.minY;
|
|
||||||
const sectionWidth = errorBounds.maxX - errorBounds.minX;
|
|
||||||
|
|
||||||
sections.push({
|
|
||||||
label: es.label,
|
|
||||||
nodes: shiftedNodes,
|
|
||||||
edges: errorEdges,
|
|
||||||
offsetY: currentY,
|
|
||||||
variant: 'error',
|
|
||||||
});
|
|
||||||
|
|
||||||
currentY += sectionHeight + SECTION_GAP;
|
|
||||||
if (sectionWidth > maxWidth) maxWidth = sectionWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalWidth = Math.max(layout.width ?? 0, mainBounds.maxX, maxWidth);
|
const totalWidth = Math.max(layout.width ?? 0, mainBounds.maxX, maxWidth);
|
||||||
const totalHeight = currentY;
|
const totalHeight = currentY;
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export default function DevDiagram() {
|
|||||||
return { apps: appArr, routes: filtered };
|
return { apps: appArr, routes: filtered };
|
||||||
}, [catalog, selectedApp]);
|
}, [catalog, selectedApp]);
|
||||||
|
|
||||||
|
// All route IDs for the selected app (for drill-down resolution)
|
||||||
|
const knownRouteIds = useMemo(() => {
|
||||||
|
if (!catalog || !selectedApp) return new Set<string>();
|
||||||
|
const app = (catalog as Array<{ appId: string; routes?: Array<{ routeId: string }> }>)
|
||||||
|
.find(a => a.appId === selectedApp);
|
||||||
|
return new Set((app?.routes ?? []).map(r => r.routeId));
|
||||||
|
}, [catalog, selectedApp]);
|
||||||
|
|
||||||
// Mock node configs for testing
|
// Mock node configs for testing
|
||||||
const nodeConfigs = useMemo(() => {
|
const nodeConfigs = useMemo(() => {
|
||||||
const map = new Map<string, NodeConfig>();
|
const map = new Map<string, NodeConfig>();
|
||||||
@@ -85,6 +93,7 @@ export default function DevDiagram() {
|
|||||||
onNodeSelect={setSelectedNodeId}
|
onNodeSelect={setSelectedNodeId}
|
||||||
onNodeAction={handleNodeAction}
|
onNodeAction={handleNodeAction}
|
||||||
nodeConfigs={nodeConfigs}
|
nodeConfigs={nodeConfigs}
|
||||||
|
knownRouteIds={knownRouteIds}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.placeholder}>
|
<div className={styles.placeholder}>
|
||||||
|
|||||||
Reference in New Issue
Block a user