feat: redesign Sidebar with hierarchical trees, starring, and collapsible sections

Replace flat app/route/agent lists with expandable tree navigation.
Apps contain their routes and agents hierarchically. Add localStorage-
backed starring with composite keys for uniqueness. Persist expand
state to sessionStorage across page navigations. Add collapsible
section headers, remove button on starred items, and parent app
context labels. Create stub pages for /apps/:id, /agents/:id,
/admin, /api-docs. Consolidate duplicated sidebar data into
shared mock. Widen sidebar from 220px to 260px.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 17:50:41 +01:00
parent 4aeb5be6ab
commit e69e5ab5fe
23 changed files with 1809 additions and 484 deletions

View File

@@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"Bash(bash \"C:/Users/Hendrik/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.4/skills/brainstorming/scripts/start-server.sh\" --project-dir \"C:/Users/Hendrik/Documents/projects/design-system\")",
"Bash(npm create:*)",
"Bash(node:*)",
"Bash(npm show:*)",
"Bash(npm install:*)",
"Bash(npm audit:*)",
"Bash(timeout 10 npm run dev)",
"Bash(npx vitest:*)",
"Bash(npx tsc:*)",
"Bash(npm run:*)",
"Bash(find C:/Users/Hendrik/Documents/projects/design-system/.worktrees/gap-fill -name *.test.tsx)",
"Bash(echo \"EXIT:$?\")",
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
"Bash(echo \"EXIT_CODE=$?\")",
"Bash(echo \"EXIT=$?\")"
]
}
}

View File

@@ -34,10 +34,10 @@
- Removable label → **Tag** - Removable label → **Tag**
### "I need navigation" ### "I need navigation"
- App-level sidebar nav → **Sidebar** (via AppShell) - App-level sidebar nav → **Sidebar** (via AppShell) — hierarchical trees with starring
- Breadcrumb trail → **Breadcrumb** - Breadcrumb trail → **Breadcrumb**
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination) - Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
- Hierarchical tree navigation → **TreeView** - Hierarchical tree navigation → **TreeView** (generic) or **SidebarTree** (sidebar-specific, internal)
### "I need floating content" ### "I need floating content"
- Tooltip on hover → **Tooltip** - Tooltip on hover → **Tooltip**
@@ -186,7 +186,7 @@ TreeView for hierarchical data (Application → Routes → Processors)
| Component | Purpose | | Component | Purpose |
|-----------|---------| |-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel | | AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| Sidebar | App navigation with apps, routes, agents sections | | Sidebar | Hierarchical navigation with Applications/Agents trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
| TopBar | Header bar with breadcrumb, environment, user info | | TopBar | Header bar with breadcrumb, environment, user info |
## Import Paths ## Import Paths

View File

@@ -0,0 +1,307 @@
# Sidebar Redesign — Hierarchical Navigation with Starring
## Context
The Cameleer3 Sidebar currently renders flat lists of applications, routes, and agents as separate sections. This doesn't reflect the hierarchical relationship in Apache Camel where routes and agents belong to applications. The redesign restructures navigation into expandable trees, adds a starring/favorites system, and adds bottom navigation links (Admin, API Docs).
This project is primarily a design system with a mock application showcasing how all pieces come together. Stub pages will be created for new routes to demonstrate the full navigation flow.
## Sidebar Structure
```
┌─────────────────────────┐
│ cameleer v3.2.1 │ Logo (fixed)
├─────────────────────────┤
│ Filter... │ Search (fixed)
├─────────────────────────┤
│ NAVIGATION │ Section header
│ │
│ ▾ Applications │ Tree (expandable)
│ ● order-svc 1.2k │ App (health dot + exchangeCount)
│ ▶ ingest 540 │ Route (arrow + exchangeCount)
│ ▶ notify 320 │
│ ● payment-svc 800 │
│ ▶ validate 800 │
│ │
│ ▾ Agents │ Tree (expandable)
│ ● order-svc 2/3 live│ App (health dot + live count)
│ agent-1 42 tps │ Agent (name + tps)
│ agent-2 38 tps │
│ ● payment-svc 1/1 ...│
│ agent-3 12 tps │
│ │
│ ▦ Dashboards │ Standalone nav link (not a tree node)
│ ↕ scrollable │
├─────────────────────────┤
│ ★ STARRED │ Section header (hidden when empty)
│ Applications │ Group label
│ ● order-svc │
│ Routes │ Group label
│ ▶ ingest │
│ Agents │ Group label
│ agent-prod-1 │
│ ↕ scrollable │
├─────────────────────────┤
│ ⚙ Admin │ Bottom link → /admin (fixed)
│ 📄 API Docs │ Bottom link → /api-docs (fixed)
└─────────────────────────┘
```
**Width:** 220px (unchanged)
**Scroll regions:**
- Navigation: `flex: 1; overflow-y: auto; min-height: 0`
- Starred: `max-height: 30vh; overflow-y: auto` (entire section hidden when no items are starred)
- Logo, Search, Bottom: fixed (`flex-shrink: 0`)
## Data Model
### New interfaces (replace current flat App/Route/Agent)
```ts
interface SidebarApp {
id: string
name: string
health: 'live' | 'stale' | 'dead'
exchangeCount: number
routes: SidebarRoute[]
agents: SidebarAgent[]
}
interface SidebarRoute {
id: string
name: string
exchangeCount: number
}
interface SidebarAgent {
id: string
name: string
status: 'live' | 'stale' | 'dead'
tps: string
}
interface SidebarProps {
apps: SidebarApp[]
activeItemId?: string // highlighted item; derived from URL if omitted
className?: string
}
```
**Key changes from current:**
- Routes and agents nested inside apps (was: three flat arrays)
- `onItemClick` removed — Sidebar navigates internally via `useNavigate()`
- Active item derived from `useLocation().pathname` if `activeItemId` not provided
- Route health is out of scope — routes show a static arrow icon (no StatusDot)
### Agent type migration
The current `src/mocks/agents.ts` has `AgentHealth extends Agent` where `Agent` is imported from Sidebar. Since `SidebarAgent` is now a minimal type (just id, name, status, tps), `AgentHealth` must become self-contained:
```ts
// src/mocks/agents.ts — no longer extends Sidebar's Agent type
export interface AgentHealth {
id: string
name: string
service: string
version: string
tps: string
lastSeen: string
status: 'live' | 'stale' | 'dead'
errorRate?: string
uptime: string
memoryUsagePct: number
cpuUsagePct: number
activeRoutes: number
totalRoutes: number
}
```
## Components
### SidebarTree (`src/design-system/layout/Sidebar/SidebarTree.tsx`)
A sidebar-specific tree component. NOT the generic TreeView — optimized for sidebar rendering with health dots, count badges, and star buttons.
**Props:**
```ts
interface SidebarTreeNode {
id: string
label: string
icon?: ReactNode // StatusDot, arrow icon, etc.
badge?: string // right-aligned text ("1.2k", "42 tps", "2/3 live")
path?: string // navigation path on click
starrable?: boolean // whether star toggle appears (default false)
children?: SidebarTreeNode[]
}
interface SidebarTreeProps {
nodes: SidebarTreeNode[]
selectedId?: string
isStarred: (id: string) => boolean
onToggleStar: (id: string) => void
onNavigate?: (path: string) => void
className?: string
filterQuery?: string // when set, auto-expands matching parents
}
```
**Styling:** All SidebarTree styles go in `Sidebar.module.css` (SidebarTree is a private component of Sidebar, not independently used).
**Behaviors:**
- **Expand/collapse:** Chevron click only. Row click navigates (if `path` set).
- **Parent nodes without `path`:** Click does nothing (only chevron toggles). The "Applications" and "Agents" root headers are not clickable or starrable — they're just section labels with a toggle chevron.
- **Star toggle:** Star icon appears on hover (right side, after badge). Filled star always visible on starred items. Click stops propagation (doesn't navigate).
- **Leaf nodes with no children:** Render without chevron. App nodes with empty `routes[]` or `agents[]` render as leaf nodes (no expand arrow).
- **Search filter:** When `filterQuery` is set, only nodes matching the query (or with matching descendants) are shown. Matching parents auto-expand. When no matches, the tree is empty (the Sidebar shows a "No results" message).
- **Keyboard nav:** ArrowUp/Down to move focus, ArrowRight to expand, ArrowLeft to collapse, Enter to navigate/select, Home/End to jump.
- **ARIA:** `role="tree"`, `role="treeitem"`, `aria-expanded`, `aria-selected`.
- **Styling:** Uses `--sidebar-*` CSS tokens. Active item gets amber highlight.
### useStarred (`src/design-system/layout/Sidebar/useStarred.ts`)
```ts
function useStarred(): {
starredIds: Set<string>
isStarred: (id: string) => boolean
toggleStar: (id: string) => void
}
```
- Reads/writes `localStorage` key `"cameleer:starred"` (JSON string array)
- Returns a `Set<string>` for O(1) lookups
- `toggleStar` updates both React state and localStorage
- **Error handling:** Wraps `localStorage` read/write in try/catch — falls back to in-memory state if localStorage is unavailable (private browsing, quota exceeded)
- Cross-tab sync is out of scope for now
### Sidebar (rewritten)
The Sidebar assembles the SidebarTree data from `SidebarApp[]` props:
1. **Applications tree:** Maps each app to a parent node with route children. App nodes show `StatusDot` + name + exchangeCount badge. Route nodes show arrow icon + name + exchangeCount badge. Both apps and routes are `starrable: true`.
2. **Agents tree:** Maps each app to a parent node with agent children. App nodes show `StatusDot` + name + "X/Y live" badge (computed: count agents where `status === 'live'` / total agents). Agent nodes show name + tps badge. Both apps and agents are `starrable: true`.
3. **Dashboards:** A standalone nav link (not part of any tree), rendered as a simple clickable row below the trees. Uses `▦` icon. Note: the current top-level Metrics and Agents nav links are intentionally removed — Metrics will be displayed contextually within app/route/agent detail views, and Agents are accessible via the Agents tree.
4. **Starred section:** Reads `starredIds` from `useStarred()`, looks up items in the apps data, groups by type (Applications, Routes, Agents in that order). Orphaned IDs (starred item no longer in data) are silently ignored. If a starred app appears in both the Applications and Agents trees, it shows only under "Applications" in the starred section. The entire section (header + content) is hidden when no items are starred.
5. **Search:** Filters both trees. When query matches a route/agent name, its parent app stays visible and auto-expands. Placeholder text: "Filter...". On clear, restore previous expand/collapse state.
## Star Icon
Inline SVG (no icon library). Outline star for unstarred, filled star for starred. Amber color (`var(--amber)`).
```html
<!-- outline (unstarred) -->
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<!-- filled (starred) -->
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
```
**Visibility rules:**
- Unstarred items: star appears on row `:hover` only
- Starred items: filled star always visible
- Star button: `opacity: 0``opacity: 1` on hover transition (0.15s)
## Navigation Paths
| Item type | Click navigates to | Route exists? |
|-----------|-------------------|---------------|
| App (in Applications tree) | `/apps/{id}` | New stub page |
| Route | `/routes/{id}` | Existing |
| App (in Agents tree) | `/apps/{id}` | Same stub page |
| Agent | `/agents/{id}` | New — currently only `/agents` (list) exists |
| Dashboards | `/` | Existing (Dashboard) |
| Admin | `/admin` | New stub page |
| API Docs | `/api-docs` | New stub page |
### Stub pages needed
Minimal placeholder pages using the design system's `EmptyState` component:
- **AppDetail** (`src/pages/AppDetail/AppDetail.tsx`) — route `/apps/:id`, shows app name from URL param + EmptyState "App detail coming soon"
- **AgentDetail** (`src/pages/AgentDetail/AgentDetail.tsx`) — route `/agents/:id`, shows agent name + EmptyState
- **Admin** (`src/pages/Admin/Admin.tsx`) — route `/admin`, EmptyState "Admin panel coming soon"
- **ApiDocs** (`src/pages/ApiDocs/ApiDocs.tsx`) — route `/api-docs`, EmptyState "API documentation coming soon"
All stub pages use `<AppShell>` with `<Sidebar>` to demonstrate the full navigation flow.
## Search Behavior
- Case-insensitive substring match on node labels
- When a child matches, its parent node is shown and auto-expanded
- When no matches, show "No results" text in the navigation area
- Search clears when input is emptied
- On clear, restore previous expand/collapse state
- Search filters both the Applications and Agents trees simultaneously
## Mock Data
### New file: `src/mocks/sidebar.ts`
Exports `SIDEBAR_APPS: SidebarApp[]` — consolidates the duplicated APPS, SIDEBAR_ROUTES, and agents data currently scattered across 6 page files. Each app contains its routes and agents nested inside.
All consumer pages + Inventory demo will import from this single source.
## Files to Create
| File | Purpose |
|------|---------|
| `src/design-system/layout/Sidebar/SidebarTree.tsx` | Tree component with health dots, badges, star toggle |
| `src/design-system/layout/Sidebar/useStarred.ts` | localStorage hook for starred item IDs |
| `src/mocks/sidebar.ts` | Shared hierarchical sidebar mock data |
| `src/pages/AppDetail/AppDetail.tsx` | Stub page for `/apps/:id` |
| `src/pages/AgentDetail/AgentDetail.tsx` | Stub page for `/agents/:id` |
| `src/pages/Admin/Admin.tsx` | Stub page for `/admin` |
| `src/pages/ApiDocs/ApiDocs.tsx` | Stub page for `/api-docs` |
| `src/design-system/layout/Sidebar/Sidebar.test.tsx` | Tests for Sidebar |
| `src/design-system/layout/Sidebar/useStarred.test.ts` | Tests for useStarred hook |
## Files to Modify
| File | Changes |
|------|---------|
| `src/design-system/layout/Sidebar/Sidebar.tsx` | Rewrite: new props, SidebarTree, starred section, bottom links |
| `src/design-system/layout/Sidebar/Sidebar.module.css` | Add tree styles, starred section, star button, bottom links, SidebarTree styles |
| `src/design-system/layout/index.ts` | Export new types (SidebarApp, SidebarRoute, SidebarAgent) |
| `src/App.tsx` | Add routes: `/apps/:id`, `/agents/:id`, `/admin`, `/api-docs` |
| `src/pages/Dashboard/Dashboard.tsx` | Simplify to `<Sidebar apps={SIDEBAR_APPS} />` |
| `src/pages/RouteDetail/RouteDetail.tsx` | Same simplification |
| `src/pages/ExchangeDetail/ExchangeDetail.tsx` | Same simplification |
| `src/pages/AgentHealth/AgentHealth.tsx` | Same simplification |
| `src/pages/Metrics/Metrics.tsx` | Same simplification |
| `src/pages/Inventory/sections/LayoutSection.tsx` | Update demo data to hierarchical shape |
| `src/mocks/agents.ts` | Make AgentHealth self-contained (no longer extends Sidebar's Agent) |
| `COMPONENT_GUIDE.md` | Update Sidebar and navigation descriptions |
## Implementation Order
1. **Mock data** (`src/mocks/sidebar.ts`) — needed for development and testing
2. **Agent type fix** (`src/mocks/agents.ts`) — make AgentHealth self-contained
3. **useStarred hook** + tests — standalone, no dependencies
4. **SidebarTree component** — depends on useStarred interface
5. **Sidebar rewrite** + CSS — composes SidebarTree + useStarred
6. **Stub pages** — AppDetail, AgentDetail, Admin, ApiDocs
7. **App.tsx routes** — register new routes
8. **Consumer migration** — update all 6 pages to use `<Sidebar apps={SIDEBAR_APPS} />`
9. **Inventory update** — update LayoutSection demo data
10. **Type exports** — update layout barrel export
11. **Sidebar tests** — integration tests for Sidebar component
12. **Docs** — update COMPONENT_GUIDE.md
## Verification
1. `npx tsc --noEmit` — zero TypeScript errors
2. `npx vitest run` — all tests pass (existing + new)
3. `npm run build` — clean Vite build
4. Manual: open `/inventory` → Layout section → verify Sidebar demo renders correctly
5. Manual: open `/` → verify sidebar trees expand/collapse, starring persists across refresh, search filters both trees, navigation to all routes works
6. Manual: verify starred section hides when empty, shows grouped items when populated
7. Manual: verify dark theme rendering (sidebar uses `--sidebar-*` tokens)
8. `grep -r "onItemClick\|routes={.*SIDEBAR\|agents={.*agents" src/design-system/layout/Sidebar/` — zero hits (old API removed)

View File

@@ -5,6 +5,10 @@ import { RouteDetail } from './pages/RouteDetail/RouteDetail'
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail' import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
import { AgentHealth } from './pages/AgentHealth/AgentHealth' import { AgentHealth } from './pages/AgentHealth/AgentHealth'
import { Inventory } from './pages/Inventory/Inventory' import { Inventory } from './pages/Inventory/Inventory'
import { AppDetail } from './pages/AppDetail/AppDetail'
import { AgentDetail } from './pages/AgentDetail/AgentDetail'
import { Admin } from './pages/Admin/Admin'
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
export default function App() { export default function App() {
return ( return (
@@ -14,6 +18,10 @@ export default function App() {
<Route path="/routes/:id" element={<RouteDetail />} /> <Route path="/routes/:id" element={<RouteDetail />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} /> <Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/agents" element={<AgentHealth />} /> <Route path="/agents" element={<AgentHealth />} />
<Route path="/agents/:id" element={<AgentDetail />} />
<Route path="/apps/:id" element={<AppDetail />} />
<Route path="/admin" element={<Admin />} />
<Route path="/api-docs" element={<ApiDocs />} />
<Route path="/inventory" element={<Inventory />} /> <Route path="/inventory" element={<Inventory />} />
</Routes> </Routes>
) )

View File

@@ -1,5 +1,5 @@
.sidebar { .sidebar {
width: 220px; width: 260px;
flex-shrink: 0; flex-shrink: 0;
background: var(--sidebar-bg); background: var(--sidebar-bg);
display: flex; display: flex;
@@ -102,7 +102,7 @@
padding: 0 6px; padding: 0 6px;
} }
/* Nav item */ /* Nav item (flat links like Dashboards) */
.item { .item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -129,16 +129,6 @@
border-left-color: var(--amber); border-left-color: var(--amber);
} }
.item.active .itemCount {
background: rgba(198, 130, 14, 0.2);
color: var(--amber-light);
}
/* Indented route items */
.indented {
padding-left: 22px;
}
.navIcon { .navIcon {
font-size: 14px; font-size: 14px;
width: 18px; width: 18px;
@@ -154,6 +144,7 @@
.routeArrow { .routeArrow {
color: var(--sidebar-muted); color: var(--sidebar-muted);
font-size: 10px; font-size: 10px;
flex-shrink: 0;
} }
/* Item sub-elements */ /* Item sub-elements */
@@ -169,143 +160,278 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.itemMeta { /* No results */
font-size: 11px; .noResults {
padding: 16px 18px;
font-size: 12px;
color: var(--sidebar-muted); color: var(--sidebar-muted);
font-family: var(--font-mono); text-align: center;
} }
.itemCount { /* ── SidebarTree styles ──────────────────────────────────────────────────── */
font-family: var(--font-mono);
font-size: 11px; .treeSection {
color: var(--sidebar-muted); padding: 0 6px;
background: rgba(255, 255, 255, 0.06); margin-bottom: 4px;
padding: 1px 6px;
border-radius: 10px;
flex-shrink: 0;
} }
/* Health dots */ .treeSectionLabel {
.healthDot { padding: 10px 12px 4px;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.healthLive {
background: #5db866;
box-shadow: 0 0 6px rgba(93, 184, 102, 0.4);
}
.healthStale {
background: var(--warning);
}
.healthDead {
background: var(--sidebar-muted);
}
/* Divider */
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.06);
margin: 6px 12px;
}
/* Agents header */
.agentsHeader {
padding: 14px 12px 6px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1.2px; letter-spacing: 1px;
color: var(--sidebar-muted); color: var(--sidebar-muted);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
} }
.agentBadge { /* Collapsible section toggle */
font-family: var(--font-mono); .treeSectionToggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 12px 4px;
font-size: 10px; font-size: 10px;
padding: 1px 6px; font-weight: 600;
border-radius: 10px; text-transform: uppercase;
background: rgba(93, 184, 102, 0.15); letter-spacing: 1px;
color: #5db866; color: var(--sidebar-muted);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: color 0.12s;
} }
/* Agents list */ .treeSectionToggle:hover {
.agentsList { color: var(--sidebar-text);
padding: 0 0 6px;
overflow-y: auto;
max-height: 180px;
flex-shrink: 0;
} }
.agentItem { .treeSectionChevron {
font-size: 9px;
width: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tree {
list-style: none;
margin: 0;
padding: 0;
}
.treeGroup {
list-style: none;
margin: 0;
padding: 0;
}
.treeRow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
padding: 6px 12px; padding: 5px 8px;
margin: 0 6px 2px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 11px;
color: var(--sidebar-text); color: var(--sidebar-text);
transition: background 0.1s; font-size: 12px;
cursor: pointer;
transition: background 0.12s;
border-left: 3px solid transparent;
margin-bottom: 1px;
user-select: none;
position: relative;
} }
.agentItem:hover { .treeRow:hover {
background: var(--sidebar-hover); background: var(--sidebar-hover);
} }
.agentDot { .treeRowActive {
width: 6px; background: var(--sidebar-active);
height: 6px; color: var(--amber-light);
border-radius: 50%; border-left-color: var(--amber);
flex-shrink: 0;
} }
.agentInfo { .treeRowActive .treeBadge {
background: rgba(198, 130, 14, 0.2);
color: var(--amber-light);
}
/* Chevron */
.treeChevronSlot {
width: 14px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.treeChevron {
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--sidebar-muted);
font-size: 11px;
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.treeChevron:hover {
color: var(--sidebar-text);
}
/* Icon slot */
.treeIcon {
flex-shrink: 0;
display: flex;
align-items: center;
}
/* Label */
.treeLabel {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
}
.agentName {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.agentDetail { /* Badge */
font-size: 10px; .treeBadge {
color: var(--sidebar-muted);
}
.agentStats {
text-align: right;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
color: var(--sidebar-muted); color: var(--sidebar-muted);
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 10px;
flex-shrink: 0;
white-space: nowrap;
} }
.agentTps { /* Star button */
color: var(--sidebar-text); .treeStar {
} background: none;
border: none;
.agentLastSeen { padding: 0;
margin: 0;
color: var(--sidebar-muted); color: var(--sidebar-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
display: flex;
align-items: center;
flex-shrink: 0;
} }
.agentError { .treeStarActive {
opacity: 1;
color: var(--amber);
}
.treeRow:hover .treeStar {
opacity: 1;
}
.treeStar:hover {
color: var(--amber-light);
}
/* ── Starred section ─────────────────────────────────────────────────────── */
.starredSection {
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.starredHeader {
color: var(--amber);
}
.starredList {
padding: 0 6px 6px;
}
.starredGroup {
margin-bottom: 4px;
}
.starredGroupLabel {
padding: 4px 12px 2px;
font-size: 10px;
color: var(--sidebar-muted);
font-weight: 500;
}
.starredItem {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: var(--radius-sm);
color: var(--sidebar-text);
font-size: 12px;
cursor: pointer;
transition: background 0.12s;
user-select: none;
}
.starredItem:hover {
background: var(--sidebar-hover);
}
.starredItemInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.starredItemName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.starredItemContext {
font-size: 10px;
color: var(--sidebar-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Remove button */
.starredRemove {
background: none;
border: none;
padding: 2px;
margin: 0;
color: var(--sidebar-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
display: flex;
align-items: center;
flex-shrink: 0;
}
.starredItem:hover .starredRemove {
opacity: 1;
}
.starredRemove:hover {
color: var(--error); color: var(--error);
} }
/* Bottom links */ /* ── Bottom links ────────────────────────────────────────────────────────── */
.bottom { .bottom {
border-top: 1px solid rgba(255, 255, 255, 0.06); border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 6px; padding: 6px;
@@ -331,6 +457,12 @@
color: var(--sidebar-text); color: var(--sidebar-text);
} }
.bottomItemActive {
background: var(--sidebar-active);
color: var(--amber-light);
border-left-color: var(--amber);
}
.bottomIcon { .bottomIcon {
font-size: 13px; font-size: 13px;
width: 18px; width: 18px;

View File

@@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Sidebar, type SidebarApp } from './Sidebar'
import { ThemeProvider } from '../../providers/ThemeProvider'
const TEST_APPS: SidebarApp[] = [
{
id: 'order-service',
name: 'order-service',
health: 'live',
exchangeCount: 1433,
routes: [
{ id: 'order-intake', name: 'order-intake', exchangeCount: 892 },
{ id: 'order-enrichment', name: 'order-enrichment', exchangeCount: 541 },
],
agents: [
{ id: 'prod-1', name: 'prod-1', status: 'live', tps: '14.2/s' },
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: '11.8/s' },
],
},
{
id: 'payment-svc',
name: 'payment-svc',
health: 'live',
exchangeCount: 912,
routes: [
{ id: 'payment-process', name: 'payment-process', exchangeCount: 414 },
],
agents: [],
},
]
function renderSidebar(props: Partial<Parameters<typeof Sidebar>[0]> = {}) {
return render(
<ThemeProvider>
<MemoryRouter>
<Sidebar apps={TEST_APPS} {...props} />
</MemoryRouter>
</ThemeProvider>,
)
}
describe('Sidebar', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
it('renders the logo and brand name', () => {
renderSidebar()
expect(screen.getByText('cameleer')).toBeInTheDocument()
expect(screen.getByText('v3.2.1')).toBeInTheDocument()
})
it('renders the search input', () => {
renderSidebar()
expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument()
})
it('renders Navigation section header', () => {
renderSidebar()
expect(screen.getByText('Navigation')).toBeInTheDocument()
})
it('renders Applications tree section', () => {
renderSidebar()
expect(screen.getByText('Applications')).toBeInTheDocument()
})
it('renders Agents tree section', () => {
renderSidebar()
expect(screen.getByText('Agents')).toBeInTheDocument()
})
it('renders Dashboards nav link', () => {
renderSidebar()
expect(screen.getByText('Dashboards')).toBeInTheDocument()
})
it('renders bottom links', () => {
renderSidebar()
expect(screen.getByText('Admin')).toBeInTheDocument()
expect(screen.getByText('API Docs')).toBeInTheDocument()
})
it('renders app names in the Applications tree', () => {
renderSidebar()
// order-service appears in both Applications and Agents trees
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('payment-svc')).toBeInTheDocument()
})
it('renders exchange count badges', () => {
renderSidebar()
expect(screen.getByText('1.4k')).toBeInTheDocument()
})
it('renders agent live count badge in Agents tree', () => {
renderSidebar()
expect(screen.getByText('2/2 live')).toBeInTheDocument()
})
it('does not show starred section when nothing is starred', () => {
renderSidebar()
expect(screen.queryByText('★ Starred')).not.toBeInTheDocument()
})
it('shows starred section after starring an item', async () => {
const user = userEvent.setup()
renderSidebar()
// Find the first app row (order-service in Applications tree) and hover to reveal star
const appRows = screen.getAllByText('order-service')
const appRow = appRows[0].closest('[role="treeitem"]')!
await user.hover(appRow)
// Click the star button
const starBtn = appRow.querySelector('button[aria-label="Add to starred"]')!
await user.click(starBtn)
expect(screen.getByText('★ Starred')).toBeInTheDocument()
})
it('filters tree items by search', async () => {
const user = userEvent.setup()
renderSidebar()
const searchInput = screen.getByPlaceholderText('Filter...')
await user.type(searchInput, 'payment')
// payment-svc should still be visible
expect(screen.getByText('payment-svc')).toBeInTheDocument()
})
it('expands tree to show children when chevron is clicked', async () => {
const user = userEvent.setup()
renderSidebar()
// Find the expand button for order-service in Applications tree
const expandBtns = screen.getAllByLabelText('Expand')
await user.click(expandBtns[0])
// Routes should now be visible
expect(screen.getByText('order-intake')).toBeInTheDocument()
expect(screen.getByText('order-enrichment')).toBeInTheDocument()
})
it('collapses expanded tree when chevron is clicked again', async () => {
const user = userEvent.setup()
renderSidebar()
const expandBtns = screen.getAllByLabelText('Expand')
await user.click(expandBtns[0])
expect(screen.getByText('order-intake')).toBeInTheDocument()
const collapseBtn = screen.getByLabelText('Collapse')
await user.click(collapseBtn)
expect(screen.queryByText('order-intake')).not.toBeInTheDocument()
})
it('does not render apps with no agents in the Agents tree', () => {
renderSidebar()
// payment-svc has no agents, so it shouldn't appear under the Agents section header
// But it still appears under Applications. Let's check the agent tree specifically.
const agentBadges = screen.queryAllByText(/\/.*live/)
// Only order-service should have an agent badge
expect(agentBadges).toHaveLength(1)
expect(agentBadges[0].textContent).toBe('2/2 live')
})
})

View File

@@ -1,89 +1,222 @@
import { useState } from 'react' import { useState, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg' import camelLogoUrl from '../../../assets/camel-logo.svg'
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
import { useStarred } from './useStarred'
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
export interface App { // ── Types ────────────────────────────────────────────────────────────────────
export interface SidebarApp {
id: string id: string
name: string name: string
agentCount: number
health: 'live' | 'stale' | 'dead' health: 'live' | 'stale' | 'dead'
exchangeCount: number exchangeCount: number
routes: SidebarRoute[]
agents: SidebarAgent[]
} }
export interface Route { export interface SidebarRoute {
id: string id: string
name: string name: string
exchangeCount: number exchangeCount: number
} }
export interface Agent { export interface SidebarAgent {
id: string id: string
name: string name: string
service: string
version: string
tps: string
lastSeen: string
status: 'live' | 'stale' | 'dead' status: 'live' | 'stale' | 'dead'
errorRate?: string tps: string
} }
interface SidebarProps { interface SidebarProps {
apps: App[] apps: SidebarApp[]
routes: Route[] className?: string
agents: Agent[]
activeItem?: string
onItemClick?: (id: string) => void
} }
function HealthDot({ status }: { status: 'live' | 'stale' | 'dead' }) { // ── Helpers ──────────────────────────────────────────────────────────────────
function formatCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps.map((app) => ({
id: `app:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: formatCount(app.exchangeCount),
path: `/apps/${app.id}`,
starrable: true,
starKey: app.id,
children: app.routes.map((route) => ({
id: `route:${app.id}:${route.id}`,
starKey: `${app.id}:${route.id}`,
label: route.name,
icon: <span className={styles.routeArrow}>&#9656;</span>,
badge: formatCount(route.exchangeCount),
path: `/routes/${route.id}`,
starrable: true,
})),
}))
}
function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
return apps
.filter((app) => app.agents.length > 0)
.map((app) => {
const liveCount = app.agents.filter((a) => a.status === 'live').length
return {
id: `agents:${app.id}`,
label: app.name,
icon: <StatusDot variant={app.health} />,
badge: `${liveCount}/${app.agents.length} live`,
path: `/agents/${app.id}`,
starrable: true,
starKey: `agents:${app.id}`,
children: app.agents.map((agent) => ({
id: `agent:${app.id}:${agent.id}`,
starKey: `${app.id}:${agent.id}`,
label: agent.name,
badge: agent.tps,
path: `/agents/${agent.id}`,
starrable: true,
})),
}
})
}
// ── Starred section helpers ──────────────────────────────────────────────────
interface StarredItem {
starKey: string
label: string
icon?: React.ReactNode
path: string
type: 'application' | 'route' | 'agent'
parentApp?: string
}
function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] {
const items: StarredItem[] = []
for (const app of apps) {
if (starredIds.has(app.id)) {
items.push({
starKey: app.id,
label: app.name,
icon: <StatusDot variant={app.health} />,
path: `/apps/${app.id}`,
type: 'application',
})
}
for (const route of app.routes) {
const key = `${app.id}:${route.id}`
if (starredIds.has(key)) {
items.push({
starKey: key,
label: route.name,
path: `/routes/${route.id}`,
type: 'route',
parentApp: app.name,
})
}
}
for (const agent of app.agents) {
const key = `${app.id}:${agent.id}`
if (starredIds.has(key)) {
items.push({
starKey: key,
label: agent.name,
path: `/agents/${agent.id}`,
type: 'agent',
parentApp: app.name,
})
}
}
}
return items
}
// ── StarredGroup ─────────────────────────────────────────────────────────────
function StarredGroup({
label,
items,
onNavigate,
onRemove,
}: {
label: string
items: StarredItem[]
onNavigate: (path: string) => void
onRemove: (starKey: string) => void
}) {
return ( return (
<span <div className={styles.starredGroup}>
className={[ <div className={styles.starredGroupLabel}>{label}</div>
styles.healthDot, {items.map((item) => (
status === 'live' ? styles.healthLive : '', <div
status === 'stale' ? styles.healthStale : '', key={item.starKey}
status === 'dead' ? styles.healthDead : '', className={styles.starredItem}
] onClick={() => onNavigate(item.path)}
.filter(Boolean) role="button"
.join(' ')} tabIndex={0}
/> onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }}
>
{item.icon}
<div className={styles.starredItemInfo}>
<span className={styles.starredItemName}>{item.label}</span>
{item.parentApp && (
<span className={styles.starredItemContext}>{item.parentApp}</span>
)}
</div>
<button
className={styles.starredRemove}
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
tabIndex={-1}
aria-label={`Remove ${item.label} from starred`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
</div>
) )
} }
interface NavItem { // ── Sidebar ──────────────────────────────────────────────────────────────────
id: string
label: string
path: string
icon: string
}
const NAV_ITEMS: NavItem[] = [ export function Sidebar({ apps, className }: SidebarProps) {
{ id: 'dashboard', label: 'Dashboard', path: '/', icon: '▦' },
{ id: 'metrics', label: 'Metrics', path: '/metrics', icon: '◔' },
{ id: 'agents', label: 'Agents', path: '/agents', icon: '⬡' },
]
export function Sidebar({
apps,
routes,
agents,
activeItem,
onItemClick,
}: SidebarProps) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [appsCollapsed, setAppsCollapsed] = useState(false)
const [agentsCollapsed, setAgentsCollapsed] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred()
const liveCount = agents.filter((a) => a.status === 'live').length // Build tree data
const agentBadge = `${liveCount}/${agents.length} live` const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
const filteredApps = search // Build starred items
? apps.filter((a) => a.name.toLowerCase().includes(search.toLowerCase())) const starredItems = useMemo(
: apps () => collectStarredItems(apps, starredIds),
[apps, starredIds],
)
const starredApps = starredItems.filter((i) => i.type === 'application')
const starredRoutes = starredItems.filter((i) => i.type === 'route')
const starredAgents = starredItems.filter((i) => i.type === 'agent')
const hasStarred = starredItems.length > 0
return ( return (
<aside className={styles.sidebar}> <aside className={`${styles.sidebar} ${className ?? ''}`}>
{/* Logo */} {/* Logo */}
<div className={styles.logo} onClick={() => navigate('/')} style={{ cursor: 'pointer' }}> <div className={styles.logo} onClick={() => navigate('/')} style={{ cursor: 'pointer' }}>
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} /> <img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
@@ -105,145 +238,148 @@ export function Sidebar({
<input <input
className={styles.searchInput} className={styles.searchInput}
type="text" type="text"
placeholder="Filter apps..." placeholder="Filter..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
</div> </div>
{/* Scrollable nav area */} {/* Navigation (scrollable) — includes starred section */}
<div className={styles.navArea}> <div className={styles.navArea}>
{/* Top-level navigation */}
<div className={styles.section}>Navigation</div> <div className={styles.section}>Navigation</div>
<div className={styles.items}>
{NAV_ITEMS.map((nav) => (
<div
key={nav.id}
className={[
styles.item,
location.pathname === nav.path ? styles.active : '',
].filter(Boolean).join(' ')}
onClick={() => navigate(nav.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate(nav.path) }}
>
<span className={styles.navIcon}>{nav.icon}</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>{nav.label}</div>
</div>
</div>
))}
</div>
<div className={styles.divider} /> {/* Applications tree (collapsible) */}
<div className={styles.treeSection}>
{/* Applications section */} <button
<div className={styles.section}>Applications</div> className={styles.treeSectionToggle}
<div className={styles.items}> onClick={() => setAppsCollapsed((v) => !v)}
{filteredApps.map((app) => ( aria-expanded={!appsCollapsed}
<div >
key={app.id} <span className={styles.treeSectionChevron}>{appsCollapsed ? '▸' : '▾'}</span>
className={[ <span>Applications</span>
styles.item, </button>
activeItem === app.id ? styles.active : '', {!appsCollapsed && (
] <SidebarTree
.filter(Boolean) nodes={appNodes}
.join(' ')} selectedPath={location.pathname}
onClick={() => onItemClick?.(app.id)} isStarred={isStarred}
role="button" onToggleStar={toggleStar}
tabIndex={0} filterQuery={search}
onKeyDown={(e) => { persistKey="cameleer:expanded:apps"
if (e.key === 'Enter' || e.key === ' ') onItemClick?.(app.id)
}}
>
<HealthDot status={app.health} />
<div className={styles.itemInfo}>
<div className={styles.itemName}>{app.name}</div>
<div className={styles.itemMeta}>{app.agentCount} agent{app.agentCount !== 1 ? 's' : ''}</div>
</div>
<span className={styles.itemCount}>{app.exchangeCount.toLocaleString()}</span>
</div>
))}
</div>
{/* Divider */}
<div className={styles.divider} />
{/* Routes section */}
<div className={styles.section}>Routes</div>
<div className={styles.items}>
{routes.map((route) => (
<div
key={route.id}
className={[
styles.item,
styles.indented,
activeItem === route.id || location.pathname === `/routes/${route.id}` ? styles.active : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => { onItemClick?.(route.id); navigate(`/routes/${route.id}`) }}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { onItemClick?.(route.id); navigate(`/routes/${route.id}`) }
}}
>
<span className={styles.routeArrow}>&#9656;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>{route.name}</div>
</div>
<span className={styles.itemCount}>{route.exchangeCount.toLocaleString()}</span>
</div>
))}
</div>
</div>
{/* Agent health section */}
<div className={styles.agentsHeader}>
<span>Agents</span>
<span className={styles.agentBadge}>{agentBadge}</span>
</div>
<div className={styles.agentsList}>
{agents.map((agent) => (
<div key={agent.id} className={styles.agentItem}>
<span
className={[
styles.agentDot,
agent.status === 'live' ? styles.healthLive : '',
agent.status === 'stale' ? styles.healthStale : '',
agent.status === 'dead' ? styles.healthDead : '',
]
.filter(Boolean)
.join(' ')}
/> />
<div className={styles.agentInfo}> )}
<div className={styles.agentName}>{agent.name}</div> </div>
<div className={styles.agentDetail}>{agent.service} {agent.version}</div>
</div> {/* Agents tree (collapsible) */}
<div className={styles.agentStats}> <div className={styles.treeSection}>
<div className={styles.agentTps}>{agent.tps}</div> <button
<div className={styles.treeSectionToggle}
className={agent.errorRate ? styles.agentError : styles.agentLastSeen} onClick={() => setAgentsCollapsed((v) => !v)}
> aria-expanded={!agentsCollapsed}
{agent.errorRate ?? agent.lastSeen} >
</div> <span className={styles.treeSectionChevron}>{agentsCollapsed ? '▸' : '▾'}</span>
<span>Agents</span>
</button>
{!agentsCollapsed && (
<SidebarTree
nodes={agentNodes}
selectedPath={location.pathname}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:agents"
/>
)}
</div>
{/* Dashboards flat link */}
<div className={styles.items}>
<div
className={[
styles.item,
location.pathname === '/' ? styles.active : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/') }}
>
<span className={styles.navIcon}></span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>Dashboards</div>
</div> </div>
</div> </div>
))} </div>
{/* No results message */}
{search && appNodes.length === 0 && agentNodes.length === 0 && (
<div className={styles.noResults}>No results</div>
)}
{/* Starred section (inside scrollable area, hidden when empty) */}
{hasStarred && (
<div className={styles.starredSection}>
<div className={styles.section}>
<span className={styles.starredHeader}> Starred</span>
</div>
<div className={styles.starredList}>
{starredApps.length > 0 && (
<StarredGroup
label="Applications"
items={starredApps}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredRoutes.length > 0 && (
<StarredGroup
label="Routes"
items={starredRoutes}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
{starredAgents.length > 0 && (
<StarredGroup
label="Agents"
items={starredAgents}
onNavigate={navigate}
onRemove={toggleStar}
/>
)}
</div>
</div>
)}
</div> </div>
{/* Bottom links */} {/* Bottom links */}
<div className={styles.bottom}> <div className={styles.bottom}>
<div className={styles.bottomItem}> <div
className={[
styles.bottomItem,
location.pathname === '/admin' ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/admin')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
>
<span className={styles.bottomIcon}>&#9881;</span> <span className={styles.bottomIcon}>&#9881;</span>
<div className={styles.itemInfo}> <div className={styles.itemInfo}>
<div className={styles.itemName}>Admin</div> <div className={styles.itemName}>Admin</div>
</div> </div>
</div> </div>
<div className={styles.bottomItem}> <div
className={[
styles.bottomItem,
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/api-docs')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
>
<span className={styles.bottomIcon}>&#9776;</span> <span className={styles.bottomIcon}>&#9776;</span>
<div className={styles.itemInfo}> <div className={styles.itemInfo}>
<div className={styles.itemName}>API Docs</div> <div className={styles.itemName}>API Docs</div>
@@ -253,3 +389,4 @@ export function Sidebar({
</aside> </aside>
) )
} }

View File

@@ -0,0 +1,429 @@
import {
useState,
useRef,
useCallback,
useMemo,
type ReactNode,
type KeyboardEvent,
type MouseEvent,
} from 'react'
import { useNavigate } from 'react-router-dom'
import styles from './Sidebar.module.css'
// ── Types ────────────────────────────────────────────────────────────────────
export interface SidebarTreeNode {
id: string
label: string
icon?: ReactNode
badge?: string
path?: string
starrable?: boolean
starKey?: string // unique key for starring (defaults to id)
children?: SidebarTreeNode[]
}
export interface SidebarTreeProps {
nodes: SidebarTreeNode[]
selectedPath?: string // current URL path — matches against node.path
isStarred: (id: string) => boolean
onToggleStar: (id: string) => void
className?: string
filterQuery?: string
persistKey?: string // sessionStorage key to persist expand state across remounts
}
// ── Star icon SVGs ───────────────────────────────────────────────────────────
function StarOutline() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
}
function StarFilled() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
}
// ── Persistent expand state ──────────────────────────────────────────────────
function readExpandState(key: string): Set<string> {
try {
const raw = sessionStorage.getItem(key)
if (raw) {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) return new Set(arr)
}
} catch { /* ignore */ }
return new Set()
}
function writeExpandState(key: string, ids: Set<string>): void {
try {
sessionStorage.setItem(key, JSON.stringify([...ids]))
} catch { /* ignore */ }
}
// ── Flat node for keyboard nav ───────────────────────────────────────────────
interface FlatNode {
node: SidebarTreeNode
depth: number
parentId: string | null
}
function flattenVisible(
nodes: SidebarTreeNode[],
expandedIds: Set<string>,
depth = 0,
parentId: string | null = null,
): FlatNode[] {
const result: FlatNode[] = []
for (const node of nodes) {
result.push({ node, depth, parentId })
if (node.children && node.children.length > 0 && expandedIds.has(node.id)) {
result.push(...flattenVisible(node.children, expandedIds, depth + 1, node.id))
}
}
return result
}
// ── Filter logic ─────────────────────────────────────────────────────────────
function filterNodes(
nodes: SidebarTreeNode[],
query: string,
): { filtered: SidebarTreeNode[]; matchedParentIds: Set<string> } {
if (!query) return { filtered: nodes, matchedParentIds: new Set() }
const q = query.toLowerCase()
const matchedParentIds = new Set<string>()
function walk(nodeList: SidebarTreeNode[]): SidebarTreeNode[] {
const result: SidebarTreeNode[] = []
for (const node of nodeList) {
const childResults = node.children ? walk(node.children) : []
const selfMatches = node.label.toLowerCase().includes(q)
if (selfMatches || childResults.length > 0) {
if (childResults.length > 0) {
matchedParentIds.add(node.id)
}
result.push({
...node,
children: childResults.length > 0
? childResults
: node.children?.filter((c) => c.label.toLowerCase().includes(q)),
})
}
}
return result
}
return { filtered: walk(nodes), matchedParentIds }
}
// ── SidebarTree ──────────────────────────────────────────────────────────────
export function SidebarTree({
nodes,
selectedPath,
isStarred,
onToggleStar,
className,
filterQuery,
persistKey,
}: SidebarTreeProps) {
const navigate = useNavigate()
// Expand/collapse state — optionally persisted to sessionStorage
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
() => persistKey ? readExpandState(persistKey) : new Set(),
)
// Filter
const { filtered, matchedParentIds } = useMemo(
() => filterNodes(nodes, filterQuery ?? ''),
[nodes, filterQuery],
)
// Effective expanded set: user toggles + auto-expanded from search
const expandedSet = useMemo(() => {
if (filterQuery) {
return new Set([...userExpandedIds, ...matchedParentIds])
}
return userExpandedIds
}, [userExpandedIds, matchedParentIds, filterQuery])
function handleToggle(id: string) {
setUserExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
if (persistKey) writeExpandState(persistKey, next)
return next
})
}
// Keyboard navigation
const [focusedId, setFocusedId] = useState<string | null>(null)
const treeRef = useRef<HTMLUListElement>(null)
const visibleNodes = useMemo(
() => flattenVisible(filtered, expandedSet),
[filtered, expandedSet],
)
const getFocusedIndex = useCallback(() => {
if (focusedId === null) return -1
return visibleNodes.findIndex((fn) => fn.node.id === focusedId)
}, [focusedId, visibleNodes])
function focusNode(id: string) {
const el = treeRef.current?.querySelector(`[data-nodeid="${CSS.escape(id)}"]`) as HTMLElement | null
if (el) {
el.focus()
} else {
setFocusedId(id)
}
}
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLUListElement>) => {
const currentIndex = getFocusedIndex()
const current = visibleNodes[currentIndex]
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = visibleNodes[currentIndex - 1]
if (prev) focusNode(prev.node.id)
break
}
case 'ArrowRight': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren) {
if (!expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else {
const next = visibleNodes[currentIndex + 1]
if (next) focusNode(next.node.id)
}
}
break
}
case 'ArrowLeft': {
e.preventDefault()
if (!current) break
const hasChildren = current.node.children && current.node.children.length > 0
if (hasChildren && expandedSet.has(current.node.id)) {
handleToggle(current.node.id)
} else if (current.parentId !== null) {
focusNode(current.parentId)
}
break
}
case 'Enter': {
e.preventDefault()
if (current?.node.path) {
navigate(current.node.path)
}
break
}
case 'Home': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[0].node.id)
}
break
}
case 'End': {
e.preventDefault()
if (visibleNodes.length > 0) {
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
}
break
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleNodes, expandedSet, focusedId],
)
return (
<ul
ref={treeRef}
role="tree"
className={`${styles.tree} ${className ?? ''}`}
onKeyDown={handleKeyDown}
>
{filtered.map((node) => (
<SidebarTreeRow
key={node.id}
node={node}
depth={0}
expandedSet={expandedSet}
selectedPath={selectedPath}
focusedId={focusedId}
isStarred={isStarred}
onToggle={handleToggle}
onToggleStar={onToggleStar}
onFocus={setFocusedId}
navigate={navigate}
/>
))}
</ul>
)
}
// ── Row ──────────────────────────────────────────────────────────────────────
interface SidebarTreeRowProps {
node: SidebarTreeNode
depth: number
expandedSet: Set<string>
selectedPath?: string
focusedId: string | null
isStarred: (id: string) => boolean
onToggle: (id: string) => void
onToggleStar: (id: string) => void
onFocus: (id: string) => void
navigate: (path: string) => void
}
function SidebarTreeRow({
node,
depth,
expandedSet,
selectedPath,
focusedId,
isStarred,
onToggle,
onToggleStar,
onFocus,
navigate,
}: SidebarTreeRowProps) {
const hasChildren = node.children && node.children.length > 0
const isExpanded = expandedSet.has(node.id)
const isSelected = Boolean(node.path && selectedPath === node.path)
const isFocused = focusedId === node.id
const effectiveStarKey = node.starKey ?? node.id
const starred = isStarred(effectiveStarKey)
function handleRowClick() {
if (node.path) {
navigate(node.path)
}
}
function handleChevronClick(e: MouseEvent) {
e.stopPropagation()
onToggle(node.id)
}
function handleStarClick(e: MouseEvent) {
e.stopPropagation()
onToggleStar(effectiveStarKey)
}
const rowClass = [
styles.treeRow,
isSelected ? styles.treeRowActive : '',
]
.filter(Boolean)
.join(' ')
return (
<li role="none">
<div
role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
tabIndex={isFocused ? 0 : -1}
data-nodeid={node.id}
className={rowClass}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleRowClick}
onFocus={() => onFocus(node.id)}
>
{/* Chevron */}
<span className={styles.treeChevronSlot}>
{hasChildren ? (
<button
className={styles.treeChevron}
onClick={handleChevronClick}
tabIndex={-1}
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? '▾' : '▸'}
</button>
) : null}
</span>
{/* Icon (health dot, arrow, etc.) */}
{node.icon && (
<span className={styles.treeIcon} aria-hidden="true">
{node.icon}
</span>
)}
{/* Label */}
<span className={styles.treeLabel}>{node.label}</span>
{/* Badge */}
{node.badge && (
<span className={styles.treeBadge}>{node.badge}</span>
)}
{/* Star */}
{node.starrable && (
<button
className={`${styles.treeStar} ${starred ? styles.treeStarActive : ''}`}
onClick={handleStarClick}
tabIndex={-1}
aria-label={starred ? 'Remove from starred' : 'Add to starred'}
>
{starred ? <StarFilled /> : <StarOutline />}
</button>
)}
</div>
{/* Children */}
{hasChildren && isExpanded && (
<ul role="group" className={styles.treeGroup}>
{node.children!.map((child) => (
<SidebarTreeRow
key={child.id}
node={child}
depth={depth + 1}
expandedSet={expandedSet}
selectedPath={selectedPath}
focusedId={focusedId}
isStarred={isStarred}
onToggle={onToggle}
onToggleStar={onToggleStar}
onFocus={onFocus}
navigate={navigate}
/>
))}
</ul>
)}
</li>
)
}

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useStarred } from './useStarred'
describe('useStarred', () => {
beforeEach(() => {
localStorage.clear()
})
it('starts with empty set when no localStorage data', () => {
const { result } = renderHook(() => useStarred())
expect(result.current.starredIds.size).toBe(0)
})
it('toggleStar adds an item', () => {
const { result } = renderHook(() => useStarred())
act(() => { result.current.toggleStar('app-1') })
expect(result.current.isStarred('app-1')).toBe(true)
expect(result.current.starredIds.size).toBe(1)
})
it('toggleStar removes a starred item', () => {
const { result } = renderHook(() => useStarred())
act(() => { result.current.toggleStar('app-1') })
act(() => { result.current.toggleStar('app-1') })
expect(result.current.isStarred('app-1')).toBe(false)
})
it('persists to localStorage', () => {
const { result } = renderHook(() => useStarred())
act(() => { result.current.toggleStar('route-1') })
const stored = JSON.parse(localStorage.getItem('cameleer:starred') ?? '[]')
expect(stored).toContain('route-1')
})
it('reads from localStorage on mount', () => {
localStorage.setItem('cameleer:starred', JSON.stringify(['agent-1', 'route-2']))
const { result } = renderHook(() => useStarred())
expect(result.current.isStarred('agent-1')).toBe(true)
expect(result.current.isStarred('route-2')).toBe(true)
})
it('handles corrupted localStorage gracefully', () => {
localStorage.setItem('cameleer:starred', 'not-valid-json')
const { result } = renderHook(() => useStarred())
expect(result.current.starredIds.size).toBe(0)
})
})

View File

@@ -0,0 +1,45 @@
import { useState, useCallback } from 'react'
const STORAGE_KEY = 'cameleer:starred'
function readStarred(): Set<string> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) return new Set(arr)
}
} catch {
// localStorage unavailable (private browsing, quota exceeded) — fall back to empty
}
return new Set()
}
function writeStarred(ids: Set<string>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]))
} catch {
// Silently fail if localStorage unavailable
}
}
export function useStarred() {
const [starredIds, setStarredIds] = useState<Set<string>>(readStarred)
const isStarred = useCallback((id: string) => starredIds.has(id), [starredIds])
const toggleStar = useCallback((id: string) => {
setStarredIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
writeStarred(next)
return next
})
}, [])
return { starredIds, isStarred, toggleStar }
}

View File

@@ -1,4 +1,4 @@
export { AppShell } from './AppShell/AppShell' export { AppShell } from './AppShell/AppShell'
export { Sidebar } from './Sidebar/Sidebar' export { Sidebar } from './Sidebar/Sidebar'
export type { App, Route, Agent } from './Sidebar/Sidebar' export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
export { TopBar } from './TopBar/TopBar' export { TopBar } from './TopBar/TopBar'

View File

@@ -1,6 +1,12 @@
import type { Agent } from '../design-system/layout/Sidebar/Sidebar' export interface AgentHealth {
id: string
export interface AgentHealth extends Agent { name: string
service: string
version: string
tps: string
lastSeen: string
status: 'live' | 'stale' | 'dead'
errorRate?: string
uptime: string uptime: string
memoryUsagePct: number memoryUsagePct: number
cpuUsagePct: number cpuUsagePct: number

75
src/mocks/sidebar.ts Normal file
View File

@@ -0,0 +1,75 @@
export interface SidebarRoute {
id: string
name: string
exchangeCount: number
}
export interface SidebarAgent {
id: string
name: string
status: 'live' | 'stale' | 'dead'
tps: string
}
export interface SidebarApp {
id: string
name: string
health: 'live' | 'stale' | 'dead'
exchangeCount: number
routes: SidebarRoute[]
agents: SidebarAgent[]
}
export const SIDEBAR_APPS: SidebarApp[] = [
{
id: 'order-service',
name: 'order-service',
health: 'live',
exchangeCount: 1433,
routes: [
{ id: 'order-intake', name: 'order-intake', exchangeCount: 892 },
{ id: 'order-enrichment', name: 'order-enrichment', exchangeCount: 541 },
],
agents: [
{ id: 'prod-1', name: 'prod-1', status: 'live', tps: '14.2/s' },
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: '11.8/s' },
],
},
{
id: 'payment-svc',
name: 'payment-svc',
health: 'live',
exchangeCount: 912,
routes: [
{ id: 'payment-process', name: 'payment-process', exchangeCount: 414 },
{ id: 'payment-validate', name: 'payment-validate', exchangeCount: 498 },
],
agents: [
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: '11.8/s' },
],
},
{
id: 'shipment-tracker',
name: 'shipment-tracker',
health: 'live',
exchangeCount: 471,
routes: [
{ id: 'shipment-dispatch', name: 'shipment-dispatch', exchangeCount: 387 },
{ id: 'shipment-track', name: 'shipment-track', exchangeCount: 923 },
],
agents: [
{ id: 'prod-3', name: 'prod-3', status: 'live', tps: '12.1/s' },
{ id: 'prod-4', name: 'prod-4', status: 'live', tps: '9.1/s' },
],
},
{
id: 'notification-hub',
name: 'notification-hub',
health: 'stale',
exchangeCount: 128,
routes: [
{ id: 'notification-dispatch', name: 'notification-dispatch', exchangeCount: 471 },
],
agents: [],
},
]

22
src/pages/Admin/Admin.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
export function Admin() {
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[{ label: 'Admin' }]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<EmptyState
title="Admin Panel"
description="Admin panel coming soon."
/>
</AppShell>
)
}

View File

@@ -0,0 +1,28 @@
import { useParams } from 'react-router-dom'
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
export function AgentDetail() {
const { id } = useParams<{ id: string }>()
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[
{ label: 'Agents', href: '/agents' },
{ label: id ?? '' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<EmptyState
title="Agent Detail"
description="Agent detail view coming soon."
/>
</AppShell>
)
}

View File

@@ -1,5 +1,4 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import styles from './AgentHealth.module.css' import styles from './AgentHealth.module.css'
// Layout // Layout
@@ -18,21 +17,7 @@ import { Card } from '../../design-system/primitives/Card/Card'
// Mock data // Mock data
import { agents } from '../../mocks/agents' import { agents } from '../../mocks/agents'
import { routes } from '../../mocks/routes' import { SIDEBAR_APPS } from '../../mocks/sidebar'
// ─── Sidebar data (shared) ────────────────────────────────────────────────────
const APPS = [
{ id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, exchangeCount: 1433 },
{ id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, exchangeCount: 912 },
{ id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, exchangeCount: 471 },
{ id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, exchangeCount: 128 },
]
const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({
id: r.id,
name: r.name,
exchangeCount: r.exchangeCount,
}))
// ─── Build trend data for each agent ───────────────────────────────────────── // ─── Build trend data for each agent ─────────────────────────────────────────
function buildAgentTrendSeries(agentId: string) { function buildAgentTrendSeries(agentId: string) {
@@ -68,16 +53,8 @@ const totalActiveRoutes = agents.reduce((sum, a) => sum + a.activeRoutes, 0)
// ─── AgentHealth page ───────────────────────────────────────────────────────── // ─── AgentHealth page ─────────────────────────────────────────────────────────
export function AgentHealth() { export function AgentHealth() {
const navigate = useNavigate()
const [activeItem, setActiveItem] = useState('agents')
const [expandedAgent, setExpandedAgent] = useState<string | null>(null) const [expandedAgent, setExpandedAgent] = useState<string | null>(null)
function handleItemClick(id: string) {
setActiveItem(id)
const route = routes.find((r) => r.id === id)
if (route) navigate(`/routes/${id}`)
}
function toggleAgent(id: string) { function toggleAgent(id: string) {
setExpandedAgent((prev) => (prev === id ? null : id)) setExpandedAgent((prev) => (prev === id ? null : id))
} }
@@ -85,13 +62,7 @@ export function AgentHealth() {
return ( return (
<AppShell <AppShell
sidebar={ sidebar={
<Sidebar <Sidebar apps={SIDEBAR_APPS} />
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
} }
> >
{/* Top bar */} {/* Top bar */}

View File

@@ -0,0 +1,22 @@
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
export function ApiDocs() {
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[{ label: 'API Documentation' }]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<EmptyState
title="API Documentation"
description="API documentation coming soon."
/>
</AppShell>
)
}

View File

@@ -0,0 +1,28 @@
import { useParams } from 'react-router-dom'
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
export function AppDetail() {
const { id } = useParams<{ id: string }>()
return (
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/' },
{ label: id ?? '' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<EmptyState
title="Application Detail"
description="Application detail view coming soon."
/>
</AppShell>
)
}

View File

@@ -28,21 +28,7 @@ import { exchanges, type Exchange } from '../../mocks/exchanges'
import { routes } from '../../mocks/routes' import { routes } from '../../mocks/routes'
import { agents } from '../../mocks/agents' import { agents } from '../../mocks/agents'
import { kpiMetrics } from '../../mocks/metrics' import { kpiMetrics } from '../../mocks/metrics'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
// ─── Sidebar app list (static) ───────────────────────────────────────────────
const APPS = [
{ id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, exchangeCount: 1433 },
{ id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, exchangeCount: 912 },
{ id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, exchangeCount: 471 },
{ id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, exchangeCount: 128 },
]
// ─── Sidebar routes (top 3) ───────────────────────────────────────────────────
const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({
id: r.id,
name: r.name,
exchangeCount: r.exchangeCount,
}))
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -208,7 +194,6 @@ const SHORTCUTS = [
// ─── Dashboard component ────────────────────────────────────────────────────── // ─── Dashboard component ──────────────────────────────────────────────────────
export function Dashboard() { export function Dashboard() {
const [activeItem, setActiveItem] = useState('order-service')
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([]) const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState<string | undefined>() const [selectedId, setSelectedId] = useState<string | undefined>()
@@ -349,13 +334,7 @@ export function Dashboard() {
return ( return (
<AppShell <AppShell
sidebar={ sidebar={
<Sidebar <Sidebar apps={SIDEBAR_APPS} />
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={setActiveItem}
/>
} }
detail={ detail={
selectedExchange ? ( selectedExchange ? (

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react' import { useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import styles from './ExchangeDetail.module.css' import styles from './ExchangeDetail.module.css'
@@ -21,22 +21,7 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
// Mock data // Mock data
import { exchanges } from '../../mocks/exchanges' import { exchanges } from '../../mocks/exchanges'
import { routes } from '../../mocks/routes' import { SIDEBAR_APPS } from '../../mocks/sidebar'
import { agents } from '../../mocks/agents'
// ─── Sidebar data (shared) ────────────────────────────────────────────────────
const APPS = [
{ id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, exchangeCount: 1433 },
{ id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, exchangeCount: 912 },
{ id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, exchangeCount: 471 },
{ id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, exchangeCount: 128 },
]
const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({
id: r.id,
name: r.name,
exchangeCount: r.exchangeCount,
}))
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -119,28 +104,15 @@ function generateExchangeSnapshot(
export function ExchangeDetail() { export function ExchangeDetail() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [activeItem, setActiveItem] = useState('')
const exchange = useMemo(() => exchanges.find((e) => e.id === id), [id]) const exchange = useMemo(() => exchanges.find((e) => e.id === id), [id])
function handleItemClick(itemId: string) {
setActiveItem(itemId)
const route = routes.find((r) => r.id === itemId)
if (route) navigate(`/routes/${itemId}`)
}
// Not found state // Not found state
if (!exchange) { if (!exchange) {
return ( return (
<AppShell <AppShell
sidebar={ sidebar={
<Sidebar <Sidebar apps={SIDEBAR_APPS} />
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
} }
> >
<TopBar <TopBar
@@ -166,13 +138,7 @@ export function ExchangeDetail() {
return ( return (
<AppShell <AppShell
sidebar={ sidebar={
<Sidebar <Sidebar apps={SIDEBAR_APPS} />
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
} }
> >
{/* Top bar */} {/* Top bar */}

View File

@@ -1,5 +1,6 @@
import styles from './LayoutSection.module.css' import styles from './LayoutSection.module.css'
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar' import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
import type { SidebarApp } from '../../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../../design-system/layout/TopBar/TopBar' import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
// ── DemoCard helper ────────────────────────────────────────────────────────── // ── DemoCard helper ──────────────────────────────────────────────────────────
@@ -21,48 +22,42 @@ function DemoCard({ id, title, description, children }: DemoCardProps) {
) )
} }
// ── Sample data ─────────────────────────────────────────────────────────────── // ── Sample data (hierarchical) ───────────────────────────────────────────────
const SAMPLE_APPS = [ const SAMPLE_APPS: SidebarApp[] = [
{ id: 'app1', name: 'cameleer-prod', agentCount: 3, health: 'live' as const, exchangeCount: 14320 },
{ id: 'app2', name: 'cameleer-staging', agentCount: 2, health: 'stale' as const, exchangeCount: 871 },
{ id: 'app3', name: 'cameleer-dev', agentCount: 1, health: 'dead' as const, exchangeCount: 42 },
]
const SAMPLE_ROUTES = [
{ id: 'r1', name: 'order-ingest', exchangeCount: 5421 },
{ id: 'r2', name: 'payment-validate', exchangeCount: 3102 },
{ id: 'r3', name: 'notify-customer', exchangeCount: 2201 },
]
const SAMPLE_AGENTS = [
{ {
id: 'ag1', id: 'app1',
name: 'agent-prod-1', name: 'cameleer-prod',
service: 'camel-core', health: 'live' as const,
version: 'v3.2.1', exchangeCount: 14320,
tps: '42 tps', routes: [
lastSeen: '1m ago', { id: 'r1', name: 'order-ingest', exchangeCount: 5421 },
status: 'live' as const, { id: 'r2', name: 'payment-validate', exchangeCount: 3102 },
],
agents: [
{ id: 'ag1', name: 'agent-prod-1', status: 'live' as const, tps: '42 tps' },
{ id: 'ag2', name: 'agent-prod-2', status: 'live' as const, tps: '38 tps' },
],
}, },
{ {
id: 'ag2', id: 'app2',
name: 'agent-prod-2', name: 'cameleer-staging',
service: 'camel-core', health: 'stale' as const,
version: 'v3.2.1', exchangeCount: 871,
tps: '38 tps', routes: [
lastSeen: '2m ago', { id: 'r3', name: 'notify-customer', exchangeCount: 2201 },
status: 'live' as const, ],
errorRate: '0.4%', agents: [
{ id: 'ag3', name: 'agent-staging-1', status: 'stale' as const, tps: '5 tps' },
],
}, },
{ {
id: 'ag3', id: 'app3',
name: 'agent-staging-1', name: 'cameleer-dev',
service: 'camel-core', health: 'dead' as const,
version: 'v3.1.9', exchangeCount: 42,
tps: '5 tps', routes: [],
lastSeen: '8m ago', agents: [],
status: 'stale' as const,
}, },
] ]
@@ -89,9 +84,9 @@ export function LayoutSection() {
<span style={{ fontWeight: 400, fontSize: 10, marginTop: 4 }}>Logo</span> <span style={{ fontWeight: 400, fontSize: 10, marginTop: 4 }}>Logo</span>
<span style={{ fontWeight: 400, fontSize: 10 }}>Search</span> <span style={{ fontWeight: 400, fontSize: 10 }}>Search</span>
<span style={{ fontWeight: 400, fontSize: 10 }}>Navigation</span> <span style={{ fontWeight: 400, fontSize: 10 }}>Navigation</span>
<span style={{ fontWeight: 400, fontSize: 10 }}>Applications</span> <span style={{ fontWeight: 400, fontSize: 10 }}>Applications tree</span>
<span style={{ fontWeight: 400, fontSize: 10 }}>Routes</span> <span style={{ fontWeight: 400, fontSize: 10 }}>Agents tree</span>
<span style={{ fontWeight: 400, fontSize: 10 }}>Agents</span> <span style={{ fontWeight: 400, fontSize: 10 }}>Starred</span>
</div> </div>
<div className={styles.shellDiagramMain}> <div className={styles.shellDiagramMain}>
&lt;children&gt; page content rendered here &lt;children&gt; page content rendered here
@@ -104,14 +99,10 @@ export function LayoutSection() {
<DemoCard <DemoCard
id="sidebar" id="sidebar"
title="Sidebar" title="Sidebar"
description="Navigation sidebar with app/route/agent sections, search filter, health dots, and exec counts." description="Navigation sidebar with hierarchical app/route/agent trees, starring, search filter, and bottom links."
> >
<div className={styles.sidebarPreview}> <div className={styles.sidebarPreview}>
<Sidebar <Sidebar apps={SAMPLE_APPS} />
apps={SAMPLE_APPS}
routes={SAMPLE_ROUTES}
agents={SAMPLE_AGENTS}
/>
</div> </div>
</DemoCard> </DemoCard>

View File

@@ -29,22 +29,7 @@ import {
routeMetrics, routeMetrics,
type RouteMetricRow, type RouteMetricRow,
} from '../../mocks/metrics' } from '../../mocks/metrics'
import { routes } from '../../mocks/routes' import { SIDEBAR_APPS } from '../../mocks/sidebar'
import { agents } from '../../mocks/agents'
// ─── Sidebar data (shared) ────────────────────────────────────────────────────
const APPS = [
{ id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, exchangeCount: 1433 },
{ id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, exchangeCount: 912 },
{ id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, exchangeCount: 471 },
{ id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, exchangeCount: 128 },
]
const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({
id: r.id,
name: r.name,
exchangeCount: r.exchangeCount,
}))
// ─── Metrics KPI cards (5 cards per spec) ───────────────────────────────────── // ─── Metrics KPI cards (5 cards per spec) ─────────────────────────────────────
const METRIC_KPIS = [ const METRIC_KPIS = [
@@ -207,29 +192,15 @@ function convertSeries(series: typeof throughputSeries) {
// ─── Metrics page ───────────────────────────────────────────────────────────── // ─── Metrics page ─────────────────────────────────────────────────────────────
export function Metrics() { export function Metrics() {
const navigate = useNavigate() const navigate = useNavigate()
const [activeItem, setActiveItem] = useState('order-service')
const [dateRange, setDateRange] = useState({ const [dateRange, setDateRange] = useState({
start: new Date('2026-03-18T06:00:00'), start: new Date('2026-03-18T06:00:00'),
end: new Date('2026-03-18T09:15:00'), end: new Date('2026-03-18T09:15:00'),
}) })
function handleItemClick(id: string) {
setActiveItem(id)
// Navigate to route detail if it's a route
const route = routes.find((r) => r.id === id)
if (route) navigate(`/routes/${id}`)
}
return ( return (
<AppShell <AppShell
sidebar={ sidebar={
<Sidebar <Sidebar apps={SIDEBAR_APPS} />
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
} }
> >
{/* Top bar */} {/* Top bar */}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react' import { useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import styles from './RouteDetail.module.css' import styles from './RouteDetail.module.css'
@@ -21,21 +21,7 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
// Mock data // Mock data
import { routes } from '../../mocks/routes' import { routes } from '../../mocks/routes'
import { exchanges, type Exchange } from '../../mocks/exchanges' import { exchanges, type Exchange } from '../../mocks/exchanges'
import { agents } from '../../mocks/agents' import { SIDEBAR_APPS } from '../../mocks/sidebar'
// ─── Sidebar data (shared) ────────────────────────────────────────────────────
const APPS = [
{ id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, exchangeCount: 1433 },
{ id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, exchangeCount: 912 },
{ id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, exchangeCount: 471 },
{ id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, exchangeCount: 128 },
]
const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({
id: r.id,
name: r.name,
exchangeCount: r.exchangeCount,
}))
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -143,7 +129,6 @@ const EXCHANGE_COLUMNS: Column<Exchange>[] = [
export function RouteDetail() { export function RouteDetail() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [activeItem, setActiveItem] = useState(id ?? '')
const route = useMemo(() => routes.find((r) => r.id === id), [id]) const route = useMemo(() => routes.find((r) => r.id === id), [id])
const routeExchanges = useMemo( const routeExchanges = useMemo(
@@ -210,24 +195,12 @@ export function RouteDetail() {
? ((successCount / routeExchanges.length) * 100).toFixed(1) ? ((successCount / routeExchanges.length) * 100).toFixed(1)
: '0.0' : '0.0'
function handleItemClick(itemId: string) {
setActiveItem(itemId)
const r = routes.find((route) => route.id === itemId)
if (r) navigate(`/routes/${itemId}`)
}
// Not found state // Not found state
if (!route) { if (!route) {
return ( return (
<AppShell <AppShell
sidebar={ sidebar={
<Sidebar <Sidebar apps={SIDEBAR_APPS} />
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
} }
> >
<TopBar <TopBar
@@ -252,13 +225,7 @@ export function RouteDetail() {
return ( return (
<AppShell <AppShell
sidebar={ sidebar={
<Sidebar <Sidebar apps={SIDEBAR_APPS} />
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
} }
> >
{/* Top bar */} {/* Top bar */}