import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useNavigate, useLocation, useParams } from 'react-router'; import { useGlobalFilters, useToast } from '@cameleer/design-system'; import { useExecutionDetail } from '../../api/queries/executions'; import { useDiagramByRoute } from '../../api/queries/diagrams'; import { useRouteCatalog } from '../../api/queries/catalog'; import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; import type { TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands'; import { useTracingStore } from '../../stores/tracing-store'; import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types'; import { TapConfigModal } from '../../components/TapConfigModal'; import { ExchangeHeader } from './ExchangeHeader'; import { ExecutionDiagram } from '../../components/ExecutionDiagram/ExecutionDiagram'; import { ProcessDiagram } from '../../components/ProcessDiagram'; import styles from './ExchangesPage.module.css'; import Dashboard from '../Dashboard/Dashboard'; import type { SelectedExchange } from '../Dashboard/Dashboard'; export default function ExchangesPage() { const navigate = useNavigate(); const location = useLocation(); const { appId: scopedAppId, routeId: scopedRouteId, exchangeId: scopedExchangeId } = useParams<{ appId?: string; routeId?: string; exchangeId?: string }>(); // Restore selection from browser history state (enables Back/Forward) const stateSelected = (location.state as any)?.selectedExchange as SelectedExchange | undefined; // Derive selection from URL params when no state-based selection exists (Cmd-K, bookmarks) const urlDerivedExchange: SelectedExchange | null = (scopedExchangeId && scopedAppId && scopedRouteId) ? { executionId: scopedExchangeId, applicationId: scopedAppId, routeId: scopedRouteId } : null; const [selected, setSelectedInternal] = useState(stateSelected ?? urlDerivedExchange); // Sync selection from history state or URL params on navigation changes useEffect(() => { const restored = (location.state as any)?.selectedExchange as SelectedExchange | undefined; if (restored) { setSelectedInternal(restored); } else if (scopedExchangeId && scopedAppId && scopedRouteId) { setSelectedInternal({ executionId: scopedExchangeId, applicationId: scopedAppId, routeId: scopedRouteId, }); } else { setSelectedInternal(null); } }, [location.state, scopedExchangeId, scopedAppId, scopedRouteId]); const [splitPercent, setSplitPercent] = useState(50); const containerRef = useRef(null); // Select an exchange: push a history entry so Back restores the previous state const handleExchangeSelect = useCallback((exchange: SelectedExchange) => { setSelectedInternal(exchange); navigate(location.pathname + location.search, { state: { ...location.state, selectedExchange: exchange }, }); }, [navigate, location.pathname, location.search, location.state]); // Select a correlated exchange: push another history entry const handleCorrelatedSelect = useCallback((executionId: string, applicationId: string, routeId: string) => { const exchange = { executionId, applicationId, routeId }; setSelectedInternal(exchange); navigate(location.pathname + location.search, { state: { ...location.state, selectedExchange: exchange }, }); }, [navigate, location.pathname, location.search, location.state]); // Clear selection: navigate up to route level when URL has exchangeId const handleClearSelection = useCallback(() => { setSelectedInternal(null); if (scopedExchangeId && scopedAppId && scopedRouteId) { navigate(`/exchanges/${scopedAppId}/${scopedRouteId}`, { state: { ...location.state, selectedExchange: undefined }, }); } }, [scopedExchangeId, scopedAppId, scopedRouteId, navigate, location.state]); const handleSplitterDown = useCallback((e: React.PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); const container = containerRef.current; if (!container) return; const onMove = (me: PointerEvent) => { const rect = container.getBoundingClientRect(); const x = me.clientX - rect.left; const pct = Math.min(80, Math.max(20, (x / rect.width) * 100)); setSplitPercent(pct); }; const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); }, []); // Show split view when a route is scoped (sidebar) or an exchange is selected const showSplit = !!selected || !!scopedRouteId; if (!showSplit) { return ; } // Determine what the right panel shows const panelAppId = selected?.applicationId ?? scopedAppId!; const panelRouteId = selected?.routeId ?? scopedRouteId!; const panelExchangeId = selected?.executionId ?? undefined; return (
); } // ─── Right panel: diagram + execution overlay ─────────────────────────────── interface DiagramPanelProps { appId: string; routeId: string; exchangeId?: string; onCorrelatedSelect: (executionId: string, applicationId: string, routeId: string) => void; onClearSelection: () => void; } function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearSelection }: DiagramPanelProps) { const { timeRange } = useGlobalFilters(); const timeFrom = timeRange.start.toISOString(); const timeTo = timeRange.end.toISOString(); const { data: detail } = useExecutionDetail(exchangeId ?? null); const diagramQuery = useDiagramByRoute(appId, routeId); const { data: catalog } = useRouteCatalog(timeFrom, timeTo); const knownRouteIds = useMemo(() => { const ids = new Set(); if (catalog) { for (const app of catalog as any[]) { for (const r of app.routes || []) { ids.add(r.routeId); } } } return ids; }, [catalog]); // Build endpoint URI → routeId map for cross-route drill-down const endpointRouteMap = useMemo(() => { const map = new Map(); if (catalog) { for (const app of catalog as any[]) { for (const r of app.routes || []) { if (r.fromEndpointUri) { map.set(r.fromEndpointUri, r.routeId); } } } } return map; }, [catalog]); // Build nodeConfigs from app config (for TRACE/TAP badges) const { data: appConfig } = useApplicationConfig(appId); const nodeConfigs = useMemo(() => { const map = new Map(); if (appConfig?.tracedProcessors) { for (const pid of Object.keys(appConfig.tracedProcessors)) { map.set(pid, { traceEnabled: true }); } } if (appConfig?.taps) { for (const tap of appConfig.taps) { if (tap.enabled) { const existing = map.get(tap.processorId); map.set(tap.processorId, { ...existing, tapExpression: tap.expression }); } } } return map; }, [appConfig]); // Processor options for tap modal dropdown const processorOptions = useMemo(() => { const nodes = diagramQuery.data?.nodes; if (!nodes) return []; return (nodes as Array<{ id?: string; label?: string }>) .filter(n => n.id) .map(n => ({ value: n.id!, label: n.label || n.id! })); }, [diagramQuery.data]); // Tap modal state const [tapModalOpen, setTapModalOpen] = useState(false); const [tapModalTarget, setTapModalTarget] = useState(); const [editingTap, setEditingTap] = useState(null); const updateConfig = useUpdateApplicationConfig(); const { toast } = useToast(); const handleTapSave = useCallback((updatedConfig: typeof appConfig) => { if (!updatedConfig) return; updateConfig.mutate(updatedConfig, { onSuccess: (saved: ConfigUpdateResponse) => { if (saved.pushResult.success) { toast({ title: 'Tap configuration saved', description: `Pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents (v${saved.config.version})`, variant: 'success' }); } else { const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut]; toast({ title: 'Tap configuration saved — partial push failure', description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 }); } }, onError: () => { toast({ title: 'Tap update failed', description: 'Could not save configuration', variant: 'error', duration: 86_400_000 }); }, }); }, [updateConfig, toast]); const handleTapDelete = useCallback((tap: TapDefinition) => { if (!appConfig) return; const taps = appConfig.taps.filter(t => t.tapId !== tap.tapId); updateConfig.mutate({ ...appConfig, taps }, { onSuccess: (saved: ConfigUpdateResponse) => { if (saved.pushResult.success) { toast({ title: 'Tap deleted', description: `${tap.attributeName} removed — pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents (v${saved.config.version})`, variant: 'success' }); } else { const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut]; toast({ title: 'Tap deleted — partial push failure', description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 }); } }, onError: () => { toast({ title: 'Tap delete failed', description: 'Could not save configuration', variant: 'error', duration: 86_400_000 }); }, }); }, [appConfig, updateConfig, toast]); const handleNodeAction = useCallback((nodeId: string, action: NodeAction) => { if (action === 'configure-tap') { if (!appConfig) return; // Check if there's an existing tap for this processor const existing = appConfig.taps?.find(t => t.processorId === nodeId) ?? null; setEditingTap(existing); setTapModalTarget(nodeId); setTapModalOpen(true); } else if (action === 'toggle-trace') { if (!appConfig) return; const newMap = useTracingStore.getState().toggleProcessor(appId, nodeId); const enabled = nodeId in newMap; const tracedProcessors: Record = {}; for (const [k, v] of Object.entries(newMap)) tracedProcessors[k] = v; updateConfig.mutate({ ...appConfig, tracedProcessors, }, { onSuccess: (saved: ConfigUpdateResponse) => { if (saved.pushResult.success) { toast({ title: `Tracing ${enabled ? 'enabled' : 'disabled'}`, description: `${nodeId} — pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents (v${saved.config.version})`, variant: 'success' }); } else { const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut]; toast({ title: `Tracing update — partial push failure`, description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 }); } }, onError: () => { useTracingStore.getState().toggleProcessor(appId, nodeId); toast({ title: 'Tracing update failed', description: 'Could not save configuration', variant: 'error', duration: 86_400_000 }); }, }); } }, [appId, appConfig, updateConfig, toast]); const tapModal = appConfig && ( setTapModalOpen(false)} tap={editingTap} processorOptions={processorOptions} defaultProcessorId={tapModalTarget} application={appId} config={appConfig} onSave={handleTapSave} onDelete={handleTapDelete} /> ); // Exchange selected: show header + execution diagram if (exchangeId && detail) { return ( <> {tapModal} ); } // No exchange selected: show topology-only diagram if (diagramQuery.data) { return ( <> {tapModal} ); } return (
Loading diagram...
); }