- 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>
441 lines
15 KiB
TypeScript
441 lines
15 KiB
TypeScript
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}>▸</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}>⚙</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}>☰</span>
|
||
<div className={styles.itemInfo}>
|
||
<div className={styles.itemName}>API Docs</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
)
|
||
}
|
||
|