Compare commits

24 Commits

Author SHA1 Message Date
hsiegeln
085c4e395b feat: execution overlay & debugger (sub-project 2)
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 36s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Adds execution overlay to the ProcessDiagram component, turning it into
an after-the-fact debugger for Camel route executions.

Backend:
- Flyway V8: iteration fields (loop/split/multicast index/size) on processor_executions
- Snapshot-by-processorId endpoint for robust processor lookup
- ELK LINEAR_SEGMENTS node placement for consistent Y-alignment

Frontend:
- ExecutionDiagram wrapper: exchange bar, resizable splitter, detail panel
- Node overlay: green tint+checkmark (completed), red tint+! (failed), dimmed (skipped)
- Edge overlay: green solid (traversed), dashed gray (not traversed)
- Per-compound iteration stepper for loops/splits/multicasts
- 7-tab detail panel: Info, Headers, Input, Output, Error, Config, Timeline
- Jump to Error: selects + centers viewport on failed processor
- Triggered error handler sections highlighted with solid red frame
- Drill-down disables overlay (sub-routes show topology only)
- Integrated into ExchangeDetail page Flow view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:51:55 +01:00
hsiegeln
d7166b6d0a 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) <noreply@anthropic.com>
2026-03-27 19:51:00 +01:00
hsiegeln
25e23c0b87 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) <noreply@anthropic.com>
2026-03-27 19:47:57 +01:00
hsiegeln
cf9e847f84 fix: use design system CodeBlock for error stack trace
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:45:54 +01:00
hsiegeln
bfd76261ef 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) <noreply@anthropic.com>
2026-03-27 19:43:51 +01:00
hsiegeln
0b8efa1998 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) <noreply@anthropic.com>
2026-03-27 19:40:20 +01:00
hsiegeln
3027e9b24f fix: scrollable headers/timeline, CodeBlock for body, ELK node alignment
- Make headers tab and timeline tab scrollable when content overflows
- Replace custom <pre> 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) <noreply@anthropic.com>
2026-03-27 19:34:25 +01:00
hsiegeln
3d5d462de0 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) <noreply@anthropic.com>
2026-03-27 19:29:30 +01:00
hsiegeln
f675451384 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) <noreply@anthropic.com>
2026-03-27 19:24:09 +01:00
hsiegeln
021a52e56b 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) <noreply@anthropic.com>
2026-03-27 19:12:11 +01:00
hsiegeln
5ccefa3cdb 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) <noreply@anthropic.com>
2026-03-27 19:05:43 +01:00
hsiegeln
e4c66b1311 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) <noreply@anthropic.com>
2026-03-27 19:01:53 +01:00
hsiegeln
5da03d0938 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) <noreply@anthropic.com>
2026-03-27 18:56:38 +01:00
hsiegeln
3af1d1f3b6 feat: add useProcessorSnapshotById hook for snapshot-by-processorId endpoint
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:54:01 +01:00
hsiegeln
1984c597de 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) <noreply@anthropic.com>
2026-03-27 18:52:32 +01:00
hsiegeln
3029704051 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) <noreply@anthropic.com>
2026-03-27 18:47:59 +01:00
hsiegeln
2b805ec196 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) <noreply@anthropic.com>
2026-03-27 18:44:16 +01:00
hsiegeln
ff59dc5d57 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) <noreply@anthropic.com>
2026-03-27 18:40:57 +01:00
hsiegeln
3928743ea7 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) <noreply@anthropic.com>
2026-03-27 18:38:09 +01:00
hsiegeln
cf6c4bd60c 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) <noreply@anthropic.com>
2026-03-27 18:34:45 +01:00
hsiegeln
edd841ffeb feat: add iteration fields to processor execution storage
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) <noreply@anthropic.com>
2026-03-27 18:32:47 +01:00
hsiegeln
889f0e5263 chore: add .worktrees/ to .gitignore for worktree isolation 2026-03-27 18:27:34 +01:00
hsiegeln
3a41e1f1d3 docs: add execution overlay implementation plan (sub-project 2)
12 tasks covering backend prerequisites (iteration fields, snapshot-by-id
endpoint), ProcessDiagram overlay props, node/edge visual states, compound
iteration stepper, detail panel with 7 tabs, ExecutionDiagram wrapper,
and ExchangeDetail page integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:25:47 +01:00
hsiegeln
509159417b docs: add execution overlay & debugger design spec (sub-project 2)
Design for overlaying real execution data onto the ProcessDiagram:
- Node status visualization (green OK, red failed, dimmed skipped)
- Per-compound iteration stepping for loops/splits
- Tabbed detail panel (Info, Headers, Input, Output, Error, Config, Timeline)
- Jump to Error with cross-route drill-down
- Backend prerequisites for iteration fields and snapshot-by-id endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:13:03 +01:00
38 changed files with 3769 additions and 131 deletions

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ logs/
# Claude
.claude/
.worktrees/

View File

@@ -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<Map<String, String>> 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<Map<String, String>> processorSnapshotById(
@PathVariable String executionId,
@PathVariable String processorId) {
return detailService.getProcessorSnapshot(executionId, processorId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -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<String, RouteNode> routeNodeMap = new HashMap<>();

View File

@@ -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());
}
@@ -113,6 +121,13 @@ public class PostgresExecutionStore implements ExecutionStore {
PROCESSOR_MAPPER, executionId);
}
@Override
public Optional<ProcessorRecord> findProcessorById(String executionId, String processorId) {
String sql = "SELECT * FROM processor_executions WHERE execution_id = ? AND processor_id = ? LIMIT 1";
List<ProcessorRecord> results = jdbc.query(sql, PROCESSOR_MAPPER, executionId, processorId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) ->
new ExecutionRecord(
rs.getString("execution_id"), rs.getString("route_id"),
@@ -140,7 +155,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);

View File

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

View File

@@ -38,6 +38,18 @@ public class DetailService {
});
}
public Optional<Map<String, String>> getProcessorSnapshot(String executionId, String processorId) {
return executionStore.findProcessorById(executionId, processorId)
.map(p -> {
Map<String, String> 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<ProcessorNode> buildTree(List<ProcessorRecord> processors) {
if (processors.isEmpty()) return List.of();
@@ -48,7 +60,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()
));
}

View File

@@ -22,12 +22,20 @@ public final class ProcessorNode {
private final String errorMessage;
private final String errorStackTrace;
private final Map<String, String> attributes;
private final Integer loopIndex;
private final Integer loopSize;
private final Integer splitIndex;
private final Integer splitSize;
private final Integer multicastIndex;
private final List<ProcessorNode> children;
public ProcessorNode(String processorId, String processorType, String status,
Instant startTime, Instant endTime, long durationMs,
String errorMessage, String errorStackTrace,
Map<String, String> attributes) {
Map<String, String> 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<String, String> 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<ProcessorNode> getChildren() { return List.copyOf(children); }
}

View File

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

View File

@@ -16,6 +16,8 @@ public interface ExecutionStore {
List<ProcessorRecord> findProcessors(String executionId);
Optional<ProcessorRecord> findProcessorById(String executionId, String processorId);
record ExecutionRecord(
String executionId, String routeId, String agentId, String applicationName,
String status, String correlationId, String exchangeId,
@@ -33,6 +35,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
) {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,417 @@
# Execution Overlay & Debugger — Design Spec
**Sub-project:** 2 of 3 (Component → **Execution Overlay** → Page Integration)
**Scope:** Overlay real execution data onto the ProcessDiagram component from sub-project 1. Adds node status visualization, per-compound iteration stepping, a tabbed detail panel, and error navigation. Does NOT include page integration — that is sub-project 3.
---
## Problem
The ProcessDiagram from sub-project 1 shows route topology but cannot display what actually happened during an exchange's execution. Users investigating failures must cross-reference between the diagram and separate execution detail views. There is no way to see which processors were hit, which were skipped, where errors occurred, or what the message looked like at each step.
## Goal
Build an `ExecutionDiagram` wrapper component that overlays execution data onto ProcessDiagram, turning it into an "after-the-fact debugger." Users can see the execution path at a glance (green = OK, red = failed, dimmed = skipped), step through loop/split iterations independently, and inspect processor-level details (input/output body, headers, errors, timing) in a tabbed detail panel below the diagram.
---
## Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Architecture | Wrapper component (`ExecutionDiagram`) composing `ProcessDiagram` | Keeps topology component pure; execution concerns isolated |
| Layout | Top/bottom IDE split (diagram top, detail panel bottom) | Left-to-right diagram needs full width; familiar IDE pattern |
| Node status | Tinted backgrounds + status badges | Green tint + checkmark for OK, red tint + ! for failed, dimmed for skipped — scannable at a glance |
| Duration display | Badge on each executed node (bottom-right) | Quick bottleneck identification without opening detail panel |
| Iteration stepping | Per-compound stepper in header bar | Independent stepping at each nesting level; contextually placed |
| Error navigation | Passive highlighting + "Jump to Error" action | Red border + ! badge on failed node; jump action drills into sub-routes if needed |
| Cross-route errors | Red border + drill-down arrow on calling node | Communicates failure exists here; arrow signals root cause is deeper |
| Detail panel tabs | Info, Headers, Input, Output, Error, Config, Timeline | Comprehensive debugging context |
| Error tab visibility | Always visible, grayed out when no error | No layout shift; consistent tab bar |
| Reusability | Component usable standalone and embedded | Immediately replaces ExchangeDetail flow view; usable elsewhere |
---
## 0. Backend Prerequisites
### Iteration fields on ProcessorNode
The `ProcessorExecution` model in `cameleer3-common` has iteration tracking fields (`loopIndex`, `loopSize`, `splitIndex`, `splitSize`, `multicastIndex`), but the server's storage layer and API response model do not surface them. The following changes are needed:
**Storage:**
- Add columns to `processor_records` table: `loop_index`, `loop_size`, `split_index`, `split_size`, `multicast_index` (all nullable integers)
- Flyway migration to add columns
- Update `ExecutionStore` to persist and read these fields
**Detail model:**
- Add fields to `ProcessorNode.java`: `loopIndex`, `loopSize`, `splitIndex`, `splitSize`, `multicastIndex`
- Update `DetailService.buildTree()` to populate them from storage
**API:**
- Regenerate `openapi.json` and `schema.d.ts` to include the new fields
### Snapshot endpoint: accept processorId
The current snapshot endpoint `GET /executions/{id}/processors/{index}/snapshot` uses a positional index into the flat processor list. This is fragile when the tree structure changes. Add an alternative parameter:
- `GET /executions/{id}/processors/by-id/{processorId}/snapshot` — fetches snapshot by processor ID
- Add corresponding `useProcessorSnapshotById(executionId, processorId)` hook on the frontend
### Diagram loading by content hash
`ExecutionDetail` includes `diagramContentHash` linking to the diagram version active during the execution. The existing `useDiagramLayout(contentHash, direction)` hook already supports loading by content hash. The `ExecutionDiagram` wrapper uses this path instead of `useDiagramByRoute(application, routeId)`.
---
## 1. ExecutionDiagram Wrapper Component
### Location
```
ui/src/components/ExecutionDiagram/
├── ExecutionDiagram.tsx # Root: top/bottom split, orchestrates overlay + detail panel
├── ExecutionDiagram.module.css # Layout styles (splitter, exchange bar, panel)
├── useExecutionOverlay.ts # Hook: maps execution data → node overlay state
├── useIterationState.ts # Hook: per-compound iteration tracking
├── ExecutionContext.tsx # React context: shares execution data + iteration state
├── DetailPanel.tsx # Bottom panel: tabs container
├── tabs/InfoTab.tsx # Processor metadata + attributes
├── tabs/HeadersTab.tsx # Input/output headers side-by-side
├── tabs/BodyTab.tsx # Shared: formatted message body (used by Input + Output)
├── tabs/ErrorTab.tsx # Exception details + stack trace
├── tabs/ConfigTab.tsx # Processor configuration (TODO: agent data)
├── tabs/TimelineTab.tsx # Gantt-style processor duration chart
├── types.ts # Overlay-specific types
└── index.ts # Public exports
```
### Props API
```typescript
interface ExecutionDiagramProps {
/** Execution to overlay — fetched externally or by executionId */
executionId: string;
/** Optional: pre-fetched execution detail (skips internal fetch) */
executionDetail?: ExecutionDetail;
/** Diagram direction */
direction?: 'LR' | 'TB';
/** Known route IDs for drill-down resolution */
knownRouteIds?: Set<string>;
/** Called when user triggers node actions (trace toggle, tap config) */
onNodeAction?: (nodeId: string, action: NodeAction) => void;
/** Active node configs (trace/tap badges) */
nodeConfigs?: Map<string, NodeConfig>;
className?: string;
}
```
### Behavior
1. Fetches `ExecutionDetail` via `useExecutionDetail(executionId)` (or uses pre-fetched prop)
2. Extracts the `diagramContentHash` from the execution to load the correct diagram version
3. Maps processor execution tree to diagram node IDs (processor IDs match diagram node IDs)
4. Passes overlay data to ProcessDiagram via new overlay props
5. Manages selected node state, detail panel content, and iteration stepping
---
## 2. ProcessDiagram Overlay Props Extension
The existing `ProcessDiagramProps` gains optional overlay props. When absent, the diagram renders in topology-only mode (sub-project 1 behavior). When present, nodes render with execution state.
```typescript
interface ProcessDiagramProps {
// ... existing props from sub-project 1 ...
/** Execution overlay: maps diagram node ID → execution state */
executionOverlay?: Map<string, NodeExecutionState>;
/** Per-compound iteration state: maps compound node ID → current iteration index */
iterationState?: Map<string, number>;
/** Called when user changes iteration on a compound stepper */
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
}
interface NodeExecutionState {
status: 'COMPLETED' | 'FAILED';
durationMs: number;
/** True if this node's target sub-route failed (for DIRECT/SEDA nodes) */
subRouteFailed?: boolean;
/** True if trace data (input/output body) is available */
hasTraceData?: boolean;
/** Loop/split iteration info for the compound containing this node */
iterationIndex?: number;
iterationCount?: number;
}
```
---
## 3. Node Visual States
### Executed — Completed
- Background: green tint (`#F0F9F1`)
- Border: 1.5px solid `--success` (`#3D7C47`) + 4px green left accent
- Badge: green circle with white checkmark (top-right corner, 16px diameter)
- Duration: green text bottom-right (e.g., "5ms")
### Executed — Failed
- Background: red tint (`#FDF2F0`)
- Border: 2px solid `--error` (`#C0392B`)
- Badge: red circle with white `!` (top-right corner, 16px diameter)
- Duration: red text bottom-right
- Label text turns red, subtitle shows "FAILED"
### Sub-Route Failure (DIRECT/SEDA node whose target route failed)
- Same visual as Failed (red tint, red border, red ! badge)
- Additional: drill-down arrow icon (bottom-left corner)
- "Jump to Error" action on this node auto-drills into the sub-route
### Not Executed (Skipped)
- Opacity: 35%
- No status badge, no duration badge
- Original topology styling (no tint)
### Compound Node Status
Compound nodes (CHOICE, LOOP, SPLIT, etc.) derive their status from their children:
- If any child failed → compound shows as COMPLETED (the compound itself executed) but the failed child shows individually
- The compound does not get its own status badge — only leaf processors do
- Compound background tint: subtle green if all children OK, no tint if mixed results
### RUNNING Executions
RUNNING executions are out of scope for overlay (see Non-Goals). If the `ExecutionDetail.status` is `RUNNING`, the ExecutionDiagram shows the overlay for processors that have completed so far — completed processors get green/red treatment, processors not yet reached are dimmed. No special "in-progress" visual is needed.
### Edge States
- **Traversed edge:** solid, `--success` green (`#3D7C47`), 1.5px stroke
- **Not traversed edge:** dashed, `#9CA3AF` gray, 1px stroke
---
## 4. Per-Compound Iteration Stepper
### Placement
Small control widget embedded in the compound node's header bar (right-aligned). Rendered as part of the `CompoundNode` component when overlay data includes iteration info.
### Visual
Semi-transparent background pill inside the purple/colored header:
```
LOOP [< 3 / 5 >]
```
Prev/next buttons with the current iteration and total count.
### Behavior
- Each compound (LOOP, SPLIT, MULTICAST) tracks its iteration independently via `iterationState` map
- Changing iteration updates the overlay data for all children of that compound
- Nested compounds: outer loop at iteration 2, inner split at branch 1 — independent
- CHOICE compounds: no stepper. The taken branch renders with execution state; untaken branches are dimmed
- Keyboard: left/right arrow keys step when compound is hovered
- Detail panel syncs: selecting a processor inside a loop shows that iteration's snapshot data
### Data Flow
The `useIterationState` hook maintains a `Map<compoundNodeId, currentIndex>`. When an iteration changes:
1. The hook recalculates which `ProcessorExecution` children correspond to the selected iteration (using `loopIndex`, `splitIndex`, or `multicastIndex` fields)
2. Rebuilds the `executionOverlay` map for that compound's children
3. ProcessDiagram re-renders with updated overlay
---
## 5. Exchange Summary Bar
A thin bar above the diagram showing exchange-level information:
- Exchange ID (monospace, copyable)
- Status badge (COMPLETED green, FAILED red)
- Application / route ID
- Total duration
- "Jump to Error" button (only for FAILED exchanges) — scrolls diagram to failed node, drills into sub-route if needed
---
## 6. Detail Panel
### Layout
Below the diagram, separated by a resizable splitter. Default split: 60% diagram / 40% panel. Minimum panel height: 120px. The panel can be collapsed by dragging the splitter to the bottom.
The panel has:
1. **Processor header:** selected processor name, status badge, processor ID, duration
2. **Tab bar:** Info | Headers | Input | Output | Error | Config | Timeline
3. **Tab content area:** scrollable
When no processor is selected, the panel shows exchange-level data:
- **Info tab:** exchange metadata (exchangeId, correlationId, route, application, total duration, engine level, route-level attributes)
- **Headers tab:** route-level input/output headers
- **Input tab:** route-level input body
- **Output tab:** route-level output body
- **Error tab:** route-level error (if failed)
- **Config tab:** grayed out (not applicable at exchange level)
- **Timeline tab:** Gantt chart of all processors (always available)
### Tab: Info
Grid layout showing processor metadata:
- Processor ID, Type, Status
- Start time, End time, Duration
- Endpoint URI, Resolved Endpoint URI
- Attributes section: tap-extracted attributes as pill badges
### Tab: Headers
Side-by-side layout:
- Left: Input headers (key/value table)
- Right: Output headers (key/value table)
- New/changed headers highlighted in green
Data source: `useProcessorSnapshotById(executionId, processorId)``inputHeaders`, `outputHeaders`
### Tab: Input
Formatted message body at processor entry:
- Auto-detect format (JSON, XML, plain text)
- Syntax-highlighted code block (dark theme)
- Copy button
- Byte size indicator
Data source: `useProcessorSnapshotById(executionId, processorId)``inputBody`
### Tab: Output
Same layout as Input tab, showing processor exit body.
Data source: `useProcessorSnapshotById(executionId, processorId)``outputBody`
### Tab: Error
Shown for all processors but grayed out when the selected processor has no error.
When error exists:
- Exception type (class name)
- Error message
- Root cause type + message
- Stack trace in monospace block
Data source: `ProcessorNode.errorMessage`, `ProcessorNode.errorStackTrace` from the execution detail tree
### Tab: Config
Processor configuration from the route definition. **TODO:** Requires agent-side work to capture and expose processor configuration metadata on `RouteNode`. Initially shows a placeholder indicating config data is not yet available.
### Tab: Timeline
Gantt-style horizontal bar chart showing executed processors' relative durations:
- One row per processor from the `ProcessorNode` execution tree (flattened in execution order) — only executed processors, not all diagram nodes
- Bar width proportional to duration relative to total route duration
- Green bars for completed, red for failed
- Clicking a bar selects that processor in the diagram and scrolls to it
- Duration label on the right of each row
- When inside a loop/split compound, shows the current iteration's processors
---
## 7. Data Flow
```
ExecutionDiagram
├── useExecutionDetail(executionId)
│ → ExecutionDetail { processors: ProcessorNode[], diagramContentHash, ... }
├── useExecutionOverlay(executionDetail, iterationState)
│ → Maps ProcessorNode tree → Map<diagramNodeId, NodeExecutionState>
│ → Handles iteration filtering (loopIndex, splitIndex matching)
│ → Detects sub-route failures on DIRECT/SEDA nodes
├── useIterationState()
│ → Map<compoundNodeId, currentIterationIndex>
│ → onIterationChange(compoundId, index) callback
├── ProcessDiagram
│ props: { application, routeId, executionOverlay, iterationState, onIterationChange, ... }
│ Renders nodes with overlay visual states
└── DetailPanel
├── useProcessorSnapshotById(executionId, selectedProcessorId)
│ → { inputBody, outputBody, inputHeaders, outputHeaders }
└── Tabs render from ProcessorNode + snapshot data
```
### Processor-to-Node Mapping
The `processorId` field on `ProcessorNode` is the same value as the `id` field on diagram `PositionedNode`. The agent uses diagram node IDs as processor IDs during route model extraction, so no separate mapping or `diagramNodeId` field is needed. The `useExecutionOverlay` hook builds its map by walking the `ProcessorNode` tree and keying on `processorId`, which directly matches diagram node IDs.
### Snapshot Loading
Per-processor body/header data is fetched lazily via `useProcessorSnapshotById(executionId, processorId)` when a processor is selected and the user switches to Input/Output/Headers tabs. This avoids loading all snapshot data upfront for routes with many processors. The snapshot endpoint accepts `processorId` (see Backend Prerequisites, Section 0).
---
## 8. Jump to Error
When the user clicks "Jump to Error":
1. Find the first `ProcessorNode` with `status === 'FAILED'` in the execution tree
2. If the failed processor is a DIRECT/SEDA node with `subRouteFailed: true`:
a. Drill down into the target route (same as double-click drill-down from sub-project 1)
b. Recursively find the failed processor in the sub-route's execution
3. Select the failed processor node
4. Pan/zoom the diagram to center the failed node
5. Show the Error tab in the detail panel
This handles arbitrarily deep cross-route error chains (route A calls direct:B which calls direct:C where the actual failure is).
---
## 9. Integration with ExchangeDetail Page
The `ExecutionDiagram` component replaces the existing "Flow" view tab on the `ExchangeDetail` page. The page passes `executionId` and the component handles everything internally.
```typescript
// In ExchangeDetail page
<ExecutionDiagram
executionId={executionId}
knownRouteIds={knownRouteIds}
onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs}
/>
```
The existing Gantt timeline view on ExchangeDetail can be removed or kept as an alternative view — the Timeline tab inside the detail panel provides the same functionality.
---
## Non-Goals (Sub-project 3)
- Replacing RouteFlow on the Dashboard or RouteDetail pages
- Aggregate execution heatmaps (showing hot processors across many exchanges)
- Live execution tracking (watching a RUNNING exchange in real-time)
- Diff between two executions
- Export/share execution view
---
## Verification
1. `npx tsc -p tsconfig.app.json --noEmit` passes
2. ExecutionDiagram renders on ExchangeDetail page for a known failed exchange
3. Completed nodes show green tint + checkmark + duration badge
4. Failed nodes show red tint + ! badge + red duration
5. Skipped nodes are dimmed to 35% opacity
6. Edges between executed nodes turn green; edges to skipped nodes are dashed gray
7. Loop/split compounds show iteration stepper; stepping updates child overlay
8. CHOICE compounds highlight taken branch, dim untaken branches
9. Nested loops step independently
10. Clicking a node shows its data in the detail panel
11. Detail panel tabs: Info shows metadata + attributes, Headers shows side-by-side, Input/Output show formatted body, Error shows exception + stack trace, Timeline shows Gantt chart
12. "Jump to Error" navigates to and selects the failed processor, drilling into sub-routes if needed
13. Error tab grayed out for non-failed processors
14. Config tab shows placeholder (TODO)
15. Resizable splitter between diagram and detail panel works

View File

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

View File

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

View File

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

View File

@@ -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<DetailTab>('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 (
<div className={styles.detailPanel}>
{/* Processor / Exchange header bar */}
<div className={styles.processorHeader}>
<span className={styles.processorName}>{headerName}</span>
<span className={`${styles.statusBadge} ${statusClass(headerStatus)}`}>
{headerStatus}
</span>
<span className={styles.processorId}>{headerId}</span>
<span className={styles.processorDuration}>{formatDuration(headerDuration)}</span>
</div>
{/* Tab bar */}
<div className={styles.tabBar}>
{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 (
<button
key={tab.key}
className={className}
onClick={() => {
if (!isDisabled) setActiveTab(tab.key);
}}
type="button"
>
{tab.label}
</button>
);
})}
</div>
{/* Tab content */}
<div className={styles.tabContent}>
{activeTab === 'info' && (
<InfoTab processor={selectedProcessor} executionDetail={executionDetail} />
)}
{activeTab === 'headers' && (
<HeadersTab
executionId={executionId}
processorId={selectedProcessor?.processorId ?? null}
exchangeInputHeaders={executionDetail.inputHeaders}
exchangeOutputHeaders={executionDetail.outputHeaders}
/>
)}
{activeTab === 'input' && (
<BodyTab body={inputBody} label="Input" />
)}
{activeTab === 'output' && (
<BodyTab body={outputBody} label="Output" />
)}
{activeTab === 'error' && (
<ErrorTab processor={selectedProcessor} executionDetail={executionDetail} />
)}
{activeTab === 'config' && (
<ConfigTab />
)}
{activeTab === 'timeline' && (
<TimelineTab
executionDetail={executionDetail}
selectedProcessorId={selectedProcessor?.processorId ?? null}
onSelectProcessor={onSelectProcessor}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,538 @@
/* ==========================================================================
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
========================================================================== */
.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;
overflow-y: auto;
max-height: 100%;
}
.headersColumn {
flex: 1;
min-width: 0;
}
.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;
overflow-y: auto;
max-height: 100%;
}
.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;
}

View File

@@ -0,0 +1,215 @@
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<string>;
onNodeAction?: (nodeId: string, action: NodeAction) => void;
nodeConfigs?: Map<string, NodeConfig>;
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 + center-on-node
const [selectedProcessorId, setSelectedProcessorId] = useState<string>('');
const [centerOnNodeId, setCenterOnNodeId] = useState<string>('');
// 6. Resizable splitter state
const [splitPercent, setSplitPercent] = useState(60);
const containerRef = useRef<HTMLDivElement>(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, 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]);
// Loading state
if (detailLoading || (detail && diagramLoading)) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.loadingState}>Loading execution data...</div>
</div>
);
}
// Error state
if (detailError) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.errorState}>Failed to load execution detail</div>
</div>
);
}
if (diagramError) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.errorState}>Failed to load diagram</div>
</div>
);
}
if (!detail) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.loadingState}>No execution data</div>
</div>
);
}
return (
<div ref={containerRef} className={`${styles.executionDiagram} ${className ?? ''}`}>
{/* Exchange summary bar */}
<div className={styles.exchangeBar}>
<span className={styles.exchangeLabel}>Exchange</span>
<code className={styles.exchangeId}>{detail.exchangeId || detail.executionId}</code>
<span className={statusBadgeClass(detail.status)}>
{detail.status}
</span>
<span className={styles.exchangeMeta}>
{detail.applicationName} / {detail.routeId}
</span>
<span className={styles.exchangeMeta}>{detail.durationMs}ms</span>
{detail.status === 'FAILED' && (
<button
className={styles.jumpToError}
onClick={handleJumpToError}
type="button"
>
Jump to Error
</button>
)}
</div>
{/* Diagram area */}
<div className={styles.diagramArea} style={{ height: `${splitPercent}%` }}>
<ProcessDiagram
application={detail.applicationName}
routeId={detail.routeId}
direction={direction}
diagramLayout={diagramLayout}
selectedNodeId={selectedProcessorId}
onNodeSelect={setSelectedProcessorId}
onNodeAction={onNodeAction}
nodeConfigs={nodeConfigs}
knownRouteIds={knownRouteIds}
executionOverlay={overlay}
iterationState={iterationState}
onIterationChange={setIteration}
centerOnNodeId={centerOnNodeId}
/>
</div>
{/* Resizable splitter */}
<div
className={styles.splitter}
onPointerDown={handleSplitterDown}
/>
{/* Detail panel */}
<div className={styles.detailArea} style={{ height: `${100 - splitPercent}%` }}>
<DetailPanel
selectedProcessor={findProcessorInTree(detail.processors, selectedProcessorId || null)}
executionDetail={detail}
executionId={executionId}
onSelectProcessor={setSelectedProcessorId}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { ExecutionDiagram } from './ExecutionDiagram';
export type { NodeExecutionState, IterationInfo, DetailTab } from './types';

View File

@@ -0,0 +1,47 @@
import { CodeBlock } from '@cameleer/design-system';
import styles from '../ExecutionDiagram.module.css';
interface BodyTabProps {
body: string | undefined;
label: string;
}
function detectLanguage(text: string): string {
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, language: string): string {
if (language === 'json') {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
return text;
}
export function BodyTab({ body, label }: BodyTabProps) {
if (!body) {
return <div className={styles.emptyState}>No {label.toLowerCase()} body available</div>;
}
const language = detectLanguage(body);
const formatted = formatBody(body, language);
return (
<div>
<CodeBlock content={formatted} language={language} copyable />
</div>
);
}

View File

@@ -0,0 +1,9 @@
import styles from '../ExecutionDiagram.module.css';
export function ConfigTab() {
return (
<div className={styles.emptyState}>
Processor configuration data is not yet available.
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { CodeBlock } from '@cameleer/design-system';
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 (
<div className={styles.emptyState}>
{processor
? 'No error on this processor'
: 'No error on this exchange'}
</div>
);
}
const exceptionType = extractExceptionType(errorMessage);
return (
<div>
<div className={styles.errorType}>{exceptionType}</div>
<div className={styles.errorMessage}>{errorMessage}</div>
{errorStackTrace && (
<>
<div className={styles.errorStackLabel}>Stack Trace</div>
<CodeBlock content={errorStackTrace} copyable />
</>
)}
</div>
);
}

View File

@@ -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<string, string> {
if (!json) return {};
try {
return JSON.parse(json);
} catch {
return {};
}
}
function HeaderTable({ headers }: { headers: Record<string, string> }) {
const entries = Object.entries(headers);
if (entries.length === 0) {
return <div className={styles.emptyState}>No headers</div>;
}
return (
<table className={styles.headersTable}>
<tbody>
{entries.map(([k, v]) => (
<tr key={k}>
<td className={styles.headerKey}>{k}</td>
<td className={styles.headerVal}>{v}</td>
</tr>
))}
</tbody>
</table>
);
}
export function HeadersTab({
executionId,
processorId,
exchangeInputHeaders,
exchangeOutputHeaders,
}: HeadersTabProps) {
const snapshotQuery = useProcessorSnapshotById(
processorId ? executionId : null,
processorId,
);
let inputHeaders: Record<string, string>;
let outputHeaders: Record<string, string>;
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 <div className={styles.emptyState}>Loading headers...</div>;
}
return (
<div className={styles.headersSplit}>
<div className={styles.headersColumn}>
<div className={styles.headersColumnLabel}>Input Headers</div>
<HeaderTable headers={inputHeaders} />
</div>
<div className={styles.headersColumn}>
<div className={styles.headersColumnLabel}>Output Headers</div>
<HeaderTable headers={outputHeaders} />
</div>
</div>
);
}

View File

@@ -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 (
<div>
<div className={styles.fieldLabel}>{label}</div>
<div className={mono ? styles.fieldValueMono : styles.fieldValue}>{value || '-'}</div>
</div>
);
}
function Attributes({ attrs }: { attrs: Record<string, string> | undefined }) {
if (!attrs) return null;
const entries = Object.entries(attrs);
if (entries.length === 0) return null;
return (
<div className={styles.attributesSection}>
<div className={styles.attributesLabel}>Attributes</div>
<div className={styles.attributesList}>
{entries.map(([k, v]) => (
<span key={k} className={styles.attributePill}>
{k}: {v}
</span>
))}
</div>
</div>
);
}
export function InfoTab({ processor, executionDetail }: InfoTabProps) {
if (processor) {
return (
<div>
<div className={styles.infoGrid}>
<Field label="Processor ID" value={processor.processorId} mono />
<Field label="Type" value={processor.processorType} />
<div>
<div className={styles.fieldLabel}>Status</div>
<span className={`${styles.statusBadge} ${statusClass(processor.status)}`}>
{processor.status}
</span>
</div>
<Field label="Start Time" value={formatTime(processor.startTime)} mono />
<Field label="End Time" value={formatTime(processor.endTime)} mono />
<Field label="Duration" value={formatDuration(processor.durationMs)} mono />
<Field label="Endpoint URI" value={processor.processorType} />
<Field label="Resolved URI" value="-" />
<div />
</div>
<Attributes attrs={processor.attributes} />
</div>
);
}
// Exchange-level view
return (
<div>
<div className={styles.infoGrid}>
<Field label="Execution ID" value={executionDetail.executionId} mono />
<Field label="Correlation ID" value={executionDetail.correlationId} mono />
<Field label="Exchange ID" value={executionDetail.exchangeId} mono />
<Field label="Application" value={executionDetail.applicationName} />
<Field label="Route ID" value={executionDetail.routeId} />
<div>
<div className={styles.fieldLabel}>Status</div>
<span className={`${styles.statusBadge} ${statusClass(executionDetail.status)}`}>
{executionDetail.status}
</span>
</div>
<Field label="Start Time" value={formatTime(executionDetail.startTime)} mono />
<Field label="End Time" value={formatTime(executionDetail.endTime)} mono />
<Field label="Duration" value={formatDuration(executionDetail.durationMs)} mono />
</div>
<Attributes attrs={executionDetail.attributes} />
</div>
);
}

View File

@@ -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 <div className={styles.emptyState}>No processor timeline data available</div>;
}
const execStart = new Date(executionDetail.startTime).getTime();
const totalDuration = executionDetail.durationMs || 1;
return (
<div className={styles.ganttContainer}>
{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 (
<div
key={proc.processorId}
className={`${styles.ganttRow} ${isSelected ? styles.ganttSelected : ''}`}
onClick={() => onSelectProcessor(proc.processorId)}
>
<div className={styles.ganttLabel} title={proc.processorType || proc.processorId}>
{' '.repeat(proc.depth)}{proc.processorType || proc.processorId}
</div>
<div className={styles.ganttBar}>
<div
className={`${styles.ganttFill} ${fillClass}`}
style={{
left: `${offsetPct}%`,
width: `${Math.min(widthPct, 100 - offsetPct)}%`,
}}
/>
</div>
<div className={styles.ganttDuration}>
{proc.durationMs}ms
</div>
</div>
);
})}
</div>
);
}

View File

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

View File

@@ -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<string, NodeExecutionState>,
iterationState: Map<string, IterationInfo>,
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<processorId, NodeExecutionState> that tells DiagramNode
* and DiagramEdge how to render each element (success/failure colors,
* traversed edges, etc.).
*/
export function useExecutionOverlay(
processors: ProcessorNode[] | undefined,
iterationState: Map<string, IterationInfo>,
): Map<string, NodeExecutionState> {
return useMemo(() => {
if (!processors) return new Map();
const overlay = new Map<string, NodeExecutionState>();
buildOverlay(processors, overlay, iterationState);
return overlay;
}, [processors, iterationState]);
}

View File

@@ -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<string, IterationInfo>,
): 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<number>();
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<Map<string, IterationInfo>>(new Map());
// Initialize iteration info when processors change
useEffect(() => {
if (!processors) return;
const newState = new Map<string, IterationInfo>();
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 };
}

View File

@@ -1,8 +1,10 @@
import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './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;
@@ -17,6 +19,14 @@ interface CompoundNodeProps {
selectedNodeId?: string;
hoveredNodeId: string | null;
nodeConfigs?: Map<string, NodeConfig>;
/** Execution overlay for edge traversal coloring */
executionOverlay?: Map<string, NodeExecutionState>;
/** Whether an execution overlay is active (enables dimming of skipped nodes) */
overlayActive?: boolean;
/** Per-compound iteration state */
iterationState?: Map<string, IterationInfo>;
/** 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;
@@ -25,7 +35,8 @@ interface CompoundNodeProps {
export function CompoundNode({
node, edges, parentX = 0, parentY = 0,
selectedNodeId, hoveredNodeId, nodeConfigs,
selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
}: CompoundNodeProps) {
const x = (node.x ?? 0) - parentX;
@@ -37,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<string>();
@@ -76,17 +89,44 @@ export function CompoundNode({
{label}
</text>
{/* Iteration stepper (for LOOP, SPLIT, MULTICAST with overlay data) */}
{iterationInfo && (
<foreignObject x={headerWidth - 80} y={1} width={75} height={20}>
<div className={styles.iterationStepper}>
<button
onClick={(e) => { e.stopPropagation(); onIterationChange?.(node.id!, iterationInfo.current - 1); }}
disabled={iterationInfo.current <= 0}
>
&lsaquo;
</button>
<span>{iterationInfo.current + 1} / {iterationInfo.total}</span>
<button
onClick={(e) => { e.stopPropagation(); onIterationChange?.(node.id!, iterationInfo.current + 1); }}
disabled={iterationInfo.current >= iterationInfo.total - 1}
>
&rsaquo;
</button>
</div>
</foreignObject>
)}
{/* Internal edges (rendered after background, before children) */}
<g className="edges">
{internalEdges.map((edge, i) => (
<DiagramEdge
key={`${edge.sourceId}-${edge.targetId}-${i}`}
edge={{
...edge,
points: edge.points.map(p => [p[0] - absX, p[1] - absY]),
}}
/>
))}
{internalEdges.map((edge, i) => {
const isTraversed = executionOverlay
? (executionOverlay.has(edge.sourceId) && executionOverlay.has(edge.targetId))
: undefined;
return (
<DiagramEdge
key={`${edge.sourceId}-${edge.targetId}-${i}`}
edge={{
...edge,
points: edge.points.map(p => [p[0] - absX, p[1] - absY]),
}}
traversed={isTraversed}
/>
);
})}
</g>
{/* Children — recurse into compound children, render leaves as DiagramNode */}
@@ -102,6 +142,10 @@ export function CompoundNode({
selectedNodeId={selectedNodeId}
hoveredNodeId={hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={executionOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeEnter={onNodeEnter}
@@ -120,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)}

View File

@@ -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) {
<path
d={d}
fill="none"
stroke="#9CA3AF"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
stroke={traversed === true ? '#3D7C47' : '#9CA3AF'}
strokeWidth={traversed === true ? 1.5 : traversed === false ? 1 : 1.5}
strokeDasharray={traversed === false ? '4,3' : undefined}
markerEnd={traversed === true ? 'url(#arrowhead-green)' : traversed === false ? undefined : 'url(#arrowhead)'}
/>
{edge.label && pts.length >= 2 && (
<text

View File

@@ -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 (
<g
data-node-id={node.id}
@@ -40,6 +77,7 @@ export function DiagramNode({
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{ cursor: 'pointer' }}
opacity={isSkipped ? 0.35 : undefined}
>
{/* 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 */}
<rect x={0} y={0} width={w} height={TOP_BAR_HEIGHT} rx={CORNER_RADIUS} fill={color} />
<rect x={CORNER_RADIUS} y={0} width={w - CORNER_RADIUS * 2} height={TOP_BAR_HEIGHT} fill={color} />
<rect x={0} y={0} width={w} height={TOP_BAR_HEIGHT} rx={CORNER_RADIUS} fill={topBarColor} />
<rect x={CORNER_RADIUS} y={0} width={w - CORNER_RADIUS * 2} height={TOP_BAR_HEIGHT} fill={topBarColor} />
{/* Icon */}
<text x={14} y={h / 2 + 6} fill={color} fontSize={14}>
<text x={14} y={h / 2 + 6} fill={statusColor ?? color} fontSize={14}>
{icon}
</text>
{/* Type name */}
<text x={32} y={h / 2 + 1} fill="#1A1612" fontSize={11} fontWeight={600}>
<text x={32} y={h / 2 + 1} fill={labelColor} fontSize={11} fontWeight={600}>
{typeName}
</text>
{/* Detail label (truncated) */}
{detail && detail !== typeName && (
<text x={32} y={h / 2 + 14} fill="#5C5347" fontSize={10}>
<text x={32} y={h / 2 + 14} fill={isFailed ? '#C0392B' : '#5C5347'} fontSize={10}>
{detail.length > 22 ? detail.slice(0, 20) + '...' : detail}
</text>
)}
{/* Config badges */}
{config && <ConfigBadge nodeWidth={w} config={config} />}
{/* Execution overlay: status badge inside card, top-right corner */}
{isCompleted && (
<>
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#3D7C47" />
<text
x={w - 10}
y={TOP_BAR_HEIGHT + 11}
textAnchor="middle"
fill="white"
fontSize={9}
fontWeight={700}
>
&#x2713;
</text>
</>
)}
{isFailed && (
<>
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#C0392B" />
<text
x={w - 10}
y={TOP_BAR_HEIGHT + 11}
textAnchor="middle"
fill="white"
fontSize={9}
fontWeight={700}
>
!
</text>
</>
)}
{/* Execution overlay: duration text at bottom-right */}
{executionState && statusColor && (
<text
x={w - 6}
y={h - 4}
textAnchor="end"
fill={statusColor}
fontSize={9}
fontWeight={500}
>
{formatDuration(executionState.durationMs)}
</text>
)}
{/* Sub-route failure: drill-down arrow at bottom-left */}
{isFailed && executionState?.subRouteFailed && (
<text
x={6}
y={h - 4}
fill="#C0392B"
fontSize={11}
fontWeight={700}
>
&#x21B3;
</text>
)}
</g>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import type { DiagramSection } from './types';
import type { NodeConfig } from './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';
@@ -16,6 +17,14 @@ interface ErrorSectionProps {
selectedNodeId?: string;
hoveredNodeId: string | null;
nodeConfigs?: Map<string, NodeConfig>;
/** Execution overlay for edge traversal coloring */
executionOverlay?: Map<string, NodeExecutionState>;
/** Whether an execution overlay is active (enables dimming of skipped nodes) */
overlayActive?: boolean;
/** Per-compound iteration state */
iterationState?: Map<string, IterationInfo>;
/** 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;
@@ -28,10 +37,25 @@ const VARIANT_COLORS: Record<string, string> = {
};
export function ErrorSection({
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange,
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) {
@@ -55,36 +79,54 @@ export function ErrorSection({
{section.label}
</text>
{/* Divider line */}
{/* Divider line — solid when triggered */}
<line
x1={0}
y1={0}
x2={totalWidth}
y2={0}
stroke={color}
strokeWidth={1}
strokeDasharray="6 3"
opacity={0.5}
strokeWidth={wasTriggered ? 1.5 : 1}
strokeDasharray={wasTriggered ? undefined : '6 3'}
opacity={wasTriggered ? 0.8 : 0.5}
/>
{/* Subtle red tint background — sized to actual content */}
{/* Background — stronger when this handler was triggered during execution */}
<rect
x={0}
y={4}
width={totalWidth}
height={boxHeight}
fill={color}
opacity={0.03}
opacity={wasTriggered ? 0.08 : 0.03}
rx={4}
/>
{wasTriggered && (
<rect
x={0}
y={4}
width={totalWidth}
height={boxHeight}
fill="none"
stroke={color}
strokeWidth={1.5}
rx={4}
opacity={0.5}
/>
)}
{/* Content group with margin from top-left */}
<g transform={`translate(${CONTENT_PADDING_LEFT}, ${CONTENT_PADDING_Y})`}>
{/* Edges */}
<g className="edges">
{section.edges.map((edge, i) => (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
))}
{section.edges.map((edge, i) => {
const isTraversed = executionOverlay
? (executionOverlay.has(edge.sourceId) && executionOverlay.has(edge.targetId))
: undefined;
return (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} traversed={isTraversed} />
);
})}
</g>
{/* Nodes */}
@@ -99,6 +141,10 @@ export function ErrorSection({
selectedNodeId={selectedNodeId}
hoveredNodeId={hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={executionOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeEnter={onNodeEnter}
@@ -113,6 +159,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)}

View File

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

View File

@@ -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';
@@ -52,6 +52,11 @@ export function ProcessDiagram({
nodeConfigs,
knownRouteIds,
className,
diagramLayout,
executionOverlay,
iterationState,
onIterationChange,
centerOnNodeId,
}: ProcessDiagramProps) {
// Route stack for drill-down navigation
const [routeStack, setRouteStack] = useState<string[]>([routeId]);
@@ -62,11 +67,33 @@ 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.
const effectiveLayout = isDrilledDown ? undefined : diagramLayout;
const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData(
application, currentRouteId, direction,
application, currentRouteId, direction, effectiveLayout,
);
// 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<string>();
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();
@@ -80,6 +107,50 @@ 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).
const getNodeExecutionState = useCallback(
(nodeId: string | undefined, nodeType: string | undefined) => {
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' && effectiveOverlay.size > 0) {
return { status: 'COMPLETED' as const, durationMs: 0, hasTraceData: false };
}
return undefined;
},
[effectiveOverlay],
);
const handleNodeClick = useCallback(
(nodeId: string) => { onNodeSelect?.(nodeId); },
[onNodeSelect],
@@ -188,8 +259,8 @@ export function ProcessDiagram({
)}
<svg
ref={zoom.svgRef}
className={styles.svg}
onWheel={zoom.onWheel}
onPointerDown={zoom.onPointerDown}
onPointerMove={zoom.onPointerMove}
onPointerUp={zoom.onPointerUp}
@@ -208,14 +279,31 @@ export function ProcessDiagram({
>
<polygon points="0 0, 8 3, 0 6" fill="#9CA3AF" />
</marker>
<marker
id="arrowhead-green"
markerWidth="8"
markerHeight="6"
refX="7"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill="#3D7C47" />
</marker>
</defs>
<g style={{ transform: zoom.transform, transformOrigin: '0 0' }}>
{/* Main section top-level edges (not inside compounds) */}
<g className="edges">
{mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
))}
{mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => {
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 (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} traversed={isTraversed} />
);
})}
</g>
{/* Main section nodes */}
@@ -230,6 +318,10 @@ export function ProcessDiagram({
selectedNodeId={selectedNodeId}
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={effectiveOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeEnter={toolbar.onNodeEnter}
@@ -244,6 +336,8 @@ export function ProcessDiagram({
isHovered={toolbar.hoveredNodeId === node.id}
isSelected={selectedNodeId === node.id}
config={node.id ? nodeConfigs?.get(node.id) : undefined}
executionState={getNodeExecutionState(node.id, node.type)}
overlayActive={overlayActive}
onClick={() => node.id && handleNodeClick(node.id)}
onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)}
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
@@ -264,6 +358,10 @@ export function ProcessDiagram({
selectedNodeId={selectedNodeId}
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={executionOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeEnter={toolbar.onNodeEnter}
@@ -345,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[],

View File

@@ -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,14 @@ export interface ProcessDiagramProps {
/** Known route IDs for this application (enables drill-down resolution) */
knownRouteIds?: Set<string>;
className?: string;
/** Pre-fetched diagram layout (bypasses internal fetch by application/routeId) */
diagramLayout?: DiagramLayout;
/** Execution overlay: maps diagram node ID → execution state */
executionOverlay?: Map<string, NodeExecutionState>;
/** Per-compound iteration info: maps compound node ID → iteration info */
iterationState?: Map<string, IterationInfo>;
/** 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;
}

View File

@@ -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. */

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface ZoomPanState {
scale: number;
@@ -20,19 +20,24 @@ export function useZoomPan() {
const isPanning = useRef(false);
const panStart = useRef({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const clampScale = (s: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
/** Returns the CSS transform string for the content <g> element. */
const transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
const onWheel = useCallback(
(e: React.WheelEvent<SVGSVGElement>) => {
// 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<SVGSVGElement>) => {
@@ -158,10 +164,10 @@ export function useZoomPan() {
return {
state,
containerRef,
svgRef,
transform,
panTo,
resetView,
onWheel,
onPointerDown,
onPointerMove,
onPointerUp,

View File

@@ -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)
========================================================================== */

View File

@@ -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<string>()
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<string, NodeConfig>()
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() {
</button>
</div>
</div>
<div className={styles.timelineBody}>
{timelineView === 'gantt' ? (
processors.length > 0 ? (
{timelineView === 'gantt' && (
<div className={styles.timelineBody}>
{processors.length > 0 ? (
<ProcessorTimeline
processors={processors}
totalMs={detail.durationMs}
@@ -481,33 +479,23 @@ export default function ExchangeDetail() {
/>
) : (
<InfoCallout>No processor data available</InfoCallout>
)
) : (
routeFlows.length > 0 ? (
<RouteFlow
flows={routeFlows}
onNodeClick={(_node, index) => {
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,
}]
}}
/>
) : (
<Spinner />
)
)}
</div>
)}
</div>
)}
</div>
{timelineView === 'flow' && detail && (
<div className={styles.executionDiagramContainer}>
<ExecutionDiagram
executionId={id!}
executionDetail={detail}
knownRouteIds={knownRouteIds}
onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs}
/>
</div>
)}
{/* Exchange-level body (start/end of route) */}
{detail && (detail.inputBody || detail.outputBody) && (
<div className={styles.detailSplit}>