feat: composable sidebar refactor
All checks were successful
Build & Publish / publish (push) Successful in 57s
All checks were successful
Build & Publish / publish (push) Successful in 57s
Replaces monolithic Sidebar with compound component API: Sidebar, Sidebar.Header, Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink. Exports SidebarTree, useStarred publicly. Migrates mock app to LayoutShell with React Router layout routes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,8 @@ import { buildSearchData } from './mocks/searchData'
|
||||
import { exchanges } from './mocks/exchanges'
|
||||
import { routes } from './mocks/routes'
|
||||
import { agents } from './mocks/agents'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar'
|
||||
import { buildRouteToAppMap } from './mocks/sidebar'
|
||||
import { LayoutShell } from './layout/LayoutShell'
|
||||
|
||||
const routeToApp = buildRouteToAppMap()
|
||||
|
||||
@@ -78,6 +79,7 @@ export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route element={<LayoutShell />}>
|
||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="/apps" element={<Dashboard />} />
|
||||
<Route path="/apps/:id" element={<Dashboard />} />
|
||||
@@ -93,6 +95,7 @@ export default function App() {
|
||||
<Route path="/admin/oidc" element={<OidcConfig />} />
|
||||
<Route path="/admin/rbac" element={<UserManagement />} />
|
||||
<Route path="/api-docs" element={<ApiDocs />} />
|
||||
</Route>
|
||||
<Route path="/inventory" element={<Inventory />} />
|
||||
</Routes>
|
||||
<CommandPalette
|
||||
|
||||
@@ -5,6 +5,36 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
|
||||
.sidebarCollapsed {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
/* Collapse toggle */
|
||||
.collapseToggle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--sidebar-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0;
|
||||
z-index: 1;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
|
||||
.collapseToggle:hover {
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
@@ -15,6 +45,12 @@
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .logo {
|
||||
padding: 16px 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logoImg {
|
||||
@@ -106,71 +142,40 @@
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Scrollable nav area */
|
||||
.navArea {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.section {
|
||||
padding: 14px 12px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
/* Section icon (collapsed rail) */
|
||||
.sectionIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
color: var(--sidebar-muted);
|
||||
}
|
||||
|
||||
/* Items container */
|
||||
.items {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* Nav item (flat links like Dashboards) */
|
||||
.item {
|
||||
/* Rail item (collapsed sidebar section) */
|
||||
.sectionRailItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--sidebar-text);
|
||||
font-size: 13px;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
border-left: 3px solid transparent;
|
||||
margin-bottom: 1px;
|
||||
user-select: none;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
.sectionRailItem:hover {
|
||||
background: var(--sidebar-hover);
|
||||
color: #e8dfd4;
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber);
|
||||
.sectionRailItemActive {
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.navIcon {
|
||||
font-size: 14px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
color: var(--sidebar-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item.active .navIcon {
|
||||
.sectionRailItemActive .sectionIcon {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.routeArrow {
|
||||
color: var(--sidebar-muted);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
.treeSectionActive {
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
/* Item sub-elements */
|
||||
@@ -200,15 +205,7 @@
|
||||
padding: 0 6px 6px;
|
||||
margin-bottom: 2px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.treeSectionLabel {
|
||||
padding: 10px 12px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--sidebar-muted);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
/* Collapsible section toggle */
|
||||
@@ -383,100 +380,13 @@
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ── 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 {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 6px;
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.bottomItem {
|
||||
|
||||
@@ -1,172 +1,327 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi } 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 { Sidebar } 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 },
|
||||
{ id: 'prod-2', name: 'prod-2', status: 'live', tps: 11.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'payment-svc',
|
||||
name: 'payment-svc',
|
||||
health: 'live',
|
||||
exchangeCount: 912,
|
||||
routes: [
|
||||
{ id: 'payment-process', name: 'payment-process', exchangeCount: 414 },
|
||||
],
|
||||
agents: [],
|
||||
},
|
||||
]
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderSidebar(props: Partial<Parameters<typeof Sidebar>[0]> = {}) {
|
||||
return render(
|
||||
const LogoIcon = () => <svg data-testid="logo-icon" />
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<MemoryRouter>
|
||||
<Sidebar apps={TEST_APPS} {...props} />
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>,
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Sidebar compound component', () => {
|
||||
// 1. renders Header with logo, title, version
|
||||
it('renders Header with logo, title, and version', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('MyApp')).toBeInTheDocument()
|
||||
expect(screen.getByText('v1.2.3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the logo and brand name', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('cameleer')).toBeInTheDocument()
|
||||
expect(screen.getByText('v3.2.1')).toBeInTheDocument()
|
||||
// 2. hides Header title and version when collapsed
|
||||
it('hides Header title and version when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByText('MyApp')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('v1.2.3')).not.toBeInTheDocument()
|
||||
// Logo should still be visible
|
||||
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the search input', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument()
|
||||
// 3. renders Section with label and children
|
||||
it('renders Section with label and children when open', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Section
|
||||
icon={<span>icon</span>}
|
||||
label="Settings"
|
||||
open
|
||||
onToggle={vi.fn()}
|
||||
>
|
||||
<div>Section Child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('Section Child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Navigation section header', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument()
|
||||
// 4. hides Section children when section collapsed (open=false)
|
||||
it('hides Section children when section is not open', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Section
|
||||
icon={<span>icon</span>}
|
||||
label="Settings"
|
||||
open={false}
|
||||
onToggle={vi.fn()}
|
||||
>
|
||||
<div>Section Child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Section Child')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Applications tree section', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Applications')).toBeInTheDocument()
|
||||
// 5. calls onToggle when Section header clicked
|
||||
it('calls onToggle when Section chevron button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onToggle = vi.fn()
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Section
|
||||
icon={<span>icon</span>}
|
||||
label="Settings"
|
||||
open
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div>child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const btn = screen.getByRole('button', { name: /collapse settings/i })
|
||||
await user.click(btn)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders Agents tree section', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Agents')).toBeInTheDocument()
|
||||
// 6. renders collapse toggle and calls onCollapseToggle
|
||||
it('renders collapse toggle button and calls onCollapseToggle when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCollapseToggle = vi.fn()
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar onCollapseToggle={onCollapseToggle}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const toggleBtn = screen.getByRole('button', { name: /collapse sidebar/i })
|
||||
await user.click(toggleBtn)
|
||||
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders Routes nav link', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText('Routes')).toBeInTheDocument()
|
||||
// 7. renders expand toggle label when collapsed
|
||||
it('renders expand toggle when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed onCollapseToggle={vi.fn()}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders bottom links', () => {
|
||||
renderSidebar()
|
||||
// 8. renders search input and calls onSearchChange
|
||||
it('renders search input and calls onSearchChange on input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSearchChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar searchValue="" onSearchChange={onSearchChange}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('Filter...')
|
||||
expect(input).toBeInTheDocument()
|
||||
|
||||
await user.type(input, 'hello')
|
||||
expect(onSearchChange).toHaveBeenCalled()
|
||||
// Each keystroke fires once
|
||||
expect(onSearchChange.mock.calls[0][0]).toBe('h')
|
||||
})
|
||||
|
||||
// 9. hides search when collapsed
|
||||
it('hides search input when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed searchValue="" onSearchChange={vi.fn()}>
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 10. hides search when onSearchChange not provided
|
||||
it('hides search input when onSearchChange is not provided', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar searchValue="">
|
||||
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 11. renders FooterLinks with icons and labels
|
||||
it('renders FooterLinks with icon and label', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink
|
||||
icon={<span data-testid="footer-icon">ic</span>}
|
||||
label="Admin"
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.getByTestId('footer-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument()
|
||||
expect(screen.getByText('API Docs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders app names in the Applications tree', () => {
|
||||
renderSidebar()
|
||||
// order-service appears in Applications, Routes, and Agents trees
|
||||
expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||
// 12. hides FooterLink labels when collapsed and sets title tooltip
|
||||
it('hides FooterLink label when collapsed and exposes title tooltip', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink
|
||||
icon={<span>ic</span>}
|
||||
label="Admin"
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
|
||||
|
||||
// The clickable element should carry a title attribute for tooltip
|
||||
// (accessible name comes from icon content when label is hidden)
|
||||
const item = screen.getByTitle('Admin')
|
||||
expect(item).toHaveAttribute('title', 'Admin')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
// 13. calls FooterLink onClick
|
||||
it('calls FooterLink onClick when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
const onClick = vi.fn()
|
||||
|
||||
// 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)
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" onClick={onClick} />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// Click the star button
|
||||
const starBtn = appRow.querySelector('button[aria-label="Add to starred"]')!
|
||||
await user.click(starBtn)
|
||||
|
||||
expect(screen.getByText('★ Starred')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('Admin'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('filters tree items by search', async () => {
|
||||
// 14. renders Section as icon-rail item when sidebar collapsed
|
||||
it('renders Section as icon-rail item when sidebar is collapsed', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed>
|
||||
<Sidebar.Section
|
||||
icon={<span data-testid="section-icon">ic</span>}
|
||||
label="Settings"
|
||||
open={false}
|
||||
onToggle={vi.fn()}
|
||||
>
|
||||
<div>child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// Label text should not be visible (only as tooltip via title attr)
|
||||
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
|
||||
|
||||
// Rail item carries title attribute for tooltip
|
||||
// (accessible name comes from icon content when label is hidden)
|
||||
const railItem = screen.getByTitle('Settings')
|
||||
expect(railItem).toHaveAttribute('title', 'Settings')
|
||||
|
||||
// Icon should still render
|
||||
expect(screen.getByTestId('section-icon')).toBeInTheDocument()
|
||||
|
||||
// Section children should not be rendered
|
||||
expect(screen.queryByText('child')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 15. fires both onCollapseToggle and onToggle when icon-rail section clicked
|
||||
it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
const onCollapseToggle = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Filter...')
|
||||
await user.type(searchInput, 'payment')
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar collapsed onCollapseToggle={onCollapseToggle}>
|
||||
<Sidebar.Section
|
||||
icon={<span>ic</span>}
|
||||
label="Settings"
|
||||
open={false}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div>child</div>
|
||||
</Sidebar.Section>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// payment-svc should still be visible (may appear in multiple trees)
|
||||
expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1)
|
||||
const railItem = screen.getByTitle('Settings')
|
||||
await user.click(railItem)
|
||||
|
||||
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('expands tree to show children when chevron is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSidebar()
|
||||
// 16. applies active highlight to FooterLink
|
||||
it('applies active highlight class to FooterLink when active', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<Sidebar>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" active />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
// 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')
|
||||
const item = screen.getByText('Admin').closest('[role="button"]')!
|
||||
expect(item.className).toMatch(/bottomItemActive/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,344 +1,203 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
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'
|
||||
import { SidebarContext, useSidebarContext } from './SidebarContext'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
// ── Sub-component props ─────────────────────────────────────────────────────
|
||||
|
||||
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[]
|
||||
interface SidebarHeaderProps {
|
||||
logo: ReactNode
|
||||
title: string
|
||||
version?: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
onNavigate?: (path: string) => void
|
||||
}
|
||||
|
||||
// ── 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: <ChevronRight size={12} />,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/apps/${app.id}/${route.id}`,
|
||||
starrable: true,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
||||
return apps
|
||||
.filter((app) => app.routes.length > 0)
|
||||
.map((app) => ({
|
||||
id: `routes:${app.id}`,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
badge: `${app.routes.length} routes`,
|
||||
path: `/routes/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `routes:${app.id}`,
|
||||
children: app.routes.map((route) => ({
|
||||
id: `routestat:${app.id}:${route.id}`,
|
||||
starKey: `routes:${app.id}:${route.id}`,
|
||||
label: route.name,
|
||||
icon: <ChevronRight size={12} />,
|
||||
badge: formatCount(route.exchangeCount),
|
||||
path: `/routes/${app.id}/${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
|
||||
interface SidebarSectionProps {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
path: string
|
||||
type: 'application' | 'route' | 'agent' | 'routestat'
|
||||
parentApp?: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
active?: boolean
|
||||
children: ReactNode
|
||||
className?: 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: `/apps/${app.id}/${route.id}`,
|
||||
type: 'route',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
const agentsAppKey = `agents:${app.id}`
|
||||
if (starredIds.has(agentsAppKey)) {
|
||||
items.push({
|
||||
starKey: agentsAppKey,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/agents/${app.id}`,
|
||||
type: 'agent',
|
||||
})
|
||||
}
|
||||
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/${app.id}/${agent.id}`,
|
||||
type: 'agent',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Routes tree starred items
|
||||
const routesAppKey = `routes:${app.id}`
|
||||
if (starredIds.has(routesAppKey)) {
|
||||
items.push({
|
||||
starKey: routesAppKey,
|
||||
label: app.name,
|
||||
icon: <StatusDot variant={app.health} />,
|
||||
path: `/routes/${app.id}`,
|
||||
type: 'routestat',
|
||||
})
|
||||
}
|
||||
for (const route of app.routes) {
|
||||
const routeKey = `routes:${app.id}:${route.id}`
|
||||
if (starredIds.has(routeKey)) {
|
||||
items.push({
|
||||
starKey: routeKey,
|
||||
label: route.name,
|
||||
path: `/routes/${app.id}/${route.id}`,
|
||||
type: 'routestat',
|
||||
parentApp: app.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
interface SidebarFooterProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// ── StarredGroup ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StarredGroup({
|
||||
label,
|
||||
items,
|
||||
onNavigate,
|
||||
onRemove,
|
||||
}: {
|
||||
interface SidebarFooterLinkProps {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
items: StarredItem[]
|
||||
onNavigate: (path: string) => void
|
||||
onRemove: (starKey: string) => void
|
||||
}) {
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface SidebarRootProps {
|
||||
collapsed?: boolean
|
||||
onCollapseToggle?: () => void
|
||||
searchValue?: string
|
||||
onSearchChange?: (query: string) => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function SidebarHeader({ logo, title, version, onClick, className }: SidebarHeaderProps) {
|
||||
const { collapsed } = useSidebarContext()
|
||||
|
||||
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) }}
|
||||
className={`${styles.logo} ${className ?? ''}`}
|
||||
onClick={onClick}
|
||||
style={onClick ? { cursor: 'pointer' } : undefined}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
<div className={styles.starredItemInfo}>
|
||||
<span className={styles.starredItemName}>{item.label}</span>
|
||||
{item.parentApp && (
|
||||
<span className={styles.starredItemContext}>{item.parentApp}</span>
|
||||
{logo}
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<span className={styles.brand}>{title}</span>
|
||||
{version && <span className={styles.version}>{version}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={styles.starredRemove}
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${item.label} from starred`}
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSection({
|
||||
icon,
|
||||
label,
|
||||
open,
|
||||
onToggle,
|
||||
active,
|
||||
children,
|
||||
className,
|
||||
}: SidebarSectionProps) {
|
||||
const { collapsed, onCollapseToggle } = useSidebarContext()
|
||||
|
||||
// In icon-rail (collapsed) mode, render a centered icon with tooltip
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.sectionRailItem} ${active ? styles.sectionRailItemActive : ''} ${className ?? ''}`}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
// Expand sidebar and open the section
|
||||
onCollapseToggle?.()
|
||||
onToggle()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onCollapseToggle?.()
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<span className={styles.sectionIcon}>{icon}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Sidebar({ apps, className, onNavigate }: 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 [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-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 setRoutesCollapsed = (updater: (v: boolean) => boolean) => {
|
||||
_setRoutesCollapsed((prev) => {
|
||||
const next = updater(prev)
|
||||
localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
const routerNavigate = useNavigate()
|
||||
const nav = onNavigate ?? routerNavigate
|
||||
const location = useLocation()
|
||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||
|
||||
// Build tree data
|
||||
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
||||
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
|
||||
const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps])
|
||||
|
||||
// Sidebar reveal from Cmd-K navigation (passed via location state)
|
||||
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarRevealPath) return
|
||||
|
||||
// Uncollapse Applications section if reveal path matches an apps tree node
|
||||
const matchesAppTree = appNodes.some((node) =>
|
||||
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||||
)
|
||||
if (matchesAppTree && appsCollapsed) {
|
||||
_setAppsCollapsed(false)
|
||||
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
|
||||
}
|
||||
|
||||
// Uncollapse Agents section if reveal path matches an agents tree node
|
||||
const matchesAgentTree = agentNodes.some((node) =>
|
||||
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||||
)
|
||||
if (matchesAgentTree && agentsCollapsed) {
|
||||
_setAgentsCollapsed(false)
|
||||
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
|
||||
}
|
||||
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 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 starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
|
||||
const hasStarred = starredItems.length > 0
|
||||
|
||||
// When a sidebar reveal path is provided (e.g. via Cmd-K navigation),
|
||||
// use it for sidebar selection so the correct item is highlighted
|
||||
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname
|
||||
|
||||
return (
|
||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||
{/* Logo */}
|
||||
<div className={styles.logo} onClick={() => nav('/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 className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
|
||||
>
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
{icon && <span className={styles.sectionIcon}>{icon}</span>}
|
||||
<span className={styles.treeSectionLabel}>{label}</span>
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Search */}
|
||||
function SidebarFooter({ children, className }: SidebarFooterProps) {
|
||||
return (
|
||||
<div className={`${styles.bottom} ${className ?? ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooterLink({ icon, label, active, onClick, className }: SidebarFooterLinkProps) {
|
||||
const { collapsed } = useSidebarContext()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
styles.bottomItem,
|
||||
active ? styles.bottomItemActive : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={collapsed ? label : undefined}
|
||||
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined}
|
||||
>
|
||||
<span className={styles.bottomIcon}>{icon}</span>
|
||||
{!collapsed && (
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>{label}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Root component ──────────────────────────────────────────────────────────
|
||||
|
||||
function SidebarRoot({
|
||||
collapsed = false,
|
||||
onCollapseToggle,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
children,
|
||||
className,
|
||||
}: SidebarRootProps) {
|
||||
return (
|
||||
<SidebarContext.Provider value={{ collapsed, onCollapseToggle }}>
|
||||
<aside
|
||||
className={[
|
||||
styles.sidebar,
|
||||
collapsed ? styles.sidebarCollapsed : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{/* Collapse toggle */}
|
||||
{onCollapseToggle && (
|
||||
<button
|
||||
className={styles.collapseToggle}
|
||||
onClick={onCollapseToggle}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <ChevronsRight size={14} /> : <ChevronsLeft size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search (only when expanded and handler provided) */}
|
||||
{onSearchChange && !collapsed && (
|
||||
<div className={styles.searchWrap}>
|
||||
<div className={styles.searchInner}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
@@ -348,14 +207,14 @@ export function Sidebar({ apps, className, onNavigate }: SidebarProps) {
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
value={searchValue ?? ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchClear}
|
||||
onClick={() => setSearch('')}
|
||||
onClick={() => onSearchChange('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={12} />
|
||||
@@ -363,199 +222,19 @@ export function Sidebar({ apps, className, onNavigate }: SidebarProps) {
|
||||
)}
|
||||
</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 ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => nav('/apps')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/apps') }}
|
||||
>
|
||||
Applications
|
||||
</span>
|
||||
</div>
|
||||
{!appsCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={appNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:apps"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</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 ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => nav('/agents')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/agents') }}
|
||||
>
|
||||
Agents
|
||||
</span>
|
||||
</div>
|
||||
{!agentsCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={agentNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:agents"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Routes tree (collapsible, label navigates to /routes) */}
|
||||
<div className={styles.treeSection}>
|
||||
<div className={styles.treeSectionToggle}>
|
||||
<button
|
||||
className={styles.treeSectionChevronBtn}
|
||||
onClick={() => setRoutesCollapsed((v) => !v)}
|
||||
aria-expanded={!routesCollapsed}
|
||||
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
||||
>
|
||||
{routesCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
||||
onClick={() => nav('/routes')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/routes') }}
|
||||
>
|
||||
Routes
|
||||
</span>
|
||||
</div>
|
||||
{!routesCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={routeNodes}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:routes"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
</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={nav}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredRoutes.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Routes"
|
||||
items={starredRoutes}
|
||||
onNavigate={nav}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredAgents.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Agents"
|
||||
items={starredAgents}
|
||||
onNavigate={nav}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
{starredRouteStats.length > 0 && (
|
||||
<StarredGroup
|
||||
label="Routes"
|
||||
items={starredRouteStats}
|
||||
onNavigate={nav}
|
||||
onRemove={toggleStar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom links */}
|
||||
<div className={styles.bottom}>
|
||||
<div
|
||||
className={[
|
||||
styles.bottomItem,
|
||||
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => nav('/admin')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/admin') }}
|
||||
>
|
||||
<span className={styles.bottomIcon}><Settings size={14} /></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={() => nav('/api-docs')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/api-docs') }}
|
||||
>
|
||||
<span className={styles.bottomIcon}><FileText size={14} /></span>
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemName}>API Docs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</aside>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Compound export ─────────────────────────────────────────────────────────
|
||||
|
||||
export const Sidebar = Object.assign(SidebarRoot, {
|
||||
Header: SidebarHeader,
|
||||
Section: SidebarSection,
|
||||
Footer: SidebarFooter,
|
||||
FooterLink: SidebarFooterLink,
|
||||
})
|
||||
|
||||
14
src/design-system/layout/Sidebar/SidebarContext.ts
Normal file
14
src/design-system/layout/Sidebar/SidebarContext.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export interface SidebarContextValue {
|
||||
collapsed: boolean
|
||||
onCollapseToggle?: () => void
|
||||
}
|
||||
|
||||
export const SidebarContext = createContext<SidebarContextValue>({
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
export function useSidebarContext(): SidebarContextValue {
|
||||
return useContext(SidebarContext)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export { AppShell } from './AppShell/AppShell'
|
||||
export { Sidebar } from './Sidebar/Sidebar'
|
||||
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
|
||||
export { SidebarTree } from './Sidebar/SidebarTree'
|
||||
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
||||
export { useStarred } from './Sidebar/useStarred'
|
||||
export { TopBar } from './TopBar/TopBar'
|
||||
|
||||
451
src/layout/LayoutShell.tsx
Normal file
451
src/layout/LayoutShell.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useNavigate, useLocation } 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 { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
import styles from './Admin.module.css'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
@@ -23,7 +20,7 @@ export function AdminLayout({ title, children }: AdminLayoutProps) {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ label: 'Admin', href: '/admin' },
|
||||
@@ -40,6 +37,6 @@ export function AdminLayout({ title, children }: AdminLayoutProps) {
|
||||
<div className={styles.adminContent}>
|
||||
{children}
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { ChevronRight } from 'lucide-react'
|
||||
import styles from './AgentHealth.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -28,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
||||
|
||||
// Mock data
|
||||
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
import { agentEvents } from '../../mocks/agentEvents'
|
||||
|
||||
// ── URL scope parsing ────────────────────────────────────────────────────────
|
||||
@@ -317,19 +314,7 @@ export function AgentHealth() {
|
||||
const isFullWidth = scope.level !== 'all'
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={<Sidebar apps={SIDEBAR_APPS} />}
|
||||
detail={
|
||||
selectedInstance ? (
|
||||
<DetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
title={selectedInstance.name}
|
||||
tabs={detailTabs}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={buildBreadcrumb(scope)}
|
||||
environment="PRODUCTION"
|
||||
@@ -454,6 +439,16 @@ export function AgentHealth() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
{/* Detail panel (portals itself) */}
|
||||
{selectedInstance && (
|
||||
<DetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
title={selectedInstance.name}
|
||||
tabs={detailTabs}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { ChevronRight } from 'lucide-react'
|
||||
import styles from './AgentInstance.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -28,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
||||
|
||||
// Data
|
||||
import { agents } from '../../mocks/agents'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
import { agentEvents } from '../../mocks/agentEvents'
|
||||
|
||||
import { useState } from 'react'
|
||||
@@ -127,12 +124,12 @@ export function AgentInstance() {
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar breadcrumb={[{ label: 'Agents', href: '/agents' }, { label: 'Not Found' }]} environment="PRODUCTION" user={{ name: 'hendrik' }} />
|
||||
<div className={styles.content}>
|
||||
<div className={styles.notFound}>Agent instance not found.</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,7 +150,7 @@ export function AgentInstance() {
|
||||
const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
|
||||
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
@@ -302,6 +299,6 @@ export function AgentInstance() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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"
|
||||
@@ -17,6 +14,6 @@ export function ApiDocs() {
|
||||
title="API Documentation"
|
||||
description="API documentation coming soon."
|
||||
/>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
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: '/apps' },
|
||||
@@ -23,6 +20,6 @@ export function AppDetail() {
|
||||
title="Application Detail"
|
||||
description="Application detail view coming soon."
|
||||
/>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } fro
|
||||
import styles from './Dashboard.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -287,12 +285,67 @@ export function Dashboard() {
|
||||
const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<TopBar
|
||||
breadcrumb={
|
||||
routeId
|
||||
? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
|
||||
: appId
|
||||
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
|
||||
: [{ label: 'Applications' }]
|
||||
}
|
||||
detail={
|
||||
selectedExchange ? (
|
||||
environment="PRODUCTION"
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className={styles.content}>
|
||||
|
||||
{/* Health strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Exchanges table */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>
|
||||
{filteredExchanges.length.toLocaleString()} of {scopedExchanges.length.toLocaleString()} exchanges
|
||||
</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
data={filteredExchanges}
|
||||
onRowClick={handleRowClick}
|
||||
selectedId={selectedId}
|
||||
sortable
|
||||
flush
|
||||
fillHeight
|
||||
rowAccent={handleRowAccent}
|
||||
expandedContent={(row) =>
|
||||
row.errorMessage ? (
|
||||
<div className={styles.inlineError}>
|
||||
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||
<div>
|
||||
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
||||
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts bar */}
|
||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||
|
||||
{/* Detail panel (portals itself) */}
|
||||
{selectedExchange && (
|
||||
<DetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
@@ -384,66 +437,7 @@ export function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
</DetailPanel>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<TopBar
|
||||
breadcrumb={
|
||||
routeId
|
||||
? [{ label: 'Applications', href: '/apps' }, { label: appId!, href: `/apps/${appId}` }, { label: routeId }]
|
||||
: appId
|
||||
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
|
||||
: [{ label: 'Applications' }]
|
||||
}
|
||||
environment="PRODUCTION"
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className={styles.content}>
|
||||
|
||||
{/* Health strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Exchanges table */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>
|
||||
{filteredExchanges.length.toLocaleString()} of {scopedExchanges.length.toLocaleString()} exchanges
|
||||
</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
data={filteredExchanges}
|
||||
onRowClick={handleRowClick}
|
||||
selectedId={selectedId}
|
||||
sortable
|
||||
flush
|
||||
fillHeight
|
||||
rowAccent={handleRowAccent}
|
||||
expandedContent={(row) =>
|
||||
row.errorMessage ? (
|
||||
<div className={styles.inlineError}>
|
||||
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||
<div>
|
||||
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
||||
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts bar */}
|
||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||
</AppShell>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import styles from './ExchangeDetail.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -22,7 +20,7 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
|
||||
|
||||
// Mock data
|
||||
import { exchanges } from '../../mocks/exchanges'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
|
||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||
|
||||
@@ -196,11 +194,7 @@ export function ExchangeDetail() {
|
||||
// Not found state
|
||||
if (!exchange) {
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
@@ -213,7 +207,7 @@ export function ExchangeDetail() {
|
||||
<div className={styles.content}>
|
||||
<InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -229,11 +223,7 @@ export function ExchangeDetail() {
|
||||
const isSelectedFailed = selectedProc?.status === 'fail'
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
@@ -454,6 +444,6 @@ export function ExchangeDetail() {
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import styles from './LayoutSection.module.css'
|
||||
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
|
||||
import type { SidebarApp } from '../../../design-system/layout/Sidebar/Sidebar'
|
||||
import { SidebarTree } from '../../../design-system/layout/Sidebar/SidebarTree'
|
||||
import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/SidebarTree'
|
||||
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
|
||||
import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
|
||||
import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// ── DemoCard helper ──────────────────────────────────────────────────────────
|
||||
@@ -22,42 +25,42 @@ function DemoCard({ id, title, description, children }: DemoCardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sample data (hierarchical) ───────────────────────────────────────────────
|
||||
// ── Sample tree nodes ────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_APPS: SidebarApp[] = [
|
||||
const SAMPLE_APP_NODES: SidebarTreeNode[] = [
|
||||
{
|
||||
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 },
|
||||
{ id: 'ag2', name: 'agent-prod-2', status: 'live' as const, tps: 38 },
|
||||
label: 'cameleer-prod',
|
||||
icon: <StatusDot variant="live" />,
|
||||
badge: '14.3k',
|
||||
path: '/apps/app1',
|
||||
starrable: true,
|
||||
starKey: 'app:app1',
|
||||
children: [
|
||||
{ id: 'app1/r1', label: 'order-ingest', icon: <ChevronRight size={12} />, badge: '5,421', path: '/apps/app1/r1' },
|
||||
{ id: 'app1/r2', label: 'payment-validate', icon: <ChevronRight size={12} />, badge: '3,102', path: '/apps/app1/r2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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 },
|
||||
label: 'cameleer-staging',
|
||||
icon: <StatusDot variant="stale" />,
|
||||
badge: '871',
|
||||
path: '/apps/app2',
|
||||
starrable: true,
|
||||
starKey: 'app:app2',
|
||||
children: [
|
||||
{ id: 'app2/r3', label: 'notify-customer', icon: <ChevronRight size={12} />, badge: '2,201', path: '/apps/app2/r3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app3',
|
||||
name: 'cameleer-dev',
|
||||
health: 'dead' as const,
|
||||
exchangeCount: 42,
|
||||
routes: [],
|
||||
agents: [],
|
||||
label: 'cameleer-dev',
|
||||
icon: <StatusDot variant="dead" />,
|
||||
badge: '42',
|
||||
path: '/apps/app3',
|
||||
starrable: true,
|
||||
starKey: 'app:app3',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -99,10 +102,19 @@ export function LayoutSection() {
|
||||
<DemoCard
|
||||
id="sidebar"
|
||||
title="Sidebar"
|
||||
description="Navigation sidebar with hierarchical app/route/agent trees, starring, search filter, and bottom links."
|
||||
description="Composable navigation sidebar with sections, tree navigation, and icon-rail collapse mode."
|
||||
>
|
||||
<div className={styles.sidebarPreview}>
|
||||
<Sidebar apps={SAMPLE_APPS} />
|
||||
<Sidebar>
|
||||
<Sidebar.Header logo={<span style={{ fontSize: 20 }}>🐪</span>} title="cameleer" version="v3.2.1" />
|
||||
<Sidebar.Section label="Applications" icon={<Box size={14} />} open={true} onToggle={() => {}} active={false}>
|
||||
<SidebarTree nodes={SAMPLE_APP_NODES} isStarred={() => false} onToggleStar={() => {}} />
|
||||
</Sidebar.Section>
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<Settings size={14} />} label="Admin" />
|
||||
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</DemoCard>
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import { AlertTriangle } from 'lucide-react'
|
||||
import styles from './RouteDetail.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -22,7 +20,6 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall
|
||||
// Mock data
|
||||
import { routes } from '../../mocks/routes'
|
||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -199,11 +196,7 @@ export function RouteDetail() {
|
||||
// Not found state
|
||||
if (!route) {
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
@@ -211,24 +204,19 @@ export function RouteDetail() {
|
||||
{ label: id ?? 'Unknown' },
|
||||
]}
|
||||
environment="PRODUCTION"
|
||||
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<InfoCallout variant="warning">Route "{id}" not found in mock data.</InfoCallout>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const statusVariant = routeStatusVariant(route.status)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={SIDEBAR_APPS} />
|
||||
}
|
||||
>
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<TopBar
|
||||
breadcrumb={[
|
||||
@@ -375,6 +363,6 @@ export function RouteDetail() {
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useNavigate, useParams } from 'react-router-dom'
|
||||
import styles from './Routes.module.css'
|
||||
|
||||
// Layout
|
||||
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||
|
||||
// Composites
|
||||
@@ -33,7 +31,7 @@ import {
|
||||
type RouteMetricRow,
|
||||
} from '../../mocks/metrics'
|
||||
import { routes } from '../../mocks/routes'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
|
||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||
|
||||
@@ -410,7 +408,7 @@ export function Routes() {
|
||||
// ── Route detail view ───────────────────────────────────────────────────────
|
||||
if (routeId && appId && routeDef) {
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
environment="PRODUCTION"
|
||||
@@ -448,13 +446,13 @@ export function Routes() {
|
||||
<RouteFlow nodes={routeFlowNodes} />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Top level / Application level view ──────────────────────────────────────
|
||||
return (
|
||||
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
|
||||
<>
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
environment="PRODUCTION"
|
||||
@@ -533,6 +531,6 @@ export function Routes() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user