Compare commits

2 Commits

Author SHA1 Message Date
hsiegeln
eb9c20e734 feat: drill-down into sub-routes with breadcrumb navigation
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 55s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 43s
Double-click a DIRECT or SEDA node to navigate into that route's
diagram. Breadcrumbs show the route stack and allow clicking back
to any level. Escape key goes back one level.

Route ID resolution handles camelCase endpoint URIs mapping to
kebab-case route IDs (e.g. direct:callGetProduct → call-get-product)
using the catalog's known route IDs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:58:35 +01:00
hsiegeln
f6220a9f89 feat: support ON_COMPLETION handler sections in diagram
Add ON_COMPLETION to backend COMPOUND_TYPES and frontend rendering.
Completion handlers render as teal-tinted sections between the main
flow and error handlers, structurally parallel to onException.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:45:10 +01:00
10 changed files with 241 additions and 64 deletions

View File

@@ -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() {

View File

@@ -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}
/> />

View File

@@ -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' }}

View File

@@ -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}
/> />

View File

@@ -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;

View File

@@ -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') {
if (routeStack.length > 1) {
// Go back one level
setRouteStack(prev => prev.slice(0, -1));
} else {
onNodeSelect?.(''); 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);

View File

@@ -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

View File

@@ -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;
} }

View File

@@ -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) {
// Normalize node coordinates relative to the section's own origin const bounds = computeBounds(hs.nodes);
const shiftedNodes = shiftNodes(es.nodes, offX, offY); const offX = bounds.minX;
const offY = bounds.minY;
const errorNodeIds = new Set<string>(); const shiftedNodes = shiftNodes(hs.nodes, offX, offY);
collectNodeIds(es.nodes, errorNodeIds); const nodeIds = new Set<string>();
collectNodeIds(hs.nodes, nodeIds);
// Shift edge points too const edges = allEdges
const errorEdges = allEdges .filter(e => nodeIds.has(e.sourceId) && nodeIds.has(e.targetId))
.filter(e => errorNodeIds.has(e.sourceId) && errorNodeIds.has(e.targetId))
.map(e => ({ .map(e => ({
...e, ...e,
points: e.points.map(p => [p[0] - offX, p[1] - offY]), points: e.points.map(p => [p[0] - offX, p[1] - offY]),
})); }));
const sectionHeight = bounds.maxY - bounds.minY;
const sectionHeight = errorBounds.maxY - errorBounds.minY; const sectionWidth = bounds.maxX - bounds.minX;
const sectionWidth = errorBounds.maxX - errorBounds.minX;
sections.push({ sections.push({
label: es.label, label: hs.label,
nodes: shiftedNodes, nodes: shiftedNodes,
edges: errorEdges, edges,
offsetY: currentY, offsetY: currentY,
variant: 'error', variant,
}); });
currentY += sectionHeight + SECTION_GAP; currentY += sectionHeight + SECTION_GAP;
if (sectionWidth > maxWidth) maxWidth = sectionWidth; if (sectionWidth > maxWidth) maxWidth = sectionWidth;
} }
};
// Completion handlers first (above error handlers)
addHandlerSections(completionSections, 'completion');
// Then error handlers
addHandlerSections(errorSections, 'error');
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;

View File

@@ -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}>