diff --git a/ui/src/pages/DashboardTab/DashboardPage.tsx b/ui/src/pages/DashboardTab/DashboardPage.tsx
new file mode 100644
index 00000000..ed6dcf0c
--- /dev/null
+++ b/ui/src/pages/DashboardTab/DashboardPage.tsx
@@ -0,0 +1,17 @@
+import { useParams } from 'react-router';
+import { lazy, Suspense } from 'react';
+import { Spinner } from '@cameleer/design-system';
+
+const RoutesMetrics = lazy(() => import('../Routes/RoutesMetrics'));
+const RouteDetail = lazy(() => import('../Routes/RouteDetail'));
+
+const Fallback =
;
+
+export default function DashboardPage() {
+ const { routeId } = useParams<{ appId?: string; routeId?: string }>();
+
+ if (routeId) {
+ return
;
+ }
+ return
;
+}
diff --git a/ui/src/pages/Exchanges/ExchangeHeader.tsx b/ui/src/pages/Exchanges/ExchangeHeader.tsx
new file mode 100644
index 00000000..420dac02
--- /dev/null
+++ b/ui/src/pages/Exchanges/ExchangeHeader.tsx
@@ -0,0 +1,85 @@
+import { StatusDot, MonoText, Badge } from '@cameleer/design-system';
+import { useCorrelationChain } from '../../api/queries/correlation';
+import type { ExecutionDetail } from '../../components/ExecutionDiagram/types';
+
+interface ExchangeHeaderProps {
+ detail: ExecutionDetail;
+ onExchangeClick?: (executionId: string) => void;
+}
+
+type StatusVariant = 'success' | 'error' | 'running' | 'warning';
+type BadgeColor = 'success' | 'error' | 'running' | 'warning';
+
+function statusVariant(s: string): StatusVariant {
+ switch (s) {
+ case 'COMPLETED': return 'success';
+ case 'FAILED': return 'error';
+ case 'RUNNING': return 'running';
+ default: return 'warning';
+ }
+}
+
+function badgeColor(s: string): BadgeColor {
+ switch (s) {
+ case 'COMPLETED': return 'success';
+ case 'FAILED': return 'error';
+ case 'RUNNING': return 'running';
+ default: return 'warning';
+ }
+}
+
+function formatDuration(ms: number): string {
+ if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
+ return `${ms}ms`;
+}
+
+export function ExchangeHeader({ detail, onExchangeClick }: ExchangeHeaderProps) {
+ const { data: chainResult } = useCorrelationChain(detail.correlationId ?? null);
+ const chain = chainResult?.data;
+ const showChain = chain && chain.length > 1;
+
+ return (
+
+
+
+ {detail.exchangeId || detail.executionId}
+
+ {detail.routeId}
+
+ {formatDuration(detail.durationMs)}
+
+
+
+ {showChain && (
+
+
+ Correlated:
+
+ {chain.map((e) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/ui/src/pages/Exchanges/ExchangeList.module.css b/ui/src/pages/Exchanges/ExchangeList.module.css
new file mode 100644
index 00000000..7dfec372
--- /dev/null
+++ b/ui/src/pages/Exchanges/ExchangeList.module.css
@@ -0,0 +1,74 @@
+.list {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ height: 100%;
+ border-right: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.625rem 0.75rem;
+ cursor: pointer;
+ border-bottom: 1px solid var(--border-light);
+ font-size: 0.8125rem;
+ transition: background 0.1s;
+}
+
+.item:hover {
+ background: var(--surface-hover);
+}
+
+.itemSelected {
+ background: var(--surface-active);
+ border-left: 3px solid var(--amber);
+ padding-left: calc(0.75rem - 3px);
+}
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.dotOk { background: var(--success); }
+.dotErr { background: var(--error); }
+.dotRun { background: var(--running); }
+
+.meta {
+ flex: 1;
+ min-width: 0;
+}
+
+.exchangeId {
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.duration {
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.timestamp {
+ font-size: 0.6875rem;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.empty {
+ padding: 2rem;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 0.8125rem;
+}
diff --git a/ui/src/pages/Exchanges/ExchangeList.tsx b/ui/src/pages/Exchanges/ExchangeList.tsx
new file mode 100644
index 00000000..0cc83d6f
--- /dev/null
+++ b/ui/src/pages/Exchanges/ExchangeList.tsx
@@ -0,0 +1,56 @@
+import type { ExecutionSummary } from '../../api/types';
+import styles from './ExchangeList.module.css';
+
+interface ExchangeListProps {
+ exchanges: ExecutionSummary[];
+ selectedId?: string;
+ onSelect: (exchange: ExecutionSummary) => void;
+}
+
+function formatDuration(ms: number): string {
+ if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
+ return `${ms}ms`;
+}
+
+function formatTime(iso: string): string {
+ const d = new Date(iso);
+ const h = String(d.getHours()).padStart(2, '0');
+ const m = String(d.getMinutes()).padStart(2, '0');
+ const s = String(d.getSeconds()).padStart(2, '0');
+ return `${h}:${m}:${s}`;
+}
+
+function dotClass(status: string): string {
+ switch (status) {
+ case 'COMPLETED': return styles.dotOk;
+ case 'FAILED': return styles.dotErr;
+ case 'RUNNING': return styles.dotRun;
+ default: return styles.dotOk;
+ }
+}
+
+export function ExchangeList({ exchanges, selectedId, onSelect }: ExchangeListProps) {
+ if (exchanges.length === 0) {
+ return
No exchanges found
;
+ }
+
+ return (
+
+ {exchanges.map((ex) => (
+
onSelect(ex)}
+ >
+
+
+
{ex.executionId.slice(0, 12)}
+
+
{formatDuration(ex.durationMs)}
+
{formatTime(ex.startTime)}
+
+ ))}
+
+ );
+}
diff --git a/ui/src/pages/Exchanges/ExchangesPage.module.css b/ui/src/pages/Exchanges/ExchangesPage.module.css
new file mode 100644
index 00000000..138d8833
--- /dev/null
+++ b/ui/src/pages/Exchanges/ExchangesPage.module.css
@@ -0,0 +1,22 @@
+.threeColumn {
+ display: grid;
+ grid-template-columns: 280px 1fr;
+ height: 100%;
+ overflow: hidden;
+}
+
+.rightPanel {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100%;
+}
+
+.emptyRight {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--text-muted);
+ font-size: 0.875rem;
+}
diff --git a/ui/src/pages/Exchanges/ExchangesPage.tsx b/ui/src/pages/Exchanges/ExchangesPage.tsx
new file mode 100644
index 00000000..94153434
--- /dev/null
+++ b/ui/src/pages/Exchanges/ExchangesPage.tsx
@@ -0,0 +1,113 @@
+import { useState, useMemo, useCallback } from 'react';
+import { useParams } from 'react-router';
+import { useGlobalFilters } from '@cameleer/design-system';
+import { useSearchExecutions, useExecutionDetail } from '../../api/queries/executions';
+import { useDiagramByRoute } from '../../api/queries/diagrams';
+import { useRouteCatalog } from '../../api/queries/catalog';
+import type { ExecutionSummary } from '../../api/types';
+import { ExchangeList } from './ExchangeList';
+import { ExchangeHeader } from './ExchangeHeader';
+import { ExecutionDiagram } from '../../components/ExecutionDiagram/ExecutionDiagram';
+import { ProcessDiagram } from '../../components/ProcessDiagram';
+import styles from './ExchangesPage.module.css';
+
+// Lazy-import the full-width Dashboard for the no-route-scope view
+import Dashboard from '../Dashboard/Dashboard';
+
+export default function ExchangesPage() {
+ const { appId, routeId, exchangeId } = useParams<{
+ appId?: string; routeId?: string; exchangeId?: string;
+ }>();
+
+ // If no route is scoped, render the existing full-width Dashboard table
+ if (!routeId) {
+ return
;
+ }
+
+ // Route is scoped: render 3-column layout
+ return (
+
+ );
+}
+
+// ─── 3-column view when route is scoped ─────────────────────────────────────
+
+interface RouteExchangeViewProps {
+ appId: string;
+ routeId: string;
+ initialExchangeId?: string;
+}
+
+function RouteExchangeView({ appId, routeId, initialExchangeId }: RouteExchangeViewProps) {
+ const [selectedExchangeId, setSelectedExchangeId] = useState
(initialExchangeId);
+ const { timeRange } = useGlobalFilters();
+ const timeFrom = timeRange.start.toISOString();
+ const timeTo = timeRange.end.toISOString();
+
+ // Fetch exchanges for this route
+ const { data: searchResult } = useSearchExecutions(
+ { timeFrom, timeTo, routeId, application: appId, sortField: 'startTime', sortDir: 'desc', offset: 0, limit: 50 },
+ true,
+ );
+ const exchanges: ExecutionSummary[] = searchResult?.data || [];
+
+ // Fetch execution detail for selected exchange
+ const { data: detail } = useExecutionDetail(selectedExchangeId ?? null);
+
+ // Fetch diagram for topology-only view (when no exchange selected)
+ const diagramQuery = useDiagramByRoute(appId, routeId);
+
+ // Known route IDs for drill-down resolution
+ 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]);
+
+ const handleExchangeSelect = useCallback((ex: ExecutionSummary) => {
+ setSelectedExchangeId(ex.executionId);
+ }, []);
+
+ return (
+
+
+
+
+ {selectedExchangeId && detail ? (
+ <>
+
+
+ >
+ ) : (
+ diagramQuery.data ? (
+
+ ) : (
+
+ Select an exchange to view execution details
+
+ )
+ )}
+
+
+ );
+}
diff --git a/ui/src/pages/RuntimeTab/RuntimePage.tsx b/ui/src/pages/RuntimeTab/RuntimePage.tsx
new file mode 100644
index 00000000..a55a4c86
--- /dev/null
+++ b/ui/src/pages/RuntimeTab/RuntimePage.tsx
@@ -0,0 +1,17 @@
+import { useParams } from 'react-router';
+import { lazy, Suspense } from 'react';
+import { Spinner } from '@cameleer/design-system';
+
+const AgentHealth = lazy(() => import('../AgentHealth/AgentHealth'));
+const AgentInstance = lazy(() => import('../AgentInstance/AgentInstance'));
+
+const Fallback =
;
+
+export default function RuntimePage() {
+ const { instanceId } = useParams<{ appId?: string; instanceId?: string }>();
+
+ if (instanceId) {
+ return ;
+ }
+ return ;
+}
diff --git a/ui/src/router.tsx b/ui/src/router.tsx
index 48eadc04..8c9383f8 100644
--- a/ui/src/router.tsx
+++ b/ui/src/router.tsx
@@ -1,4 +1,4 @@
-import { createBrowserRouter, Navigate } from 'react-router';
+import { createBrowserRouter, Navigate, useParams } from 'react-router';
import { ProtectedRoute } from './auth/ProtectedRoute';
import { LoginPage } from './auth/LoginPage';
import { OidcCallback } from './auth/OidcCallback';
@@ -6,12 +6,9 @@ import { LayoutShell } from './components/LayoutShell';
import { lazy, Suspense } from 'react';
import { Spinner } from '@cameleer/design-system';
-const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard'));
-const ExchangeDetail = lazy(() => import('./pages/ExchangeDetail/ExchangeDetail'));
-const RoutesMetrics = lazy(() => import('./pages/Routes/RoutesMetrics'));
-const RouteDetail = lazy(() => import('./pages/Routes/RouteDetail'));
-const AgentHealth = lazy(() => import('./pages/AgentHealth/AgentHealth'));
-const AgentInstance = lazy(() => import('./pages/AgentInstance/AgentInstance'));
+const ExchangesPage = lazy(() => import('./pages/Exchanges/ExchangesPage'));
+const DashboardPage = lazy(() => import('./pages/DashboardTab/DashboardPage'));
+const RuntimePage = lazy(() => import('./pages/RuntimeTab/RuntimePage'));
const AdminLayout = lazy(() => import('./pages/Admin/AdminLayout'));
const RbacPage = lazy(() => import('./pages/Admin/RbacPage'));
const AuditLogPage = lazy(() => import('./pages/Admin/AuditLogPage'));
@@ -29,6 +26,20 @@ function SuspenseWrapper({ children }: { children: React.ReactNode }) {
);
}
+/** Redirect legacy /apps/:appId/:routeId paths to /exchanges/:appId/:routeId */
+function LegacyAppRedirect() {
+ const { appId, routeId } = useParams<{ appId: string; routeId?: string }>();
+ const path = routeId ? `/exchanges/${appId}/${routeId}` : `/exchanges/${appId}`;
+ return ;
+}
+
+/** Redirect legacy /agents/:appId/:instanceId paths to /runtime/:appId/:instanceId */
+function LegacyAgentRedirect() {
+ const { appId, instanceId } = useParams<{ appId: string; instanceId?: string }>();
+ const path = instanceId ? `/runtime/${appId}/${instanceId}` : `/runtime/${appId}`;
+ return ;
+}
+
export const router = createBrowserRouter([
{ path: '/login', element: },
{ path: '/oidc/callback', element: },
@@ -38,17 +49,34 @@ export const router = createBrowserRouter([
{
element: ,
children: [
- { index: true, element: },
- { path: 'apps', element: },
- { path: 'apps/:appId', element: },
- { path: 'apps/:appId/:routeId', element: },
- { path: 'exchanges/:id', element: },
- { path: 'routes', element: },
- { path: 'routes/:appId', element: },
- { path: 'routes/:appId/:routeId', element: },
- { path: 'agents', element: },
- { path: 'agents/:appId', element: },
- { path: 'agents/:appId/:instanceId', element: },
+ // Default redirect
+ { index: true, element: },
+
+ // Exchanges tab
+ { path: 'exchanges', element: },
+ { path: 'exchanges/:appId', element: },
+ { path: 'exchanges/:appId/:routeId', element: },
+ { path: 'exchanges/:appId/:routeId/:exchangeId', element: },
+
+ // Dashboard tab
+ { path: 'dashboard', element: },
+ { path: 'dashboard/:appId', element: },
+ { path: 'dashboard/:appId/:routeId', element: },
+
+ // Runtime tab
+ { path: 'runtime', element: },
+ { path: 'runtime/:appId', element: },
+ { path: 'runtime/:appId/:instanceId', element: },
+
+ // Legacy redirects — Sidebar uses hardcoded /apps/... and /agents/... paths
+ { path: 'apps', element: },
+ { path: 'apps/:appId', element: },
+ { path: 'apps/:appId/:routeId', element: },
+ { path: 'agents', element: },
+ { path: 'agents/:appId', element: },
+ { path: 'agents/:appId/:instanceId', element: },
+
+ // Admin (unchanged)
{
path: 'admin',
element: ,