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) <noreply@anthropic.com>
This commit is contained in:
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ProcessorRecord> PROCESSOR_MAPPER = (rs, rowNum) ->
|
||||
new ProcessorRecord(
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ExecutionRecord> 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<ProcessorRecord> processors = List.of(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ public class DetailService {
|
||||
if (executions == null) return List.of();
|
||||
List<ProcessorNode> 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<String, ProcessorNode> 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
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ProcessorNode> 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<ProcessorNode> getChildren() { return List.copyOf(children); }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ProcessorExecution> 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<ProcessorRecord> flattenProcessors(
|
||||
List<ProcessorExecution> processors, String executionId,
|
||||
java.time.Instant execStartTime, String applicationName, String routeId,
|
||||
|
||||
@@ -33,6 +33,7 @@ public record ExecutionSummary(
|
||||
String errorMessage,
|
||||
String diagramContentHash,
|
||||
String highlight,
|
||||
Map<String, String> attributes
|
||||
Map<String, String> attributes,
|
||||
boolean hasTraceData
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -9,7 +9,8 @@ public record ExecutionDocument(
|
||||
Instant startTime, Instant endTime, Long durationMs,
|
||||
String errorMessage, String errorStacktrace,
|
||||
List<ProcessorDoc> processors,
|
||||
String attributes
|
||||
String attributes,
|
||||
boolean hasTraceData
|
||||
) {
|
||||
public record ProcessorDoc(
|
||||
String processorId, String processorType, String status,
|
||||
|
||||
2
ui/src/api/schema.d.ts
vendored
2
ui/src/api/schema.d.ts
vendored
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
<div className={styles.tabBar}>
|
||||
{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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -138,7 +138,9 @@ export function DiagramNode({
|
||||
</g>
|
||||
|
||||
{/* Config badges */}
|
||||
{config && <ConfigBadge nodeWidth={w} config={config} />}
|
||||
{(config || executionState?.hasTraceData) && (
|
||||
<ConfigBadge nodeWidth={w} config={config ?? {}} hasTraceData={executionState?.hasTraceData} />
|
||||
)}
|
||||
|
||||
{/* Execution overlay: status badge inside card, top-right corner */}
|
||||
{isCompleted && (
|
||||
|
||||
111
ui/src/components/TapConfigModal.module.css
Normal file
111
ui/src/components/TapConfigModal.module.css
Normal file
@@ -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);
|
||||
}
|
||||
261
ui/src/components/TapConfigModal.tsx
Normal file
261
ui/src/components/TapConfigModal.tsx
Normal file
@@ -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<TapDefinition['attributeType']>('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 (
|
||||
<Modal open={open} onClose={() => {}} title={isEdit ? 'Edit Tap' : 'Add Tap'} size="lg">
|
||||
<div className={styles.body}>
|
||||
<FormField label="Attribute Name">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. orderId" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Processor">
|
||||
<Select
|
||||
options={allOptions}
|
||||
value={processor}
|
||||
onChange={(e) => setProcessor(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<FormField label="Language">
|
||||
<Select
|
||||
options={LANGUAGE_OPTIONS}
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Target">
|
||||
<Select
|
||||
options={TARGET_OPTIONS}
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value as 'INPUT' | 'OUTPUT' | 'BOTH')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Expression">
|
||||
<Textarea
|
||||
className={styles.monoTextarea}
|
||||
value={expression}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
placeholder="e.g. ${body.orderId}"
|
||||
rows={3}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Type">
|
||||
<div className={styles.typeSelector}>
|
||||
{TYPE_CHOICES.map(tc => (
|
||||
<button
|
||||
key={tc.value}
|
||||
type="button"
|
||||
title={tc.tooltip}
|
||||
className={`${styles.typeOption} ${attrType === tc.value ? styles.typeOptionActive : ''}`}
|
||||
onClick={() => setAttrType(tc.value)}
|
||||
>
|
||||
{tc.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<FormField label="Enabled">
|
||||
<Toggle checked={enabled} onChange={() => setEnabled(!enabled)} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<Collapsible title="Test Expression">
|
||||
<div className={styles.testSection}>
|
||||
<div className={styles.testBody}>
|
||||
<Textarea
|
||||
className={styles.monoTextarea}
|
||||
value={testPayload}
|
||||
onChange={(e) => setTestPayload(e.target.value)}
|
||||
placeholder='{"orderId": "12345", ...}'
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.testBody}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
loading={testMutation.isPending}
|
||||
disabled={!expression}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`${styles.testResult} ${testResult.error ? styles.testError : styles.testSuccess}`}>
|
||||
{testResult.error ?? testResult.result ?? 'No result'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{isEdit && onDelete && (
|
||||
<div className={styles.footerLeft}>
|
||||
<Button variant="danger" onClick={handleDelete}>Delete</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={!name || !processor || !expression}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router'
|
||||
import { AlertTriangle, X, Search } from 'lucide-react'
|
||||
import { AlertTriangle, X, Search, Footprints } from 'lucide-react'
|
||||
import {
|
||||
DataTable,
|
||||
StatusDot,
|
||||
@@ -78,6 +78,7 @@ function buildBaseColumns(): Column<Row>[] {
|
||||
<span className={styles.statusCell}>
|
||||
<StatusDot variant={statusToVariant(row.status)} />
|
||||
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
|
||||
{row.hasTraceData && <Footprints size={11} color="#3D7C47" style={{ marginLeft: 2, flexShrink: 0 }} />}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNavigate, useLocation, useParams } from 'react-router';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import { useGlobalFilters, useToast } from '@cameleer/design-system';
|
||||
import { useExecutionDetail } from '../../api/queries/executions';
|
||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import { useApplicationConfig } from '../../api/queries/commands';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||
import type { TapDefinition } from '../../api/queries/commands';
|
||||
import { useTracingStore } from '../../stores/tracing-store';
|
||||
import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types';
|
||||
import { TapConfigModal } from '../../components/TapConfigModal';
|
||||
import { ExchangeHeader } from './ExchangeHeader';
|
||||
import { ExecutionDiagram } from '../../components/ExecutionDiagram/ExecutionDiagram';
|
||||
import { ProcessDiagram } from '../../components/ProcessDiagram';
|
||||
@@ -115,7 +117,6 @@ interface DiagramPanelProps {
|
||||
}
|
||||
|
||||
function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearSelection }: DiagramPanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
@@ -172,11 +173,90 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
||||
return map;
|
||||
}, [tracedMap, appConfig]);
|
||||
|
||||
// Processor options for tap modal dropdown
|
||||
const processorOptions = useMemo(() => {
|
||||
const nodes = diagramQuery.data?.nodes;
|
||||
if (!nodes) return [];
|
||||
return (nodes as Array<{ id?: string; label?: string }>)
|
||||
.filter(n => n.id)
|
||||
.map(n => ({ value: n.id!, label: n.label || n.id! }));
|
||||
}, [diagramQuery.data]);
|
||||
|
||||
// Tap modal state
|
||||
const [tapModalOpen, setTapModalOpen] = useState(false);
|
||||
const [tapModalTarget, setTapModalTarget] = useState<string | undefined>();
|
||||
const [editingTap, setEditingTap] = useState<TapDefinition | null>(null);
|
||||
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleTapSave = useCallback((updatedConfig: typeof appConfig) => {
|
||||
if (!updatedConfig) return;
|
||||
updateConfig.mutate(updatedConfig, {
|
||||
onSuccess: (saved) => {
|
||||
toast({ title: 'Tap configuration saved', description: `Pushed to agents (v${saved.version})`, variant: 'success' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Tap update failed', description: 'Could not save configuration', variant: 'error' });
|
||||
},
|
||||
});
|
||||
}, [updateConfig, toast]);
|
||||
|
||||
const handleTapDelete = useCallback((tap: TapDefinition) => {
|
||||
if (!appConfig) return;
|
||||
const taps = appConfig.taps.filter(t => t.tapId !== tap.tapId);
|
||||
updateConfig.mutate({ ...appConfig, taps }, {
|
||||
onSuccess: (saved) => {
|
||||
toast({ title: 'Tap deleted', description: `${tap.attributeName} removed (v${saved.version})`, variant: 'success' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Tap delete failed', description: 'Could not save configuration', variant: 'error' });
|
||||
},
|
||||
});
|
||||
}, [appConfig, updateConfig, toast]);
|
||||
|
||||
const handleNodeAction = useCallback((nodeId: string, action: NodeAction) => {
|
||||
if (action === 'configure-tap') {
|
||||
navigate(`/admin/appconfig?app=${encodeURIComponent(appId)}&processor=${encodeURIComponent(nodeId)}`);
|
||||
if (!appConfig) return;
|
||||
// Check if there's an existing tap for this processor
|
||||
const existing = appConfig.taps?.find(t => t.processorId === nodeId) ?? null;
|
||||
setEditingTap(existing);
|
||||
setTapModalTarget(nodeId);
|
||||
setTapModalOpen(true);
|
||||
} else if (action === 'toggle-trace') {
|
||||
if (!appConfig) return;
|
||||
const newMap = useTracingStore.getState().toggleProcessor(appId, nodeId);
|
||||
const enabled = nodeId in newMap;
|
||||
const tracedProcessors: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(newMap)) tracedProcessors[k] = v;
|
||||
updateConfig.mutate({
|
||||
...appConfig,
|
||||
tracedProcessors,
|
||||
}, {
|
||||
onSuccess: (saved) => {
|
||||
toast({ title: `Tracing ${enabled ? 'enabled' : 'disabled'}`, description: `${nodeId} — pushed to agents (v${saved.version})`, variant: 'success' });
|
||||
},
|
||||
onError: () => {
|
||||
useTracingStore.getState().toggleProcessor(appId, nodeId);
|
||||
toast({ title: 'Tracing update failed', description: 'Could not save configuration', variant: 'error' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [appId, navigate]);
|
||||
}, [appId, appConfig, updateConfig, toast]);
|
||||
|
||||
const tapModal = appConfig && (
|
||||
<TapConfigModal
|
||||
open={tapModalOpen}
|
||||
onClose={() => setTapModalOpen(false)}
|
||||
tap={editingTap}
|
||||
processorOptions={processorOptions}
|
||||
defaultProcessorId={tapModalTarget}
|
||||
application={appId}
|
||||
config={appConfig}
|
||||
onSave={handleTapSave}
|
||||
onDelete={handleTapDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
// Exchange selected: show header + execution diagram
|
||||
if (exchangeId && detail) {
|
||||
@@ -191,6 +271,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
||||
onNodeAction={handleNodeAction}
|
||||
nodeConfigs={nodeConfigs}
|
||||
/>
|
||||
{tapModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -198,6 +279,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
||||
// No exchange selected: show topology-only diagram
|
||||
if (diagramQuery.data) {
|
||||
return (
|
||||
<>
|
||||
<ProcessDiagram
|
||||
application={appId}
|
||||
routeId={routeId}
|
||||
@@ -207,6 +289,8 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
||||
onNodeAction={handleNodeAction}
|
||||
nodeConfigs={nodeConfigs}
|
||||
/>
|
||||
{tapModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user