Fix missing utility navigation icons

This commit is contained in:
rcourtman 2026-04-17 23:17:38 +01:00
parent 0721669067
commit beb143aade
6 changed files with 185 additions and 157 deletions

View file

@ -1,12 +1,4 @@
import {
For,
Show,
Suspense,
createEffect,
createMemo,
createSignal,
onCleanup,
} from 'solid-js';
import { For, Show, Suspense, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import type { JSX } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import BoxesIcon from 'lucide-solid/icons/boxes';
@ -18,7 +10,11 @@ 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 { MobileNavBar } from '@/components/shared/MobileNavBar';
import {
MobileNavBar,
type MobileNavBarPlatformTab as PlatformTab,
type MobileNavBarUtilityTab as UtilityTab,
} from '@/components/shared/MobileNavBar';
import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';
import { OrgSwitcher } from '@/components/OrgSwitcher';
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
@ -42,38 +38,12 @@ 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 { AI_CHAT_LAUNCHER_ARIA_LABEL, getAIChatLauncherTitle } from '@/utils/aiChatPresentation';
import type { AppConnectionStatus } from '@/useAppRuntimeState';
const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath();
const ROOT_WORKLOADS_PATH = buildWorkloadsPath();
type PlatformTab = {
id: string;
label: string;
route: string;
settingsRoute: string;
tooltip: string;
enabled: boolean;
live: boolean;
icon: JSX.Element;
alwaysShow: boolean;
badge?: string;
};
type UtilityTab = {
id: 'alerts' | 'ai' | 'settings';
label: string;
route: string;
tooltip: string;
badge: 'update' | 'pro' | null;
count: number | undefined;
breakdown: { warning: number; critical: number } | undefined;
icon: JSX.Element;
};
const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';
export interface AppLayoutProps {
connectionStatus: () => AppConnectionStatus;
@ -283,7 +253,7 @@ export function AppLayout(props: AppLayoutProps) {
const getActiveTabDesktop = () => getActiveTabForPath(location.pathname);
const getActiveTabMobile = () => getActiveTabForPath(location.pathname);
const platformTabsDesktop = createMemo(() => {
const platformTabs = createMemo<PlatformTab[]>(() => {
const allPlatforms: PlatformTab[] = [
{
id: 'dashboard',
@ -293,7 +263,7 @@ export function AppLayout(props: AppLayoutProps) {
tooltip: 'Environment overview and command center',
enabled: true,
live: true,
icon: <LayoutDashboardIcon class="w-4 h-4 shrink-0" />,
icon: LayoutDashboardIcon,
alwaysShow: true,
},
{
@ -304,7 +274,7 @@ export function AppLayout(props: AppLayoutProps) {
tooltip: 'All agents and nodes across platforms',
enabled: true,
live: true,
icon: <ServerIcon class="w-4 h-4 shrink-0" />,
icon: ServerIcon,
alwaysShow: true,
},
{
@ -315,69 +285,14 @@ export function AppLayout(props: AppLayoutProps) {
tooltip: 'VMs, containers, and Kubernetes workloads',
enabled: true,
live: true,
icon: <BoxesIcon class="w-4 h-4 shrink-0" />,
icon: BoxesIcon,
alwaysShow: true,
},
...buildStorageRecoveryTabSpecs().map((tab) => ({
...tab,
enabled: true,
live: true,
icon: tab.id.startsWith('storage') ? (
<HardDriveIcon class="w-4 h-4 shrink-0" />
) : (
<ArchiveIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true,
})),
];
return allPlatforms.filter((platform) => platform.alwaysShow || platform.enabled);
});
const platformTabsMobile = createMemo(() => {
const allPlatforms: PlatformTab[] = [
{
id: 'dashboard',
label: 'Dashboard',
route: DASHBOARD_PATH,
settingsRoute: '/settings',
tooltip: 'Environment overview and command center',
enabled: true,
live: true,
icon: <LayoutDashboardIcon class="w-4 h-4 shrink-0" />,
alwaysShow: true,
},
{
id: 'infrastructure',
label: 'Infrastructure',
route: ROOT_INFRASTRUCTURE_PATH,
settingsRoute: '/settings',
tooltip: 'All agents and nodes across platforms',
enabled: true,
live: true,
icon: <ServerIcon class="w-4 h-4 shrink-0" />,
alwaysShow: true,
},
{
id: 'workloads',
label: 'Workloads',
route: ROOT_WORKLOADS_PATH,
settingsRoute: '/settings/workloads/docker',
tooltip: 'VMs, containers, and Kubernetes workloads',
enabled: true,
live: true,
icon: <BoxesIcon class="w-4 h-4 shrink-0" />,
alwaysShow: true,
},
...buildStorageRecoveryTabSpecs().map((tab) => ({
...tab,
enabled: true,
live: true,
icon: tab.id.startsWith('storage') ? (
<HardDriveIcon class="w-4 h-4 shrink-0" />
) : (
<ArchiveIcon class="w-4 h-4 shrink-0" />
),
icon: tab.id.startsWith('storage') ? HardDriveIcon : ArchiveIcon,
alwaysShow: true,
})),
];
@ -415,7 +330,7 @@ export function AppLayout(props: AppLayoutProps) {
badge: null,
count: activeAlertCount,
breakdown,
icon: <BellIcon class="w-4 h-4 shrink-0" />,
icon: BellIcon,
},
{
id: 'ai',
@ -425,7 +340,7 @@ export function AppLayout(props: AppLayoutProps) {
badge: null,
count: undefined,
breakdown: undefined,
icon: <PulsePatrolLogo class="w-4 h-4 shrink-0" />,
icon: PulsePatrolLogo,
},
];
@ -438,7 +353,7 @@ export function AppLayout(props: AppLayoutProps) {
badge: updateStore.isUpdateVisible() ? 'update' : null,
count: undefined,
breakdown: undefined,
icon: <SettingsIcon class="w-4 h-4 shrink-0" />,
icon: SettingsIcon,
});
}
@ -557,14 +472,24 @@ export function AppLayout(props: AppLayoutProps) {
class={`pulse-logo ${props.connectionStatus().kind === 'connected' && props.dataUpdated() ? 'animate-pulse-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-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" />
<circle
class="pulse-center fill-white dark:fill-[#dbeafe]"
cx="128"
cy="128"
r="26"
/>
</svg>
<span class="text-lg font-medium text-base-content">Pulse</span>
<Show when={props.versionInfo()?.channel === 'rc'}>
@ -604,10 +529,7 @@ export function AppLayout(props: AppLayoutProps) {
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" />}
>
<Show when={kioskMode()} fallback={<Maximize2Icon class="h-4 w-4 flex-shrink-0" />}>
<Minimize2Icon class="h-4 w-4 flex-shrink-0" />
</Show>
</button>
@ -638,10 +560,7 @@ export function AppLayout(props: AppLayoutProps) {
</button>
</div>
</Show>
<ConnectionStatusBadge
connectionStatus={props.connectionStatus}
class="flex-shrink-0"
/>
<ConnectionStatusBadge connectionStatus={props.connectionStatus} class="flex-shrink-0" />
</div>
</div>
@ -652,10 +571,11 @@ export function AppLayout(props: AppLayoutProps) {
aria-label="Primary navigation"
>
<div class="flex items-end gap-1" role="group" aria-label="Infrastructure">
<For each={platformTabsDesktop()}>
<For each={platformTabs()}>
{(platform) => {
const isActive = () => getActiveTabDesktop() === platform.id;
const disabled = () => !platform.enabled;
const Icon = platform.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';
@ -683,7 +603,7 @@ export function AppLayout(props: AppLayoutProps) {
onClick={() => handlePlatformClick(platform)}
title={title()}
>
{platform.icon}
<Icon class={NAV_TAB_ICON_CLASS} />
<span class="hidden xs:inline-flex items-center gap-1">
<span>{platform.label}</span>
<Show when={platform.badge}>
@ -703,6 +623,7 @@ export function AppLayout(props: AppLayoutProps) {
<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';
@ -722,7 +643,7 @@ export function AppLayout(props: AppLayoutProps) {
onClick={() => handleUtilityClick(tab)}
title={tab.tooltip}
>
{tab.icon}
<Icon class={NAV_TAB_ICON_CLASS} />
<span class="flex items-center gap-1">
<span class="hidden xs:inline">{tab.label}</span>
<span class="xs:hidden">{tab.label.charAt(0)}</span>
@ -757,9 +678,7 @@ export function AppLayout(props: AppLayoutProps) {
/>
</span>
</Show>
<Show
when={tab.badge === 'pro' && !presentationPolicyHidesUpgradePrompts()}
>
<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>
@ -787,7 +706,7 @@ export function AppLayout(props: AppLayoutProps) {
<Show when={!kioskMode()}>
<MobileNavBar
activeTab={getActiveTabMobile}
platformTabs={platformTabsMobile}
platformTabs={platformTabs}
utilityTabs={utilityTabs}
onPlatformClick={handlePlatformClick}
onUtilityClick={handleUtilityClick}

View file

@ -91,6 +91,18 @@ describe('App architecture', () => {
expect(appLayoutSource).not.toContain("route: '/operations',");
expect(appLayoutSource).not.toContain('props.connected()');
expect(appLayoutSource).toContain('const utilityTabs = createMemo(() =>');
expect(appLayoutSource).toContain(
"type MobileNavBarPlatformTab as PlatformTab,\n type MobileNavBarUtilityTab as UtilityTab,",
);
expect(appLayoutSource).toContain("const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';");
expect(appLayoutSource).toContain('const platformTabs = createMemo<PlatformTab[]>(() =>');
expect(appLayoutSource).toContain('const Icon = platform.icon;');
expect(appLayoutSource).toContain('const Icon = tab.icon;');
expect(appLayoutSource).toContain('<Icon class={NAV_TAB_ICON_CLASS} />');
expect(appLayoutSource).not.toContain('type PlatformTab = {');
expect(appLayoutSource).not.toContain('type UtilityTab = {');
expect(appLayoutSource).not.toContain('const platformTabsDesktop = createMemo(() =>');
expect(appLayoutSource).not.toContain('const platformTabsMobile = createMemo(() =>');
expect(appLayoutSource).not.toContain("import { isMultiTenantEnabled } from '@/stores/license';");
expect(appLayoutSource).not.toContain('loadCommercialPosture');
expect(appLayoutSource).not.toContain('buildReleaseNotesUrl');

View file

@ -0,0 +1,86 @@
import { cleanup, render, screen, within } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { State } from '@/types/api';
import { AppLayout } from '@/AppLayout';
HTMLElement.prototype.scrollIntoView = vi.fn();
describe('AppLayout navigation icons', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/dashboard');
});
afterEach(() => {
cleanup();
});
const renderLayout = () =>
render(() => (
<Router>
<Route
path="/dashboard"
component={() => (
<AppLayout
connectionStatus={() => ({
kind: 'connected',
label: 'Connected',
detail: 'Backend and live data stream are connected.',
tone: 'healthy',
})}
dataUpdated={() => false}
lastUpdateText={() => ''}
versionInfo={() =>
({
version: '6.0.0-rc.2',
channel: 'rc',
isDevelopment: false,
isDocker: false,
}) as never
}
hasAuth={() => true}
needsAuth={() => false}
proxyAuthInfo={() => null}
handleLogout={() => {}}
state={() =>
({
activeAlerts: [{ id: 'alert-1', level: 'warning', acknowledged: false }],
}) as unknown as State
}
tokenScopes={() => ['settings:read']}
organizations={() => []}
activeOrgID={() => 'default'}
orgsLoading={() => false}
showOrgSwitcher={() => false}
onSwitchOrg={() => {}}
>
<div>Dashboard body</div>
</AppLayout>
)}
/>
</Router>
));
it('renders fresh utility icons for both desktop and mobile navigation trees', () => {
const { container } = renderLayout();
const desktopNav = screen.getByRole('tablist', { name: 'Primary navigation' });
const systemGroup = desktopNav.querySelector('[aria-label="System"]');
expect(systemGroup).toBeTruthy();
const desktopTabs = within(systemGroup as HTMLElement).getAllByRole('tab');
expect(desktopTabs).toHaveLength(3);
desktopTabs.forEach((tab) => {
expect(tab.querySelector('svg')).toBeTruthy();
});
const mobileTablist = screen.getByRole('tablist', { name: 'Mobile navigation' });
['alerts', 'ai', 'settings'].forEach((tabId) => {
const button = mobileTablist.querySelector<HTMLElement>(`[data-tab-id="${tabId}"]`);
expect(button).toBeTruthy();
expect(button?.querySelector('svg')).toBeTruthy();
});
expect(container).toHaveTextContent('Dashboard body');
});
});

View file

@ -1,9 +1,6 @@
import { For, Show } from 'solid-js';
import { type MobileNavBarProps, useMobileNavBarState } from './useMobileNavBarState';
import {
getMobileNavAlertBadgeCounts,
getMobileNavTabButtonClass,
} from './mobileNavBarModel';
import { getMobileNavAlertBadgeCounts, getMobileNavTabButtonClass } from './mobileNavBarModel';
export type {
MobileNavBarPlatformTab,
@ -13,6 +10,7 @@ export type {
export function MobileNavBar(props: MobileNavBarProps) {
const mobileNav = useMobileNavBarState(props);
const tabIconClass = 'h-4 w-4 shrink-0';
return (
<>
@ -25,36 +23,43 @@ export function MobileNavBar(props: MobileNavBarProps) {
aria-label="Mobile navigation"
>
<For each={mobileNav.orderedPlatformTabs()}>
{(platform) => (
<button
type="button"
data-tab-id={platform.id}
onClick={() => mobileNav.handlePlatformClick(platform)}
title={platform.tooltip}
class={getMobileNavTabButtonClass({
active: props.activeTab() === platform.id,
enabled: platform.enabled,
})}
>
<span class="relative flex items-center justify-center">{platform.icon}</span>
<span class="whitespace-nowrap">{platform.label}</span>
<Show when={!platform.enabled}>
<span class="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700 dark:bg-amber-900 dark:text-amber-200">
Setup
{(platform) => {
const Icon = platform.icon;
return (
<button
type="button"
data-tab-id={platform.id}
onClick={() => mobileNav.handlePlatformClick(platform)}
title={platform.tooltip}
class={getMobileNavTabButtonClass({
active: props.activeTab() === platform.id,
enabled: platform.enabled,
})}
>
<span class="relative flex items-center justify-center">
<Icon class={tabIconClass} />
</span>
</Show>
<Show when={platform.badge}>
<span class="rounded-full bg-surface-hover px-1.5 py-0.5 text-[9px] font-semibold text-muted">
{platform.badge}
</span>
</Show>
</button>
)}
<span class="whitespace-nowrap">{platform.label}</span>
<Show when={!platform.enabled}>
<span class="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700 dark:bg-amber-900 dark:text-amber-200">
Setup
</span>
</Show>
<Show when={platform.badge}>
<span class="rounded-full bg-surface-hover px-1.5 py-0.5 text-[9px] font-semibold text-muted">
{platform.badge}
</span>
</Show>
</button>
);
}}
</For>
<For each={mobileNav.orderedUtilityTabs()}>
{(tab) => {
const alertBadges = () => getMobileNavAlertBadgeCounts(tab);
const Icon = tab.icon;
return (
<button
@ -67,7 +72,7 @@ export function MobileNavBar(props: MobileNavBarProps) {
})}
>
<span class="relative flex items-center justify-center">
{tab.icon}
<Icon class={tabIconClass} />
<Show when={alertBadges()}>
{(badges) => (
<span class="absolute -right-2 -top-1 flex items-center gap-1">

View file

@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { describe, expect, it, vi } from 'vitest';
import type { Component } from 'solid-js';
import mobileNavBarSource from '@/components/shared/MobileNavBar.tsx?raw';
import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?raw';
import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw';
@ -11,6 +12,11 @@ window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
return 1;
}) as typeof window.requestAnimationFrame;
const DashboardIcon: Component<{ class?: string }> = (props) => <span class={props.class}>DB</span>;
const StorageIcon: Component<{ class?: string }> = (props) => <span class={props.class}>ST</span>;
const AlertsIcon: Component<{ class?: string }> = (props) => <span class={props.class}>AL</span>;
const SettingsIcon: Component<{ class?: string }> = (props) => <span class={props.class}>SE</span>;
describe('MobileNavBar', () => {
it('keeps the mobile nav on shell, runtime, and model owners', () => {
expect(mobileNavBarSource).toContain('useMobileNavBarState');
@ -47,7 +53,7 @@ describe('MobileNavBar', () => {
tooltip: 'Storage',
enabled: true,
live: true,
icon: <span>ST</span>,
icon: StorageIcon,
alwaysShow: true,
},
{
@ -58,7 +64,7 @@ describe('MobileNavBar', () => {
tooltip: 'Dashboard',
enabled: true,
live: true,
icon: <span>DB</span>,
icon: DashboardIcon,
alwaysShow: true,
},
]}
@ -71,7 +77,7 @@ describe('MobileNavBar', () => {
badge: 'pro',
count: undefined,
breakdown: undefined,
icon: <span>SE</span>,
icon: SettingsIcon,
},
{
id: 'alerts',
@ -81,7 +87,7 @@ describe('MobileNavBar', () => {
badge: null,
count: 5,
breakdown: { critical: 2, warning: 3 },
icon: <span>AL</span>,
icon: AlertsIcon,
},
]}
onPlatformClick={onPlatformClick}

View file

@ -1,4 +1,6 @@
import type { JSX } from 'solid-js';
import type { Component } from 'solid-js';
export type MobileNavBarIcon = Component<{ class?: string }>;
export type MobileNavBarPlatformTab = {
id: string;
@ -8,7 +10,7 @@ export type MobileNavBarPlatformTab = {
tooltip: string;
enabled: boolean;
live: boolean;
icon: JSX.Element;
icon: MobileNavBarIcon;
alwaysShow: boolean;
badge?: string;
};
@ -21,7 +23,7 @@ export type MobileNavBarUtilityTab = {
badge: 'update' | 'pro' | null;
count: number | undefined;
breakdown: { warning: number; critical: number } | undefined;
icon: JSX.Element;
icon: MobileNavBarIcon;
};
export type MobileNavBarProps = {
@ -91,9 +93,7 @@ export function getMobileNavTabButtonClass(options: {
enabled?: boolean;
}): string {
return `relative flex min-h-10 shrink-0 flex-col items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors ${
options.active
? 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-muted'
options.active ? 'bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'text-muted'
} ${options.enabled === false ? 'opacity-70' : ''}`.trim();
}