fix: separate onException/errorHandler into distinct RouteFlow segments
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 57s
CI / docker (push) Successful in 52s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

ON_EXCEPTION and ERROR_HANDLER nodes are now treated as compound containers
in the ELK diagram renderer, nesting their children. The frontend
diagram-mapping builds separate FlowSegments for each error handler,
displayed as distinct sections in the RouteFlow component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-27 09:15:06 +01:00
parent 62709ce80b
commit 78e12f5cf9
6 changed files with 128 additions and 84 deletions

View File

@@ -99,7 +99,7 @@ public class ElkDiagramRenderer implements DiagramRenderer {
private static final Set<NodeType> COMPOUND_TYPES = EnumSet.of( private static final Set<NodeType> COMPOUND_TYPES = EnumSet.of(
NodeType.EIP_CHOICE, NodeType.EIP_SPLIT, NodeType.TRY_CATCH, NodeType.EIP_CHOICE, NodeType.EIP_SPLIT, NodeType.TRY_CATCH,
NodeType.DO_TRY, NodeType.EIP_LOOP, NodeType.EIP_MULTICAST, NodeType.DO_TRY, NodeType.EIP_LOOP, NodeType.EIP_MULTICAST,
NodeType.EIP_AGGREGATE NodeType.EIP_AGGREGATE, NodeType.ON_EXCEPTION, NodeType.ERROR_HANDLER
); );
public ElkDiagramRenderer() { public ElkDiagramRenderer() {

View File

@@ -1,10 +1,21 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '../client'; import { api } from '../client';
export interface DiagramNode {
id?: string;
label?: string;
type?: string;
x?: number;
y?: number;
width?: number;
height?: number;
children?: DiagramNode[];
}
interface DiagramLayout { interface DiagramLayout {
width?: number; width?: number;
height?: number; height?: number;
nodes?: Array<{ id?: string; label?: string; type?: string; x?: number; y?: number; width?: number; height?: number }>; nodes?: DiagramNode[];
edges?: Array<{ from?: string; to?: string }>; edges?: Array<{ from?: string; to?: string }>;
} }

View File

@@ -21,7 +21,7 @@ import {
} from '../../api/queries/executions' } from '../../api/queries/executions'
import { useDiagramLayout } from '../../api/queries/diagrams' import { useDiagramLayout } from '../../api/queries/diagrams'
import type { ExecutionSummary } from '../../api/types' import type { ExecutionSummary } from '../../api/types'
import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping' import { buildFlowSegments } from '../../utils/diagram-mapping'
import styles from './Dashboard.module.css' import styles from './Dashboard.module.css'
// Row type extends ExecutionSummary with an `id` field for DataTable // Row type extends ExecutionSummary with an `id` field for DataTable
@@ -376,7 +376,7 @@ export default function Dashboard() {
const routeFlows = useMemo(() => { const routeFlows = useMemo(() => {
if (diagram?.nodes) { if (diagram?.nodes) {
return toFlowSegments(mapDiagramToRouteNodes(diagram.nodes || [], procList)).flows return buildFlowSegments(diagram.nodes || [], procList).flows
} }
return [] return []
}, [diagram, procList]) }, [diagram, procList])

View File

@@ -10,7 +10,7 @@ import type { ProcessorStep, RouteNode, NodeBadge, LogEntry, ButtonGroupItem } f
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions' import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
import { useCorrelationChain } from '../../api/queries/correlation' import { useCorrelationChain } from '../../api/queries/correlation'
import { useDiagramLayout } from '../../api/queries/diagrams' import { useDiagramLayout } from '../../api/queries/diagrams'
import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping' import { buildFlowSegments, toFlowSegments } from '../../utils/diagram-mapping'
import { useTracingStore } from '../../stores/tracing-store' import { useTracingStore } from '../../stores/tracing-store'
import { useApplicationConfig, useUpdateApplicationConfig, useReplayExchange } from '../../api/queries/commands' import { useApplicationConfig, useUpdateApplicationConfig, useReplayExchange } from '../../api/queries/commands'
import { useAgents } from '../../api/queries/agents' import { useAgents } from '../../api/queries/agents'
@@ -171,26 +171,30 @@ export default function ExchangeDetail() {
const outputBody = snapshot?.outputBody ?? null const outputBody = snapshot?.outputBody ?? null
// Build RouteFlow nodes from diagram + execution data, split into flow segments // Build RouteFlow nodes from diagram + execution data, split into flow segments
const { routeFlows, flowIndexMap } = useMemo(() => { const { routeFlows, flowNodeIds } = useMemo(() => {
let nodes: RouteNode[]
if (diagram?.nodes) { if (diagram?.nodes) {
// node.id is the processorId (Camel nodeId), so lookup is direct const { flows, nodeIds } = buildFlowSegments(diagram.nodes, procList)
nodes = mapDiagramToRouteNodes(diagram.nodes, procList).map((node, i) => ({ // Apply badges to each node across all flows
let idx = 0
const badgedFlows = flows.map(flow => ({
...flow,
nodes: flow.nodes.map(node => ({
...node, ...node,
badges: badgesFor(diagram.nodes[i]?.id ?? ''), badges: badgesFor(nodeIds[idx++]),
})),
})) }))
} else { return { routeFlows: badgedFlows, flowNodeIds: nodeIds }
// Fallback: build from processor list }
nodes = processors.map((p) => ({ // Fallback: build from processor list (no diagram available)
const nodes = processors.map((p) => ({
name: p.name, name: p.name,
type: 'process' as RouteNode['type'], type: 'process' as RouteNode['type'],
durationMs: p.durationMs, durationMs: p.durationMs,
status: p.status, status: p.status,
badges: badgesFor(p.name), badges: badgesFor(p.name),
})) }))
} const { flows } = toFlowSegments(nodes)
const { flows, indexMap } = toFlowSegments(nodes) return { routeFlows: flows, flowNodeIds: [] as string[] }
return { routeFlows: flows, flowIndexMap: indexMap }
}, [diagram, processors, procList, tracedMap]) }, [diagram, processors, procList, tracedMap])
// ProcessorId lookup: timeline index → processorId // ProcessorId lookup: timeline index → processorId
@@ -204,19 +208,11 @@ export default function ExchangeDetail() {
return ids return ids
}, [procList]) }, [procList])
// ProcessorId lookup: diagram node index → processorId (node.id IS processorId)
const flowProcessorIds: string[] = useMemo(() => {
if (!diagram?.nodes) return processorIds
return diagram.nodes.map(node => node.id ?? '')
}, [diagram, processorIds])
// Map flow display index → processor tree index (for snapshot API) // Map flow display index → processor tree index (for snapshot API)
// flowNodeIds already contains processor IDs in flow-order
const flowToTreeIndex = useMemo(() => const flowToTreeIndex = useMemo(() =>
flowIndexMap.map(diagramIdx => { flowNodeIds.map(pid => pid ? processorIds.indexOf(pid) : -1),
const pid = flowProcessorIds[diagramIdx] [flowNodeIds, processorIds],
return pid ? processorIds.indexOf(pid) : -1
}),
[flowIndexMap, flowProcessorIds, processorIds],
) )
// ── Tracing toggle ────────────────────────────────────────────────────── // ── Tracing toggle ──────────────────────────────────────────────────────
@@ -496,8 +492,7 @@ export default function ExchangeDetail() {
}} }}
selectedIndex={flowToTreeIndex.indexOf(activeIndex)} selectedIndex={flowToTreeIndex.indexOf(activeIndex)}
getActions={(_node, index) => { getActions={(_node, index) => {
const origIdx = flowIndexMap[index] ?? index const pid = flowNodeIds[index] ?? ''
const pid = flowProcessorIds[origIdx]
if (!pid || !detail?.applicationName) return [] if (!pid || !detail?.applicationName) return []
return [{ return [{
label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing', label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing',

View File

@@ -32,7 +32,7 @@ import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../.
import { useApplicationConfig, useUpdateApplicationConfig, useTestExpression } from '../../api/queries/commands'; import { useApplicationConfig, useUpdateApplicationConfig, useTestExpression } from '../../api/queries/commands';
import type { TapDefinition } from '../../api/queries/commands'; import type { TapDefinition } from '../../api/queries/commands';
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types'; import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping'; import { buildFlowSegments } from '../../utils/diagram-mapping';
import styles from './RouteDetail.module.css'; import styles from './RouteDetail.module.css';
// ── Row types ──────────────────────────────────────────────────────────────── // ── Row types ────────────────────────────────────────────────────────────────
@@ -366,7 +366,7 @@ export default function RouteDetail() {
// Route flow from diagram // Route flow from diagram
const diagramFlows = useMemo(() => { const diagramFlows = useMemo(() => {
if (!diagram?.nodes) return []; if (!diagram?.nodes) return [];
return toFlowSegments(mapDiagramToRouteNodes(diagram.nodes, [])).flows; return buildFlowSegments(diagram.nodes, []).flows;
}, [diagram]); }, [diagram]);
// Processor table rows // Processor table rows

View File

@@ -1,12 +1,16 @@
import type { RouteNode, FlowSegment } from '@cameleer/design-system'; import type { RouteNode, FlowSegment } from '@cameleer/design-system';
import type { DiagramNode } from '../api/queries/diagrams';
// Map NodeType strings to RouteNode types // Map NodeType strings to RouteNode types
function mapNodeType(type: string): RouteNode['type'] { function mapNodeType(type: string): RouteNode['type'] {
const lower = type?.toLowerCase() || ''; const lower = type?.toLowerCase() || '';
if (lower.includes('from') || lower === 'endpoint') return 'from'; if (lower.includes('from') || lower === 'endpoint') return 'from';
if (lower.includes('to')) return 'to'; if (lower.includes('exception') || lower.includes('error_handler')
if (lower.includes('choice') || lower.includes('when') || lower.includes('otherwise')) return 'choice'; || lower.includes('dead_letter')) return 'error-handler';
if (lower.includes('error') || lower.includes('dead')) return 'error-handler'; if (lower === 'to' || lower === 'to_dynamic' || lower === 'direct'
|| lower === 'seda') return 'to';
if (lower.includes('choice') || lower.includes('when')
|| lower.includes('otherwise')) return 'choice';
return 'process'; return 'process';
} }
@@ -18,31 +22,21 @@ function mapStatus(status: string | undefined): RouteNode['status'] {
return 'ok'; return 'ok';
} }
/** type ProcessorNode = { processorId?: string; status?: string; durationMs?: number; children?: ProcessorNode[] };
* Maps diagram PositionedNodes + execution ProcessorNodes to RouteFlow RouteNode[] format.
* Joins on processorId → node.id (node IDs are Camel processor IDs). function buildProcMap(processors: ProcessorNode[]): Map<string, ProcessorNode> {
*/ const map = new Map<string, ProcessorNode>();
export function mapDiagramToRouteNodes( function walk(nodes: ProcessorNode[]) {
diagramNodes: Array<{ id?: string; label?: string; type?: string }>,
processors: Array<{ processorId?: string; status?: string; durationMs?: number; children?: any[] }>
): RouteNode[] {
// Flatten processor tree
const flatProcessors: typeof processors = [];
function flatten(nodes: typeof processors) {
for (const n of nodes) { for (const n of nodes) {
flatProcessors.push(n); if (n.processorId) map.set(n.processorId, n);
if (n.children) flatten(n.children); if (n.children) walk(n.children);
} }
} }
flatten(processors || []); walk(processors || []);
return map;
// Build lookup: processorId → processor
const procMap = new Map<string, (typeof flatProcessors)[0]>();
for (const p of flatProcessors) {
if (p.processorId) procMap.set(p.processorId, p);
} }
return diagramNodes.map(node => { function toRouteNode(node: DiagramNode, procMap: Map<string, ProcessorNode>): RouteNode {
const proc = procMap.get(node.id ?? ''); const proc = procMap.get(node.id ?? '');
return { return {
name: node.label || node.id || '', name: node.label || node.id || '',
@@ -51,31 +45,75 @@ export function mapDiagramToRouteNodes(
status: mapStatus(proc?.status), status: mapStatus(proc?.status),
isBottleneck: false, isBottleneck: false,
}; };
});
} }
/** /**
* Splits a flat RouteNode[] into FlowSegment[] (main route + error handlers). * Builds FlowSegment[] from diagram nodes, properly separating error handler
* Returns the segments and an index map: indexMap[newFlatIndex] = originalIndex. * compounds (ON_EXCEPTION, ERROR_HANDLER) into distinct flow sections.
*
* Returns:
* - flows: FlowSegment[] with main route + error handler sections
* - nodeIds: flat array of diagram node IDs aligned across all flows
* (main flow IDs first, then error handler children IDs)
*/ */
export function toFlowSegments(nodes: RouteNode[]): { flows: FlowSegment[]; indexMap: number[] } { export function buildFlowSegments(
const mainIndices: number[] = []; diagramNodes: DiagramNode[],
const errorIndices: number[] = []; processors: ProcessorNode[],
for (let i = 0; i < nodes.length; i++) { ): { flows: FlowSegment[]; nodeIds: string[] } {
if (nodes[i].type === 'error-handler') { const procMap = buildProcMap(processors);
errorIndices.push(i);
const mainNodes: RouteNode[] = [];
const mainIds: string[] = [];
const errorFlows: { label: string; nodes: RouteNode[]; ids: string[] }[] = [];
for (const node of diagramNodes) {
const type = mapNodeType(node.type ?? '');
if (type === 'error-handler' && node.children && node.children.length > 0) {
// Error handler compound → separate flow segment with its children
const children: RouteNode[] = [];
const childIds: string[] = [];
for (const child of node.children) {
children.push(toRouteNode(child, procMap));
childIds.push(child.id ?? '');
}
errorFlows.push({
label: node.label || 'Error Handler',
nodes: children,
ids: childIds,
});
} else { } else {
mainIndices.push(i); // Regular node → main flow
mainNodes.push(toRouteNode(node, procMap));
mainIds.push(node.id ?? '');
} }
} }
const flows: FlowSegment[] = [{ label: 'Main Route', nodes: mainNodes }];
const nodeIds = [...mainIds];
for (const ef of errorFlows) {
flows.push({ label: ef.label, nodes: ef.nodes, variant: 'error' });
nodeIds.push(...ef.ids);
}
return { flows, nodeIds };
}
/**
* Legacy: splits a flat RouteNode[] into FlowSegment[] by type.
* Used as fallback when no diagram data is available.
*/
export function toFlowSegments(nodes: RouteNode[]): { flows: FlowSegment[] } {
const mainNodes = nodes.filter(n => n.type !== 'error-handler');
const errorNodes = nodes.filter(n => n.type === 'error-handler');
const flows: FlowSegment[] = [ const flows: FlowSegment[] = [
{ label: 'Main Route', nodes: mainIndices.map(i => nodes[i]) }, { label: 'Main Route', nodes: mainNodes },
]; ];
if (errorIndices.length > 0) { if (errorNodes.length > 0) {
flows.push({ label: 'Error Handler', nodes: errorIndices.map(i => nodes[i]), variant: 'error' }); flows.push({ label: 'Error Handler', nodes: errorNodes, variant: 'error' });
} }
const indexMap = [...mainIndices, ...errorIndices]; return { flows };
return { flows, indexMap };
} }