Files
cameleer-server/ui/src/components/LayoutShell.tsx

898 lines
31 KiB
TypeScript
Raw Normal View History

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';
2026-04-15 19:41:36 +02:00
import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff } from 'lucide-react';
import { AboutMeDialog } from './AboutMeDialog';
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 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 { EnvironmentSelector } from './EnvironmentSelector';
import { useScope } from '../hooks/useScope';
import { formatDuration } from '../utils/format-utils';
import {
buildAppTreeNodes,
buildAdminTreeNodes,
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',
});
}
}
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 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[] = [
2026-04-15 19:41:36 +02:00
{ 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_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 { data: catalog } = useCatalog(selectedEnv);
const { data: allAgents } = useAgents(); // unfiltered — for environment discovery
const { data: agents } = useAgents(undefined, undefined, selectedEnv); // filtered — for sidebar/search
const { data: attributeKeys } = useAttributeKeys();
const { data: envRecords = [] } = useEnvironments();
// 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 { 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('');
2026-04-15 19:41:36 +02:00
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]);
// --- Section open states ------------------------------------------
const [appsOpen, setAppsOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_APPS, true));
const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, 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
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]);
2026-04-15 19:41:36 +02:00
// --- 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(
2026-04-15 19:41:36 +02:00
() => buildAppTreeNodes(filteredSidebarApps, makeStatusDot, makeChevron, makeStopIcon, makePauseIcon),
[filteredSidebarApps],
);
const adminTreeNodes: SidebarTreeNode[] = useMemo(
() => buildAdminTreeNodes({ infrastructureEndpoints: capabilities?.infrastructureEndpoints }),
[capabilities?.infrastructureEndpoints],
);
// --- 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
// 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 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,
});
}
}
}
}
return [...catalogRef.current, ...exchangeItems, ...attributeItems];
}, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery]);
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',
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();
useEnvironmentStore.getState().setEnvironment(undefined);
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.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' || 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) => {
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');
}
} else {
const baseParts = ['/exchanges'];
if (scope.appId) baseParts.push(scope.appId);
if (scope.routeId) baseParts.push(scope.routeId);
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__}
/>
2026-04-15 19:41:36 +02:00
{/* 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}>
2026-04-15 19:41:36 +02:00
{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}
>
<SidebarTree
nodes={appTreeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="apps"
autoRevealPath={sidebarRevealPath}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
</div>
{/* 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)}
>
<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}
>
<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}>
<TopBar
breadcrumb={breadcrumb}
environment={
<EnvironmentSelector
environments={environments}
value={selectedEnv}
onChange={setSelectedEnv}
/>
}
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 && (
<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>
);
}