From 78e12f5cf9c4967753cad49e7577c6b4ed989352 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:15:06 +0100 Subject: [PATCH] fix: separate onException/errorHandler into distinct RouteFlow segments 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) --- .../app/diagram/ElkDiagramRenderer.java | 2 +- ui/src/api/queries/diagrams.ts | 13 +- ui/src/pages/Dashboard/Dashboard.tsx | 4 +- .../pages/ExchangeDetail/ExchangeDetail.tsx | 57 ++++---- ui/src/pages/Routes/RouteDetail.tsx | 4 +- ui/src/utils/diagram-mapping.ts | 132 +++++++++++------- 6 files changed, 128 insertions(+), 84 deletions(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java index 07ffcf38..127f956f 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java @@ -99,7 +99,7 @@ public class ElkDiagramRenderer implements DiagramRenderer { private static final Set COMPOUND_TYPES = EnumSet.of( NodeType.EIP_CHOICE, NodeType.EIP_SPLIT, NodeType.TRY_CATCH, NodeType.DO_TRY, NodeType.EIP_LOOP, NodeType.EIP_MULTICAST, - NodeType.EIP_AGGREGATE + NodeType.EIP_AGGREGATE, NodeType.ON_EXCEPTION, NodeType.ERROR_HANDLER ); public ElkDiagramRenderer() { diff --git a/ui/src/api/queries/diagrams.ts b/ui/src/api/queries/diagrams.ts index 60dd8ec5..7dde3bd6 100644 --- a/ui/src/api/queries/diagrams.ts +++ b/ui/src/api/queries/diagrams.ts @@ -1,10 +1,21 @@ import { useQuery } from '@tanstack/react-query'; 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 { width?: 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 }>; } diff --git a/ui/src/pages/Dashboard/Dashboard.tsx b/ui/src/pages/Dashboard/Dashboard.tsx index 2ee54c21..94745d39 100644 --- a/ui/src/pages/Dashboard/Dashboard.tsx +++ b/ui/src/pages/Dashboard/Dashboard.tsx @@ -21,7 +21,7 @@ import { } from '../../api/queries/executions' import { useDiagramLayout } from '../../api/queries/diagrams' 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' // Row type extends ExecutionSummary with an `id` field for DataTable @@ -376,7 +376,7 @@ export default function Dashboard() { const routeFlows = useMemo(() => { if (diagram?.nodes) { - return toFlowSegments(mapDiagramToRouteNodes(diagram.nodes || [], procList)).flows + return buildFlowSegments(diagram.nodes || [], procList).flows } return [] }, [diagram, procList]) diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx index dc1ec76b..afdb6ce5 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx @@ -10,7 +10,7 @@ import type { ProcessorStep, RouteNode, NodeBadge, LogEntry, ButtonGroupItem } f import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions' import { useCorrelationChain } from '../../api/queries/correlation' 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 { useApplicationConfig, useUpdateApplicationConfig, useReplayExchange } from '../../api/queries/commands' import { useAgents } from '../../api/queries/agents' @@ -171,26 +171,30 @@ export default function ExchangeDetail() { const outputBody = snapshot?.outputBody ?? null // Build RouteFlow nodes from diagram + execution data, split into flow segments - const { routeFlows, flowIndexMap } = useMemo(() => { - let nodes: RouteNode[] + const { routeFlows, flowNodeIds } = useMemo(() => { if (diagram?.nodes) { - // node.id is the processorId (Camel nodeId), so lookup is direct - nodes = mapDiagramToRouteNodes(diagram.nodes, procList).map((node, i) => ({ - ...node, - badges: badgesFor(diagram.nodes[i]?.id ?? ''), - })) - } else { - // Fallback: build from processor list - nodes = processors.map((p) => ({ - name: p.name, - type: 'process' as RouteNode['type'], - durationMs: p.durationMs, - status: p.status, - badges: badgesFor(p.name), + const { flows, nodeIds } = buildFlowSegments(diagram.nodes, procList) + // Apply badges to each node across all flows + let idx = 0 + const badgedFlows = flows.map(flow => ({ + ...flow, + nodes: flow.nodes.map(node => ({ + ...node, + badges: badgesFor(nodeIds[idx++]), + })), })) + return { routeFlows: badgedFlows, flowNodeIds: nodeIds } } - const { flows, indexMap } = toFlowSegments(nodes) - return { routeFlows: flows, flowIndexMap: indexMap } + // Fallback: build from processor list (no diagram available) + const nodes = processors.map((p) => ({ + name: p.name, + type: 'process' as RouteNode['type'], + durationMs: p.durationMs, + status: p.status, + badges: badgesFor(p.name), + })) + const { flows } = toFlowSegments(nodes) + return { routeFlows: flows, flowNodeIds: [] as string[] } }, [diagram, processors, procList, tracedMap]) // ProcessorId lookup: timeline index → processorId @@ -204,19 +208,11 @@ export default function ExchangeDetail() { return ids }, [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) + // flowNodeIds already contains processor IDs in flow-order const flowToTreeIndex = useMemo(() => - flowIndexMap.map(diagramIdx => { - const pid = flowProcessorIds[diagramIdx] - return pid ? processorIds.indexOf(pid) : -1 - }), - [flowIndexMap, flowProcessorIds, processorIds], + flowNodeIds.map(pid => pid ? processorIds.indexOf(pid) : -1), + [flowNodeIds, processorIds], ) // ── Tracing toggle ────────────────────────────────────────────────────── @@ -496,8 +492,7 @@ export default function ExchangeDetail() { }} selectedIndex={flowToTreeIndex.indexOf(activeIndex)} getActions={(_node, index) => { - const origIdx = flowIndexMap[index] ?? index - const pid = flowProcessorIds[origIdx] + const pid = flowNodeIds[index] ?? '' if (!pid || !detail?.applicationName) return [] return [{ label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing', diff --git a/ui/src/pages/Routes/RouteDetail.tsx b/ui/src/pages/Routes/RouteDetail.tsx index bb37ca5a..302b8b68 100644 --- a/ui/src/pages/Routes/RouteDetail.tsx +++ b/ui/src/pages/Routes/RouteDetail.tsx @@ -32,7 +32,7 @@ import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../. import { useApplicationConfig, useUpdateApplicationConfig, useTestExpression } from '../../api/queries/commands'; import type { TapDefinition } from '../../api/queries/commands'; 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'; // ── Row types ──────────────────────────────────────────────────────────────── @@ -366,7 +366,7 @@ export default function RouteDetail() { // Route flow from diagram const diagramFlows = useMemo(() => { if (!diagram?.nodes) return []; - return toFlowSegments(mapDiagramToRouteNodes(diagram.nodes, [])).flows; + return buildFlowSegments(diagram.nodes, []).flows; }, [diagram]); // Processor table rows diff --git a/ui/src/utils/diagram-mapping.ts b/ui/src/utils/diagram-mapping.ts index cd9be40c..b8e234ce 100644 --- a/ui/src/utils/diagram-mapping.ts +++ b/ui/src/utils/diagram-mapping.ts @@ -1,12 +1,16 @@ import type { RouteNode, FlowSegment } from '@cameleer/design-system'; +import type { DiagramNode } from '../api/queries/diagrams'; // Map NodeType strings to RouteNode types function mapNodeType(type: string): RouteNode['type'] { const lower = type?.toLowerCase() || ''; if (lower.includes('from') || lower === 'endpoint') return 'from'; - if (lower.includes('to')) return 'to'; - if (lower.includes('choice') || lower.includes('when') || lower.includes('otherwise')) return 'choice'; - if (lower.includes('error') || lower.includes('dead')) return 'error-handler'; + if (lower.includes('exception') || lower.includes('error_handler') + || lower.includes('dead_letter')) 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'; } @@ -18,64 +22,98 @@ function mapStatus(status: string | undefined): RouteNode['status'] { return 'ok'; } -/** - * Maps diagram PositionedNodes + execution ProcessorNodes to RouteFlow RouteNode[] format. - * Joins on processorId → node.id (node IDs are Camel processor IDs). - */ -export function mapDiagramToRouteNodes( - 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) { +type ProcessorNode = { processorId?: string; status?: string; durationMs?: number; children?: ProcessorNode[] }; + +function buildProcMap(processors: ProcessorNode[]): Map { + const map = new Map(); + function walk(nodes: ProcessorNode[]) { for (const n of nodes) { - flatProcessors.push(n); - if (n.children) flatten(n.children); + if (n.processorId) map.set(n.processorId, n); + if (n.children) walk(n.children); } } - flatten(processors || []); + walk(processors || []); + return map; +} - // Build lookup: processorId → processor - const procMap = new Map(); - for (const p of flatProcessors) { - if (p.processorId) procMap.set(p.processorId, p); - } - - return diagramNodes.map(node => { - const proc = procMap.get(node.id ?? ''); - return { - name: node.label || node.id || '', - type: mapNodeType(node.type ?? ''), - durationMs: proc?.durationMs ?? 0, - status: mapStatus(proc?.status), - isBottleneck: false, - }; - }); +function toRouteNode(node: DiagramNode, procMap: Map): RouteNode { + const proc = procMap.get(node.id ?? ''); + return { + name: node.label || node.id || '', + type: mapNodeType(node.type ?? ''), + durationMs: proc?.durationMs ?? 0, + status: mapStatus(proc?.status), + isBottleneck: false, + }; } /** - * Splits a flat RouteNode[] into FlowSegment[] (main route + error handlers). - * Returns the segments and an index map: indexMap[newFlatIndex] = originalIndex. + * Builds FlowSegment[] from diagram nodes, properly separating error handler + * 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[] } { - const mainIndices: number[] = []; - const errorIndices: number[] = []; - for (let i = 0; i < nodes.length; i++) { - if (nodes[i].type === 'error-handler') { - errorIndices.push(i); +export function buildFlowSegments( + diagramNodes: DiagramNode[], + processors: ProcessorNode[], +): { flows: FlowSegment[]; nodeIds: string[] } { + const procMap = buildProcMap(processors); + + 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 { - 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[] = [ - { label: 'Main Route', nodes: mainIndices.map(i => nodes[i]) }, + { label: 'Main Route', nodes: mainNodes }, ]; - if (errorIndices.length > 0) { - flows.push({ label: 'Error Handler', nodes: errorIndices.map(i => nodes[i]), variant: 'error' }); + if (errorNodes.length > 0) { + flows.push({ label: 'Error Handler', nodes: errorNodes, variant: 'error' }); } - const indexMap = [...mainIndices, ...errorIndices]; - return { flows, indexMap }; + return { flows }; }