587 lines
19 KiB
TypeScript
587 lines
19 KiB
TypeScript
import { Outlet, useNavigate, useLocation } from 'react-router';
|
|
import {
|
|
AppShell,
|
|
Sidebar,
|
|
SidebarTree,
|
|
StatusDot,
|
|
TopBar,
|
|
CommandPalette,
|
|
CommandPaletteProvider,
|
|
GlobalFilterProvider,
|
|
ToastProvider,
|
|
BreadcrumbProvider,
|
|
useCommandPalette,
|
|
useGlobalFilters,
|
|
useStarred,
|
|
} from '@cameleer/design-system';
|
|
import type { SearchResult, SidebarTreeNode } from '@cameleer/design-system';
|
|
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight } from 'lucide-react';
|
|
import { useRouteCatalog } from '../api/queries/catalog';
|
|
import { useAgents } from '../api/queries/agents';
|
|
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
|
|
import { useAuthStore } from '../auth/auth-store';
|
|
import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
|
|
import { ContentTabs } from './ContentTabs';
|
|
import { useScope } from '../hooks/useScope';
|
|
import {
|
|
buildAppTreeNodes,
|
|
buildAgentTreeNodes,
|
|
buildAdminTreeNodes,
|
|
readCollapsed,
|
|
writeCollapsed,
|
|
} from './sidebar-utils';
|
|
import type { SidebarApp } from './sidebar-utils';
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Search data builder (unchanged) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function buildSearchData(
|
|
catalog: any[] | undefined,
|
|
agents: any[] | undefined,
|
|
attrKeys: string[] | undefined,
|
|
): SearchResult[] {
|
|
if (!catalog) return [];
|
|
const results: SearchResult[] = [];
|
|
|
|
for (const app of catalog) {
|
|
const liveAgents = (app.agents || []).filter((a: any) => a.status === 'live').length;
|
|
results.push({
|
|
id: app.appId,
|
|
category: 'application',
|
|
title: app.appId,
|
|
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToSearchColor(app.health) }],
|
|
meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
|
path: `/exchanges/${app.appId}`,
|
|
});
|
|
|
|
for (const route of (app.routes || [])) {
|
|
results.push({
|
|
id: `${app.appId}/${route.routeId}`,
|
|
category: 'route',
|
|
title: route.routeId,
|
|
badges: [{ label: app.appId }],
|
|
meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
|
path: `/exchanges/${app.appId}/${route.routeId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (agents) {
|
|
for (const agent of agents) {
|
|
results.push({
|
|
id: agent.instanceId,
|
|
category: 'agent',
|
|
title: agent.displayName,
|
|
badges: [{ label: (agent.status || 'unknown').toUpperCase(), color: healthToSearchColor((agent.status || '').toLowerCase()) }],
|
|
meta: `${agent.applicationId} · ${agent.version || ''}${agent.tps != null ? ` · ${agent.tps.toFixed(1)} msg/s` : ''}`,
|
|
path: `/runtime/${agent.applicationId}/${agent.instanceId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (attrKeys) {
|
|
for (const key of attrKeys) {
|
|
results.push({
|
|
id: `attr-key-${key}`,
|
|
category: 'attribute',
|
|
title: key,
|
|
meta: 'attribute key',
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function healthToSearchColor(health: string): string {
|
|
switch (health) {
|
|
case 'live': return 'success';
|
|
case 'stale': return 'warning';
|
|
case 'dead': return 'error';
|
|
default: return 'auto';
|
|
}
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
|
|
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
|
return `${ms}ms`;
|
|
}
|
|
|
|
function statusToColor(status: string): string {
|
|
switch (status) {
|
|
case 'COMPLETED': return 'success';
|
|
case 'FAILED': return 'error';
|
|
case 'RUNNING': return 'running';
|
|
default: return 'warning';
|
|
}
|
|
}
|
|
|
|
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
const [debounced, setDebounced] = useState(value);
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setDebounced(value), delayMs);
|
|
return () => clearTimeout(timer);
|
|
}, [value, delayMs]);
|
|
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_ADMIN = 'sidebar:section:admin';
|
|
const SK_COLLAPSED = 'sidebar:collapsed';
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Main layout content */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function LayoutContent() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { timeRange } = useGlobalFilters();
|
|
const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString());
|
|
const { data: agents } = useAgents();
|
|
const { data: attributeKeys } = useAttributeKeys();
|
|
const { username, logout } = useAuthStore();
|
|
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
|
const { scope, setTab } = useScope();
|
|
|
|
// --- Starred items ------------------------------------------------
|
|
const { isStarred, toggleStar } = useStarred();
|
|
|
|
// --- Sidebar collapse ---------------------------------------------
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readCollapsed(SK_COLLAPSED, false));
|
|
const handleCollapseToggle = useCallback(() => {
|
|
setSidebarCollapsed((prev) => {
|
|
writeCollapsed(SK_COLLAPSED, !prev);
|
|
return !prev;
|
|
});
|
|
}, []);
|
|
|
|
// --- Sidebar filter -----------------------------------------------
|
|
const [filterQuery, setFilterQuery] = useState('');
|
|
|
|
// --- Section open states ------------------------------------------
|
|
const [appsOpen, setAppsOpen] = useState(() => readCollapsed(SK_APPS, true));
|
|
const [agentsOpen, setAgentsOpen] = useState(() => readCollapsed(SK_AGENTS, false));
|
|
const [adminOpen, setAdminOpen] = useState(() => readCollapsed(SK_ADMIN, false));
|
|
|
|
// Ref to remember operational section states when switching to admin
|
|
const opsStateRef = useRef({ apps: appsOpen, agents: agentsOpen });
|
|
|
|
const isAdminPage = location.pathname.startsWith('/admin');
|
|
|
|
// 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 };
|
|
setAppsOpen(false);
|
|
setAgentsOpen(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);
|
|
setAdminOpen(false);
|
|
writeCollapsed(SK_APPS, opsStateRef.current.apps);
|
|
writeCollapsed(SK_AGENTS, opsStateRef.current.agents);
|
|
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 toggleAdmin = useCallback(() => {
|
|
setAdminOpen((prev) => {
|
|
writeCollapsed(SK_ADMIN, !prev);
|
|
return !prev;
|
|
});
|
|
}, []);
|
|
|
|
// --- Build SidebarApp[] from catalog ------------------------------
|
|
const sidebarApps: SidebarApp[] = useMemo(() => {
|
|
if (!catalog) return [];
|
|
const cmp = (a: string, b: string) => a.localeCompare(b);
|
|
return [...catalog]
|
|
.sort((a: any, b: any) => cmp(a.appId, b.appId))
|
|
.map((app: any) => ({
|
|
id: app.appId,
|
|
name: app.appId,
|
|
health: app.health as 'live' | 'stale' | 'dead',
|
|
exchangeCount: app.exchangeCount,
|
|
routes: [...(app.routes || [])]
|
|
.sort((a: any, b: any) => cmp(a.routeId, b.routeId))
|
|
.map((r: any) => ({
|
|
id: r.routeId,
|
|
name: r.routeId,
|
|
exchangeCount: r.exchangeCount,
|
|
})),
|
|
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,
|
|
})),
|
|
}));
|
|
}, [catalog]);
|
|
|
|
// --- Tree nodes ---------------------------------------------------
|
|
const appTreeNodes: SidebarTreeNode[] = useMemo(
|
|
() => buildAppTreeNodes(sidebarApps, makeStatusDot, makeChevron),
|
|
[sidebarApps],
|
|
);
|
|
|
|
const agentTreeNodes: SidebarTreeNode[] = useMemo(
|
|
() => buildAgentTreeNodes(sidebarApps, makeStatusDot),
|
|
[sidebarApps],
|
|
);
|
|
|
|
const adminTreeNodes: SidebarTreeNode[] = useMemo(
|
|
() => buildAdminTreeNodes(),
|
|
[],
|
|
);
|
|
|
|
// --- Reveal path for SidebarTree auto-expand ----------------------
|
|
const sidebarRevealPath = (location.state as any)?.sidebarReveal ?? null;
|
|
|
|
// --- Exchange full-text search via command palette -----------------
|
|
const [paletteQuery, setPaletteQuery] = useState('');
|
|
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
|
|
const { data: exchangeResults } = useSearchExecutions(
|
|
{
|
|
text: debouncedQuery || undefined,
|
|
applicationId: scope.appId || undefined,
|
|
routeId: scope.routeId || undefined,
|
|
offset: 0,
|
|
limit: 10,
|
|
},
|
|
false,
|
|
);
|
|
|
|
const catalogData = useMemo(
|
|
() => buildSearchData(catalog, agents as any[], attributeKeys),
|
|
[catalog, agents, attributeKeys],
|
|
);
|
|
|
|
// Stable reference for catalog data
|
|
const catalogRef = useRef(catalogData);
|
|
if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) {
|
|
catalogRef.current = catalogData;
|
|
}
|
|
|
|
const searchData: SearchResult[] = useMemo(() => {
|
|
const exchangeItems: SearchResult[] = (exchangeResults?.data || []).map((e: any) => ({
|
|
id: e.executionId,
|
|
category: 'exchange' as const,
|
|
title: e.executionId,
|
|
badges: [{ label: e.status, color: statusToColor(e.status) }],
|
|
meta: `${e.routeId} · ${e.applicationId ?? ''} · ${formatDuration(e.durationMs)}`,
|
|
path: `/exchanges/${e.applicationId ?? ''}/${e.routeId}/${e.executionId}`,
|
|
serverFiltered: true,
|
|
matchContext: e.highlight ?? undefined,
|
|
}));
|
|
|
|
const attributeItems: SearchResult[] = [];
|
|
if (debouncedQuery) {
|
|
const q = debouncedQuery.toLowerCase();
|
|
for (const e of exchangeResults?.data || []) {
|
|
if (!e.attributes) continue;
|
|
for (const [key, value] of Object.entries(e.attributes as Record<string, string>)) {
|
|
if (key.toLowerCase().includes(q) || String(value).toLowerCase().includes(q)) {
|
|
attributeItems.push({
|
|
id: `${e.executionId}-attr-${key}`,
|
|
category: 'attribute' as const,
|
|
title: `${key} = "${value}"`,
|
|
badges: [{ label: e.status, color: statusToColor(e.status) }],
|
|
meta: `${e.executionId} · ${e.routeId} · ${e.applicationId ?? ''}`,
|
|
path: `/exchanges/${e.applicationId ?? ''}/${e.routeId}/${e.executionId}`,
|
|
serverFiltered: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...catalogRef.current, ...exchangeItems, ...attributeItems];
|
|
}, [catalogRef.current, exchangeResults, debouncedQuery]);
|
|
|
|
// --- Breadcrumb ---------------------------------------------------
|
|
const breadcrumb = useMemo(() => {
|
|
if (isAdminPage) {
|
|
const LABELS: Record<string, string> = {
|
|
admin: 'Admin',
|
|
rbac: 'Users & Roles',
|
|
audit: 'Audit Log',
|
|
oidc: 'OIDC',
|
|
database: 'Database',
|
|
clickhouse: 'ClickHouse',
|
|
appconfig: 'App Config',
|
|
};
|
|
const parts = location.pathname.split('/').filter(Boolean);
|
|
return parts.map((part, i) => ({
|
|
label: LABELS[part] ?? part,
|
|
...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}),
|
|
}));
|
|
}
|
|
const items: { label: string; href?: string }[] = [
|
|
{ label: 'All Applications', href: `/${scope.tab}` },
|
|
];
|
|
if (scope.appId) {
|
|
items.push({ label: scope.appId, href: `/${scope.tab}/${scope.appId}` });
|
|
}
|
|
if (scope.routeId) {
|
|
items.push({ label: scope.routeId });
|
|
}
|
|
if (items.length > 0 && !scope.routeId && !scope.appId) {
|
|
delete items[items.length - 1].href;
|
|
}
|
|
return items;
|
|
}, [location.pathname, isAdminPage, scope.tab, scope.appId, scope.routeId]);
|
|
|
|
// --- Callbacks ----------------------------------------------------
|
|
const handleLogout = useCallback(() => {
|
|
logout();
|
|
navigate('/login');
|
|
}, [logout, navigate]);
|
|
|
|
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') {
|
|
state.selectedExchange = {
|
|
executionId: parts[3],
|
|
applicationId: parts[1],
|
|
routeId: parts[2],
|
|
};
|
|
}
|
|
}
|
|
|
|
navigate(result.path, { state });
|
|
}
|
|
setPaletteOpen(false);
|
|
}, [navigate, setPaletteOpen]);
|
|
|
|
const handlePaletteSubmit = useCallback((query: string) => {
|
|
const baseParts = ['/exchanges'];
|
|
if (scope.appId) baseParts.push(scope.appId);
|
|
if (scope.routeId) baseParts.push(scope.routeId);
|
|
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;
|
|
navigate(sRouteId ? `/${scope.tab}/${sAppId}/${sRouteId}` : `/${scope.tab}/${sAppId}`, { state });
|
|
return;
|
|
}
|
|
|
|
// /agents/:appId/:instanceId -> runtime tab
|
|
const agentMatch = path.match(/^\/agents\/([^/]+)(?:\/(.+))?$/);
|
|
if (agentMatch) {
|
|
const [, sAppId, sInstanceId] = agentMatch;
|
|
navigate(sInstanceId ? `/runtime/${sAppId}/${sInstanceId}` : `/runtime/${sAppId}`, { state });
|
|
return;
|
|
}
|
|
|
|
navigate(path, { state });
|
|
}, [navigate, scope.tab]);
|
|
|
|
// --- Render -------------------------------------------------------
|
|
const sidebarElement = (
|
|
<Sidebar
|
|
collapsed={sidebarCollapsed}
|
|
onCollapseToggle={handleCollapseToggle}
|
|
searchValue={filterQuery}
|
|
onSearchChange={setFilterQuery}
|
|
>
|
|
<Sidebar.Header
|
|
logo={createElement(Box, { size: 20 })}
|
|
title="Cameleer"
|
|
/>
|
|
|
|
{/* When on admin pages, show Admin section first (expanded) */}
|
|
{isAdminPage && (
|
|
<Sidebar.Section
|
|
icon={createElement(Settings, { size: 16 })}
|
|
label="Admin"
|
|
open={adminOpen}
|
|
onToggle={toggleAdmin}
|
|
active
|
|
>
|
|
<SidebarTree
|
|
nodes={adminTreeNodes}
|
|
selectedPath={location.pathname}
|
|
isStarred={isStarred}
|
|
onToggleStar={toggleStar}
|
|
filterQuery={filterQuery}
|
|
persistKey="admin"
|
|
autoRevealPath={sidebarRevealPath}
|
|
onNavigate={handleSidebarNavigate}
|
|
/>
|
|
</Sidebar.Section>
|
|
)}
|
|
|
|
<Sidebar.Section
|
|
icon={createElement(Box, { size: 16 })}
|
|
label="Applications"
|
|
open={appsOpen}
|
|
onToggle={toggleApps}
|
|
>
|
|
<SidebarTree
|
|
nodes={appTreeNodes}
|
|
selectedPath={sidebarRevealPath ?? location.pathname}
|
|
isStarred={isStarred}
|
|
onToggleStar={toggleStar}
|
|
filterQuery={filterQuery}
|
|
persistKey="apps"
|
|
autoRevealPath={sidebarRevealPath}
|
|
onNavigate={handleSidebarNavigate}
|
|
/>
|
|
</Sidebar.Section>
|
|
|
|
<Sidebar.Section
|
|
icon={createElement(Cpu, { size: 16 })}
|
|
label="Agents"
|
|
open={agentsOpen}
|
|
onToggle={toggleAgents}
|
|
>
|
|
<SidebarTree
|
|
nodes={agentTreeNodes}
|
|
selectedPath={sidebarRevealPath ?? location.pathname}
|
|
isStarred={isStarred}
|
|
onToggleStar={toggleStar}
|
|
filterQuery={filterQuery}
|
|
persistKey="agents"
|
|
autoRevealPath={sidebarRevealPath}
|
|
onNavigate={handleSidebarNavigate}
|
|
/>
|
|
</Sidebar.Section>
|
|
|
|
{/* When NOT on admin pages, show Admin section at bottom */}
|
|
{!isAdminPage && (
|
|
<Sidebar.Section
|
|
icon={createElement(Settings, { size: 16 })}
|
|
label="Admin"
|
|
open={adminOpen}
|
|
onToggle={toggleAdmin}
|
|
>
|
|
<SidebarTree
|
|
nodes={adminTreeNodes}
|
|
selectedPath={location.pathname}
|
|
isStarred={isStarred}
|
|
onToggleStar={toggleStar}
|
|
filterQuery={filterQuery}
|
|
persistKey="admin"
|
|
autoRevealPath={sidebarRevealPath}
|
|
onNavigate={handleSidebarNavigate}
|
|
/>
|
|
</Sidebar.Section>
|
|
)}
|
|
|
|
<Sidebar.Footer>
|
|
<Sidebar.FooterLink
|
|
icon={createElement(FileText, { size: 16 })}
|
|
label="API Docs"
|
|
active={location.pathname === '/api-docs'}
|
|
onClick={() => handleSidebarNavigate('/api-docs')}
|
|
/>
|
|
</Sidebar.Footer>
|
|
</Sidebar>
|
|
);
|
|
|
|
return (
|
|
<AppShell sidebar={sidebarElement}>
|
|
<TopBar
|
|
breadcrumb={breadcrumb}
|
|
user={username ? { name: username } : undefined}
|
|
onLogout={handleLogout}
|
|
/>
|
|
<CommandPalette
|
|
open={paletteOpen}
|
|
onClose={() => setPaletteOpen(false)}
|
|
onOpen={() => setPaletteOpen(true)}
|
|
onSelect={handlePaletteSelect}
|
|
onSubmit={handlePaletteSubmit}
|
|
onQueryChange={setPaletteQuery}
|
|
data={searchData}
|
|
/>
|
|
|
|
{!isAdminPage && (
|
|
<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 }}>
|
|
<Outlet />
|
|
</main>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
export function LayoutShell() {
|
|
return (
|
|
<ToastProvider>
|
|
<CommandPaletteProvider>
|
|
<GlobalFilterProvider>
|
|
<BreadcrumbProvider>
|
|
<LayoutContent />
|
|
</BreadcrumbProvider>
|
|
</GlobalFilterProvider>
|
|
</CommandPaletteProvider>
|
|
</ToastProvider>
|
|
);
|
|
}
|