fix: simplify sidebar to Applications + Starred + Admin footer
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:
@@ -15,26 +15,26 @@ import {
|
|||||||
useStarred,
|
useStarred,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { SearchResult, SidebarTreeNode } 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 { 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, createElement } from 'react';
|
import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { ContentTabs } from './ContentTabs';
|
import { ContentTabs } from './ContentTabs';
|
||||||
import { useScope } from '../hooks/useScope';
|
import { useScope } from '../hooks/useScope';
|
||||||
import {
|
import {
|
||||||
buildAppTreeNodes,
|
buildAppTreeNodes,
|
||||||
buildAgentTreeNodes,
|
|
||||||
buildRouteTreeNodes,
|
|
||||||
buildAdminTreeNodes,
|
buildAdminTreeNodes,
|
||||||
|
formatCount,
|
||||||
readCollapsed,
|
readCollapsed,
|
||||||
writeCollapsed,
|
writeCollapsed,
|
||||||
} from './sidebar-utils';
|
} from './sidebar-utils';
|
||||||
import type { SidebarApp } from './sidebar-utils';
|
import type { SidebarApp } from './sidebar-utils';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Search data builder (unchanged) */
|
/* Search data builder */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function buildSearchData(
|
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) {
|
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_APPS = 'sidebar:section:apps';
|
||||||
const SK_AGENTS = 'sidebar:section:agents';
|
|
||||||
const SK_ROUTES = 'sidebar:section:routes';
|
|
||||||
const SK_ADMIN = 'sidebar:section:admin';
|
const SK_ADMIN = 'sidebar:section:admin';
|
||||||
const SK_COLLAPSED = 'sidebar:collapsed';
|
const SK_COLLAPSED = 'sidebar:collapsed';
|
||||||
|
|
||||||
@@ -174,7 +231,7 @@ function LayoutContent() {
|
|||||||
const { scope, setTab } = useScope();
|
const { scope, setTab } = useScope();
|
||||||
|
|
||||||
// --- Starred items ------------------------------------------------
|
// --- Starred items ------------------------------------------------
|
||||||
const { isStarred, toggleStar } = useStarred();
|
const { starredIds, isStarred, toggleStar } = useStarred();
|
||||||
|
|
||||||
// --- Sidebar collapse ---------------------------------------------
|
// --- Sidebar collapse ---------------------------------------------
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readCollapsed(SK_COLLAPSED, false));
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readCollapsed(SK_COLLAPSED, false));
|
||||||
@@ -191,43 +248,28 @@ function LayoutContent() {
|
|||||||
// --- Section open states ------------------------------------------
|
// --- Section open states ------------------------------------------
|
||||||
const isAdminPage = location.pathname.startsWith('/admin');
|
const isAdminPage = location.pathname.startsWith('/admin');
|
||||||
const [appsOpen, setAppsOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_APPS, true));
|
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 [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false));
|
||||||
|
const [starredOpen, setStarredOpen] = useState(true);
|
||||||
|
|
||||||
// Ref to remember operational section states when switching to admin
|
// Accordion: entering admin collapses apps + starred; leaving restores
|
||||||
const opsStateRef = useRef({ apps: appsOpen, agents: agentsOpen, routes: routesOpen });
|
const opsStateRef = useRef({ apps: appsOpen, starred: starredOpen });
|
||||||
|
|
||||||
// Accordion effect: when entering admin, collapse operational sections; when leaving, restore
|
|
||||||
const prevAdminRef = useRef(isAdminPage);
|
const prevAdminRef = useRef(isAdminPage);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAdminPage && !prevAdminRef.current) {
|
if (isAdminPage && !prevAdminRef.current) {
|
||||||
// Entering admin — save operational states and collapse them
|
opsStateRef.current = { apps: appsOpen, starred: starredOpen };
|
||||||
opsStateRef.current = { apps: appsOpen, agents: agentsOpen, routes: routesOpen };
|
|
||||||
setAppsOpen(false);
|
setAppsOpen(false);
|
||||||
setAgentsOpen(false);
|
setStarredOpen(false);
|
||||||
setRoutesOpen(false);
|
|
||||||
setAdminOpen(true);
|
setAdminOpen(true);
|
||||||
writeCollapsed(SK_APPS, false);
|
|
||||||
writeCollapsed(SK_AGENTS, false);
|
|
||||||
writeCollapsed(SK_ADMIN, true);
|
|
||||||
} else if (!isAdminPage && prevAdminRef.current) {
|
} else if (!isAdminPage && prevAdminRef.current) {
|
||||||
// Leaving admin — restore operational states
|
|
||||||
setAppsOpen(opsStateRef.current.apps);
|
setAppsOpen(opsStateRef.current.apps);
|
||||||
setAgentsOpen(opsStateRef.current.agents);
|
setStarredOpen(opsStateRef.current.starred);
|
||||||
setRoutesOpen(opsStateRef.current.routes);
|
|
||||||
setAdminOpen(false);
|
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;
|
prevAdminRef.current = isAdminPage;
|
||||||
}, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const toggleApps = useCallback(() => {
|
const toggleApps = useCallback(() => {
|
||||||
if (isAdminPage) {
|
if (isAdminPage) {
|
||||||
// Clicking operational section while in admin navigates away
|
|
||||||
navigate('/exchanges');
|
navigate('/exchanges');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -237,34 +279,16 @@ function LayoutContent() {
|
|||||||
});
|
});
|
||||||
}, [isAdminPage, navigate]);
|
}, [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(() => {
|
const toggleAdmin = useCallback(() => {
|
||||||
|
if (!isAdminPage) {
|
||||||
|
navigate('/admin/rbac');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAdminOpen((prev) => {
|
setAdminOpen((prev) => {
|
||||||
writeCollapsed(SK_ADMIN, !prev);
|
writeCollapsed(SK_ADMIN, !prev);
|
||||||
return !prev;
|
return !prev;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [isAdminPage, navigate]);
|
||||||
|
|
||||||
// --- Build SidebarApp[] from catalog ------------------------------
|
// --- Build SidebarApp[] from catalog ------------------------------
|
||||||
const sidebarApps: SidebarApp[] = useMemo(() => {
|
const sidebarApps: SidebarApp[] = useMemo(() => {
|
||||||
@@ -285,14 +309,7 @@ function LayoutContent() {
|
|||||||
exchangeCount: r.exchangeCount,
|
exchangeCount: r.exchangeCount,
|
||||||
routeState: r.routeState ?? undefined,
|
routeState: r.routeState ?? undefined,
|
||||||
})),
|
})),
|
||||||
agents: [...(app.agents || [])]
|
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,
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
}, [catalog]);
|
}, [catalog]);
|
||||||
|
|
||||||
@@ -302,24 +319,28 @@ function LayoutContent() {
|
|||||||
[sidebarApps],
|
[sidebarApps],
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentTreeNodes: SidebarTreeNode[] = useMemo(
|
|
||||||
() => buildAgentTreeNodes(sidebarApps, makeStatusDot),
|
|
||||||
[sidebarApps],
|
|
||||||
);
|
|
||||||
|
|
||||||
const routeTreeNodes: SidebarTreeNode[] = useMemo(
|
|
||||||
() => buildRouteTreeNodes(sidebarApps, makeStatusDot, makeChevron, makeStopIcon, makePauseIcon),
|
|
||||||
[sidebarApps],
|
|
||||||
);
|
|
||||||
|
|
||||||
const adminTreeNodes: SidebarTreeNode[] = useMemo(
|
const adminTreeNodes: SidebarTreeNode[] = useMemo(
|
||||||
() => buildAdminTreeNodes(),
|
() => buildAdminTreeNodes(),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Starred items ------------------------------------------------
|
||||||
|
const starredItems = useMemo(
|
||||||
|
() => collectStarredItems(sidebarApps, starredIds),
|
||||||
|
[sidebarApps, starredIds],
|
||||||
|
);
|
||||||
|
|
||||||
// --- Reveal path for SidebarTree auto-expand ----------------------
|
// --- Reveal path for SidebarTree auto-expand ----------------------
|
||||||
const sidebarRevealPath = (location.state as any)?.sidebarReveal ?? null;
|
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 -----------------
|
// --- Exchange full-text search via command palette -----------------
|
||||||
const [paletteQuery, setPaletteQuery] = useState('');
|
const [paletteQuery, setPaletteQuery] = useState('');
|
||||||
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
|
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
|
||||||
@@ -339,7 +360,6 @@ function LayoutContent() {
|
|||||||
[catalog, agents, attributeKeys],
|
[catalog, agents, attributeKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stable reference for catalog data
|
|
||||||
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;
|
||||||
@@ -423,7 +443,6 @@ function LayoutContent() {
|
|||||||
const handlePaletteSelect = useCallback((result: any) => {
|
const handlePaletteSelect = useCallback((result: any) => {
|
||||||
if (result.path) {
|
if (result.path) {
|
||||||
const state: Record<string, unknown> = { sidebarReveal: result.path };
|
const state: Record<string, unknown> = { sidebarReveal: result.path };
|
||||||
|
|
||||||
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') {
|
||||||
@@ -434,7 +453,6 @@ function LayoutContent() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(result.path, { state });
|
navigate(result.path, { state });
|
||||||
}
|
}
|
||||||
setPaletteOpen(false);
|
setPaletteOpen(false);
|
||||||
@@ -447,11 +465,9 @@ function LayoutContent() {
|
|||||||
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
|
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
|
||||||
}, [navigate, scope.appId, scope.routeId]);
|
}, [navigate, scope.appId, scope.routeId]);
|
||||||
|
|
||||||
// Translate Sidebar's internal paths to our URL structure.
|
|
||||||
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
|
|
||||||
const appMatch = path.match(/^\/apps\/([^/]+)(?:\/(.+))?$/);
|
const appMatch = path.match(/^\/apps\/([^/]+)(?:\/(.+))?$/);
|
||||||
if (appMatch) {
|
if (appMatch) {
|
||||||
const [, sAppId, sRouteId] = appMatch;
|
const [, sAppId, sRouteId] = appMatch;
|
||||||
@@ -459,7 +475,6 @@ function LayoutContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// /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;
|
||||||
@@ -471,6 +486,10 @@ function LayoutContent() {
|
|||||||
}, [navigate, scope.tab]);
|
}, [navigate, scope.tab]);
|
||||||
|
|
||||||
// --- Render -------------------------------------------------------
|
// --- Render -------------------------------------------------------
|
||||||
|
const camelLogo = (
|
||||||
|
<img src="/favicon.svg" alt="" style={{ width: 28, height: 24 }} />
|
||||||
|
);
|
||||||
|
|
||||||
const sidebarElement = (
|
const sidebarElement = (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
@@ -479,11 +498,11 @@ function LayoutContent() {
|
|||||||
onSearchChange={setFilterQuery}
|
onSearchChange={setFilterQuery}
|
||||||
>
|
>
|
||||||
<Sidebar.Header
|
<Sidebar.Header
|
||||||
logo={createElement(Box, { size: 20 })}
|
logo={camelLogo}
|
||||||
title="Cameleer"
|
title="Cameleer"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* When on admin pages, show Admin section first (expanded) */}
|
{/* Admin section — shown at top when on admin pages */}
|
||||||
{isAdminPage && (
|
{isAdminPage && (
|
||||||
<Sidebar.Section
|
<Sidebar.Section
|
||||||
icon={createElement(Settings, { size: 16 })}
|
icon={createElement(Settings, { size: 16 })}
|
||||||
@@ -505,6 +524,7 @@ function LayoutContent() {
|
|||||||
</Sidebar.Section>
|
</Sidebar.Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Applications section */}
|
||||||
<Sidebar.Section
|
<Sidebar.Section
|
||||||
icon={createElement(Box, { size: 16 })}
|
icon={createElement(Box, { size: 16 })}
|
||||||
label="Applications"
|
label="Applications"
|
||||||
@@ -513,7 +533,7 @@ function LayoutContent() {
|
|||||||
>
|
>
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
nodes={appTreeNodes}
|
nodes={appTreeNodes}
|
||||||
selectedPath={sidebarRevealPath ?? location.pathname}
|
selectedPath={effectiveSelectedPath}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
onToggleStar={toggleStar}
|
onToggleStar={toggleStar}
|
||||||
filterQuery={filterQuery}
|
filterQuery={filterQuery}
|
||||||
@@ -523,64 +543,32 @@ function LayoutContent() {
|
|||||||
/>
|
/>
|
||||||
</Sidebar.Section>
|
</Sidebar.Section>
|
||||||
|
|
||||||
|
{/* Starred section — only when there are starred items */}
|
||||||
|
{starredItems.length > 0 && (
|
||||||
<Sidebar.Section
|
<Sidebar.Section
|
||||||
icon={createElement(Cpu, { size: 16 })}
|
icon={createElement(Star, { size: 16 })}
|
||||||
label="Agents"
|
label="Starred"
|
||||||
open={agentsOpen}
|
open={starredOpen}
|
||||||
onToggle={toggleAgents}
|
onToggle={() => setStarredOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
<SidebarTree
|
<StarredList
|
||||||
nodes={agentTreeNodes}
|
items={starredItems}
|
||||||
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}
|
onNavigate={handleSidebarNavigate}
|
||||||
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
</Sidebar.Section>
|
</Sidebar.Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Footer — Admin + API Docs */}
|
||||||
<Sidebar.Footer>
|
<Sidebar.Footer>
|
||||||
|
{!isAdminPage && (
|
||||||
|
<Sidebar.FooterLink
|
||||||
|
icon={createElement(Settings, { size: 16 })}
|
||||||
|
label="Admin"
|
||||||
|
active={false}
|
||||||
|
onClick={() => navigate('/admin/rbac')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Sidebar.FooterLink
|
<Sidebar.FooterLink
|
||||||
icon={createElement(FileText, { size: 16 })}
|
icon={createElement(FileText, { size: 16 })}
|
||||||
label="API Docs"
|
label="API Docs"
|
||||||
|
|||||||
@@ -12,20 +12,12 @@ export interface SidebarRoute {
|
|||||||
routeState?: 'stopped' | 'suspended';
|
routeState?: 'stopped' | 'suspended';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarAgent {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status: 'live' | 'stale' | 'dead';
|
|
||||||
tps?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarApp {
|
export interface SidebarApp {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
health: 'live' | 'stale' | 'dead';
|
health: 'live' | 'stale' | 'dead';
|
||||||
exchangeCount: number;
|
exchangeCount: number;
|
||||||
routes: SidebarRoute[];
|
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.
|
* Admin tree — static 6 nodes.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user