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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user