Move all application-specific sidebar logic (tree builders, starred items, section collapse state, sidebarReveal handling) out of the DS Sidebar into a shared LayoutShell that wraps Outlet for route-level layout sharing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
import { useState, useEffect, useMemo, type ReactNode } from 'react'
|
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
|
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight, X } from 'lucide-react'
|
|
import { AppShell } from '../design-system/layout/AppShell/AppShell'
|
|
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
|
|
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
|
|
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
|
|
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
|
|
import { StatusDot } from '../design-system/primitives/StatusDot/StatusDot'
|
|
import { SIDEBAR_APPS } from '../mocks/sidebar'
|
|
import type { SidebarApp } from '../mocks/sidebar'
|
|
import camelLogoUrl from '../assets/camel-logo.svg'
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function formatCount(n: number): string {
|
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
|
return String(n)
|
|
}
|
|
|
|
// ── Tree node builders ──────────────────────────────────────────────────────
|
|
|
|
function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
|
return apps.map((app) => ({
|
|
id: app.id,
|
|
label: app.name,
|
|
icon: <StatusDot status={app.health} />,
|
|
badge: formatCount(app.exchangeCount),
|
|
path: `/apps/${app.id}`,
|
|
starrable: true,
|
|
starKey: `app:${app.id}`,
|
|
children: app.routes.map((route) => ({
|
|
id: `${app.id}/${route.id}`,
|
|
label: route.name,
|
|
icon: <ChevronRight size={12} />,
|
|
badge: formatCount(route.exchangeCount),
|
|
path: `/apps/${app.id}/${route.id}`,
|
|
})),
|
|
}))
|
|
}
|
|
|
|
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
|
return apps
|
|
.filter((app) => app.routes.length > 0)
|
|
.map((app) => ({
|
|
id: `routes:${app.id}`,
|
|
label: app.name,
|
|
icon: <StatusDot status={app.health} />,
|
|
badge: `${app.routes.length} route${app.routes.length !== 1 ? 's' : ''}`,
|
|
path: `/routes/${app.id}`,
|
|
starrable: true,
|
|
starKey: `routestat:${app.id}`,
|
|
children: app.routes.map((route) => ({
|
|
id: `routes:${app.id}/${route.id}`,
|
|
label: route.name,
|
|
icon: <ChevronRight size={12} />,
|
|
badge: formatCount(route.exchangeCount),
|
|
path: `/routes/${app.id}/${route.id}`,
|
|
})),
|
|
}))
|
|
}
|
|
|
|
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 status={app.health} />,
|
|
badge: `${liveCount}/${app.agents.length} live`,
|
|
path: `/agents/${app.id}`,
|
|
starrable: true,
|
|
starKey: `agent:${app.id}`,
|
|
children: app.agents.map((agent) => ({
|
|
id: `agents:${app.id}/${agent.id}`,
|
|
label: agent.name,
|
|
icon: <StatusDot status={agent.status} />,
|
|
badge: `${agent.tps} tps`,
|
|
path: `/agents/${app.id}/${agent.id}`,
|
|
})),
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── Starred items ───────────────────────────────────────────────────────────
|
|
|
|
interface StarredItem {
|
|
starKey: string
|
|
label: string
|
|
icon?: ReactNode
|
|
path: string
|
|
type: 'application' | 'route' | 'agent' | 'routestat'
|
|
parentApp?: string
|
|
}
|
|
|
|
function collectStarredItems(
|
|
apps: SidebarApp[],
|
|
starredIds: Set<string>,
|
|
): StarredItem[] {
|
|
const items: StarredItem[] = []
|
|
|
|
for (const app of apps) {
|
|
if (starredIds.has(`app:${app.id}`)) {
|
|
items.push({
|
|
starKey: `app:${app.id}`,
|
|
label: app.name,
|
|
icon: <Box size={12} />,
|
|
path: `/apps/${app.id}`,
|
|
type: 'application',
|
|
})
|
|
}
|
|
|
|
for (const route of app.routes) {
|
|
if (starredIds.has(`route:${app.id}/${route.id}`)) {
|
|
items.push({
|
|
starKey: `route:${app.id}/${route.id}`,
|
|
label: route.name,
|
|
icon: <ChevronRight size={12} />,
|
|
path: `/apps/${app.id}/${route.id}`,
|
|
type: 'route',
|
|
parentApp: app.name,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (starredIds.has(`routestat:${app.id}`)) {
|
|
items.push({
|
|
starKey: `routestat:${app.id}`,
|
|
label: app.name,
|
|
icon: <GitBranch size={12} />,
|
|
path: `/routes/${app.id}`,
|
|
type: 'routestat',
|
|
})
|
|
}
|
|
|
|
if (starredIds.has(`agent:${app.id}`)) {
|
|
items.push({
|
|
starKey: `agent:${app.id}`,
|
|
label: app.name,
|
|
icon: <Cpu size={12} />,
|
|
path: `/agents/${app.id}`,
|
|
type: 'agent',
|
|
})
|
|
}
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
// ── Starred group component ─────────────────────────────────────────────────
|
|
|
|
interface StarredGroupProps {
|
|
label: string
|
|
items: StarredItem[]
|
|
onRemove: (starKey: string) => void
|
|
onNavigate: (path: string) => void
|
|
}
|
|
|
|
function StarredGroup({ label, items, onRemove, onNavigate }: StarredGroupProps) {
|
|
if (items.length === 0) return null
|
|
|
|
return (
|
|
<div style={{ marginBottom: 8 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 10,
|
|
fontWeight: 600,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em',
|
|
color: 'var(--text-tertiary)',
|
|
padding: '4px 12px',
|
|
}}
|
|
>
|
|
{label}
|
|
</div>
|
|
{items.map((item) => (
|
|
<div
|
|
key={item.starKey}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
padding: '4px 12px',
|
|
cursor: 'pointer',
|
|
fontSize: 12,
|
|
color: 'var(--text-secondary)',
|
|
borderRadius: 4,
|
|
}}
|
|
onClick={() => onNavigate(item.path)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path)
|
|
}}
|
|
>
|
|
{item.icon && (
|
|
<span style={{ display: 'flex', alignItems: 'center', color: 'var(--text-tertiary)' }}>
|
|
{item.icon}
|
|
</span>
|
|
)}
|
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{item.label}
|
|
{item.parentApp && (
|
|
<span style={{ color: 'var(--text-tertiary)', marginLeft: 4, fontSize: 10 }}>
|
|
{item.parentApp}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<button
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
padding: 2,
|
|
cursor: 'pointer',
|
|
color: 'var(--text-tertiary)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
opacity: 0.6,
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onRemove(item.starKey)
|
|
}}
|
|
aria-label={`Remove ${item.label} from starred`}
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── localStorage-backed section collapse ────────────────────────────────────
|
|
|
|
function usePersistedCollapse(key: string, defaultValue: boolean): [boolean, () => void] {
|
|
const [value, setValue] = useState(() => {
|
|
try {
|
|
const raw = localStorage.getItem(key)
|
|
if (raw !== null) return raw === 'true'
|
|
} catch { /* ignore */ }
|
|
return defaultValue
|
|
})
|
|
|
|
const toggle = () => {
|
|
setValue((prev) => {
|
|
const next = !prev
|
|
try {
|
|
localStorage.setItem(key, String(next))
|
|
} catch { /* ignore */ }
|
|
return next
|
|
})
|
|
}
|
|
|
|
return [value, toggle]
|
|
}
|
|
|
|
// ── LayoutShell ─────────────────────────────────────────────────────────────
|
|
|
|
export function LayoutShell() {
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const { starredIds, isStarred, toggleStar } = useStarred()
|
|
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
|
const [filterQuery, setFilterQuery] = useState('')
|
|
|
|
// Section collapse state — persisted to localStorage
|
|
const [appsCollapsed, toggleAppsCollapsed] = usePersistedCollapse('cameleer:sidebar:apps-collapsed', false)
|
|
const [agentsCollapsed, toggleAgentsCollapsed] = usePersistedCollapse('cameleer:sidebar:agents-collapsed', false)
|
|
const [routesCollapsed, toggleRoutesCollapsed] = usePersistedCollapse('cameleer:sidebar:routes-collapsed', false)
|
|
|
|
// Tree data — static, so empty deps
|
|
const appNodes = useMemo(() => buildAppTreeNodes(SIDEBAR_APPS), [])
|
|
const agentNodes = useMemo(() => buildAgentTreeNodes(SIDEBAR_APPS), [])
|
|
const routeNodes = useMemo(() => buildRouteTreeNodes(SIDEBAR_APPS), [])
|
|
|
|
// Sidebar reveal from Cmd-K navigation
|
|
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
|
|
|
// Auto-uncollapse matching sections when sidebarRevealPath changes
|
|
useEffect(() => {
|
|
if (!sidebarRevealPath) return
|
|
|
|
if (sidebarRevealPath.startsWith('/apps') && appsCollapsed) {
|
|
toggleAppsCollapsed()
|
|
}
|
|
if (sidebarRevealPath.startsWith('/agents') && agentsCollapsed) {
|
|
toggleAgentsCollapsed()
|
|
}
|
|
if (sidebarRevealPath.startsWith('/routes') && routesCollapsed) {
|
|
toggleRoutesCollapsed()
|
|
}
|
|
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname
|
|
|
|
// Starred items — collected and grouped
|
|
const allStarred = useMemo(
|
|
() => collectStarredItems(SIDEBAR_APPS, starredIds),
|
|
[starredIds],
|
|
)
|
|
|
|
const starredApps = allStarred.filter((s) => s.type === 'application')
|
|
const starredRoutes = allStarred.filter((s) => s.type === 'route')
|
|
const starredAgents = allStarred.filter((s) => s.type === 'agent')
|
|
const starredRouteStats = allStarred.filter((s) => s.type === 'routestat')
|
|
const hasStarred = allStarred.length > 0
|
|
|
|
const camelLogo = (
|
|
<img
|
|
src={camelLogoUrl}
|
|
alt=""
|
|
aria-hidden="true"
|
|
style={{
|
|
width: 28,
|
|
height: 24,
|
|
filter:
|
|
'brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%)',
|
|
}}
|
|
/>
|
|
)
|
|
|
|
return (
|
|
<AppShell
|
|
sidebar={
|
|
<Sidebar
|
|
collapsed={sidebarCollapsed}
|
|
onCollapseToggle={() => setSidebarCollapsed((c) => !c)}
|
|
searchValue={filterQuery}
|
|
onSearchChange={setFilterQuery}
|
|
>
|
|
<Sidebar.Header
|
|
logo={camelLogo}
|
|
title="cameleer"
|
|
version="v3.2.1"
|
|
onClick={() => navigate('/apps')}
|
|
/>
|
|
|
|
<Sidebar.Section
|
|
label="Applications"
|
|
icon={<Box size={14} />}
|
|
open={!appsCollapsed}
|
|
onToggle={toggleAppsCollapsed}
|
|
active={location.pathname.startsWith('/apps')}
|
|
>
|
|
<SidebarTree
|
|
nodes={appNodes}
|
|
selectedPath={effectiveSelectedPath}
|
|
isStarred={isStarred}
|
|
onToggleStar={toggleStar}
|
|
filterQuery={filterQuery}
|
|
persistKey="cameleer:expanded:apps"
|
|
autoRevealPath={sidebarRevealPath}
|
|
/>
|
|
</Sidebar.Section>
|
|
|
|
<Sidebar.Section
|
|
label="Agents"
|
|
icon={<Cpu size={14} />}
|
|
open={!agentsCollapsed}
|
|
onToggle={toggleAgentsCollapsed}
|
|
active={location.pathname.startsWith('/agents')}
|
|
>
|
|
<SidebarTree
|
|
nodes={agentNodes}
|
|
selectedPath={effectiveSelectedPath}
|
|
isStarred={isStarred}
|
|
onToggleStar={toggleStar}
|
|
filterQuery={filterQuery}
|
|
persistKey="cameleer:expanded:agents"
|
|
autoRevealPath={sidebarRevealPath}
|
|
/>
|
|
</Sidebar.Section>
|
|
|
|
<Sidebar.Section
|
|
label="Routes"
|
|
icon={<GitBranch size={14} />}
|
|
open={!routesCollapsed}
|
|
onToggle={toggleRoutesCollapsed}
|
|
active={location.pathname.startsWith('/routes')}
|
|
>
|
|
<SidebarTree
|
|
nodes={routeNodes}
|
|
selectedPath={effectiveSelectedPath}
|
|
isStarred={isStarred}
|
|
onToggleStar={toggleStar}
|
|
filterQuery={filterQuery}
|
|
persistKey="cameleer:expanded:routes"
|
|
autoRevealPath={sidebarRevealPath}
|
|
/>
|
|
</Sidebar.Section>
|
|
|
|
{hasStarred && (
|
|
<Sidebar.Section
|
|
label="\u2605 Starred"
|
|
icon={<span />}
|
|
open={true}
|
|
onToggle={() => {}}
|
|
active={false}
|
|
>
|
|
<StarredGroup
|
|
label="Applications"
|
|
items={starredApps}
|
|
onRemove={toggleStar}
|
|
onNavigate={navigate}
|
|
/>
|
|
<StarredGroup
|
|
label="Routes"
|
|
items={starredRoutes}
|
|
onRemove={toggleStar}
|
|
onNavigate={navigate}
|
|
/>
|
|
<StarredGroup
|
|
label="Route Stats"
|
|
items={starredRouteStats}
|
|
onRemove={toggleStar}
|
|
onNavigate={navigate}
|
|
/>
|
|
<StarredGroup
|
|
label="Agents"
|
|
items={starredAgents}
|
|
onRemove={toggleStar}
|
|
onNavigate={navigate}
|
|
/>
|
|
</Sidebar.Section>
|
|
)}
|
|
|
|
<Sidebar.Footer>
|
|
<Sidebar.FooterLink
|
|
icon={<Settings size={14} />}
|
|
label="Admin"
|
|
onClick={() => navigate('/admin')}
|
|
active={location.pathname.startsWith('/admin')}
|
|
/>
|
|
<Sidebar.FooterLink
|
|
icon={<FileText size={14} />}
|
|
label="API Docs"
|
|
onClick={() => navigate('/api-docs')}
|
|
active={location.pathname === '/api-docs'}
|
|
/>
|
|
</Sidebar.Footer>
|
|
</Sidebar>
|
|
}
|
|
>
|
|
<Outlet />
|
|
</AppShell>
|
|
)
|
|
}
|