Files
design-system/src/design-system/layout/Sidebar/Sidebar.tsx
hsiegeln 7cd8864f2c refactor: move Dashboard to /apps, add Metrics sidebar entry, scope by app
- Route / redirects to /apps, Dashboard serves both /apps and /apps/:id
- When appId is present, exchanges/routes/agents/search are scoped to that app
- Remove Dashboards sidebar link, add Metrics link
- Sidebar section labels (Applications, Agents) are now clickable nav links
  with separate chevron for collapse toggle
- Update all breadcrumbs from Dashboard/href:'/' to Applications/href:'/apps'
- Remove AppDetail page (replaced by scoped Dashboard)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 19:26:27 +01:00

441 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
// ── Types ────────────────────────────────────────────────────────────────────
export interface SidebarApp {
id: string
name: string
health: 'live' | 'stale' | 'dead'
exchangeCount: number
routes: SidebarRoute[]
agents: SidebarAgent[]
}
export interface SidebarRoute {
id: string
name: string
exchangeCount: number
}
export interface SidebarAgent {
id: string
name: string
status: 'live' | 'stale' | 'dead'
tps: number
}
interface SidebarProps {
apps: SidebarApp[]
className?: string
}
// ── 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.toFixed(1)}/s`,
path: `/agents/${app.id}/${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 (
<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>
)
}
// ── Sidebar ──────────────────────────────────────────────────────────────────
export function Sidebar({ apps, className }: SidebarProps) {
const [search, setSearch] = useState('')
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
const setAppsCollapsed = (updater: (v: boolean) => boolean) => {
_setAppsCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
return next
})
}
const setAgentsCollapsed = (updater: (v: boolean) => boolean) => {
_setAgentsCollapsed((prev) => {
const next = updater(prev)
localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
return next
})
}
const navigate = useNavigate()
const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred()
// Build tree data
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [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} ${className ?? ''}`}>
{/* Logo */}
<div className={styles.logo} onClick={() => navigate('/apps')} style={{ cursor: 'pointer' }}>
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
<div>
<span className={styles.brand}>cameleer</span>
<span className={styles.version}>v3.2.1</span>
</div>
</div>
{/* Search */}
<div className={styles.searchWrap}>
<div className={styles.searchInner}>
<span className={styles.searchIcon} aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<input
className={styles.searchInput}
type="text"
placeholder="Filter..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearch('')}
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
{/* Navigation (scrollable) — includes starred section */}
<div className={styles.navArea}>
<div className={styles.section}>Navigation</div>
{/* Applications tree (collapsible, label navigates to /apps) */}
<div className={styles.treeSection}>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAppsCollapsed((v) => !v)}
aria-expanded={!appsCollapsed}
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
>
{appsCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/apps')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
>
Applications
</span>
</div>
{!appsCollapsed && (
<SidebarTree
nodes={appNodes}
selectedPath={location.pathname}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:apps"
/>
)}
</div>
{/* Agents tree (collapsible, label navigates to /agents) */}
<div className={styles.treeSection}>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAgentsCollapsed((v) => !v)}
aria-expanded={!agentsCollapsed}
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
>
{agentsCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/agents')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
>
Agents
</span>
</div>
{!agentsCollapsed && (
<SidebarTree
nodes={agentNodes}
selectedPath={location.pathname}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={search}
persistKey="cameleer:expanded:agents"
/>
)}
</div>
{/* Flat nav links */}
<div className={styles.items}>
<div
className={[
styles.item,
location.pathname === '/metrics' ? styles.active : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/metrics')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/metrics') }}
>
<span className={styles.navIcon}></span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>Metrics</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,
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,
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>
</div>
</div>
</div>
</aside>
)
}