diff --git a/ui/src/pages/Routes/RouteDetail.module.css b/ui/src/pages/Routes/RouteDetail.module.css index 692ce6f8..df1a80dc 100644 --- a/ui/src/pages/Routes/RouteDetail.module.css +++ b/ui/src/pages/Routes/RouteDetail.module.css @@ -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); +} diff --git a/ui/src/pages/Routes/RouteDetail.tsx b/ui/src/pages/Routes/RouteDetail.tsx index 1c581f4c..3f2c2080 100644 --- a/ui/src/pages/Routes/RouteDetail.tsx +++ b/ui/src/pages/Routes/RouteDetail.tsx @@ -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('startTime'); const [recentSortDir, setRecentSortDir] = useState<'asc' | 'desc'>('desc'); + // ── Tap modal state ──────────────────────────────────────────────────────── + const [tapModalOpen, setTapModalOpen] = useState(false); + const [editingTap, setEditingTap] = useState(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(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[] = useMemo(() => [ + { + key: 'attributeName', + header: 'Attribute', + sortable: true, + render: (_, row) => {row.attributeName}, + }, + { + key: 'processorId', + header: 'Processor', + sortable: true, + render: (_, row) => {row.processorId}, + }, + { + key: 'expression', + header: 'Expression', + render: (_, row) => {row.expression}, + }, + { + key: 'language', + header: 'Language', + render: (_, row) => , + }, + { + key: 'target', + header: 'Target', + render: (_, row) => , + }, + { + key: 'attributeType', + header: 'Type', + render: (_, row) => , + }, + { + key: 'enabled', + header: 'Enabled', + width: '80px', + render: (_, row) => ( + toggleTapEnabled(row)} + /> + ), + }, + { + key: 'actions' as any, + header: '', + width: '100px', + render: (_, row) => ( +
+ + +
+ ), + }, + ], [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' && (
-
- {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)`} +
+ Data Extraction Taps +
+ {routeTaps.length === 0 ? ( +
+ No taps configured for this route. Add a tap to extract business attributes from exchange data. +
+ ) : ( + ({ ...t, id: t.tapId }))} + flush + /> + )}
)}
+ + {/* Tap Modal */} + setTapModalOpen(false)} title={editingTap ? 'Edit Tap' : 'Add Tap'} size="lg"> +
+ + setTapName(e.target.value)} placeholder="e.g. orderId" /> + + + + setTapLanguage(e.target.value)} + /> + + +