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:
@@ -5,6 +5,10 @@ import { RouteDetail } from './pages/RouteDetail/RouteDetail'
|
||||
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
|
||||
import { AgentHealth } from './pages/AgentHealth/AgentHealth'
|
||||
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() {
|
||||
return (
|
||||
@@ -14,6 +18,10 @@ export default function App() {
|
||||
<Route path="/routes/:id" element={<RouteDetail />} />
|
||||
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
||||
<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 />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
background: var(--sidebar-bg);
|
||||
display: flex;
|
||||
@@ -102,7 +102,7 @@
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* Nav item */
|
||||
/* Nav item (flat links like Dashboards) */
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -129,16 +129,6 @@
|
||||
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 {
|
||||
font-size: 14px;
|
||||
width: 18px;
|
||||
@@ -154,6 +144,7 @@
|
||||
.routeArrow {
|
||||
color: var(--sidebar-muted);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Item sub-elements */
|
||||
@@ -169,143 +160,278 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
font-size: 11px;
|
||||
/* No results */
|
||||
.noResults {
|
||||
padding: 16px 18px;
|
||||
font-size: 12px;
|
||||
color: var(--sidebar-muted);
|
||||
font-family: var(--font-mono);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.itemCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--sidebar-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
/* ── SidebarTree styles ──────────────────────────────────────────────────── */
|
||||
|
||||
.treeSection {
|
||||
padding: 0 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Health dots */
|
||||
.healthDot {
|
||||
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;
|
||||
.treeSectionLabel {
|
||||
padding: 10px 12px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--sidebar-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agentBadge {
|
||||
font-family: var(--font-mono);
|
||||
/* Collapsible section toggle */
|
||||
.treeSectionToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 8px 12px 4px;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(93, 184, 102, 0.15);
|
||||
color: #5db866;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--sidebar-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
|
||||
/* Agents list */
|
||||
.agentsList {
|
||||
padding: 0 0 6px;
|
||||
overflow-y: auto;
|
||||
max-height: 180px;
|
||||
flex-shrink: 0;
|
||||
.treeSectionToggle:hover {
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
margin: 0 6px 2px;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
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);
|
||||
}
|
||||
|
||||
.agentDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
.treeRowActive {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.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;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agentName {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agentDetail {
|
||||
font-size: 10px;
|
||||
color: var(--sidebar-muted);
|
||||
}
|
||||
|
||||
.agentStats {
|
||||
text-align: right;
|
||||
/* Badge */
|
||||
.treeBadge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--sidebar-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agentTps {
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
.agentLastSeen {
|
||||
/* Star button */
|
||||
.treeStar {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Bottom links */
|
||||
/* ── Bottom links ────────────────────────────────────────────────────────── */
|
||||
|
||||
.bottom {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 6px;
|
||||
@@ -331,6 +457,12 @@
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
.bottomItemActive {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.bottomIcon {
|
||||
font-size: 13px;
|
||||
width: 18px;
|
||||
|
||||
172
src/design-system/layout/Sidebar/Sidebar.test.tsx
Normal file
172
src/design-system/layout/Sidebar/Sidebar.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -1,89 +1,222 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import styles from './Sidebar.module.css'
|
||||
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
|
||||
name: string
|
||||
agentCount: number
|
||||
health: 'live' | 'stale' | 'dead'
|
||||
exchangeCount: number
|
||||
routes: SidebarRoute[]
|
||||
agents: SidebarAgent[]
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
export interface SidebarRoute {
|
||||
id: string
|
||||
name: string
|
||||
exchangeCount: number
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
export interface SidebarAgent {
|
||||
id: string
|
||||
name: string
|
||||
service: string
|
||||
version: string
|
||||
tps: string
|
||||
lastSeen: string
|
||||
status: 'live' | 'stale' | 'dead'
|
||||
errorRate?: string
|
||||
tps: string
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
apps: App[]
|
||||
routes: Route[]
|
||||
agents: Agent[]
|
||||
activeItem?: string
|
||||
onItemClick?: (id: string) => void
|
||||
apps: SidebarApp[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
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}>▸</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 (
|
||||
<span
|
||||
className={[
|
||||
styles.healthDot,
|
||||
status === 'live' ? styles.healthLive : '',
|
||||
status === 'stale' ? styles.healthStale : '',
|
||||
status === 'dead' ? styles.healthDead : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
/>
|
||||
<div className={styles.starredGroup}>
|
||||
<div className={styles.starredGroupLabel}>{label}</div>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.starKey}
|
||||
className={styles.starredItem}
|
||||
onClick={() => onNavigate(item.path)}
|
||||
role="button"
|
||||
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 {
|
||||
id: string
|
||||
label: string
|
||||
path: string
|
||||
icon: string
|
||||
}
|
||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ 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) {
|
||||
export function Sidebar({ apps, className }: SidebarProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [appsCollapsed, setAppsCollapsed] = useState(false)
|
||||
const [agentsCollapsed, setAgentsCollapsed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||
|
||||
const liveCount = agents.filter((a) => a.status === 'live').length
|
||||
const agentBadge = `${liveCount}/${agents.length} live`
|
||||
// Build tree data
|
||||
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
||||
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
|
||||
|
||||
const filteredApps = search
|
||||
? apps.filter((a) => a.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: apps
|
||||
// Build starred items
|
||||
const starredItems = useMemo(
|
||||
() => 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 (
|
||||
<aside className={styles.sidebar}>
|
||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||
{/* Logo */}
|
||||
<div className={styles.logo} onClick={() => navigate('/')} style={{ cursor: 'pointer' }}>
|
||||
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
||||
@@ -105,145 +238,148 @@ export function Sidebar({
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter apps..."
|
||||
placeholder="Filter..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable nav area */}
|
||||
{/* Navigation (scrollable) — includes starred section */}
|
||||
<div className={styles.navArea}>
|
||||
{/* Top-level navigation */}
|
||||
<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 section */}
|
||||
<div className={styles.section}>Applications</div>
|
||||
<div className={styles.items}>
|
||||
{filteredApps.map((app) => (
|
||||
<div
|
||||
key={app.id}
|
||||
className={[
|
||||
styles.item,
|
||||
activeItem === app.id ? styles.active : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => onItemClick?.(app.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
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}>▸</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(' ')}
|
||||
{/* Applications tree (collapsible) */}
|
||||
<div className={styles.treeSection}>
|
||||
<button
|
||||
className={styles.treeSectionToggle}
|
||||
onClick={() => setAppsCollapsed((v) => !v)}
|
||||
aria-expanded={!appsCollapsed}
|
||||
>
|
||||
<span className={styles.treeSectionChevron}>{appsCollapsed ? '▸' : '▾'}</span>
|
||||
<span>Applications</span>
|
||||
</button>
|
||||
{!appsCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={appNodes}
|
||||
selectedPath={location.pathname}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:apps"
|
||||
/>
|
||||
<div className={styles.agentInfo}>
|
||||
<div className={styles.agentName}>{agent.name}</div>
|
||||
<div className={styles.agentDetail}>{agent.service} {agent.version}</div>
|
||||
</div>
|
||||
<div className={styles.agentStats}>
|
||||
<div className={styles.agentTps}>{agent.tps}</div>
|
||||
<div
|
||||
className={agent.errorRate ? styles.agentError : styles.agentLastSeen}
|
||||
>
|
||||
{agent.errorRate ?? agent.lastSeen}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agents tree (collapsible) */}
|
||||
<div className={styles.treeSection}>
|
||||
<button
|
||||
className={styles.treeSectionToggle}
|
||||
onClick={() => setAgentsCollapsed((v) => !v)}
|
||||
aria-expanded={!agentsCollapsed}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Bottom links */}
|
||||
<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}>⚙</span>
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>Admin</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}>☰</span>
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>API Docs</div>
|
||||
@@ -253,3 +389,4 @@ export function Sidebar({
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
429
src/design-system/layout/Sidebar/SidebarTree.tsx
Normal file
429
src/design-system/layout/Sidebar/SidebarTree.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
src/design-system/layout/Sidebar/useStarred.test.ts
Normal file
48
src/design-system/layout/Sidebar/useStarred.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
45
src/design-system/layout/Sidebar/useStarred.ts
Normal file
45
src/design-system/layout/Sidebar/useStarred.ts
Normal 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 }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export { AppShell } from './AppShell/AppShell'
|
||||
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'
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Agent } from '../design-system/layout/Sidebar/Sidebar'
|
||||
|
||||
export interface AgentHealth extends Agent {
|
||||
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
|
||||
|
||||
75
src/mocks/sidebar.ts
Normal file
75
src/mocks/sidebar.ts
Normal 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
22
src/pages/Admin/Admin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/pages/AgentDetail/AgentDetail.tsx
Normal file
28
src/pages/AgentDetail/AgentDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import styles from './AgentHealth.module.css'
|
||||
|
||||
// Layout
|
||||
@@ -18,21 +17,7 @@ import { Card } from '../../design-system/primitives/Card/Card'
|
||||
|
||||
// Mock data
|
||||
import { agents } from '../../mocks/agents'
|
||||
import { routes } from '../../mocks/routes'
|
||||
|
||||
// ─── 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,
|
||||
}))
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
|
||||
// ─── Build trend data for each agent ─────────────────────────────────────────
|
||||
function buildAgentTrendSeries(agentId: string) {
|
||||
@@ -68,16 +53,8 @@ const totalActiveRoutes = agents.reduce((sum, a) => sum + a.activeRoutes, 0)
|
||||
|
||||
// ─── AgentHealth page ─────────────────────────────────────────────────────────
|
||||
export function AgentHealth() {
|
||||
const navigate = useNavigate()
|
||||
const [activeItem, setActiveItem] = useState('agents')
|
||||
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) {
|
||||
setExpandedAgent((prev) => (prev === id ? null : id))
|
||||
}
|
||||
@@ -85,13 +62,7 @@ export function AgentHealth() {
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
apps={APPS}
|
||||
routes={SIDEBAR_ROUTES}
|
||||
agents={agents}
|
||||
activeItem={activeItem}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
{/* Top bar */}
|
||||
|
||||
22
src/pages/ApiDocs/ApiDocs.tsx
Normal file
22
src/pages/ApiDocs/ApiDocs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/pages/AppDetail/AppDetail.tsx
Normal file
28
src/pages/AppDetail/AppDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -28,21 +28,7 @@ import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||
import { routes } from '../../mocks/routes'
|
||||
import { agents } from '../../mocks/agents'
|
||||
import { kpiMetrics } from '../../mocks/metrics'
|
||||
|
||||
// ─── 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,
|
||||
}))
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -208,7 +194,6 @@ const SHORTCUTS = [
|
||||
|
||||
// ─── Dashboard component ──────────────────────────────────────────────────────
|
||||
export function Dashboard() {
|
||||
const [activeItem, setActiveItem] = useState('order-service')
|
||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||
@@ -349,13 +334,7 @@ export function Dashboard() {
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
apps={APPS}
|
||||
routes={SIDEBAR_ROUTES}
|
||||
agents={agents}
|
||||
activeItem={activeItem}
|
||||
onItemClick={setActiveItem}
|
||||
/>
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
detail={
|
||||
selectedExchange ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import styles from './ExchangeDetail.module.css'
|
||||
|
||||
@@ -21,22 +21,7 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
|
||||
|
||||
// Mock data
|
||||
import { exchanges } from '../../mocks/exchanges'
|
||||
import { routes } from '../../mocks/routes'
|
||||
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,
|
||||
}))
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -119,28 +104,15 @@ function generateExchangeSnapshot(
|
||||
export function ExchangeDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [activeItem, setActiveItem] = useState('')
|
||||
|
||||
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
|
||||
if (!exchange) {
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
apps={APPS}
|
||||
routes={SIDEBAR_ROUTES}
|
||||
agents={agents}
|
||||
activeItem={activeItem}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
<TopBar
|
||||
@@ -166,13 +138,7 @@ export function ExchangeDetail() {
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
apps={APPS}
|
||||
routes={SIDEBAR_ROUTES}
|
||||
agents={agents}
|
||||
activeItem={activeItem}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
{/* Top bar */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import styles from './LayoutSection.module.css'
|
||||
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'
|
||||
|
||||
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
||||
@@ -21,48 +22,42 @@ function DemoCard({ id, title, description, children }: DemoCardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sample data ───────────────────────────────────────────────────────────────
|
||||
// ── Sample data (hierarchical) ───────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_APPS = [
|
||||
{ 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 = [
|
||||
const SAMPLE_APPS: SidebarApp[] = [
|
||||
{
|
||||
id: 'ag1',
|
||||
name: 'agent-prod-1',
|
||||
service: 'camel-core',
|
||||
version: 'v3.2.1',
|
||||
tps: '42 tps',
|
||||
lastSeen: '1m ago',
|
||||
status: 'live' as const,
|
||||
id: 'app1',
|
||||
name: 'cameleer-prod',
|
||||
health: 'live' as const,
|
||||
exchangeCount: 14320,
|
||||
routes: [
|
||||
{ id: 'r1', name: 'order-ingest', exchangeCount: 5421 },
|
||||
{ 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',
|
||||
name: 'agent-prod-2',
|
||||
service: 'camel-core',
|
||||
version: 'v3.2.1',
|
||||
tps: '38 tps',
|
||||
lastSeen: '2m ago',
|
||||
status: 'live' as const,
|
||||
errorRate: '0.4%',
|
||||
id: 'app2',
|
||||
name: 'cameleer-staging',
|
||||
health: 'stale' as const,
|
||||
exchangeCount: 871,
|
||||
routes: [
|
||||
{ id: 'r3', name: 'notify-customer', exchangeCount: 2201 },
|
||||
],
|
||||
agents: [
|
||||
{ id: 'ag3', name: 'agent-staging-1', status: 'stale' as const, tps: '5 tps' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ag3',
|
||||
name: 'agent-staging-1',
|
||||
service: 'camel-core',
|
||||
version: 'v3.1.9',
|
||||
tps: '5 tps',
|
||||
lastSeen: '8m ago',
|
||||
status: 'stale' as const,
|
||||
id: 'app3',
|
||||
name: 'cameleer-dev',
|
||||
health: 'dead' as const,
|
||||
exchangeCount: 42,
|
||||
routes: [],
|
||||
agents: [],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -89,9 +84,9 @@ export function LayoutSection() {
|
||||
<span style={{ fontWeight: 400, fontSize: 10, marginTop: 4 }}>Logo</span>
|
||||
<span style={{ fontWeight: 400, fontSize: 10 }}>Search</span>
|
||||
<span style={{ fontWeight: 400, fontSize: 10 }}>Navigation</span>
|
||||
<span style={{ fontWeight: 400, fontSize: 10 }}>Applications</span>
|
||||
<span style={{ fontWeight: 400, fontSize: 10 }}>Routes</span>
|
||||
<span style={{ fontWeight: 400, fontSize: 10 }}>Agents</span>
|
||||
<span style={{ fontWeight: 400, fontSize: 10 }}>Applications tree</span>
|
||||
<span style={{ fontWeight: 400, fontSize: 10 }}>Agents tree</span>
|
||||
<span style={{ fontWeight: 400, fontSize: 10 }}>Starred</span>
|
||||
</div>
|
||||
<div className={styles.shellDiagramMain}>
|
||||
<children> — page content rendered here
|
||||
@@ -104,14 +99,10 @@ export function LayoutSection() {
|
||||
<DemoCard
|
||||
id="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}>
|
||||
<Sidebar
|
||||
apps={SAMPLE_APPS}
|
||||
routes={SAMPLE_ROUTES}
|
||||
agents={SAMPLE_AGENTS}
|
||||
/>
|
||||
<Sidebar apps={SAMPLE_APPS} />
|
||||
</div>
|
||||
</DemoCard>
|
||||
|
||||
|
||||
@@ -29,22 +29,7 @@ import {
|
||||
routeMetrics,
|
||||
type RouteMetricRow,
|
||||
} from '../../mocks/metrics'
|
||||
import { routes } from '../../mocks/routes'
|
||||
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,
|
||||
}))
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
|
||||
// ─── Metrics KPI cards (5 cards per spec) ─────────────────────────────────────
|
||||
const METRIC_KPIS = [
|
||||
@@ -207,29 +192,15 @@ function convertSeries(series: typeof throughputSeries) {
|
||||
// ─── Metrics page ─────────────────────────────────────────────────────────────
|
||||
export function Metrics() {
|
||||
const navigate = useNavigate()
|
||||
const [activeItem, setActiveItem] = useState('order-service')
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: new Date('2026-03-18T06:00: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 (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
apps={APPS}
|
||||
routes={SIDEBAR_ROUTES}
|
||||
agents={agents}
|
||||
activeItem={activeItem}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
{/* Top bar */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import styles from './RouteDetail.module.css'
|
||||
|
||||
@@ -21,21 +21,7 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
|
||||
// Mock data
|
||||
import { routes } from '../../mocks/routes'
|
||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||
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,
|
||||
}))
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -143,7 +129,6 @@ const EXCHANGE_COLUMNS: Column<Exchange>[] = [
|
||||
export function RouteDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [activeItem, setActiveItem] = useState(id ?? '')
|
||||
|
||||
const route = useMemo(() => routes.find((r) => r.id === id), [id])
|
||||
const routeExchanges = useMemo(
|
||||
@@ -210,24 +195,12 @@ export function RouteDetail() {
|
||||
? ((successCount / routeExchanges.length) * 100).toFixed(1)
|
||||
: '0.0'
|
||||
|
||||
function handleItemClick(itemId: string) {
|
||||
setActiveItem(itemId)
|
||||
const r = routes.find((route) => route.id === itemId)
|
||||
if (r) navigate(`/routes/${itemId}`)
|
||||
}
|
||||
|
||||
// Not found state
|
||||
if (!route) {
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
apps={APPS}
|
||||
routes={SIDEBAR_ROUTES}
|
||||
agents={agents}
|
||||
activeItem={activeItem}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
<TopBar
|
||||
@@ -252,13 +225,7 @@ export function RouteDetail() {
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
apps={APPS}
|
||||
routes={SIDEBAR_ROUTES}
|
||||
agents={agents}
|
||||
activeItem={activeItem}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
{/* Top bar */}
|
||||
|
||||
Reference in New Issue
Block a user