All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m27s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m40s
SonarQube / sonarqube (push) Successful in 4m29s
BREAKING: wipe dev PostgreSQL before deploying — V1 checksum changes. Agents must now send environmentId on registration (400 if missing). Two tables previously keyed on app name alone caused cross-environment data bleed: writing config for (app=X, env=dev) would overwrite the row used by (app=X, env=prod) agents, and agent startup fetches ignored env entirely. - V1 schema: application_config and app_settings are now PK (app, env). - Repositories: env-keyed finders/saves; env is the authoritative column, stamped on the stored JSON so the row agrees with itself. - ApplicationConfigController.getConfig is dual-mode — AGENT role uses JWT env claim (agents cannot spoof env); non-agent callers provide env via ?environment= query param. - AppSettingsController endpoints now require ?environment=. - SensitiveKeysAdminController fan-out iterates (app, env) slices so each env gets its own merged keys. - DiagramController ingestion stamps env on TaggedDiagram; ClickHouse route_diagrams INSERT + findProcessorRouteMapping are env-scoped. - AgentRegistrationController: environmentId is required on register; removed all "default" fallbacks from register/refresh/heartbeat auto-heal. - UI hooks (useApplicationConfig, useProcessorRouteMapping, useAppSettings, useAllAppSettings, useUpdateAppSettings) take env, wired to useEnvironmentStore at all call sites. - New ConfigEnvIsolationIT covers env-isolation for both repositories. Plan in docs/superpowers/plans/2026-04-16-environment-scoping.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
382 lines
16 KiB
TypeScript
382 lines
16 KiB
TypeScript
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
|
import { X } from 'lucide-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 { useCatalog } from '../../api/queries/catalog';
|
|
import { useAgents } from '../../api/queries/agents';
|
|
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
|
import type { TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
|
|
import { useEnvironmentStore } from '../../api/environment-store';
|
|
import { useCanControl } from '../../auth/auth-store';
|
|
import { useTracingStore } from '../../stores/tracing-store';
|
|
import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types';
|
|
import { TapConfigModal } from '../../components/TapConfigModal';
|
|
import { ExchangeHeader } from './ExchangeHeader';
|
|
import { RouteControlBar } from './RouteControlBar';
|
|
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 selectedEnv = useEnvironmentStore((s) => s.environment);
|
|
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}%` }}>
|
|
<button className={styles.closeBtn} onClick={handleClearSelection} title="Close panel" aria-label="Close panel">
|
|
<X size={14} />
|
|
</button>
|
|
<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 selectedEnv = useEnvironmentStore((s) => s.environment);
|
|
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 } = useCatalog();
|
|
|
|
// Route state + capabilities for topology-only control bar
|
|
const { data: agents } = useAgents(undefined, appId);
|
|
const canControl = useCanControl();
|
|
const { hasRouteControl, hasReplay } = useMemo(() => {
|
|
if (!agents) return { hasRouteControl: false, hasReplay: false };
|
|
const agentList = agents as any[];
|
|
return {
|
|
hasRouteControl: agentList.some((a: any) => a.capabilities?.routeControl === true),
|
|
hasReplay: agentList.some((a: any) => a.capabilities?.replay === true),
|
|
};
|
|
}, [agents]);
|
|
const routeState = useMemo(() => {
|
|
if (!catalog) return undefined;
|
|
for (const app of catalog as any[]) {
|
|
if (app.slug !== appId) continue;
|
|
for (const r of app.routes || []) {
|
|
if (r.routeId === routeId) return (r.routeState ?? 'started') as 'started' | 'stopped' | 'suspended';
|
|
}
|
|
}
|
|
return undefined;
|
|
}, [catalog, appId, routeId]);
|
|
|
|
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, selectedEnv);
|
|
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({ config: updatedConfig, environment: selectedEnv }, {
|
|
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({ config: { ...appConfig, taps }, environment: selectedEnv }, {
|
|
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({ config: {
|
|
...appConfig,
|
|
tracedProcessors,
|
|
}, environment: selectedEnv }, {
|
|
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={canControl ? handleNodeAction : undefined}
|
|
nodeConfigs={nodeConfigs}
|
|
/>
|
|
{tapModal}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// No exchange selected: show topology-only diagram with route control bar
|
|
if (diagramQuery.data) {
|
|
return (
|
|
<>
|
|
{canControl && (hasRouteControl || hasReplay) && (
|
|
<RouteControlBar
|
|
application={appId}
|
|
routeId={routeId}
|
|
routeState={routeState}
|
|
hasRouteControl={hasRouteControl}
|
|
hasReplay={hasReplay}
|
|
/>
|
|
)}
|
|
<ProcessDiagram
|
|
application={appId}
|
|
routeId={routeId}
|
|
diagramLayout={diagramQuery.data}
|
|
knownRouteIds={knownRouteIds}
|
|
endpointRouteMap={endpointRouteMap}
|
|
onNodeAction={canControl ? handleNodeAction : undefined}
|
|
nodeConfigs={nodeConfigs}
|
|
/>
|
|
{tapModal}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.emptyRight}>
|
|
Loading diagram...
|
|
</div>
|
|
);
|
|
}
|