1302 lines
41 KiB
Markdown
1302 lines
41 KiB
Markdown
|
|
# Navigation Redesign Implementation Plan
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Redesign the navigation from page-based routing to a scope-based model with three content tabs (Exchanges, Dashboard, Runtime) and a scoping sidebar.
|
||
|
|
|
||
|
|
**Architecture:** The sidebar becomes a scope filter (app -> route), not a navigator. Three content-level tabs (using SegmentedTabs from design system) switch the view. The URL structure changes from `/apps/:appId` to `/:tab/:appId`. The Exchanges tab transitions between full-width table (no route scope) and 3-column layout (route-scoped: exchange list | exchange header + diagram + details).
|
||
|
|
|
||
|
|
**Tech Stack:** React 18, React Router v7, TypeScript, @cameleer/design-system v0.1.18, CSS Modules, TanStack Query
|
||
|
|
|
||
|
|
**Spec:** `docs/superpowers/specs/2026-03-28-navigation-redesign-design.md`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File Structure
|
||
|
|
|
||
|
|
**New files (create):**
|
||
|
|
|
||
|
|
| File | Responsibility |
|
||
|
|
|------|---------------|
|
||
|
|
| `ui/src/hooks/useScope.ts` | Parse tab/appId/routeId/exchangeId from URL, provide navigation helpers |
|
||
|
|
| `ui/src/components/ScopeTrail.tsx` | Clickable scope trail (replaces breadcrumbs): All > app > route |
|
||
|
|
| `ui/src/components/ScopeTrail.module.css` | Scope trail styling |
|
||
|
|
| `ui/src/components/ContentTabs.tsx` | Tab bar (Exchanges \| Dashboard \| Runtime) using SegmentedTabs |
|
||
|
|
| `ui/src/components/ContentTabs.module.css` | Tab bar positioning/spacing |
|
||
|
|
| `ui/src/pages/Exchanges/ExchangesPage.tsx` | Orchestrates full-width table vs 3-column layout |
|
||
|
|
| `ui/src/pages/Exchanges/ExchangesPage.module.css` | 3-column grid, exchange list, right panel |
|
||
|
|
| `ui/src/pages/Exchanges/ExchangeList.tsx` | Compact exchange list for left column of 3-column view |
|
||
|
|
| `ui/src/pages/Exchanges/ExchangeHeader.tsx` | Exchange summary + correlation chain for right panel top |
|
||
|
|
| `ui/src/pages/RuntimeTab/RuntimePage.tsx` | Thin wrapper: renders AgentHealth or AgentInstance |
|
||
|
|
| `ui/src/pages/DashboardTab/DashboardPage.tsx` | Thin wrapper: renders RoutesMetrics or RouteDetail |
|
||
|
|
|
||
|
|
**Modified files:**
|
||
|
|
|
||
|
|
| File | Changes |
|
||
|
|
|------|---------|
|
||
|
|
| `ui/src/router.tsx` | New URL structure with 3 tab paths + scope params |
|
||
|
|
| `ui/src/components/LayoutShell.tsx` | Add ContentTabs + ScopeTrail, intercept sidebar navigation, remove agents from sidebar data |
|
||
|
|
|
||
|
|
**Unchanged files (reused as-is):**
|
||
|
|
|
||
|
|
| File | Used by |
|
||
|
|
|------|---------|
|
||
|
|
| `ui/src/pages/Dashboard/Dashboard.tsx` | ExchangesPage (full-width mode) |
|
||
|
|
| `ui/src/pages/AgentHealth/AgentHealth.tsx` | RuntimePage |
|
||
|
|
| `ui/src/pages/AgentInstance/AgentInstance.tsx` | RuntimePage |
|
||
|
|
| `ui/src/pages/Routes/RoutesMetrics.tsx` | DashboardPage |
|
||
|
|
| `ui/src/pages/Routes/RouteDetail.tsx` | DashboardPage |
|
||
|
|
| `ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx` | ExchangesPage (3-column mode) |
|
||
|
|
| `ui/src/components/ProcessDiagram/ProcessDiagram.tsx` | ExchangesPage (topology-only when no exchange selected) |
|
||
|
|
| `ui/src/pages/Admin/AdminLayout.tsx` | Unchanged |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Design System Notes
|
||
|
|
|
||
|
|
**Sidebar navigation interception:** The current `Sidebar` component has hardcoded `<Link>` paths (`/apps/:appId`, `/agents/:appId/:instanceId`). Since `SidebarProps` has no `onNavigate` callback, we intercept clicks via event delegation on a wrapper `<div>`, preventing default Link navigation and re-routing to the current tab's URL. This is a pragmatic workaround. A proper `onNavigate` prop should be added to the design system in a future update.
|
||
|
|
|
||
|
|
**Agents removed from sidebar:** Pass `agents: []` in `SidebarApp` data. The Sidebar component renders nothing for empty agent arrays.
|
||
|
|
|
||
|
|
**SegmentedTabs:** Available from `@cameleer/design-system`. Interface: `SegmentedTabs({ tabs: TabItem[], active: string, onChange: (value: string) => void })` where `TabItem = { label: ReactNode; count?: number; value: string }`.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 1: Create useScope hook
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `ui/src/hooks/useScope.ts`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the hook**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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) => {
|
||
|
|
// Preserve scope when switching tabs (except exchangeId which is tab-specific)
|
||
|
|
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 };
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify build**
|
||
|
|
|
||
|
|
Run: `cd ui && npx tsc --noEmit`
|
||
|
|
Expected: No type errors
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/hooks/useScope.ts
|
||
|
|
git commit -m "feat(ui): add useScope hook for tab+scope URL management"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 2: Create ScopeTrail component
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `ui/src/components/ScopeTrail.tsx`
|
||
|
|
- Create: `ui/src/components/ScopeTrail.module.css`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the CSS module**
|
||
|
|
|
||
|
|
```css
|
||
|
|
/* ui/src/components/ScopeTrail.module.css */
|
||
|
|
.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;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the component**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ui/src/components/ScopeTrail.tsx
|
||
|
|
import type { Scope, TabKey } 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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify build**
|
||
|
|
|
||
|
|
Run: `cd ui && npx tsc --noEmit`
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/components/ScopeTrail.tsx ui/src/components/ScopeTrail.module.css
|
||
|
|
git commit -m "feat(ui): add ScopeTrail component for scope-based breadcrumbs"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 3: Create ContentTabs component
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `ui/src/components/ContentTabs.tsx`
|
||
|
|
- Create: `ui/src/components/ContentTabs.module.css`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the CSS module**
|
||
|
|
|
||
|
|
```css
|
||
|
|
/* ui/src/components/ContentTabs.module.css */
|
||
|
|
.wrapper {
|
||
|
|
padding: 0 1.5rem;
|
||
|
|
padding-top: 0.75rem;
|
||
|
|
padding-bottom: 0;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the component**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ui/src/components/ContentTabs.tsx
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify build**
|
||
|
|
|
||
|
|
Run: `cd ui && npx tsc --noEmit`
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/components/ContentTabs.tsx ui/src/components/ContentTabs.module.css
|
||
|
|
git commit -m "feat(ui): add ContentTabs component (Exchanges | Dashboard | Runtime)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 4: Create ExchangeList component
|
||
|
|
|
||
|
|
Compact exchange list for the left column of the 3-column Exchanges layout.
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `ui/src/pages/Exchanges/ExchangeList.tsx`
|
||
|
|
- Create: `ui/src/pages/Exchanges/ExchangeList.module.css`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the CSS module**
|
||
|
|
|
||
|
|
```css
|
||
|
|
/* ui/src/pages/Exchanges/ExchangeList.module.css */
|
||
|
|
.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;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the component**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ui/src/pages/Exchanges/ExchangeList.tsx
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify build**
|
||
|
|
|
||
|
|
Run: `cd ui && npx tsc --noEmit`
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/pages/Exchanges/ExchangeList.tsx ui/src/pages/Exchanges/ExchangeList.module.css
|
||
|
|
git commit -m "feat(ui): add ExchangeList compact component for 3-column layout"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 5: Create ExchangeHeader component
|
||
|
|
|
||
|
|
Compact exchange summary + correlation chain for the top of the right panel.
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `ui/src/pages/Exchanges/ExchangeHeader.tsx`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the component**
|
||
|
|
|
||
|
|
This component extracts the exchange header pattern from `ExchangeDetail.tsx` (lines ~1-50 of the header card section).
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ui/src/pages/Exchanges/ExchangeHeader.tsx
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
function statusVariant(s: string): 'success' | 'error' | 'running' | 'warning' {
|
||
|
|
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: chain } = useCorrelationChain(detail.correlationId ?? null);
|
||
|
|
const correlatedExchanges = (chain ?? []).filter((e: any) => 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={statusVariant(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>
|
||
|
|
|
||
|
|
{/* Correlation chain */}
|
||
|
|
{correlatedExchanges.length > 1 && (
|
||
|
|
<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>
|
||
|
|
{correlatedExchanges.map((e: any) => (
|
||
|
|
<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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Note:** This uses inline styles intentionally since it's a compact, one-off header. If it grows, extract to a CSS module.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Check that `useCorrelationChain` exists**
|
||
|
|
|
||
|
|
Run: `cd ui && grep -r "useCorrelationChain" src/api/queries/`
|
||
|
|
|
||
|
|
Expected: Found in `correlation.ts`. If not found, check `executions.ts` for correlation query.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify build**
|
||
|
|
|
||
|
|
Run: `cd ui && npx tsc --noEmit`
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/pages/Exchanges/ExchangeHeader.tsx
|
||
|
|
git commit -m "feat(ui): add ExchangeHeader component with correlation chain"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 6: Create ExchangesPage
|
||
|
|
|
||
|
|
Orchestrates full-width table (no route scope) vs 3-column layout (route-scoped).
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `ui/src/pages/Exchanges/ExchangesPage.tsx`
|
||
|
|
- Create: `ui/src/pages/Exchanges/ExchangesPage.module.css`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the CSS module**
|
||
|
|
|
||
|
|
```css
|
||
|
|
/* ui/src/pages/Exchanges/ExchangesPage.module.css */
|
||
|
|
.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;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the page component**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ui/src/pages/Exchanges/ExchangesPage.tsx
|
||
|
|
import { useState, useMemo, useCallback } from 'react';
|
||
|
|
import { useParams } from 'react-router';
|
||
|
|
import { useGlobalFilters } from '@cameleer/design-system';
|
||
|
|
import { useSearchExecutions } 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 { useExecutionDetail } from '../../api/queries/executions';
|
||
|
|
import styles from './ExchangesPage.module.css';
|
||
|
|
|
||
|
|
// Lazy-import the full-width Dashboard for the no-route-scope view.
|
||
|
|
// This avoids duplicating the KPI strip + DataTable + detail panel logic.
|
||
|
|
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.
|
||
|
|
// Dashboard already reads appId/routeId from useParams().
|
||
|
|
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) {
|
||
|
|
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}>
|
||
|
|
{/* Left column: exchange list */}
|
||
|
|
<ExchangeList
|
||
|
|
exchanges={exchanges}
|
||
|
|
selectedId={selectedExchangeId}
|
||
|
|
onSelect={handleExchangeSelect}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Right column: exchange header + diagram + detail */}
|
||
|
|
<div className={styles.rightPanel}>
|
||
|
|
{selectedExchangeId && detail ? (
|
||
|
|
<>
|
||
|
|
<ExchangeHeader detail={detail} />
|
||
|
|
<ExecutionDiagram
|
||
|
|
executionId={selectedExchangeId}
|
||
|
|
executionDetail={detail}
|
||
|
|
knownRouteIds={knownRouteIds}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
/* No exchange selected: show topology-only diagram */
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify build**
|
||
|
|
|
||
|
|
Run: `cd ui && npx tsc --noEmit`
|
||
|
|
|
||
|
|
Fix any type errors. Common issues:
|
||
|
|
- `useDiagramByRoute` might need different params — check `ui/src/api/queries/diagrams.ts`
|
||
|
|
- `ExecutionSummary` import path — check `ui/src/api/types.ts`
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/pages/Exchanges/
|
||
|
|
git commit -m "feat(ui): add ExchangesPage with full-width and 3-column modes"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 7: Create RuntimePage and DashboardPage wrappers
|
||
|
|
|
||
|
|
Thin wrappers that render the existing page components based on URL params.
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `ui/src/pages/RuntimeTab/RuntimePage.tsx`
|
||
|
|
- Create: `ui/src/pages/DashboardTab/DashboardPage.tsx`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create RuntimePage**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ui/src/pages/RuntimeTab/RuntimePage.tsx
|
||
|
|
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 is present, show agent instance detail; otherwise show agent health overview
|
||
|
|
if (instanceId) {
|
||
|
|
return <Suspense fallback={Fallback}><AgentInstance /></Suspense>;
|
||
|
|
}
|
||
|
|
return <Suspense fallback={Fallback}><AgentHealth /></Suspense>;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create DashboardPage**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ui/src/pages/DashboardTab/DashboardPage.tsx
|
||
|
|
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 is present, show route detail; otherwise show routes metrics overview
|
||
|
|
if (routeId) {
|
||
|
|
return <Suspense fallback={Fallback}><RouteDetail /></Suspense>;
|
||
|
|
}
|
||
|
|
return <Suspense fallback={Fallback}><RoutesMetrics /></Suspense>;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify build**
|
||
|
|
|
||
|
|
Run: `cd ui && npx tsc --noEmit`
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/pages/RuntimeTab/ ui/src/pages/DashboardTab/
|
||
|
|
git commit -m "feat(ui): add RuntimePage and DashboardPage tab wrappers"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 8: Update router with new URL structure
|
||
|
|
|
||
|
|
Replace the old page-based routes with the new tab-based structure.
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `ui/src/router.tsx`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Rewrite router.tsx**
|
||
|
|
|
||
|
|
Replace the content of `ui/src/router.tsx` with:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ui/src/router.tsx
|
||
|
|
import { createBrowserRouter, Navigate, useParams } from 'react-router';
|
||
|
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||
|
|
import { LoginPage } from './auth/LoginPage';
|
||
|
|
import { OidcCallback } from './auth/OidcCallback';
|
||
|
|
import { LayoutShell } from './components/LayoutShell';
|
||
|
|
import { lazy, Suspense } from 'react';
|
||
|
|
import { Spinner } from '@cameleer/design-system';
|
||
|
|
|
||
|
|
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'));
|
||
|
|
const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
||
|
|
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||
|
|
const OpenSearchAdminPage = lazy(() => import('./pages/Admin/OpenSearchAdminPage'));
|
||
|
|
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
|
||
|
|
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||
|
|
|
||
|
|
function SuspenseWrapper({ children }: { children: React.ReactNode }) {
|
||
|
|
return (
|
||
|
|
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>}>
|
||
|
|
{children}
|
||
|
|
</Suspense>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export const router = createBrowserRouter([
|
||
|
|
{ path: '/login', element: <LoginPage /> },
|
||
|
|
{ path: '/oidc/callback', element: <OidcCallback /> },
|
||
|
|
{
|
||
|
|
element: <ProtectedRoute />,
|
||
|
|
children: [
|
||
|
|
{
|
||
|
|
element: <LayoutShell />,
|
||
|
|
children: [
|
||
|
|
// 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 /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 /> },
|
||
|
|
|
||
|
|
// Old exchange detail redirect
|
||
|
|
{ path: 'exchanges-old/:id', element: <Navigate to="/exchanges" replace /> },
|
||
|
|
|
||
|
|
// Admin (unchanged)
|
||
|
|
{
|
||
|
|
path: 'admin',
|
||
|
|
element: <SuspenseWrapper><AdminLayout /></SuspenseWrapper>,
|
||
|
|
children: [
|
||
|
|
{ index: true, element: <Navigate to="/admin/rbac" replace /> },
|
||
|
|
{ path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
|
||
|
|
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
|
||
|
|
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
||
|
|
{ path: 'appconfig', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
||
|
|
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||
|
|
{ path: 'opensearch', element: <SuspenseWrapper><OpenSearchAdminPage /></SuspenseWrapper> },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{ path: 'api-docs', element: <SuspenseWrapper><SwaggerPage /></SuspenseWrapper> },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Legacy redirect components — translate old sidebar paths to current tab
|
||
|
|
// (useParams is already imported at the top of this file)
|
||
|
|
|
||
|
|
function LegacyAppRedirect() {
|
||
|
|
const { appId, routeId } = useParams<{ appId: string; routeId?: string }>();
|
||
|
|
const path = routeId ? `/exchanges/${appId}/${routeId}` : `/exchanges/${appId}`;
|
||
|
|
return <Navigate to={path} replace />;
|
||
|
|
}
|
||
|
|
|
||
|
|
function LegacyAgentRedirect() {
|
||
|
|
const { appId, instanceId } = useParams<{ appId: string; instanceId?: string }>();
|
||
|
|
const path = instanceId ? `/runtime/${appId}/${instanceId}` : `/runtime/${appId}`;
|
||
|
|
return <Navigate to={path} replace />;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
The `useParams` import from `react-router` is already at the top alongside the other router imports. The legacy redirects ensure the Sidebar's hardcoded `/apps/...` and `/agents/...` Links still work (they redirect to the Exchanges tab by default).
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify build**
|
||
|
|
|
||
|
|
Run: `cd ui && npx tsc --noEmit`
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/router.tsx
|
||
|
|
git commit -m "feat(ui): restructure router for tab-based navigation with legacy redirects"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 9: Update LayoutShell
|
||
|
|
|
||
|
|
Wire ContentTabs, ScopeTrail, sidebar interception, and remove agents from sidebar data.
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `ui/src/components/LayoutShell.tsx`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Update imports**
|
||
|
|
|
||
|
|
Add these imports to the top of `LayoutShell.tsx`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { ContentTabs } from './ContentTabs';
|
||
|
|
import { ScopeTrail } from './ScopeTrail';
|
||
|
|
import { useScope } from '../hooks/useScope';
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Remove agents from sidebar data**
|
||
|
|
|
||
|
|
In the `sidebarApps` useMemo (around line 106), change the agents mapping to always return an empty array:
|
||
|
|
|
||
|
|
Replace:
|
||
|
|
```typescript
|
||
|
|
agents: (app.agents || []).map((a: any) => ({
|
||
|
|
id: a.id,
|
||
|
|
name: a.name,
|
||
|
|
status: a.status as 'live' | 'stale' | 'dead',
|
||
|
|
tps: a.tps,
|
||
|
|
})),
|
||
|
|
```
|
||
|
|
|
||
|
|
With:
|
||
|
|
```typescript
|
||
|
|
agents: [],
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Add scope + sidebar interception to LayoutContent**
|
||
|
|
|
||
|
|
At the top of the `LayoutContent` function (after existing hooks around line 92), add:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const { scope, setTab, clearScope } = useScope();
|
||
|
|
```
|
||
|
|
|
||
|
|
Add the sidebar click interceptor function (before the return statement):
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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]);
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Replace breadcrumbs with ScopeTrail**
|
||
|
|
|
||
|
|
Replace the existing `breadcrumb` useMemo block (lines 168-188) with:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Breadcrumb is now the ScopeTrail — built from scope, not URL path
|
||
|
|
// Keep the old breadcrumb generation for admin pages only
|
||
|
|
const isAdminPage = location.pathname.startsWith('/admin');
|
||
|
|
const breadcrumb = useMemo(() => {
|
||
|
|
if (!isAdminPage) return []; // ScopeTrail handles non-admin breadcrumbs
|
||
|
|
const LABELS: Record<string, string> = {
|
||
|
|
admin: 'Admin',
|
||
|
|
rbac: 'Users & Roles',
|
||
|
|
audit: 'Audit Log',
|
||
|
|
oidc: 'OIDC',
|
||
|
|
database: 'Database',
|
||
|
|
opensearch: 'OpenSearch',
|
||
|
|
appconfig: 'App Config',
|
||
|
|
};
|
||
|
|
const parts = location.pathname.split('/').filter(Boolean);
|
||
|
|
return parts.map((part, i) => ({
|
||
|
|
label: LABELS[part] ?? part,
|
||
|
|
...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}),
|
||
|
|
}));
|
||
|
|
}, [location.pathname, isAdminPage]);
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Update CommandPalette submit handler**
|
||
|
|
|
||
|
|
Replace the `handlePaletteSubmit` callback (around line 202) so it uses the Exchanges tab path:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const handlePaletteSubmit = useCallback((query: string) => {
|
||
|
|
// Full-text search: navigate to Exchanges tab with text param
|
||
|
|
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]);
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Update the search result paths in buildSearchData**
|
||
|
|
|
||
|
|
In `buildSearchData` function, update the `path` values for apps and routes:
|
||
|
|
|
||
|
|
Replace:
|
||
|
|
```typescript
|
||
|
|
path: `/apps/${app.appId}`,
|
||
|
|
```
|
||
|
|
With:
|
||
|
|
```typescript
|
||
|
|
path: `/exchanges/${app.appId}`,
|
||
|
|
```
|
||
|
|
|
||
|
|
Replace:
|
||
|
|
```typescript
|
||
|
|
path: `/apps/${app.appId}/${route.routeId}`,
|
||
|
|
```
|
||
|
|
With:
|
||
|
|
```typescript
|
||
|
|
path: `/exchanges/${app.appId}/${route.routeId}`,
|
||
|
|
```
|
||
|
|
|
||
|
|
Replace:
|
||
|
|
```typescript
|
||
|
|
path: `/agents/${agent.application}/${agent.id}`,
|
||
|
|
```
|
||
|
|
With:
|
||
|
|
```typescript
|
||
|
|
path: `/runtime/${agent.application}/${agent.id}`,
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 7: Update exchange search result paths**
|
||
|
|
|
||
|
|
In the `searchData` useMemo (around line 132), update the exchange path:
|
||
|
|
|
||
|
|
Replace:
|
||
|
|
```typescript
|
||
|
|
path: `/exchanges/${e.executionId}`,
|
||
|
|
```
|
||
|
|
|
||
|
|
With a path that includes the app and route for proper 3-column view:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
path: `/exchanges/${e.applicationName ?? ''}/${e.routeId}/${e.executionId}`,
|
||
|
|
```
|
||
|
|
|
||
|
|
Do the same for `attributeItems` path.
|
||
|
|
|
||
|
|
- [ ] **Step 8: Update the JSX return**
|
||
|
|
|
||
|
|
Replace the return block of `LayoutContent` with:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
return (
|
||
|
|
<AppShell
|
||
|
|
sidebar={
|
||
|
|
<div onClick={handleSidebarClick}>
|
||
|
|
<Sidebar apps={sidebarApps} />
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<TopBar
|
||
|
|
breadcrumb={breadcrumb}
|
||
|
|
user={username ? { name: username } : undefined}
|
||
|
|
onLogout={handleLogout}
|
||
|
|
/>
|
||
|
|
<CommandPalette
|
||
|
|
open={paletteOpen}
|
||
|
|
onClose={() => setPaletteOpen(false)}
|
||
|
|
onOpen={() => setPaletteOpen(true)}
|
||
|
|
onSelect={handlePaletteSelect}
|
||
|
|
onSubmit={handlePaletteSubmit}
|
||
|
|
onQueryChange={setPaletteQuery}
|
||
|
|
data={searchData}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Content tabs + scope trail — only for main content, not admin */}
|
||
|
|
{!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>
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 9: Verify build**
|
||
|
|
|
||
|
|
Run: `cd ui && npx tsc --noEmit`
|
||
|
|
|
||
|
|
Fix any type errors. Then run the dev server:
|
||
|
|
|
||
|
|
Run: `cd ui && npm run dev`
|
||
|
|
|
||
|
|
Visually verify:
|
||
|
|
- Tab bar appears below TopBar with Exchanges | Dashboard | Runtime
|
||
|
|
- Scope trail shows "All Applications" by default
|
||
|
|
- Clicking an app in sidebar scopes to that app and stays on current tab
|
||
|
|
- Clicking a route transitions to 3-column layout (Exchanges tab)
|
||
|
|
- Clicking Dashboard or Runtime tabs shows the correct content
|
||
|
|
- Admin pages still work without tabs/scope trail
|
||
|
|
|
||
|
|
- [ ] **Step 10: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add ui/src/components/LayoutShell.tsx
|
||
|
|
git commit -m "feat(ui): integrate ContentTabs, ScopeTrail, and sidebar scope interception"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 10: Final cleanup and verification
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `ui/src/pages/Dashboard/Dashboard.tsx` (update inspect link path)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Update Dashboard inspect link**
|
||
|
|
|
||
|
|
In `Dashboard.tsx`, the inspect button navigates to `/exchanges/:id` (the old exchange detail page). Update it to stay in the current scope. Find the inspect column render (around line 347):
|
||
|
|
|
||
|
|
Replace:
|
||
|
|
```typescript
|
||
|
|
navigate(`/exchanges/${row.executionId}`)
|
||
|
|
```
|
||
|
|
|
||
|
|
With:
|
||
|
|
```typescript
|
||
|
|
navigate(`/exchanges/${row.applicationName}/${row.routeId}/${row.executionId}`)
|
||
|
|
```
|
||
|
|
|
||
|
|
This navigates to the 3-column view with the exchange pre-selected.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Update Dashboard "Open full details" link**
|
||
|
|
|
||
|
|
In the detail panel section (around line 465):
|
||
|
|
|
||
|
|
Replace:
|
||
|
|
```typescript
|
||
|
|
onClick={() => navigate(`/exchanges/${detail.executionId}`)}
|
||
|
|
```
|
||
|
|
|
||
|
|
With:
|
||
|
|
```typescript
|
||
|
|
onClick={() => navigate(`/exchanges/${detail.applicationName}/${detail.routeId}/${detail.executionId}`)}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify all navigation flows**
|
||
|
|
|
||
|
|
Run the dev server and verify:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd ui && npm run dev
|
||
|
|
```
|
||
|
|
|
||
|
|
Verification checklist:
|
||
|
|
1. `/exchanges` — Shows full-width exchange table with KPI strip
|
||
|
|
2. Click app in sidebar — URL updates to `/exchanges/:appId`, table filters
|
||
|
|
3. Click route in sidebar — URL updates to `/exchanges/:appId/:routeId`, 3-column layout appears
|
||
|
|
4. Click exchange in left list — Right panel shows exchange header + diagram + details
|
||
|
|
5. Click "Dashboard" tab — URL changes to `/dashboard/:appId/:routeId` (scope preserved)
|
||
|
|
6. Dashboard tab shows RoutesMetrics (no route) or RouteDetail (with route)
|
||
|
|
7. Click "Runtime" tab — URL changes to `/runtime`, shows AgentHealth
|
||
|
|
8. Click agent in Runtime content — Shows AgentInstance detail
|
||
|
|
9. Scope trail segments are clickable and navigate correctly
|
||
|
|
10. Cmd+K search works, results navigate to correct new URLs
|
||
|
|
11. Admin pages (gear icon or /admin) still work with breadcrumbs, no tabs
|
||
|
|
12. Sidebar shows apps with routes only, no agents section
|
||
|
|
|
||
|
|
- [ ] **Step 4: Verify production build**
|
||
|
|
|
||
|
|
Run: `cd ui && npm run build`
|
||
|
|
Expected: Clean build with no errors
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A
|
||
|
|
git commit -m "feat(ui): complete navigation redesign - tab-based layout with scope filtering
|
||
|
|
|
||
|
|
Redesigns navigation from page-based routing to scope-based model:
|
||
|
|
- Three content tabs: Exchanges, Dashboard, Runtime
|
||
|
|
- Sidebar simplified to app/route hierarchy (scope filter)
|
||
|
|
- Scope trail replaces breadcrumbs
|
||
|
|
- Exchanges tab: full-width table or 3-column layout with diagram
|
||
|
|
- Legacy URL redirects for backward compatibility"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Known Limitations and Future Work
|
||
|
|
|
||
|
|
1. **Sidebar design system update needed:** The click interception via event delegation is a workaround. A proper `onNavigate` prop should be added to the design system's `Sidebar` component.
|
||
|
|
|
||
|
|
2. **Dashboard tab content:** The analytics/dashboard content is deferred per spec. Currently wraps existing RoutesMetrics and RouteDetail pages.
|
||
|
|
|
||
|
|
3. **ExchangeDetail page:** The old full-page ExchangeDetail at `/exchanges/:id` is replaced by the inline 3-column view. The ExchangeDetail component file is not deleted — it may be useful for reference. Clean up when confident the new view covers all use cases.
|
||
|
|
|
||
|
|
4. **Exchange header:** Uses inline styles for brevity. Extract to CSS module if it grows.
|
||
|
|
|
||
|
|
5. **KPI hero per tab:** Currently only the Exchanges tab has a KPI strip (from Dashboard). The Dashboard and Runtime tabs will get their own KPI strips in future iterations.
|
||
|
|
|
||
|
|
6. **Application log and replay:** These features from ExchangeDetail are accessible through the ExecutionDiagram's detail panel tabs but not directly in the exchange header. A future iteration could add log/replay buttons.
|