Files
pulse/frontend-modern/src/AppLayout.tsx
T
rcourtman f4ffdab695 Unify platform icons through a canonical helper
Platform icons were chosen independently in AppLayout, each platform page
surface, and the alerts thresholds table, so the thresholds page drifted
(Server/Boxes/Network/HardDrive) from the tabs and pages it should match.
Route every site through a single getPlatformIcon helper and complete the
brand-icon direction Proxmox already used: add inlined CC0 Docker,
Kubernetes, and TrueNAS marks. vSphere keeps a generic CPU glyph since
VMware has no legible square brand mark.
2026-05-28 21:25:31 +01:00

905 lines
34 KiB
TypeScript

import { For, Show, Suspense, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import type { JSX } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import BellIcon from 'lucide-solid/icons/bell';
import SettingsIcon from 'lucide-solid/icons/settings';
import Maximize2Icon from 'lucide-solid/icons/maximize-2';
import Minimize2Icon from 'lucide-solid/icons/minimize-2';
import SparklesIcon from 'lucide-solid/icons/sparkles';
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
import {
MobileNavBar,
type MobileNavBarPrimaryTab as PrimaryTab,
type MobileNavBarUtilityTab as UtilityTab,
} from '@/components/shared/MobileNavBar';
import {
buildPrimaryPlatformNavigationVisibility,
primaryPlatformNavigationIsVisible,
selectFirstVisiblePrimaryPlatformNavigationId,
type PrimaryPlatformNavId,
} from '@/features/platformNavigation/platformNavigationModel';
import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';
import { OrgSwitcher } from '@/components/OrgSwitcher';
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
import { MONITORING_READ_SCOPE } from '@/constants/apiScopes';
import type { Organization } from '@/api/orgs';
import type { VersionInfo } from '@/api/updates';
import type { Alert, State } from '@/types/api';
import { useKioskMode } from '@/hooks/useKioskMode';
import { layoutStore } from '@/utils/layout';
import { logger } from '@/utils/logger';
import { getActiveTabForPath } from '@/routing/navigation';
import { preloadRouteModule } from '@/routing/routePreload';
import {
buildDockerPath,
buildKubernetesPath,
buildProxmoxPath,
buildStandalonePath,
buildTrueNASPath,
buildVmwarePath,
} from '@/routing/resourceLinks';
import { getKioskModePreference, setKioskMode } from '@/utils/url';
import { updateStore } from '@/stores/updates';
import { aiChatStore } from '@/stores/aiChat';
import { isPro } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { AI_CHAT_LAUNCHER_ARIA_LABEL, getAIChatLauncherTitle } from '@/utils/aiChatPresentation';
import type { AppConnectionStatus } from '@/useAppRuntimeState';
import { buildInfrastructureWorkspacePath } from '@/components/Settings/infrastructureWorkspaceModel';
const ROOT_PROXMOX_PATH = buildProxmoxPath();
const ROOT_DOCKER_PATH = buildDockerPath();
const ROOT_KUBERNETES_PATH = buildKubernetesPath();
const ROOT_TRUENAS_PATH = buildTrueNASPath();
const ROOT_VMWARE_PATH = buildVmwarePath();
const ROOT_STANDALONE_PATH = buildStandalonePath();
const ROOT_INFRASTRUCTURE_SETTINGS_PATH = buildInfrastructureWorkspacePath();
const ROOT_ALERTS_PATH = '/alerts';
const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';
const AI_CHAT_LAUNCHER_BUTTON_CLASS =
'fixed right-4 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-surface text-blue-600 shadow-lg transition-colors duration-200 hover:bg-surface-hover hover:text-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-blue-400 dark:hover:text-blue-300 lg:right-0 lg:top-1/2 lg:bottom-auto lg:h-auto lg:w-auto lg:min-h-9 lg:min-w-10 lg:-translate-y-1/2 lg:rounded-l-lg lg:rounded-r-none lg:border-r-0 lg:px-2.5 lg:py-2.5 lg:shadow-none';
function getDesktopUtilityTabAriaLabel(tab: UtilityTab): string {
if (tab.id === 'alerts') {
const count = tab.count ?? 0;
if (count > 0) {
return `${count} ${tab.label}`;
}
}
return tab.label;
}
export interface AppLayoutProps {
connectionStatus: () => AppConnectionStatus;
lastUpdateText: () => string;
versionInfo: () => VersionInfo | null;
hasAuth: () => boolean;
needsAuth: () => boolean;
proxyAuthInfo: () => { username?: string; logoutURL?: string } | null;
handleLogout: () => void;
state: () => State;
tokenScopes: () => string[] | undefined;
organizations: () => Organization[];
activeOrgID: () => string;
orgsLoading: () => boolean;
showOrgSwitcher: () => boolean;
onSwitchOrg: (orgID: string) => void;
children?: JSX.Element;
}
export function ConnectionStatusBadge(props: {
connectionStatus: () => AppConnectionStatus;
class?: string;
}) {
const status = () => props.connectionStatus();
const showSpinner = () =>
status().kind === 'sync-reconnecting' || status().kind === 'reconnecting';
const showLabelByDefault = () => status().tone !== 'healthy';
const containerClass = () => {
if (status().tone === 'healthy') {
return 'connected bg-green-200 dark:bg-green-700 text-green-700 dark:text-green-300 min-w-6 h-6 group-hover:px-3';
}
if (status().tone === 'warning') {
return 'degraded bg-amber-200 dark:bg-amber-700 text-amber-800 dark:text-amber-200 py-1 px-2';
}
if (status().kind === 'reconnecting') {
return 'reconnecting bg-yellow-200 dark:bg-yellow-700 text-yellow-700 dark:text-yellow-300 py-1 px-2';
}
return 'disconnected bg-surface-hover text-base-content py-1 px-2';
};
const indicatorClass = () => {
if (status().tone === 'healthy') {
return 'bg-green-600 dark:bg-green-400';
}
if (status().tone === 'warning') {
return 'bg-amber-600 dark:bg-amber-300';
}
return 'bg-slate-600';
};
return (
<div
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out ${containerClass()} ${props.class ?? ''}`}
title={status().detail}
role="status"
aria-label={status().detail}
>
<Show when={showSpinner()}>
<svg class="animate-spin h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</Show>
<Show when={!showSpinner()}>
<span class={`h-2.5 w-2.5 rounded-full flex-shrink-0 ${indicatorClass()}`} />
</Show>
<span
class={`whitespace-nowrap overflow-hidden transition-all duration-500 ${
showLabelByDefault()
? 'max-w-[170px] ml-2 opacity-100'
: 'max-w-0 group-hover:max-w-[120px] group-hover:ml-2 group-hover:mr-1 opacity-0 group-hover:opacity-100'
}`}
>
{status().label}
</span>
</div>
);
}
export function AppLayout(props: AppLayoutProps) {
const navigate = useNavigate();
const location = useLocation();
const kioskMode = useKioskMode();
const brandMotionActive = createMemo(() => props.connectionStatus().tone === 'healthy');
const [headerVisible, setHeaderVisible] = createSignal(true);
const [skipLinkFocused, setSkipLinkFocused] = createSignal(false);
let headerEl: HTMLDivElement | undefined;
let headerHideTimeout: ReturnType<typeof setTimeout> | undefined;
const clearHeaderHideTimeout = () => {
if (headerHideTimeout !== undefined) {
clearTimeout(headerHideTimeout);
headerHideTimeout = undefined;
}
};
const showHeader = () => {
clearHeaderHideTimeout();
setHeaderVisible(true);
};
const scheduleHideHeader = (delayMs: number) => {
clearHeaderHideTimeout();
headerHideTimeout = setTimeout(() => {
setHeaderVisible(false);
headerHideTimeout = undefined;
}, delayMs);
};
createEffect(() => {
const scopes = props.tokenScopes();
if (scopes && scopes.length === 1 && scopes[0] === MONITORING_READ_SCOPE) {
const preference = getKioskModePreference();
if (preference === null) {
setKioskMode(true);
}
}
});
// Reflect the active tab in the browser tab title so multi-tab use,
// browser history, and screen-reader page-title announcements all
// identify the current Pulse surface instead of every page reading
// as the bare app name.
const tabTitleByActive: Record<NonNullable<ReturnType<typeof getActiveTabForPath>>, string> = {
proxmox: 'Proxmox',
docker: 'Docker',
kubernetes: 'Kubernetes',
truenas: 'TrueNAS',
vmware: 'vSphere',
standalone: 'Machines',
alerts: 'Alerts',
ai: 'Patrol',
settings: 'Settings',
};
createEffect(() => {
const active = getActiveTabForPath(location.pathname);
document.title = active ? `${tabTitleByActive[active]} · Pulse` : 'Pulse';
});
const toggleKioskMode = () => {
setKioskMode(!kioskMode());
};
const platformNavigationVisibility = createMemo(() =>
buildPrimaryPlatformNavigationVisibility(props.state().resources || []),
);
const primaryInfrastructureRouteById: Record<PrimaryPlatformNavId, string> = {
proxmox: ROOT_PROXMOX_PATH,
docker: ROOT_DOCKER_PATH,
kubernetes: ROOT_KUBERNETES_PATH,
truenas: ROOT_TRUENAS_PATH,
vmware: ROOT_VMWARE_PATH,
standalone: ROOT_STANDALONE_PATH,
};
const primaryWorkspacePath = createMemo(() => {
const navId = selectFirstVisiblePrimaryPlatformNavigationId(
platformNavigationVisibility(),
);
return navId ? primaryInfrastructureRouteById[navId] : ROOT_ALERTS_PATH;
});
createEffect(() => {
if (kioskMode()) {
setHeaderVisible(true);
scheduleHideHeader(1500);
} else {
clearHeaderHideTimeout();
setHeaderVisible(true);
}
});
createEffect(() => {
if (!kioskMode()) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
toggleKioskMode();
}
};
window.addEventListener('keydown', onKeyDown);
onCleanup(() => window.removeEventListener('keydown', onKeyDown));
});
createEffect(() => {
if (!kioskMode()) return;
const normalizedPath = location.pathname.replace(/\/+$/, '') || '/';
const blockedPrefixes = ['/settings', '/patrol'];
const isBlocked = blockedPrefixes.some(
(prefix) => normalizedPath === prefix || normalizedPath.startsWith(prefix + '/'),
);
const isAlertsPath = normalizedPath === '/alerts' || normalizedPath.startsWith('/alerts/');
const isAlertConfigTab =
isAlertsPath &&
normalizedPath !== '/alerts' &&
normalizedPath !== '/alerts/overview' &&
normalizedPath !== '/alerts/history' &&
!normalizedPath.startsWith('/alerts/overview/') &&
!normalizedPath.startsWith('/alerts/history/');
const targetPath = primaryWorkspacePath();
if ((isBlocked || isAlertConfigTab) && normalizedPath !== targetPath) {
navigate(targetPath, { replace: true });
}
});
createEffect(() => {
if (!kioskMode()) return;
const onPointerDown = (event: PointerEvent) => {
if (event.pointerType !== 'touch') return;
if (event.clientY > 60) return;
showHeader();
scheduleHideHeader(3000);
};
window.addEventListener('pointerdown', onPointerDown, { passive: true, capture: true });
onCleanup(() => window.removeEventListener('pointerdown', onPointerDown, true));
if (typeof (window as { PointerEvent?: unknown }).PointerEvent === 'undefined') {
const onTouchStart = (event: TouchEvent) => {
const touch = event.touches?.[0];
if (!touch || touch.clientY > 60) return;
showHeader();
scheduleHideHeader(3000);
};
window.addEventListener('touchstart', onTouchStart, { passive: true, capture: true });
onCleanup(() => window.removeEventListener('touchstart', onTouchStart, true));
}
});
onCleanup(() => {
clearHeaderHideTimeout();
});
const getActiveTabDesktop = () => getActiveTabForPath(location.pathname);
const getActiveTabMobile = () => getActiveTabForPath(location.pathname);
// Platform/runtime nav is resource-admitted. A platform or runtime lens only
// appears when the support manifest says the surface is supported and the
// current resource snapshot proves that surface is actually present.
const primaryTabs = createMemo<PrimaryTab[]>(() => {
const visible = platformNavigationVisibility();
const isVisible = (id: PrimaryTab['id']) =>
primaryPlatformNavigationIsVisible(visible, id as PrimaryPlatformNavId);
const allPrimaryTabs: PrimaryTab[] = [
{
id: 'proxmox',
label: 'Proxmox',
route: ROOT_PROXMOX_PATH,
settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH,
tooltip: 'Proxmox VE, Backup Server, Mail Gateway, storage, backups, and guests',
enabled: isVisible('proxmox'),
live: isVisible('proxmox'),
icon: getPlatformIcon('proxmox'),
alwaysShow: false,
},
{
id: 'docker',
label: 'Docker',
route: ROOT_DOCKER_PATH,
settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH,
tooltip: 'Docker / Podman runtime lens: hosts, containers, and Swarm services',
enabled: isVisible('docker'),
live: isVisible('docker'),
icon: getPlatformIcon('docker'),
alwaysShow: false,
},
{
id: 'kubernetes',
label: 'Kubernetes',
route: ROOT_KUBERNETES_PATH,
settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH,
tooltip: 'Kubernetes clusters, nodes, pods, deployments, and services',
enabled: isVisible('kubernetes'),
live: isVisible('kubernetes'),
icon: getPlatformIcon('kubernetes'),
alwaysShow: false,
},
{
id: 'truenas',
label: 'TrueNAS',
route: ROOT_TRUENAS_PATH,
settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH,
tooltip: 'TrueNAS hosts, storage, and apps',
enabled: isVisible('truenas'),
live: isVisible('truenas'),
icon: getPlatformIcon('truenas'),
alwaysShow: false,
},
{
id: 'vmware',
label: 'vSphere',
route: ROOT_VMWARE_PATH,
settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH,
tooltip: 'VMware vSphere hosts, virtual machines, datastores, and networks',
enabled: isVisible('vmware'),
live: isVisible('vmware'),
icon: getPlatformIcon('vmware'),
alwaysShow: false,
},
{
id: 'standalone',
label: 'Machines',
route: ROOT_STANDALONE_PATH,
settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH,
tooltip: 'Pulse Agent machines, agentless computers, and availability checks',
enabled: isVisible('standalone'),
live: isVisible('standalone'),
icon: getPlatformIcon('standalone'),
alwaysShow: false,
},
];
return allPrimaryTabs.filter((tab) => tab.alwaysShow || tab.enabled);
});
const utilityTabs = createMemo(() => {
const allAlerts = props.state().activeAlerts || [];
const breakdown = allAlerts.reduce(
(accumulator, alert: Alert) => {
if (alert?.acknowledged) return accumulator;
const level = String(alert?.level || '').toLowerCase();
if (level === 'critical') {
accumulator.critical += 1;
} else {
accumulator.warning += 1;
}
return accumulator;
},
{ warning: 0, critical: 0 },
);
const activeAlertCount = breakdown.warning + breakdown.critical;
const scopes = props.tokenScopes();
const hasSettingsAccess =
!scopes || scopes.length === 0 || scopes.includes('*') || scopes.includes('settings:read');
const tabs: UtilityTab[] = [
{
id: 'alerts',
label: 'Alerts',
route: '/alerts',
tooltip: 'Review active alerts and automation rules',
badge: null,
count: activeAlertCount,
breakdown,
icon: BellIcon,
},
{
id: 'ai',
label: 'Patrol',
route: '/patrol',
tooltip: 'Continuous verification and actionable findings',
badge: null,
count: undefined,
breakdown: undefined,
icon: PulsePatrolLogo,
},
];
if (hasSettingsAccess) {
tabs.push({
id: 'settings',
label: 'Settings',
route: '/settings',
tooltip: 'Configure Pulse preferences and integrations',
badge: updateStore.isUpdateVisible() ? 'update' : null,
count: undefined,
breakdown: undefined,
icon: SettingsIcon,
});
}
return tabs;
});
const handlePrimaryClick = (tab: PrimaryTab) => {
const targetRoute = tab.enabled ? tab.route : tab.settingsRoute;
void (async () => {
try {
await preloadRouteModule(targetRoute);
} catch (error) {
logger.warn('Failed to preload navigation target', {
route: targetRoute,
error: error instanceof Error ? error.message : String(error),
});
}
navigate(targetRoute);
})();
};
const handleUtilityClick = (tab: UtilityTab) => {
void (async () => {
try {
await preloadRouteModule(tab.route);
} catch (error) {
logger.warn('Failed to preload navigation target', {
route: tab.route,
error: error instanceof Error ? error.message : String(error),
});
}
navigate(tab.route);
})();
};
const warmNavigationTarget = (route: string) => {
void preloadRouteModule(route).catch((error) => {
logger.warn('Failed to warm navigation target', {
route,
error: error instanceof Error ? error.message : String(error),
});
});
};
const getPrimaryTargetRoute = (tab: PrimaryTab) => {
if (tab.enabled) {
return tab.route;
}
return tab.settingsRoute;
};
const renderPrimaryNavigationTab = (tab: PrimaryTab) => {
const isActive = () => getActiveTabDesktop() === tab.id;
const disabled = () => !tab.enabled;
const Icon = tab.icon;
const baseClasses =
'tab relative px-1.5 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
const className = () => {
if (isActive()) {
return `${baseClasses} bg-surface text-blue-600 dark:text-blue-400 border-border border-b border-b-surface shadow-sm font-semibold`;
}
if (disabled()) {
return `${baseClasses} cursor-not-allowed text-muted opacity-70 bg-base`;
}
return `${baseClasses} text-muted hover:text-base-content hover:bg-surface-hover`;
};
const title = () =>
disabled() ? `${tab.label} is not configured yet. Click to open settings.` : tab.tooltip;
return (
<div
class={className()}
role="tab"
tabIndex={0}
aria-label={tab.label}
aria-disabled={disabled()}
onMouseEnter={() => warmNavigationTarget(getPrimaryTargetRoute(tab))}
onClick={() => handlePrimaryClick(tab)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handlePrimaryClick(tab);
}
}}
title={title()}
>
<span aria-hidden="true" class="inline-flex items-center justify-center">
<Icon class={NAV_TAB_ICON_CLASS} />
</span>
<span class="hidden xs:inline-flex items-center gap-1">
<span>{tab.label}</span>
<Show when={tab.badge}>
<span class="px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted bg-surface-hover rounded">
{tab.badge}
</span>
</Show>
</span>
<span class="xs:hidden">{tab.label.charAt(0)}</span>
</div>
);
};
return (
<div
class={`pulse-shell ${layoutStore.isFullWidth() || kioskMode() ? 'pulse-shell--full-width' : ''} ${!kioskMode() ? 'pb-safe-or-20 lg:pb-0' : ''}`}
>
{/* Skip-to-content link: visually hidden until focused, then
appears as a button at the top-left. Lets keyboard users
jump past the chrome straight into the page content. */}
<a
href="#main"
onFocus={() => setSkipLinkFocused(true)}
onBlur={() => setSkipLinkFocused(false)}
class={
skipLinkFocused()
? 'absolute left-2 top-2 z-[100] rounded bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow-lg outline outline-2 outline-offset-2 outline-white'
: 'sr-only'
}
>
Skip to main content
</a>
<Show when={kioskMode()}>
<div
class="fixed top-0 left-0 right-0 z-40 h-4 bg-transparent"
aria-hidden="true"
onMouseEnter={() => {
if (!kioskMode()) return;
showHeader();
}}
onMouseLeave={() => {
if (!kioskMode()) return;
scheduleHideHeader(500);
}}
/>
</Show>
<div
class={`header mb-3 flex items-center gap-2 ${
kioskMode()
? 'fixed top-0 left-0 right-0 z-50 justify-end bg-surface shadow-sm'
: 'justify-between sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0'
}`}
style={
kioskMode()
? {
transform: headerVisible() ? 'translateY(0)' : 'translateY(-100%)',
opacity: headerVisible() ? 1 : 0,
transition: `transform ${headerVisible() ? 200 : 300}ms ease, opacity ${headerVisible() ? 200 : 300}ms ease`,
'pointer-events': headerVisible() ? 'auto' : 'none',
}
: undefined
}
ref={(element) => {
headerEl = element;
}}
onMouseEnter={() => {
if (!kioskMode()) return;
showHeader();
}}
onMouseLeave={() => {
if (!kioskMode()) return;
scheduleHideHeader(500);
}}
onFocusIn={() => {
if (!kioskMode()) return;
showHeader();
}}
onFocusOut={(event) => {
if (!kioskMode()) return;
const next = event.relatedTarget as Node | null;
if (next && headerEl?.contains(next)) return;
scheduleHideHeader(500);
}}
>
<Show when={!kioskMode()}>
<div class="flex items-center gap-2 sm:flex-initial sm:gap-2 sm:col-start-2 sm:col-end-3 sm:justify-self-center">
<div
class={`pulse-brand-lockup flex items-center gap-2 ${brandMotionActive() ? 'animate-pulse-brand' : ''}`}
data-testid="pulse-brand-lockup"
>
<svg
width="20"
height="20"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
class="pulse-brand-logo"
>
<title>Pulse Logo</title>
<circle
class="pulse-bg fill-blue-600 dark:fill-blue-500"
cx="128"
cy="128"
r="122"
/>
<circle
class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]"
cx="128"
cy="128"
r="84"
/>
<circle
class="pulse-center fill-white dark:fill-[#dbeafe]"
cx="128"
cy="128"
r="26"
/>
</svg>
<span class="pulse-brand-wordmark text-lg font-medium text-base-content">Pulse</span>
<Show when={props.versionInfo()?.channel === 'rc'}>
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">
Preview
</span>
</Show>
</div>
</div>
</Show>
<div
class={`header-controls flex items-center gap-2 ${kioskMode() ? '' : 'justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end'}`}
>
<Show when={props.hasAuth() && !props.needsAuth()}>
<div class="flex items-center gap-2">
<Show when={props.showOrgSwitcher()}>
<OrgSwitcher
orgs={props.organizations()}
selectedOrgId={props.activeOrgID()}
loading={props.orgsLoading()}
onChange={props.onSwitchOrg}
/>
</Show>
<button
type="button"
onClick={toggleKioskMode}
class={`group relative flex h-11 w-11 items-center justify-center rounded-full text-xs transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 sm:h-10 sm:w-10 ${
kioskMode()
? 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800'
: 'bg-surface-hover text-base-content hover:bg-border'
}`}
title={
kioskMode()
? 'Exit kiosk mode (show navigation)'
: 'Enter kiosk mode (hide navigation)'
}
aria-label={kioskMode() ? 'Exit kiosk mode' : 'Enter kiosk mode'}
aria-pressed={kioskMode()}
>
<Show when={kioskMode()} fallback={<Maximize2Icon class="h-4 w-4 flex-shrink-0" />}>
<Minimize2Icon class="h-4 w-4 flex-shrink-0" />
</Show>
</button>
<Show when={props.proxyAuthInfo()?.username}>
<span class="text-xs px-2 py-1 text-muted">{props.proxyAuthInfo()?.username}</span>
</Show>
<button
type="button"
onClick={props.handleLogout}
class="group relative flex h-11 w-11 items-center justify-center rounded-full bg-surface-hover text-xs text-base-content transition hover:bg-border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 sm:h-10 sm:w-10"
title="Logout"
aria-label="Logout"
>
<svg
class="h-4 w-4 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button>
</div>
</Show>
<ConnectionStatusBadge connectionStatus={props.connectionStatus} class="flex-shrink-0" />
</div>
</div>
<Show when={!kioskMode()}>
<div
class="tabs mb-2 hidden lg:flex items-end gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap border-b border-border scrollbar-hide"
role="tablist"
aria-label="Primary navigation"
>
<div class="flex items-end gap-1" role="group" aria-label="Infrastructure">
<For each={primaryTabs()}>{renderPrimaryNavigationTab}</For>
</div>
<div class="flex items-end gap-1 ml-auto" role="group" aria-label="System">
<div class="flex items-end gap-1 pl-1 sm:pl-4">
<For each={utilityTabs()}>
{(tab) => {
const isActive = () => getActiveTabDesktop() === tab.id;
const Icon = tab.icon;
const baseClasses =
'tab relative px-1.5 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
const className = () => {
if (isActive()) {
return `${baseClasses} bg-surface text-blue-600 dark:text-blue-400 border-border border-b border-b-surface shadow-sm font-semibold`;
}
return `${baseClasses} text-muted hover:text-base-content hover:bg-surface-hover`;
};
return (
<div
class={className()}
role="tab"
tabIndex={0}
aria-label={getDesktopUtilityTabAriaLabel(tab)}
aria-disabled={false}
onMouseEnter={() => warmNavigationTarget(tab.route)}
onClick={() => handleUtilityClick(tab)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleUtilityClick(tab);
}
}}
title={tab.tooltip}
>
<span aria-hidden="true" class="inline-flex items-center justify-center">
<Icon class={NAV_TAB_ICON_CLASS} />
</span>
<span class="flex items-center gap-1">
<span class="hidden xs:inline">{tab.label}</span>
<span class="xs:hidden">{tab.label.charAt(0)}</span>
{tab.id === 'alerts' &&
(() => {
const total = tab.count ?? 0;
if (total <= 0) {
return null;
}
return (
<span class="inline-flex items-center gap-1">
{tab.breakdown && tab.breakdown.critical > 0 && (
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-600 dark:bg-red-500 rounded-full">
{tab.breakdown.critical}
</span>
)}
{tab.breakdown && tab.breakdown.warning > 0 && (
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900 dark:text-amber-100 bg-amber-200 dark:bg-amber-500 rounded-full">
{tab.breakdown.warning}
</span>
)}
</span>
);
})()}
</span>
<Show when={tab.badge === 'update'}>
<span class="ml-1 flex items-center">
<span class="sr-only">Update available</span>
<span
aria-hidden="true"
class="block h-2 w-2 rounded-full bg-red-500 animate-pulse"
/>
</span>
</Show>
<Show when={tab.badge === 'pro' && !presentationPolicyHidesUpgradePrompts()}>
<span class="ml-1.5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 rounded">
Pro
</span>
</Show>
</div>
);
}}
</For>
</div>
</div>
</div>
</Show>
<main
id="main"
class="tab-content block bg-surface rounded-b rounded-tr rounded-tl shadow mb-2"
>
<div class="pulse-panel">
<Suspense fallback={<div class="p-6 text-sm text-muted">Loading view...</div>}>
{props.children}
</Suspense>
</div>
</main>
<Show when={!kioskMode()}>
<MobileNavBar
activeTab={getActiveTabMobile}
primaryTabs={primaryTabs}
utilityTabs={utilityTabs}
onPrimaryClick={handlePrimaryClick}
onUtilityClick={handleUtilityClick}
/>
</Show>
<Show when={!kioskMode()}>
<footer class="px-4 py-4 text-xs leading-relaxed text-muted">
<div class="text-center">
<span>Pulse | Version: </span>
<a
href="https://github.com/rcourtman/Pulse/releases"
target="_blank"
rel="noopener noreferrer"
class="inline-flex min-h-10 sm:min-h-9 items-center break-all rounded px-1 py-1 text-blue-600 dark:text-blue-400 hover:underline"
>
{props.versionInfo()?.version || 'loading...'}
</a>
{props.versionInfo()?.isDevelopment && ' (Development)'}
{props.versionInfo()?.isDocker && ' - Docker'}
</div>
<div class="mt-1 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-center">
<Show when={props.lastUpdateText()}>
<>
<span>Last refresh: {props.lastUpdateText()}</span>
<Show when={isPro()}>
<span aria-hidden="true">|</span>
</Show>
</>
</Show>
<Show when={isPro()}>
<a
href={`mailto:support@pulserelay.pro?subject=${encodeURIComponent(`Support Request - Pulse ${props.versionInfo()?.version || ''}`)}`}
class="inline-flex min-h-10 sm:min-h-9 items-center rounded px-1 py-1 text-blue-600 dark:text-blue-400 hover:underline"
>
Get Support
</a>
</Show>
</div>
</footer>
</Show>
<Show
when={
aiChatStore.enabled === true &&
!aiChatStore.isOpenSignal() &&
!kioskMode() &&
!dialogStackHasBlockingDialog()
}
>
<button
type="button"
onClick={() => aiChatStore.toggle()}
class={AI_CHAT_LAUNCHER_BUTTON_CLASS}
title={getAIChatLauncherTitle(aiChatStore.context.context?.name)}
aria-label={AI_CHAT_LAUNCHER_ARIA_LABEL}
>
<SparklesIcon class="h-5 w-5 flex-shrink-0" />
</button>
</Show>
</div>
);
}