feat: execution overlay & debugger (sub-project 2)
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 36s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Adds execution overlay to the ProcessDiagram component, turning it into
an after-the-fact debugger for Camel route executions.

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-27 19:51:55 +01:00
35 changed files with 2230 additions and 131 deletions

View File

@@ -49,7 +49,7 @@ public class DetailController {
}
@GetMapping("/{executionId}/processors/{index}/snapshot")
@Operation(summary = "Get exchange snapshot for a specific processor")
@Operation(summary = "Get exchange snapshot for a specific processor by index")
@ApiResponse(responseCode = "200", description = "Snapshot data")
@ApiResponse(responseCode = "404", description = "Snapshot not found")
public ResponseEntity<Map<String, String>> getProcessorSnapshot(
@@ -69,4 +69,16 @@ public class DetailController {
return ResponseEntity.ok(snapshot);
}
@GetMapping("/{executionId}/processors/by-id/{processorId}/snapshot")
@Operation(summary = "Get exchange snapshot for a specific processor by processorId")
@ApiResponse(responseCode = "200", description = "Snapshot data")
@ApiResponse(responseCode = "404", description = "Snapshot not found")
public ResponseEntity<Map<String, String>> processorSnapshotById(
@PathVariable String executionId,
@PathVariable String processorId) {
return detailService.getProcessorSnapshot(executionId, processorId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -13,6 +13,7 @@ import org.eclipse.elk.core.RecursiveGraphLayoutEngine;
import org.eclipse.elk.core.options.CoreOptions;
import org.eclipse.elk.core.options.Direction;
import org.eclipse.elk.core.options.HierarchyHandling;
import org.eclipse.elk.alg.layered.options.NodePlacementStrategy;
import org.eclipse.elk.core.util.BasicProgressMonitor;
import org.eclipse.elk.graph.ElkBendPoint;
import org.eclipse.elk.graph.ElkEdge;
@@ -181,6 +182,8 @@ public class ElkDiagramRenderer implements DiagramRenderer {
rootNode.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING);
rootNode.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING);
rootNode.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN);
rootNode.setProperty(org.eclipse.elk.alg.layered.options.LayeredOptions.NODE_PLACEMENT_STRATEGY,
NodePlacementStrategy.LINEAR_SEGMENTS);
// Build index of all RouteNodes (flat list from graph + recursive children)
Map<String, RouteNode> routeNodeMap = new HashMap<>();

View File

@@ -72,8 +72,9 @@ public class PostgresExecutionStore implements ExecutionStore {
INSERT INTO processor_executions (execution_id, processor_id, processor_type,
application_name, route_id, depth, parent_processor_id,
status, start_time, end_time, duration_ms, error_message, error_stacktrace,
input_body, output_body, input_headers, output_headers, attributes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb)
input_body, output_body, input_headers, output_headers, attributes,
loop_index, loop_size, split_index, split_size, multicast_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?, ?, ?, ?, ?)
ON CONFLICT (execution_id, processor_id, start_time) DO UPDATE SET
status = EXCLUDED.status,
end_time = COALESCE(EXCLUDED.end_time, processor_executions.end_time),
@@ -84,7 +85,12 @@ public class PostgresExecutionStore implements ExecutionStore {
output_body = COALESCE(EXCLUDED.output_body, processor_executions.output_body),
input_headers = COALESCE(EXCLUDED.input_headers, processor_executions.input_headers),
output_headers = COALESCE(EXCLUDED.output_headers, processor_executions.output_headers),
attributes = COALESCE(EXCLUDED.attributes, processor_executions.attributes)
attributes = COALESCE(EXCLUDED.attributes, processor_executions.attributes),
loop_index = COALESCE(EXCLUDED.loop_index, processor_executions.loop_index),
loop_size = COALESCE(EXCLUDED.loop_size, processor_executions.loop_size),
split_index = COALESCE(EXCLUDED.split_index, processor_executions.split_index),
split_size = COALESCE(EXCLUDED.split_size, processor_executions.split_size),
multicast_index = COALESCE(EXCLUDED.multicast_index, processor_executions.multicast_index)
""",
processors.stream().map(p -> new Object[]{
p.executionId(), p.processorId(), p.processorType(),
@@ -94,7 +100,9 @@ public class PostgresExecutionStore implements ExecutionStore {
p.endTime() != null ? Timestamp.from(p.endTime()) : null,
p.durationMs(), p.errorMessage(), p.errorStacktrace(),
p.inputBody(), p.outputBody(), p.inputHeaders(), p.outputHeaders(),
p.attributes()
p.attributes(),
p.loopIndex(), p.loopSize(), p.splitIndex(), p.splitSize(),
p.multicastIndex()
}).toList());
}
@@ -113,6 +121,13 @@ public class PostgresExecutionStore implements ExecutionStore {
PROCESSOR_MAPPER, executionId);
}
@Override
public Optional<ProcessorRecord> findProcessorById(String executionId, String processorId) {
String sql = "SELECT * FROM processor_executions WHERE execution_id = ? AND processor_id = ? LIMIT 1";
List<ProcessorRecord> results = jdbc.query(sql, PROCESSOR_MAPPER, executionId, processorId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) ->
new ExecutionRecord(
rs.getString("execution_id"), rs.getString("route_id"),
@@ -140,7 +155,12 @@ public class PostgresExecutionStore implements ExecutionStore {
rs.getString("error_message"), rs.getString("error_stacktrace"),
rs.getString("input_body"), rs.getString("output_body"),
rs.getString("input_headers"), rs.getString("output_headers"),
rs.getString("attributes"));
rs.getString("attributes"),
rs.getObject("loop_index") != null ? rs.getInt("loop_index") : null,
rs.getObject("loop_size") != null ? rs.getInt("loop_size") : null,
rs.getObject("split_index") != null ? rs.getInt("split_index") : null,
rs.getObject("split_size") != null ? rs.getInt("split_size") : null,
rs.getObject("multicast_index") != null ? rs.getInt("multicast_index") : null);
private static Instant toInstant(ResultSet rs, String column) throws SQLException {
Timestamp ts = rs.getTimestamp(column);

View File

@@ -0,0 +1,5 @@
ALTER TABLE processor_executions ADD COLUMN IF NOT EXISTS loop_index INTEGER;
ALTER TABLE processor_executions ADD COLUMN IF NOT EXISTS loop_size INTEGER;
ALTER TABLE processor_executions ADD COLUMN IF NOT EXISTS split_index INTEGER;
ALTER TABLE processor_executions ADD COLUMN IF NOT EXISTS split_size INTEGER;
ALTER TABLE processor_executions ADD COLUMN IF NOT EXISTS multicast_index INTEGER;

View File

@@ -38,6 +38,18 @@ public class DetailService {
});
}
public Optional<Map<String, String>> getProcessorSnapshot(String executionId, String processorId) {
return executionStore.findProcessorById(executionId, processorId)
.map(p -> {
Map<String, String> snapshot = new LinkedHashMap<>();
if (p.inputBody() != null) snapshot.put("inputBody", p.inputBody());
if (p.outputBody() != null) snapshot.put("outputBody", p.outputBody());
if (p.inputHeaders() != null) snapshot.put("inputHeaders", p.inputHeaders());
if (p.outputHeaders() != null) snapshot.put("outputHeaders", p.outputHeaders());
return snapshot;
});
}
List<ProcessorNode> buildTree(List<ProcessorRecord> processors) {
if (processors.isEmpty()) return List.of();
@@ -48,7 +60,10 @@ public class DetailService {
p.startTime(), p.endTime(),
p.durationMs() != null ? p.durationMs() : 0L,
p.errorMessage(), p.errorStacktrace(),
parseAttributes(p.attributes())
parseAttributes(p.attributes()),
p.loopIndex(), p.loopSize(),
p.splitIndex(), p.splitSize(),
p.multicastIndex()
));
}

View File

@@ -22,12 +22,20 @@ public final class ProcessorNode {
private final String errorMessage;
private final String errorStackTrace;
private final Map<String, String> attributes;
private final Integer loopIndex;
private final Integer loopSize;
private final Integer splitIndex;
private final Integer splitSize;
private final Integer multicastIndex;
private final List<ProcessorNode> children;
public ProcessorNode(String processorId, String processorType, String status,
Instant startTime, Instant endTime, long durationMs,
String errorMessage, String errorStackTrace,
Map<String, String> attributes) {
Map<String, String> attributes,
Integer loopIndex, Integer loopSize,
Integer splitIndex, Integer splitSize,
Integer multicastIndex) {
this.processorId = processorId;
this.processorType = processorType;
this.status = status;
@@ -37,6 +45,11 @@ public final class ProcessorNode {
this.errorMessage = errorMessage;
this.errorStackTrace = errorStackTrace;
this.attributes = attributes;
this.loopIndex = loopIndex;
this.loopSize = loopSize;
this.splitIndex = splitIndex;
this.splitSize = splitSize;
this.multicastIndex = multicastIndex;
this.children = new ArrayList<>();
}
@@ -53,5 +66,10 @@ public final class ProcessorNode {
public String getErrorMessage() { return errorMessage; }
public String getErrorStackTrace() { return errorStackTrace; }
public Map<String, String> getAttributes() { return attributes; }
public Integer getLoopIndex() { return loopIndex; }
public Integer getLoopSize() { return loopSize; }
public Integer getSplitIndex() { return splitIndex; }
public Integer getSplitSize() { return splitSize; }
public Integer getMulticastIndex() { return multicastIndex; }
public List<ProcessorNode> getChildren() { return List.copyOf(children); }
}

View File

@@ -128,7 +128,10 @@ public class IngestionService {
p.getErrorMessage(), p.getErrorStackTrace(),
truncateBody(p.getInputBody()), truncateBody(p.getOutputBody()),
toJson(p.getInputHeaders()), toJson(p.getOutputHeaders()),
toJson(p.getAttributes())
toJson(p.getAttributes()),
p.getLoopIndex(), p.getLoopSize(),
p.getSplitIndex(), p.getSplitSize(),
p.getMulticastIndex()
));
if (p.getChildren() != null) {
flat.addAll(flattenProcessors(

View File

@@ -16,6 +16,8 @@ public interface ExecutionStore {
List<ProcessorRecord> findProcessors(String executionId);
Optional<ProcessorRecord> findProcessorById(String executionId, String processorId);
record ExecutionRecord(
String executionId, String routeId, String agentId, String applicationName,
String status, String correlationId, String exchangeId,
@@ -33,6 +35,9 @@ public interface ExecutionStore {
Instant startTime, Instant endTime, Long durationMs,
String errorMessage, String errorStacktrace,
String inputBody, String outputBody, String inputHeaders, String outputHeaders,
String attributes
String attributes,
Integer loopIndex, Integer loopSize,
Integer splitIndex, Integer splitSize,
Integer multicastIndex
) {}
}

View File

@@ -2359,6 +2359,61 @@
}
}
},
"/executions/{executionId}/processors/by-id/{processorId}/snapshot": {
"get": {
"tags": [
"Detail"
],
"summary": "Get exchange snapshot for a processor by processorId",
"operationId": "getProcessorSnapshotById",
"parameters": [
{
"name": "executionId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "processorId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Snapshot data",
"content": {
"*/*": {
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"404": {
"description": "Snapshot not found",
"content": {
"*/*": {
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
},
"/diagrams": {
"get": {
"tags": [
@@ -4588,8 +4643,25 @@
"type": "integer",
"format": "int64"
},
"diagramNodeId": {
"type": "string"
"loopIndex": {
"type": "integer",
"format": "int32"
},
"loopSize": {
"type": "integer",
"format": "int32"
},
"splitIndex": {
"type": "integer",
"format": "int32"
},
"splitSize": {
"type": "integer",
"format": "int32"
},
"multicastIndex": {
"type": "integer",
"format": "int32"
},
"errorMessage": {
"type": "string"
@@ -4613,7 +4685,6 @@
"required": [
"attributes",
"children",
"diagramNodeId",
"durationMs",
"endTime",
"errorMessage",

View File

@@ -114,3 +114,25 @@ export function useProcessorSnapshot(
enabled: !!executionId && index !== null,
});
}
export function useProcessorSnapshotById(
executionId: string | null,
processorId: string | null,
) {
return useQuery({
queryKey: ['executions', 'snapshot-by-id', executionId, processorId],
queryFn: async () => {
const { data, error } = await api.GET(
'/executions/{executionId}/processors/by-id/{processorId}/snapshot',
{
params: {
path: { executionId: executionId!, processorId: processorId! },
},
},
);
if (error) throw new Error('Failed to load snapshot');
return data!;
},
enabled: !!executionId && !!processorId,
});
}

View File

@@ -735,6 +735,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/executions/{executionId}/processors/by-id/{processorId}/snapshot": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get exchange snapshot for a processor by processorId */
get: operations["getProcessorSnapshotById"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/diagrams": {
parameters: {
query?: never;
@@ -1620,7 +1637,16 @@ export interface components {
endTime: string;
/** Format: int64 */
durationMs: number;
diagramNodeId: string;
/** Format: int32 */
loopIndex?: number;
/** Format: int32 */
loopSize?: number;
/** Format: int32 */
splitIndex?: number;
/** Format: int32 */
splitSize?: number;
/** Format: int32 */
multicastIndex?: number;
errorMessage: string;
errorStackTrace: string;
attributes: {
@@ -3637,6 +3663,42 @@ export interface operations {
};
};
};
getProcessorSnapshotById: {
parameters: {
query?: never;
header?: never;
path: {
executionId: string;
processorId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Snapshot data */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": {
[key: string]: string;
};
};
};
/** @description Snapshot not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": {
[key: string]: string;
};
};
};
};
};
findByApplicationAndRoute: {
parameters: {
query: {

View File

@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react';
import type { ProcessorNode, ExecutionDetail, DetailTab } from './types';
import { useProcessorSnapshotById } from '../../api/queries/executions';
import { InfoTab } from './tabs/InfoTab';
import { HeadersTab } from './tabs/HeadersTab';
import { BodyTab } from './tabs/BodyTab';
import { ErrorTab } from './tabs/ErrorTab';
import { ConfigTab } from './tabs/ConfigTab';
import { TimelineTab } from './tabs/TimelineTab';
import styles from './ExecutionDiagram.module.css';
interface DetailPanelProps {
selectedProcessor: ProcessorNode | null;
executionDetail: ExecutionDetail;
executionId: string;
onSelectProcessor: (processorId: string) => void;
}
const TABS: { key: DetailTab; label: string }[] = [
{ key: 'info', label: 'Info' },
{ key: 'headers', label: 'Headers' },
{ key: 'input', label: 'Input' },
{ key: 'output', label: 'Output' },
{ key: 'error', label: 'Error' },
{ key: 'config', label: 'Config' },
{ key: 'timeline', label: 'Timeline' },
];
function formatDuration(ms: number | undefined): string {
if (ms === undefined || ms === null) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function statusClass(status: string): string {
const s = status?.toUpperCase();
if (s === 'COMPLETED') return styles.statusCompleted;
if (s === 'FAILED') return styles.statusFailed;
return '';
}
export function DetailPanel({
selectedProcessor,
executionDetail,
executionId,
onSelectProcessor,
}: DetailPanelProps) {
const [activeTab, setActiveTab] = useState<DetailTab>('info');
// When selectedProcessor changes, keep current tab unless it was a
// processor-specific tab and now there is no processor selected.
const prevProcessorId = selectedProcessor?.processorId;
useEffect(() => {
// If no processor is selected and we're on a processor-specific tab, go to info
if (!selectedProcessor && (activeTab === 'input' || activeTab === 'output')) {
// Input/Output at exchange level still make sense, keep them
}
}, [prevProcessorId]); // eslint-disable-line react-hooks/exhaustive-deps
const hasError = selectedProcessor
? !!selectedProcessor.errorMessage
: !!executionDetail.errorMessage;
// Fetch snapshot for body tabs when a processor is selected
const snapshotQuery = useProcessorSnapshotById(
selectedProcessor ? executionId : null,
selectedProcessor?.processorId ?? null,
);
// Determine body content for Input/Output tabs
let inputBody: string | undefined;
let outputBody: string | undefined;
if (selectedProcessor && snapshotQuery.data) {
inputBody = snapshotQuery.data.inputBody;
outputBody = snapshotQuery.data.outputBody;
} else if (!selectedProcessor) {
inputBody = executionDetail.inputBody;
outputBody = executionDetail.outputBody;
}
// Header display
const headerName = selectedProcessor ? selectedProcessor.processorType : 'Exchange';
const headerStatus = selectedProcessor ? selectedProcessor.status : executionDetail.status;
const headerId = selectedProcessor ? selectedProcessor.processorId : executionDetail.executionId;
const headerDuration = selectedProcessor ? selectedProcessor.durationMs : executionDetail.durationMs;
return (
<div className={styles.detailPanel}>
{/* Processor / Exchange header bar */}
<div className={styles.processorHeader}>
<span className={styles.processorName}>{headerName}</span>
<span className={`${styles.statusBadge} ${statusClass(headerStatus)}`}>
{headerStatus}
</span>
<span className={styles.processorId}>{headerId}</span>
<span className={styles.processorDuration}>{formatDuration(headerDuration)}</span>
</div>
{/* Tab bar */}
<div className={styles.tabBar}>
{TABS.map((tab) => {
const isActive = activeTab === tab.key;
const isDisabled = tab.key === 'config';
const isError = tab.key === 'error' && hasError;
const isErrorGrayed = tab.key === 'error' && !hasError;
let className = styles.tab;
if (isActive) className += ` ${styles.tabActive}`;
if (isDisabled) className += ` ${styles.tabDisabled}`;
if (isError && !isActive) className += ` ${styles.tabError}`;
if (isErrorGrayed && !isActive) className += ` ${styles.tabDisabled}`;
return (
<button
key={tab.key}
className={className}
onClick={() => {
if (!isDisabled) setActiveTab(tab.key);
}}
type="button"
>
{tab.label}
</button>
);
})}
</div>
{/* Tab content */}
<div className={styles.tabContent}>
{activeTab === 'info' && (
<InfoTab processor={selectedProcessor} executionDetail={executionDetail} />
)}
{activeTab === 'headers' && (
<HeadersTab
executionId={executionId}
processorId={selectedProcessor?.processorId ?? null}
exchangeInputHeaders={executionDetail.inputHeaders}
exchangeOutputHeaders={executionDetail.outputHeaders}
/>
)}
{activeTab === 'input' && (
<BodyTab body={inputBody} label="Input" />
)}
{activeTab === 'output' && (
<BodyTab body={outputBody} label="Output" />
)}
{activeTab === 'error' && (
<ErrorTab processor={selectedProcessor} executionDetail={executionDetail} />
)}
{activeTab === 'config' && (
<ConfigTab />
)}
{activeTab === 'timeline' && (
<TimelineTab
executionDetail={executionDetail}
selectedProcessorId={selectedProcessor?.processorId ?? null}
onSelectProcessor={onSelectProcessor}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,538 @@
/* ==========================================================================
EXECUTION DIAGRAM — LAYOUT
========================================================================== */
.executionDiagram {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 400px;
overflow: hidden;
}
.exchangeBar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
background: var(--bg-surface, #FFFFFF);
border-bottom: 1px solid var(--border, #E4DFD8);
font-size: 12px;
color: var(--text-secondary, #5C5347);
flex-shrink: 0;
}
.exchangeLabel {
font-weight: 600;
color: var(--text-primary, #1A1612);
}
.exchangeId {
font-size: 11px;
background: var(--bg-hover, #F5F0EA);
padding: 2px 6px;
border-radius: 3px;
color: var(--text-primary, #1A1612);
}
.exchangeMeta {
color: var(--text-muted, #9C9184);
}
.jumpToError {
margin-left: auto;
font-size: 10px;
padding: 3px 10px;
border: 1px solid var(--error, #C0392B);
background: #FDF2F0;
color: var(--error, #C0392B);
border-radius: 4px;
cursor: pointer;
font-weight: 500;
font-family: inherit;
}
.jumpToError:hover {
background: #F9E0DC;
}
.diagramArea {
overflow: hidden;
position: relative;
}
.splitter {
height: 4px;
background: var(--border, #E4DFD8);
cursor: row-resize;
flex-shrink: 0;
}
.splitter:hover {
background: var(--amber, #C6820E);
}
.detailArea {
overflow: hidden;
min-height: 120px;
}
.loadingState {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-muted, #9C9184);
font-size: 13px;
}
.errorState {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--error, #C0392B);
font-size: 13px;
}
.statusRunning {
color: var(--amber, #C6820E);
background: #FFF8F0;
}
/* ==========================================================================
DETAIL PANEL
========================================================================== */
.detailPanel {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-surface, #FFFFFF);
border-top: 1px solid var(--border, #E4DFD8);
}
.processorHeader {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 6px 14px;
border-bottom: 1px solid var(--border, #E4DFD8);
background: #FAFAF8;
min-height: 32px;
}
.processorName {
font-size: 12px;
font-weight: 600;
color: var(--text-primary, #1A1612);
}
.processorId {
font-size: 11px;
font-family: var(--font-mono, monospace);
color: var(--text-muted, #9C9184);
}
.processorDuration {
font-size: 11px;
font-family: var(--font-mono, monospace);
color: var(--text-secondary, #5C5347);
margin-left: auto;
}
/* ==========================================================================
STATUS BADGE
========================================================================== */
.statusBadge {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.statusCompleted {
color: var(--success, #3D7C47);
background: #F0F9F1;
}
.statusFailed {
color: var(--error, #C0392B);
background: #FDF2F0;
}
/* ==========================================================================
TAB BAR
========================================================================== */
.tabBar {
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--border, #E4DFD8);
padding: 0 14px;
background: #FAFAF8;
gap: 0;
}
.tab {
padding: 6px 12px;
font-size: 11px;
font-family: var(--font-body, inherit);
cursor: pointer;
color: var(--text-muted, #9C9184);
border: none;
background: none;
border-bottom: 2px solid transparent;
transition: color 0.12s, border-color 0.12s;
white-space: nowrap;
}
.tab:hover {
color: var(--text-secondary, #5C5347);
}
.tabActive {
color: var(--amber, #C6820E);
border-bottom: 2px solid var(--amber, #C6820E);
font-weight: 600;
}
.tabDisabled {
opacity: 0.4;
cursor: default;
}
.tabDisabled:hover {
color: var(--text-muted, #9C9184);
}
.tabError {
color: var(--error, #C0392B);
}
.tabError:hover {
color: var(--error, #C0392B);
}
/* ==========================================================================
TAB CONTENT
========================================================================== */
.tabContent {
flex: 1;
overflow-y: auto;
padding: 10px 14px;
}
/* ==========================================================================
INFO TAB — GRID
========================================================================== */
.infoGrid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px 24px;
}
.fieldLabel {
font-size: 10px;
color: var(--text-muted, #9C9184);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.fieldValue {
font-size: 12px;
color: var(--text-primary, #1A1612);
word-break: break-all;
}
.fieldValueMono {
font-size: 12px;
color: var(--text-primary, #1A1612);
font-family: var(--font-mono, monospace);
word-break: break-all;
}
/* ==========================================================================
ATTRIBUTE PILLS
========================================================================== */
.attributesSection {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--border, #E4DFD8);
}
.attributesLabel {
font-size: 10px;
color: var(--text-muted, #9C9184);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.attributesList {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.attributePill {
font-size: 10px;
padding: 2px 8px;
background: var(--bg-hover, #F5F0EA);
border-radius: 10px;
color: var(--text-secondary, #5C5347);
font-family: var(--font-mono, monospace);
}
/* ==========================================================================
HEADERS TAB — SPLIT
========================================================================== */
.headersSplit {
display: flex;
gap: 0;
min-height: 0;
overflow-y: auto;
max-height: 100%;
}
.headersColumn {
flex: 1;
min-width: 0;
}
.headersColumn + .headersColumn {
border-left: 1px solid var(--border, #E4DFD8);
padding-left: 14px;
margin-left: 14px;
}
.headersColumnLabel {
font-size: 10px;
font-weight: 600;
color: var(--text-muted, #9C9184);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.headersTable {
width: 100%;
font-size: 11px;
border-collapse: collapse;
}
.headersTable td {
padding: 3px 0;
border-bottom: 1px solid var(--border, #E4DFD8);
vertical-align: top;
}
.headersTable tr:last-child td {
border-bottom: none;
}
.headerKey {
font-family: var(--font-mono, monospace);
font-weight: 600;
color: var(--text-muted, #9C9184);
white-space: nowrap;
padding-right: 12px;
width: 140px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
}
.headerVal {
font-family: var(--font-mono, monospace);
color: var(--text-primary, #1A1612);
word-break: break-all;
}
/* ==========================================================================
BODY / CODE TAB
========================================================================== */
.codeHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.codeFormat {
font-size: 10px;
font-weight: 600;
color: var(--text-muted, #9C9184);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.codeSize {
font-size: 10px;
color: var(--text-muted, #9C9184);
font-family: var(--font-mono, monospace);
}
.codeCopyBtn {
margin-left: auto;
font-size: 10px;
font-family: var(--font-body, inherit);
padding: 2px 8px;
border: 1px solid var(--border, #E4DFD8);
border-radius: 4px;
background: var(--bg-surface, #FFFFFF);
color: var(--text-secondary, #5C5347);
cursor: pointer;
}
.codeCopyBtn:hover {
background: var(--bg-hover, #F5F0EA);
}
.codeBlock {
background: #1A1612;
color: #E4DFD8;
padding: 12px;
border-radius: 6px;
font-family: var(--font-mono, monospace);
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
}
/* ==========================================================================
ERROR TAB
========================================================================== */
.errorType {
font-size: 13px;
font-weight: 600;
color: var(--error, #C0392B);
margin-bottom: 8px;
}
.errorMessage {
font-size: 12px;
color: var(--text-primary, #1A1612);
background: #FDF2F0;
border: 1px solid #F5D5D0;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 12px;
line-height: 1.5;
word-break: break-word;
}
.errorStackTrace {
background: #1A1612;
color: #E4DFD8;
padding: 12px;
border-radius: 6px;
font-family: var(--font-mono, monospace);
font-size: 10px;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
max-height: 300px;
overflow-y: auto;
}
.errorStackLabel {
font-size: 10px;
font-weight: 600;
color: var(--text-muted, #9C9184);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
/* ==========================================================================
TIMELINE / GANTT TAB
========================================================================== */
.ganttContainer {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
max-height: 100%;
}
.ganttRow {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 3px 4px;
border-radius: 3px;
transition: background 0.1s;
}
.ganttRow:hover {
background: var(--bg-hover, #F5F0EA);
}
.ganttSelected {
background: #FFF8F0;
}
.ganttSelected:hover {
background: #FFF8F0;
}
.ganttLabel {
width: 100px;
min-width: 100px;
font-size: 10px;
color: var(--text-secondary, #5C5347);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ganttBar {
flex: 1;
height: 16px;
background: var(--bg-hover, #F5F0EA);
border-radius: 2px;
position: relative;
min-width: 0;
}
.ganttFill {
position: absolute;
height: 100%;
border-radius: 2px;
min-width: 2px;
}
.ganttFillCompleted {
background: var(--success, #3D7C47);
}
.ganttFillFailed {
background: var(--error, #C0392B);
}
.ganttDuration {
width: 50px;
min-width: 50px;
font-size: 10px;
font-family: var(--font-mono, monospace);
color: var(--text-muted, #9C9184);
text-align: right;
}
/* ==========================================================================
EMPTY STATE
========================================================================== */
.emptyState {
text-align: center;
color: var(--text-muted, #9C9184);
font-size: 12px;
padding: 20px;
}

View File

@@ -0,0 +1,215 @@
import { useCallback, useRef, useState } from 'react';
import type { NodeAction, NodeConfig } from '../ProcessDiagram/types';
import type { ExecutionDetail, ProcessorNode } from './types';
import { useExecutionDetail } from '../../api/queries/executions';
import { useDiagramLayout } from '../../api/queries/diagrams';
import { ProcessDiagram } from '../ProcessDiagram';
import { DetailPanel } from './DetailPanel';
import { useExecutionOverlay } from './useExecutionOverlay';
import { useIterationState } from './useIterationState';
import styles from './ExecutionDiagram.module.css';
interface ExecutionDiagramProps {
executionId: string;
executionDetail?: ExecutionDetail;
direction?: 'LR' | 'TB';
knownRouteIds?: Set<string>;
onNodeAction?: (nodeId: string, action: NodeAction) => void;
nodeConfigs?: Map<string, NodeConfig>;
className?: string;
}
function findProcessorInTree(
nodes: ProcessorNode[] | undefined,
processorId: string | null,
): ProcessorNode | null {
if (!nodes || !processorId) return null;
for (const n of nodes) {
if (n.processorId === processorId) return n;
if (n.children) {
const found = findProcessorInTree(n.children, processorId);
if (found) return found;
}
}
return null;
}
function findFailedProcessor(nodes: ProcessorNode[]): ProcessorNode | null {
for (const n of nodes) {
if (n.status === 'FAILED') return n;
if (n.children) {
const found = findFailedProcessor(n.children);
if (found) return found;
}
}
return null;
}
function statusBadgeClass(status: string): string {
const s = status?.toUpperCase();
if (s === 'COMPLETED') return `${styles.statusBadge} ${styles.statusCompleted}`;
if (s === 'FAILED') return `${styles.statusBadge} ${styles.statusFailed}`;
if (s === 'RUNNING') return `${styles.statusBadge} ${styles.statusRunning}`;
return styles.statusBadge;
}
export function ExecutionDiagram({
executionId,
executionDetail: externalDetail,
direction = 'LR',
knownRouteIds,
onNodeAction,
nodeConfigs,
className,
}: ExecutionDiagramProps) {
// 1. Fetch execution data (skip if pre-fetched prop provided)
const detailQuery = useExecutionDetail(externalDetail ? null : executionId);
const detail = externalDetail ?? detailQuery.data;
const detailLoading = !externalDetail && detailQuery.isLoading;
const detailError = !externalDetail && detailQuery.error;
// 2. Load diagram by content hash
const diagramQuery = useDiagramLayout(detail?.diagramContentHash ?? null, direction);
const diagramLayout = diagramQuery.data;
const diagramLoading = diagramQuery.isLoading;
const diagramError = diagramQuery.error;
// 3. Initialize iteration state
const { iterationState, setIteration } = useIterationState(detail?.processors);
// 4. Compute overlay
const overlay = useExecutionOverlay(detail?.processors, iterationState);
// 5. Manage selection + center-on-node
const [selectedProcessorId, setSelectedProcessorId] = useState<string>('');
const [centerOnNodeId, setCenterOnNodeId] = useState<string>('');
// 6. Resizable splitter state
const [splitPercent, setSplitPercent] = useState(60);
const containerRef = useRef<HTMLDivElement>(null);
const handleSplitterDown = useCallback((e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
const container = containerRef.current;
if (!container) return;
const onMove = (me: PointerEvent) => {
const rect = container.getBoundingClientRect();
const y = me.clientY - rect.top;
const pct = Math.min(85, Math.max(30, (y / rect.height) * 100));
setSplitPercent(pct);
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
}, []);
// Jump to error: find first FAILED processor, select it, and center the viewport
const handleJumpToError = useCallback(() => {
if (!detail?.processors) return;
const failed = findFailedProcessor(detail.processors);
if (failed?.processorId) {
setSelectedProcessorId(failed.processorId);
// Use a unique value to re-trigger centering even if the same node
setCenterOnNodeId('');
requestAnimationFrame(() => setCenterOnNodeId(failed.processorId));
}
}, [detail?.processors]);
// Loading state
if (detailLoading || (detail && diagramLoading)) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.loadingState}>Loading execution data...</div>
</div>
);
}
// Error state
if (detailError) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.errorState}>Failed to load execution detail</div>
</div>
);
}
if (diagramError) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.errorState}>Failed to load diagram</div>
</div>
);
}
if (!detail) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.loadingState}>No execution data</div>
</div>
);
}
return (
<div ref={containerRef} className={`${styles.executionDiagram} ${className ?? ''}`}>
{/* Exchange summary bar */}
<div className={styles.exchangeBar}>
<span className={styles.exchangeLabel}>Exchange</span>
<code className={styles.exchangeId}>{detail.exchangeId || detail.executionId}</code>
<span className={statusBadgeClass(detail.status)}>
{detail.status}
</span>
<span className={styles.exchangeMeta}>
{detail.applicationName} / {detail.routeId}
</span>
<span className={styles.exchangeMeta}>{detail.durationMs}ms</span>
{detail.status === 'FAILED' && (
<button
className={styles.jumpToError}
onClick={handleJumpToError}
type="button"
>
Jump to Error
</button>
)}
</div>
{/* Diagram area */}
<div className={styles.diagramArea} style={{ height: `${splitPercent}%` }}>
<ProcessDiagram
application={detail.applicationName}
routeId={detail.routeId}
direction={direction}
diagramLayout={diagramLayout}
selectedNodeId={selectedProcessorId}
onNodeSelect={setSelectedProcessorId}
onNodeAction={onNodeAction}
nodeConfigs={nodeConfigs}
knownRouteIds={knownRouteIds}
executionOverlay={overlay}
iterationState={iterationState}
onIterationChange={setIteration}
centerOnNodeId={centerOnNodeId}
/>
</div>
{/* Resizable splitter */}
<div
className={styles.splitter}
onPointerDown={handleSplitterDown}
/>
{/* Detail panel */}
<div className={styles.detailArea} style={{ height: `${100 - splitPercent}%` }}>
<DetailPanel
selectedProcessor={findProcessorInTree(detail.processors, selectedProcessorId || null)}
executionDetail={detail}
executionId={executionId}
onSelectProcessor={setSelectedProcessorId}
/>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
import { CodeBlock } from '@cameleer/design-system';
import type { ProcessorNode, ExecutionDetail } from '../types';
import styles from '../ExecutionDiagram.module.css';
interface ErrorTabProps {
processor: ProcessorNode | null;
executionDetail: ExecutionDetail;
}
function extractExceptionType(errorMessage: string): string {
const colonIdx = errorMessage.indexOf(':');
if (colonIdx > 0) {
return errorMessage.substring(0, colonIdx).trim();
}
return 'Error';
}
export function ErrorTab({ processor, executionDetail }: ErrorTabProps) {
const errorMessage = processor?.errorMessage || executionDetail.errorMessage;
const errorStackTrace = processor?.errorStackTrace || executionDetail.errorStackTrace;
if (!errorMessage) {
return (
<div className={styles.emptyState}>
{processor
? 'No error on this processor'
: 'No error on this exchange'}
</div>
);
}
const exceptionType = extractExceptionType(errorMessage);
return (
<div>
<div className={styles.errorType}>{exceptionType}</div>
<div className={styles.errorMessage}>{errorMessage}</div>
{errorStackTrace && (
<>
<div className={styles.errorStackLabel}>Stack Trace</div>
<CodeBlock content={errorStackTrace} copyable />
</>
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useProcessorSnapshotById } from '../../../api/queries/executions';
import styles from '../ExecutionDiagram.module.css';
interface HeadersTabProps {
executionId: string;
processorId: string | null;
exchangeInputHeaders?: string;
exchangeOutputHeaders?: string;
}
function parseHeaders(json: string | undefined): Record<string, string> {
if (!json) return {};
try {
return JSON.parse(json);
} catch {
return {};
}
}
function HeaderTable({ headers }: { headers: Record<string, string> }) {
const entries = Object.entries(headers);
if (entries.length === 0) {
return <div className={styles.emptyState}>No headers</div>;
}
return (
<table className={styles.headersTable}>
<tbody>
{entries.map(([k, v]) => (
<tr key={k}>
<td className={styles.headerKey}>{k}</td>
<td className={styles.headerVal}>{v}</td>
</tr>
))}
</tbody>
</table>
);
}
export function HeadersTab({
executionId,
processorId,
exchangeInputHeaders,
exchangeOutputHeaders,
}: HeadersTabProps) {
const snapshotQuery = useProcessorSnapshotById(
processorId ? executionId : null,
processorId,
);
let inputHeaders: Record<string, string>;
let outputHeaders: Record<string, string>;
if (processorId && snapshotQuery.data) {
inputHeaders = parseHeaders(snapshotQuery.data.inputHeaders);
outputHeaders = parseHeaders(snapshotQuery.data.outputHeaders);
} else if (!processorId) {
inputHeaders = parseHeaders(exchangeInputHeaders);
outputHeaders = parseHeaders(exchangeOutputHeaders);
} else {
inputHeaders = {};
outputHeaders = {};
}
if (processorId && snapshotQuery.isLoading) {
return <div className={styles.emptyState}>Loading headers...</div>;
}
return (
<div className={styles.headersSplit}>
<div className={styles.headersColumn}>
<div className={styles.headersColumnLabel}>Input Headers</div>
<HeaderTable headers={inputHeaders} />
</div>
<div className={styles.headersColumn}>
<div className={styles.headersColumnLabel}>Output Headers</div>
<HeaderTable headers={outputHeaders} />
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import type { ProcessorNode, ExecutionDetail } from '../types';
import styles from '../ExecutionDiagram.module.css';
interface InfoTabProps {
processor: ProcessorNode | null;
executionDetail: ExecutionDetail;
}
function formatTime(iso: string | undefined): string {
if (!iso) return '-';
try {
const d = new Date(iso);
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
const s = String(d.getSeconds()).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return `${h}:${m}:${s}.${ms}`;
} catch {
return iso;
}
}
function formatDuration(ms: number | undefined): string {
if (ms === undefined || ms === null) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function statusClass(status: string): string {
const s = status?.toUpperCase();
if (s === 'COMPLETED') return styles.statusCompleted;
if (s === 'FAILED') return styles.statusFailed;
return '';
}
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div className={styles.fieldLabel}>{label}</div>
<div className={mono ? styles.fieldValueMono : styles.fieldValue}>{value || '-'}</div>
</div>
);
}
function Attributes({ attrs }: { attrs: Record<string, string> | undefined }) {
if (!attrs) return null;
const entries = Object.entries(attrs);
if (entries.length === 0) return null;
return (
<div className={styles.attributesSection}>
<div className={styles.attributesLabel}>Attributes</div>
<div className={styles.attributesList}>
{entries.map(([k, v]) => (
<span key={k} className={styles.attributePill}>
{k}: {v}
</span>
))}
</div>
</div>
);
}
export function InfoTab({ processor, executionDetail }: InfoTabProps) {
if (processor) {
return (
<div>
<div className={styles.infoGrid}>
<Field label="Processor ID" value={processor.processorId} mono />
<Field label="Type" value={processor.processorType} />
<div>
<div className={styles.fieldLabel}>Status</div>
<span className={`${styles.statusBadge} ${statusClass(processor.status)}`}>
{processor.status}
</span>
</div>
<Field label="Start Time" value={formatTime(processor.startTime)} mono />
<Field label="End Time" value={formatTime(processor.endTime)} mono />
<Field label="Duration" value={formatDuration(processor.durationMs)} mono />
<Field label="Endpoint URI" value={processor.processorType} />
<Field label="Resolved URI" value="-" />
<div />
</div>
<Attributes attrs={processor.attributes} />
</div>
);
}
// Exchange-level view
return (
<div>
<div className={styles.infoGrid}>
<Field label="Execution ID" value={executionDetail.executionId} mono />
<Field label="Correlation ID" value={executionDetail.correlationId} mono />
<Field label="Exchange ID" value={executionDetail.exchangeId} mono />
<Field label="Application" value={executionDetail.applicationName} />
<Field label="Route ID" value={executionDetail.routeId} />
<div>
<div className={styles.fieldLabel}>Status</div>
<span className={`${styles.statusBadge} ${statusClass(executionDetail.status)}`}>
{executionDetail.status}
</span>
</div>
<Field label="Start Time" value={formatTime(executionDetail.startTime)} mono />
<Field label="End Time" value={formatTime(executionDetail.endTime)} mono />
<Field label="Duration" value={formatDuration(executionDetail.durationMs)} mono />
</div>
<Attributes attrs={executionDetail.attributes} />
</div>
);
}

View File

@@ -0,0 +1,94 @@
import type { ExecutionDetail, ProcessorNode } from '../types';
import styles from '../ExecutionDiagram.module.css';
interface TimelineTabProps {
executionDetail: ExecutionDetail;
selectedProcessorId: string | null;
onSelectProcessor: (id: string) => void;
}
interface FlatProcessor {
processorId: string;
processorType: string;
status: string;
startTime: string;
durationMs: number;
depth: number;
}
function flattenProcessors(
nodes: ProcessorNode[],
depth: number,
result: FlatProcessor[],
): void {
for (const node of nodes) {
const status = node.status?.toUpperCase();
if (status === 'COMPLETED' || status === 'FAILED') {
result.push({
processorId: node.processorId,
processorType: node.processorType,
status,
startTime: node.startTime,
durationMs: node.durationMs,
depth,
});
}
if (node.children && node.children.length > 0) {
flattenProcessors(node.children, depth + 1, result);
}
}
}
export function TimelineTab({
executionDetail,
selectedProcessorId,
onSelectProcessor,
}: TimelineTabProps) {
const flat: FlatProcessor[] = [];
flattenProcessors(executionDetail.processors || [], 0, flat);
if (flat.length === 0) {
return <div className={styles.emptyState}>No processor timeline data available</div>;
}
const execStart = new Date(executionDetail.startTime).getTime();
const totalDuration = executionDetail.durationMs || 1;
return (
<div className={styles.ganttContainer}>
{flat.map((proc) => {
const procStart = new Date(proc.startTime).getTime();
const offsetPct = Math.max(0, ((procStart - execStart) / totalDuration) * 100);
const widthPct = Math.max(0.5, (proc.durationMs / totalDuration) * 100);
const isSelected = proc.processorId === selectedProcessorId;
const fillClass = proc.status === 'FAILED'
? styles.ganttFillFailed
: styles.ganttFillCompleted;
return (
<div
key={proc.processorId}
className={`${styles.ganttRow} ${isSelected ? styles.ganttSelected : ''}`}
onClick={() => onSelectProcessor(proc.processorId)}
>
<div className={styles.ganttLabel} title={proc.processorType || proc.processorId}>
{' '.repeat(proc.depth)}{proc.processorType || proc.processorId}
</div>
<div className={styles.ganttBar}>
<div
className={`${styles.ganttFill} ${fillClass}`}
style={{
left: `${offsetPct}%`,
width: `${Math.min(widthPct, 100 - offsetPct)}%`,
}}
/>
</div>
<div className={styles.ganttDuration}>
{proc.durationMs}ms
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { components } from '../../api/schema';
export type ExecutionDetail = components['schemas']['ExecutionDetail'];
export type ProcessorNode = components['schemas']['ProcessorNode'];
export interface NodeExecutionState {
status: 'COMPLETED' | 'FAILED';
durationMs: number;
/** True if this node's target sub-route failed (DIRECT/SEDA) */
subRouteFailed?: boolean;
/** True if trace data is available for this processor */
hasTraceData?: boolean;
}
export interface IterationInfo {
/** Current iteration index (0-based) */
current: number;
/** Total number of iterations */
total: number;
/** Type of iteration (determines label) */
type: 'loop' | 'split' | 'multicast';
}
export type DetailTab = 'info' | 'headers' | 'input' | 'output' | 'error' | 'config' | 'timeline';

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import type { NodeExecutionState, IterationInfo, ProcessorNode } from './types';
/**
* Recursively walks the ProcessorNode tree and populates an overlay map
* keyed by processorId → NodeExecutionState.
*
* Handles iteration filtering: when a processor has a loop/split/multicast
* index, only include it if it matches the currently selected iteration
* for its parent compound node.
*/
function buildOverlay(
processors: ProcessorNode[],
overlay: Map<string, NodeExecutionState>,
iterationState: Map<string, IterationInfo>,
parentId?: string,
): void {
for (const proc of processors) {
if (!proc.processorId || !proc.status) continue;
if (proc.status !== 'COMPLETED' && proc.status !== 'FAILED') continue;
// Iteration filtering: if this processor belongs to an iterated parent,
// only include it when the index matches the selected iteration.
if (parentId && iterationState.has(parentId)) {
const info = iterationState.get(parentId)!;
if (info.type === 'loop' && proc.loopIndex != null) {
if (proc.loopIndex !== info.current) {
// Still recurse into children so nested compounds are discovered,
// but skip adding this processor to the overlay.
continue;
}
}
if (info.type === 'split' && proc.splitIndex != null) {
if (proc.splitIndex !== info.current) {
continue;
}
}
if (info.type === 'multicast' && proc.multicastIndex != null) {
if (proc.multicastIndex !== info.current) {
continue;
}
}
}
const subRouteFailed =
proc.status === 'FAILED' &&
(proc.processorType?.includes('DIRECT') || proc.processorType?.includes('SEDA'));
overlay.set(proc.processorId, {
status: proc.status as 'COMPLETED' | 'FAILED',
durationMs: proc.durationMs ?? 0,
subRouteFailed: subRouteFailed || undefined,
hasTraceData: true,
});
// Recurse into children, passing this processor as the parent for iteration filtering.
if (proc.children?.length) {
buildOverlay(proc.children, overlay, iterationState, proc.processorId);
}
}
}
/**
* Maps execution data (processor tree) to diagram overlay state.
*
* Returns a Map<processorId, NodeExecutionState> that tells DiagramNode
* and DiagramEdge how to render each element (success/failure colors,
* traversed edges, etc.).
*/
export function useExecutionOverlay(
processors: ProcessorNode[] | undefined,
iterationState: Map<string, IterationInfo>,
): Map<string, NodeExecutionState> {
return useMemo(() => {
if (!processors) return new Map();
const overlay = new Map<string, NodeExecutionState>();
buildOverlay(processors, overlay, iterationState);
return overlay;
}, [processors, iterationState]);
}

View File

@@ -0,0 +1,91 @@
import { useCallback, useEffect, useState } from 'react';
import type { IterationInfo, ProcessorNode } from './types';
/**
* Walks the processor tree and detects compound nodes that have iterated
* children (loop, split, multicast). Populates a map of compoundId →
* IterationInfo so the UI can show stepper widgets and filter iterations.
*/
function detectIterations(
processors: ProcessorNode[],
result: Map<string, IterationInfo>,
): void {
for (const proc of processors) {
if (!proc.children?.length) continue;
// Check if children indicate a loop compound
const loopChild = proc.children.find(
(c) => c.loopSize != null && c.loopSize > 0,
);
if (loopChild && proc.processorId) {
result.set(proc.processorId, {
current: 0,
total: loopChild.loopSize!,
type: 'loop',
});
}
// Check if children indicate a split compound
const splitChild = proc.children.find(
(c) => c.splitSize != null && c.splitSize > 0,
);
if (splitChild && !loopChild && proc.processorId) {
result.set(proc.processorId, {
current: 0,
total: splitChild.splitSize!,
type: 'split',
});
}
// Check if children indicate a multicast compound
if (!loopChild && !splitChild) {
const multicastIndices = new Set<number>();
for (const child of proc.children) {
if (child.multicastIndex != null) {
multicastIndices.add(child.multicastIndex);
}
}
if (multicastIndices.size > 0 && proc.processorId) {
result.set(proc.processorId, {
current: 0,
total: multicastIndices.size,
type: 'multicast',
});
}
}
// Recurse into children to find nested iterations
detectIterations(proc.children, result);
}
}
/**
* Manages per-compound iteration state for the execution overlay.
*
* Scans the processor tree to detect compounds with iterated children
* and tracks which iteration index is currently selected for each.
*/
export function useIterationState(processors: ProcessorNode[] | undefined) {
const [state, setState] = useState<Map<string, IterationInfo>>(new Map());
// Initialize iteration info when processors change
useEffect(() => {
if (!processors) return;
const newState = new Map<string, IterationInfo>();
detectIterations(processors, newState);
setState(newState);
}, [processors]);
const setIteration = useCallback((compoundId: string, index: number) => {
setState((prev) => {
const next = new Map(prev);
const info = next.get(compoundId);
if (info && index >= 0 && index < info.total) {
next.set(compoundId, { ...info, current: index });
}
return next;
});
}, []);
return { iterationState: state, setIteration };
}

View File

@@ -1,8 +1,10 @@
import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types';
import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types';
import { colorForType, isCompoundType } from './node-colors';
import { DiagramNode } from './DiagramNode';
import { DiagramEdge } from './DiagramEdge';
import styles from './ProcessDiagram.module.css';
const HEADER_HEIGHT = 22;
const CORNER_RADIUS = 4;
@@ -17,6 +19,14 @@ interface CompoundNodeProps {
selectedNodeId?: string;
hoveredNodeId: string | null;
nodeConfigs?: Map<string, NodeConfig>;
/** Execution overlay for edge traversal coloring */
executionOverlay?: Map<string, NodeExecutionState>;
/** Whether an execution overlay is active (enables dimming of skipped nodes) */
overlayActive?: boolean;
/** Per-compound iteration state */
iterationState?: Map<string, IterationInfo>;
/** Called when user changes iteration on a compound stepper */
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
onNodeClick: (nodeId: string) => void;
onNodeDoubleClick?: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void;
@@ -25,7 +35,8 @@ interface CompoundNodeProps {
export function CompoundNode({
node, edges, parentX = 0, parentY = 0,
selectedNodeId, hoveredNodeId, nodeConfigs,
selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
}: CompoundNodeProps) {
const x = (node.x ?? 0) - parentX;
@@ -37,6 +48,8 @@ export function CompoundNode({
const color = colorForType(node.type);
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
const label = node.label ? `${typeName}: ${node.label}` : typeName;
const iterationInfo = node.id ? iterationState?.get(node.id) : undefined;
const headerWidth = w;
// Collect all descendant node IDs to filter edges that belong inside this compound
const descendantIds = new Set<string>();
@@ -76,17 +89,44 @@ export function CompoundNode({
{label}
</text>
{/* Iteration stepper (for LOOP, SPLIT, MULTICAST with overlay data) */}
{iterationInfo && (
<foreignObject x={headerWidth - 80} y={1} width={75} height={20}>
<div className={styles.iterationStepper}>
<button
onClick={(e) => { e.stopPropagation(); onIterationChange?.(node.id!, iterationInfo.current - 1); }}
disabled={iterationInfo.current <= 0}
>
&lsaquo;
</button>
<span>{iterationInfo.current + 1} / {iterationInfo.total}</span>
<button
onClick={(e) => { e.stopPropagation(); onIterationChange?.(node.id!, iterationInfo.current + 1); }}
disabled={iterationInfo.current >= iterationInfo.total - 1}
>
&rsaquo;
</button>
</div>
</foreignObject>
)}
{/* Internal edges (rendered after background, before children) */}
<g className="edges">
{internalEdges.map((edge, i) => (
<DiagramEdge
key={`${edge.sourceId}-${edge.targetId}-${i}`}
edge={{
...edge,
points: edge.points.map(p => [p[0] - absX, p[1] - absY]),
}}
/>
))}
{internalEdges.map((edge, i) => {
const isTraversed = executionOverlay
? (executionOverlay.has(edge.sourceId) && executionOverlay.has(edge.targetId))
: undefined;
return (
<DiagramEdge
key={`${edge.sourceId}-${edge.targetId}-${i}`}
edge={{
...edge,
points: edge.points.map(p => [p[0] - absX, p[1] - absY]),
}}
traversed={isTraversed}
/>
);
})}
</g>
{/* Children — recurse into compound children, render leaves as DiagramNode */}
@@ -102,6 +142,10 @@ export function CompoundNode({
selectedNodeId={selectedNodeId}
hoveredNodeId={hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={executionOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeEnter={onNodeEnter}
@@ -120,6 +164,8 @@ export function CompoundNode({
isHovered={hoveredNodeId === child.id}
isSelected={selectedNodeId === child.id}
config={child.id ? nodeConfigs?.get(child.id) : undefined}
executionState={executionOverlay?.get(child.id ?? '')}
overlayActive={overlayActive}
onClick={() => child.id && onNodeClick(child.id)}
onDoubleClick={() => child.id && onNodeDoubleClick?.(child.id)}
onMouseEnter={() => child.id && onNodeEnter(child.id)}

View File

@@ -3,9 +3,11 @@ import type { DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams'
interface DiagramEdgeProps {
edge: DiagramEdgeType;
offsetY?: number;
/** undefined = no overlay (default gray solid), true = traversed (green solid), false = not traversed (gray dashed) */
traversed?: boolean | undefined;
}
export function DiagramEdge({ edge, offsetY = 0 }: DiagramEdgeProps) {
export function DiagramEdge({ edge, offsetY = 0, traversed }: DiagramEdgeProps) {
const pts = edge.points;
if (!pts || pts.length < 2) return null;
@@ -29,9 +31,10 @@ export function DiagramEdge({ edge, offsetY = 0 }: DiagramEdgeProps) {
<path
d={d}
fill="none"
stroke="#9CA3AF"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
stroke={traversed === true ? '#3D7C47' : '#9CA3AF'}
strokeWidth={traversed === true ? 1.5 : traversed === false ? 1 : 1.5}
strokeDasharray={traversed === false ? '4,3' : undefined}
markerEnd={traversed === true ? 'url(#arrowhead-green)' : traversed === false ? undefined : 'url(#arrowhead)'}
/>
{edge.label && pts.length >= 2 && (
<text

View File

@@ -1,5 +1,6 @@
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types';
import type { NodeExecutionState } from '../ExecutionDiagram/types';
import { colorForType, iconForType } from './node-colors';
import { ConfigBadge } from './ConfigBadge';
@@ -11,14 +12,23 @@ interface DiagramNodeProps {
isHovered: boolean;
isSelected: boolean;
config?: NodeConfig;
executionState?: NodeExecutionState;
overlayActive?: boolean;
onClick: () => void;
onDoubleClick?: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
export function DiagramNode({
node, isHovered, isSelected, config, onClick, onDoubleClick, onMouseEnter, onMouseLeave,
node, isHovered, isSelected, config,
executionState, overlayActive,
onClick, onDoubleClick, onMouseEnter, onMouseLeave,
}: DiagramNodeProps) {
const x = node.x ?? 0;
const y = node.y ?? 0;
@@ -31,6 +41,33 @@ export function DiagramNode({
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
const detail = node.label || '';
// Overlay state derivation
const isCompleted = executionState?.status === 'COMPLETED';
const isFailed = executionState?.status === 'FAILED';
const isSkipped = overlayActive && !executionState;
// Colors based on execution state
let cardFill = isHovered ? '#F5F0EA' : 'white';
let borderStroke = isHovered || isSelected ? color : '#E4DFD8';
let borderWidth = isHovered || isSelected ? 1.5 : 1;
let topBarColor = color;
let labelColor = '#1A1612';
if (isCompleted) {
cardFill = isHovered ? '#E4F5E6' : '#F0F9F1';
borderStroke = '#3D7C47';
borderWidth = 1.5;
topBarColor = '#3D7C47';
} else if (isFailed) {
cardFill = isHovered ? '#F9E4E1' : '#FDF2F0';
borderStroke = '#C0392B';
borderWidth = 2;
topBarColor = '#C0392B';
labelColor = '#C0392B';
}
const statusColor = isCompleted ? '#3D7C47' : isFailed ? '#C0392B' : undefined;
return (
<g
data-node-id={node.id}
@@ -40,6 +77,7 @@ export function DiagramNode({
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{ cursor: 'pointer' }}
opacity={isSkipped ? 0.35 : undefined}
>
{/* Selection ring */}
{isSelected && (
@@ -62,34 +100,93 @@ export function DiagramNode({
width={w}
height={h}
rx={CORNER_RADIUS}
fill={isHovered ? '#F5F0EA' : 'white'}
stroke={isHovered || isSelected ? color : '#E4DFD8'}
strokeWidth={isHovered || isSelected ? 1.5 : 1}
fill={cardFill}
stroke={borderStroke}
strokeWidth={borderWidth}
/>
{/* Colored top bar */}
<rect x={0} y={0} width={w} height={TOP_BAR_HEIGHT} rx={CORNER_RADIUS} fill={color} />
<rect x={CORNER_RADIUS} y={0} width={w - CORNER_RADIUS * 2} height={TOP_BAR_HEIGHT} fill={color} />
<rect x={0} y={0} width={w} height={TOP_BAR_HEIGHT} rx={CORNER_RADIUS} fill={topBarColor} />
<rect x={CORNER_RADIUS} y={0} width={w - CORNER_RADIUS * 2} height={TOP_BAR_HEIGHT} fill={topBarColor} />
{/* Icon */}
<text x={14} y={h / 2 + 6} fill={color} fontSize={14}>
<text x={14} y={h / 2 + 6} fill={statusColor ?? color} fontSize={14}>
{icon}
</text>
{/* Type name */}
<text x={32} y={h / 2 + 1} fill="#1A1612" fontSize={11} fontWeight={600}>
<text x={32} y={h / 2 + 1} fill={labelColor} fontSize={11} fontWeight={600}>
{typeName}
</text>
{/* Detail label (truncated) */}
{detail && detail !== typeName && (
<text x={32} y={h / 2 + 14} fill="#5C5347" fontSize={10}>
<text x={32} y={h / 2 + 14} fill={isFailed ? '#C0392B' : '#5C5347'} fontSize={10}>
{detail.length > 22 ? detail.slice(0, 20) + '...' : detail}
</text>
)}
{/* Config badges */}
{config && <ConfigBadge nodeWidth={w} config={config} />}
{/* Execution overlay: status badge inside card, top-right corner */}
{isCompleted && (
<>
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#3D7C47" />
<text
x={w - 10}
y={TOP_BAR_HEIGHT + 11}
textAnchor="middle"
fill="white"
fontSize={9}
fontWeight={700}
>
&#x2713;
</text>
</>
)}
{isFailed && (
<>
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#C0392B" />
<text
x={w - 10}
y={TOP_BAR_HEIGHT + 11}
textAnchor="middle"
fill="white"
fontSize={9}
fontWeight={700}
>
!
</text>
</>
)}
{/* Execution overlay: duration text at bottom-right */}
{executionState && statusColor && (
<text
x={w - 6}
y={h - 4}
textAnchor="end"
fill={statusColor}
fontSize={9}
fontWeight={500}
>
{formatDuration(executionState.durationMs)}
</text>
)}
{/* Sub-route failure: drill-down arrow at bottom-left */}
{isFailed && executionState?.subRouteFailed && (
<text
x={6}
y={h - 4}
fill="#C0392B"
fontSize={11}
fontWeight={700}
>
&#x21B3;
</text>
)}
</g>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import type { DiagramSection } from './types';
import type { NodeConfig } from './types';
import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types';
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import { DiagramEdge } from './DiagramEdge';
import { DiagramNode } from './DiagramNode';
@@ -16,6 +17,14 @@ interface ErrorSectionProps {
selectedNodeId?: string;
hoveredNodeId: string | null;
nodeConfigs?: Map<string, NodeConfig>;
/** Execution overlay for edge traversal coloring */
executionOverlay?: Map<string, NodeExecutionState>;
/** Whether an execution overlay is active (enables dimming of skipped nodes) */
overlayActive?: boolean;
/** Per-compound iteration state */
iterationState?: Map<string, IterationInfo>;
/** Called when user changes iteration on a compound stepper */
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
onNodeClick: (nodeId: string) => void;
onNodeDoubleClick?: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void;
@@ -28,10 +37,25 @@ const VARIANT_COLORS: Record<string, string> = {
};
export function ErrorSection({
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs,
section, totalWidth, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
}: ErrorSectionProps) {
const color = VARIANT_COLORS[section.variant ?? 'error'] ?? VARIANT_COLORS.error;
// Check if any node in this section was executed (has overlay entry)
const wasTriggered = useMemo(() => {
if (!executionOverlay || executionOverlay.size === 0) return false;
function checkNodes(nodes: DiagramNodeType[]): boolean {
for (const n of nodes) {
if (n.id && executionOverlay!.has(n.id)) return true;
if (n.children && checkNodes(n.children)) return true;
}
return false;
}
return checkNodes(section.nodes);
}, [executionOverlay, section.nodes]);
const boxHeight = useMemo(() => {
let maxY = 0;
for (const n of section.nodes) {
@@ -55,36 +79,54 @@ export function ErrorSection({
{section.label}
</text>
{/* Divider line */}
{/* Divider line — solid when triggered */}
<line
x1={0}
y1={0}
x2={totalWidth}
y2={0}
stroke={color}
strokeWidth={1}
strokeDasharray="6 3"
opacity={0.5}
strokeWidth={wasTriggered ? 1.5 : 1}
strokeDasharray={wasTriggered ? undefined : '6 3'}
opacity={wasTriggered ? 0.8 : 0.5}
/>
{/* Subtle red tint background — sized to actual content */}
{/* Background — stronger when this handler was triggered during execution */}
<rect
x={0}
y={4}
width={totalWidth}
height={boxHeight}
fill={color}
opacity={0.03}
opacity={wasTriggered ? 0.08 : 0.03}
rx={4}
/>
{wasTriggered && (
<rect
x={0}
y={4}
width={totalWidth}
height={boxHeight}
fill="none"
stroke={color}
strokeWidth={1.5}
rx={4}
opacity={0.5}
/>
)}
{/* Content group with margin from top-left */}
<g transform={`translate(${CONTENT_PADDING_LEFT}, ${CONTENT_PADDING_Y})`}>
{/* Edges */}
<g className="edges">
{section.edges.map((edge, i) => (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
))}
{section.edges.map((edge, i) => {
const isTraversed = executionOverlay
? (executionOverlay.has(edge.sourceId) && executionOverlay.has(edge.targetId))
: undefined;
return (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} traversed={isTraversed} />
);
})}
</g>
{/* Nodes */}
@@ -99,6 +141,10 @@ export function ErrorSection({
selectedNodeId={selectedNodeId}
hoveredNodeId={hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={executionOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeEnter={onNodeEnter}
@@ -113,6 +159,8 @@ export function ErrorSection({
isHovered={hoveredNodeId === node.id}
isSelected={selectedNodeId === node.id}
config={node.id ? nodeConfigs?.get(node.id) : undefined}
executionState={executionOverlay?.get(node.id ?? '')}
overlayActive={overlayActive}
onClick={() => node.id && onNodeClick(node.id)}
onDoubleClick={() => node.id && onNodeDoubleClick?.(node.id)}
onMouseEnter={() => node.id && onNodeEnter(node.id)}

View File

@@ -168,3 +168,36 @@
background: var(--bg-hover, #F5F0EA);
color: var(--text-primary, #1A1612);
}
.iterationStepper {
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
padding: 1px 3px;
font-size: 10px;
color: white;
font-family: inherit;
}
.iterationStepper button {
width: 16px;
height: 16px;
border: none;
background: rgba(255, 255, 255, 0.2);
color: white;
border-radius: 2px;
cursor: pointer;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font-family: inherit;
}
.iterationStepper button:disabled {
opacity: 0.3;
cursor: default;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ProcessDiagramProps } from './types';
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import { useDiagramData } from './useDiagramData';
@@ -52,6 +52,11 @@ export function ProcessDiagram({
nodeConfigs,
knownRouteIds,
className,
diagramLayout,
executionOverlay,
iterationState,
onIterationChange,
centerOnNodeId,
}: ProcessDiagramProps) {
// Route stack for drill-down navigation
const [routeStack, setRouteStack] = useState<string[]>([routeId]);
@@ -62,11 +67,33 @@ export function ProcessDiagram({
}, [routeId]);
const currentRouteId = routeStack[routeStack.length - 1];
const isDrilledDown = currentRouteId !== routeId;
// Disable overlay when drilled down — the execution data is for the root route
// and doesn't map to sub-route node IDs. Sub-route shows topology only.
const overlayActive = !!executionOverlay && !isDrilledDown;
const effectiveOverlay = isDrilledDown ? undefined : executionOverlay;
// Only use the pre-fetched diagramLayout for the root route.
const effectiveLayout = isDrilledDown ? undefined : diagramLayout;
const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData(
application, currentRouteId, direction,
application, currentRouteId, direction, effectiveLayout,
);
// Collect ENDPOINT node IDs — these are always "traversed" when overlay is active
// because the endpoint is the route entry point (not in the processor execution tree).
const endpointNodeIds = useMemo(() => {
const ids = new Set<string>();
if (!overlayActive || !sections.length) return ids;
for (const section of sections) {
for (const node of section.nodes) {
if (node.type === 'ENDPOINT' && node.id) ids.add(node.id);
}
}
return ids;
}, [overlayActive, sections]);
const zoom = useZoomPan();
const toolbar = useToolbarHover();
@@ -80,6 +107,50 @@ export function ProcessDiagram({
}
}, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps
// Center on a specific node when centerOnNodeId changes
useEffect(() => {
if (!centerOnNodeId || sections.length === 0) return;
const node = findNodeById(sections, centerOnNodeId);
if (!node) return;
const container = zoom.containerRef.current;
if (!container) return;
// Compute the node center in diagram coordinates
const nodeX = (node.x ?? 0) + (node.width ?? 160) / 2;
const nodeY = (node.y ?? 0) + (node.height ?? 40) / 2;
// Find which section the node is in to add its offsetY
let sectionOffsetY = 0;
for (const section of sections) {
const found = findNodeInSection(section.nodes, centerOnNodeId);
if (found) { sectionOffsetY = section.offsetY; break; }
}
const adjustedY = nodeY + sectionOffsetY;
// Pan so the node center is at the viewport center
const cw = container.clientWidth;
const ch = container.clientHeight;
const scale = zoom.state.scale;
zoom.panTo(
cw / 2 - nodeX * scale,
ch / 2 - adjustedY * scale,
);
}, [centerOnNodeId]); // eslint-disable-line react-hooks/exhaustive-deps
// Resolve execution state for a node. ENDPOINT nodes (the route's "from:")
// don't appear in the processor execution tree, but should be marked as
// COMPLETED when the route executed (i.e., overlay has any entries).
const getNodeExecutionState = useCallback(
(nodeId: string | undefined, nodeType: string | undefined) => {
if (!nodeId || !effectiveOverlay) return undefined;
const state = effectiveOverlay.get(nodeId);
if (state) return state;
// Synthesize COMPLETED for ENDPOINT nodes when overlay is active
if (nodeType === 'ENDPOINT' && effectiveOverlay.size > 0) {
return { status: 'COMPLETED' as const, durationMs: 0, hasTraceData: false };
}
return undefined;
},
[effectiveOverlay],
);
const handleNodeClick = useCallback(
(nodeId: string) => { onNodeSelect?.(nodeId); },
[onNodeSelect],
@@ -188,8 +259,8 @@ export function ProcessDiagram({
)}
<svg
ref={zoom.svgRef}
className={styles.svg}
onWheel={zoom.onWheel}
onPointerDown={zoom.onPointerDown}
onPointerMove={zoom.onPointerMove}
onPointerUp={zoom.onPointerUp}
@@ -208,14 +279,31 @@ export function ProcessDiagram({
>
<polygon points="0 0, 8 3, 0 6" fill="#9CA3AF" />
</marker>
<marker
id="arrowhead-green"
markerWidth="8"
markerHeight="6"
refX="7"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill="#3D7C47" />
</marker>
</defs>
<g style={{ transform: zoom.transform, transformOrigin: '0 0' }}>
{/* Main section top-level edges (not inside compounds) */}
<g className="edges">
{mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
))}
{mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => {
const sourceHasState = effectiveOverlay?.has(edge.sourceId) || endpointNodeIds.has(edge.sourceId);
const targetHasState = effectiveOverlay?.has(edge.targetId) || endpointNodeIds.has(edge.targetId);
const isTraversed = effectiveOverlay
? (!!sourceHasState && !!targetHasState)
: undefined;
return (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} traversed={isTraversed} />
);
})}
</g>
{/* Main section nodes */}
@@ -230,6 +318,10 @@ export function ProcessDiagram({
selectedNodeId={selectedNodeId}
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={effectiveOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeEnter={toolbar.onNodeEnter}
@@ -244,6 +336,8 @@ export function ProcessDiagram({
isHovered={toolbar.hoveredNodeId === node.id}
isSelected={selectedNodeId === node.id}
config={node.id ? nodeConfigs?.get(node.id) : undefined}
executionState={getNodeExecutionState(node.id, node.type)}
overlayActive={overlayActive}
onClick={() => node.id && handleNodeClick(node.id)}
onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)}
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
@@ -264,6 +358,10 @@ export function ProcessDiagram({
selectedNodeId={selectedNodeId}
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
executionOverlay={executionOverlay}
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeEnter={toolbar.onNodeEnter}
@@ -345,6 +443,13 @@ function findInChildren(
return undefined;
}
function findNodeInSection(
nodes: DiagramNodeType[],
nodeId: string,
): boolean {
return !!findInChildren(nodes, nodeId) || nodes.some(n => n.id === nodeId);
}
function topLevelEdge(
edge: import('../../api/queries/diagrams').DiagramEdge,
nodes: DiagramNodeType[],

View File

@@ -1,4 +1,5 @@
import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams';
import type { DiagramNode, DiagramEdge, DiagramLayout } from '../../api/queries/diagrams';
import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types';
export type NodeAction = 'inspect' | 'toggle-trace' | 'configure-tap' | 'copy-id';
@@ -26,4 +27,14 @@ export interface ProcessDiagramProps {
/** Known route IDs for this application (enables drill-down resolution) */
knownRouteIds?: Set<string>;
className?: string;
/** Pre-fetched diagram layout (bypasses internal fetch by application/routeId) */
diagramLayout?: DiagramLayout;
/** Execution overlay: maps diagram node ID → execution state */
executionOverlay?: Map<string, NodeExecutionState>;
/** Per-compound iteration info: maps compound node ID → iteration info */
iterationState?: Map<string, IterationInfo>;
/** Called when user changes iteration on a compound stepper */
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
/** When set, the diagram pans to center this node in the viewport */
centerOnNodeId?: string;
}

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import type { DiagramNode, DiagramEdge } from '../../api/queries/diagrams';
import type { DiagramNode, DiagramEdge, DiagramLayout } from '../../api/queries/diagrams';
import type { DiagramSection } from './types';
import { isErrorCompoundType, isCompletionCompoundType } from './node-colors';
@@ -10,8 +10,14 @@ export function useDiagramData(
application: string,
routeId: string,
direction: 'LR' | 'TB' = 'LR',
preloadedLayout?: DiagramLayout,
) {
const { data: layout, isLoading, error } = useDiagramByRoute(application, routeId, direction);
// When a preloaded layout is provided, disable the internal fetch
const fetchApp = preloadedLayout ? undefined : application;
const fetchRoute = preloadedLayout ? undefined : routeId;
const { data: fetchedLayout, isLoading, error } = useDiagramByRoute(fetchApp, fetchRoute, direction);
const layout = preloadedLayout ?? fetchedLayout;
const result = useMemo(() => {
if (!layout?.nodes) {
@@ -106,7 +112,11 @@ export function useDiagramData(
return { sections, totalWidth, totalHeight };
}, [layout]);
return { ...result, isLoading, error };
return {
...result,
isLoading: preloadedLayout ? false : isLoading,
error: preloadedLayout ? null : error,
};
}
/** Shift all node coordinates by subtracting an offset, recursively. */

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface ZoomPanState {
scale: number;
@@ -20,19 +20,24 @@ export function useZoomPan() {
const isPanning = useRef(false);
const panStart = useRef({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const clampScale = (s: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
/** Returns the CSS transform string for the content <g> element. */
const transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
const onWheel = useCallback(
(e: React.WheelEvent<SVGSVGElement>) => {
// Attach wheel listener with { passive: false } so preventDefault() stops page scroll.
// React's onWheel is passive by default and cannot prevent scrolling.
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const handler = (e: WheelEvent) => {
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
const factor = 1 + direction * ZOOM_STEP;
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
const rect = svg.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const cursorY = e.clientY - rect.top;
@@ -45,9 +50,10 @@ export function useZoomPan() {
translateY: cursorY - scaleRatio * (cursorY - prev.translateY),
};
});
},
[],
);
};
svg.addEventListener('wheel', handler, { passive: false });
return () => svg.removeEventListener('wheel', handler);
}, []);
const onPointerDown = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
@@ -158,10 +164,10 @@ export function useZoomPan() {
return {
state,
containerRef,
svgRef,
transform,
panTo,
resetView,
onWheel,
onPointerDown,
onPointerMove,
onPointerUp,

View File

@@ -265,6 +265,17 @@
padding: 12px 16px;
}
/* ==========================================================================
EXECUTION DIAGRAM CONTAINER (Flow view)
========================================================================== */
.executionDiagramContainer {
height: 600px;
border: 1px solid var(--border, #E4DFD8);
border-radius: var(--radius-md, 8px);
overflow: hidden;
margin-bottom: 16px;
}
/* ==========================================================================
DETAIL SPLIT (IN / OUT panels)
========================================================================== */

View File

@@ -2,19 +2,21 @@ import { useState, useMemo, useCallback, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router'
import {
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
ProcessorTimeline, Spinner, RouteFlow, useToast,
ProcessorTimeline, Spinner, useToast,
LogViewer, ButtonGroup, SectionHeader, useBreadcrumb,
Modal, Tabs, Button, Select, Input, Textarea,
useGlobalFilters,
} from '@cameleer/design-system'
import type { ProcessorStep, RouteNode, NodeBadge, LogEntry, ButtonGroupItem } from '@cameleer/design-system'
import type { ProcessorStep, NodeBadge, LogEntry, ButtonGroupItem } from '@cameleer/design-system'
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
import { useCorrelationChain } from '../../api/queries/correlation'
import { useDiagramLayout } from '../../api/queries/diagrams'
import { buildFlowSegments, toFlowSegments } from '../../utils/diagram-mapping'
import { useTracingStore } from '../../stores/tracing-store'
import { useApplicationConfig, useUpdateApplicationConfig, useReplayExchange } from '../../api/queries/commands'
import { useAgents } from '../../api/queries/agents'
import { useApplicationLogs } from '../../api/queries/logs'
import { useRouteCatalog } from '../../api/queries/catalog'
import { ExecutionDiagram } from '../../components/ExecutionDiagram'
import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types'
import styles from './ExchangeDetail.module.css'
const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
@@ -88,7 +90,6 @@ export default function ExchangeDetail() {
const { data: detail, isLoading } = useExecutionDetail(id ?? null)
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null)
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null)
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
const [logSearch, setLogSearch] = useState('')
@@ -170,33 +171,6 @@ export default function ExchangeDetail() {
const inputBody = snapshot?.inputBody ?? null
const outputBody = snapshot?.outputBody ?? null
// Build RouteFlow nodes from diagram + execution data, split into flow segments
const { routeFlows, flowNodeIds } = useMemo(() => {
if (diagram?.nodes) {
const { flows, nodeIds } = buildFlowSegments(diagram.nodes, procList)
// Apply badges to each node across all flows
let idx = 0
const badgedFlows = flows.map(flow => ({
...flow,
nodes: flow.nodes.map(node => ({
...node,
badges: badgesFor(nodeIds[idx++]),
})),
}))
return { routeFlows: badgedFlows, flowNodeIds: nodeIds }
}
// Fallback: build from processor list (no diagram available)
const nodes = processors.map((p) => ({
name: p.name,
type: 'process' as RouteNode['type'],
durationMs: p.durationMs,
status: p.status,
badges: badgesFor(p.name),
}))
const { flows } = toFlowSegments(nodes)
return { routeFlows: flows, flowNodeIds: [] as string[] }
}, [diagram, processors, procList, tracedMap])
// ProcessorId lookup: timeline index → processorId
const processorIds: string[] = useMemo(() => {
const ids: string[] = []
@@ -208,12 +182,6 @@ export default function ExchangeDetail() {
return ids
}, [procList])
// Map flow display index → processor tree index (for snapshot API)
// flowNodeIds already contains processor IDs in flow-order
const flowToTreeIndex = useMemo(() =>
flowNodeIds.map(pid => pid ? processorIds.indexOf(pid) : -1),
[flowNodeIds, processorIds],
)
// ── Tracing toggle ──────────────────────────────────────────────────────
const { toast } = useToast()
@@ -248,6 +216,36 @@ export default function ExchangeDetail() {
})
}, [detail, app, appConfig, tracingStore, updateConfig, toast])
// ── ExecutionDiagram support ──────────────────────────────────────────
const { timeRange } = useGlobalFilters()
const { data: catalog } = useRouteCatalog(
timeRange.start.toISOString(),
timeRange.end.toISOString(),
)
const knownRouteIds = useMemo(() => {
if (!catalog || !app) return new Set<string>()
const appEntry = (catalog as Array<{ appId: string; routes?: Array<{ routeId: string }> }>)
.find(a => a.appId === app)
return new Set((appEntry?.routes ?? []).map(r => r.routeId))
}, [catalog, app])
const nodeConfigs = useMemo(() => {
const map = new Map<string, NodeConfig>()
if (tracedMap) {
for (const pid of Object.keys(tracedMap)) {
map.set(pid, { traceEnabled: true })
}
}
return map
}, [tracedMap])
const handleNodeAction = useCallback((nodeId: string, action: NodeAction) => {
if (action === 'toggle-trace') {
handleToggleTracing(nodeId)
}
}, [handleToggleTracing])
// ── Replay ─────────────────────────────────────────────────────────────
const { data: liveAgents } = useAgents('LIVE', detail?.applicationName)
const replay = useReplayExchange()
@@ -461,9 +459,9 @@ export default function ExchangeDetail() {
</button>
</div>
</div>
<div className={styles.timelineBody}>
{timelineView === 'gantt' ? (
processors.length > 0 ? (
{timelineView === 'gantt' && (
<div className={styles.timelineBody}>
{processors.length > 0 ? (
<ProcessorTimeline
processors={processors}
totalMs={detail.durationMs}
@@ -481,33 +479,23 @@ export default function ExchangeDetail() {
/>
) : (
<InfoCallout>No processor data available</InfoCallout>
)
) : (
routeFlows.length > 0 ? (
<RouteFlow
flows={routeFlows}
onNodeClick={(_node, index) => {
const treeIdx = flowToTreeIndex[index]
if (treeIdx >= 0) setSelectedProcessorIndex(treeIdx)
}}
selectedIndex={flowToTreeIndex.indexOf(activeIndex)}
getActions={(_node, index) => {
const pid = flowNodeIds[index] ?? ''
if (!pid || !detail?.applicationName) return []
return [{
label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing',
onClick: () => handleToggleTracing(pid),
disabled: updateConfig.isPending,
}]
}}
/>
) : (
<Spinner />
)
)}
</div>
)}
</div>
)}
</div>
{timelineView === 'flow' && detail && (
<div className={styles.executionDiagramContainer}>
<ExecutionDiagram
executionId={id!}
executionDetail={detail}
knownRouteIds={knownRouteIds}
onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs}
/>
</div>
)}
{/* Exchange-level body (start/end of route) */}
{detail && (detail.inputBody || detail.outputBody) && (
<div className={styles.detailSplit}>