Compare commits
24 Commits
30c8fe1091
...
085c4e395b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
085c4e395b | ||
|
|
d7166b6d0a | ||
|
|
25e23c0b87 | ||
|
|
cf9e847f84 | ||
|
|
bfd76261ef | ||
|
|
0b8efa1998 | ||
|
|
3027e9b24f | ||
|
|
3d5d462de0 | ||
|
|
f675451384 | ||
|
|
021a52e56b | ||
|
|
5ccefa3cdb | ||
|
|
e4c66b1311 | ||
|
|
5da03d0938 | ||
|
|
3af1d1f3b6 | ||
|
|
1984c597de | ||
|
|
3029704051 | ||
|
|
2b805ec196 | ||
|
|
ff59dc5d57 | ||
|
|
3928743ea7 | ||
|
|
cf6c4bd60c | ||
|
|
edd841ffeb | ||
|
|
889f0e5263 | ||
|
|
3a41e1f1d3 | ||
|
|
509159417b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ logs/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
.worktrees/
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
|
||||
1121
docs/superpowers/plans/2026-03-27-execution-overlay.md
Normal file
1121
docs/superpowers/plans/2026-03-27-execution-overlay.md
Normal file
File diff suppressed because it is too large
Load Diff
417
docs/superpowers/specs/2026-03-27-execution-overlay-design.md
Normal file
417
docs/superpowers/specs/2026-03-27-execution-overlay-design.md
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
64
ui/src/api/schema.d.ts
vendored
64
ui/src/api/schema.d.ts
vendored
@@ -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: {
|
||||
|
||||
164
ui/src/components/ExecutionDiagram/DetailPanel.tsx
Normal file
164
ui/src/components/ExecutionDiagram/DetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
538
ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css
Normal file
538
ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css
Normal 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;
|
||||
}
|
||||
215
ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx
Normal file
215
ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
ui/src/components/ExecutionDiagram/index.ts
Normal file
2
ui/src/components/ExecutionDiagram/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ExecutionDiagram } from './ExecutionDiagram';
|
||||
export type { NodeExecutionState, IterationInfo, DetailTab } from './types';
|
||||
47
ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx
Normal file
47
ui/src/components/ExecutionDiagram/tabs/BodyTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx
Normal file
9
ui/src/components/ExecutionDiagram/tabs/ConfigTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx
Normal file
46
ui/src/components/ExecutionDiagram/tabs/ErrorTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx
Normal file
80
ui/src/components/ExecutionDiagram/tabs/HeadersTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx
Normal file
115
ui/src/components/ExecutionDiagram/tabs/InfoTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx
Normal file
94
ui/src/components/ExecutionDiagram/tabs/TimelineTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
ui/src/components/ExecutionDiagram/types.ts
Normal file
24
ui/src/components/ExecutionDiagram/types.ts
Normal 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';
|
||||
80
ui/src/components/ExecutionDiagram/useExecutionOverlay.ts
Normal file
80
ui/src/components/ExecutionDiagram/useExecutionOverlay.ts
Normal 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]);
|
||||
}
|
||||
91
ui/src/components/ExecutionDiagram/useIterationState.ts
Normal file
91
ui/src/components/ExecutionDiagram/useIterationState.ts
Normal 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 };
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
‹
|
||||
</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}
|
||||
>
|
||||
›
|
||||
</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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
✓
|
||||
</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}
|
||||
>
|
||||
↳
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
========================================================================== */
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user