From cf439248b5bc28b81e4c6f364e7c5389d10cb0a7 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:14:36 +0200 Subject: [PATCH] feat: expose iteration/iterationSize fields for diagram overlay Replace synthetic wrapper node approach with direct iteration fields: - ProcessorNode gains iteration (child's index) and iterationSize (container's total) fields, populated from ClickHouse flat records - Frontend hooks detect iteration containers from iterationSize != null instead of scanning for wrapper processorTypes - useExecutionOverlay filters children by iteration field instead of wrapper nodes, eliminating ITERATION_WRAPPER_TYPES entirely - Cleaner data contract: API returns exactly what the DB stores Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/core/detail/DetailService.java | 5 ++- .../server/core/detail/ProcessorNode.java | 7 +++ .../core/detail/TreeReconstructionTest.java | 18 +++++--- ui/src/api/schema.d.ts | 4 ++ .../ExecutionDiagram/ExecutionDiagram.tsx | 28 +++--------- .../ExecutionDiagram/useExecutionOverlay.ts | 37 +++------------ .../ExecutionDiagram/useIterationState.ts | 45 +++++++++---------- 7 files changed, 61 insertions(+), 83 deletions(-) diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java index f7a1c7ce..e71d45e9 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java @@ -101,7 +101,7 @@ public class DetailService { p.getDurationMs(), p.getErrorMessage(), p.getErrorStackTrace(), p.getAttributes() != null ? new LinkedHashMap<>(p.getAttributes()) : null, - null, null, null, null, null, + null, null, null, null, null, null, null, p.getResolvedEndpointUri(), p.getErrorType(), p.getErrorCategory(), p.getRootCauseType(), p.getRootCauseMessage(), @@ -144,7 +144,7 @@ public class DetailService { p.errorMessage(), p.errorStacktrace(), parseAttributes(p.attributes()), p.iteration(), p.iterationSize(), - null, null, null, + null, null, null, null, null, p.resolvedEndpointUri(), p.errorType(), p.errorCategory(), p.rootCauseType(), p.rootCauseMessage(), @@ -188,6 +188,7 @@ public class DetailService { p.durationMs() != null ? p.durationMs() : 0L, p.errorMessage(), p.errorStacktrace(), parseAttributes(p.attributes()), + null, null, p.loopIndex(), p.loopSize(), p.splitIndex(), p.splitSize(), p.multicastIndex(), diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ProcessorNode.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ProcessorNode.java index 8c7d41da..ebfb8184 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ProcessorNode.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ProcessorNode.java @@ -22,6 +22,8 @@ public final class ProcessorNode { private final String errorMessage; private final String errorStackTrace; private final Map attributes; + private final Integer iteration; + private final Integer iterationSize; private final Integer loopIndex; private final Integer loopSize; private final Integer splitIndex; @@ -44,6 +46,7 @@ public final class ProcessorNode { Instant startTime, Instant endTime, long durationMs, String errorMessage, String errorStackTrace, Map attributes, + Integer iteration, Integer iterationSize, Integer loopIndex, Integer loopSize, Integer splitIndex, Integer splitSize, Integer multicastIndex, @@ -63,6 +66,8 @@ public final class ProcessorNode { this.errorMessage = errorMessage; this.errorStackTrace = errorStackTrace; this.attributes = attributes; + this.iteration = iteration; + this.iterationSize = iterationSize; this.loopIndex = loopIndex; this.loopSize = loopSize; this.splitIndex = splitIndex; @@ -95,6 +100,8 @@ public final class ProcessorNode { public String getErrorMessage() { return errorMessage; } public String getErrorStackTrace() { return errorStackTrace; } public Map getAttributes() { return attributes; } + public Integer getIteration() { return iteration; } + public Integer getIterationSize() { return iterationSize; } public Integer getLoopIndex() { return loopIndex; } public Integer getLoopSize() { return loopSize; } public Integer getSplitIndex() { return splitIndex; } diff --git a/cameleer3-server-core/src/test/java/com/cameleer3/server/core/detail/TreeReconstructionTest.java b/cameleer3-server-core/src/test/java/com/cameleer3/server/core/detail/TreeReconstructionTest.java index 2bdcdfba..0fdc29e9 100644 --- a/cameleer3-server-core/src/test/java/com/cameleer3/server/core/detail/TreeReconstructionTest.java +++ b/cameleer3-server-core/src/test/java/com/cameleer3/server/core/detail/TreeReconstructionTest.java @@ -192,17 +192,23 @@ class TreeReconstructionTest { @Test void buildTree_seqBasedModel_iterationFields() { - // Verify iteration/iterationSize are populated as loopIndex/loopSize + // Verify iteration/iterationSize are populated on the correct nodes List processors = List.of( - procWithSeq("loop1", "loop", "COMPLETED", 1, null, null, null), - procWithSeq("body", "log", "COMPLETED", 2, 1, 5, 10) + procWithSeq("split1", "split", "COMPLETED", 1, null, null, 3), + procWithSeq("body", "log", "COMPLETED", 2, 1, 0, null), + procWithSeq("body", "log", "COMPLETED", 3, 1, 1, null), + procWithSeq("body", "log", "COMPLETED", 4, 1, 2, null) ); List roots = detailService.buildTree(processors); assertThat(roots).hasSize(1); - ProcessorNode child = roots.get(0).getChildren().get(0); - assertThat(child.getLoopIndex()).isEqualTo(5); - assertThat(child.getLoopSize()).isEqualTo(10); + ProcessorNode container = roots.get(0); + assertThat(container.getIterationSize()).isEqualTo(3); + assertThat(container.getIteration()).isNull(); + assertThat(container.getChildren()).hasSize(3); + assertThat(container.getChildren().get(0).getIteration()).isEqualTo(0); + assertThat(container.getChildren().get(1).getIteration()).isEqualTo(1); + assertThat(container.getChildren().get(2).getIteration()).isEqualTo(2); } } diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 1c7876b7..a2e35ae4 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -1852,6 +1852,10 @@ export interface components { [key: string]: string; }; /** Format: int32 */ + iteration: number; + /** Format: int32 */ + iterationSize: number; + /** Format: int32 */ loopIndex: number; /** Format: int32 */ loopSize: number; diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx index 53bb2a9c..b5239b74 100644 --- a/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx +++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx @@ -20,18 +20,10 @@ interface ExecutionDiagramProps { className?: string; } -const ITERATION_WRAPPER_TYPES = new Set([ - 'loopIteration', 'splitIteration', 'multicastBranch', -]); - -function wrapperIndex(proc: ProcessorNode): number | undefined { - return proc.loopIndex ?? proc.splitIndex ?? proc.multicastIndex ?? undefined; -} - /** * Find a processor in the tree, respecting iteration filtering. - * Only recurses into the selected iteration wrapper so the returned - * ProcessorNode has data from the correct iteration. + * Only recurses into children matching the selected iteration index + * so the returned ProcessorNode has data from the correct iteration. */ function findProcessorInTree( nodes: ProcessorNode[] | undefined, @@ -43,18 +35,10 @@ function findProcessorInTree( for (const n of nodes) { if (!n.processorId) continue; - // Iteration wrapper: only recurse into the selected iteration - if (ITERATION_WRAPPER_TYPES.has(n.processorType)) { - if (parentId && iterationState?.has(parentId)) { - const info = iterationState.get(parentId)!; - const idx = wrapperIndex(n); - if (idx != null && idx !== info.current) continue; - } - if (n.children) { - const found = findProcessorInTree(n.children, processorId, iterationState, n.processorId); - if (found) return found; - } - continue; + // If parent is an iteration container, skip children from other iterations + if (parentId && iterationState?.has(parentId)) { + const info = iterationState.get(parentId)!; + if (n.iteration != null && n.iteration !== info.current) continue; } if (n.processorId === processorId) return n; diff --git a/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts b/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts index 4e506182..ddf5e28f 100644 --- a/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts +++ b/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts @@ -1,26 +1,12 @@ import { useMemo } from 'react'; import type { NodeExecutionState, IterationInfo, ProcessorNode } from './types'; -/** Synthetic wrapper processorTypes emitted by the agent for each iteration. */ -const ITERATION_WRAPPER_TYPES = new Set([ - 'loopIteration', 'splitIteration', 'multicastBranch', -]); - -/** - * Extract the iteration index from a wrapper node. - */ -function wrapperIndex(proc: ProcessorNode): number | undefined { - return proc.loopIndex ?? proc.splitIndex ?? proc.multicastIndex ?? undefined; -} - /** * Recursively walks the ProcessorNode tree and populates an overlay map * keyed by processorId → NodeExecutionState. * - * Iteration wrappers (loopIteration, splitIteration, multicastBranch) are - * used for filtering: only the wrapper matching the selected iteration - * is recursed into. The wrapper itself is not added to the overlay - * (it's synthetic and has no corresponding diagram node). + * For iteration containers (nodes with iterationSize), only children + * whose `iteration` matches the selected index are included. */ function buildOverlay( processors: ProcessorNode[], @@ -31,21 +17,12 @@ function buildOverlay( for (const proc of processors) { if (!proc.processorId) continue; - // Iteration wrapper: filter by selected iteration, skip the wrapper itself. - // Must be checked before the status filter — wrappers may not have a status. - if (ITERATION_WRAPPER_TYPES.has(proc.processorType)) { - if (parentId && iterationState.has(parentId)) { - const info = iterationState.get(parentId)!; - const idx = wrapperIndex(proc); - if (idx != null && idx !== info.current) { - continue; // Skip this wrapper and all its children - } + // If this node's parent is an iteration container, filter by selected iteration + if (parentId && iterationState.has(parentId)) { + const info = iterationState.get(parentId)!; + if (proc.iteration != null && proc.iteration !== info.current) { + continue; // Different iteration than selected — skip this subtree } - // Matching wrapper: don't add to overlay but recurse into children - if (proc.children?.length) { - buildOverlay(proc.children, overlay, iterationState, proc.processorId); - } - continue; } // Regular processor: only include completed/failed nodes diff --git a/ui/src/components/ExecutionDiagram/useIterationState.ts b/ui/src/components/ExecutionDiagram/useIterationState.ts index 39006986..f0fcfbb7 100644 --- a/ui/src/components/ExecutionDiagram/useIterationState.ts +++ b/ui/src/components/ExecutionDiagram/useIterationState.ts @@ -1,46 +1,45 @@ import { useCallback, useEffect, useState } from 'react'; import type { IterationInfo, ProcessorNode } from './types'; -const WRAPPER_TYPES: Record = { - loopIteration: 'loop', - splitIteration: 'split', - multicastBranch: 'multicast', -}; +/** Map container processorType to iteration display type. */ +function inferIterationType(processorType: string | undefined): IterationInfo['type'] { + switch (processorType) { + case 'loop': return 'loop'; + case 'multicast': return 'multicast'; + default: return 'split'; // split, recipientList, and any future iterating EIP + } +} /** - * Walks the processor tree and detects compound nodes that have iteration - * wrapper children (loopIteration, splitIteration, multicastBranch). + * Walks the processor tree and detects iteration containers — + * any node with a non-null iterationSize. */ function detectIterations( processors: ProcessorNode[], result: Map, ): void { for (const proc of processors) { - if (!proc.children?.length || !proc.processorId) continue; + if (!proc.processorId) continue; - // Check if children are iteration wrappers - for (const [wrapperType, iterType] of Object.entries(WRAPPER_TYPES)) { - const wrappers = proc.children.filter(c => c.processorType === wrapperType); - if (wrappers.length > 0) { - result.set(proc.processorId, { - current: 0, - total: wrappers.length, - type: iterType, - }); - break; - } + if (proc.iterationSize != null) { + result.set(proc.processorId, { + current: 0, + total: proc.iterationSize, + type: inferIterationType(proc.processorType), + }); } - // Recurse into children to find nested iterations - detectIterations(proc.children, result); + if (proc.children?.length) { + detectIterations(proc.children, result); + } } } /** * Manages per-compound iteration state for the execution overlay. * - * Scans the processor tree to detect compounds with iteration wrapper - * children and tracks which iteration index is currently selected. + * Scans the processor tree to detect containers with iterationSize + * and tracks which iteration index is currently selected. */ export function useIterationState(processors: ProcessorNode[] | undefined) { const [state, setState] = useState>(new Map());