Compare commits
13 Commits
56297701e6
...
d21d8b2c48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21d8b2c48 | ||
|
|
d5f5601554 | ||
|
|
00042b1d14 | ||
|
|
fe49eb5aba | ||
|
|
bc913eef6e | ||
|
|
d70ad91b33 | ||
|
|
ba361af2d7 | ||
|
|
78777d2ba6 | ||
|
|
3f8a9715a4 | ||
|
|
f00a3e8b97 | ||
|
|
d5028193c0 | ||
|
|
a484364029 | ||
|
|
d95e518622 |
@@ -0,0 +1,368 @@
|
||||
# Composable Sidebar with Accordion & Collapse
|
||||
|
||||
**Issue:** [#112](https://gitea.siegeln.net/cameleer/cameleer3-server/issues/112)
|
||||
**Date:** 2026-04-02
|
||||
**Scope:** Design system refactor + server UI migration
|
||||
|
||||
## Problem
|
||||
|
||||
The current `Sidebar` component in `@cameleer/design-system` is monolithic — it hardcodes three navigation sections (Applications, Agents, Routes), a starred section, bottom links (Admin, API Docs), and all tree-building logic. This makes it impossible for the consuming application to:
|
||||
|
||||
1. Add new sections (e.g., Admin sub-pages) without modifying the DS
|
||||
2. Control section ordering or visibility based on application state
|
||||
3. Implement accordion behavior (expanding one section collapses others)
|
||||
4. Collapse the sidebar to an icon rail
|
||||
|
||||
## Solution
|
||||
|
||||
Refactor the Sidebar into a **composable compound component** where the DS provides the shell and building blocks, and the consuming application controls all content.
|
||||
|
||||
## New DS API
|
||||
|
||||
### Compound Component Structure
|
||||
|
||||
```tsx
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
|
||||
onSearchChange={setFilterQuery}
|
||||
>
|
||||
<Sidebar.Header
|
||||
logo={<CameleerLogo />}
|
||||
title="cameleer"
|
||||
version="v3.2.1"
|
||||
/>
|
||||
|
||||
<Sidebar.Section
|
||||
label="APPLICATIONS"
|
||||
icon={<Box size={14} />}
|
||||
collapsed={appsCollapsed}
|
||||
onToggle={() => setAppsCollapsed(v => !v)}
|
||||
>
|
||||
<SidebarTree nodes={appNodes} selectedPath={path} onNavigate={nav} filterQuery={filterQuery} />
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
label="AGENTS"
|
||||
icon={<Cpu size={14} />}
|
||||
collapsed={agentsCollapsed}
|
||||
onToggle={() => setAgentsCollapsed(v => !v)}
|
||||
>
|
||||
<SidebarTree nodes={agentNodes} selectedPath={path} onNavigate={nav} filterQuery={filterQuery} />
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
label="ROUTES"
|
||||
icon={<GitBranch size={14} />}
|
||||
collapsed={routesCollapsed}
|
||||
onToggle={() => setRoutesCollapsed(v => !v)}
|
||||
>
|
||||
<SidebarTree nodes={routeNodes} selectedPath={path} onNavigate={nav} filterQuery={filterQuery} />
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
label="ADMIN"
|
||||
icon={<Settings size={14} />}
|
||||
collapsed={adminCollapsed}
|
||||
onToggle={() => setAdminCollapsed(v => !v)}
|
||||
>
|
||||
<SidebarTree nodes={adminNodes} selectedPath={path} onNavigate={nav} filterQuery={filterQuery} />
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => nav('/api-docs')} />
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
```
|
||||
|
||||
### Component Specifications
|
||||
|
||||
#### `<Sidebar>`
|
||||
|
||||
The outer shell. Provides the sidebar frame, search input, scrollable content area, and collapse toggle.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `collapsed` | `boolean` | `false` | When true, renders as ~48px icon rail |
|
||||
| `onCollapseToggle` | `() => void` | - | Called when user clicks the collapse/expand toggle |
|
||||
| `onSearchChange` | `(query: string) => void` | - | Called on search input change. Omit to hide search. |
|
||||
| `children` | `ReactNode` | - | `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer` |
|
||||
| `className` | `string` | - | Additional CSS class |
|
||||
|
||||
**Search state ownership:** The DS renders the search input and calls `onSearchChange` on every keystroke. The consuming app owns the state (`filterQuery`) and passes it to each `SidebarTree`. This lets the app control filtering behavior (e.g., clear search on section switch, filter only certain sections).
|
||||
|
||||
**Expanded layout (default):**
|
||||
```
|
||||
+---------------------------+
|
||||
| [Header] [<<] |
|
||||
|---------------------------|
|
||||
| [Search...] |
|
||||
| |
|
||||
| [Section 1] |
|
||||
| [Section 2] |
|
||||
| [Section ...] |
|
||||
| |
|
||||
| [Footer] |
|
||||
+---------------------------+
|
||||
~260px
|
||||
```
|
||||
|
||||
**Collapsed layout:**
|
||||
```
|
||||
+------+
|
||||
| [>>] |
|
||||
|------|
|
||||
| [i1] | <- Section icons, centered
|
||||
| [i2] | <- Tooltip on hover shows label
|
||||
| [i3] |
|
||||
| [i4] |
|
||||
|------|
|
||||
| [f1] | <- Footer link icons
|
||||
+------+
|
||||
~48px
|
||||
```
|
||||
|
||||
- `[<<]` / `[>>]` toggle button in top-right corner (chevron icon)
|
||||
- Width transition: CSS `width` + `transition: width 200ms ease`
|
||||
- When collapsed, search input is hidden
|
||||
- When collapsed, clicking a section icon fires both `onCollapseToggle` and that section's `onToggle` simultaneously. The sidebar expands and the section opens in one motion. No navigation occurs — the user clicks a tree item after the section is visible to navigate.
|
||||
|
||||
#### `<Sidebar.Header>`
|
||||
|
||||
Renders logo, title, and version. In collapsed mode, renders only the logo (centered).
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `logo` | `ReactNode` | - | Logo icon/image (required) |
|
||||
| `title` | `string` | - | App name shown next to logo |
|
||||
| `version` | `string` | - | Version badge |
|
||||
|
||||
#### `<Sidebar.Section>`
|
||||
|
||||
An accordion section with a collapsible header and content area.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `label` | `string` | - | Section header text (rendered uppercase) |
|
||||
| `icon` | `ReactNode` | - | Icon shown in header and in collapsed rail |
|
||||
| `collapsed` | `boolean` | `false` | Whether content is hidden |
|
||||
| `onToggle` | `() => void` | - | Called when header is clicked |
|
||||
| `children` | `ReactNode` | - | Content rendered when expanded (typically `SidebarTree`) |
|
||||
| `active` | `boolean` | auto | Override active state highlight. Auto-detected if omitted (any descendant matches current path). |
|
||||
|
||||
**Expanded state:**
|
||||
```
|
||||
v APPLICATIONS <- clickable header, chevron rotates
|
||||
> backend-app 3.4k
|
||||
> caller-app 891
|
||||
> sample-app 9.6k
|
||||
```
|
||||
|
||||
**Collapsed state:**
|
||||
```
|
||||
> APPLICATIONS <- single line, clickable
|
||||
```
|
||||
|
||||
**In sidebar collapsed (icon rail) mode:**
|
||||
```
|
||||
[B] <- icon only, tooltip "Applications"
|
||||
```
|
||||
|
||||
Header styling: uppercase label, muted color, chevron left of label, icon left of chevron. Active section gets amber accent (same as current active highlighting pattern).
|
||||
|
||||
#### `<Sidebar.Footer>`
|
||||
|
||||
Pinned to the bottom of the sidebar. Renders children (typically `Sidebar.FooterLink` items).
|
||||
|
||||
In collapsed mode, footer links render as centered icons.
|
||||
|
||||
#### `<Sidebar.FooterLink>`
|
||||
|
||||
A single bottom link item.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `icon` | `ReactNode` | - | Link icon |
|
||||
| `label` | `string` | - | Link text (hidden when sidebar collapsed, shown as tooltip) |
|
||||
| `onClick` | `() => void` | - | Click handler |
|
||||
| `active` | `boolean` | `false` | Active state highlight |
|
||||
|
||||
#### `<SidebarTree>` (unchanged, newly exported)
|
||||
|
||||
The existing `SidebarTree` component stays as-is. It already accepts all its data via props (`nodes`, `selectedPath`, `filterQuery`, `onNavigate`, `persistKey`, `autoRevealPath`, `isStarred`, `onToggleStar`). It just needs to be exported from the package.
|
||||
|
||||
The `SidebarTreeNode` type is also exported so consuming apps can build tree data.
|
||||
|
||||
#### `useStarred` hook (unchanged, newly exported)
|
||||
|
||||
The existing `useStarred` hook stays as-is. Export it so the consuming app can pass `isStarred`/`onToggleStar` to `SidebarTree`.
|
||||
|
||||
### What Gets Removed from DS
|
||||
|
||||
The current monolithic `Sidebar` component contains ~300 lines of application-specific logic that moves to the server UI:
|
||||
|
||||
1. **Tree-building functions**: `buildAppTreeNodes()`, `buildRouteTreeNodes()`, `buildAgentTreeNodes()` — these transform `SidebarApp[]` into `SidebarTreeNode[]`. They move to the server UI.
|
||||
2. **Starred section rendering**: `collectStarredItems()` and `StarredGroup` — starred items become a regular `Sidebar.Section` in the server UI (or inline within sections via `SidebarTree`'s existing star support).
|
||||
3. **Hardcoded bottom links**: Admin and API Docs links — move to `Sidebar.Footer` + `Sidebar.FooterLink` in server UI.
|
||||
4. **Section collapse state management**: localStorage persistence of `cameleer:sidebar:*-collapsed` — moves to server UI.
|
||||
5. **Auto-reveal logic**: `sidebarRevealPath` effect that auto-expands sections — moves to server UI.
|
||||
6. **`SidebarApp` / `SidebarRoute` / `SidebarAgent` types**: These are application-domain types, not DS types. Move to server UI. DS only exports `SidebarTreeNode`.
|
||||
|
||||
### New DS Exports
|
||||
|
||||
```typescript
|
||||
// layout/index.ts — updated exports
|
||||
export { Sidebar } from './Sidebar/Sidebar'
|
||||
export { SidebarTree } from './Sidebar/SidebarTree'
|
||||
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
||||
export { useStarred } from './Sidebar/useStarred'
|
||||
```
|
||||
|
||||
The `SidebarApp`, `SidebarRoute`, `SidebarAgent` type exports are removed (they move to the consuming application).
|
||||
|
||||
## Server UI Migration
|
||||
|
||||
### LayoutShell.tsx Changes
|
||||
|
||||
The current `LayoutShell` already does most of the data preparation (building `sidebarApps`, handling `handleSidebarNavigate`). After migration:
|
||||
|
||||
1. Move tree-building functions (`buildAppTreeNodes`, etc.) from DS into a local `sidebar-utils.ts`
|
||||
2. Manage section collapse states with localStorage persistence
|
||||
3. Implement accordion logic: when on `/admin/*`, expand Admin section and collapse operational sections; when navigating away, restore previous states
|
||||
4. Pass `filterQuery` from search to each `SidebarTree`
|
||||
5. Compose the new `<Sidebar>` with sections
|
||||
|
||||
### AdminLayout.tsx Changes
|
||||
|
||||
Remove the `<Tabs>` navigation — sidebar now handles admin sub-page navigation. Keep just the content wrapper:
|
||||
|
||||
```tsx
|
||||
export default function AdminLayout() {
|
||||
return (
|
||||
<div style={{ padding: '20px 24px 40px' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ContentTabs
|
||||
|
||||
Still hidden on admin pages (existing `isAdminPage` guard). The tab strip is irrelevant when the sidebar shows the admin context.
|
||||
|
||||
### TopBar
|
||||
|
||||
No changes. Stays visible on all pages.
|
||||
|
||||
## Accordion Behavior (Server UI Logic)
|
||||
|
||||
The accordion is not a DS concept — it's application logic in the server UI:
|
||||
|
||||
```typescript
|
||||
// When navigating to /admin/*:
|
||||
// 1. Remember current operational collapse states
|
||||
// 2. Collapse all operational sections
|
||||
// 3. Expand Admin section
|
||||
|
||||
// When navigating away from /admin/*:
|
||||
// 1. Collapse Admin section
|
||||
// 2. Restore operational collapse states from memory
|
||||
```
|
||||
|
||||
This means the DS sections are purely controlled components. The app decides which are open/closed based on current route.
|
||||
|
||||
## Visual States
|
||||
|
||||
### Operational Mode
|
||||
```
|
||||
+---------------------------+
|
||||
| [C] cameleer v3.2.1 [<<] |
|
||||
|---------------------------|
|
||||
| [Search...] |
|
||||
| |
|
||||
| v APPLICATIONS |
|
||||
| > backend-app 3.4k |
|
||||
| > caller-app 891 |
|
||||
| > sample-app 9.6k |
|
||||
| |
|
||||
| v AGENTS |
|
||||
| > backend-app 3/3 live |
|
||||
| > caller-app 2/2 live |
|
||||
| |
|
||||
| > ROUTES |
|
||||
| > ADMIN |
|
||||
| |
|
||||
| [API Docs] |
|
||||
+---------------------------+
|
||||
```
|
||||
|
||||
### Admin Mode (accordion)
|
||||
```
|
||||
+---------------------------+
|
||||
| [C] cameleer v3.2.1 [<<] |
|
||||
|---------------------------|
|
||||
| [Search...] |
|
||||
| |
|
||||
| v ADMIN |
|
||||
| > User Management |
|
||||
| > Audit Log |
|
||||
| > OIDC |
|
||||
| > App Config |
|
||||
| > Database |
|
||||
| > ClickHouse |
|
||||
| |
|
||||
| > APPLICATIONS |
|
||||
| > AGENTS |
|
||||
| > ROUTES |
|
||||
| |
|
||||
| [API Docs] |
|
||||
+---------------------------+
|
||||
```
|
||||
|
||||
### Collapsed (Icon Rail)
|
||||
```
|
||||
+------+
|
||||
| [C] |
|
||||
| [>>] |
|
||||
|------|
|
||||
| [B] | <- Applications
|
||||
| [C] | <- Agents
|
||||
| [G] | <- Routes
|
||||
| [S] | <- Admin
|
||||
|------|
|
||||
| [F] | <- API Docs
|
||||
+------+
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
This is a two-repo change:
|
||||
|
||||
1. **DS refactor** (design-system repo):
|
||||
- Create `Sidebar` compound component shell (`Sidebar`, `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`)
|
||||
- Extract `SidebarTree` and `useStarred` as standalone exports
|
||||
- Add `collapsed` / icon-rail mode
|
||||
- Remove application-specific logic (tree builders, hardcoded sections, bottom links)
|
||||
- Update barrel exports
|
||||
- Bump version
|
||||
|
||||
2. **Server UI migration** (cameleer3-server repo):
|
||||
- Move tree-building functions to local utils
|
||||
- Rewrite sidebar composition in `LayoutShell` using new compound API
|
||||
- Add accordion logic for admin mode
|
||||
- Add sidebar collapse toggle with localStorage persistence
|
||||
- Simplify `AdminLayout` (remove tabs)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] DS: `Sidebar` accepts `collapsed` prop and renders as icon rail when true
|
||||
- [ ] DS: `Sidebar.Section` renders as accordion item with icon, label, chevron, and children
|
||||
- [ ] DS: `Sidebar.Footer` / `Sidebar.FooterLink` render bottom links
|
||||
- [ ] DS: `SidebarTree`, `SidebarTreeNode`, `useStarred` exported as standalone
|
||||
- [ ] DS: No application-specific logic remains in Sidebar (no hardcoded sections, no tree builders)
|
||||
- [ ] UI: Sidebar shows operational sections (Apps, Agents, Routes) on operational pages
|
||||
- [ ] UI: Clicking Admin expands Admin section, collapses operational sections
|
||||
- [ ] UI: Clicking an operational section header exits admin mode, restores previous state
|
||||
- [ ] UI: Sidebar collapse/expand toggle works with icon rail mode
|
||||
- [ ] UI: Admin tabs removed from AdminLayout (sidebar handles navigation)
|
||||
- [ ] UI: All icons passed by the consuming application, not hardcoded in DS
|
||||
@@ -1,22 +1,41 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, BreadcrumbProvider, useCommandPalette, useGlobalFilters } from '@cameleer/design-system';
|
||||
import type { SidebarApp, SearchResult } from '@cameleer/design-system';
|
||||
import {
|
||||
AppShell,
|
||||
Sidebar,
|
||||
SidebarTree,
|
||||
StatusDot,
|
||||
TopBar,
|
||||
CommandPalette,
|
||||
CommandPaletteProvider,
|
||||
GlobalFilterProvider,
|
||||
ToastProvider,
|
||||
BreadcrumbProvider,
|
||||
useCommandPalette,
|
||||
useGlobalFilters,
|
||||
useStarred,
|
||||
} from '@cameleer/design-system';
|
||||
import type { SearchResult, SidebarTreeNode } from '@cameleer/design-system';
|
||||
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight } from 'lucide-react';
|
||||
import { useRouteCatalog } from '../api/queries/catalog';
|
||||
import { useAgents } from '../api/queries/agents';
|
||||
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
|
||||
import { ContentTabs } from './ContentTabs';
|
||||
import { useScope } from '../hooks/useScope';
|
||||
import {
|
||||
buildAppTreeNodes,
|
||||
buildAgentTreeNodes,
|
||||
buildRouteTreeNodes,
|
||||
buildAdminTreeNodes,
|
||||
readCollapsed,
|
||||
writeCollapsed,
|
||||
} from './sidebar-utils';
|
||||
import type { SidebarApp } from './sidebar-utils';
|
||||
|
||||
function healthToColor(health: string): string {
|
||||
switch (health) {
|
||||
case 'live': return 'success';
|
||||
case 'stale': return 'warning';
|
||||
case 'dead': return 'error';
|
||||
default: return 'auto';
|
||||
}
|
||||
}
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Search data builder (unchanged) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function buildSearchData(
|
||||
catalog: any[] | undefined,
|
||||
@@ -32,7 +51,7 @@ function buildSearchData(
|
||||
id: app.appId,
|
||||
category: 'application',
|
||||
title: app.appId,
|
||||
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToColor(app.health) }],
|
||||
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToSearchColor(app.health) }],
|
||||
meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
||||
path: `/exchanges/${app.appId}`,
|
||||
});
|
||||
@@ -55,7 +74,7 @@ function buildSearchData(
|
||||
id: agent.instanceId,
|
||||
category: 'agent',
|
||||
title: agent.displayName,
|
||||
badges: [{ label: (agent.status || 'unknown').toUpperCase(), color: healthToColor((agent.status || '').toLowerCase()) }],
|
||||
badges: [{ label: (agent.status || 'unknown').toUpperCase(), color: healthToSearchColor((agent.status || '').toLowerCase()) }],
|
||||
meta: `${agent.applicationId} · ${agent.version || ''}${agent.tps != null ? ` · ${agent.tps.toFixed(1)} msg/s` : ''}`,
|
||||
path: `/runtime/${agent.applicationId}/${agent.instanceId}`,
|
||||
});
|
||||
@@ -76,6 +95,15 @@ function buildSearchData(
|
||||
return results;
|
||||
}
|
||||
|
||||
function healthToSearchColor(health: string): string {
|
||||
switch (health) {
|
||||
case 'live': return 'success';
|
||||
case 'stale': return 'warning';
|
||||
case 'dead': return 'error';
|
||||
default: return 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
||||
@@ -100,6 +128,32 @@ function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Icon factories for tree builders */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function makeStatusDot(health: string) {
|
||||
return createElement(StatusDot, { variant: health as any });
|
||||
}
|
||||
|
||||
function makeChevron() {
|
||||
return createElement(ChevronRight, { size: 14 });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Section open-state keys */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const SK_APPS = 'sidebar:section:apps';
|
||||
const SK_AGENTS = 'sidebar:section:agents';
|
||||
const SK_ROUTES = 'sidebar:section:routes';
|
||||
const SK_ADMIN = 'sidebar:section:admin';
|
||||
const SK_COLLAPSED = 'sidebar:collapsed';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main layout content */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function LayoutContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -111,20 +165,100 @@ function LayoutContent() {
|
||||
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
||||
const { scope, setTab } = useScope();
|
||||
|
||||
// Exchange full-text search via command palette (scoped to current sidebar selection)
|
||||
const [paletteQuery, setPaletteQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
|
||||
const { data: exchangeResults } = useSearchExecutions(
|
||||
{
|
||||
text: debouncedQuery || undefined,
|
||||
applicationId: scope.appId || undefined,
|
||||
routeId: scope.routeId || undefined,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
false,
|
||||
);
|
||||
// --- Starred items ------------------------------------------------
|
||||
const { isStarred, toggleStar } = useStarred();
|
||||
|
||||
// --- Sidebar collapse ---------------------------------------------
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readCollapsed(SK_COLLAPSED, false));
|
||||
const handleCollapseToggle = useCallback(() => {
|
||||
setSidebarCollapsed((prev) => {
|
||||
writeCollapsed(SK_COLLAPSED, !prev);
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// --- Sidebar filter -----------------------------------------------
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
|
||||
// --- Section open states ------------------------------------------
|
||||
const isAdminPage = location.pathname.startsWith('/admin');
|
||||
const [appsOpen, setAppsOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_APPS, true));
|
||||
const [agentsOpen, setAgentsOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_AGENTS, false));
|
||||
const [routesOpen, setRoutesOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_ROUTES, false));
|
||||
const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false));
|
||||
|
||||
// Ref to remember operational section states when switching to admin
|
||||
const opsStateRef = useRef({ apps: appsOpen, agents: agentsOpen, routes: routesOpen });
|
||||
|
||||
// Accordion effect: when entering admin, collapse operational sections; when leaving, restore
|
||||
const prevAdminRef = useRef(isAdminPage);
|
||||
useEffect(() => {
|
||||
if (isAdminPage && !prevAdminRef.current) {
|
||||
// Entering admin — save operational states and collapse them
|
||||
opsStateRef.current = { apps: appsOpen, agents: agentsOpen, routes: routesOpen };
|
||||
setAppsOpen(false);
|
||||
setAgentsOpen(false);
|
||||
setRoutesOpen(false);
|
||||
setAdminOpen(true);
|
||||
writeCollapsed(SK_APPS, false);
|
||||
writeCollapsed(SK_AGENTS, false);
|
||||
writeCollapsed(SK_ADMIN, true);
|
||||
} else if (!isAdminPage && prevAdminRef.current) {
|
||||
// Leaving admin — restore operational states
|
||||
setAppsOpen(opsStateRef.current.apps);
|
||||
setAgentsOpen(opsStateRef.current.agents);
|
||||
setRoutesOpen(opsStateRef.current.routes);
|
||||
setAdminOpen(false);
|
||||
writeCollapsed(SK_APPS, opsStateRef.current.apps);
|
||||
writeCollapsed(SK_AGENTS, opsStateRef.current.agents);
|
||||
writeCollapsed(SK_ROUTES, opsStateRef.current.routes);
|
||||
writeCollapsed(SK_ADMIN, false);
|
||||
}
|
||||
prevAdminRef.current = isAdminPage;
|
||||
}, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const toggleApps = useCallback(() => {
|
||||
if (isAdminPage) {
|
||||
// Clicking operational section while in admin navigates away
|
||||
navigate('/exchanges');
|
||||
return;
|
||||
}
|
||||
setAppsOpen((prev) => {
|
||||
writeCollapsed(SK_APPS, !prev);
|
||||
return !prev;
|
||||
});
|
||||
}, [isAdminPage, navigate]);
|
||||
|
||||
const toggleAgents = useCallback(() => {
|
||||
if (isAdminPage) {
|
||||
navigate('/exchanges');
|
||||
return;
|
||||
}
|
||||
setAgentsOpen((prev) => {
|
||||
writeCollapsed(SK_AGENTS, !prev);
|
||||
return !prev;
|
||||
});
|
||||
}, [isAdminPage, navigate]);
|
||||
|
||||
const toggleRoutes = useCallback(() => {
|
||||
if (isAdminPage) {
|
||||
navigate('/exchanges');
|
||||
return;
|
||||
}
|
||||
setRoutesOpen((prev) => {
|
||||
writeCollapsed(SK_ROUTES, !prev);
|
||||
return !prev;
|
||||
});
|
||||
}, [isAdminPage, navigate]);
|
||||
|
||||
const toggleAdmin = useCallback(() => {
|
||||
setAdminOpen((prev) => {
|
||||
writeCollapsed(SK_ADMIN, !prev);
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// --- Build SidebarApp[] from catalog ------------------------------
|
||||
const sidebarApps: SidebarApp[] = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
const cmp = (a: string, b: string) => a.localeCompare(b);
|
||||
@@ -153,13 +287,50 @@ function LayoutContent() {
|
||||
}));
|
||||
}, [catalog]);
|
||||
|
||||
// --- Tree nodes ---------------------------------------------------
|
||||
const appTreeNodes: SidebarTreeNode[] = useMemo(
|
||||
() => buildAppTreeNodes(sidebarApps, makeStatusDot, makeChevron),
|
||||
[sidebarApps],
|
||||
);
|
||||
|
||||
const agentTreeNodes: SidebarTreeNode[] = useMemo(
|
||||
() => buildAgentTreeNodes(sidebarApps, makeStatusDot),
|
||||
[sidebarApps],
|
||||
);
|
||||
|
||||
const routeTreeNodes: SidebarTreeNode[] = useMemo(
|
||||
() => buildRouteTreeNodes(sidebarApps, makeStatusDot, makeChevron),
|
||||
[sidebarApps],
|
||||
);
|
||||
|
||||
const adminTreeNodes: SidebarTreeNode[] = useMemo(
|
||||
() => buildAdminTreeNodes(),
|
||||
[],
|
||||
);
|
||||
|
||||
// --- Reveal path for SidebarTree auto-expand ----------------------
|
||||
const sidebarRevealPath = (location.state as any)?.sidebarReveal ?? null;
|
||||
|
||||
// --- Exchange full-text search via command palette -----------------
|
||||
const [paletteQuery, setPaletteQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
|
||||
const { data: exchangeResults } = useSearchExecutions(
|
||||
{
|
||||
text: debouncedQuery || undefined,
|
||||
applicationId: scope.appId || undefined,
|
||||
routeId: scope.routeId || undefined,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const catalogData = useMemo(
|
||||
() => buildSearchData(catalog, agents as any[], attributeKeys),
|
||||
[catalog, agents, attributeKeys],
|
||||
);
|
||||
|
||||
// Stable reference for catalog data — only changes when catalog/agents actually change,
|
||||
// not on every poll cycle (prevents cmd-k scroll reset)
|
||||
// Stable reference for catalog data
|
||||
const catalogRef = useRef(catalogData);
|
||||
if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) {
|
||||
catalogRef.current = catalogData;
|
||||
@@ -201,7 +372,7 @@ function LayoutContent() {
|
||||
return [...catalogRef.current, ...exchangeItems, ...attributeItems];
|
||||
}, [catalogRef.current, exchangeResults, debouncedQuery]);
|
||||
|
||||
const isAdminPage = location.pathname.startsWith('/admin');
|
||||
// --- Breadcrumb ---------------------------------------------------
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (isAdminPage) {
|
||||
const LABELS: Record<string, string> = {
|
||||
@@ -219,7 +390,6 @@ function LayoutContent() {
|
||||
...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}),
|
||||
}));
|
||||
}
|
||||
// Scope trail as breadcrumb items
|
||||
const items: { label: string; href?: string }[] = [
|
||||
{ label: 'All Applications', href: `/${scope.tab}` },
|
||||
];
|
||||
@@ -229,13 +399,13 @@ function LayoutContent() {
|
||||
if (scope.routeId) {
|
||||
items.push({ label: scope.routeId });
|
||||
}
|
||||
// Last item has no href (current location)
|
||||
if (items.length > 0 && !scope.routeId && !scope.appId) {
|
||||
delete items[items.length - 1].href;
|
||||
}
|
||||
return items;
|
||||
}, [location.pathname, isAdminPage, scope.tab, scope.appId, scope.routeId]);
|
||||
|
||||
// --- Callbacks ----------------------------------------------------
|
||||
const handleLogout = useCallback(() => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
@@ -245,7 +415,6 @@ function LayoutContent() {
|
||||
if (result.path) {
|
||||
const state: Record<string, unknown> = { sidebarReveal: result.path };
|
||||
|
||||
// For exchange/attribute results, pass selectedExchange in state
|
||||
if (result.category === 'exchange' || result.category === 'attribute') {
|
||||
const parts = result.path.split('/').filter(Boolean);
|
||||
if (parts.length === 4 && parts[0] === 'exchanges') {
|
||||
@@ -270,11 +439,10 @@ function LayoutContent() {
|
||||
}, [navigate, scope.appId, scope.routeId]);
|
||||
|
||||
// Translate Sidebar's internal paths to our URL structure.
|
||||
// Pass sidebarReveal state so the DS Sidebar can highlight the clicked entry.
|
||||
const handleSidebarNavigate = useCallback((path: string) => {
|
||||
const state = { sidebarReveal: path };
|
||||
|
||||
// /apps/:appId and /apps/:appId/:routeId → current tab
|
||||
// /apps/:appId and /apps/:appId/:routeId -> current tab
|
||||
const appMatch = path.match(/^\/apps\/([^/]+)(?:\/(.+))?$/);
|
||||
if (appMatch) {
|
||||
const [, sAppId, sRouteId] = appMatch;
|
||||
@@ -282,7 +450,7 @@ function LayoutContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// /agents/:appId/:instanceId → runtime tab
|
||||
// /agents/:appId/:instanceId -> runtime tab
|
||||
const agentMatch = path.match(/^\/agents\/([^/]+)(?:\/(.+))?$/);
|
||||
if (agentMatch) {
|
||||
const [, sAppId, sInstanceId] = agentMatch;
|
||||
@@ -293,12 +461,129 @@ function LayoutContent() {
|
||||
navigate(path, { state });
|
||||
}, [navigate, scope.tab]);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar apps={sidebarApps} onNavigate={handleSidebarNavigate} />
|
||||
}
|
||||
// --- Render -------------------------------------------------------
|
||||
const sidebarElement = (
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapseToggle={handleCollapseToggle}
|
||||
searchValue={filterQuery}
|
||||
onSearchChange={setFilterQuery}
|
||||
>
|
||||
<Sidebar.Header
|
||||
logo={createElement(Box, { size: 20 })}
|
||||
title="Cameleer"
|
||||
/>
|
||||
|
||||
{/* When on admin pages, show Admin section first (expanded) */}
|
||||
{isAdminPage && (
|
||||
<Sidebar.Section
|
||||
icon={createElement(Settings, { size: 16 })}
|
||||
label="Admin"
|
||||
open={adminOpen}
|
||||
onToggle={toggleAdmin}
|
||||
active
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={adminTreeNodes}
|
||||
selectedPath={location.pathname}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="admin"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
)}
|
||||
|
||||
<Sidebar.Section
|
||||
icon={createElement(Box, { size: 16 })}
|
||||
label="Applications"
|
||||
open={appsOpen}
|
||||
onToggle={toggleApps}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={appTreeNodes}
|
||||
selectedPath={sidebarRevealPath ?? location.pathname}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="apps"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
icon={createElement(Cpu, { size: 16 })}
|
||||
label="Agents"
|
||||
open={agentsOpen}
|
||||
onToggle={toggleAgents}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={agentTreeNodes}
|
||||
selectedPath={sidebarRevealPath ?? location.pathname}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="agents"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
icon={createElement(GitBranch, { size: 16 })}
|
||||
label="Routes"
|
||||
open={routesOpen}
|
||||
onToggle={toggleRoutes}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={routeTreeNodes}
|
||||
selectedPath={sidebarRevealPath ?? location.pathname}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="routes"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
|
||||
{/* When NOT on admin pages, show Admin section at bottom */}
|
||||
{!isAdminPage && (
|
||||
<Sidebar.Section
|
||||
icon={createElement(Settings, { size: 16 })}
|
||||
label="Admin"
|
||||
open={adminOpen}
|
||||
onToggle={toggleAdmin}
|
||||
>
|
||||
<SidebarTree
|
||||
nodes={adminTreeNodes}
|
||||
selectedPath={location.pathname}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={filterQuery}
|
||||
persistKey="admin"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
/>
|
||||
</Sidebar.Section>
|
||||
)}
|
||||
|
||||
<Sidebar.Footer>
|
||||
<Sidebar.FooterLink
|
||||
icon={createElement(FileText, { size: 16 })}
|
||||
label="API Docs"
|
||||
active={location.pathname === '/api-docs'}
|
||||
onClick={() => handleSidebarNavigate('/api-docs')}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell sidebar={sidebarElement}>
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
user={username ? { name: username } : undefined}
|
||||
@@ -318,7 +603,7 @@ function LayoutContent() {
|
||||
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
|
||||
)}
|
||||
|
||||
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, padding: isAdminPage ? '1.5rem' : 0 }}>
|
||||
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</AppShell>
|
||||
|
||||
160
ui/src/components/sidebar-utils.ts
Normal file
160
ui/src/components/sidebar-utils.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SidebarTreeNode } from '@cameleer/design-system';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Domain types (moved out of DS — no longer exported there) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface SidebarRoute {
|
||||
id: string;
|
||||
name: string;
|
||||
exchangeCount: number;
|
||||
}
|
||||
|
||||
export interface SidebarAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'live' | 'stale' | 'dead';
|
||||
tps?: number;
|
||||
}
|
||||
|
||||
export interface SidebarApp {
|
||||
id: string;
|
||||
name: string;
|
||||
health: 'live' | 'stale' | 'dead';
|
||||
exchangeCount: number;
|
||||
routes: SidebarRoute[];
|
||||
agents: SidebarAgent[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Formatting helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function formatCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* localStorage collapse helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function readCollapsed(key: string, defaultValue: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return defaultValue;
|
||||
return raw === 'true';
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCollapsed(key: string, value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tree builders */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Apps tree — one node per app, routes as children.
|
||||
* Paths: /apps/{appId}, /apps/{appId}/{routeId}
|
||||
*/
|
||||
export function buildAppTreeNodes(
|
||||
apps: SidebarApp[],
|
||||
statusDot: (health: string) => ReactNode,
|
||||
chevron: () => ReactNode,
|
||||
): SidebarTreeNode[] {
|
||||
return apps.map((app) => ({
|
||||
id: app.id,
|
||||
label: app.name,
|
||||
icon: statusDot(app.health),
|
||||
badge: formatCount(app.exchangeCount),
|
||||
path: `/apps/${app.id}`,
|
||||
starrable: true,
|
||||
starKey: `app:${app.id}`,
|
||||
children: app.routes.map((r) => ({
|
||||
id: `${app.id}/${r.id}`,
|
||||
label: r.name,
|
||||
icon: chevron(),
|
||||
badge: formatCount(r.exchangeCount),
|
||||
path: `/apps/${app.id}/${r.id}`,
|
||||
starrable: true,
|
||||
starKey: `route:${app.id}/${r.id}`,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents tree — one node per app, agents as children.
|
||||
* Paths: /agents/{appId}, /agents/{appId}/{agentId}
|
||||
* Badge shows "N/M live".
|
||||
*/
|
||||
export function buildAgentTreeNodes(
|
||||
apps: SidebarApp[],
|
||||
statusDot: (health: string) => ReactNode,
|
||||
): SidebarTreeNode[] {
|
||||
return apps.map((app) => {
|
||||
const liveCount = app.agents.filter((a) => a.status === 'live').length;
|
||||
return {
|
||||
id: `agent:${app.id}`,
|
||||
label: app.name,
|
||||
icon: statusDot(app.health),
|
||||
badge: `${liveCount}/${app.agents.length} live`,
|
||||
path: `/agents/${app.id}`,
|
||||
children: app.agents.map((a) => ({
|
||||
id: `agent:${app.id}/${a.id}`,
|
||||
label: a.name,
|
||||
icon: statusDot(a.status),
|
||||
badge: a.tps != null ? `${a.tps.toFixed(1)} msg/s` : undefined,
|
||||
path: `/agents/${app.id}/${a.id}`,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes stats tree — one node per app, routes as children.
|
||||
* Paths: /routes/{appId}, /routes/{appId}/{routeId}
|
||||
*/
|
||||
export function buildRouteTreeNodes(
|
||||
apps: SidebarApp[],
|
||||
statusDot: (health: string) => ReactNode,
|
||||
chevron: () => ReactNode,
|
||||
): SidebarTreeNode[] {
|
||||
return apps.map((app) => ({
|
||||
id: `route:${app.id}`,
|
||||
label: app.name,
|
||||
icon: statusDot(app.health),
|
||||
badge: `${app.routes.length} routes`,
|
||||
path: `/routes/${app.id}`,
|
||||
children: app.routes.map((r) => ({
|
||||
id: `route:${app.id}/${r.id}`,
|
||||
label: r.name,
|
||||
icon: chevron(),
|
||||
badge: formatCount(r.exchangeCount),
|
||||
path: `/routes/${app.id}/${r.id}`,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin tree — static 6 nodes.
|
||||
*/
|
||||
export function buildAdminTreeNodes(): SidebarTreeNode[] {
|
||||
return [
|
||||
{ id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
|
||||
{ id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' },
|
||||
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
||||
{ id: 'admin:appconfig', label: 'App Config', path: '/admin/appconfig' },
|
||||
{ id: 'admin:database', label: 'Database', path: '/admin/database' },
|
||||
{ id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' },
|
||||
];
|
||||
}
|
||||
@@ -1,29 +1,9 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||
import { Tabs } from '@cameleer/design-system';
|
||||
|
||||
const ADMIN_TABS = [
|
||||
{ label: 'User Management', value: '/admin/rbac' },
|
||||
{ label: 'Audit Log', value: '/admin/audit' },
|
||||
{ label: 'OIDC', value: '/admin/oidc' },
|
||||
{ label: 'App Config', value: '/admin/appconfig' },
|
||||
{ label: 'Database', value: '/admin/database' },
|
||||
{ label: 'ClickHouse', value: '/admin/clickhouse' },
|
||||
];
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
tabs={ADMIN_TABS}
|
||||
active={location.pathname}
|
||||
onChange={(path) => navigate(path)}
|
||||
/>
|
||||
<div style={{ padding: '20px 24px 40px' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user