feat: trace data indicators, inline tap config, and detail tab gating
All checks were successful
CI / build (push) Successful in 1m46s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 1m25s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m57s

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:
hsiegeln
2026-03-29 13:08:58 +02:00
parent 5103f40196
commit 3d71345181
22 changed files with 568 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ public record ExecutionSummary(
String errorMessage,
String diagramContentHash,
String highlight,
Map<String, String> attributes
Map<String, String> attributes,
boolean hasTraceData
) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}

View 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>
);
}

View File

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

View File

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