mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Preserve platform tab route state
Remember each primary platform tab's last route so URL-backed filters survive tab switches without leaking query state across tabs.
This commit is contained in:
@@ -3429,6 +3429,12 @@ Intelligence > Provider & Models route
|
|||||||
route remains a compatibility alias for old deep links, not a href emitted by
|
route remains a compatibility alias for old deep links, not a href emitted by
|
||||||
new Assistant provider-repair actions.
|
new Assistant provider-repair actions.
|
||||||
|
|
||||||
|
Primary platform route memory in `frontend-modern/src/AppLayout.tsx` may
|
||||||
|
preserve platform-local query state such as workload filters, but that memory
|
||||||
|
remains navigation chrome only. It must not fork Assistant drawer state, Patrol
|
||||||
|
utility route state, prompt context, resource reads, commercial posture, or
|
||||||
|
cross-platform query parameters.
|
||||||
|
|
||||||
Rejected Patrol investigation-fix approvals are terminal governed-action
|
Rejected Patrol investigation-fix approvals are terminal governed-action
|
||||||
decisions in the AI runtime. `/api/ai/approvals/{id}/deny` must persist the
|
decisions in the AI runtime. `/api/ai/approvals/{id}/deny` must persist the
|
||||||
approval-store denial, record a rejected unified action-audit decision when the
|
approval-store denial, record a rejected unified action-audit decision when the
|
||||||
|
|||||||
@@ -826,6 +826,13 @@ or other self-hosted uncapped continuity plans.
|
|||||||
existing Patrol findings and live-approval read model after authentication;
|
existing Patrol findings and live-approval read model after authentication;
|
||||||
it must not read hosted billing state, trigger commercial-posture loading,
|
it must not read hosted billing state, trigger commercial-posture loading,
|
||||||
affect organization visibility, or become an upgrade/acquisition cue.
|
affect organization visibility, or become an upgrade/acquisition cue.
|
||||||
|
The same primary platform navigation must remember the last in-tab route
|
||||||
|
state per platform, including query and hash, so route-owned filters such
|
||||||
|
as Proxmox workload status survive switching to another platform tab and
|
||||||
|
back. That memory is chrome route state only: it must validate the route
|
||||||
|
still belongs to the selected platform before reuse and must not become
|
||||||
|
hosted org bootstrap, entitlement, billing, acquisition, or cross-platform
|
||||||
|
query state.
|
||||||
The same AppLayout shell may contextualize the closed Pulse Assistant
|
The same AppLayout shell may contextualize the closed Pulse Assistant
|
||||||
launcher around the current monitoring, Patrol, Alerts, or Settings route,
|
launcher around the current monitoring, Patrol, Alerts, or Settings route,
|
||||||
but that launcher must remain a local product affordance. It must not read
|
but that launcher must remain a local product affordance. It must not read
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ import { logger } from '@/utils/logger';
|
|||||||
import { getActiveTabForPath } from '@/routing/navigation';
|
import { getActiveTabForPath } from '@/routing/navigation';
|
||||||
import { preloadRouteModule } from '@/routing/routePreload';
|
import { preloadRouteModule } from '@/routing/routePreload';
|
||||||
import {
|
import {
|
||||||
|
DOCKER_PATH,
|
||||||
|
KUBERNETES_PATH,
|
||||||
|
PROXMOX_PATH,
|
||||||
|
STANDALONE_PATH,
|
||||||
|
TRUENAS_PATH,
|
||||||
|
VMWARE_PATH,
|
||||||
buildDockerPath,
|
buildDockerPath,
|
||||||
buildKubernetesPath,
|
buildKubernetesPath,
|
||||||
buildProxmoxPath,
|
buildProxmoxPath,
|
||||||
@@ -56,12 +62,54 @@ const ROOT_VMWARE_PATH = buildVmwarePath();
|
|||||||
const ROOT_STANDALONE_PATH = buildStandalonePath();
|
const ROOT_STANDALONE_PATH = buildStandalonePath();
|
||||||
const ROOT_INFRASTRUCTURE_SETTINGS_PATH = buildInfrastructureWorkspacePath();
|
const ROOT_INFRASTRUCTURE_SETTINGS_PATH = buildInfrastructureWorkspacePath();
|
||||||
const ROOT_ALERTS_PATH = '/alerts';
|
const ROOT_ALERTS_PATH = '/alerts';
|
||||||
|
const PRIMARY_ROUTE_PREFIX_BY_ID: Record<PrimaryPlatformNavId, string> = {
|
||||||
|
proxmox: PROXMOX_PATH,
|
||||||
|
docker: DOCKER_PATH,
|
||||||
|
kubernetes: KUBERNETES_PATH,
|
||||||
|
truenas: TRUENAS_PATH,
|
||||||
|
vmware: VMWARE_PATH,
|
||||||
|
standalone: STANDALONE_PATH,
|
||||||
|
};
|
||||||
|
type PrimaryRouteMemory = Partial<Record<PrimaryPlatformNavId, string>>;
|
||||||
|
let primaryRouteMemory: PrimaryRouteMemory = {};
|
||||||
|
|
||||||
function resolveStandaloneSubTabTitle(pathname: string): string {
|
function resolveStandaloneSubTabTitle(pathname: string): string {
|
||||||
const normalized = pathname.replace(/\/+$/, '');
|
const normalized = pathname.replace(/\/+$/, '');
|
||||||
if (normalized === buildStandalonePath('availability')) return 'Availability checks';
|
if (normalized === buildStandalonePath('availability')) return 'Availability checks';
|
||||||
return 'Machines';
|
return 'Machines';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routeBelongsToPrimaryTab(route: string, tabId: PrimaryPlatformNavId): boolean {
|
||||||
|
const prefix = PRIMARY_ROUTE_PREFIX_BY_ID[tabId];
|
||||||
|
const pathname = route.split(/[?#]/, 1)[0]?.replace(/\/+$/, '') || '/';
|
||||||
|
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimaryPlatformNavId(tabId: string | null | undefined): tabId is PrimaryPlatformNavId {
|
||||||
|
return Boolean(tabId && tabId in PRIMARY_ROUTE_PREFIX_BY_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPrimaryRoute(pathname: string, search: string, hash: string): string {
|
||||||
|
return `${pathname}${search}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePrimaryNavigationRoute(
|
||||||
|
tab: PrimaryTab,
|
||||||
|
routeMemory: PrimaryRouteMemory,
|
||||||
|
): string {
|
||||||
|
if (!tab.enabled) {
|
||||||
|
return tab.settingsRoute;
|
||||||
|
}
|
||||||
|
const remembered = routeMemory[tab.id as PrimaryPlatformNavId];
|
||||||
|
if (remembered && routeBelongsToPrimaryTab(remembered, tab.id as PrimaryPlatformNavId)) {
|
||||||
|
return remembered;
|
||||||
|
}
|
||||||
|
return tab.route;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPrimaryNavigationRouteMemory() {
|
||||||
|
primaryRouteMemory = {};
|
||||||
|
}
|
||||||
const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';
|
const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';
|
||||||
const AI_CHAT_LAUNCHER_BUTTON_CLASS =
|
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';
|
'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';
|
||||||
@@ -258,6 +306,15 @@ export function AppLayout(props: AppLayoutProps) {
|
|||||||
return navId ? primaryInfrastructureRouteById[navId] : ROOT_ALERTS_PATH;
|
return navId ? primaryInfrastructureRouteById[navId] : ROOT_ALERTS_PATH;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const activeTab = getActiveTabForPath(location.pathname);
|
||||||
|
if (!isPrimaryPlatformNavId(activeTab)) return;
|
||||||
|
const route = currentPrimaryRoute(location.pathname, location.search, location.hash);
|
||||||
|
if (!routeBelongsToPrimaryTab(route, activeTab)) return;
|
||||||
|
if (primaryRouteMemory[activeTab] === route) return;
|
||||||
|
primaryRouteMemory = { ...primaryRouteMemory, [activeTab]: route };
|
||||||
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (kioskMode()) {
|
if (kioskMode()) {
|
||||||
setHeaderVisible(true);
|
setHeaderVisible(true);
|
||||||
@@ -483,7 +540,7 @@ export function AppLayout(props: AppLayoutProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handlePrimaryClick = (tab: PrimaryTab) => {
|
const handlePrimaryClick = (tab: PrimaryTab) => {
|
||||||
const targetRoute = tab.enabled ? tab.route : tab.settingsRoute;
|
const targetRoute = resolvePrimaryNavigationRoute(tab, primaryRouteMemory);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await preloadRouteModule(targetRoute);
|
await preloadRouteModule(targetRoute);
|
||||||
@@ -521,10 +578,7 @@ export function AppLayout(props: AppLayoutProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPrimaryTargetRoute = (tab: PrimaryTab) => {
|
const getPrimaryTargetRoute = (tab: PrimaryTab) => {
|
||||||
if (tab.enabled) {
|
return resolvePrimaryNavigationRoute(tab, primaryRouteMemory);
|
||||||
return tab.route;
|
|
||||||
}
|
|
||||||
return tab.settingsRoute;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPrimaryNavigationTab = (tab: PrimaryTab) => {
|
const renderPrimaryNavigationTab = (tab: PrimaryTab) => {
|
||||||
|
|||||||
@@ -237,6 +237,12 @@ describe('App architecture', () => {
|
|||||||
expect(appLayoutSource).toContain('settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH');
|
expect(appLayoutSource).toContain('settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH');
|
||||||
expect(appLayoutSource).not.toContain("settingsRoute: '/settings/workloads");
|
expect(appLayoutSource).not.toContain("settingsRoute: '/settings/workloads");
|
||||||
expect(appLayoutSource).not.toContain("settingsRoute: '/settings/infrastructure/platforms");
|
expect(appLayoutSource).not.toContain("settingsRoute: '/settings/infrastructure/platforms");
|
||||||
|
expect(appLayoutSource).toContain('type PrimaryRouteMemory = Partial');
|
||||||
|
expect(appLayoutSource).toContain('let primaryRouteMemory: PrimaryRouteMemory = {};');
|
||||||
|
expect(appLayoutSource).toContain('function resolvePrimaryNavigationRoute(');
|
||||||
|
expect(appLayoutSource).toContain('routeBelongsToPrimaryTab(remembered');
|
||||||
|
expect(appLayoutSource).not.toContain('primaryRouteMemory[props.activeOrgID');
|
||||||
|
expect(appLayoutSource).not.toContain('primaryRouteMemory[activeOrgID');
|
||||||
expect(appLayoutSource).toContain('<OrgSwitcher');
|
expect(appLayoutSource).toContain('<OrgSwitcher');
|
||||||
expect(appLayoutSource).toContain('const status = () => props.connectionStatus();');
|
expect(appLayoutSource).toContain('const status = () => props.connectionStatus();');
|
||||||
expect(appLayoutSource).toContain(
|
expect(appLayoutSource).toContain(
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { Route, Router } from '@solidjs/router';
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { State } from '@/types/api';
|
import type { State } from '@/types/api';
|
||||||
import type { Resource } from '@/types/resource';
|
import type { Resource } from '@/types/resource';
|
||||||
import { AppLayout } from '@/AppLayout';
|
import { AppLayout, resetPrimaryNavigationRouteMemory } from '@/AppLayout';
|
||||||
import { aiChatStore } from '@/stores/aiChat';
|
import { aiChatStore } from '@/stores/aiChat';
|
||||||
|
|
||||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
window.scrollTo = vi.fn();
|
||||||
|
|
||||||
const aiIntelligenceMockState = vi.hoisted(() => ({
|
const aiIntelligenceMockState = vi.hoisted(() => ({
|
||||||
patrolOpenWorkCount: 0,
|
patrolOpenWorkCount: 0,
|
||||||
@@ -20,9 +21,14 @@ vi.mock('@/stores/aiIntelligence', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/routing/routePreload', () => ({
|
||||||
|
preloadRouteModule: vi.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('AppLayout navigation icons', () => {
|
describe('AppLayout navigation icons', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.history.replaceState({}, '', '/settings/infrastructure');
|
window.history.replaceState({}, '', '/settings/infrastructure');
|
||||||
|
resetPrimaryNavigationRouteMemory();
|
||||||
aiIntelligenceMockState.patrolOpenWorkCount = 0;
|
aiIntelligenceMockState.patrolOpenWorkCount = 0;
|
||||||
aiChatStore.close();
|
aiChatStore.close();
|
||||||
aiChatStore.setEnabled(true);
|
aiChatStore.setEnabled(true);
|
||||||
@@ -48,51 +54,65 @@ describe('AppLayout navigation icons', () => {
|
|||||||
...overrides,
|
...overrides,
|
||||||
}) as Resource;
|
}) as Resource;
|
||||||
|
|
||||||
const renderLayout = (resources: Resource[] = []) =>
|
const renderLayout = (resources: Resource[] = [], initialPath = '/settings/infrastructure') => {
|
||||||
render(() => (
|
window.history.replaceState({}, '', initialPath);
|
||||||
|
const LayoutRoute = () => (
|
||||||
|
<AppLayout
|
||||||
|
connectionStatus={() => ({
|
||||||
|
kind: 'connected',
|
||||||
|
label: 'Connected',
|
||||||
|
detail: 'Backend and live data stream are connected.',
|
||||||
|
tone: 'healthy',
|
||||||
|
})}
|
||||||
|
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 }],
|
||||||
|
resources,
|
||||||
|
}) as unknown as State
|
||||||
|
}
|
||||||
|
tokenScopes={() => ['settings:read']}
|
||||||
|
organizations={() => []}
|
||||||
|
activeOrgID={() => 'default'}
|
||||||
|
orgsLoading={() => false}
|
||||||
|
showOrgSwitcher={() => false}
|
||||||
|
onSwitchOrg={() => {}}
|
||||||
|
>
|
||||||
|
<div>Infrastructure body</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
return render(() => (
|
||||||
<Router>
|
<Router>
|
||||||
<Route
|
<Route path="/settings/infrastructure" component={LayoutRoute} />
|
||||||
path="/settings/infrastructure"
|
<Route path="/proxmox/overview" component={LayoutRoute} />
|
||||||
component={() => (
|
<Route path="/docker/overview" component={LayoutRoute} />
|
||||||
<AppLayout
|
|
||||||
connectionStatus={() => ({
|
|
||||||
kind: 'connected',
|
|
||||||
label: 'Connected',
|
|
||||||
detail: 'Backend and live data stream are connected.',
|
|
||||||
tone: 'healthy',
|
|
||||||
})}
|
|
||||||
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 }],
|
|
||||||
resources,
|
|
||||||
}) as unknown as State
|
|
||||||
}
|
|
||||||
tokenScopes={() => ['settings:read']}
|
|
||||||
organizations={() => []}
|
|
||||||
activeOrgID={() => 'default'}
|
|
||||||
orgsLoading={() => false}
|
|
||||||
showOrgSwitcher={() => false}
|
|
||||||
onSwitchOrg={() => {}}
|
|
||||||
>
|
|
||||||
<div>Infrastructure body</div>
|
|
||||||
</AppLayout>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Router>
|
</Router>
|
||||||
));
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInfrastructureTab = (name: string) => {
|
||||||
|
const desktopNav = screen.getByRole('tablist', { name: 'Primary navigation' });
|
||||||
|
const infrastructureGroup = desktopNav.querySelector('[aria-label="Infrastructure"]');
|
||||||
|
expect(infrastructureGroup).toBeTruthy();
|
||||||
|
return within(infrastructureGroup as HTMLElement).getByRole('tab', { name });
|
||||||
|
};
|
||||||
|
|
||||||
|
const platformResources = () => [
|
||||||
|
makeResource({ id: 'pve-1', type: 'agent', platformType: 'proxmox-pve' }),
|
||||||
|
makeResource({ id: 'docker-1', type: 'docker-host', platformType: 'docker' }),
|
||||||
|
];
|
||||||
|
|
||||||
it('renders fresh utility icons for both desktop and mobile navigation trees', () => {
|
it('renders fresh utility icons for both desktop and mobile navigation trees', () => {
|
||||||
const { container } = renderLayout();
|
const { container } = renderLayout();
|
||||||
@@ -193,6 +213,48 @@ describe('AppLayout navigation icons', () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores the previous Proxmox route state when returning from another platform tab', async () => {
|
||||||
|
renderLayout(platformResources(), '/proxmox/overview?status=running');
|
||||||
|
|
||||||
|
await fireEvent.click(getInfrastructureTab('Docker'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.pathname).toBe('/docker/overview');
|
||||||
|
expect(window.location.search).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(getInfrastructureTab('Proxmox'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.pathname).toBe('/proxmox/overview');
|
||||||
|
expect(window.location.search).toBe('?status=running');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps remembered route state scoped to the platform tab that owns it', async () => {
|
||||||
|
renderLayout(platformResources(), '/docker/overview?host=docker-1');
|
||||||
|
|
||||||
|
await fireEvent.click(getInfrastructureTab('Proxmox'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.pathname).toBe('/proxmox/overview');
|
||||||
|
expect(window.location.search).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(getInfrastructureTab('Docker'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.pathname).toBe('/docker/overview');
|
||||||
|
expect(window.location.search).toBe('?host=docker-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the canonical platform root route when there is no remembered route state', async () => {
|
||||||
|
renderLayout(platformResources(), '/settings/infrastructure');
|
||||||
|
|
||||||
|
await fireEvent.click(getInfrastructureTab('Proxmox'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.pathname).toBe('/proxmox/overview');
|
||||||
|
expect(window.location.search).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps connected brand motion on the logo while the wordmark stays static', () => {
|
it('keeps connected brand motion on the logo while the wordmark stays static', () => {
|
||||||
const { container } = renderLayout();
|
const { container } = renderLayout();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user