From edd841ffeb8d67140bb687e0597cf65a42504dfc Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:32:47 +0100 Subject: [PATCH 01/20] feat: add iteration fields to processor execution storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add loop_index, loop_size, split_index, split_size, multicast_index columns to processor_executions table and thread them through the full storage → ingestion → detail pipeline. These fields enable execution overlay to display iteration context for loop, split, and multicast EIPs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/storage/PostgresExecutionStore.java | 23 +++++++++++++++---- .../V8__processor_iteration_fields.sql | 5 ++++ .../server/core/detail/DetailService.java | 5 +++- .../server/core/detail/ProcessorNode.java | 20 +++++++++++++++- .../core/ingestion/IngestionService.java | 5 +++- .../server/core/storage/ExecutionStore.java | 5 +++- 6 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 cameleer3-server-app/src/main/resources/db/migration/V8__processor_iteration_fields.sql diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java index 760cb5e2..5060b718 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java @@ -72,8 +72,9 @@ public class PostgresExecutionStore implements ExecutionStore { INSERT INTO processor_executions (execution_id, processor_id, processor_type, application_name, route_id, depth, parent_processor_id, status, start_time, end_time, duration_ms, error_message, error_stacktrace, - input_body, output_body, input_headers, output_headers, attributes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb) + input_body, output_body, input_headers, output_headers, attributes, + loop_index, loop_size, split_index, split_size, multicast_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?, ?, ?, ?, ?) ON CONFLICT (execution_id, processor_id, start_time) DO UPDATE SET status = EXCLUDED.status, end_time = COALESCE(EXCLUDED.end_time, processor_executions.end_time), @@ -84,7 +85,12 @@ public class PostgresExecutionStore implements ExecutionStore { output_body = COALESCE(EXCLUDED.output_body, processor_executions.output_body), input_headers = COALESCE(EXCLUDED.input_headers, processor_executions.input_headers), output_headers = COALESCE(EXCLUDED.output_headers, processor_executions.output_headers), - attributes = COALESCE(EXCLUDED.attributes, processor_executions.attributes) + attributes = COALESCE(EXCLUDED.attributes, processor_executions.attributes), + loop_index = COALESCE(EXCLUDED.loop_index, processor_executions.loop_index), + loop_size = COALESCE(EXCLUDED.loop_size, processor_executions.loop_size), + split_index = COALESCE(EXCLUDED.split_index, processor_executions.split_index), + split_size = COALESCE(EXCLUDED.split_size, processor_executions.split_size), + multicast_index = COALESCE(EXCLUDED.multicast_index, processor_executions.multicast_index) """, processors.stream().map(p -> new Object[]{ p.executionId(), p.processorId(), p.processorType(), @@ -94,7 +100,9 @@ public class PostgresExecutionStore implements ExecutionStore { p.endTime() != null ? Timestamp.from(p.endTime()) : null, p.durationMs(), p.errorMessage(), p.errorStacktrace(), p.inputBody(), p.outputBody(), p.inputHeaders(), p.outputHeaders(), - p.attributes() + p.attributes(), + p.loopIndex(), p.loopSize(), p.splitIndex(), p.splitSize(), + p.multicastIndex() }).toList()); } @@ -140,7 +148,12 @@ public class PostgresExecutionStore implements ExecutionStore { rs.getString("error_message"), rs.getString("error_stacktrace"), rs.getString("input_body"), rs.getString("output_body"), rs.getString("input_headers"), rs.getString("output_headers"), - rs.getString("attributes")); + rs.getString("attributes"), + rs.getObject("loop_index") != null ? rs.getInt("loop_index") : null, + rs.getObject("loop_size") != null ? rs.getInt("loop_size") : null, + rs.getObject("split_index") != null ? rs.getInt("split_index") : null, + rs.getObject("split_size") != null ? rs.getInt("split_size") : null, + rs.getObject("multicast_index") != null ? rs.getInt("multicast_index") : null); private static Instant toInstant(ResultSet rs, String column) throws SQLException { Timestamp ts = rs.getTimestamp(column); diff --git a/cameleer3-server-app/src/main/resources/db/migration/V8__processor_iteration_fields.sql b/cameleer3-server-app/src/main/resources/db/migration/V8__processor_iteration_fields.sql new file mode 100644 index 00000000..5adb0cce --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V8__processor_iteration_fields.sql @@ -0,0 +1,5 @@ +ALTER TABLE processor_executions ADD COLUMN IF NOT EXISTS loop_index INTEGER; +ALTER TABLE processor_executions ADD COLUMN IF NOT EXISTS loop_size INTEGER; +ALTER TABLE processor_executions ADD COLUMN IF NOT EXISTS split_index INTEGER; +ALTER TABLE processor_executions ADD COLUMN IF NOT EXISTS split_size INTEGER; +ALTER TABLE processor_executions ADD COLUMN IF NOT EXISTS multicast_index INTEGER; 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 fd26e183..c12d3be6 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 @@ -48,7 +48,10 @@ public class DetailService { p.startTime(), p.endTime(), p.durationMs() != null ? p.durationMs() : 0L, p.errorMessage(), p.errorStacktrace(), - parseAttributes(p.attributes()) + parseAttributes(p.attributes()), + 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 32cd14c4..f071352d 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,12 +22,20 @@ public final class ProcessorNode { private final String errorMessage; private final String errorStackTrace; private final Map attributes; + private final Integer loopIndex; + private final Integer loopSize; + private final Integer splitIndex; + private final Integer splitSize; + private final Integer multicastIndex; private final List children; public ProcessorNode(String processorId, String processorType, String status, Instant startTime, Instant endTime, long durationMs, String errorMessage, String errorStackTrace, - Map attributes) { + Map attributes, + Integer loopIndex, Integer loopSize, + Integer splitIndex, Integer splitSize, + Integer multicastIndex) { this.processorId = processorId; this.processorType = processorType; this.status = status; @@ -37,6 +45,11 @@ public final class ProcessorNode { this.errorMessage = errorMessage; this.errorStackTrace = errorStackTrace; this.attributes = attributes; + this.loopIndex = loopIndex; + this.loopSize = loopSize; + this.splitIndex = splitIndex; + this.splitSize = splitSize; + this.multicastIndex = multicastIndex; this.children = new ArrayList<>(); } @@ -53,5 +66,10 @@ public final class ProcessorNode { public String getErrorMessage() { return errorMessage; } public String getErrorStackTrace() { return errorStackTrace; } public Map getAttributes() { return attributes; } + public Integer getLoopIndex() { return loopIndex; } + public Integer getLoopSize() { return loopSize; } + public Integer getSplitIndex() { return splitIndex; } + public Integer getSplitSize() { return splitSize; } + public Integer getMulticastIndex() { return multicastIndex; } public List getChildren() { return List.copyOf(children); } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/ingestion/IngestionService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/ingestion/IngestionService.java index c4fad6fa..f07f67a4 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/ingestion/IngestionService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/ingestion/IngestionService.java @@ -128,7 +128,10 @@ public class IngestionService { p.getErrorMessage(), p.getErrorStackTrace(), truncateBody(p.getInputBody()), truncateBody(p.getOutputBody()), toJson(p.getInputHeaders()), toJson(p.getOutputHeaders()), - toJson(p.getAttributes()) + toJson(p.getAttributes()), + p.getLoopIndex(), p.getLoopSize(), + p.getSplitIndex(), p.getSplitSize(), + p.getMulticastIndex() )); if (p.getChildren() != null) { flat.addAll(flattenProcessors( diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java index ecc076dd..75971586 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java @@ -33,6 +33,9 @@ public interface ExecutionStore { Instant startTime, Instant endTime, Long durationMs, String errorMessage, String errorStacktrace, String inputBody, String outputBody, String inputHeaders, String outputHeaders, - String attributes + String attributes, + Integer loopIndex, Integer loopSize, + Integer splitIndex, Integer splitSize, + Integer multicastIndex ) {} } From cf6c4bd60cf4180f7a2dd0bab6f4078b48135f94 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:34:45 +0100 Subject: [PATCH 02/20] feat: add snapshot-by-processorId endpoint for robust processor lookup Add GET /executions/{id}/processors/by-id/{processorId}/snapshot endpoint that fetches processor snapshot data by processorId instead of positional index, which is fragile when the tree structure changes. The existing index-based endpoint remains unchanged for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/app/controller/DetailController.java | 14 +++++++++++++- .../server/app/storage/PostgresExecutionStore.java | 7 +++++++ .../server/core/detail/DetailService.java | 12 ++++++++++++ .../server/core/storage/ExecutionStore.java | 2 ++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DetailController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DetailController.java index 2bd6ea55..dc705523 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DetailController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DetailController.java @@ -49,7 +49,7 @@ public class DetailController { } @GetMapping("/{executionId}/processors/{index}/snapshot") - @Operation(summary = "Get exchange snapshot for a specific processor") + @Operation(summary = "Get exchange snapshot for a specific processor by index") @ApiResponse(responseCode = "200", description = "Snapshot data") @ApiResponse(responseCode = "404", description = "Snapshot not found") public ResponseEntity> getProcessorSnapshot( @@ -69,4 +69,16 @@ public class DetailController { return ResponseEntity.ok(snapshot); } + + @GetMapping("/{executionId}/processors/by-id/{processorId}/snapshot") + @Operation(summary = "Get exchange snapshot for a specific processor by processorId") + @ApiResponse(responseCode = "200", description = "Snapshot data") + @ApiResponse(responseCode = "404", description = "Snapshot not found") + public ResponseEntity> processorSnapshotById( + @PathVariable String executionId, + @PathVariable String processorId) { + return detailService.getProcessorSnapshot(executionId, processorId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java index 5060b718..61de0828 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java @@ -121,6 +121,13 @@ public class PostgresExecutionStore implements ExecutionStore { PROCESSOR_MAPPER, executionId); } + @Override + public Optional findProcessorById(String executionId, String processorId) { + String sql = "SELECT * FROM processor_executions WHERE execution_id = ? AND processor_id = ? LIMIT 1"; + List results = jdbc.query(sql, PROCESSOR_MAPPER, executionId, processorId); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + private static final RowMapper EXECUTION_MAPPER = (rs, rowNum) -> new ExecutionRecord( rs.getString("execution_id"), rs.getString("route_id"), 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 c12d3be6..236aafc8 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 @@ -38,6 +38,18 @@ public class DetailService { }); } + public Optional> getProcessorSnapshot(String executionId, String processorId) { + return executionStore.findProcessorById(executionId, processorId) + .map(p -> { + Map snapshot = new LinkedHashMap<>(); + if (p.inputBody() != null) snapshot.put("inputBody", p.inputBody()); + if (p.outputBody() != null) snapshot.put("outputBody", p.outputBody()); + if (p.inputHeaders() != null) snapshot.put("inputHeaders", p.inputHeaders()); + if (p.outputHeaders() != null) snapshot.put("outputHeaders", p.outputHeaders()); + return snapshot; + }); + } + List buildTree(List processors) { if (processors.isEmpty()) return List.of(); diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java index 75971586..181dd8f9 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java @@ -16,6 +16,8 @@ public interface ExecutionStore { List findProcessors(String executionId); + Optional findProcessorById(String executionId, String processorId); + record ExecutionRecord( String executionId, String routeId, String agentId, String applicationName, String status, String correlationId, String exchangeId, From 3928743ea76d09f6a5c76e7f5427a75f3d28231d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:38:09 +0100 Subject: [PATCH 03/20] feat: update OpenAPI spec and TypeScript types for execution overlay Add iteration fields (loopIndex, loopSize, splitIndex, splitSize, multicastIndex) to ProcessorNode schema. Add new endpoint path /executions/{executionId}/processors/by-id/{processorId}/snapshot. Remove stale diagramNodeId field that was dropped in V6 migration. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/api/openapi.json | 77 +++++++++++++++++++++++++++++++++++++++-- ui/src/api/schema.d.ts | 64 +++++++++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 4 deletions(-) diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index 3fe82c87..56e71656 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -2359,6 +2359,61 @@ } } }, + "/executions/{executionId}/processors/by-id/{processorId}/snapshot": { + "get": { + "tags": [ + "Detail" + ], + "summary": "Get exchange snapshot for a processor by processorId", + "operationId": "getProcessorSnapshotById", + "parameters": [ + { + "name": "executionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "processorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Snapshot data", + "content": { + "*/*": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "404": { + "description": "Snapshot not found", + "content": { + "*/*": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + }, "/diagrams": { "get": { "tags": [ @@ -4588,8 +4643,25 @@ "type": "integer", "format": "int64" }, - "diagramNodeId": { - "type": "string" + "loopIndex": { + "type": "integer", + "format": "int32" + }, + "loopSize": { + "type": "integer", + "format": "int32" + }, + "splitIndex": { + "type": "integer", + "format": "int32" + }, + "splitSize": { + "type": "integer", + "format": "int32" + }, + "multicastIndex": { + "type": "integer", + "format": "int32" }, "errorMessage": { "type": "string" @@ -4613,7 +4685,6 @@ "required": [ "attributes", "children", - "diagramNodeId", "durationMs", "endTime", "errorMessage", diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 79389d45..43de9ef7 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -735,6 +735,23 @@ export interface paths { patch?: never; trace?: never; }; + "/executions/{executionId}/processors/by-id/{processorId}/snapshot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get exchange snapshot for a processor by processorId */ + get: operations["getProcessorSnapshotById"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/diagrams": { parameters: { query?: never; @@ -1620,7 +1637,16 @@ export interface components { endTime: string; /** Format: int64 */ durationMs: number; - diagramNodeId: string; + /** Format: int32 */ + loopIndex?: number; + /** Format: int32 */ + loopSize?: number; + /** Format: int32 */ + splitIndex?: number; + /** Format: int32 */ + splitSize?: number; + /** Format: int32 */ + multicastIndex?: number; errorMessage: string; errorStackTrace: string; attributes: { @@ -3637,6 +3663,42 @@ export interface operations { }; }; }; + getProcessorSnapshotById: { + parameters: { + query?: never; + header?: never; + path: { + executionId: string; + processorId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Snapshot data */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; + /** @description Snapshot not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; + }; + }; findByApplicationAndRoute: { parameters: { query: { From ff59dc5d573ba49568d12b0bb3d2c6f56ff63957 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:40:57 +0100 Subject: [PATCH 04/20] feat: add execution overlay types and extend ProcessDiagram with diagramLayout prop Define the execution overlay type system (NodeExecutionState, IterationInfo, DetailTab) and extend ProcessDiagramProps with optional overlay props. Add diagramLayout prop so ExecutionDiagram can pass a pre-fetched layout by content hash, bypassing the internal route-based fetch in useDiagramData. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/ExecutionDiagram/types.ts | 24 +++++++++++++++++++ .../ProcessDiagram/ProcessDiagram.tsx | 3 ++- ui/src/components/ProcessDiagram/types.ts | 11 ++++++++- .../ProcessDiagram/useDiagramData.ts | 16 ++++++++++--- 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 ui/src/components/ExecutionDiagram/types.ts diff --git a/ui/src/components/ExecutionDiagram/types.ts b/ui/src/components/ExecutionDiagram/types.ts new file mode 100644 index 00000000..5837fbd3 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/types.ts @@ -0,0 +1,24 @@ +import type { components } from '../../api/schema'; + +export type ExecutionDetail = components['schemas']['ExecutionDetail']; +export type ProcessorNode = components['schemas']['ProcessorNode']; + +export interface NodeExecutionState { + status: 'COMPLETED' | 'FAILED'; + durationMs: number; + /** True if this node's target sub-route failed (DIRECT/SEDA) */ + subRouteFailed?: boolean; + /** True if trace data is available for this processor */ + hasTraceData?: boolean; +} + +export interface IterationInfo { + /** Current iteration index (0-based) */ + current: number; + /** Total number of iterations */ + total: number; + /** Type of iteration (determines label) */ + type: 'loop' | 'split' | 'multicast'; +} + +export type DetailTab = 'info' | 'headers' | 'input' | 'output' | 'error' | 'config' | 'timeline'; diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index 7269c262..d9c6bdbc 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -52,6 +52,7 @@ export function ProcessDiagram({ nodeConfigs, knownRouteIds, className, + diagramLayout, }: ProcessDiagramProps) { // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([routeId]); @@ -64,7 +65,7 @@ export function ProcessDiagram({ const currentRouteId = routeStack[routeStack.length - 1]; const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData( - application, currentRouteId, direction, + application, currentRouteId, direction, diagramLayout, ); const zoom = useZoomPan(); diff --git a/ui/src/components/ProcessDiagram/types.ts b/ui/src/components/ProcessDiagram/types.ts index 41e153e0..01d3776d 100644 --- a/ui/src/components/ProcessDiagram/types.ts +++ b/ui/src/components/ProcessDiagram/types.ts @@ -1,4 +1,5 @@ -import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams'; +import type { DiagramNode, DiagramEdge, DiagramLayout } from '../../api/queries/diagrams'; +import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types'; export type NodeAction = 'inspect' | 'toggle-trace' | 'configure-tap' | 'copy-id'; @@ -26,4 +27,12 @@ export interface ProcessDiagramProps { /** Known route IDs for this application (enables drill-down resolution) */ knownRouteIds?: Set; className?: string; + /** Pre-fetched diagram layout (bypasses internal fetch by application/routeId) */ + diagramLayout?: DiagramLayout; + /** Execution overlay: maps diagram node ID → execution state */ + executionOverlay?: Map; + /** Per-compound iteration info: maps compound node ID → iteration info */ + iterationState?: Map; + /** Called when user changes iteration on a compound stepper */ + onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void; } diff --git a/ui/src/components/ProcessDiagram/useDiagramData.ts b/ui/src/components/ProcessDiagram/useDiagramData.ts index c23fa56b..fbf6a3f9 100644 --- a/ui/src/components/ProcessDiagram/useDiagramData.ts +++ b/ui/src/components/ProcessDiagram/useDiagramData.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useDiagramByRoute } from '../../api/queries/diagrams'; -import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams'; +import type { DiagramNode, DiagramEdge, DiagramLayout } from '../../api/queries/diagrams'; import type { DiagramSection } from './types'; import { isErrorCompoundType, isCompletionCompoundType } from './node-colors'; @@ -10,8 +10,14 @@ export function useDiagramData( application: string, routeId: string, direction: 'LR' | 'TB' = 'LR', + preloadedLayout?: DiagramLayout, ) { - const { data: layout, isLoading, error } = useDiagramByRoute(application, routeId, direction); + // When a preloaded layout is provided, disable the internal fetch + const fetchApp = preloadedLayout ? undefined : application; + const fetchRoute = preloadedLayout ? undefined : routeId; + const { data: fetchedLayout, isLoading, error } = useDiagramByRoute(fetchApp, fetchRoute, direction); + + const layout = preloadedLayout ?? fetchedLayout; const result = useMemo(() => { if (!layout?.nodes) { @@ -106,7 +112,11 @@ export function useDiagramData( return { sections, totalWidth, totalHeight }; }, [layout]); - return { ...result, isLoading, error }; + return { + ...result, + isLoading: preloadedLayout ? false : isLoading, + error: preloadedLayout ? null : error, + }; } /** Shift all node coordinates by subtracting an offset, recursively. */ From 2b805ec196702909ea661bd1d1058efd24e75e05 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:44:16 +0100 Subject: [PATCH 05/20] feat: add execution overlay visual states to DiagramNode DiagramNode now accepts executionState and overlayActive props to render execution status: green tint + checkmark badge for completed nodes, red tint + exclamation badge for failed nodes, dimmed opacity for skipped nodes. Duration is shown at bottom-right, and a drill-down arrow appears for sub-route failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ProcessDiagram/DiagramNode.tsx | 115 ++++++++++++++++-- 1 file changed, 106 insertions(+), 9 deletions(-) diff --git a/ui/src/components/ProcessDiagram/DiagramNode.tsx b/ui/src/components/ProcessDiagram/DiagramNode.tsx index 1fd03d92..ec21587b 100644 --- a/ui/src/components/ProcessDiagram/DiagramNode.tsx +++ b/ui/src/components/ProcessDiagram/DiagramNode.tsx @@ -1,5 +1,6 @@ import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams'; import type { NodeConfig } from './types'; +import type { NodeExecutionState } from '../ExecutionDiagram/types'; import { colorForType, iconForType } from './node-colors'; import { ConfigBadge } from './ConfigBadge'; @@ -11,14 +12,23 @@ interface DiagramNodeProps { isHovered: boolean; isSelected: boolean; config?: NodeConfig; + executionState?: NodeExecutionState; + overlayActive?: boolean; onClick: () => void; onDoubleClick?: () => void; onMouseEnter: () => void; onMouseLeave: () => void; } +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + export function DiagramNode({ - node, isHovered, isSelected, config, onClick, onDoubleClick, onMouseEnter, onMouseLeave, + node, isHovered, isSelected, config, + executionState, overlayActive, + onClick, onDoubleClick, onMouseEnter, onMouseLeave, }: DiagramNodeProps) { const x = node.x ?? 0; const y = node.y ?? 0; @@ -31,6 +41,33 @@ export function DiagramNode({ const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? ''; const detail = node.label || ''; + // Overlay state derivation + const isCompleted = executionState?.status === 'COMPLETED'; + const isFailed = executionState?.status === 'FAILED'; + const isSkipped = overlayActive && !executionState; + + // Colors based on execution state + let cardFill = isHovered ? '#F5F0EA' : 'white'; + let borderStroke = isHovered || isSelected ? color : '#E4DFD8'; + let borderWidth = isHovered || isSelected ? 1.5 : 1; + let topBarColor = color; + let labelColor = '#1A1612'; + + if (isCompleted) { + cardFill = isHovered ? '#E4F5E6' : '#F0F9F1'; + borderStroke = '#3D7C47'; + borderWidth = 1.5; + topBarColor = '#3D7C47'; + } else if (isFailed) { + cardFill = isHovered ? '#F9E4E1' : '#FDF2F0'; + borderStroke = '#C0392B'; + borderWidth = 2; + topBarColor = '#C0392B'; + labelColor = '#C0392B'; + } + + const statusColor = isCompleted ? '#3D7C47' : isFailed ? '#C0392B' : undefined; + return ( {/* Selection ring */} {isSelected && ( @@ -62,34 +100,93 @@ export function DiagramNode({ width={w} height={h} rx={CORNER_RADIUS} - fill={isHovered ? '#F5F0EA' : 'white'} - stroke={isHovered || isSelected ? color : '#E4DFD8'} - strokeWidth={isHovered || isSelected ? 1.5 : 1} + fill={cardFill} + stroke={borderStroke} + strokeWidth={borderWidth} /> {/* Colored top bar */} - - + + {/* Icon */} - + {icon} {/* Type name */} - + {typeName} {/* Detail label (truncated) */} {detail && detail !== typeName && ( - + {detail.length > 22 ? detail.slice(0, 20) + '...' : detail} )} {/* Config badges */} {config && } + + {/* Execution overlay: status badge at top-right */} + {isCompleted && ( + <> + + + ✓ + + + )} + {isFailed && ( + <> + + + ! + + + )} + + {/* Execution overlay: duration text at bottom-right */} + {executionState && statusColor && ( + + {formatDuration(executionState.durationMs)} + + )} + + {/* Sub-route failure: drill-down arrow at bottom-left */} + {isFailed && executionState?.subRouteFailed && ( + + ↳ + + )} ); } From 3029704051f2cef4d5e4d13167eb4ca768c8e811 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:47:59 +0100 Subject: [PATCH 06/20] feat: add traversed/not-traversed visual states to DiagramEdge Add green solid edges for traversed paths and dashed gray for not-traversed when execution overlay is active. Includes green arrowhead marker and overlay threading through CompoundNode and ErrorSection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProcessDiagram/CompoundNode.tsx | 30 ++++++++++++------- .../components/ProcessDiagram/DiagramEdge.tsx | 11 ++++--- .../ProcessDiagram/ErrorSection.tsx | 17 ++++++++--- .../ProcessDiagram/ProcessDiagram.tsx | 24 +++++++++++++-- 4 files changed, 61 insertions(+), 21 deletions(-) diff --git a/ui/src/components/ProcessDiagram/CompoundNode.tsx b/ui/src/components/ProcessDiagram/CompoundNode.tsx index eb5f7c33..f1180c3e 100644 --- a/ui/src/components/ProcessDiagram/CompoundNode.tsx +++ b/ui/src/components/ProcessDiagram/CompoundNode.tsx @@ -1,5 +1,6 @@ import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams'; import type { NodeConfig } from './types'; +import type { NodeExecutionState } from '../ExecutionDiagram/types'; import { colorForType, isCompoundType } from './node-colors'; import { DiagramNode } from './DiagramNode'; import { DiagramEdge } from './DiagramEdge'; @@ -17,6 +18,8 @@ interface CompoundNodeProps { selectedNodeId?: string; hoveredNodeId: string | null; nodeConfigs?: Map; + /** Execution overlay for edge traversal coloring */ + executionOverlay?: Map; onNodeClick: (nodeId: string) => void; onNodeDoubleClick?: (nodeId: string) => void; onNodeEnter: (nodeId: string) => void; @@ -25,7 +28,7 @@ interface CompoundNodeProps { export function CompoundNode({ node, edges, parentX = 0, parentY = 0, - selectedNodeId, hoveredNodeId, nodeConfigs, + selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay, onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave, }: CompoundNodeProps) { const x = (node.x ?? 0) - parentX; @@ -78,15 +81,21 @@ export function CompoundNode({ {/* Internal edges (rendered after background, before children) */} - {internalEdges.map((edge, i) => ( - [p[0] - absX, p[1] - absY]), - }} - /> - ))} + {internalEdges.map((edge, i) => { + const isTraversed = executionOverlay + ? (executionOverlay.has(edge.sourceId) && executionOverlay.has(edge.targetId)) + : undefined; + return ( + [p[0] - absX, p[1] - absY]), + }} + traversed={isTraversed} + /> + ); + })} {/* Children — recurse into compound children, render leaves as DiagramNode */} @@ -102,6 +111,7 @@ export function CompoundNode({ selectedNodeId={selectedNodeId} hoveredNodeId={hoveredNodeId} nodeConfigs={nodeConfigs} + executionOverlay={executionOverlay} onNodeClick={onNodeClick} onNodeDoubleClick={onNodeDoubleClick} onNodeEnter={onNodeEnter} diff --git a/ui/src/components/ProcessDiagram/DiagramEdge.tsx b/ui/src/components/ProcessDiagram/DiagramEdge.tsx index 7c6c5a9c..3b0a8b41 100644 --- a/ui/src/components/ProcessDiagram/DiagramEdge.tsx +++ b/ui/src/components/ProcessDiagram/DiagramEdge.tsx @@ -3,9 +3,11 @@ import type { DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams' interface DiagramEdgeProps { edge: DiagramEdgeType; offsetY?: number; + /** undefined = no overlay (default gray solid), true = traversed (green solid), false = not traversed (gray dashed) */ + traversed?: boolean | undefined; } -export function DiagramEdge({ edge, offsetY = 0 }: DiagramEdgeProps) { +export function DiagramEdge({ edge, offsetY = 0, traversed }: DiagramEdgeProps) { const pts = edge.points; if (!pts || pts.length < 2) return null; @@ -29,9 +31,10 @@ export function DiagramEdge({ edge, offsetY = 0 }: DiagramEdgeProps) { {edge.label && pts.length >= 2 && ( ; + /** Execution overlay for edge traversal coloring */ + executionOverlay?: Map; onNodeClick: (nodeId: string) => void; onNodeDoubleClick?: (nodeId: string) => void; onNodeEnter: (nodeId: string) => void; @@ -28,7 +31,7 @@ const VARIANT_COLORS: Record = { }; export function ErrorSection({ - section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs, + section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay, onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave, }: ErrorSectionProps) { const color = VARIANT_COLORS[section.variant ?? 'error'] ?? VARIANT_COLORS.error; @@ -82,9 +85,14 @@ export function ErrorSection({ {/* Edges */} - {section.edges.map((edge, i) => ( - - ))} + {section.edges.map((edge, i) => { + const isTraversed = executionOverlay + ? (executionOverlay.has(edge.sourceId) && executionOverlay.has(edge.targetId)) + : undefined; + return ( + + ); + })} {/* Nodes */} @@ -99,6 +107,7 @@ export function ErrorSection({ selectedNodeId={selectedNodeId} hoveredNodeId={hoveredNodeId} nodeConfigs={nodeConfigs} + executionOverlay={executionOverlay} onNodeClick={onNodeClick} onNodeDoubleClick={onNodeDoubleClick} onNodeEnter={onNodeEnter} diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index d9c6bdbc..f63b4ffe 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -53,6 +53,7 @@ export function ProcessDiagram({ knownRouteIds, className, diagramLayout, + executionOverlay, }: ProcessDiagramProps) { // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([routeId]); @@ -209,14 +210,29 @@ export function ProcessDiagram({ > + + + {/* Main section top-level edges (not inside compounds) */} - {mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => ( - - ))} + {mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => { + const isTraversed = executionOverlay + ? (executionOverlay.has(edge.sourceId) && executionOverlay.has(edge.targetId)) + : undefined; + return ( + + ); + })} {/* Main section nodes */} @@ -231,6 +247,7 @@ export function ProcessDiagram({ selectedNodeId={selectedNodeId} hoveredNodeId={toolbar.hoveredNodeId} nodeConfigs={nodeConfigs} + executionOverlay={executionOverlay} onNodeClick={handleNodeClick} onNodeDoubleClick={handleNodeDoubleClick} onNodeEnter={toolbar.onNodeEnter} @@ -265,6 +282,7 @@ export function ProcessDiagram({ selectedNodeId={selectedNodeId} hoveredNodeId={toolbar.hoveredNodeId} nodeConfigs={nodeConfigs} + executionOverlay={executionOverlay} onNodeClick={handleNodeClick} onNodeDoubleClick={handleNodeDoubleClick} onNodeEnter={toolbar.onNodeEnter} From 1984c597de39fb90fe541346544cb2fb109cf397 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:52:32 +0100 Subject: [PATCH 07/20] feat: add iteration stepper to compound nodes and thread overlay props Add a left/right stepper widget to compound node headers (LOOP, SPLIT, MULTICAST) when iteration overlay data is present. Thread executionOverlay, overlayActive, iterationState, and onIterationChange props through ProcessDiagram -> CompoundNode -> children and ProcessDiagram -> ErrorSection -> children so leaf DiagramNode instances render with execution state (green/red badges, dimming for skipped nodes). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProcessDiagram/CompoundNode.tsx | 38 ++++++++++++++++++- .../ProcessDiagram/ErrorSection.tsx | 14 ++++++- .../ProcessDiagram/ProcessDiagram.module.css | 33 ++++++++++++++++ .../ProcessDiagram/ProcessDiagram.tsx | 11 ++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/ui/src/components/ProcessDiagram/CompoundNode.tsx b/ui/src/components/ProcessDiagram/CompoundNode.tsx index f1180c3e..2692a2bf 100644 --- a/ui/src/components/ProcessDiagram/CompoundNode.tsx +++ b/ui/src/components/ProcessDiagram/CompoundNode.tsx @@ -1,9 +1,10 @@ import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams'; import type { NodeConfig } from './types'; -import type { NodeExecutionState } from '../ExecutionDiagram/types'; +import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types'; import { colorForType, isCompoundType } from './node-colors'; import { DiagramNode } from './DiagramNode'; import { DiagramEdge } from './DiagramEdge'; +import styles from './ProcessDiagram.module.css'; const HEADER_HEIGHT = 22; const CORNER_RADIUS = 4; @@ -20,6 +21,12 @@ interface CompoundNodeProps { nodeConfigs?: Map; /** Execution overlay for edge traversal coloring */ executionOverlay?: Map; + /** Whether an execution overlay is active (enables dimming of skipped nodes) */ + overlayActive?: boolean; + /** Per-compound iteration state */ + iterationState?: Map; + /** Called when user changes iteration on a compound stepper */ + onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void; onNodeClick: (nodeId: string) => void; onNodeDoubleClick?: (nodeId: string) => void; onNodeEnter: (nodeId: string) => void; @@ -29,6 +36,7 @@ interface CompoundNodeProps { export function CompoundNode({ node, edges, parentX = 0, parentY = 0, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay, + overlayActive, iterationState, onIterationChange, onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave, }: CompoundNodeProps) { const x = (node.x ?? 0) - parentX; @@ -40,6 +48,8 @@ export function CompoundNode({ const color = colorForType(node.type); const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? ''; const label = node.label ? `${typeName}: ${node.label}` : typeName; + const iterationInfo = node.id ? iterationState?.get(node.id) : undefined; + const headerWidth = w; // Collect all descendant node IDs to filter edges that belong inside this compound const descendantIds = new Set(); @@ -79,6 +89,27 @@ export function CompoundNode({ {label} + {/* Iteration stepper (for LOOP, SPLIT, MULTICAST with overlay data) */} + {iterationInfo && ( + +
+ + {iterationInfo.current + 1} / {iterationInfo.total} + +
+
+ )} + {/* Internal edges (rendered after background, before children) */} {internalEdges.map((edge, i) => { @@ -112,6 +143,9 @@ export function CompoundNode({ hoveredNodeId={hoveredNodeId} nodeConfigs={nodeConfigs} executionOverlay={executionOverlay} + overlayActive={overlayActive} + iterationState={iterationState} + onIterationChange={onIterationChange} onNodeClick={onNodeClick} onNodeDoubleClick={onNodeDoubleClick} onNodeEnter={onNodeEnter} @@ -130,6 +164,8 @@ export function CompoundNode({ isHovered={hoveredNodeId === child.id} isSelected={selectedNodeId === child.id} config={child.id ? nodeConfigs?.get(child.id) : undefined} + executionState={executionOverlay?.get(child.id ?? '')} + overlayActive={overlayActive} onClick={() => child.id && onNodeClick(child.id)} onDoubleClick={() => child.id && onNodeDoubleClick?.(child.id)} onMouseEnter={() => child.id && onNodeEnter(child.id)} diff --git a/ui/src/components/ProcessDiagram/ErrorSection.tsx b/ui/src/components/ProcessDiagram/ErrorSection.tsx index 43448c6a..fad5c970 100644 --- a/ui/src/components/ProcessDiagram/ErrorSection.tsx +++ b/ui/src/components/ProcessDiagram/ErrorSection.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import type { DiagramSection } from './types'; import type { NodeConfig } from './types'; -import type { NodeExecutionState } from '../ExecutionDiagram/types'; +import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types'; import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams'; import { DiagramEdge } from './DiagramEdge'; import { DiagramNode } from './DiagramNode'; @@ -19,6 +19,12 @@ interface ErrorSectionProps { nodeConfigs?: Map; /** Execution overlay for edge traversal coloring */ executionOverlay?: Map; + /** Whether an execution overlay is active (enables dimming of skipped nodes) */ + overlayActive?: boolean; + /** Per-compound iteration state */ + iterationState?: Map; + /** Called when user changes iteration on a compound stepper */ + onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void; onNodeClick: (nodeId: string) => void; onNodeDoubleClick?: (nodeId: string) => void; onNodeEnter: (nodeId: string) => void; @@ -32,6 +38,7 @@ const VARIANT_COLORS: Record = { export function ErrorSection({ section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay, + overlayActive, iterationState, onIterationChange, onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave, }: ErrorSectionProps) { const color = VARIANT_COLORS[section.variant ?? 'error'] ?? VARIANT_COLORS.error; @@ -108,6 +115,9 @@ export function ErrorSection({ hoveredNodeId={hoveredNodeId} nodeConfigs={nodeConfigs} executionOverlay={executionOverlay} + overlayActive={overlayActive} + iterationState={iterationState} + onIterationChange={onIterationChange} onNodeClick={onNodeClick} onNodeDoubleClick={onNodeDoubleClick} onNodeEnter={onNodeEnter} @@ -122,6 +132,8 @@ export function ErrorSection({ isHovered={hoveredNodeId === node.id} isSelected={selectedNodeId === node.id} config={node.id ? nodeConfigs?.get(node.id) : undefined} + executionState={executionOverlay?.get(node.id ?? '')} + overlayActive={overlayActive} onClick={() => node.id && onNodeClick(node.id)} onDoubleClick={() => node.id && onNodeDoubleClick?.(node.id)} onMouseEnter={() => node.id && onNodeEnter(node.id)} diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.module.css b/ui/src/components/ProcessDiagram/ProcessDiagram.module.css index c61cd5ad..c70667c0 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.module.css +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.module.css @@ -168,3 +168,36 @@ background: var(--bg-hover, #F5F0EA); color: var(--text-primary, #1A1612); } + +.iterationStepper { + display: flex; + align-items: center; + gap: 2px; + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; + padding: 1px 3px; + font-size: 10px; + color: white; + font-family: inherit; +} + +.iterationStepper button { + width: 16px; + height: 16px; + border: none; + background: rgba(255, 255, 255, 0.2); + color: white; + border-radius: 2px; + cursor: pointer; + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + font-family: inherit; +} + +.iterationStepper button:disabled { + opacity: 0.3; + cursor: default; +} diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index f63b4ffe..5fcbb043 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -54,7 +54,10 @@ export function ProcessDiagram({ className, diagramLayout, executionOverlay, + iterationState, + onIterationChange, }: ProcessDiagramProps) { + const overlayActive = !!executionOverlay; // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([routeId]); @@ -248,6 +251,9 @@ export function ProcessDiagram({ hoveredNodeId={toolbar.hoveredNodeId} nodeConfigs={nodeConfigs} executionOverlay={executionOverlay} + overlayActive={overlayActive} + iterationState={iterationState} + onIterationChange={onIterationChange} onNodeClick={handleNodeClick} onNodeDoubleClick={handleNodeDoubleClick} onNodeEnter={toolbar.onNodeEnter} @@ -262,6 +268,8 @@ export function ProcessDiagram({ isHovered={toolbar.hoveredNodeId === node.id} isSelected={selectedNodeId === node.id} config={node.id ? nodeConfigs?.get(node.id) : undefined} + executionState={executionOverlay?.get(node.id ?? '')} + overlayActive={overlayActive} onClick={() => node.id && handleNodeClick(node.id)} onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)} onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)} @@ -283,6 +291,9 @@ export function ProcessDiagram({ hoveredNodeId={toolbar.hoveredNodeId} nodeConfigs={nodeConfigs} executionOverlay={executionOverlay} + overlayActive={overlayActive} + iterationState={iterationState} + onIterationChange={onIterationChange} onNodeClick={handleNodeClick} onNodeDoubleClick={handleNodeDoubleClick} onNodeEnter={toolbar.onNodeEnter} From 3af1d1f3b61dffc71fb2129189d238eec78327d0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:54:01 +0100 Subject: [PATCH 08/20] feat: add useProcessorSnapshotById hook for snapshot-by-processorId endpoint Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/api/queries/executions.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/ui/src/api/queries/executions.ts b/ui/src/api/queries/executions.ts index 90a2e44b..681ea312 100644 --- a/ui/src/api/queries/executions.ts +++ b/ui/src/api/queries/executions.ts @@ -114,3 +114,25 @@ export function useProcessorSnapshot( enabled: !!executionId && index !== null, }); } + +export function useProcessorSnapshotById( + executionId: string | null, + processorId: string | null, +) { + return useQuery({ + queryKey: ['executions', 'snapshot-by-id', executionId, processorId], + queryFn: async () => { + const { data, error } = await api.GET( + '/executions/{executionId}/processors/by-id/{processorId}/snapshot', + { + params: { + path: { executionId: executionId!, processorId: processorId! }, + }, + }, + ); + if (error) throw new Error('Failed to load snapshot'); + return data!; + }, + enabled: !!executionId && !!processorId, + }); +} From 5da03d0938399f8977d61888ed2daf8d5f8883f3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:56:38 +0100 Subject: [PATCH 09/20] feat: add useExecutionOverlay and useIterationState hooks useExecutionOverlay maps processor tree to overlay state map, handling iteration filtering, sub-route failure detection, and trace data flags. useIterationState detects compound nodes with iterated children and manages per-compound iteration selection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ExecutionDiagram/useExecutionOverlay.ts | 80 ++++++++++++++++ .../ExecutionDiagram/useIterationState.ts | 91 +++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 ui/src/components/ExecutionDiagram/useExecutionOverlay.ts create mode 100644 ui/src/components/ExecutionDiagram/useIterationState.ts diff --git a/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts b/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts new file mode 100644 index 00000000..21736e24 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import type { NodeExecutionState, IterationInfo, ProcessorNode } from './types'; + +/** + * Recursively walks the ProcessorNode tree and populates an overlay map + * keyed by processorId → NodeExecutionState. + * + * Handles iteration filtering: when a processor has a loop/split/multicast + * index, only include it if it matches the currently selected iteration + * for its parent compound node. + */ +function buildOverlay( + processors: ProcessorNode[], + overlay: Map, + iterationState: Map, + parentId?: string, +): void { + for (const proc of processors) { + if (!proc.processorId || !proc.status) continue; + if (proc.status !== 'COMPLETED' && proc.status !== 'FAILED') continue; + + // Iteration filtering: if this processor belongs to an iterated parent, + // only include it when the index matches the selected iteration. + if (parentId && iterationState.has(parentId)) { + const info = iterationState.get(parentId)!; + if (info.type === 'loop' && proc.loopIndex != null) { + if (proc.loopIndex !== info.current) { + // Still recurse into children so nested compounds are discovered, + // but skip adding this processor to the overlay. + continue; + } + } + if (info.type === 'split' && proc.splitIndex != null) { + if (proc.splitIndex !== info.current) { + continue; + } + } + if (info.type === 'multicast' && proc.multicastIndex != null) { + if (proc.multicastIndex !== info.current) { + continue; + } + } + } + + const subRouteFailed = + proc.status === 'FAILED' && + (proc.processorType?.includes('DIRECT') || proc.processorType?.includes('SEDA')); + + overlay.set(proc.processorId, { + status: proc.status as 'COMPLETED' | 'FAILED', + durationMs: proc.durationMs ?? 0, + subRouteFailed: subRouteFailed || undefined, + hasTraceData: true, + }); + + // Recurse into children, passing this processor as the parent for iteration filtering. + if (proc.children?.length) { + buildOverlay(proc.children, overlay, iterationState, proc.processorId); + } + } +} + +/** + * Maps execution data (processor tree) to diagram overlay state. + * + * Returns a Map that tells DiagramNode + * and DiagramEdge how to render each element (success/failure colors, + * traversed edges, etc.). + */ +export function useExecutionOverlay( + processors: ProcessorNode[] | undefined, + iterationState: Map, +): Map { + return useMemo(() => { + if (!processors) return new Map(); + const overlay = new Map(); + buildOverlay(processors, overlay, iterationState); + return overlay; + }, [processors, iterationState]); +} diff --git a/ui/src/components/ExecutionDiagram/useIterationState.ts b/ui/src/components/ExecutionDiagram/useIterationState.ts new file mode 100644 index 00000000..02a61c04 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/useIterationState.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { IterationInfo, ProcessorNode } from './types'; + +/** + * Walks the processor tree and detects compound nodes that have iterated + * children (loop, split, multicast). Populates a map of compoundId → + * IterationInfo so the UI can show stepper widgets and filter iterations. + */ +function detectIterations( + processors: ProcessorNode[], + result: Map, +): void { + for (const proc of processors) { + if (!proc.children?.length) continue; + + // Check if children indicate a loop compound + const loopChild = proc.children.find( + (c) => c.loopSize != null && c.loopSize > 0, + ); + if (loopChild && proc.processorId) { + result.set(proc.processorId, { + current: 0, + total: loopChild.loopSize!, + type: 'loop', + }); + } + + // Check if children indicate a split compound + const splitChild = proc.children.find( + (c) => c.splitSize != null && c.splitSize > 0, + ); + if (splitChild && !loopChild && proc.processorId) { + result.set(proc.processorId, { + current: 0, + total: splitChild.splitSize!, + type: 'split', + }); + } + + // Check if children indicate a multicast compound + if (!loopChild && !splitChild) { + const multicastIndices = new Set(); + for (const child of proc.children) { + if (child.multicastIndex != null) { + multicastIndices.add(child.multicastIndex); + } + } + if (multicastIndices.size > 0 && proc.processorId) { + result.set(proc.processorId, { + current: 0, + total: multicastIndices.size, + type: 'multicast', + }); + } + } + + // Recurse into children to find nested iterations + detectIterations(proc.children, result); + } +} + +/** + * Manages per-compound iteration state for the execution overlay. + * + * Scans the processor tree to detect compounds with iterated children + * and tracks which iteration index is currently selected for each. + */ +export function useIterationState(processors: ProcessorNode[] | undefined) { + const [state, setState] = useState>(new Map()); + + // Initialize iteration info when processors change + useEffect(() => { + if (!processors) return; + const newState = new Map(); + detectIterations(processors, newState); + setState(newState); + }, [processors]); + + const setIteration = useCallback((compoundId: string, index: number) => { + setState((prev) => { + const next = new Map(prev); + const info = next.get(compoundId); + if (info && index >= 0 && index < info.total) { + next.set(compoundId, { ...info, current: index }); + } + return next; + }); + }, []); + + return { iterationState: state, setIteration }; +} From e4c66b13113d8b81ad885cca793f70986769c266 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:01:53 +0100 Subject: [PATCH 10/20] feat: add DetailPanel with 7 tabs for execution diagram overlay Implements the bottom detail panel with processor header bar, tab bar (Info, Headers, Input, Output, Error, Config, Timeline), and all tab content components. Info shows processor/exchange metadata in a grid, Headers fetches per-processor snapshots for side-by-side display, Input/Output render formatted code blocks, Error extracts exception types, Config is a placeholder, and Timeline renders a Gantt chart. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ExecutionDiagram/DetailPanel.tsx | 164 +++++++ .../ExecutionDiagram.module.css | 433 ++++++++++++++++++ .../ExecutionDiagram/tabs/BodyTab.tsx | 70 +++ .../ExecutionDiagram/tabs/ConfigTab.tsx | 9 + .../ExecutionDiagram/tabs/ErrorTab.tsx | 45 ++ .../ExecutionDiagram/tabs/HeadersTab.tsx | 80 ++++ .../ExecutionDiagram/tabs/InfoTab.tsx | 115 +++++ .../ExecutionDiagram/tabs/TimelineTab.tsx | 94 ++++ 8 files changed, 1010 insertions(+) create mode 100644 ui/src/components/ExecutionDiagram/DetailPanel.tsx create mode 100644 ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css create mode 100644 ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx create mode 100644 ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx create mode 100644 ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx create mode 100644 ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx create mode 100644 ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx create mode 100644 ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx diff --git a/ui/src/components/ExecutionDiagram/DetailPanel.tsx b/ui/src/components/ExecutionDiagram/DetailPanel.tsx new file mode 100644 index 00000000..74c90411 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/DetailPanel.tsx @@ -0,0 +1,164 @@ +import { useState, useEffect } from 'react'; +import type { ProcessorNode, ExecutionDetail, DetailTab } from './types'; +import { useProcessorSnapshotById } from '../../api/queries/executions'; +import { InfoTab } from './tabs/InfoTab'; +import { HeadersTab } from './tabs/HeadersTab'; +import { BodyTab } from './tabs/BodyTab'; +import { ErrorTab } from './tabs/ErrorTab'; +import { ConfigTab } from './tabs/ConfigTab'; +import { TimelineTab } from './tabs/TimelineTab'; +import styles from './ExecutionDiagram.module.css'; + +interface DetailPanelProps { + selectedProcessor: ProcessorNode | null; + executionDetail: ExecutionDetail; + executionId: string; + onSelectProcessor: (processorId: string) => void; +} + +const TABS: { key: DetailTab; label: string }[] = [ + { key: 'info', label: 'Info' }, + { key: 'headers', label: 'Headers' }, + { key: 'input', label: 'Input' }, + { key: 'output', label: 'Output' }, + { key: 'error', label: 'Error' }, + { key: 'config', label: 'Config' }, + { key: 'timeline', label: 'Timeline' }, +]; + +function formatDuration(ms: number | undefined): string { + if (ms === undefined || ms === null) return '-'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function statusClass(status: string): string { + const s = status?.toUpperCase(); + if (s === 'COMPLETED') return styles.statusCompleted; + if (s === 'FAILED') return styles.statusFailed; + return ''; +} + +export function DetailPanel({ + selectedProcessor, + executionDetail, + executionId, + onSelectProcessor, +}: DetailPanelProps) { + const [activeTab, setActiveTab] = useState('info'); + + // When selectedProcessor changes, keep current tab unless it was a + // processor-specific tab and now there is no processor selected. + const prevProcessorId = selectedProcessor?.processorId; + useEffect(() => { + // If no processor is selected and we're on a processor-specific tab, go to info + if (!selectedProcessor && (activeTab === 'input' || activeTab === 'output')) { + // Input/Output at exchange level still make sense, keep them + } + }, [prevProcessorId]); // eslint-disable-line react-hooks/exhaustive-deps + + const hasError = selectedProcessor + ? !!selectedProcessor.errorMessage + : !!executionDetail.errorMessage; + + // Fetch snapshot for body tabs when a processor is selected + const snapshotQuery = useProcessorSnapshotById( + selectedProcessor ? executionId : null, + selectedProcessor?.processorId ?? null, + ); + + // Determine body content for Input/Output tabs + let inputBody: string | undefined; + let outputBody: string | undefined; + + if (selectedProcessor && snapshotQuery.data) { + inputBody = snapshotQuery.data.inputBody; + outputBody = snapshotQuery.data.outputBody; + } else if (!selectedProcessor) { + inputBody = executionDetail.inputBody; + outputBody = executionDetail.outputBody; + } + + // Header display + const headerName = selectedProcessor ? selectedProcessor.processorType : 'Exchange'; + const headerStatus = selectedProcessor ? selectedProcessor.status : executionDetail.status; + const headerId = selectedProcessor ? selectedProcessor.processorId : executionDetail.executionId; + const headerDuration = selectedProcessor ? selectedProcessor.durationMs : executionDetail.durationMs; + + return ( +
+ {/* Processor / Exchange header bar */} +
+ {headerName} + + {headerStatus} + + {headerId} + {formatDuration(headerDuration)} +
+ + {/* Tab bar */} +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.key; + const isDisabled = tab.key === 'config'; + const isError = tab.key === 'error' && hasError; + const isErrorGrayed = tab.key === 'error' && !hasError; + + let className = styles.tab; + if (isActive) className += ` ${styles.tabActive}`; + if (isDisabled) className += ` ${styles.tabDisabled}`; + if (isError && !isActive) className += ` ${styles.tabError}`; + if (isErrorGrayed && !isActive) className += ` ${styles.tabDisabled}`; + + return ( + + ); + })} +
+ + {/* Tab content */} +
+ {activeTab === 'info' && ( + + )} + {activeTab === 'headers' && ( + + )} + {activeTab === 'input' && ( + + )} + {activeTab === 'output' && ( + + )} + {activeTab === 'error' && ( + + )} + {activeTab === 'config' && ( + + )} + {activeTab === 'timeline' && ( + + )} +
+
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css b/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css new file mode 100644 index 00000000..c4964460 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css @@ -0,0 +1,433 @@ +/* ========================================================================== + DETAIL PANEL + ========================================================================== */ +.detailPanel { + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-surface, #FFFFFF); + border-top: 1px solid var(--border, #E4DFD8); +} + +.processorHeader { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + padding: 6px 14px; + border-bottom: 1px solid var(--border, #E4DFD8); + background: #FAFAF8; + min-height: 32px; +} + +.processorName { + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #1A1612); +} + +.processorId { + font-size: 11px; + font-family: var(--font-mono, monospace); + color: var(--text-muted, #9C9184); +} + +.processorDuration { + font-size: 11px; + font-family: var(--font-mono, monospace); + color: var(--text-secondary, #5C5347); + margin-left: auto; +} + +/* ========================================================================== + STATUS BADGE + ========================================================================== */ +.statusBadge { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.statusCompleted { + color: var(--success, #3D7C47); + background: #F0F9F1; +} + +.statusFailed { + color: var(--error, #C0392B); + background: #FDF2F0; +} + +/* ========================================================================== + TAB BAR + ========================================================================== */ +.tabBar { + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--border, #E4DFD8); + padding: 0 14px; + background: #FAFAF8; + gap: 0; +} + +.tab { + padding: 6px 12px; + font-size: 11px; + font-family: var(--font-body, inherit); + cursor: pointer; + color: var(--text-muted, #9C9184); + border: none; + background: none; + border-bottom: 2px solid transparent; + transition: color 0.12s, border-color 0.12s; + white-space: nowrap; +} + +.tab:hover { + color: var(--text-secondary, #5C5347); +} + +.tabActive { + color: var(--amber, #C6820E); + border-bottom: 2px solid var(--amber, #C6820E); + font-weight: 600; +} + +.tabDisabled { + opacity: 0.4; + cursor: default; +} + +.tabDisabled:hover { + color: var(--text-muted, #9C9184); +} + +.tabError { + color: var(--error, #C0392B); +} + +.tabError:hover { + color: var(--error, #C0392B); +} + +/* ========================================================================== + TAB CONTENT + ========================================================================== */ +.tabContent { + flex: 1; + overflow-y: auto; + padding: 10px 14px; +} + +/* ========================================================================== + INFO TAB — GRID + ========================================================================== */ +.infoGrid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px 24px; +} + +.fieldLabel { + font-size: 10px; + color: var(--text-muted, #9C9184); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 2px; +} + +.fieldValue { + font-size: 12px; + color: var(--text-primary, #1A1612); + word-break: break-all; +} + +.fieldValueMono { + font-size: 12px; + color: var(--text-primary, #1A1612); + font-family: var(--font-mono, monospace); + word-break: break-all; +} + +/* ========================================================================== + ATTRIBUTE PILLS + ========================================================================== */ +.attributesSection { + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid var(--border, #E4DFD8); +} + +.attributesLabel { + font-size: 10px; + color: var(--text-muted, #9C9184); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.attributesList { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.attributePill { + font-size: 10px; + padding: 2px 8px; + background: var(--bg-hover, #F5F0EA); + border-radius: 10px; + color: var(--text-secondary, #5C5347); + font-family: var(--font-mono, monospace); +} + +/* ========================================================================== + HEADERS TAB — SPLIT + ========================================================================== */ +.headersSplit { + display: flex; + gap: 0; + min-height: 0; +} + +.headersColumn { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.headersColumn + .headersColumn { + border-left: 1px solid var(--border, #E4DFD8); + padding-left: 14px; + margin-left: 14px; +} + +.headersColumnLabel { + font-size: 10px; + font-weight: 600; + color: var(--text-muted, #9C9184); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.headersTable { + width: 100%; + font-size: 11px; + border-collapse: collapse; +} + +.headersTable td { + padding: 3px 0; + border-bottom: 1px solid var(--border, #E4DFD8); + vertical-align: top; +} + +.headersTable tr:last-child td { + border-bottom: none; +} + +.headerKey { + font-family: var(--font-mono, monospace); + font-weight: 600; + color: var(--text-muted, #9C9184); + white-space: nowrap; + padding-right: 12px; + width: 140px; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; +} + +.headerVal { + font-family: var(--font-mono, monospace); + color: var(--text-primary, #1A1612); + word-break: break-all; +} + +/* ========================================================================== + BODY / CODE TAB + ========================================================================== */ +.codeHeader { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.codeFormat { + font-size: 10px; + font-weight: 600; + color: var(--text-muted, #9C9184); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.codeSize { + font-size: 10px; + color: var(--text-muted, #9C9184); + font-family: var(--font-mono, monospace); +} + +.codeCopyBtn { + margin-left: auto; + font-size: 10px; + font-family: var(--font-body, inherit); + padding: 2px 8px; + border: 1px solid var(--border, #E4DFD8); + border-radius: 4px; + background: var(--bg-surface, #FFFFFF); + color: var(--text-secondary, #5C5347); + cursor: pointer; +} + +.codeCopyBtn:hover { + background: var(--bg-hover, #F5F0EA); +} + +.codeBlock { + background: #1A1612; + color: #E4DFD8; + padding: 12px; + border-radius: 6px; + font-family: var(--font-mono, monospace); + font-size: 11px; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + max-height: 400px; + overflow-y: auto; +} + +/* ========================================================================== + ERROR TAB + ========================================================================== */ +.errorType { + font-size: 13px; + font-weight: 600; + color: var(--error, #C0392B); + margin-bottom: 8px; +} + +.errorMessage { + font-size: 12px; + color: var(--text-primary, #1A1612); + background: #FDF2F0; + border: 1px solid #F5D5D0; + border-radius: 6px; + padding: 10px 12px; + margin-bottom: 12px; + line-height: 1.5; + word-break: break-word; +} + +.errorStackTrace { + background: #1A1612; + color: #E4DFD8; + padding: 12px; + border-radius: 6px; + font-family: var(--font-mono, monospace); + font-size: 10px; + line-height: 1.5; + overflow-x: auto; + white-space: pre; + max-height: 300px; + overflow-y: auto; +} + +.errorStackLabel { + font-size: 10px; + font-weight: 600; + color: var(--text-muted, #9C9184); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +/* ========================================================================== + TIMELINE / GANTT TAB + ========================================================================== */ +.ganttContainer { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ganttRow { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 3px 4px; + border-radius: 3px; + transition: background 0.1s; +} + +.ganttRow:hover { + background: var(--bg-hover, #F5F0EA); +} + +.ganttSelected { + background: #FFF8F0; +} + +.ganttSelected:hover { + background: #FFF8F0; +} + +.ganttLabel { + width: 100px; + min-width: 100px; + font-size: 10px; + color: var(--text-secondary, #5C5347); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ganttBar { + flex: 1; + height: 16px; + background: var(--bg-hover, #F5F0EA); + border-radius: 2px; + position: relative; + min-width: 0; +} + +.ganttFill { + position: absolute; + height: 100%; + border-radius: 2px; + min-width: 2px; +} + +.ganttFillCompleted { + background: var(--success, #3D7C47); +} + +.ganttFillFailed { + background: var(--error, #C0392B); +} + +.ganttDuration { + width: 50px; + min-width: 50px; + font-size: 10px; + font-family: var(--font-mono, monospace); + color: var(--text-muted, #9C9184); + text-align: right; +} + +/* ========================================================================== + EMPTY STATE + ========================================================================== */ +.emptyState { + text-align: center; + color: var(--text-muted, #9C9184); + font-size: 12px; + padding: 20px; +} diff --git a/ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx b/ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx new file mode 100644 index 00000000..1ef04535 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import styles from '../ExecutionDiagram.module.css'; + +interface BodyTabProps { + body: string | undefined; + label: string; +} + +function detectFormat(text: string): 'JSON' | 'XML' | 'Text' { + const trimmed = text.trimStart(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + JSON.parse(text); + return 'JSON'; + } catch { + // not valid JSON + } + } + if (trimmed.startsWith('<')) return 'XML'; + return 'Text'; +} + +function formatBody(text: string, format: string): string { + if (format === 'JSON') { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + } + return text; +} + +function byteSize(text: string): string { + const bytes = new TextEncoder().encode(text).length; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function BodyTab({ body, label }: BodyTabProps) { + const [copied, setCopied] = useState(false); + + if (!body) { + return
No {label.toLowerCase()} body available
; + } + + const format = detectFormat(body); + const formatted = formatBody(body, format); + + function handleCopy() { + navigator.clipboard.writeText(body!).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + } + + return ( +
+
+ {format} + {byteSize(body)} + +
+
{formatted}
+
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx b/ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx new file mode 100644 index 00000000..da6cf21d --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx @@ -0,0 +1,9 @@ +import styles from '../ExecutionDiagram.module.css'; + +export function ConfigTab() { + return ( +
+ Processor configuration data is not yet available. +
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx b/ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx new file mode 100644 index 00000000..511d4e8b --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx @@ -0,0 +1,45 @@ +import type { ProcessorNode, ExecutionDetail } from '../types'; +import styles from '../ExecutionDiagram.module.css'; + +interface ErrorTabProps { + processor: ProcessorNode | null; + executionDetail: ExecutionDetail; +} + +function extractExceptionType(errorMessage: string): string { + const colonIdx = errorMessage.indexOf(':'); + if (colonIdx > 0) { + return errorMessage.substring(0, colonIdx).trim(); + } + return 'Error'; +} + +export function ErrorTab({ processor, executionDetail }: ErrorTabProps) { + const errorMessage = processor?.errorMessage || executionDetail.errorMessage; + const errorStackTrace = processor?.errorStackTrace || executionDetail.errorStackTrace; + + if (!errorMessage) { + return ( +
+ {processor + ? 'No error on this processor' + : 'No error on this exchange'} +
+ ); + } + + const exceptionType = extractExceptionType(errorMessage); + + return ( +
+
{exceptionType}
+
{errorMessage}
+ {errorStackTrace && ( + <> +
Stack Trace
+
{errorStackTrace}
+ + )} +
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx b/ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx new file mode 100644 index 00000000..762133b6 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx @@ -0,0 +1,80 @@ +import { useProcessorSnapshotById } from '../../../api/queries/executions'; +import styles from '../ExecutionDiagram.module.css'; + +interface HeadersTabProps { + executionId: string; + processorId: string | null; + exchangeInputHeaders?: string; + exchangeOutputHeaders?: string; +} + +function parseHeaders(json: string | undefined): Record { + if (!json) return {}; + try { + return JSON.parse(json); + } catch { + return {}; + } +} + +function HeaderTable({ headers }: { headers: Record }) { + const entries = Object.entries(headers); + if (entries.length === 0) { + return
No headers
; + } + return ( + + + {entries.map(([k, v]) => ( + + + + + ))} + +
{k}{v}
+ ); +} + +export function HeadersTab({ + executionId, + processorId, + exchangeInputHeaders, + exchangeOutputHeaders, +}: HeadersTabProps) { + const snapshotQuery = useProcessorSnapshotById( + processorId ? executionId : null, + processorId, + ); + + let inputHeaders: Record; + let outputHeaders: Record; + + if (processorId && snapshotQuery.data) { + inputHeaders = parseHeaders(snapshotQuery.data.inputHeaders); + outputHeaders = parseHeaders(snapshotQuery.data.outputHeaders); + } else if (!processorId) { + inputHeaders = parseHeaders(exchangeInputHeaders); + outputHeaders = parseHeaders(exchangeOutputHeaders); + } else { + inputHeaders = {}; + outputHeaders = {}; + } + + if (processorId && snapshotQuery.isLoading) { + return
Loading headers...
; + } + + return ( +
+
+
Input Headers
+ +
+
+
Output Headers
+ +
+
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx b/ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx new file mode 100644 index 00000000..65f59566 --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx @@ -0,0 +1,115 @@ +import type { ProcessorNode, ExecutionDetail } from '../types'; +import styles from '../ExecutionDiagram.module.css'; + +interface InfoTabProps { + processor: ProcessorNode | null; + executionDetail: ExecutionDetail; +} + +function formatTime(iso: string | undefined): string { + if (!iso) return '-'; + try { + const d = new Date(iso); + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + const s = String(d.getSeconds()).padStart(2, '0'); + const ms = String(d.getMilliseconds()).padStart(3, '0'); + return `${h}:${m}:${s}.${ms}`; + } catch { + return iso; + } +} + +function formatDuration(ms: number | undefined): string { + if (ms === undefined || ms === null) return '-'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function statusClass(status: string): string { + const s = status?.toUpperCase(); + if (s === 'COMPLETED') return styles.statusCompleted; + if (s === 'FAILED') return styles.statusFailed; + return ''; +} + +function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value || '-'}
+
+ ); +} + +function Attributes({ attrs }: { attrs: Record | undefined }) { + if (!attrs) return null; + const entries = Object.entries(attrs); + if (entries.length === 0) return null; + + return ( +
+
Attributes
+
+ {entries.map(([k, v]) => ( + + {k}: {v} + + ))} +
+
+ ); +} + +export function InfoTab({ processor, executionDetail }: InfoTabProps) { + if (processor) { + return ( +
+
+ + +
+
Status
+ + {processor.status} + +
+ + + + + + + +
+
+ +
+ ); + } + + // Exchange-level view + return ( +
+
+ + + + + + +
+
Status
+ + {executionDetail.status} + +
+ + + + +
+ +
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx b/ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx new file mode 100644 index 00000000..1438b9fb --- /dev/null +++ b/ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx @@ -0,0 +1,94 @@ +import type { ExecutionDetail, ProcessorNode } from '../types'; +import styles from '../ExecutionDiagram.module.css'; + +interface TimelineTabProps { + executionDetail: ExecutionDetail; + selectedProcessorId: string | null; + onSelectProcessor: (id: string) => void; +} + +interface FlatProcessor { + processorId: string; + processorType: string; + status: string; + startTime: string; + durationMs: number; + depth: number; +} + +function flattenProcessors( + nodes: ProcessorNode[], + depth: number, + result: FlatProcessor[], +): void { + for (const node of nodes) { + const status = node.status?.toUpperCase(); + if (status === 'COMPLETED' || status === 'FAILED') { + result.push({ + processorId: node.processorId, + processorType: node.processorType, + status, + startTime: node.startTime, + durationMs: node.durationMs, + depth, + }); + } + if (node.children && node.children.length > 0) { + flattenProcessors(node.children, depth + 1, result); + } + } +} + +export function TimelineTab({ + executionDetail, + selectedProcessorId, + onSelectProcessor, +}: TimelineTabProps) { + const flat: FlatProcessor[] = []; + flattenProcessors(executionDetail.processors || [], 0, flat); + + if (flat.length === 0) { + return
No processor timeline data available
; + } + + const execStart = new Date(executionDetail.startTime).getTime(); + const totalDuration = executionDetail.durationMs || 1; + + return ( +
+ {flat.map((proc) => { + const procStart = new Date(proc.startTime).getTime(); + const offsetPct = Math.max(0, ((procStart - execStart) / totalDuration) * 100); + const widthPct = Math.max(0.5, (proc.durationMs / totalDuration) * 100); + const isSelected = proc.processorId === selectedProcessorId; + const fillClass = proc.status === 'FAILED' + ? styles.ganttFillFailed + : styles.ganttFillCompleted; + + return ( +
onSelectProcessor(proc.processorId)} + > +
+ {' '.repeat(proc.depth)}{proc.processorType || proc.processorId} +
+
+
+
+
+ {proc.durationMs}ms +
+
+ ); + })} +
+ ); +} From 5ccefa3cdb2282e9a4dc8c98d46791002cd622a8 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:05:43 +0100 Subject: [PATCH 11/20] feat: add ExecutionDiagram wrapper component Composes ProcessDiagram with execution overlay data, exchange summary bar, resizable splitter, and detail panel into a single root component. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ExecutionDiagram.module.css | 102 +++++++++ .../ExecutionDiagram/ExecutionDiagram.tsx | 210 ++++++++++++++++++ ui/src/components/ExecutionDiagram/index.ts | 2 + 3 files changed, 314 insertions(+) create mode 100644 ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx create mode 100644 ui/src/components/ExecutionDiagram/index.ts diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css b/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css index c4964460..4c50b751 100644 --- a/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css +++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css @@ -1,3 +1,105 @@ +/* ========================================================================== + EXECUTION DIAGRAM — LAYOUT + ========================================================================== */ +.executionDiagram { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 400px; + overflow: hidden; +} + +.exchangeBar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--bg-surface, #FFFFFF); + border-bottom: 1px solid var(--border, #E4DFD8); + font-size: 12px; + color: var(--text-secondary, #5C5347); + flex-shrink: 0; +} + +.exchangeLabel { + font-weight: 600; + color: var(--text-primary, #1A1612); +} + +.exchangeId { + font-size: 11px; + background: var(--bg-hover, #F5F0EA); + padding: 2px 6px; + border-radius: 3px; + color: var(--text-primary, #1A1612); +} + +.exchangeMeta { + color: var(--text-muted, #9C9184); +} + +.jumpToError { + margin-left: auto; + font-size: 10px; + padding: 3px 10px; + border: 1px solid var(--error, #C0392B); + background: #FDF2F0; + color: var(--error, #C0392B); + border-radius: 4px; + cursor: pointer; + font-weight: 500; + font-family: inherit; +} + +.jumpToError:hover { + background: #F9E0DC; +} + +.diagramArea { + overflow: hidden; + position: relative; +} + +.splitter { + height: 4px; + background: var(--border, #E4DFD8); + cursor: row-resize; + flex-shrink: 0; +} + +.splitter:hover { + background: var(--amber, #C6820E); +} + +.detailArea { + overflow: hidden; + min-height: 120px; +} + +.loadingState { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: var(--text-muted, #9C9184); + font-size: 13px; +} + +.errorState { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: var(--error, #C0392B); + font-size: 13px; +} + +.statusRunning { + color: var(--amber, #C6820E); + background: #FFF8F0; +} + /* ========================================================================== DETAIL PANEL ========================================================================== */ diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx new file mode 100644 index 00000000..e19ddf7e --- /dev/null +++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx @@ -0,0 +1,210 @@ +import { useCallback, useRef, useState } from 'react'; +import type { NodeAction, NodeConfig } from '../ProcessDiagram/types'; +import type { ExecutionDetail, ProcessorNode } from './types'; +import { useExecutionDetail } from '../../api/queries/executions'; +import { useDiagramLayout } from '../../api/queries/diagrams'; +import { ProcessDiagram } from '../ProcessDiagram'; +import { DetailPanel } from './DetailPanel'; +import { useExecutionOverlay } from './useExecutionOverlay'; +import { useIterationState } from './useIterationState'; +import styles from './ExecutionDiagram.module.css'; + +interface ExecutionDiagramProps { + executionId: string; + executionDetail?: ExecutionDetail; + direction?: 'LR' | 'TB'; + knownRouteIds?: Set; + onNodeAction?: (nodeId: string, action: NodeAction) => void; + nodeConfigs?: Map; + className?: string; +} + +function findProcessorInTree( + nodes: ProcessorNode[] | undefined, + processorId: string | null, +): ProcessorNode | null { + if (!nodes || !processorId) return null; + for (const n of nodes) { + if (n.processorId === processorId) return n; + if (n.children) { + const found = findProcessorInTree(n.children, processorId); + if (found) return found; + } + } + return null; +} + +function findFailedProcessor(nodes: ProcessorNode[]): ProcessorNode | null { + for (const n of nodes) { + if (n.status === 'FAILED') return n; + if (n.children) { + const found = findFailedProcessor(n.children); + if (found) return found; + } + } + return null; +} + +function statusBadgeClass(status: string): string { + const s = status?.toUpperCase(); + if (s === 'COMPLETED') return `${styles.statusBadge} ${styles.statusCompleted}`; + if (s === 'FAILED') return `${styles.statusBadge} ${styles.statusFailed}`; + if (s === 'RUNNING') return `${styles.statusBadge} ${styles.statusRunning}`; + return styles.statusBadge; +} + +export function ExecutionDiagram({ + executionId, + executionDetail: externalDetail, + direction = 'LR', + knownRouteIds, + onNodeAction, + nodeConfigs, + className, +}: ExecutionDiagramProps) { + // 1. Fetch execution data (skip if pre-fetched prop provided) + const detailQuery = useExecutionDetail(externalDetail ? null : executionId); + const detail = externalDetail ?? detailQuery.data; + const detailLoading = !externalDetail && detailQuery.isLoading; + const detailError = !externalDetail && detailQuery.error; + + // 2. Load diagram by content hash + const diagramQuery = useDiagramLayout(detail?.diagramContentHash ?? null, direction); + const diagramLayout = diagramQuery.data; + const diagramLoading = diagramQuery.isLoading; + const diagramError = diagramQuery.error; + + // 3. Initialize iteration state + const { iterationState, setIteration } = useIterationState(detail?.processors); + + // 4. Compute overlay + const overlay = useExecutionOverlay(detail?.processors, iterationState); + + // 5. Manage selection + const [selectedProcessorId, setSelectedProcessorId] = useState(''); + + // 6. Resizable splitter state + const [splitPercent, setSplitPercent] = useState(60); + const containerRef = useRef(null); + + const handleSplitterDown = useCallback((e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + const container = containerRef.current; + if (!container) return; + const onMove = (me: PointerEvent) => { + const rect = container.getBoundingClientRect(); + const y = me.clientY - rect.top; + const pct = Math.min(85, Math.max(30, (y / rect.height) * 100)); + setSplitPercent(pct); + }; + const onUp = () => { + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + }; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + }, []); + + // Jump to error: find first FAILED processor and select it + const handleJumpToError = useCallback(() => { + if (!detail?.processors) return; + const failed = findFailedProcessor(detail.processors); + if (failed?.processorId) { + setSelectedProcessorId(failed.processorId); + } + }, [detail?.processors]); + + // Loading state + if (detailLoading || (detail && diagramLoading)) { + return ( +
+
Loading execution data...
+
+ ); + } + + // Error state + if (detailError) { + return ( +
+
Failed to load execution detail
+
+ ); + } + + if (diagramError) { + return ( +
+
Failed to load diagram
+
+ ); + } + + if (!detail) { + return ( +
+
No execution data
+
+ ); + } + + return ( +
+ {/* Exchange summary bar */} +
+ Exchange + {detail.exchangeId || detail.executionId} + + {detail.status} + + + {detail.applicationName} / {detail.routeId} + + {detail.durationMs}ms + {detail.status === 'FAILED' && ( + + )} +
+ + {/* Diagram area */} +
+ +
+ + {/* Resizable splitter */} +
+ + {/* Detail panel */} +
+ +
+
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/index.ts b/ui/src/components/ExecutionDiagram/index.ts new file mode 100644 index 00000000..45e339eb --- /dev/null +++ b/ui/src/components/ExecutionDiagram/index.ts @@ -0,0 +1,2 @@ +export { ExecutionDiagram } from './ExecutionDiagram'; +export type { NodeExecutionState, IterationInfo, DetailTab } from './types'; From 021a52e56b2d63d970c47e4166c71ac0137d2ce9 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:12:11 +0100 Subject: [PATCH 12/20] feat: integrate ExecutionDiagram into ExchangeDetail flow view Replace the RouteFlow-based flow view with the new ExecutionDiagram component which provides execution overlay, iteration stepping, and an integrated detail panel. The gantt view and all other page sections remain unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ExchangeDetail/ExchangeDetail.module.css | 11 ++ .../pages/ExchangeDetail/ExchangeDetail.tsx | 120 ++++++++---------- 2 files changed, 65 insertions(+), 66 deletions(-) diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css b/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css index 5c977839..2d18e19f 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css @@ -265,6 +265,17 @@ padding: 12px 16px; } +/* ========================================================================== + EXECUTION DIAGRAM CONTAINER (Flow view) + ========================================================================== */ +.executionDiagramContainer { + height: 600px; + border: 1px solid var(--border, #E4DFD8); + border-radius: var(--radius-md, 8px); + overflow: hidden; + margin-bottom: 16px; +} + /* ========================================================================== DETAIL SPLIT (IN / OUT panels) ========================================================================== */ diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx index afdb6ce5..70216452 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx @@ -2,19 +2,21 @@ import { useState, useMemo, useCallback, useEffect } from 'react' import { useParams, useNavigate } from 'react-router' import { Badge, StatusDot, MonoText, CodeBlock, InfoCallout, - ProcessorTimeline, Spinner, RouteFlow, useToast, + ProcessorTimeline, Spinner, useToast, LogViewer, ButtonGroup, SectionHeader, useBreadcrumb, Modal, Tabs, Button, Select, Input, Textarea, + useGlobalFilters, } from '@cameleer/design-system' -import type { ProcessorStep, RouteNode, NodeBadge, LogEntry, ButtonGroupItem } from '@cameleer/design-system' +import type { ProcessorStep, NodeBadge, LogEntry, ButtonGroupItem } from '@cameleer/design-system' import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions' import { useCorrelationChain } from '../../api/queries/correlation' -import { useDiagramLayout } from '../../api/queries/diagrams' -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' import { useApplicationLogs } from '../../api/queries/logs' +import { useRouteCatalog } from '../../api/queries/catalog' +import { ExecutionDiagram } from '../../components/ExecutionDiagram' +import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types' import styles from './ExchangeDetail.module.css' const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [ @@ -88,7 +90,6 @@ export default function ExchangeDetail() { const { data: detail, isLoading } = useExecutionDetail(id ?? null) const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null) - const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null) const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt') const [logSearch, setLogSearch] = useState('') @@ -170,33 +171,6 @@ export default function ExchangeDetail() { const inputBody = snapshot?.inputBody ?? null const outputBody = snapshot?.outputBody ?? null - // Build RouteFlow nodes from diagram + execution data, split into flow segments - const { routeFlows, flowNodeIds } = useMemo(() => { - if (diagram?.nodes) { - 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 } - } - // 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 const processorIds: string[] = useMemo(() => { const ids: string[] = [] @@ -208,12 +182,6 @@ export default function ExchangeDetail() { return ids }, [procList]) - // Map flow display index → processor tree index (for snapshot API) - // flowNodeIds already contains processor IDs in flow-order - const flowToTreeIndex = useMemo(() => - flowNodeIds.map(pid => pid ? processorIds.indexOf(pid) : -1), - [flowNodeIds, processorIds], - ) // ── Tracing toggle ────────────────────────────────────────────────────── const { toast } = useToast() @@ -248,6 +216,36 @@ export default function ExchangeDetail() { }) }, [detail, app, appConfig, tracingStore, updateConfig, toast]) + // ── ExecutionDiagram support ────────────────────────────────────────── + const { timeRange } = useGlobalFilters() + const { data: catalog } = useRouteCatalog( + timeRange.start.toISOString(), + timeRange.end.toISOString(), + ) + + const knownRouteIds = useMemo(() => { + if (!catalog || !app) return new Set() + const appEntry = (catalog as Array<{ appId: string; routes?: Array<{ routeId: string }> }>) + .find(a => a.appId === app) + return new Set((appEntry?.routes ?? []).map(r => r.routeId)) + }, [catalog, app]) + + const nodeConfigs = useMemo(() => { + const map = new Map() + if (tracedMap) { + for (const pid of Object.keys(tracedMap)) { + map.set(pid, { traceEnabled: true }) + } + } + return map + }, [tracedMap]) + + const handleNodeAction = useCallback((nodeId: string, action: NodeAction) => { + if (action === 'toggle-trace') { + handleToggleTracing(nodeId) + } + }, [handleToggleTracing]) + // ── Replay ───────────────────────────────────────────────────────────── const { data: liveAgents } = useAgents('LIVE', detail?.applicationName) const replay = useReplayExchange() @@ -461,9 +459,9 @@ export default function ExchangeDetail() {
-
- {timelineView === 'gantt' ? ( - processors.length > 0 ? ( + {timelineView === 'gantt' && ( +
+ {processors.length > 0 ? ( ) : ( No processor data available - ) - ) : ( - routeFlows.length > 0 ? ( - { - const treeIdx = flowToTreeIndex[index] - if (treeIdx >= 0) setSelectedProcessorIndex(treeIdx) - }} - selectedIndex={flowToTreeIndex.indexOf(activeIndex)} - getActions={(_node, index) => { - const pid = flowNodeIds[index] ?? '' - if (!pid || !detail?.applicationName) return [] - return [{ - label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing', - onClick: () => handleToggleTracing(pid), - disabled: updateConfig.isPending, - }] - }} - /> - ) : ( - - ) - )} -
+ )} +
+ )}
+ {timelineView === 'flow' && detail && ( +
+ +
+ )} + {/* Exchange-level body (start/end of route) */} {detail && (detail.inputBody || detail.outputBody) && (
From f675451384f565f89dc4ec240313707800c6a039 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:24:09 +0100 Subject: [PATCH 13/20] fix: use non-passive wheel listener to prevent page scroll during diagram zoom React's onWheel is passive by default, so preventDefault() doesn't stop page scrolling. Attach native wheel listener with { passive: false } via useEffect instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProcessDiagram/ProcessDiagram.tsx | 2 +- .../components/ProcessDiagram/useZoomPan.ts | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index 5fcbb043..f3cadaac 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -193,8 +193,8 @@ export function ProcessDiagram({ )} (null); + const svgRef = useRef(null); const clampScale = (s: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s)); /** Returns the CSS transform string for the content element. */ const transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`; - const onWheel = useCallback( - (e: React.WheelEvent) => { + // Attach wheel listener with { passive: false } so preventDefault() stops page scroll. + // React's onWheel is passive by default and cannot prevent scrolling. + useEffect(() => { + const svg = svgRef.current; + if (!svg) return; + const handler = (e: WheelEvent) => { e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; const factor = 1 + direction * ZOOM_STEP; - const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect(); + const rect = svg.getBoundingClientRect(); const cursorX = e.clientX - rect.left; const cursorY = e.clientY - rect.top; @@ -45,9 +50,10 @@ export function useZoomPan() { translateY: cursorY - scaleRatio * (cursorY - prev.translateY), }; }); - }, - [], - ); + }; + svg.addEventListener('wheel', handler, { passive: false }); + return () => svg.removeEventListener('wheel', handler); + }, []); const onPointerDown = useCallback( (e: React.PointerEvent) => { @@ -158,10 +164,10 @@ export function useZoomPan() { return { state, containerRef, + svgRef, transform, panTo, resetView, - onWheel, onPointerDown, onPointerMove, onPointerUp, From 3d5d462de0862845c8ca47b12648dc924f6eac67 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:29:30 +0100 Subject: [PATCH 14/20] fix: ENDPOINT node execution state, badge position, and edge traversal - Synthesize COMPLETED state for ENDPOINT nodes when overlay is active (endpoints are route entry points, not in the processor execution tree) - Move status badge (check/error) inside the card (top-right, below top bar) to avoid collision with ConfigBadge (TRACE/TAP) badges - Include ENDPOINT nodes in edge traversal check so the edge from endpoint to first processor renders as green/traversed Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ProcessDiagram/DiagramNode.tsx | 18 ++++----- .../ProcessDiagram/ProcessDiagram.tsx | 39 +++++++++++++++++-- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/ui/src/components/ProcessDiagram/DiagramNode.tsx b/ui/src/components/ProcessDiagram/DiagramNode.tsx index ec21587b..e4efe737 100644 --- a/ui/src/components/ProcessDiagram/DiagramNode.tsx +++ b/ui/src/components/ProcessDiagram/DiagramNode.tsx @@ -129,16 +129,16 @@ export function DiagramNode({ {/* Config badges */} {config && } - {/* Execution overlay: status badge at top-right */} + {/* Execution overlay: status badge inside card, top-right corner */} {isCompleted && ( <> - + ✓ @@ -147,13 +147,13 @@ export function DiagramNode({ )} {isFailed && ( <> - + ! diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index f3cadaac..30fa88f1 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ProcessDiagramProps } from './types'; import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams'; import { useDiagramData } from './useDiagramData'; @@ -58,6 +58,7 @@ export function ProcessDiagram({ onIterationChange, }: ProcessDiagramProps) { const overlayActive = !!executionOverlay; + // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([routeId]); @@ -72,6 +73,19 @@ export function ProcessDiagram({ application, currentRouteId, direction, diagramLayout, ); + // Collect ENDPOINT node IDs — these are always "traversed" when overlay is active + // because the endpoint is the route entry point (not in the processor execution tree). + const endpointNodeIds = useMemo(() => { + const ids = new Set(); + if (!overlayActive || !sections.length) return ids; + for (const section of sections) { + for (const node of section.nodes) { + if (node.type === 'ENDPOINT' && node.id) ids.add(node.id); + } + } + return ids; + }, [overlayActive, sections]); + const zoom = useZoomPan(); const toolbar = useToolbarHover(); @@ -85,6 +99,23 @@ export function ProcessDiagram({ } }, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps + // Resolve execution state for a node. ENDPOINT nodes (the route's "from:") + // don't appear in the processor execution tree, but should be marked as + // COMPLETED when the route executed (i.e., overlay has any entries). + const getNodeExecutionState = useCallback( + (nodeId: string | undefined, nodeType: string | undefined) => { + if (!nodeId || !executionOverlay) return undefined; + const state = executionOverlay.get(nodeId); + if (state) return state; + // Synthesize COMPLETED for ENDPOINT nodes when overlay is active + if (nodeType === 'ENDPOINT' && executionOverlay.size > 0) { + return { status: 'COMPLETED' as const, durationMs: 0, hasTraceData: false }; + } + return undefined; + }, + [executionOverlay], + ); + const handleNodeClick = useCallback( (nodeId: string) => { onNodeSelect?.(nodeId); }, [onNodeSelect], @@ -229,8 +260,10 @@ export function ProcessDiagram({ {/* Main section top-level edges (not inside compounds) */} {mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => { + const sourceHasState = executionOverlay?.has(edge.sourceId) || endpointNodeIds.has(edge.sourceId); + const targetHasState = executionOverlay?.has(edge.targetId) || endpointNodeIds.has(edge.targetId); const isTraversed = executionOverlay - ? (executionOverlay.has(edge.sourceId) && executionOverlay.has(edge.targetId)) + ? (!!sourceHasState && !!targetHasState) : undefined; return ( @@ -268,7 +301,7 @@ export function ProcessDiagram({ isHovered={toolbar.hoveredNodeId === node.id} isSelected={selectedNodeId === node.id} config={node.id ? nodeConfigs?.get(node.id) : undefined} - executionState={executionOverlay?.get(node.id ?? '')} + executionState={getNodeExecutionState(node.id, node.type)} overlayActive={overlayActive} onClick={() => node.id && handleNodeClick(node.id)} onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)} From 3027e9b24f8b7d4e4e9c48f1aa12b8776d19a217 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:34:25 +0100 Subject: [PATCH 15/20] fix: scrollable headers/timeline, CodeBlock for body, ELK node alignment - Make headers tab and timeline tab scrollable when content overflows - Replace custom
 code block with design system CodeBlock component
  for body tabs (Input/Output) to match existing styleguide
- Add LINEAR_SEGMENTS node placement strategy to ELK layout to fix
  Y-offset misalignment between nodes in left-to-right diagrams
  (e.g., ENDPOINT at different Y level than subsequent processors)

Co-Authored-By: Claude Opus 4.6 (1M context) 
---
 .../app/diagram/ElkDiagramRenderer.java       |  3 ++
 .../ExecutionDiagram.module.css               |  5 ++-
 .../ExecutionDiagram/tabs/BodyTab.tsx         | 43 +++++--------------
 3 files changed, 17 insertions(+), 34 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 25234df2..ae357cb3 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
@@ -13,6 +13,7 @@ import org.eclipse.elk.core.RecursiveGraphLayoutEngine;
 import org.eclipse.elk.core.options.CoreOptions;
 import org.eclipse.elk.core.options.Direction;
 import org.eclipse.elk.core.options.HierarchyHandling;
+import org.eclipse.elk.alg.layered.options.NodePlacementStrategy;
 import org.eclipse.elk.core.util.BasicProgressMonitor;
 import org.eclipse.elk.graph.ElkBendPoint;
 import org.eclipse.elk.graph.ElkEdge;
@@ -181,6 +182,8 @@ public class ElkDiagramRenderer implements DiagramRenderer {
         rootNode.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING);
         rootNode.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING);
         rootNode.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN);
+        rootNode.setProperty(org.eclipse.elk.alg.layered.options.LayeredOptions.NODE_PLACEMENT_STRATEGY,
+                NodePlacementStrategy.LINEAR_SEGMENTS);
 
         // Build index of all RouteNodes (flat list from graph + recursive children)
         Map routeNodeMap = new HashMap<>();
diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css b/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css
index 4c50b751..dfd17468 100644
--- a/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css
+++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css
@@ -293,12 +293,13 @@
   display: flex;
   gap: 0;
   min-height: 0;
+  overflow-y: auto;
+  max-height: 100%;
 }
 
 .headersColumn {
   flex: 1;
   min-width: 0;
-  overflow: hidden;
 }
 
 .headersColumn + .headersColumn {
@@ -457,6 +458,8 @@
   display: flex;
   flex-direction: column;
   gap: 2px;
+  overflow-y: auto;
+  max-height: 100%;
 }
 
 .ganttRow {
diff --git a/ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx b/ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx
index 1ef04535..20eae893 100644
--- a/ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx
+++ b/ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { CodeBlock } from '@cameleer/design-system';
 import styles from '../ExecutionDiagram.module.css';
 
 interface BodyTabProps {
@@ -6,22 +6,22 @@ interface BodyTabProps {
   label: string;
 }
 
-function detectFormat(text: string): 'JSON' | 'XML' | 'Text' {
+function detectLanguage(text: string): string {
   const trimmed = text.trimStart();
   if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
     try {
       JSON.parse(text);
-      return 'JSON';
+      return 'json';
     } catch {
       // not valid JSON
     }
   }
-  if (trimmed.startsWith('<')) return 'XML';
-  return 'Text';
+  if (trimmed.startsWith('<')) return 'xml';
+  return 'text';
 }
 
-function formatBody(text: string, format: string): string {
-  if (format === 'JSON') {
+function formatBody(text: string, language: string): string {
+  if (language === 'json') {
     try {
       return JSON.stringify(JSON.parse(text), null, 2);
     } catch {
@@ -31,40 +31,17 @@ function formatBody(text: string, format: string): string {
   return text;
 }
 
-function byteSize(text: string): string {
-  const bytes = new TextEncoder().encode(text).length;
-  if (bytes < 1024) return `${bytes} B`;
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
-
 export function BodyTab({ body, label }: BodyTabProps) {
-  const [copied, setCopied] = useState(false);
-
   if (!body) {
     return 
No {label.toLowerCase()} body available
; } - const format = detectFormat(body); - const formatted = formatBody(body, format); - - function handleCopy() { - navigator.clipboard.writeText(body!).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - } + const language = detectLanguage(body); + const formatted = formatBody(body, language); return (
-
- {format} - {byteSize(body)} - -
-
{formatted}
+
); } From 0b8efa1998191b3068b0b69f05853d517a5421fc Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:40:20 +0100 Subject: [PATCH 16/20] fix: drill-down uses route-based fetch instead of pre-loaded layout When drilled into a sub-route, the pre-fetched diagramLayout (loaded by content hash for the root execution) doesn't contain the sub-route's diagram. Only use the pre-loaded layout for the root route; fall back to useDiagramByRoute for drilled-down sub-routes. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/ProcessDiagram/ProcessDiagram.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index 30fa88f1..05b47e94 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -69,8 +69,12 @@ export function ProcessDiagram({ const currentRouteId = routeStack[routeStack.length - 1]; + // Only use the pre-fetched diagramLayout for the root route. + // When drilled down into a sub-route, fetch the sub-route's diagram by route ID. + const effectiveLayout = currentRouteId === routeId ? diagramLayout : undefined; + const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData( - application, currentRouteId, direction, diagramLayout, + application, currentRouteId, direction, effectiveLayout, ); // Collect ENDPOINT node IDs — these are always "traversed" when overlay is active From bfd76261ef3ffe4da0b33da28afdcc8f898da19a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:43:51 +0100 Subject: [PATCH 17/20] fix: disable execution overlay when drilled into sub-route The execution overlay data maps to the root route's processor IDs. When drilled into a sub-route, those IDs don't match, causing all nodes to appear dimmed. Now clears the overlay and shows pure topology when viewing a sub-route via drill-down. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProcessDiagram/ProcessDiagram.tsx | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index 05b47e94..d1b33c00 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -57,8 +57,6 @@ export function ProcessDiagram({ iterationState, onIterationChange, }: ProcessDiagramProps) { - const overlayActive = !!executionOverlay; - // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([routeId]); @@ -68,10 +66,15 @@ export function ProcessDiagram({ }, [routeId]); const currentRouteId = routeStack[routeStack.length - 1]; + const isDrilledDown = currentRouteId !== routeId; + + // Disable overlay when drilled down — the execution data is for the root route + // and doesn't map to sub-route node IDs. Sub-route shows topology only. + const overlayActive = !!executionOverlay && !isDrilledDown; + const effectiveOverlay = isDrilledDown ? undefined : executionOverlay; // Only use the pre-fetched diagramLayout for the root route. - // When drilled down into a sub-route, fetch the sub-route's diagram by route ID. - const effectiveLayout = currentRouteId === routeId ? diagramLayout : undefined; + const effectiveLayout = isDrilledDown ? undefined : diagramLayout; const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData( application, currentRouteId, direction, effectiveLayout, @@ -108,16 +111,16 @@ export function ProcessDiagram({ // COMPLETED when the route executed (i.e., overlay has any entries). const getNodeExecutionState = useCallback( (nodeId: string | undefined, nodeType: string | undefined) => { - if (!nodeId || !executionOverlay) return undefined; - const state = executionOverlay.get(nodeId); + if (!nodeId || !effectiveOverlay) return undefined; + const state = effectiveOverlay.get(nodeId); if (state) return state; // Synthesize COMPLETED for ENDPOINT nodes when overlay is active - if (nodeType === 'ENDPOINT' && executionOverlay.size > 0) { + if (nodeType === 'ENDPOINT' && effectiveOverlay.size > 0) { return { status: 'COMPLETED' as const, durationMs: 0, hasTraceData: false }; } return undefined; }, - [executionOverlay], + [effectiveOverlay], ); const handleNodeClick = useCallback( @@ -264,9 +267,9 @@ export function ProcessDiagram({ {/* Main section top-level edges (not inside compounds) */} {mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => { - const sourceHasState = executionOverlay?.has(edge.sourceId) || endpointNodeIds.has(edge.sourceId); - const targetHasState = executionOverlay?.has(edge.targetId) || endpointNodeIds.has(edge.targetId); - const isTraversed = executionOverlay + const sourceHasState = effectiveOverlay?.has(edge.sourceId) || endpointNodeIds.has(edge.sourceId); + const targetHasState = effectiveOverlay?.has(edge.targetId) || endpointNodeIds.has(edge.targetId); + const isTraversed = effectiveOverlay ? (!!sourceHasState && !!targetHasState) : undefined; return ( @@ -287,7 +290,7 @@ export function ProcessDiagram({ selectedNodeId={selectedNodeId} hoveredNodeId={toolbar.hoveredNodeId} nodeConfigs={nodeConfigs} - executionOverlay={executionOverlay} + executionOverlay={effectiveOverlay} overlayActive={overlayActive} iterationState={iterationState} onIterationChange={onIterationChange} From cf9e847f84e8e6e6dbd571d396a7546ee7663259 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:45:54 +0100 Subject: [PATCH 18/20] fix: use design system CodeBlock for error stack trace Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx b/ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx index 511d4e8b..2a8630c9 100644 --- a/ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx +++ b/ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx @@ -1,3 +1,4 @@ +import { CodeBlock } from '@cameleer/design-system'; import type { ProcessorNode, ExecutionDetail } from '../types'; import styles from '../ExecutionDiagram.module.css'; @@ -37,7 +38,7 @@ export function ErrorTab({ processor, executionDetail }: ErrorTabProps) { {errorStackTrace && ( <>
Stack Trace
-
{errorStackTrace}
+ )}
From 25e23c0b87546c4beea682569e5576a936f07ed5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:47:57 +0100 Subject: [PATCH 19/20] feat: highlight triggered error handler sections When an onException/error handler section has any executed processors (overlay entries), it renders with a stronger red tint (8% vs 3%), a solid red border frame, and a solid divider line. This makes it easy to identify which handler was triggered when multiple exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProcessDiagram/ErrorSection.tsx | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/ui/src/components/ProcessDiagram/ErrorSection.tsx b/ui/src/components/ProcessDiagram/ErrorSection.tsx index fad5c970..2ed3200a 100644 --- a/ui/src/components/ProcessDiagram/ErrorSection.tsx +++ b/ui/src/components/ProcessDiagram/ErrorSection.tsx @@ -42,6 +42,20 @@ export function ErrorSection({ onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave, }: ErrorSectionProps) { const color = VARIANT_COLORS[section.variant ?? 'error'] ?? VARIANT_COLORS.error; + + // Check if any node in this section was executed (has overlay entry) + const wasTriggered = useMemo(() => { + if (!executionOverlay || executionOverlay.size === 0) return false; + function checkNodes(nodes: DiagramNodeType[]): boolean { + for (const n of nodes) { + if (n.id && executionOverlay!.has(n.id)) return true; + if (n.children && checkNodes(n.children)) return true; + } + return false; + } + return checkNodes(section.nodes); + }, [executionOverlay, section.nodes]); + const boxHeight = useMemo(() => { let maxY = 0; for (const n of section.nodes) { @@ -65,28 +79,41 @@ export function ErrorSection({ {section.label} - {/* Divider line */} + {/* Divider line — solid when triggered */} - {/* Subtle red tint background — sized to actual content */} + {/* Background — stronger when this handler was triggered during execution */} + {wasTriggered && ( + + )} {/* Content group with margin from top-left */} From d7166b6d0ac3b1ef294399747949ea93e160f9aa Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:51:00 +0100 Subject: [PATCH 20/20] feat: Jump to Error centers the failed node in the viewport Added centerOnNodeId prop to ProcessDiagram. When set, the diagram pans to center the specified node in the viewport. Jump to Error now selects the failed processor AND centers the viewport on it. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ExecutionDiagram/ExecutionDiagram.tsx | 9 +++-- .../ProcessDiagram/ProcessDiagram.tsx | 35 +++++++++++++++++++ ui/src/components/ProcessDiagram/types.ts | 2 ++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx index e19ddf7e..fc5378c2 100644 --- a/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx +++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx @@ -80,8 +80,9 @@ export function ExecutionDiagram({ // 4. Compute overlay const overlay = useExecutionOverlay(detail?.processors, iterationState); - // 5. Manage selection + // 5. Manage selection + center-on-node const [selectedProcessorId, setSelectedProcessorId] = useState(''); + const [centerOnNodeId, setCenterOnNodeId] = useState(''); // 6. Resizable splitter state const [splitPercent, setSplitPercent] = useState(60); @@ -105,12 +106,15 @@ export function ExecutionDiagram({ document.addEventListener('pointerup', onUp); }, []); - // Jump to error: find first FAILED processor and select it + // Jump to error: find first FAILED processor, select it, and center the viewport const handleJumpToError = useCallback(() => { if (!detail?.processors) return; const failed = findFailedProcessor(detail.processors); if (failed?.processorId) { setSelectedProcessorId(failed.processorId); + // Use a unique value to re-trigger centering even if the same node + setCenterOnNodeId(''); + requestAnimationFrame(() => setCenterOnNodeId(failed.processorId)); } }, [detail?.processors]); @@ -187,6 +191,7 @@ export function ExecutionDiagram({ executionOverlay={overlay} iterationState={iterationState} onIterationChange={setIteration} + centerOnNodeId={centerOnNodeId} /> diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index d1b33c00..382c22fb 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -56,6 +56,7 @@ export function ProcessDiagram({ executionOverlay, iterationState, onIterationChange, + centerOnNodeId, }: ProcessDiagramProps) { // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([routeId]); @@ -106,6 +107,33 @@ export function ProcessDiagram({ } }, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps + // Center on a specific node when centerOnNodeId changes + useEffect(() => { + if (!centerOnNodeId || sections.length === 0) return; + const node = findNodeById(sections, centerOnNodeId); + if (!node) return; + const container = zoom.containerRef.current; + if (!container) return; + // Compute the node center in diagram coordinates + const nodeX = (node.x ?? 0) + (node.width ?? 160) / 2; + const nodeY = (node.y ?? 0) + (node.height ?? 40) / 2; + // Find which section the node is in to add its offsetY + let sectionOffsetY = 0; + for (const section of sections) { + const found = findNodeInSection(section.nodes, centerOnNodeId); + if (found) { sectionOffsetY = section.offsetY; break; } + } + const adjustedY = nodeY + sectionOffsetY; + // Pan so the node center is at the viewport center + const cw = container.clientWidth; + const ch = container.clientHeight; + const scale = zoom.state.scale; + zoom.panTo( + cw / 2 - nodeX * scale, + ch / 2 - adjustedY * scale, + ); + }, [centerOnNodeId]); // eslint-disable-line react-hooks/exhaustive-deps + // Resolve execution state for a node. ENDPOINT nodes (the route's "from:") // don't appear in the processor execution tree, but should be marked as // COMPLETED when the route executed (i.e., overlay has any entries). @@ -415,6 +443,13 @@ function findInChildren( return undefined; } +function findNodeInSection( + nodes: DiagramNodeType[], + nodeId: string, +): boolean { + return !!findInChildren(nodes, nodeId) || nodes.some(n => n.id === nodeId); +} + function topLevelEdge( edge: import('../../api/queries/diagrams').DiagramEdge, nodes: DiagramNodeType[], diff --git a/ui/src/components/ProcessDiagram/types.ts b/ui/src/components/ProcessDiagram/types.ts index 01d3776d..e7a6e516 100644 --- a/ui/src/components/ProcessDiagram/types.ts +++ b/ui/src/components/ProcessDiagram/types.ts @@ -35,4 +35,6 @@ export interface ProcessDiagramProps { iterationState?: Map; /** Called when user changes iteration on a compound stepper */ onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void; + /** When set, the diagram pans to center this node in the viewport */ + centerOnNodeId?: string; }