Merge pull request 'feat/navigation-redesign' (#92) from feat/navigation-redesign into main
Reviewed-on: cameleer/cameleer3-server#92
This commit is contained in:
5
ui/src/components/ContentTabs.module.css
Normal file
5
ui/src/components/ContentTabs.module.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.wrapper {
|
||||
padding: 0 1.5rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
26
ui/src/components/ContentTabs.tsx
Normal file
26
ui/src/components/ContentTabs.tsx
Normal file
@@ -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 (
|
||||
<div className={styles.wrapper}>
|
||||
<SegmentedTabs
|
||||
tabs={TABS}
|
||||
active={active}
|
||||
onChange={(v) => onChange(v as TabKey)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 <Link> 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 (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
apps={sidebarApps}
|
||||
/>
|
||||
<div onClick={handleSidebarClick}>
|
||||
<Sidebar apps={sidebarApps} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TopBar
|
||||
@@ -229,7 +248,17 @@ function LayoutContent() {
|
||||
onQueryChange={setPaletteQuery}
|
||||
data={searchData}
|
||||
/>
|
||||
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
||||
|
||||
{!isAdminPage && (
|
||||
<>
|
||||
<ContentTabs active={scope.tab} onChange={setTab} />
|
||||
<div style={{ padding: '0 1.5rem', paddingTop: '0.5rem' }}>
|
||||
<ScopeTrail scope={scope} onNavigate={(path) => navigate(path)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<main style={{ flex: 1, overflow: 'auto', padding: isAdminPage ? '1.5rem' : '0.75rem 1.5rem' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</AppShell>
|
||||
|
||||
40
ui/src/components/ScopeTrail.module.css
Normal file
40
ui/src/components/ScopeTrail.module.css
Normal file
@@ -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;
|
||||
}
|
||||
38
ui/src/components/ScopeTrail.tsx
Normal file
38
ui/src/components/ScopeTrail.tsx
Normal file
@@ -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 (
|
||||
<nav className={styles.trail}>
|
||||
{segments.map((seg, i) => (
|
||||
<span key={seg.path} className={styles.segment}>
|
||||
{i > 0 && <span className={styles.separator}>></span>}
|
||||
{i < segments.length - 1 ? (
|
||||
<button className={styles.link} onClick={() => onNavigate(seg.path)}>
|
||||
{seg.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.current}>{seg.label}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
68
ui/src/hooks/useScope.ts
Normal file
68
ui/src/hooks/useScope.ts
Normal file
@@ -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<TabKey>(['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 };
|
||||
}
|
||||
@@ -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}`)
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
@@ -461,7 +461,7 @@ export default function Dashboard() {
|
||||
<div className={styles.panelSection}>
|
||||
<button
|
||||
className={styles.openDetailLink}
|
||||
onClick={() => navigate(`/exchanges/${detail.executionId}`)}
|
||||
onClick={() => navigate(`/exchanges/${detail.applicationName}/${detail.routeId}/${detail.executionId}`)}
|
||||
>
|
||||
Open full details →
|
||||
</button>
|
||||
|
||||
17
ui/src/pages/DashboardTab/DashboardPage.tsx
Normal file
17
ui/src/pages/DashboardTab/DashboardPage.tsx
Normal file
@@ -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 = <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { routeId } = useParams<{ appId?: string; routeId?: string }>();
|
||||
|
||||
if (routeId) {
|
||||
return <Suspense fallback={Fallback}><RouteDetail /></Suspense>;
|
||||
}
|
||||
return <Suspense fallback={Fallback}><RoutesMetrics /></Suspense>;
|
||||
}
|
||||
85
ui/src/pages/Exchanges/ExchangeHeader.tsx
Normal file
85
ui/src/pages/Exchanges/ExchangeHeader.tsx
Normal file
@@ -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 (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: '0.5rem',
|
||||
padding: '0.75rem', borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface)', fontSize: '0.8125rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<StatusDot variant={statusVariant(detail.status)} />
|
||||
<MonoText size="xs">{detail.exchangeId || detail.executionId}</MonoText>
|
||||
<Badge label={detail.status === 'COMPLETED' ? 'OK' : detail.status} color={badgeColor(detail.status)} />
|
||||
<span style={{ color: 'var(--text-muted)' }}>{detail.routeId}</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
|
||||
{formatDuration(detail.durationMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showChain && (
|
||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', marginRight: '0.25rem' }}>
|
||||
Correlated:
|
||||
</span>
|
||||
{chain.map((e) => (
|
||||
<button
|
||||
key={e.executionId}
|
||||
onClick={() => onExchangeClick?.(e.executionId)}
|
||||
style={{
|
||||
background: e.executionId === detail.executionId ? 'var(--surface-active)' : 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
padding: '0.125rem 0.375rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.6875rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: e.status === 'FAILED' ? 'var(--error)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{e.executionId.slice(0, 8)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
ui/src/pages/Exchanges/ExchangeList.module.css
Normal file
74
ui/src/pages/Exchanges/ExchangeList.module.css
Normal file
@@ -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;
|
||||
}
|
||||
56
ui/src/pages/Exchanges/ExchangeList.tsx
Normal file
56
ui/src/pages/Exchanges/ExchangeList.tsx
Normal file
@@ -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 <div className={styles.empty}>No exchanges found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{exchanges.map((ex) => (
|
||||
<div
|
||||
key={ex.executionId}
|
||||
className={`${styles.item} ${selectedId === ex.executionId ? styles.itemSelected : ''}`}
|
||||
onClick={() => onSelect(ex)}
|
||||
>
|
||||
<span className={`${styles.dot} ${dotClass(ex.status)}`} />
|
||||
<div className={styles.meta}>
|
||||
<div className={styles.exchangeId}>{ex.executionId.slice(0, 12)}</div>
|
||||
</div>
|
||||
<span className={styles.duration}>{formatDuration(ex.durationMs)}</span>
|
||||
<span className={styles.timestamp}>{formatTime(ex.startTime)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
ui/src/pages/Exchanges/ExchangesPage.module.css
Normal file
22
ui/src/pages/Exchanges/ExchangesPage.module.css
Normal file
@@ -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;
|
||||
}
|
||||
113
ui/src/pages/Exchanges/ExchangesPage.tsx
Normal file
113
ui/src/pages/Exchanges/ExchangesPage.tsx
Normal file
@@ -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 <Dashboard />;
|
||||
}
|
||||
|
||||
// Route is scoped: render 3-column layout
|
||||
return (
|
||||
<RouteExchangeView appId={appId!} routeId={routeId} initialExchangeId={exchangeId} />
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<string | undefined>(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<string>();
|
||||
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 (
|
||||
<div className={styles.threeColumn}>
|
||||
<ExchangeList
|
||||
exchanges={exchanges}
|
||||
selectedId={selectedExchangeId}
|
||||
onSelect={handleExchangeSelect}
|
||||
/>
|
||||
|
||||
<div className={styles.rightPanel}>
|
||||
{selectedExchangeId && detail ? (
|
||||
<>
|
||||
<ExchangeHeader detail={detail} />
|
||||
<ExecutionDiagram
|
||||
executionId={selectedExchangeId}
|
||||
executionDetail={detail}
|
||||
knownRouteIds={knownRouteIds}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
diagramQuery.data ? (
|
||||
<ProcessDiagram
|
||||
application={appId}
|
||||
routeId={routeId}
|
||||
diagramLayout={diagramQuery.data}
|
||||
knownRouteIds={knownRouteIds}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.emptyRight}>
|
||||
Select an exchange to view execution details
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
ui/src/pages/RuntimeTab/RuntimePage.tsx
Normal file
17
ui/src/pages/RuntimeTab/RuntimePage.tsx
Normal file
@@ -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 = <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
|
||||
|
||||
export default function RuntimePage() {
|
||||
const { instanceId } = useParams<{ appId?: string; instanceId?: string }>();
|
||||
|
||||
if (instanceId) {
|
||||
return <Suspense fallback={Fallback}><AgentInstance /></Suspense>;
|
||||
}
|
||||
return <Suspense fallback={Fallback}><AgentHealth /></Suspense>;
|
||||
}
|
||||
@@ -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 <Navigate to={path} replace />;
|
||||
}
|
||||
|
||||
/** 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 <Navigate to={path} replace />;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/oidc/callback', element: <OidcCallback /> },
|
||||
@@ -38,17 +49,34 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
element: <LayoutShell />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/apps" replace /> },
|
||||
{ path: 'apps', element: <SuspenseWrapper><Dashboard /></SuspenseWrapper> },
|
||||
{ path: 'apps/:appId', element: <SuspenseWrapper><Dashboard /></SuspenseWrapper> },
|
||||
{ path: 'apps/:appId/:routeId', element: <SuspenseWrapper><Dashboard /></SuspenseWrapper> },
|
||||
{ path: 'exchanges/:id', element: <SuspenseWrapper><ExchangeDetail /></SuspenseWrapper> },
|
||||
{ path: 'routes', element: <SuspenseWrapper><RoutesMetrics /></SuspenseWrapper> },
|
||||
{ path: 'routes/:appId', element: <SuspenseWrapper><RoutesMetrics /></SuspenseWrapper> },
|
||||
{ path: 'routes/:appId/:routeId', element: <SuspenseWrapper><RouteDetail /></SuspenseWrapper> },
|
||||
{ path: 'agents', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
|
||||
{ path: 'agents/:appId', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
|
||||
{ path: 'agents/:appId/:instanceId', element: <SuspenseWrapper><AgentInstance /></SuspenseWrapper> },
|
||||
// Default redirect
|
||||
{ index: true, element: <Navigate to="/exchanges" replace /> },
|
||||
|
||||
// Exchanges tab
|
||||
{ path: 'exchanges', element: <SuspenseWrapper><ExchangesPage /></SuspenseWrapper> },
|
||||
{ path: 'exchanges/:appId', element: <SuspenseWrapper><ExchangesPage /></SuspenseWrapper> },
|
||||
{ path: 'exchanges/:appId/:routeId', element: <SuspenseWrapper><ExchangesPage /></SuspenseWrapper> },
|
||||
{ path: 'exchanges/:appId/:routeId/:exchangeId', element: <SuspenseWrapper><ExchangesPage /></SuspenseWrapper> },
|
||||
|
||||
// Dashboard tab
|
||||
{ path: 'dashboard', element: <SuspenseWrapper><DashboardPage /></SuspenseWrapper> },
|
||||
{ path: 'dashboard/:appId', element: <SuspenseWrapper><DashboardPage /></SuspenseWrapper> },
|
||||
{ path: 'dashboard/:appId/:routeId', element: <SuspenseWrapper><DashboardPage /></SuspenseWrapper> },
|
||||
|
||||
// Runtime tab
|
||||
{ path: 'runtime', element: <SuspenseWrapper><RuntimePage /></SuspenseWrapper> },
|
||||
{ path: 'runtime/:appId', element: <SuspenseWrapper><RuntimePage /></SuspenseWrapper> },
|
||||
{ path: 'runtime/:appId/:instanceId', element: <SuspenseWrapper><RuntimePage /></SuspenseWrapper> },
|
||||
|
||||
// Legacy redirects — Sidebar uses hardcoded /apps/... and /agents/... paths
|
||||
{ path: 'apps', element: <Navigate to="/exchanges" replace /> },
|
||||
{ path: 'apps/:appId', element: <LegacyAppRedirect /> },
|
||||
{ path: 'apps/:appId/:routeId', element: <LegacyAppRedirect /> },
|
||||
{ path: 'agents', element: <Navigate to="/runtime" replace /> },
|
||||
{ path: 'agents/:appId', element: <LegacyAgentRedirect /> },
|
||||
{ path: 'agents/:appId/:instanceId', element: <LegacyAgentRedirect /> },
|
||||
|
||||
// Admin (unchanged)
|
||||
{
|
||||
path: 'admin',
|
||||
element: <SuspenseWrapper><AdminLayout /></SuspenseWrapper>,
|
||||
|
||||
Reference in New Issue
Block a user