diff --git a/ui/src/components/ContentTabs.module.css b/ui/src/components/ContentTabs.module.css new file mode 100644 index 00000000..cd3c72d1 --- /dev/null +++ b/ui/src/components/ContentTabs.module.css @@ -0,0 +1,5 @@ +.wrapper { + padding: 0 1.5rem; + padding-top: 0.75rem; + padding-bottom: 0; +} diff --git a/ui/src/components/ContentTabs.tsx b/ui/src/components/ContentTabs.tsx new file mode 100644 index 00000000..e28b5bde --- /dev/null +++ b/ui/src/components/ContentTabs.tsx @@ -0,0 +1,26 @@ +import { SegmentedTabs } from '@cameleer/design-system'; +import type { TabKey } from '../hooks/useScope'; +import styles from './ContentTabs.module.css'; + +const TABS = [ + { label: 'Exchanges', value: 'exchanges' as const }, + { label: 'Dashboard', value: 'dashboard' as const }, + { label: 'Runtime', value: 'runtime' as const }, +]; + +interface ContentTabsProps { + active: TabKey; + onChange: (tab: TabKey) => void; +} + +export function ContentTabs({ active, onChange }: ContentTabsProps) { + return ( +
+ onChange(v as TabKey)} + /> +
+ ); +} diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 7112f01f..10dcb64f 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -6,6 +6,9 @@ import { useAgents } from '../api/queries/agents'; import { useSearchExecutions } from '../api/queries/executions'; import { useAuthStore } from '../auth/auth-store'; import { useState, useMemo, useCallback, useEffect } from 'react'; +import { ContentTabs } from './ContentTabs'; +import { ScopeTrail } from './ScopeTrail'; +import { useScope } from '../hooks/useScope'; function healthToColor(health: string): string { switch (health) { @@ -31,7 +34,7 @@ function buildSearchData( title: app.appId, badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToColor(app.health) }], meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`, - path: `/apps/${app.appId}`, + path: `/exchanges/${app.appId}`, }); for (const route of (app.routes || [])) { @@ -41,7 +44,7 @@ function buildSearchData( title: route.routeId, badges: [{ label: app.appId }], meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`, - path: `/apps/${app.appId}/${route.routeId}`, + path: `/exchanges/${app.appId}/${route.routeId}`, }); } } @@ -54,7 +57,7 @@ function buildSearchData( title: agent.name, badges: [{ label: (agent.state || 'unknown').toUpperCase(), color: healthToColor((agent.state || '').toLowerCase()) }], meta: `${agent.application} · ${agent.version || ''}${agent.agentTps != null ? ` · ${agent.agentTps.toFixed(1)} msg/s` : ''}`, - path: `/agents/${agent.application}/${agent.id}`, + path: `/runtime/${agent.application}/${agent.id}`, }); } } @@ -94,6 +97,7 @@ function LayoutContent() { const { data: agents } = useAgents(); const { username, logout } = useAuthStore(); const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette(); + const { scope, setTab } = useScope(); // Exchange full-text search via command palette const [paletteQuery, setPaletteQuery] = useState(''); @@ -115,12 +119,7 @@ function LayoutContent() { name: r.routeId, exchangeCount: r.exchangeCount, })), - agents: (app.agents || []).map((a: any) => ({ - id: a.id, - name: a.name, - status: a.status as 'live' | 'stale' | 'dead', - tps: a.tps, - })), + agents: [], })); }, [catalog]); @@ -136,7 +135,7 @@ function LayoutContent() { title: e.executionId, badges: [{ label: e.status, color: statusToColor(e.status) }], meta: `${e.routeId} · ${e.applicationName ?? ''} · ${formatDuration(e.durationMs)}`, - path: `/exchanges/${e.executionId}`, + path: `/exchanges/${e.applicationName ?? ''}/${e.routeId}/${e.executionId}`, serverFiltered: true, matchContext: e.highlight ?? undefined, })); @@ -154,7 +153,7 @@ function LayoutContent() { title: `${key} = "${value}"`, badges: [{ label: e.status, color: statusToColor(e.status) }], meta: `${e.executionId} · ${e.routeId} · ${e.applicationName ?? ''}`, - path: `/exchanges/${e.executionId}`, + path: `/exchanges/${e.applicationName ?? ''}/${e.routeId}/${e.executionId}`, serverFiltered: true, }); } @@ -165,14 +164,11 @@ function LayoutContent() { return [...catalogData, ...exchangeItems, ...attributeItems]; }, [catalogData, exchangeResults, debouncedQuery]); + const isAdminPage = location.pathname.startsWith('/admin'); const breadcrumb = useMemo(() => { + if (!isAdminPage) return []; const LABELS: Record = { - apps: 'Applications', - agents: 'Agents', - exchanges: 'Exchanges', - routes: 'Routes', admin: 'Admin', - 'api-docs': 'API Docs', rbac: 'Users & Roles', audit: 'Audit Log', oidc: 'OIDC', @@ -185,7 +181,7 @@ function LayoutContent() { label: LABELS[part] ?? part, ...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}), })); - }, [location.pathname]); + }, [location.pathname, isAdminPage]); const handleLogout = useCallback(() => { logout(); @@ -200,19 +196,42 @@ function LayoutContent() { }, [navigate, setPaletteOpen]); const handlePaletteSubmit = useCallback((query: string) => { - // Navigate to dashboard with full-text search applied - const currentPath = location.pathname; - // Stay on the current app/route context if we're already there - const basePath = currentPath.startsWith('/apps/') ? currentPath.split('/').slice(0, 4).join('/') : '/apps'; - navigate(`${basePath}?text=${encodeURIComponent(query)}`); - }, [navigate, location.pathname]); + const baseParts = ['/exchanges']; + if (scope.appId) baseParts.push(scope.appId); + if (scope.routeId) baseParts.push(scope.routeId); + navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`); + }, [navigate, scope.appId, scope.routeId]); + + // Intercept Sidebar's internal navigation to re-route through current tab + const handleSidebarClick = useCallback((e: React.MouseEvent) => { + const anchor = (e.target as HTMLElement).closest('a[href]'); + if (!anchor) return; + const href = anchor.getAttribute('href') || ''; + + // Intercept /apps/:appId and /apps/:appId/:routeId links + const appMatch = href.match(/^\/apps\/([^/]+)(?:\/(.+))?$/); + if (appMatch) { + e.preventDefault(); + const [, sAppId, sRouteId] = appMatch; + navigate(sRouteId ? `/${scope.tab}/${sAppId}/${sRouteId}` : `/${scope.tab}/${sAppId}`); + return; + } + + // Intercept /agents/* links — redirect to runtime tab + const agentMatch = href.match(/^\/agents\/([^/]+)(?:\/(.+))?$/); + if (agentMatch) { + e.preventDefault(); + const [, sAppId, sInstanceId] = agentMatch; + navigate(sInstanceId ? `/runtime/${sAppId}/${sInstanceId}` : `/runtime/${sAppId}`); + } + }, [navigate, scope.tab]); return ( +
+ +
} > -
+ + {!isAdminPage && ( + <> + +
+ navigate(path)} /> +
+ + )} + +
diff --git a/ui/src/components/ScopeTrail.module.css b/ui/src/components/ScopeTrail.module.css new file mode 100644 index 00000000..844c4b5b --- /dev/null +++ b/ui/src/components/ScopeTrail.module.css @@ -0,0 +1,40 @@ +.trail { + display: flex; + align-items: center; + gap: 0; + font-size: 0.8125rem; + color: var(--text-muted); + min-height: 1.5rem; +} + +.segment { + display: inline-flex; + align-items: center; +} + +.link { + color: var(--text-secondary); + text-decoration: none; + cursor: pointer; + background: none; + border: none; + padding: 0; + font: inherit; + font-size: 0.8125rem; +} + +.link:hover { + color: var(--amber); + text-decoration: underline; +} + +.separator { + margin: 0 0.375rem; + color: var(--text-muted); + user-select: none; +} + +.current { + color: var(--text-primary); + font-weight: 500; +} diff --git a/ui/src/components/ScopeTrail.tsx b/ui/src/components/ScopeTrail.tsx new file mode 100644 index 00000000..fa733661 --- /dev/null +++ b/ui/src/components/ScopeTrail.tsx @@ -0,0 +1,38 @@ +import type { Scope } from '../hooks/useScope'; +import styles from './ScopeTrail.module.css'; + +interface ScopeTrailProps { + scope: Scope; + onNavigate: (path: string) => void; +} + +export function ScopeTrail({ scope, onNavigate }: ScopeTrailProps) { + const segments: { label: string; path: string }[] = [ + { label: 'All Applications', path: `/${scope.tab}` }, + ]; + + if (scope.appId) { + segments.push({ label: scope.appId, path: `/${scope.tab}/${scope.appId}` }); + } + + if (scope.routeId) { + segments.push({ label: scope.routeId, path: `/${scope.tab}/${scope.appId}/${scope.routeId}` }); + } + + return ( + + ); +} diff --git a/ui/src/hooks/useScope.ts b/ui/src/hooks/useScope.ts new file mode 100644 index 00000000..a466fb3a --- /dev/null +++ b/ui/src/hooks/useScope.ts @@ -0,0 +1,68 @@ +// ui/src/hooks/useScope.ts +import { useParams, useNavigate, useLocation } from 'react-router'; +import { useCallback } from 'react'; + +export type TabKey = 'exchanges' | 'dashboard' | 'runtime'; + +const VALID_TABS = new Set(['exchanges', 'dashboard', 'runtime']); + +export interface Scope { + tab: TabKey; + appId?: string; + routeId?: string; + exchangeId?: string; +} + +export function useScope() { + const params = useParams<{ tab?: string; appId?: string; routeId?: string; exchangeId?: string }>(); + const navigate = useNavigate(); + const location = useLocation(); + + // Derive tab from first URL segment — fallback to 'exchanges' + const rawTab = location.pathname.split('/').filter(Boolean)[0] ?? 'exchanges'; + const tab: TabKey = VALID_TABS.has(rawTab as TabKey) ? (rawTab as TabKey) : 'exchanges'; + + const scope: Scope = { + tab, + appId: params.appId, + routeId: params.routeId, + exchangeId: params.exchangeId, + }; + + const setTab = useCallback((newTab: TabKey) => { + const parts = ['', newTab]; + if (scope.appId) parts.push(scope.appId); + if (scope.routeId) parts.push(scope.routeId); + navigate(parts.join('/')); + }, [navigate, scope.appId, scope.routeId]); + + const setApp = useCallback((appId: string | undefined) => { + if (!appId) { + navigate(`/${tab}`); + } else { + navigate(`/${tab}/${appId}`); + } + }, [navigate, tab]); + + const setRoute = useCallback((appId: string, routeId: string | undefined) => { + if (!routeId) { + navigate(`/${tab}/${appId}`); + } else { + navigate(`/${tab}/${appId}/${routeId}`); + } + }, [navigate, tab]); + + const setExchange = useCallback((appId: string, routeId: string, exchangeId: string | undefined) => { + if (!exchangeId) { + navigate(`/${tab}/${appId}/${routeId}`); + } else { + navigate(`/${tab}/${appId}/${routeId}/${exchangeId}`); + } + }, [navigate, tab]); + + const clearScope = useCallback(() => { + navigate(`/${tab}`); + }, [navigate, tab]); + + return { scope, setTab, setApp, setRoute, setExchange, clearScope }; +} diff --git a/ui/src/pages/Dashboard/Dashboard.tsx b/ui/src/pages/Dashboard/Dashboard.tsx index 16862364..3ade0a1d 100644 --- a/ui/src/pages/Dashboard/Dashboard.tsx +++ b/ui/src/pages/Dashboard/Dashboard.tsx @@ -345,7 +345,7 @@ export default function Dashboard() { title="Inspect exchange" onClick={(e) => { e.stopPropagation() - navigate(`/exchanges/${row.executionId}`) + navigate(`/exchanges/${row.applicationName}/${row.routeId}/${row.executionId}`) }} > @@ -461,7 +461,7 @@ export default function Dashboard() {
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: ,