Files
cameleer-server/ui/src/pages/Exchanges/ExchangesPage.tsx
hsiegeln b4c9be9334
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m1s
CI / docker (push) Successful in 57s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 36s
feat(ui): browser Back/Forward restores exchange selection via history state
Each exchange selection (from table or correlation chain) pushes a
browser history entry with the selected exchange in location.state.
When the user navigates away (to agent details, app scope, etc.) and
presses Back, the previous history entry is restored and the split
view with the diagram reappears exactly as they left it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:48:38 +01:00

155 lines
5.8 KiB
TypeScript

import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { useGlobalFilters } from '@cameleer/design-system';
import { useExecutionDetail } from '../../api/queries/executions';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useRouteCatalog } from '../../api/queries/catalog';
import type { NodeAction } from '../../components/ProcessDiagram/types';
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();
// Restore selection from browser history state (enables Back/Forward)
const stateSelected = (location.state as any)?.selectedExchange as SelectedExchange | undefined;
const [selected, setSelectedInternal] = useState<SelectedExchange | null>(stateSelected ?? null);
// Sync from history state when the user navigates Back/Forward
useEffect(() => {
const restored = (location.state as any)?.selectedExchange as SelectedExchange | undefined;
setSelectedInternal(restored ?? null);
}, [location.state]);
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, applicationName: string, routeId: string) => {
const exchange = { executionId, applicationName, routeId };
setSelectedInternal(exchange);
navigate(location.pathname + location.search, {
state: { ...location.state, selectedExchange: exchange },
});
}, [navigate, location.pathname, location.search, location.state]);
// Clear selection: push a history entry without selection (so Back returns to selected state)
const handleClearSelection = useCallback(() => {
setSelectedInternal(null);
}, []);
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);
}, []);
// No exchange selected: full-width Dashboard
if (!selected) {
return <Dashboard onExchangeSelect={handleExchangeSelect} />;
}
// Exchange selected: resizable split — Dashboard on left, diagram on right
return (
<div ref={containerRef} className={styles.splitView}>
<div className={styles.leftPanel} style={{ width: `${splitPercent}%` }}>
<Dashboard onExchangeSelect={handleExchangeSelect} />
</div>
<div className={styles.splitter} onPointerDown={handleSplitterDown} />
<div className={styles.rightPanel} style={{ width: `${100 - splitPercent}%` }}>
<DiagramPanel
appId={selected.applicationName}
routeId={selected.routeId}
exchangeId={selected.executionId}
onCorrelatedSelect={handleCorrelatedSelect}
onClearSelection={handleClearSelection}
/>
</div>
</div>
);
}
// ─── Right panel: diagram + execution overlay ───────────────────────────────
interface DiagramPanelProps {
appId: string;
routeId: string;
exchangeId: string;
onCorrelatedSelect: (executionId: string, applicationName: string, routeId: string) => void;
onClearSelection: () => void;
}
function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearSelection }: DiagramPanelProps) {
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const { data: detail } = useExecutionDetail(exchangeId);
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]);
const handleNodeAction = useCallback((nodeId: string, action: NodeAction) => {
if (action === 'configure-tap') {
navigate(`/admin/appconfig?app=${encodeURIComponent(appId)}&processor=${encodeURIComponent(nodeId)}`);
}
}, [appId, navigate]);
if (detail) {
return (
<>
<ExchangeHeader detail={detail} onCorrelatedSelect={onCorrelatedSelect} onClearSelection={onClearSelection} />
<ExecutionDiagram
executionId={exchangeId}
executionDetail={detail}
knownRouteIds={knownRouteIds}
onNodeAction={handleNodeAction}
/>
</>
);
}
return (
<div className={styles.emptyRight}>
Loading execution...
</div>
);
}