feat: expose iteration/iterationSize fields for diagram overlay
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m12s
CI / docker (push) Successful in 1m5s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 52s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-01 17:14:36 +02:00
parent e8f9ada1d1
commit cf439248b5
7 changed files with 61 additions and 83 deletions

View File

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

View File

@@ -22,6 +22,8 @@ public final class ProcessorNode {
private final String errorMessage;
private final String errorStackTrace;
private final Map<String, String> 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<String, String> 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<String, String> 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; }

View File

@@ -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<ProcessorRecord> 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<ProcessorNode> 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);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,46 +1,45 @@
import { useCallback, useEffect, useState } from 'react';
import type { IterationInfo, ProcessorNode } from './types';
const WRAPPER_TYPES: Record<string, IterationInfo['type']> = {
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<string, IterationInfo>,
): 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<Map<string, IterationInfo>>(new Map());