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

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