Files
cameleer-server/ui/src/pages/Exchanges/ExchangesPage.tsx

340 lines
14 KiB
TypeScript
Raw Normal View History

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<SelectedExchange | null>(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<HTMLDivElement>(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 <Dashboard onExchangeSelect={handleExchangeSelect} activeExchangeId={selected?.executionId} />;
}
// Determine what the right panel shows
const panelAppId = selected?.applicationId ?? scopedAppId!;
const panelRouteId = selected?.routeId ?? scopedRouteId!;
const panelExchangeId = selected?.executionId ?? undefined;
return (
<div ref={containerRef} className={styles.splitView}>
<div className={styles.leftPanel} style={{ width: `${splitPercent}%` }}>
<Dashboard onExchangeSelect={handleExchangeSelect} activeExchangeId={selected?.executionId} />
</div>
<div className={styles.splitter} onPointerDown={handleSplitterDown} />
<div className={styles.rightPanel} style={{ width: `${100 - splitPercent}%` }}>
<DiagramPanel
appId={panelAppId}
routeId={panelRouteId}
exchangeId={panelExchangeId}
onCorrelatedSelect={handleCorrelatedSelect}
onClearSelection={handleClearSelection}
/>
</div>
</div>
);
}
// ─── 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<string>();
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<string, string>();
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<string, NodeConfig>();
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<string | undefined>();
const [editingTap, setEditingTap] = useState<TapDefinition | null>(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<string, string> = {};
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 && (
<TapConfigModal
open={tapModalOpen}
onClose={() => 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 (
<>
<ExchangeHeader detail={detail} onCorrelatedSelect={onCorrelatedSelect} onClearSelection={onClearSelection} />
<ExecutionDiagram
executionId={exchangeId}
executionDetail={detail}
knownRouteIds={knownRouteIds}
endpointRouteMap={endpointRouteMap}
onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs}
/>
{tapModal}
</>
);
}
// No exchange selected: show topology-only diagram
if (diagramQuery.data) {
return (
<>
<ProcessDiagram
application={appId}
routeId={routeId}
diagramLayout={diagramQuery.data}
knownRouteIds={knownRouteIds}
endpointRouteMap={endpointRouteMap}
onNodeAction={handleNodeAction}
nodeConfigs={nodeConfigs}
/>
{tapModal}
</>
);
}
return (
<div className={styles.emptyRight}>
Loading diagram...
</div>
);
}