fix: simplify sidebar to Applications + Starred + Admin footer
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m7s
CI / docker (push) Successful in 59s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 42s

Remove Agents and Routes sections from sidebar. Layout is now:
Header (camel logo + Cameleer) → Search → Applications section →
Starred section (when items exist) → Footer (Admin + API Docs).

Admin accordion: clicking Admin navigates to /admin/rbac and
expands Admin section at top while collapsing Applications and
Starred. Clicking Applications exits admin mode.

Removed buildAgentTreeNodes and buildRouteTreeNodes from
sidebar-utils (no longer needed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-02 22:29:44 +02:00
parent e495b80432
commit b676450995
2 changed files with 118 additions and 199 deletions

View File

@@ -15,26 +15,26 @@ import {
useStarred,
} from '@cameleer/design-system';
import type { SearchResult, SidebarTreeNode } from '@cameleer/design-system';
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight, Square, Pause } from 'lucide-react';
import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X } 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, createElement } from 'react';
import type { ReactNode } from 'react';
import { ContentTabs } from './ContentTabs';
import { useScope } from '../hooks/useScope';
import {
buildAppTreeNodes,
buildAgentTreeNodes,
buildRouteTreeNodes,
buildAdminTreeNodes,
formatCount,
readCollapsed,
writeCollapsed,
} from './sidebar-utils';
import type { SidebarApp } from './sidebar-utils';
/* ------------------------------------------------------------------ */
/* Search data builder (unchanged) */
/* Search data builder */
/* ------------------------------------------------------------------ */
function buildSearchData(
@@ -129,7 +129,7 @@ function useDebouncedValue<T>(value: T, delayMs: number): T {
}
/* ------------------------------------------------------------------ */
/* Icon factories for tree builders */
/* Icon factories */
/* ------------------------------------------------------------------ */
function makeStatusDot(health: string) {
@@ -149,12 +149,69 @@ function makePauseIcon() {
}
/* ------------------------------------------------------------------ */
/* Section open-state keys */
/* Starred items */
/* ------------------------------------------------------------------ */
interface StarredItem {
starKey: string;
label: string;
icon?: ReactNode;
path: string;
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: makeStatusDot(app.health), path: `/apps/${app.id}` });
}
for (const route of app.routes) {
const key = `app:${app.id}/${route.id}`;
if (starredIds.has(key)) {
items.push({ starKey: key, label: route.name, path: `/apps/${app.id}/${route.id}`, parentApp: app.name });
}
}
}
return items;
}
function StarredList({ items, onNavigate, onRemove }: { items: StarredItem[]; onNavigate: (path: string) => void; onRemove: (key: string) => void }) {
if (items.length === 0) return null;
return (
<div style={{ padding: '4px 0' }}>
{items.map((item) => (
<div
key={item.starKey}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 12px', cursor: 'pointer', fontSize: 12, color: 'var(--sidebar-text)', 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(--sidebar-muted)' }}>{item.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.label}
{item.parentApp && <span style={{ color: 'var(--sidebar-muted)', marginLeft: 4, fontSize: 10 }}>{item.parentApp}</span>}
</span>
<button
style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--sidebar-muted)', 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>
);
}
/* ------------------------------------------------------------------ */
/* Section 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';
@@ -174,7 +231,7 @@ function LayoutContent() {
const { scope, setTab } = useScope();
// --- Starred items ------------------------------------------------
const { isStarred, toggleStar } = useStarred();
const { starredIds, isStarred, toggleStar } = useStarred();
// --- Sidebar collapse ---------------------------------------------
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readCollapsed(SK_COLLAPSED, false));
@@ -191,43 +248,28 @@ function LayoutContent() {
// --- 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));
const [starredOpen, setStarredOpen] = useState(true);
// 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
// Accordion: entering admin collapses apps + starred; leaving restores
const opsStateRef = useRef({ apps: appsOpen, starred: starredOpen });
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 };
opsStateRef.current = { apps: appsOpen, starred: starredOpen };
setAppsOpen(false);
setAgentsOpen(false);
setRoutesOpen(false);
setStarredOpen(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);
setStarredOpen(opsStateRef.current.starred);
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;
}
@@ -237,34 +279,16 @@ function LayoutContent() {
});
}, [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(() => {
if (!isAdminPage) {
navigate('/admin/rbac');
return;
}
setAdminOpen((prev) => {
writeCollapsed(SK_ADMIN, !prev);
return !prev;
});
}, []);
}, [isAdminPage, navigate]);
// --- Build SidebarApp[] from catalog ------------------------------
const sidebarApps: SidebarApp[] = useMemo(() => {
@@ -285,14 +309,7 @@ function LayoutContent() {
exchangeCount: r.exchangeCount,
routeState: r.routeState ?? undefined,
})),
agents: [...(app.agents || [])]
.sort((a: any, b: any) => cmp(a.name, b.name))
.map((a: any) => ({
id: a.id,
name: a.name,
status: a.status as 'live' | 'stale' | 'dead',
tps: a.tps,
})),
agents: [],
}));
}, [catalog]);
@@ -302,24 +319,28 @@ function LayoutContent() {
[sidebarApps],
);
const agentTreeNodes: SidebarTreeNode[] = useMemo(
() => buildAgentTreeNodes(sidebarApps, makeStatusDot),
[sidebarApps],
);
const routeTreeNodes: SidebarTreeNode[] = useMemo(
() => buildRouteTreeNodes(sidebarApps, makeStatusDot, makeChevron, makeStopIcon, makePauseIcon),
[sidebarApps],
);
const adminTreeNodes: SidebarTreeNode[] = useMemo(
() => buildAdminTreeNodes(),
[],
);
// --- Starred items ------------------------------------------------
const starredItems = useMemo(
() => collectStarredItems(sidebarApps, starredIds),
[sidebarApps, starredIds],
);
// --- Reveal path for SidebarTree auto-expand ----------------------
const sidebarRevealPath = (location.state as any)?.sidebarReveal ?? null;
useEffect(() => {
if (!sidebarRevealPath) return;
if (sidebarRevealPath.startsWith('/apps') && !appsOpen) setAppsOpen(true);
if (sidebarRevealPath.startsWith('/admin') && !adminOpen) setAdminOpen(true);
}, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname;
// --- Exchange full-text search via command palette -----------------
const [paletteQuery, setPaletteQuery] = useState('');
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
@@ -339,7 +360,6 @@ function LayoutContent() {
[catalog, agents, attributeKeys],
);
// Stable reference for catalog data
const catalogRef = useRef(catalogData);
if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) {
catalogRef.current = catalogData;
@@ -423,7 +443,6 @@ function LayoutContent() {
const handlePaletteSelect = useCallback((result: any) => {
if (result.path) {
const state: Record<string, unknown> = { sidebarReveal: result.path };
if (result.category === 'exchange' || result.category === 'attribute') {
const parts = result.path.split('/').filter(Boolean);
if (parts.length === 4 && parts[0] === 'exchanges') {
@@ -434,7 +453,6 @@ function LayoutContent() {
};
}
}
navigate(result.path, { state });
}
setPaletteOpen(false);
@@ -447,11 +465,9 @@ function LayoutContent() {
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
}, [navigate, scope.appId, scope.routeId]);
// Translate Sidebar's internal paths to our URL structure.
const handleSidebarNavigate = useCallback((path: string) => {
const state = { sidebarReveal: path };
// /apps/:appId and /apps/:appId/:routeId -> current tab
const appMatch = path.match(/^\/apps\/([^/]+)(?:\/(.+))?$/);
if (appMatch) {
const [, sAppId, sRouteId] = appMatch;
@@ -459,7 +475,6 @@ function LayoutContent() {
return;
}
// /agents/:appId/:instanceId -> runtime tab
const agentMatch = path.match(/^\/agents\/([^/]+)(?:\/(.+))?$/);
if (agentMatch) {
const [, sAppId, sInstanceId] = agentMatch;
@@ -471,6 +486,10 @@ function LayoutContent() {
}, [navigate, scope.tab]);
// --- Render -------------------------------------------------------
const camelLogo = (
<img src="/favicon.svg" alt="" style={{ width: 28, height: 24 }} />
);
const sidebarElement = (
<Sidebar
collapsed={sidebarCollapsed}
@@ -479,11 +498,11 @@ function LayoutContent() {
onSearchChange={setFilterQuery}
>
<Sidebar.Header
logo={createElement(Box, { size: 20 })}
logo={camelLogo}
title="Cameleer"
/>
{/* When on admin pages, show Admin section first (expanded) */}
{/* Admin section — shown at top when on admin pages */}
{isAdminPage && (
<Sidebar.Section
icon={createElement(Settings, { size: 16 })}
@@ -505,6 +524,7 @@ function LayoutContent() {
</Sidebar.Section>
)}
{/* Applications section */}
<Sidebar.Section
icon={createElement(Box, { size: 16 })}
label="Applications"
@@ -513,7 +533,7 @@ function LayoutContent() {
>
<SidebarTree
nodes={appTreeNodes}
selectedPath={sidebarRevealPath ?? location.pathname}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
@@ -523,64 +543,32 @@ function LayoutContent() {
/>
</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 && (
{/* Starred section — only when there are starred items */}
{starredItems.length > 0 && (
<Sidebar.Section
icon={createElement(Settings, { size: 16 })}
label="Admin"
open={adminOpen}
onToggle={toggleAdmin}
icon={createElement(Star, { size: 16 })}
label="Starred"
open={starredOpen}
onToggle={() => setStarredOpen((v) => !v)}
>
<SidebarTree
nodes={adminTreeNodes}
selectedPath={location.pathname}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="admin"
autoRevealPath={sidebarRevealPath}
<StarredList
items={starredItems}
onNavigate={handleSidebarNavigate}
onRemove={toggleStar}
/>
</Sidebar.Section>
)}
{/* Footer — Admin + API Docs */}
<Sidebar.Footer>
{!isAdminPage && (
<Sidebar.FooterLink
icon={createElement(Settings, { size: 16 })}
label="Admin"
active={false}
onClick={() => navigate('/admin/rbac')}
/>
)}
<Sidebar.FooterLink
icon={createElement(FileText, { size: 16 })}
label="API Docs"

View File

@@ -12,20 +12,12 @@ export interface SidebarRoute {
routeState?: 'stopped' | 'suspended';
}
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[];
}
/* ------------------------------------------------------------------ */
@@ -101,67 +93,6 @@ export function buildAppTreeNodes(
}));
}
/**
* 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,
stopIcon?: () => ReactNode,
pauseIcon?: () => 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: r.routeState === 'stopped' && stopIcon
? stopIcon()
: r.routeState === 'suspended' && pauseIcon
? pauseIcon()
: chevron(),
badge: r.routeState
? `${r.routeState.toUpperCase()} \u00b7 ${formatCount(r.exchangeCount)}`
: formatCount(r.exchangeCount),
path: `/routes/${app.id}/${r.id}`,
})),
}));
}
/**
* Admin tree — static 6 nodes.
*/