Compare commits

13 Commits

Author SHA1 Message Date
hsiegeln
d21d8b2c48 fix(#112): initialize sidebar accordion state from initial route
Some checks failed
CI / build (push) Failing after 43s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Direct navigation to /admin/* now correctly opens Admin section
and collapses operational sections on first render. Previously
the accordion effect only triggered on route transitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:36:43 +02:00
hsiegeln
d5f5601554 fix(#112): add missing Routes section, fix admin double padding
Review feedback: buildRouteTreeNodes was defined but never rendered.
Added Routes section between Agents and Admin. Removed duplicate
padding on admin pages (AdminLayout handles its own padding).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:32:26 +02:00
hsiegeln
00042b1d14 feat(#112): remove admin tabs, sidebar handles navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:29:29 +02:00
hsiegeln
fe49eb5aba feat(#112): migrate to composable sidebar with accordion and collapse
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:29:25 +02:00
hsiegeln
bc913eef6e feat(#112): extract sidebar tree builders and types from DS
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:29:22 +02:00
hsiegeln
d70ad91b33 docs: clarify search ownership and icon-rail click behavior
Search: DS renders dumb input, app owns filterQuery state and
passes it to each SidebarTree. Icon-rail click: fires both
onCollapseToggle and onToggle simultaneously, no navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:41:31 +02:00
hsiegeln
ba361af2d7 docs: composable sidebar design spec for #112
Replaces the previous "hide sidebar on admin" approach with a
composable compound component design. DS provides shell + building
blocks (Sidebar, Section, Footer, SidebarTree); consuming app
controls all content, section ordering, accordion behavior, and
icon-rail collapse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:38:01 +02:00
hsiegeln
78777d2ba6 Revert "feat(#112): hide sidebar, topbar, cmd palette on admin pages"
This reverts commit d95e518622.
2026-04-02 17:22:06 +02:00
hsiegeln
3f8a9715a4 Revert "feat(#112): add admin header bar with back button and logout"
This reverts commit a484364029.
2026-04-02 17:22:06 +02:00
hsiegeln
f00a3e8b97 Revert "fix(#112): remove dead admin breadcrumb code, add logout aria-label"
This reverts commit d5028193c0.
2026-04-02 17:22:06 +02:00
hsiegeln
d5028193c0 fix(#112): remove dead admin breadcrumb code, add logout aria-label
Review feedback: breadcrumb memo had an unused isAdminPage branch
(TopBar no longer renders on admin pages). Added aria-label to
icon-only logout button for screen readers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:16:01 +02:00
hsiegeln
a484364029 feat(#112): add admin header bar with back button and logout
AdminLayout gains a self-contained header (Back / Admin / user+logout)
with CSS module styles, replacing the inline padding wrapper. Admin
pages now render fully without the main app chrome.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:12:50 +02:00
hsiegeln
d95e518622 feat(#112): hide sidebar, topbar, cmd palette on admin pages
Pass null as sidebar prop, guard TopBar and CommandPalette with
!isAdminPage, and remove conditional admin padding from main element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:12:44 +02:00
4 changed files with 857 additions and 64 deletions

View File

@@ -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

View File

@@ -1,22 +1,41 @@
import { Outlet, useNavigate, useLocation } from 'react-router'; import { Outlet, useNavigate, useLocation } from 'react-router';
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, BreadcrumbProvider, useCommandPalette, useGlobalFilters } from '@cameleer/design-system'; import {
import type { SidebarApp, SearchResult } from '@cameleer/design-system'; 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 { useRouteCatalog } from '../api/queries/catalog';
import { useAgents } from '../api/queries/agents'; import { useAgents } from '../api/queries/agents';
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions'; import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
import { useAuthStore } from '../auth/auth-store'; 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 { ContentTabs } from './ContentTabs';
import { useScope } from '../hooks/useScope'; 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) { /* Search data builder (unchanged) */
case 'live': return 'success'; /* ------------------------------------------------------------------ */
case 'stale': return 'warning';
case 'dead': return 'error';
default: return 'auto';
}
}
function buildSearchData( function buildSearchData(
catalog: any[] | undefined, catalog: any[] | undefined,
@@ -32,7 +51,7 @@ function buildSearchData(
id: app.appId, id: app.appId,
category: 'application', category: 'application',
title: app.appId, 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`, meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
path: `/exchanges/${app.appId}`, path: `/exchanges/${app.appId}`,
}); });
@@ -55,7 +74,7 @@ function buildSearchData(
id: agent.instanceId, id: agent.instanceId,
category: 'agent', category: 'agent',
title: agent.displayName, 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` : ''}`, meta: `${agent.applicationId} · ${agent.version || ''}${agent.tps != null ? ` · ${agent.tps.toFixed(1)} msg/s` : ''}`,
path: `/runtime/${agent.applicationId}/${agent.instanceId}`, path: `/runtime/${agent.applicationId}/${agent.instanceId}`,
}); });
@@ -76,6 +95,15 @@ function buildSearchData(
return results; 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 { function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`; if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
@@ -100,6 +128,32 @@ function useDebouncedValue<T>(value: T, delayMs: number): T {
return debounced; 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() { function LayoutContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -111,20 +165,100 @@ function LayoutContent() {
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette(); const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
const { scope, setTab } = useScope(); const { scope, setTab } = useScope();
// Exchange full-text search via command palette (scoped to current sidebar selection) // --- Starred items ------------------------------------------------
const [paletteQuery, setPaletteQuery] = useState(''); const { isStarred, toggleStar } = useStarred();
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,
);
// --- 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(() => { const sidebarApps: SidebarApp[] = useMemo(() => {
if (!catalog) return []; if (!catalog) return [];
const cmp = (a: string, b: string) => a.localeCompare(b); const cmp = (a: string, b: string) => a.localeCompare(b);
@@ -153,13 +287,50 @@ function LayoutContent() {
})); }));
}, [catalog]); }, [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( const catalogData = useMemo(
() => buildSearchData(catalog, agents as any[], attributeKeys), () => buildSearchData(catalog, agents as any[], attributeKeys),
[catalog, agents, attributeKeys], [catalog, agents, attributeKeys],
); );
// Stable reference for catalog data — only changes when catalog/agents actually change, // Stable reference for catalog data
// not on every poll cycle (prevents cmd-k scroll reset)
const catalogRef = useRef(catalogData); const catalogRef = useRef(catalogData);
if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) { if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) {
catalogRef.current = catalogData; catalogRef.current = catalogData;
@@ -201,7 +372,7 @@ function LayoutContent() {
return [...catalogRef.current, ...exchangeItems, ...attributeItems]; return [...catalogRef.current, ...exchangeItems, ...attributeItems];
}, [catalogRef.current, exchangeResults, debouncedQuery]); }, [catalogRef.current, exchangeResults, debouncedQuery]);
const isAdminPage = location.pathname.startsWith('/admin'); // --- Breadcrumb ---------------------------------------------------
const breadcrumb = useMemo(() => { const breadcrumb = useMemo(() => {
if (isAdminPage) { if (isAdminPage) {
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
@@ -219,7 +390,6 @@ function LayoutContent() {
...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}), ...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}),
})); }));
} }
// Scope trail as breadcrumb items
const items: { label: string; href?: string }[] = [ const items: { label: string; href?: string }[] = [
{ label: 'All Applications', href: `/${scope.tab}` }, { label: 'All Applications', href: `/${scope.tab}` },
]; ];
@@ -229,13 +399,13 @@ function LayoutContent() {
if (scope.routeId) { if (scope.routeId) {
items.push({ label: scope.routeId }); items.push({ label: scope.routeId });
} }
// Last item has no href (current location)
if (items.length > 0 && !scope.routeId && !scope.appId) { if (items.length > 0 && !scope.routeId && !scope.appId) {
delete items[items.length - 1].href; delete items[items.length - 1].href;
} }
return items; return items;
}, [location.pathname, isAdminPage, scope.tab, scope.appId, scope.routeId]); }, [location.pathname, isAdminPage, scope.tab, scope.appId, scope.routeId]);
// --- Callbacks ----------------------------------------------------
const handleLogout = useCallback(() => { const handleLogout = useCallback(() => {
logout(); logout();
navigate('/login'); navigate('/login');
@@ -245,7 +415,6 @@ function LayoutContent() {
if (result.path) { if (result.path) {
const state: Record<string, unknown> = { sidebarReveal: 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') { if (result.category === 'exchange' || result.category === 'attribute') {
const parts = result.path.split('/').filter(Boolean); const parts = result.path.split('/').filter(Boolean);
if (parts.length === 4 && parts[0] === 'exchanges') { if (parts.length === 4 && parts[0] === 'exchanges') {
@@ -270,11 +439,10 @@ function LayoutContent() {
}, [navigate, scope.appId, scope.routeId]); }, [navigate, scope.appId, scope.routeId]);
// Translate Sidebar's internal paths to our URL structure. // 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 handleSidebarNavigate = useCallback((path: string) => {
const state = { sidebarReveal: path }; 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\/([^/]+)(?:\/(.+))?$/); const appMatch = path.match(/^\/apps\/([^/]+)(?:\/(.+))?$/);
if (appMatch) { if (appMatch) {
const [, sAppId, sRouteId] = appMatch; const [, sAppId, sRouteId] = appMatch;
@@ -282,7 +450,7 @@ function LayoutContent() {
return; return;
} }
// /agents/:appId/:instanceId runtime tab // /agents/:appId/:instanceId -> runtime tab
const agentMatch = path.match(/^\/agents\/([^/]+)(?:\/(.+))?$/); const agentMatch = path.match(/^\/agents\/([^/]+)(?:\/(.+))?$/);
if (agentMatch) { if (agentMatch) {
const [, sAppId, sInstanceId] = agentMatch; const [, sAppId, sInstanceId] = agentMatch;
@@ -293,12 +461,129 @@ function LayoutContent() {
navigate(path, { state }); navigate(path, { state });
}, [navigate, scope.tab]); }, [navigate, scope.tab]);
return ( // --- Render -------------------------------------------------------
<AppShell const sidebarElement = (
sidebar={ <Sidebar
<Sidebar apps={sidebarApps} onNavigate={handleSidebarNavigate} /> 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 <TopBar
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
user={username ? { name: username } : undefined} user={username ? { name: username } : undefined}
@@ -318,7 +603,7 @@ function LayoutContent() {
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} /> <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 /> <Outlet />
</main> </main>
</AppShell> </AppShell>

View 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' },
];
}

View File

@@ -1,29 +1,9 @@
import { Outlet, useNavigate, useLocation } from 'react-router'; import { Outlet } 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' },
];
export default function AdminLayout() { export default function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
return ( return (
<div>
<Tabs
tabs={ADMIN_TABS}
active={location.pathname}
onChange={(path) => navigate(path)}
/>
<div style={{ padding: '20px 24px 40px' }}> <div style={{ padding: '20px 24px 40px' }}>
<Outlet /> <Outlet />
</div> </div>
</div>
); );
} }