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,
|
||||
} 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"
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user