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

@@ -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>
)

View File

@@ -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;

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 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}>&#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 (
<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}>&#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(' ')}
{/* 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}>&#9881;</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}>&#9776;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>API Docs</div>
@@ -253,3 +389,4 @@ export function Sidebar({
</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 { 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'

View File

@@ -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
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 { 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 */}

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 { 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 ? (

View File

@@ -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 */}

View File

@@ -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}>
&lt;children&gt; 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>

View File

@@ -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 */}

View File

@@ -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 */}