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:
hsiegeln
2026-03-26 18:44:36 +01:00
parent 807e191397
commit 78813ea15f
2 changed files with 462 additions and 5 deletions

View File

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

View File

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