163 lines
13 KiB
TypeScript
163 lines
13 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
import { copy } from './copy';
|
|
import { OperatorCommandBar } from './OperatorCommandBar';
|
|
import { routeFromLocation, type RoutePath } from './routes';
|
|
import {
|
|
WORKSPACE_EVENT,
|
|
isFavorite,
|
|
readWorkspaceState,
|
|
recordRecent,
|
|
removeSavedView,
|
|
saveCurrentView,
|
|
toggleFavorite,
|
|
} from './operatorWorkspace';
|
|
|
|
type SectionId = 'command' | 'infrastructure' | 'workloads' | 'services' | 'response' | 'manage';
|
|
type NavigationDefinition = {
|
|
path: RoutePath;
|
|
label: string;
|
|
icon: string;
|
|
group: string;
|
|
section: SectionId;
|
|
primary: boolean;
|
|
};
|
|
|
|
export const navigation = [
|
|
{ path: '/', label: copy.navigation.overview, icon: '⌂', group: copy.navigation.groups.command, section: 'command', primary: true },
|
|
{ path: '/dashboards', label: copy.navigation.dashboards, icon: '▦', group: copy.navigation.groups.command, section: 'command', primary: true },
|
|
{ path: '/explore', label: copy.navigation.explore, icon: '⌁', group: copy.navigation.groups.command, section: 'command', primary: true },
|
|
|
|
{ path: '/host', label: copy.navigation.host, icon: '▣', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: true },
|
|
{ path: '/processes', label: copy.navigation.processes, icon: '≋', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
|
{ path: '/storage', label: copy.navigation.storage, icon: '▤', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: true },
|
|
{ path: '/array', label: copy.navigation.array, icon: '▥', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
|
{ path: '/disks', label: copy.navigation.disks, icon: '◉', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
|
{ path: '/pools', label: copy.navigation.pools, icon: '◫', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
|
{ path: '/shares', label: copy.navigation.shares, icon: '⇄', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
|
{ path: '/capacity', label: copy.navigation.capacity, icon: '⌁', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
|
{ path: '/network', label: copy.navigation.network, icon: '⌘', group: copy.navigation.groups.infrastructure, section: 'infrastructure', primary: false },
|
|
|
|
{ path: '/applications', label: copy.navigation.applications, icon: '◆', group: copy.navigation.groups.workloads, section: 'workloads', primary: true },
|
|
{ path: '/containers', label: copy.navigation.containers, icon: '⬡', group: copy.navigation.groups.workloads, section: 'workloads', primary: false },
|
|
|
|
{ path: '/services', label: copy.navigation.services, icon: '◉', group: copy.navigation.groups.services, section: 'services', primary: true },
|
|
{ path: '/topology', label: copy.navigation.topology, icon: '⌬', group: copy.navigation.groups.services, section: 'services', primary: false },
|
|
|
|
{ path: '/incidents', label: copy.navigation.incidents, icon: '△', group: copy.navigation.groups.response, section: 'response', primary: true },
|
|
{ path: '/alerts', label: copy.navigation.alerts, icon: '!', group: copy.navigation.groups.response, section: 'response', primary: false },
|
|
{ path: '/events', label: copy.navigation.events, icon: '≡', group: copy.navigation.groups.response, section: 'response', primary: false },
|
|
|
|
{ path: '/inventory', label: copy.navigation.inventory, icon: '▥', group: copy.navigation.groups.manage, section: 'manage', primary: true },
|
|
{ path: '/settings', label: copy.navigation.settings, icon: '⚙', group: copy.navigation.groups.manage, section: 'manage', primary: true },
|
|
{ path: '/status', label: copy.navigation.status, icon: '♥', group: copy.navigation.groups.manage, section: 'manage', primary: false },
|
|
{ path: '/onboarding', label: copy.navigation.onboarding, icon: '→', group: copy.navigation.groups.manage, section: 'manage', primary: false },
|
|
{ path: '/wallboard', label: copy.navigation.wallboard, icon: '▰', group: copy.navigation.groups.manage, section: 'manage', primary: false },
|
|
] as const satisfies ReadonlyArray<NavigationDefinition>;
|
|
|
|
export type NavigationItem = (typeof navigation)[number];
|
|
|
|
const sectionLabels: Record<SectionId, string> = {
|
|
command: 'Operator',
|
|
infrastructure: 'Infrastructuur',
|
|
workloads: 'Workloads',
|
|
services: 'Services',
|
|
response: 'Response',
|
|
manage: 'Beheer',
|
|
};
|
|
|
|
const primaryNavigation = navigation.filter((item) => item.primary);
|
|
const mobilePrimaryOrder = new Map<RoutePath, number>([['/', 0], ['/incidents', 1], ['/containers', 2], ['/storage', 3], ['/services', 4]]);
|
|
const mobilePrimaryNavigation = navigation.filter((item) => mobilePrimaryOrder.has(item.path)).sort((left, right) => mobilePrimaryOrder.get(left.path)! - mobilePrimaryOrder.get(right.path)!);
|
|
const mobileMoreNavigation = navigation.filter((item) => !mobilePrimaryOrder.has(item.path));
|
|
const navigationGroups = Array.from(new Set(primaryNavigation.map((item) => item.group)));
|
|
|
|
function matchesRoute(route: RoutePath, path: RoutePath): boolean {
|
|
return route === path || (path !== '/' && route.startsWith(path + '/'));
|
|
}
|
|
|
|
function routeDefinition(route: RoutePath): NavigationItem {
|
|
return [...navigation]
|
|
.sort((left, right) => right.path.length - left.path.length)
|
|
.find((item) => matchesRoute(route, item.path)) ?? navigation[0];
|
|
}
|
|
|
|
function detailSegment(route: RoutePath, item: NavigationItem): string | null {
|
|
if (route === item.path) return null;
|
|
const raw = route.slice(item.path.length + 1).split('/')[0];
|
|
if (!raw) return null;
|
|
try { return decodeURIComponent(raw).slice(0, 80); } catch { return raw.slice(0, 80); }
|
|
}
|
|
|
|
export function navigate(path: string): void {
|
|
const from = window.location.pathname + window.location.search;
|
|
if (path === from) return;
|
|
window.history.pushState({ pulseFrom: from }, '', path);
|
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
}
|
|
|
|
function NavigationLink({ item, label = item.label, domain = false }: { item: NavigationItem; label?: string; domain?: boolean }) {
|
|
const active = matchesRoute(routeFromLocation(window.location.pathname), item.path);
|
|
return <li><a className={(active ? 'nav-link nav-link--active' : 'nav-link') + (domain ? ' nav-link--domain' : '')} aria-current={active ? 'page' : undefined} href={item.path} title={label} onClick={(event) => { event.preventDefault(); navigate(item.path); }}><span className="nav-icon" aria-hidden="true">{item.icon}</span><span>{label}</span></a></li>;
|
|
}
|
|
|
|
function DesktopNavigation({ route }: { route: RoutePath }) {
|
|
const activeItem = routeDefinition(route);
|
|
const activeGroup = activeItem.group;
|
|
const [openGroups, setOpenGroups] = useState<Set<string>>(() => new Set([navigationGroups[0], activeGroup]));
|
|
useEffect(() => setOpenGroups((current) => current.has(activeGroup) ? current : new Set([...current, activeGroup])), [activeGroup]);
|
|
return <nav className="desktop-navigation" aria-label={copy.navigation.label}>{navigationGroups.map((group) => {
|
|
const items = primaryNavigation.filter((item) => item.group === group);
|
|
return <details className="nav-group" key={group} open={openGroups.has(group)} onToggle={(event) => { const open = event.currentTarget.open; setOpenGroups((current) => { if (current.has(group) === open) return current; const next = new Set(current); if (open) next.add(group); else next.delete(group); return next; }); }}><summary><span>{group}</span><span aria-hidden="true">⌄</span></summary><ul className="nav-list">{items.map((item) => <NavigationLink key={item.path} item={item} domain />)}</ul></details>;
|
|
})}</nav>;
|
|
}
|
|
|
|
function ShellStatusBadge({ label, tone }: { label: string; tone: 'unknown' | 'ready' }) {
|
|
return <span className={'status-badge status-badge--' + tone}><span className="status-icon" aria-hidden="true">{tone === 'ready' ? '✓' : '?'}</span>{label}</span>;
|
|
}
|
|
|
|
function WorkspaceContext({ route, currentNavigation }: { route: RoutePath; currentNavigation: NavigationItem }) {
|
|
const [, setVersion] = useState(0);
|
|
useEffect(() => {
|
|
const refresh = () => setVersion((value) => value + 1);
|
|
window.addEventListener(WORKSPACE_EVENT, refresh);
|
|
return () => window.removeEventListener(WORKSPACE_EVENT, refresh);
|
|
}, []);
|
|
|
|
const item = routeDefinition(route);
|
|
const sectionItems = navigation.filter((entry) => entry.section === item.section && entry.path !== '/wallboard');
|
|
const detail = detailSegment(route, item);
|
|
const currentPath = window.location.pathname + window.location.search;
|
|
const favorite = isFavorite(currentPath);
|
|
const saved = readWorkspaceState().views.some((entry) => entry.path === currentPath);
|
|
const from = typeof window.history.state?.pulseFrom === 'string' ? window.history.state.pulseFrom as string : '';
|
|
const fallback = sectionItems.find((entry) => entry.primary)?.path ?? '/';
|
|
const backTarget = from && from !== currentPath ? from : (route !== fallback ? fallback : '');
|
|
const pageLabel = detail ? currentNavigation.label + ' · ' + detail : currentNavigation.label;
|
|
|
|
useEffect(() => { recordRecent(pageLabel, currentPath); }, [pageLabel, currentPath]);
|
|
|
|
return <div className="workspace-context" aria-label="Werkcontext">
|
|
<div className="workspace-context-main">
|
|
{backTarget && <button className="workspace-back" type="button" onClick={() => navigate(backTarget)} aria-label="Terug naar vorige context"><span aria-hidden="true">←</span><span>Terug</span></button>}
|
|
<nav className="workspace-breadcrumbs" aria-label="Breadcrumb">
|
|
<a href="/" onClick={(event) => { event.preventDefault(); navigate('/'); }}>Pulse</a><span aria-hidden="true">/</span>
|
|
<button type="button" onClick={() => navigate(sectionItems.find((entry) => entry.primary)?.path ?? '/')}>{sectionLabels[item.section]}</button><span aria-hidden="true">/</span>
|
|
<strong>{pageLabel}</strong>
|
|
</nav>
|
|
</div>
|
|
<div className="workspace-actions">
|
|
<button className={'workspace-action' + (favorite ? ' workspace-action--active' : '')} type="button" aria-pressed={favorite} onClick={() => toggleFavorite(pageLabel, currentPath)} title={favorite ? 'Verwijder uit favorieten' : 'Voeg toe aan favorieten'}><span aria-hidden="true">☆</span><span>{favorite ? 'Favoriet' : 'Favoriet'}</span></button>
|
|
<button className={'workspace-action' + (saved ? ' workspace-action--active' : '')} type="button" aria-pressed={saved} onClick={() => saved ? removeSavedView(currentPath) : saveCurrentView(pageLabel, currentPath)} title={saved ? 'Verwijder opgeslagen view' : 'Bewaar huidige filters en view'}><span aria-hidden="true">▣</span><span>{saved ? 'View bewaard' : 'Bewaar view'}</span></button>
|
|
</div>
|
|
<nav className="workspace-subnav" aria-label={sectionLabels[item.section] + ' navigatie'}>{sectionItems.map((entry) => {
|
|
const active = matchesRoute(route, entry.path);
|
|
return <a key={entry.path} href={entry.path} aria-current={active ? 'page' : undefined} onClick={(event) => { event.preventDefault(); navigate(entry.path); }}>{entry.label}</a>;
|
|
})}</nav>
|
|
</div>;
|
|
}
|
|
|
|
export function OperatorAppShell({ route, shellStatus, currentNavigation, children }: { route: RoutePath; shellStatus: { label: string; tone: 'unknown' | 'ready'; detail: string }; currentNavigation: NavigationItem; children: ReactNode }) {
|
|
const resolved = useMemo(() => routeDefinition(route), [route]);
|
|
return <div className="app-shell"><a className="skip-link" href="#main-content">{copy.accessibility.skipToContent}</a><aside className="sidebar"><a className="brand" href="/" title={copy.brand.name} onClick={(event) => { event.preventDefault(); navigate('/'); }}><span className="brand-mark" aria-hidden="true"><span>P</span></span><span className="brand-copy"><strong>{copy.brand.name}</strong><small>{copy.brand.context}</small></span></a><DesktopNavigation route={route} /><nav className="mobile-navigation" aria-label={copy.navigation.label}><ul className="nav-list mobile-primary-list">{mobilePrimaryNavigation.map((item) => <NavigationLink key={item.path} item={item} label={item.path === '/storage' ? copy.navigation.mobileStorage : item.label} />)}</ul><details className="mobile-more" open={mobileMoreNavigation.some((item) => matchesRoute(route, item.path))}><summary>{copy.navigation.more}</summary><ul className="nav-list">{mobileMoreNavigation.map((item) => <NavigationLink key={item.path} item={item} />)}</ul></details></nav><div className="sidebar-status" title={shellStatus.detail}><ShellStatusBadge label={shellStatus.label} tone={shellStatus.tone} /><span>{shellStatus.detail}</span></div></aside><div className="app-workspace"><OperatorCommandBar routeLabel={route === '/404' ? copy.notFound.context : currentNavigation.label} navigation={navigation} onNavigate={navigate} /><WorkspaceContext route={route} currentNavigation={resolved} /><main id="main-content" className="content">{children}</main></div></div>;
|
|
}
|