feat(ui): add taps DataTable, CRUD modal with test expression to RouteDetail
- Replace taps tab placeholder with full DataTable showing all route taps - Add columns: attribute, processor, expression, language, target, type, enabled toggle, actions - Add tap modal with form fields: attribute name, processor select, language, target, expression, type selector - Implement inline enable/disable toggle per tap row - Add ConfirmDialog for tap deletion - Add test expression section with Recent Exchange and Custom Payload tabs - Add save/edit/delete tap operations via application config update - Add all supporting CSS module classes (no inline styles) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -326,3 +326,113 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tapActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Tap modal */
|
||||
.tapModalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tapFormRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tapFormRow > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.monoTextarea {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.typeSelector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.typeOption {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.typeOptionActive {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tapModalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Test expression */
|
||||
.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 var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.testTabBtnActive {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.testBody {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.testResult {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.testSuccess {
|
||||
background: var(--bg-success-subtle, #0f2a1a);
|
||||
border: 1px solid var(--border-success, #166534);
|
||||
color: var(--text-success, #4ade80);
|
||||
}
|
||||
|
||||
.testError {
|
||||
background: var(--bg-error-subtle, #2a0f0f);
|
||||
border: 1px solid var(--border-error, #991b1b);
|
||||
color: var(--text-error, #f87171);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ import {
|
||||
MonoText,
|
||||
Sparkline,
|
||||
Toggle,
|
||||
Button,
|
||||
Modal,
|
||||
FormField,
|
||||
Input,
|
||||
Select,
|
||||
Textarea,
|
||||
Collapsible,
|
||||
ConfirmDialog,
|
||||
} from '@cameleer/design-system';
|
||||
import type { KpiItem, Column } from '@cameleer/design-system';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
@@ -21,7 +29,8 @@ import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
|
||||
import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig, useTestExpression } from '../../api/queries/commands';
|
||||
import type { TapDefinition } from '../../api/queries/commands';
|
||||
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||
import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping';
|
||||
import styles from './RouteDetail.module.css';
|
||||
@@ -265,6 +274,24 @@ export default function RouteDetail() {
|
||||
const [recentSortField, setRecentSortField] = useState<string>('startTime');
|
||||
const [recentSortDir, setRecentSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// ── Tap modal state ────────────────────────────────────────────────────────
|
||||
const [tapModalOpen, setTapModalOpen] = useState(false);
|
||||
const [editingTap, setEditingTap] = useState<TapDefinition | null>(null);
|
||||
const [tapName, setTapName] = useState('');
|
||||
const [tapProcessor, setTapProcessor] = useState('');
|
||||
const [tapLanguage, setTapLanguage] = useState('simple');
|
||||
const [tapTarget, setTapTarget] = useState<'INPUT' | 'OUTPUT' | 'BOTH'>('OUTPUT');
|
||||
const [tapExpression, setTapExpression] = useState('');
|
||||
const [tapType, setTapType] = useState<'BUSINESS_OBJECT' | 'CORRELATION' | 'EVENT' | 'CUSTOM'>('BUSINESS_OBJECT');
|
||||
const [tapEnabled, setTapEnabled] = useState(true);
|
||||
const [deletingTap, setDeletingTap] = useState<TapDefinition | null>(null);
|
||||
|
||||
// ── Test expression state ──────────────────────────────────────────────────
|
||||
const [testTab, setTestTab] = useState('recent');
|
||||
const [testPayload, setTestPayload] = useState('');
|
||||
const [testResult, setTestResult] = useState<{ result?: string; error?: string } | null>(null);
|
||||
const [testExchangeId, setTestExchangeId] = useState('');
|
||||
|
||||
const handleRecentSortChange = useCallback((key: string, dir: 'asc' | 'desc') => {
|
||||
setRecentSortField(key);
|
||||
setRecentSortDir(dir);
|
||||
@@ -299,6 +326,7 @@ export default function RouteDetail() {
|
||||
// ── Application config ──────────────────────────────────────────────────────
|
||||
const config = useApplicationConfig(appId);
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
const testExpressionMutation = useTestExpression();
|
||||
|
||||
const isRecording = config.data?.routeRecording?.[routeId!] !== false;
|
||||
|
||||
@@ -455,6 +483,171 @@ export default function RouteDetail() {
|
||||
{ label: 'Taps', value: 'taps', count: routeTaps.length },
|
||||
];
|
||||
|
||||
// ── Tap helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const processorOptions = useMemo(() => {
|
||||
if (!diagram?.nodes) return [];
|
||||
return (diagram.nodes as Array<{ id?: string; label?: string }>)
|
||||
.filter((n) => n.id)
|
||||
.map((n) => ({ value: n.id!, label: n.label || n.id! }));
|
||||
}, [diagram]);
|
||||
|
||||
function openTapModal(tap: TapDefinition | null) {
|
||||
if (tap) {
|
||||
setEditingTap(tap);
|
||||
setTapName(tap.attributeName);
|
||||
setTapProcessor(tap.processorId);
|
||||
setTapLanguage(tap.language);
|
||||
setTapTarget(tap.target);
|
||||
setTapExpression(tap.expression);
|
||||
setTapType(tap.attributeType);
|
||||
setTapEnabled(tap.enabled);
|
||||
} else {
|
||||
setEditingTap(null);
|
||||
setTapName('');
|
||||
setTapProcessor(processorOptions[0]?.value ?? '');
|
||||
setTapLanguage('simple');
|
||||
setTapTarget('OUTPUT');
|
||||
setTapExpression('');
|
||||
setTapType('BUSINESS_OBJECT');
|
||||
setTapEnabled(true);
|
||||
}
|
||||
setTestResult(null);
|
||||
setTestPayload('');
|
||||
setTestExchangeId('');
|
||||
setTapModalOpen(true);
|
||||
}
|
||||
|
||||
function saveTap() {
|
||||
if (!config.data) return;
|
||||
const tap: TapDefinition = {
|
||||
tapId: editingTap?.tapId || crypto.randomUUID(),
|
||||
processorId: tapProcessor,
|
||||
target: tapTarget,
|
||||
expression: tapExpression,
|
||||
language: tapLanguage,
|
||||
attributeName: tapName,
|
||||
attributeType: tapType,
|
||||
enabled: tapEnabled,
|
||||
version: editingTap ? editingTap.version + 1 : 1,
|
||||
};
|
||||
const taps = editingTap
|
||||
? config.data.taps.map(t => t.tapId === editingTap.tapId ? tap : t)
|
||||
: [...(config.data.taps || []), tap];
|
||||
updateConfig.mutate({ ...config.data, taps });
|
||||
setTapModalOpen(false);
|
||||
}
|
||||
|
||||
function deleteTap(tap: TapDefinition) {
|
||||
if (!config.data) return;
|
||||
const taps = config.data.taps.filter(t => t.tapId !== tap.tapId);
|
||||
updateConfig.mutate({ ...config.data, taps });
|
||||
setDeletingTap(null);
|
||||
}
|
||||
|
||||
function toggleTapEnabled(tap: TapDefinition) {
|
||||
if (!config.data) return;
|
||||
const taps = config.data.taps.map(t =>
|
||||
t.tapId === tap.tapId ? { ...t, enabled: !t.enabled } : t,
|
||||
);
|
||||
updateConfig.mutate({ ...config.data, taps });
|
||||
}
|
||||
|
||||
function runTestExpression() {
|
||||
if (!appId) return;
|
||||
const body = testTab === 'recent' ? testExchangeId : testPayload;
|
||||
testExpressionMutation.mutate(
|
||||
{ application: appId, expression: tapExpression, language: tapLanguage, body, target: tapTarget },
|
||||
{ onSuccess: (data) => setTestResult(data), onError: (err) => setTestResult({ error: (err as Error).message }) },
|
||||
);
|
||||
}
|
||||
|
||||
const tapColumns: Column<TapDefinition & { id: string }>[] = useMemo(() => [
|
||||
{
|
||||
key: 'attributeName',
|
||||
header: 'Attribute',
|
||||
sortable: true,
|
||||
render: (_, row) => <span>{row.attributeName}</span>,
|
||||
},
|
||||
{
|
||||
key: 'processorId',
|
||||
header: 'Processor',
|
||||
sortable: true,
|
||||
render: (_, row) => <MonoText size="xs">{row.processorId}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'expression',
|
||||
header: 'Expression',
|
||||
render: (_, row) => <MonoText size="xs">{row.expression}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'language',
|
||||
header: 'Language',
|
||||
render: (_, row) => <Badge label={row.language} color="auto" />,
|
||||
},
|
||||
{
|
||||
key: 'target',
|
||||
header: 'Target',
|
||||
render: (_, row) => <Badge label={row.target} color="auto" />,
|
||||
},
|
||||
{
|
||||
key: 'attributeType',
|
||||
header: 'Type',
|
||||
render: (_, row) => <Badge label={row.attributeType} color="auto" />,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
header: 'Enabled',
|
||||
width: '80px',
|
||||
render: (_, row) => (
|
||||
<Toggle
|
||||
checked={row.enabled}
|
||||
onChange={() => toggleTapEnabled(row)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions' as any,
|
||||
header: '',
|
||||
width: '100px',
|
||||
render: (_, row) => (
|
||||
<div className={styles.tapActions}>
|
||||
<Button variant="ghost" size="sm" onClick={() => openTapModal(row)}>Edit</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeletingTap(row)}>Del</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [config.data, processorOptions]);
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'simple', label: 'Simple' },
|
||||
{ value: 'jsonpath', label: 'JSONPath' },
|
||||
{ value: 'xpath', label: 'XPath' },
|
||||
{ value: 'jq', label: 'jq' },
|
||||
{ value: 'groovy', label: 'Groovy' },
|
||||
];
|
||||
|
||||
const targetOptions = [
|
||||
{ value: 'INPUT', label: 'Input' },
|
||||
{ value: 'OUTPUT', label: 'Output' },
|
||||
{ value: 'BOTH', label: 'Both' },
|
||||
];
|
||||
|
||||
const typeChoices: Array<{ value: TapDefinition['attributeType']; label: string }> = [
|
||||
{ value: 'BUSINESS_OBJECT', label: 'Business Object' },
|
||||
{ value: 'CORRELATION', label: 'Correlation' },
|
||||
{ value: 'EVENT', label: 'Event' },
|
||||
{ value: 'CUSTOM', label: 'Custom' },
|
||||
];
|
||||
|
||||
const recentExchangeOptions = useMemo(() =>
|
||||
exchangeRows.slice(0, 20).map(e => ({
|
||||
value: e.executionId,
|
||||
label: `${e.executionId.slice(0, 12)} — ${e.status}`,
|
||||
})),
|
||||
[exchangeRows],
|
||||
);
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
@@ -632,14 +825,168 @@ export default function RouteDetail() {
|
||||
|
||||
{activeTab === 'taps' && (
|
||||
<div className={styles.tapsSection}>
|
||||
<div className={styles.emptyState}>
|
||||
{routeTaps.length === 0
|
||||
? 'No taps configured for this route. Add a tap to extract business attributes from exchange data.'
|
||||
: `${routeTaps.length} taps configured (${activeTapCount} active)`}
|
||||
<div className={styles.tapsHeader}>
|
||||
<span className={styles.tapsTitle}>Data Extraction Taps</span>
|
||||
<Button variant="primary" size="sm" onClick={() => openTapModal(null)}>+ Add Tap</Button>
|
||||
</div>
|
||||
{routeTaps.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
No taps configured for this route. Add a tap to extract business attributes from exchange data.
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={tapColumns}
|
||||
data={routeTaps.map(t => ({ ...t, id: t.tapId }))}
|
||||
flush
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tap Modal */}
|
||||
<Modal open={tapModalOpen} onClose={() => setTapModalOpen(false)} title={editingTap ? 'Edit Tap' : 'Add Tap'} size="lg">
|
||||
<div className={styles.tapModalBody}>
|
||||
<FormField label="Attribute Name">
|
||||
<Input value={tapName} onChange={(e) => setTapName(e.target.value)} placeholder="e.g. orderId" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Processor">
|
||||
<Select
|
||||
options={processorOptions}
|
||||
value={tapProcessor}
|
||||
onChange={(e) => setTapProcessor(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.tapFormRow}>
|
||||
<FormField label="Language">
|
||||
<Select
|
||||
options={languageOptions}
|
||||
value={tapLanguage}
|
||||
onChange={(e) => setTapLanguage(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Target">
|
||||
<Select
|
||||
options={targetOptions}
|
||||
value={tapTarget}
|
||||
onChange={(e) => setTapTarget(e.target.value as 'INPUT' | 'OUTPUT' | 'BOTH')}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Expression">
|
||||
<Textarea
|
||||
className={styles.monoTextarea}
|
||||
value={tapExpression}
|
||||
onChange={(e) => setTapExpression(e.target.value)}
|
||||
placeholder="e.g. ${body.orderId}"
|
||||
rows={3}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Type">
|
||||
<div className={styles.typeSelector}>
|
||||
{typeChoices.map(tc => (
|
||||
<button
|
||||
key={tc.value}
|
||||
type="button"
|
||||
className={`${styles.typeOption} ${tapType === tc.value ? styles.typeOptionActive : ''}`}
|
||||
onClick={() => setTapType(tc.value)}
|
||||
>
|
||||
{tc.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.tapFormRow}>
|
||||
<FormField label="Enabled">
|
||||
<Toggle checked={tapEnabled} onChange={() => setTapEnabled(!tapEnabled)} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Test Expression */}
|
||||
<Collapsible title="Test Expression" defaultOpen>
|
||||
<div className={styles.testSection}>
|
||||
<div className={styles.testTabs}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.testTabBtn} ${testTab === 'recent' ? styles.testTabBtnActive : ''}`}
|
||||
onClick={() => setTestTab('recent')}
|
||||
>
|
||||
Recent Exchange
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.testTabBtn} ${testTab === 'custom' ? styles.testTabBtnActive : ''}`}
|
||||
onClick={() => setTestTab('custom')}
|
||||
>
|
||||
Custom Payload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testTab === 'recent' && (
|
||||
<div className={styles.testBody}>
|
||||
<Select
|
||||
options={recentExchangeOptions.length > 0 ? recentExchangeOptions : [{ value: '', label: 'No recent exchanges' }]}
|
||||
value={testExchangeId}
|
||||
onChange={(e) => setTestExchangeId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testTab === 'custom' && (
|
||||
<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={runTestExpression}
|
||||
loading={testExpressionMutation.isPending}
|
||||
disabled={!tapExpression}
|
||||
>
|
||||
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.tapModalFooter}>
|
||||
<Button variant="secondary" onClick={() => setTapModalOpen(false)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={saveTap} disabled={!tapName || !tapProcessor || !tapExpression}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Tap Confirm */}
|
||||
<ConfirmDialog
|
||||
open={!!deletingTap}
|
||||
onClose={() => setDeletingTap(null)}
|
||||
onConfirm={() => deletingTap && deleteTap(deletingTap)}
|
||||
title="Delete Tap"
|
||||
message={`This will remove the tap "${deletingTap?.attributeName ?? ''}" from the configuration.`}
|
||||
confirmText={deletingTap?.attributeName ?? ''}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user