1131 lines
39 KiB
TypeScript
1131 lines
39 KiB
TypeScript
import { Outlet, useNavigate, useLocation } from 'react-router';
|
|
import { config } from '../config';
|
|
import {
|
|
AppShell,
|
|
Sidebar,
|
|
SidebarTree,
|
|
StatusDot,
|
|
TopBar,
|
|
SearchTrigger,
|
|
AutoRefreshToggle,
|
|
ButtonGroup,
|
|
TimeRangeDropdown,
|
|
CommandPalette,
|
|
CommandPaletteProvider,
|
|
GlobalFilterProvider,
|
|
ToastProvider,
|
|
BreadcrumbProvider,
|
|
useCommandPalette,
|
|
useGlobalFilters,
|
|
useStarred,
|
|
} from '@cameleer/design-system';
|
|
import type { SearchResult, SidebarTreeNode, DropdownItem, ButtonGroupItem, ExchangeStatus } from '@cameleer/design-system';
|
|
import sidebarLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
|
import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff, Bell } from 'lucide-react';
|
|
import { AboutMeDialog } from './AboutMeDialog';
|
|
import { NotificationBell } from './NotificationBell';
|
|
import css from './LayoutShell.module.css';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { useCatalog } from '../api/queries/catalog';
|
|
import { useAgents } from '../api/queries/agents';
|
|
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
|
|
import { useUsers, useGroups, useRoles } from '../api/queries/admin/rbac';
|
|
import { useEnvironments } from '../api/queries/admin/environments';
|
|
import { useAlerts } from '../api/queries/alerts';
|
|
import { useAlertRules } from '../api/queries/alertRules';
|
|
import type { UserDetail, GroupDetail, RoleDetail } from '../api/queries/admin/rbac';
|
|
import { useAuthStore, useIsAdmin, useCanControl } from '../auth/auth-store';
|
|
import { useEnvironmentStore } from '../api/environment-store';
|
|
import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { ContentTabs } from './ContentTabs';
|
|
import { EnvironmentSwitcherButton } from './EnvironmentSwitcherButton';
|
|
import { EnvironmentSwitcherModal } from './EnvironmentSwitcherModal';
|
|
import { envColorVar } from './env-colors';
|
|
import { useScope } from '../hooks/useScope';
|
|
import { formatDuration } from '../utils/format-utils';
|
|
import { parseFacetQuery, formatAttrParam } from '../utils/attribute-filter';
|
|
import {
|
|
buildAppTreeNodes,
|
|
buildAdminTreeNodes,
|
|
buildAlertsTreeNodes,
|
|
formatCount,
|
|
readCollapsed,
|
|
writeCollapsed,
|
|
} from './sidebar-utils';
|
|
import { useServerCapabilities } from '../api/queries/capabilities';
|
|
import type { SidebarApp } from './sidebar-utils';
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Search data builder */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function buildSearchData(
|
|
catalog: any[] | undefined,
|
|
agents: any[] | undefined,
|
|
attrKeys: string[] | undefined,
|
|
): SearchResult[] {
|
|
if (!catalog) return [];
|
|
const results: SearchResult[] = [];
|
|
|
|
for (const app of catalog) {
|
|
const slug = app.slug || app.appId;
|
|
const name = app.displayName || slug;
|
|
const liveAgents = (app.agents || []).filter((a: any) => a.status === 'live').length;
|
|
results.push({
|
|
id: slug,
|
|
category: 'application',
|
|
title: name,
|
|
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/${slug}`,
|
|
});
|
|
|
|
for (const route of (app.routes || [])) {
|
|
results.push({
|
|
id: `${slug}/${route.routeId}`,
|
|
category: 'route',
|
|
title: route.routeId,
|
|
badges: [{ label: name }],
|
|
meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
|
path: `/exchanges/${slug}/${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 — filter list',
|
|
// Path carries the facet in query-string form; handlePaletteSelect routes
|
|
// attribute results to the current scope, so the leading segment below is
|
|
// only used as a fallback when no scope is active.
|
|
path: `/exchanges?attr=${encodeURIComponent(key)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function buildAdminSearchData(
|
|
users: UserDetail[] | undefined,
|
|
groups: GroupDetail[] | undefined,
|
|
roles: RoleDetail[] | undefined,
|
|
): SearchResult[] {
|
|
const results: SearchResult[] = [];
|
|
|
|
if (users) {
|
|
for (const u of users) {
|
|
results.push({
|
|
id: `user:${u.userId}`,
|
|
category: 'user',
|
|
title: u.displayName || u.userId,
|
|
meta: u.userId,
|
|
path: '/admin/rbac',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (groups) {
|
|
for (const g of groups) {
|
|
results.push({
|
|
id: `group:${g.id}`,
|
|
category: 'group',
|
|
title: g.name,
|
|
meta: g.parentGroupId ? `parent: ${g.parentGroupId}` : 'top-level group',
|
|
path: '/admin/rbac',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (roles) {
|
|
for (const r of roles) {
|
|
results.push({
|
|
id: `role:${r.id}`,
|
|
category: 'role',
|
|
title: r.name,
|
|
meta: r.scope,
|
|
path: '/admin/rbac',
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function buildAlertSearchData(
|
|
alerts: any[] | undefined,
|
|
rules: any[] | undefined,
|
|
): SearchResult[] {
|
|
const results: SearchResult[] = [];
|
|
if (alerts) {
|
|
for (const a of alerts) {
|
|
results.push({
|
|
id: `alert:${a.id}`,
|
|
category: 'alert',
|
|
title: a.title ?? '(untitled)',
|
|
badges: [
|
|
{ label: a.severity, color: severityToSearchColor(a.severity) },
|
|
{ label: a.state, color: stateToSearchColor(a.state) },
|
|
],
|
|
meta: `${a.firedAt ?? ''}${a.silenced ? ' · silenced' : ''}`,
|
|
path: `/alerts/inbox/${a.id}`,
|
|
});
|
|
}
|
|
}
|
|
if (rules) {
|
|
for (const r of rules) {
|
|
results.push({
|
|
id: `rule:${r.id}`,
|
|
category: 'alertRule',
|
|
title: r.name,
|
|
badges: [
|
|
{ label: r.severity, color: severityToSearchColor(r.severity) },
|
|
{ label: r.conditionKind, color: 'auto' },
|
|
...(r.enabled ? [] : [{ label: 'DISABLED', color: 'warning' as const }]),
|
|
],
|
|
meta: `${r.evaluationIntervalSeconds}s · ${r.targets?.length ?? 0} targets`,
|
|
path: `/alerts/rules/${r.id}`,
|
|
});
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function severityToSearchColor(s: string): string {
|
|
if (s === 'CRITICAL') return 'error';
|
|
if (s === 'WARNING') return 'warning';
|
|
return 'auto';
|
|
}
|
|
|
|
function stateToSearchColor(s: string): string {
|
|
if (s === 'FIRING') return 'error';
|
|
if (s === 'ACKNOWLEDGED') return 'warning';
|
|
if (s === 'RESOLVED') return 'success';
|
|
return 'auto';
|
|
}
|
|
|
|
function healthToSearchColor(health: string): string {
|
|
switch (health) {
|
|
case 'live': return 'success';
|
|
case 'stale': return 'warning';
|
|
case 'dead': return 'error';
|
|
default: return 'auto';
|
|
}
|
|
}
|
|
|
|
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 */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function makeStatusDot(health: string) {
|
|
return createElement(StatusDot, { variant: health as any });
|
|
}
|
|
|
|
function makeChevron() {
|
|
return createElement(ChevronRight, { size: 14 });
|
|
}
|
|
|
|
function makeStopIcon() {
|
|
return createElement(Square, { size: 12, style: { color: 'var(--error)' } });
|
|
}
|
|
|
|
function makePauseIcon() {
|
|
return createElement(Pause, { size: 12, style: { color: 'var(--amber)' } });
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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 = `route:${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 className={css.starredList}>
|
|
{items.map((item) => (
|
|
<div
|
|
key={item.starKey}
|
|
className={css.starredItem}
|
|
onClick={() => onNavigate(item.path)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path); }}
|
|
>
|
|
{item.icon && <span className={css.starredIconWrap}>{item.icon}</span>}
|
|
<span className={css.starredLabel}>
|
|
{item.label}
|
|
{item.parentApp && <span className={css.starredParentApp}>{item.parentApp}</span>}
|
|
</span>
|
|
<button
|
|
className={css.starredRemoveBtn}
|
|
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey); }}
|
|
aria-label={`Remove ${item.label} from starred`}
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Section state keys */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const STATUS_ITEMS: ButtonGroupItem[] = [
|
|
{ value: 'completed', label: 'Completed', color: 'var(--success)' },
|
|
{ value: 'warning', label: 'Warning', color: 'var(--warning)' },
|
|
{ value: 'failed', label: 'Failed', color: 'var(--error)' },
|
|
{ value: 'running', label: 'Running', color: 'var(--running)' },
|
|
]
|
|
|
|
const SK_APPS = 'sidebar:section:apps';
|
|
const SK_ADMIN = 'sidebar:section:admin';
|
|
const SK_ALERTS = 'sidebar:section:alerts';
|
|
const SK_COLLAPSED = 'sidebar:collapsed';
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Main layout content */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function LayoutContent() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const queryClient = useQueryClient();
|
|
const globalFilters = useGlobalFilters();
|
|
const { timeRange, autoRefresh, refreshTimeRange } = globalFilters;
|
|
|
|
// --- Server capabilities ------------------------------------------
|
|
const { data: capabilities } = useServerCapabilities();
|
|
|
|
// --- Role checks ----------------------------------------------------
|
|
const isAdmin = useIsAdmin();
|
|
const canControl = useCanControl();
|
|
|
|
// --- Environment filtering -----------------------------------------
|
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
|
const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment);
|
|
|
|
const catalogFrom = timeRange.start.toISOString();
|
|
const catalogTo = timeRange.end.toISOString();
|
|
const { data: catalog } = useCatalog(selectedEnv, catalogFrom, catalogTo);
|
|
// Env is always required now (path-based endpoint). For cross-env "all agents"
|
|
// we'd need a separate flat endpoint; sidebar uses env-filtered list directly.
|
|
const { data: agents } = useAgents(); // env pulled from store internally
|
|
const allAgents = agents;
|
|
const { data: attributeKeys } = useAttributeKeys();
|
|
const { data: envRecords = [] } = useEnvironments();
|
|
|
|
// Open alerts + rules for CMD-K (env-scoped).
|
|
const { data: cmdkAlerts } = useAlerts({ state: ['FIRING'], acked: false, limit: 100 });
|
|
const { data: cmdkRules } = useAlertRules();
|
|
|
|
// Merge environments from both the environments table and agent heartbeats
|
|
const environments: string[] = useMemo(() => {
|
|
const envSet = new Set<string>();
|
|
for (const e of envRecords) envSet.add(e.slug);
|
|
if (allAgents) {
|
|
for (const a of allAgents as any[]) {
|
|
envSet.add(a.environmentId || 'default');
|
|
}
|
|
}
|
|
if (envSet.size === 0) envSet.add('default');
|
|
return [...envSet].sort();
|
|
}, [allAgents, envRecords]);
|
|
|
|
// --- Admin search data (only fetched on admin pages) ----------------
|
|
const isAdminPage = location.pathname.startsWith('/admin');
|
|
const isAlertsPage = location.pathname.startsWith('/alerts');
|
|
const { data: adminUsers } = useUsers(isAdminPage);
|
|
const { data: adminGroups } = useGroups(isAdminPage);
|
|
const { data: adminRoles } = useRoles(isAdminPage);
|
|
|
|
const { username, logout } = useAuthStore();
|
|
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
|
const { scope, setTab } = useScope();
|
|
|
|
// --- Starred items ------------------------------------------------
|
|
const { starredIds, 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('');
|
|
const [hideEmptyRoutes, setHideEmptyRoutes] = useState(() => readCollapsed('sidebar:hideEmptyRoutes', false));
|
|
const [hideOfflineApps, setHideOfflineApps] = useState(() => readCollapsed('sidebar:hideOfflineApps', false));
|
|
|
|
const toggleHideEmptyRoutes = useCallback(() => {
|
|
setHideEmptyRoutes((prev) => { writeCollapsed('sidebar:hideEmptyRoutes', !prev); return !prev; });
|
|
}, []);
|
|
const toggleHideOfflineApps = useCallback(() => {
|
|
setHideOfflineApps((prev) => { writeCollapsed('sidebar:hideOfflineApps', !prev); return !prev; });
|
|
}, []);
|
|
|
|
const setSelectedEnv = useCallback((env: string | undefined) => {
|
|
setSelectedEnvRaw(env);
|
|
setFilterQuery('');
|
|
if (location.search) {
|
|
navigate(location.pathname, { replace: true });
|
|
}
|
|
queryClient.invalidateQueries();
|
|
}, [setSelectedEnvRaw, navigate, location.pathname, location.search, queryClient]);
|
|
|
|
// --- Env switcher modal -------------------------------------------
|
|
const [switcherOpen, setSwitcherOpen] = useState(false);
|
|
|
|
// Force-open the switcher when we have envs loaded but no valid selection.
|
|
// This replaces the old "All Envs" fallback: every session must pick one.
|
|
const selectionInvalid =
|
|
envRecords.length > 0 &&
|
|
(selectedEnv === undefined || !envRecords.some((e) => e.slug === selectedEnv));
|
|
const switcherForced = selectionInvalid;
|
|
useEffect(() => {
|
|
if (selectionInvalid) {
|
|
if (selectedEnv !== undefined) setSelectedEnvRaw(undefined);
|
|
setSwitcherOpen(true);
|
|
}
|
|
}, [selectionInvalid, selectedEnv, setSelectedEnvRaw]);
|
|
|
|
const currentEnvRecord = envRecords.find((e) => e.slug === selectedEnv);
|
|
const envBarColor = envColorVar(currentEnvRecord?.color);
|
|
|
|
// --- Section open states ------------------------------------------
|
|
const [appsOpen, setAppsOpen] = useState(() => (isAdminPage || isAlertsPage) ? false : readCollapsed(SK_APPS, true));
|
|
const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false));
|
|
const [alertsOpen, setAlertsOpen] = useState(() => isAlertsPage ? true : readCollapsed(SK_ALERTS, false));
|
|
const [starredOpen, setStarredOpen] = useState(true);
|
|
|
|
// Accordion: entering admin collapses apps + starred; leaving restores
|
|
const opsStateRef = useRef({ apps: appsOpen, starred: starredOpen });
|
|
const prevAdminRef = useRef(isAdminPage);
|
|
useEffect(() => {
|
|
if (isAdminPage && !prevAdminRef.current) {
|
|
opsStateRef.current = { apps: appsOpen, starred: starredOpen };
|
|
setAppsOpen(false);
|
|
setStarredOpen(false);
|
|
setAdminOpen(true);
|
|
} else if (!isAdminPage && prevAdminRef.current) {
|
|
setAppsOpen(opsStateRef.current.apps);
|
|
setStarredOpen(opsStateRef.current.starred);
|
|
setAdminOpen(false);
|
|
}
|
|
prevAdminRef.current = isAdminPage;
|
|
}, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Accordion: entering alerts collapses apps + admin + starred; leaving restores
|
|
const opsAlertsStateRef = useRef({ apps: appsOpen, admin: adminOpen, starred: starredOpen });
|
|
const prevAlertsRef = useRef(isAlertsPage);
|
|
useEffect(() => {
|
|
if (isAlertsPage && !prevAlertsRef.current) {
|
|
opsAlertsStateRef.current = { apps: appsOpen, admin: adminOpen, starred: starredOpen };
|
|
setAppsOpen(false);
|
|
setAdminOpen(false);
|
|
setStarredOpen(false);
|
|
setAlertsOpen(true);
|
|
} else if (!isAlertsPage && prevAlertsRef.current) {
|
|
setAppsOpen(opsAlertsStateRef.current.apps);
|
|
setAdminOpen(opsAlertsStateRef.current.admin);
|
|
setStarredOpen(opsAlertsStateRef.current.starred);
|
|
setAlertsOpen(false);
|
|
}
|
|
prevAlertsRef.current = isAlertsPage;
|
|
}, [isAlertsPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const toggleAlerts = useCallback(() => {
|
|
if (!isAlertsPage) {
|
|
navigate('/alerts/inbox');
|
|
return;
|
|
}
|
|
setAlertsOpen((prev) => {
|
|
writeCollapsed(SK_ALERTS, !prev);
|
|
return !prev;
|
|
});
|
|
}, [isAlertsPage, navigate]);
|
|
|
|
const toggleApps = useCallback(() => {
|
|
if (isAdminPage) {
|
|
navigate('/exchanges');
|
|
return;
|
|
}
|
|
if (appsOpen) {
|
|
// Already open — navigate to all applications
|
|
if (!autoRefresh) {
|
|
refreshTimeRange();
|
|
queryClient.invalidateQueries();
|
|
}
|
|
navigate(`/${scope.tab}`);
|
|
} else {
|
|
setAppsOpen(true);
|
|
writeCollapsed(SK_APPS, true);
|
|
}
|
|
}, [isAdminPage, appsOpen, navigate, scope.tab, autoRefresh, refreshTimeRange, queryClient]);
|
|
|
|
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(() => {
|
|
if (!catalog) return [];
|
|
const cmp = (a: string, b: string) => a.localeCompare(b);
|
|
return [...catalog]
|
|
.sort((a: any, b: any) => cmp(a.slug, b.slug))
|
|
.map((app: any) => ({
|
|
id: app.slug,
|
|
name: app.displayName || app.slug,
|
|
health: (app.health === 'offline' ? 'dead' : app.health) as SidebarApp['health'],
|
|
healthTooltip: app.healthTooltip,
|
|
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,
|
|
routeState: r.routeState ?? undefined,
|
|
})),
|
|
agents: [],
|
|
}));
|
|
}, [catalog]);
|
|
|
|
// --- Apply sidebar filters -----------------------------------------
|
|
const filteredSidebarApps: SidebarApp[] = useMemo(() => {
|
|
let apps = sidebarApps;
|
|
if (hideOfflineApps) {
|
|
apps = apps.filter((a) => a.health !== 'dead' && a.health !== 'stale');
|
|
}
|
|
if (hideEmptyRoutes) {
|
|
apps = apps
|
|
.map((a) => ({
|
|
...a,
|
|
routes: a.routes.filter((r) => r.exchangeCount > 0),
|
|
}))
|
|
.filter((a) => a.exchangeCount > 0 || a.routes.length > 0);
|
|
}
|
|
return apps;
|
|
}, [sidebarApps, hideOfflineApps, hideEmptyRoutes]);
|
|
|
|
// --- Tree nodes ---------------------------------------------------
|
|
const appTreeNodes: SidebarTreeNode[] = useMemo(
|
|
() => buildAppTreeNodes(filteredSidebarApps, makeStatusDot, makeChevron, makeStopIcon, makePauseIcon),
|
|
[filteredSidebarApps],
|
|
);
|
|
|
|
const adminTreeNodes: SidebarTreeNode[] = useMemo(
|
|
() => buildAdminTreeNodes({ infrastructureEndpoints: capabilities?.infrastructureEndpoints }),
|
|
[capabilities?.infrastructureEndpoints],
|
|
);
|
|
|
|
const alertsTreeNodes: SidebarTreeNode[] = useMemo(
|
|
() => buildAlertsTreeNodes(),
|
|
[],
|
|
);
|
|
|
|
// --- 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);
|
|
if (sidebarRevealPath.startsWith('/alerts') && !alertsOpen) setAlertsOpen(true);
|
|
}, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Normalize path so sidebar highlights the app regardless of which tab is active.
|
|
// Sidebar nodes use /exchanges/{slug} paths, so map /dashboard/{slug}, /apps/{slug}, etc.
|
|
const effectiveSelectedPath = useMemo(() => {
|
|
const raw = sidebarRevealPath ?? location.pathname;
|
|
const match = raw.match(/^\/(exchanges|dashboard|apps|runtime)\/([^/]+)(\/.*)?$/);
|
|
if (match) return `/exchanges/${match[2]}${match[3] ?? ''}`;
|
|
return raw;
|
|
}, [sidebarRevealPath, location.pathname]);
|
|
|
|
// --- About Me dialog -----------------------------------------------
|
|
const [aboutMeOpen, setAboutMeOpen] = useState(false);
|
|
const userMenuItems: DropdownItem[] = useMemo(() => [
|
|
{ label: 'About Me', icon: createElement(User, { size: 14 }), onClick: () => setAboutMeOpen(true) },
|
|
], []);
|
|
|
|
// --- 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],
|
|
);
|
|
|
|
const catalogRef = useRef(catalogData);
|
|
if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) {
|
|
catalogRef.current = catalogData;
|
|
}
|
|
|
|
const adminSearchData: SearchResult[] = useMemo(
|
|
() => buildAdminSearchData(adminUsers, adminGroups, adminRoles),
|
|
[adminUsers, adminGroups, adminRoles],
|
|
);
|
|
|
|
const alertingSearchData: SearchResult[] = useMemo(
|
|
() => buildAlertSearchData(cmdkAlerts, cmdkRules),
|
|
[cmdkAlerts, cmdkRules],
|
|
);
|
|
|
|
const operationalSearchData: SearchResult[] = useMemo(() => {
|
|
if (isAdminPage) return [];
|
|
|
|
const exchangeItems: SearchResult[] = (exchangeResults?.data || []).map((e: any) => ({
|
|
id: e.executionId,
|
|
category: 'exchange' as const,
|
|
title: `...${e.executionId.slice(-8)}`,
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const facet = parseFacetQuery(debouncedQuery ?? '');
|
|
const facetItems: SearchResult[] =
|
|
facet
|
|
? [{
|
|
id: `facet-${formatAttrParam(facet)}`,
|
|
category: 'attribute' as const,
|
|
title: `Filter: ${facet.key} = "${facet.value}"${facet.value?.includes('*') ? ' (wildcard)' : ''}`,
|
|
meta: 'apply attribute filter',
|
|
path: `/exchanges?attr=${encodeURIComponent(formatAttrParam(facet))}`,
|
|
}]
|
|
: [];
|
|
|
|
return [...facetItems, ...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData];
|
|
}, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery, alertingSearchData]);
|
|
|
|
const searchData = isAdminPage ? adminSearchData : operationalSearchData;
|
|
|
|
// --- 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',
|
|
'server-metrics': 'Server Metrics',
|
|
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 ADMIN_CATEGORIES = new Set(['user', 'group', 'role']);
|
|
const ADMIN_TAB_MAP: Record<string, string> = { user: 'users', group: 'groups', role: 'roles' };
|
|
|
|
const handlePaletteSelect = useCallback((result: any) => {
|
|
if (result.category === 'alert' || result.category === 'alertRule') {
|
|
if (result.path) navigate(result.path);
|
|
setPaletteOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (result.category === 'attribute') {
|
|
// Three sources feed 'attribute' results:
|
|
// - buildSearchData → id `attr-key-<key>` (key-only)
|
|
// - operationalSearchData per-exchange → id `<execId>-attr-<key>`, title `key = "value"`
|
|
// - synthetic facet (Task 9) → id `facet-<serialized>` where <serialized> is already
|
|
// the URL `attr=` form (`key` or `key:value`)
|
|
let attrParam: string | null = null;
|
|
if (typeof result.id === 'string' && result.id.startsWith('attr-key-')) {
|
|
attrParam = result.id.substring('attr-key-'.length);
|
|
} else if (typeof result.id === 'string' && result.id.startsWith('facet-')) {
|
|
attrParam = result.id.substring('facet-'.length);
|
|
} else if (typeof result.title === 'string') {
|
|
const m = /^([a-zA-Z0-9._-]+)\s*=\s*"([^"]*)"/.exec(result.title);
|
|
if (m) attrParam = `${m[1]}:${m[2]}`;
|
|
}
|
|
if (attrParam) {
|
|
const base = ['/exchanges'];
|
|
if (scope.appId) base.push(scope.appId);
|
|
if (scope.routeId) base.push(scope.routeId);
|
|
navigate(`${base.join('/')}?attr=${encodeURIComponent(attrParam)}`);
|
|
}
|
|
setPaletteOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (result.path) {
|
|
if (ADMIN_CATEGORIES.has(result.category)) {
|
|
const itemId = result.id.split(':').slice(1).join(':');
|
|
navigate(result.path, {
|
|
state: { tab: ADMIN_TAB_MAP[result.category], highlight: itemId },
|
|
});
|
|
} else {
|
|
const state: Record<string, unknown> = { sidebarReveal: result.path };
|
|
if (result.category === 'exchange') {
|
|
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, scope.appId, scope.routeId]);
|
|
|
|
const handlePaletteSubmit = useCallback((query: string) => {
|
|
if (isAdminPage) {
|
|
// Find first matching admin result and navigate to it
|
|
const q = query.toLowerCase();
|
|
const match = adminSearchData.find(
|
|
(r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
|
);
|
|
if (match) {
|
|
handlePaletteSelect(match);
|
|
} else {
|
|
navigate('/admin/rbac');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const facet = parseFacetQuery(query);
|
|
const baseParts = ['/exchanges'];
|
|
if (scope.appId) baseParts.push(scope.appId);
|
|
if (scope.routeId) baseParts.push(scope.routeId);
|
|
if (facet) {
|
|
navigate(`${baseParts.join('/')}?attr=${encodeURIComponent(formatAttrParam(facet))}`);
|
|
return;
|
|
}
|
|
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
|
|
}, [isAdminPage, adminSearchData, handlePaletteSelect, navigate, scope.appId, scope.routeId]);
|
|
|
|
const handleSidebarNavigate = useCallback((path: string) => {
|
|
const state = { sidebarReveal: path };
|
|
|
|
// When not auto-refreshing, treat navigation as a manual refresh
|
|
if (!autoRefresh) {
|
|
refreshTimeRange();
|
|
queryClient.invalidateQueries();
|
|
}
|
|
|
|
const appMatch = path.match(/^\/apps\/([^/]+)(?:\/(.+))?$/);
|
|
if (appMatch) {
|
|
const [, sAppId, sRouteId] = appMatch;
|
|
if (scope.tab === 'apps') {
|
|
navigate(`/apps/${sAppId}`, { state });
|
|
} else if (scope.tab === 'runtime') {
|
|
// Runtime tab has no route-level view — stay at app level
|
|
navigate(`/runtime/${sAppId}`, { state });
|
|
} else {
|
|
navigate(sRouteId ? `/${scope.tab}/${sAppId}/${sRouteId}` : `/${scope.tab}/${sAppId}`, { state });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const exchangeMatch = path.match(/^\/exchanges\/([^/]+)(?:\/(.+))?$/);
|
|
if (exchangeMatch) {
|
|
const [, sAppId, sRouteId] = exchangeMatch;
|
|
if (scope.tab === 'apps') {
|
|
navigate(`/apps/${sAppId}`, { state });
|
|
} else if (scope.tab === 'runtime') {
|
|
navigate(`/runtime/${sAppId}`, { state });
|
|
} else {
|
|
navigate(sRouteId ? `/${scope.tab}/${sAppId}/${sRouteId}` : `/${scope.tab}/${sAppId}`, { state });
|
|
}
|
|
return;
|
|
}
|
|
|
|
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, autoRefresh, refreshTimeRange, queryClient]);
|
|
|
|
// --- Render -------------------------------------------------------
|
|
const camelLogo = (
|
|
<img src={sidebarLogo} alt="" style={{ width: 28, height: 28 }} />
|
|
);
|
|
|
|
const sidebarElement = (
|
|
<Sidebar
|
|
collapsed={sidebarCollapsed}
|
|
onCollapseToggle={handleCollapseToggle}
|
|
searchValue={filterQuery}
|
|
onSearchChange={setFilterQuery}
|
|
>
|
|
<Sidebar.Header
|
|
logo={camelLogo}
|
|
title="Cameleer"
|
|
version={__APP_VERSION__}
|
|
/>
|
|
|
|
{/* Sidebar filters */}
|
|
{!sidebarCollapsed && <div className={css.sidebarFilters}>
|
|
<button
|
|
className={`${css.filterChip} ${hideEmptyRoutes ? css.filterChipActive : ''}`}
|
|
onClick={toggleHideEmptyRoutes}
|
|
title="Hide routes with 0 executions"
|
|
>
|
|
<span className={css.filterChipIcon}><EyeOff size={10} /></span>
|
|
Empty routes
|
|
</button>
|
|
<button
|
|
className={`${css.filterChip} ${hideOfflineApps ? css.filterChipActive : ''}`}
|
|
onClick={toggleHideOfflineApps}
|
|
title="Hide stale and disconnected apps"
|
|
>
|
|
<span className={css.filterChipIcon}><EyeOff size={10} /></span>
|
|
Offline apps
|
|
</button>
|
|
</div>}
|
|
|
|
{/* Applications section */}
|
|
<div className={css.appSectionWrap}>
|
|
{canControl && !sidebarCollapsed && (
|
|
<button
|
|
className={css.addAppBtn}
|
|
onClick={(e) => { e.stopPropagation(); navigate('/apps/new'); }}
|
|
title="Create App"
|
|
>
|
|
<Plus size={12} /> App
|
|
</button>
|
|
)}
|
|
<Sidebar.Section
|
|
icon={createElement(Box, { size: 16 })}
|
|
label="Applications"
|
|
open={appsOpen}
|
|
onToggle={toggleApps}
|
|
maxHeight="50vh"
|
|
>
|
|
<SidebarTree
|
|
nodes={appTreeNodes}
|
|
selectedPath={effectiveSelectedPath}
|
|
isStarred={isStarred}
|
|
onToggleStar={toggleStar}
|
|
filterQuery={filterQuery}
|
|
persistKey="apps"
|
|
autoRevealPath={sidebarRevealPath}
|
|
onNavigate={handleSidebarNavigate}
|
|
/>
|
|
</Sidebar.Section>
|
|
</div>
|
|
|
|
{/* Alerts section */}
|
|
<Sidebar.Section
|
|
icon={createElement(Bell, { size: 16 })}
|
|
label="Alerts"
|
|
open={alertsOpen}
|
|
onToggle={toggleAlerts}
|
|
active={isAlertsPage}
|
|
>
|
|
<SidebarTree
|
|
nodes={alertsTreeNodes}
|
|
selectedPath={location.pathname}
|
|
isStarred={isStarred}
|
|
onToggleStar={toggleStar}
|
|
filterQuery={filterQuery}
|
|
persistKey="alerts"
|
|
autoRevealPath={sidebarRevealPath}
|
|
onNavigate={handleSidebarNavigate}
|
|
/>
|
|
</Sidebar.Section>
|
|
|
|
{/* Starred section — only when there are starred items */}
|
|
{starredItems.length > 0 && (
|
|
<Sidebar.Section
|
|
icon={createElement(Star, { size: 16 })}
|
|
label="Starred"
|
|
open={starredOpen}
|
|
onToggle={() => setStarredOpen((v) => !v)}
|
|
maxHeight="30vh"
|
|
>
|
|
<StarredList
|
|
items={starredItems}
|
|
onNavigate={handleSidebarNavigate}
|
|
onRemove={toggleStar}
|
|
/>
|
|
</Sidebar.Section>
|
|
)}
|
|
|
|
{/* Admin section — only visible to ADMIN role */}
|
|
{isAdmin && (
|
|
<Sidebar.Section
|
|
icon={createElement(Settings, { size: 16 })}
|
|
label="Admin"
|
|
open={adminOpen}
|
|
onToggle={toggleAdmin}
|
|
active={isAdminPage}
|
|
position="bottom"
|
|
>
|
|
<SidebarTree
|
|
nodes={adminTreeNodes}
|
|
selectedPath={location.pathname}
|
|
isStarred={isStarred}
|
|
onToggleStar={toggleStar}
|
|
filterQuery={filterQuery}
|
|
persistKey="admin"
|
|
autoRevealPath={sidebarRevealPath}
|
|
onNavigate={handleSidebarNavigate}
|
|
/>
|
|
</Sidebar.Section>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<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}>
|
|
<div
|
|
aria-hidden
|
|
style={{
|
|
position: 'fixed',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 3,
|
|
background: envBarColor,
|
|
zIndex: 900,
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
<EnvironmentSwitcherModal
|
|
open={switcherOpen}
|
|
onClose={() => setSwitcherOpen(false)}
|
|
envs={envRecords}
|
|
value={selectedEnv}
|
|
onPick={(slug) => {
|
|
setSelectedEnv(slug);
|
|
setSwitcherOpen(false);
|
|
}}
|
|
forced={switcherForced}
|
|
/>
|
|
<TopBar
|
|
breadcrumb={breadcrumb}
|
|
environment={
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<EnvironmentSwitcherButton
|
|
envs={envRecords}
|
|
value={selectedEnv}
|
|
onClick={() => setSwitcherOpen(true)}
|
|
/>
|
|
<NotificationBell />
|
|
</div>
|
|
}
|
|
user={username ? { name: username } : undefined}
|
|
userMenuItems={userMenuItems}
|
|
onLogout={handleLogout}
|
|
onNavigate={navigate}
|
|
>
|
|
<SearchTrigger onClick={() => setPaletteOpen(true)} />
|
|
<ButtonGroup
|
|
items={STATUS_ITEMS}
|
|
value={globalFilters.statusFilters}
|
|
onChange={(sel) => {
|
|
type S = 'completed' | 'warning' | 'failed' | 'running'
|
|
const selected = sel as Set<S>
|
|
const current = globalFilters.statusFilters
|
|
for (const v of selected) {
|
|
if (!current.has(v)) globalFilters.toggleStatus(v)
|
|
}
|
|
for (const v of current) {
|
|
if (!selected.has(v)) globalFilters.toggleStatus(v)
|
|
}
|
|
}}
|
|
/>
|
|
<TimeRangeDropdown
|
|
value={globalFilters.timeRange}
|
|
onChange={globalFilters.setTimeRange}
|
|
/>
|
|
<AutoRefreshToggle
|
|
active={globalFilters.autoRefresh}
|
|
onChange={globalFilters.setAutoRefresh}
|
|
/>
|
|
</TopBar>
|
|
<AboutMeDialog open={aboutMeOpen} onClose={() => setAboutMeOpen(false)} />
|
|
<CommandPalette
|
|
key={isAdminPage ? 'admin' : 'ops'}
|
|
open={paletteOpen}
|
|
onClose={() => setPaletteOpen(false)}
|
|
onOpen={() => setPaletteOpen(true)}
|
|
onSelect={handlePaletteSelect}
|
|
onSubmit={handlePaletteSubmit}
|
|
onQueryChange={setPaletteQuery}
|
|
data={searchData}
|
|
/>
|
|
|
|
{!isAdminPage && !isAlertsPage && (
|
|
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
|
|
)}
|
|
|
|
<main className={css.mainContent}>
|
|
<Outlet key={selectedEnv ?? '__all__'} />
|
|
</main>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
export function LayoutShell() {
|
|
return (
|
|
<ToastProvider>
|
|
<CommandPaletteProvider>
|
|
<GlobalFilterProvider>
|
|
<BreadcrumbProvider>
|
|
<LayoutContent />
|
|
</BreadcrumbProvider>
|
|
</GlobalFilterProvider>
|
|
</CommandPaletteProvider>
|
|
</ToastProvider>
|
|
);
|
|
}
|