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

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