From 3d71345181b735b14dadb497c543a08fa2cd471f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:08:58 +0200 Subject: [PATCH] feat: trace data indicators, inline tap config, and detail tab gating Trace data visibility: - ProcessorNode now includes hasTraceData flag computed from captured body/headers during tree conversion - ConfigBadge shows teal for tracing configured, green when data captured - Search results show green footprints icon for exchanges with trace data - New has_trace_data column on executions table (V11 migration with backfill) - OpenSearch documents and ExecutionSummary include the flag Inline tap configuration: - Extracted reusable TapConfigModal component from RouteDetail - Diagram context menu opens tap modal inline instead of navigating away - Toggle-trace action works immediately with toast feedback - Modal closes only on ESC, Cancel, Save, or Delete (not backdrop click) Detail panel tab gating: - Headers, Input, Output tabs disabled when no data is available - Works at both exchange and processor level - Falls back to Info tab when active tab becomes empty Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/app/search/OpenSearchIndex.java | 3 +- .../app/storage/PostgresExecutionStore.java | 10 +- .../db/migration/V11__has_trace_data.sql | 10 + .../server/app/search/OpenSearchIndexIT.java | 4 +- .../app/storage/PostgresExecutionStoreIT.java | 8 +- .../app/storage/PostgresStatsStoreIT.java | 2 +- .../server/core/detail/DetailService.java | 10 +- .../server/core/detail/ProcessorNode.java | 6 +- .../server/core/indexing/SearchIndexer.java | 2 +- .../core/ingestion/IngestionService.java | 14 +- .../server/core/search/ExecutionSummary.java | 3 +- .../server/core/storage/ExecutionStore.java | 3 +- .../core/storage/model/ExecutionDocument.java | 3 +- ui/src/api/schema.d.ts | 2 + .../ExecutionDiagram/DetailPanel.tsx | 25 +- .../ExecutionDiagram/useExecutionOverlay.ts | 2 +- .../components/ProcessDiagram/ConfigBadge.tsx | 11 +- .../components/ProcessDiagram/DiagramNode.tsx | 4 +- ui/src/components/TapConfigModal.module.css | 111 ++++++++ ui/src/components/TapConfigModal.tsx | 261 ++++++++++++++++++ ui/src/pages/Dashboard/Dashboard.tsx | 3 +- ui/src/pages/Exchanges/ExchangesPage.tsx | 112 +++++++- 22 files changed, 568 insertions(+), 41 deletions(-) create mode 100644 cameleer3-server-app/src/main/resources/db/migration/V11__has_trace_data.sql create mode 100644 ui/src/components/TapConfigModal.module.css create mode 100644 ui/src/components/TapConfigModal.tsx diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchIndex.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchIndex.java index 2631a58f..6d5878d2 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchIndex.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchIndex.java @@ -397,7 +397,8 @@ public class OpenSearchIndex implements SearchIndex { (String) src.get("error_message"), null, // diagramContentHash not stored in index extractHighlight(hit), - attributes + attributes, + Boolean.TRUE.equals(src.get("has_trace_data")) ); } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java index 73c4b845..7758c21d 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresExecutionStore.java @@ -31,10 +31,10 @@ public class PostgresExecutionStore implements ExecutionStore { attributes, error_type, error_category, root_cause_type, root_cause_message, trace_id, span_id, - processors_json, + processors_json, has_trace_data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, - ?, ?, ?, ?, ?, ?, ?::jsonb, now(), now()) + ?, ?, ?, ?, ?, ?, ?::jsonb, ?, now(), now()) ON CONFLICT (execution_id, start_time) DO UPDATE SET status = CASE WHEN EXCLUDED.status IN ('COMPLETED', 'FAILED') @@ -61,6 +61,7 @@ public class PostgresExecutionStore implements ExecutionStore { trace_id = COALESCE(EXCLUDED.trace_id, executions.trace_id), span_id = COALESCE(EXCLUDED.span_id, executions.span_id), processors_json = COALESCE(EXCLUDED.processors_json, executions.processors_json), + has_trace_data = EXCLUDED.has_trace_data OR executions.has_trace_data, updated_at = now() """, execution.executionId(), execution.routeId(), execution.agentId(), @@ -77,7 +78,7 @@ public class PostgresExecutionStore implements ExecutionStore { execution.errorType(), execution.errorCategory(), execution.rootCauseType(), execution.rootCauseMessage(), execution.traceId(), execution.spanId(), - execution.processorsJson()); + execution.processorsJson(), execution.hasTraceData()); } @Override @@ -178,7 +179,8 @@ public class PostgresExecutionStore implements ExecutionStore { rs.getString("error_type"), rs.getString("error_category"), rs.getString("root_cause_type"), rs.getString("root_cause_message"), rs.getString("trace_id"), rs.getString("span_id"), - rs.getString("processors_json")); + rs.getString("processors_json"), + rs.getBoolean("has_trace_data")); private static final RowMapper PROCESSOR_MAPPER = (rs, rowNum) -> new ProcessorRecord( diff --git a/cameleer3-server-app/src/main/resources/db/migration/V11__has_trace_data.sql b/cameleer3-server-app/src/main/resources/db/migration/V11__has_trace_data.sql new file mode 100644 index 00000000..7d925e5a --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V11__has_trace_data.sql @@ -0,0 +1,10 @@ +-- Flag indicating whether any processor in this execution captured trace data +ALTER TABLE executions ADD COLUMN IF NOT EXISTS has_trace_data BOOLEAN NOT NULL DEFAULT FALSE; + +-- Backfill: set flag for existing executions that have processor trace data +UPDATE executions e SET has_trace_data = TRUE +WHERE EXISTS ( + SELECT 1 FROM processor_executions pe + WHERE pe.execution_id = e.execution_id + AND (pe.input_body IS NOT NULL OR pe.output_body IS NOT NULL) +); diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/OpenSearchIndexIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/OpenSearchIndexIT.java index b7ad3140..d95983b5 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/OpenSearchIndexIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/OpenSearchIndexIT.java @@ -36,7 +36,7 @@ class OpenSearchIndexIT extends AbstractPostgresIT { "OrderNotFoundException: order-12345 not found", null, List.of(new ProcessorDoc("proc-1", "log", "COMPLETED", null, null, "request body with customer-99", null, null, null, null)), - null); + null, false); searchIndex.index(doc); refreshOpenSearchIndices(); @@ -62,7 +62,7 @@ class OpenSearchIndexIT extends AbstractPostgresIT { now, now.plusMillis(50), 50L, null, null, List.of(new ProcessorDoc("proc-1", "bean", "COMPLETED", null, null, "UniquePayloadIdentifier12345", null, null, null, null)), - null); + null, false); searchIndex.index(doc); refreshOpenSearchIndices(); diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/PostgresExecutionStoreIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/PostgresExecutionStoreIT.java index 957b3877..3cda56ae 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/PostgresExecutionStoreIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/PostgresExecutionStoreIT.java @@ -27,7 +27,7 @@ class PostgresExecutionStoreIT extends AbstractPostgresIT { now, now.plusMillis(100), 100L, null, null, null, "REGULAR", null, null, null, null, null, - null, null, null, null, null, null, null); + null, null, null, null, null, null, null, false); executionStore.upsert(record); Optional found = executionStore.findById("exec-1"); @@ -45,12 +45,12 @@ class PostgresExecutionStoreIT extends AbstractPostgresIT { "exec-dup", "route-a", "agent-1", "app-1", "RUNNING", null, null, now, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null); + null, null, null, null, null, null, null, false); ExecutionRecord second = new ExecutionRecord( "exec-dup", "route-a", "agent-1", "app-1", "COMPLETED", null, null, now, now.plusMillis(200), 200L, null, null, null, "COMPLETE", null, null, null, null, null, - null, null, null, null, null, null, null); + null, null, null, null, null, null, null, false); executionStore.upsert(first); executionStore.upsert(second); @@ -68,7 +68,7 @@ class PostgresExecutionStoreIT extends AbstractPostgresIT { "exec-proc", "route-a", "agent-1", "app-1", "COMPLETED", null, null, now, now.plusMillis(50), 50L, null, null, null, "COMPLETE", null, null, null, null, null, - null, null, null, null, null, null, null); + null, null, null, null, null, null, null, false); executionStore.upsert(exec); List processors = List.of( diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/PostgresStatsStoreIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/PostgresStatsStoreIT.java index 6a448d9a..d01d7ac7 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/PostgresStatsStoreIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/PostgresStatsStoreIT.java @@ -61,6 +61,6 @@ class PostgresStatsStoreIT extends AbstractPostgresIT { startTime, startTime.plusMillis(durationMs), durationMs, status.equals("FAILED") ? "error" : null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null)); + null, null, null, null, null, null, null, false)); } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java index 96a5eb12..b3defe23 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java @@ -80,6 +80,8 @@ public class DetailService { if (executions == null) return List.of(); List result = new ArrayList<>(); for (ProcessorExecution p : executions) { + boolean hasTrace = p.getInputBody() != null || p.getOutputBody() != null + || p.getInputHeaders() != null || p.getOutputHeaders() != null; ProcessorNode node = new ProcessorNode( p.getProcessorId(), p.getProcessorType(), p.getStatus() != null ? p.getStatus().name() : null, @@ -94,7 +96,8 @@ public class DetailService { p.getErrorType(), p.getErrorCategory(), p.getRootCauseType(), p.getRootCauseMessage(), p.getErrorHandlerType(), p.getCircuitBreakerState(), - p.getFallbackTriggered() + p.getFallbackTriggered(), + hasTrace ); for (ProcessorNode child : convertProcessors(p.getChildren())) { node.addChild(child); @@ -113,6 +116,8 @@ public class DetailService { Map nodeMap = new LinkedHashMap<>(); for (ProcessorRecord p : processors) { + boolean hasTrace = p.inputBody() != null || p.outputBody() != null + || p.inputHeaders() != null || p.outputHeaders() != null; nodeMap.put(p.processorId(), new ProcessorNode( p.processorId(), p.processorType(), p.status(), p.startTime(), p.endTime(), @@ -126,7 +131,8 @@ public class DetailService { p.errorType(), p.errorCategory(), p.rootCauseType(), p.rootCauseMessage(), p.errorHandlerType(), p.circuitBreakerState(), - p.fallbackTriggered() + p.fallbackTriggered(), + hasTrace )); } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ProcessorNode.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ProcessorNode.java index 768a52a0..850c7cf7 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ProcessorNode.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ProcessorNode.java @@ -35,6 +35,7 @@ public final class ProcessorNode { private final String errorHandlerType; private final String circuitBreakerState; private final Boolean fallbackTriggered; + private final boolean hasTraceData; private final List children; public ProcessorNode(String processorId, String processorType, String status, @@ -48,7 +49,8 @@ public final class ProcessorNode { String errorType, String errorCategory, String rootCauseType, String rootCauseMessage, String errorHandlerType, String circuitBreakerState, - Boolean fallbackTriggered) { + Boolean fallbackTriggered, + boolean hasTraceData) { this.processorId = processorId; this.processorType = processorType; this.status = status; @@ -71,6 +73,7 @@ public final class ProcessorNode { this.errorHandlerType = errorHandlerType; this.circuitBreakerState = circuitBreakerState; this.fallbackTriggered = fallbackTriggered; + this.hasTraceData = hasTraceData; this.children = new ArrayList<>(); } @@ -100,5 +103,6 @@ public final class ProcessorNode { public String getErrorHandlerType() { return errorHandlerType; } public String getCircuitBreakerState() { return circuitBreakerState; } public Boolean getFallbackTriggered() { return fallbackTriggered; } + public boolean isHasTraceData() { return hasTraceData; } public List getChildren() { return List.copyOf(children); } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexer.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexer.java index 1531f647..ad6a606f 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexer.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/indexing/SearchIndexer.java @@ -79,7 +79,7 @@ public class SearchIndexer implements SearchIndexerStats { exec.status(), exec.correlationId(), exec.exchangeId(), exec.startTime(), exec.endTime(), exec.durationMs(), exec.errorMessage(), exec.errorStacktrace(), processorDocs, - exec.attributes())); + exec.attributes(), exec.hasTraceData())); indexedCount.incrementAndGet(); lastIndexedAt = Instant.now(); diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/ingestion/IngestionService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/ingestion/IngestionService.java index baa7ea67..92766a0e 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/ingestion/IngestionService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/ingestion/IngestionService.java @@ -100,6 +100,8 @@ public class IngestionService { outputHeaders = toJson(outputSnapshot.getHeaders()); } + boolean hasTraceData = hasAnyTraceData(exec.getProcessors()); + return new ExecutionRecord( exec.getExchangeId(), exec.getRouteId(), agentId, applicationName, exec.getStatus() != null ? exec.getStatus().name() : "RUNNING", @@ -114,10 +116,20 @@ public class IngestionService { exec.getErrorType(), exec.getErrorCategory(), exec.getRootCauseType(), exec.getRootCauseMessage(), exec.getTraceId(), exec.getSpanId(), - toJsonObject(exec.getProcessors()) + toJsonObject(exec.getProcessors()), + hasTraceData ); } + private static boolean hasAnyTraceData(List processors) { + if (processors == null) return false; + for (ProcessorExecution p : processors) { + if (p.getInputBody() != null || p.getOutputBody() != null) return true; + if (hasAnyTraceData(p.getChildren())) return true; + } + return false; + } + private List flattenProcessors( List processors, String executionId, java.time.Instant execStartTime, String applicationName, String routeId, diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/ExecutionSummary.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/ExecutionSummary.java index 2808777d..a9704ee9 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/ExecutionSummary.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/ExecutionSummary.java @@ -33,6 +33,7 @@ public record ExecutionSummary( String errorMessage, String diagramContentHash, String highlight, - Map attributes + Map attributes, + boolean hasTraceData ) { } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java index 0850559d..cd38d376 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/ExecutionStore.java @@ -29,7 +29,8 @@ public interface ExecutionStore { String errorType, String errorCategory, String rootCauseType, String rootCauseMessage, String traceId, String spanId, - String processorsJson + String processorsJson, + boolean hasTraceData ) {} record ProcessorRecord( diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/model/ExecutionDocument.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/model/ExecutionDocument.java index 5b4d0eba..a2ce52c7 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/model/ExecutionDocument.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/model/ExecutionDocument.java @@ -9,7 +9,8 @@ public record ExecutionDocument( Instant startTime, Instant endTime, Long durationMs, String errorMessage, String errorStacktrace, List processors, - String attributes + String attributes, + boolean hasTraceData ) { public record ProcessorDoc( String processorId, String processorType, String status, diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 02c1ea87..2a68d869 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -1330,6 +1330,7 @@ export interface components { attributes: { [key: string]: string; }; + hasTraceData?: boolean; }; SearchResultExecutionSummary: { data: components["schemas"]["ExecutionSummary"][]; @@ -1666,6 +1667,7 @@ export interface components { errorHandlerType?: string; circuitBreakerState?: string; fallbackTriggered?: boolean; + hasTraceData?: boolean; children: components["schemas"]["ProcessorNode"][]; }; DiagramLayout: { diff --git a/ui/src/components/ExecutionDiagram/DetailPanel.tsx b/ui/src/components/ExecutionDiagram/DetailPanel.tsx index d2b28c7e..1b06820e 100644 --- a/ui/src/components/ExecutionDiagram/DetailPanel.tsx +++ b/ui/src/components/ExecutionDiagram/DetailPanel.tsx @@ -63,7 +63,7 @@ export function DetailPanel({ ? !!selectedProcessor.errorMessage : !!executionDetail.errorMessage; - // Fetch snapshot for body tabs when a processor is selected + // Fetch snapshot for body/headers tabs when a processor is selected const snapshotQuery = useProcessorSnapshotById( selectedProcessor ? executionId : null, selectedProcessor?.processorId ?? null, @@ -72,15 +72,33 @@ export function DetailPanel({ // Determine body content for Input/Output tabs let inputBody: string | undefined; let outputBody: string | undefined; + let hasHeaders = false; if (selectedProcessor && snapshotQuery.data) { inputBody = snapshotQuery.data.inputBody; outputBody = snapshotQuery.data.outputBody; + hasHeaders = !!(snapshotQuery.data.inputHeaders || snapshotQuery.data.outputHeaders); + } else if (selectedProcessor && snapshotQuery.isLoading) { + // Still loading — keep tabs enabled + hasHeaders = true; + inputBody = undefined; + outputBody = undefined; } else if (!selectedProcessor) { inputBody = executionDetail.inputBody; outputBody = executionDetail.outputBody; + hasHeaders = !!(executionDetail.inputHeaders || executionDetail.outputHeaders); } + const hasInput = !!inputBody; + const hasOutput = !!outputBody; + + // If active tab becomes disabled, fall back to info + useEffect(() => { + if (activeTab === 'headers' && !hasHeaders) setActiveTab('info'); + if (activeTab === 'input' && !hasInput) setActiveTab('info'); + if (activeTab === 'output' && !hasOutput) setActiveTab('info'); + }, [hasHeaders, hasInput, hasOutput, activeTab]); + // Header display const headerName = selectedProcessor ? selectedProcessor.processorType : 'Exchange'; const headerStatus = selectedProcessor ? selectedProcessor.status : executionDetail.status; @@ -103,7 +121,10 @@ export function DetailPanel({
{TABS.map((tab) => { const isActive = activeTab === tab.key; - const isDisabled = tab.key === 'config'; + const isDisabled = tab.key === 'config' + || (tab.key === 'headers' && !hasHeaders) + || (tab.key === 'input' && !hasInput) + || (tab.key === 'output' && !hasOutput); const isError = tab.key === 'error' && hasError; const isErrorGrayed = tab.key === 'error' && !hasError; diff --git a/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts b/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts index 3de89e73..65dbfa2e 100644 --- a/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts +++ b/ui/src/components/ExecutionDiagram/useExecutionOverlay.ts @@ -59,7 +59,7 @@ function buildOverlay( status: proc.status as 'COMPLETED' | 'FAILED', durationMs: proc.durationMs ?? 0, subRouteFailed: subRouteFailed || undefined, - hasTraceData: true, + hasTraceData: !!proc.hasTraceData, }); // Recurse into children diff --git a/ui/src/components/ProcessDiagram/ConfigBadge.tsx b/ui/src/components/ProcessDiagram/ConfigBadge.tsx index 7e7d237c..67d96e20 100644 --- a/ui/src/components/ProcessDiagram/ConfigBadge.tsx +++ b/ui/src/components/ProcessDiagram/ConfigBadge.tsx @@ -2,16 +2,23 @@ import type { NodeConfig } from './types'; const BADGE_SIZE = 18; const BADGE_GAP = 4; +const TRACE_ENABLED_COLOR = '#1A7F8E'; // teal — tracing configured +const TRACE_DATA_COLOR = '#3D7C47'; // green — data captured interface ConfigBadgeProps { nodeWidth: number; config: NodeConfig; + /** True if actual trace data was captured for this processor */ + hasTraceData?: boolean; } -export function ConfigBadge({ nodeWidth, config }: ConfigBadgeProps) { +export function ConfigBadge({ nodeWidth, config, hasTraceData }: ConfigBadgeProps) { const badges: { icon: 'tap' | 'trace'; color: string }[] = []; if (config.tapExpression) badges.push({ icon: 'tap', color: '#7C3AED' }); - if (config.traceEnabled) badges.push({ icon: 'trace', color: '#1A7F8E' }); + // Show trace badge if tracing is enabled OR data was captured + if (config.traceEnabled || hasTraceData) { + badges.push({ icon: 'trace', color: hasTraceData ? TRACE_DATA_COLOR : TRACE_ENABLED_COLOR }); + } if (badges.length === 0) return null; let xOffset = nodeWidth; diff --git a/ui/src/components/ProcessDiagram/DiagramNode.tsx b/ui/src/components/ProcessDiagram/DiagramNode.tsx index 6e50f58f..907c0c39 100644 --- a/ui/src/components/ProcessDiagram/DiagramNode.tsx +++ b/ui/src/components/ProcessDiagram/DiagramNode.tsx @@ -138,7 +138,9 @@ export function DiagramNode({ {/* Config badges */} - {config && } + {(config || executionState?.hasTraceData) && ( + + )} {/* Execution overlay: status badge inside card, top-right corner */} {isCompleted && ( diff --git a/ui/src/components/TapConfigModal.module.css b/ui/src/components/TapConfigModal.module.css new file mode 100644 index 00000000..04433fdb --- /dev/null +++ b/ui/src/components/TapConfigModal.module.css @@ -0,0 +1,111 @@ +.body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.formRow { + display: flex; + gap: 12px; +} + +.formRow > * { + flex: 1; +} + +.monoTextarea { + font-family: var(--font-mono); + font-size: 12px; +} + +.typeSelector { + display: flex; + gap: 8px; +} + +.typeOption { + padding: 4px 12px; + border-radius: var(--radius-sm); + font-size: 11px; + cursor: pointer; + border: 1px solid var(--border-subtle); + background: transparent; + color: var(--text-muted); + transition: all 0.12s; +} + +.typeOption:hover { + border-color: var(--border); + color: var(--text-secondary); +} + +.typeOptionActive { + background: var(--amber-bg); + color: var(--amber-deep); + border-color: var(--amber); + font-weight: 600; +} + +.footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.footerLeft { + display: flex; + flex: 1; +} + +.testSection { + display: flex; + flex-direction: column; + gap: 8px; +} + +.testTabs { + display: flex; + gap: 4px; +} + +.testTabBtn { + padding: 4px 12px; + border-radius: 6px; + font-size: 11px; + cursor: pointer; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); +} + +.testTabBtnActive { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--text-muted); +} + +.testBody { + margin-top: 4px; +} + +.testResult { + padding: 10px 14px; + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; +} + +.testSuccess { + background: var(--success-bg); + border: 1px solid var(--success-border); + color: var(--success); +} + +.testError { + background: var(--error-bg); + border: 1px solid var(--error-border); + color: var(--error); +} diff --git a/ui/src/components/TapConfigModal.tsx b/ui/src/components/TapConfigModal.tsx new file mode 100644 index 00000000..5a2c500e --- /dev/null +++ b/ui/src/components/TapConfigModal.tsx @@ -0,0 +1,261 @@ +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { + Modal, FormField, Input, Select, Textarea, Toggle, Button, Collapsible, +} from '@cameleer/design-system'; +import type { TapDefinition, ApplicationConfig } from '../api/queries/commands'; +import { useTestExpression } from '../api/queries/commands'; +import styles from './TapConfigModal.module.css'; + +const LANGUAGE_OPTIONS = [ + { value: 'simple', label: 'Simple' }, + { value: 'jsonpath', label: 'JSONPath' }, + { value: 'xpath', label: 'XPath' }, + { value: 'jq', label: 'jq' }, + { value: 'groovy', label: 'Groovy' }, +]; + +const TARGET_OPTIONS = [ + { value: 'INPUT', label: 'Input' }, + { value: 'OUTPUT', label: 'Output' }, + { value: 'BOTH', label: 'Both' }, +]; + +const TYPE_CHOICES: Array<{ value: TapDefinition['attributeType']; label: string; tooltip: string }> = [ + { value: 'BUSINESS_OBJECT', label: 'Business Object', tooltip: 'A key business identifier like orderId, customerId, or invoiceNumber' }, + { value: 'CORRELATION', label: 'Correlation', tooltip: 'Used to correlate related exchanges across routes or services' }, + { value: 'EVENT', label: 'Event', tooltip: 'Marks a business event occurrence like orderPlaced or paymentReceived' }, + { value: 'CUSTOM', label: 'Custom', tooltip: 'General-purpose attribute for any other extraction need' }, +]; + +interface ProcessorOption { + value: string; + label: string; +} + +export interface TapConfigModalProps { + open: boolean; + onClose: () => void; + /** Tap to edit, or null for creating a new tap */ + tap: TapDefinition | null; + /** Processor options for the dropdown (id + label) */ + processorOptions: ProcessorOption[]; + /** Pre-selected processor ID (used when opening from diagram context menu) */ + defaultProcessorId?: string; + /** Application name (for test expression API) */ + application: string; + /** Current application config (taps array will be modified) */ + config: ApplicationConfig; + /** Called with the updated config to persist */ + onSave: (config: ApplicationConfig) => void; + /** Called to delete the tap */ + onDelete?: (tap: TapDefinition) => void; +} + +export function TapConfigModal({ + open, onClose, tap, processorOptions, defaultProcessorId, + application, config, onSave, onDelete, +}: TapConfigModalProps) { + const isEdit = !!tap; + + const [name, setName] = useState(''); + const [processor, setProcessor] = useState(''); + const [language, setLanguage] = useState('simple'); + const [target, setTarget] = useState<'INPUT' | 'OUTPUT' | 'BOTH'>('OUTPUT'); + const [expression, setExpression] = useState(''); + const [attrType, setAttrType] = useState('BUSINESS_OBJECT'); + const [enabled, setEnabled] = useState(true); + + const [testTab, setTestTab] = useState('custom'); + const [testPayload, setTestPayload] = useState(''); + const [testResult, setTestResult] = useState<{ result?: string; error?: string } | null>(null); + const testMutation = useTestExpression(); + + // Reset form when modal opens + const [lastOpen, setLastOpen] = useState(false); + if (open && !lastOpen) { + if (tap) { + setName(tap.attributeName); + setProcessor(tap.processorId); + setLanguage(tap.language); + setTarget(tap.target); + setExpression(tap.expression); + setAttrType(tap.attributeType); + setEnabled(tap.enabled); + } else { + setName(''); + setProcessor(defaultProcessorId ?? processorOptions[0]?.value ?? ''); + setLanguage('simple'); + setTarget('OUTPUT'); + setExpression(''); + setAttrType('BUSINESS_OBJECT'); + setEnabled(true); + } + setTestResult(null); + setTestPayload(''); + } + if (open !== lastOpen) setLastOpen(open); + + function handleSave() { + const tapDef: TapDefinition = { + tapId: tap?.tapId || `tap-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + processorId: processor, + target, + expression, + language, + attributeName: name, + attributeType: attrType, + enabled, + version: tap ? tap.version + 1 : 1, + }; + const taps = tap + ? config.taps.map(t => t.tapId === tap.tapId ? tapDef : t) + : [...(config.taps || []), tapDef]; + onSave({ ...config, taps }); + onClose(); + } + + function handleDelete() { + if (tap && onDelete) { + onDelete(tap); + onClose(); + } + } + + function handleTest() { + testMutation.mutate( + { application, expression, language, body: testPayload, target }, + { + onSuccess: (data) => setTestResult(data), + onError: (err) => setTestResult({ error: (err as Error).message }), + }, + ); + } + + // Processor options with fallback if the current processor isn't in the list + const allOptions = useMemo(() => { + if (processor && !processorOptions.find(p => p.value === processor)) { + return [{ value: processor, label: processor }, ...processorOptions]; + } + return processorOptions; + }, [processorOptions, processor]); + + // Close only on ESC key, not on backdrop click. + // Modal calls onClose for both — pass a no-op, handle ESC ourselves. + const handleEsc = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }, [onClose]); + useEffect(() => { + if (!open) return; + document.addEventListener('keydown', handleEsc); + return () => document.removeEventListener('keydown', handleEsc); + }, [open, handleEsc]); + + return ( + {}} title={isEdit ? 'Edit Tap' : 'Add Tap'} size="lg"> +
+ + setName(e.target.value)} placeholder="e.g. orderId" /> + + + + setLanguage(e.target.value)} + /> + + +