mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-20 22:43:31 +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:
parent
fe32dfffb8
commit
d20c556db3
5 changed files with 183 additions and 48 deletions
|
|
@ -3429,6 +3429,12 @@ Intelligence > Provider & Models route
|
|||
route remains a compatibility alias for old deep links, not a href emitted by
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -826,6 +826,13 @@ or other self-hosted uncapped continuity plans.
|
|||
existing Patrol findings and live-approval read model after authentication;
|
||||
it must not read hosted billing state, trigger commercial-posture loading,
|
||||
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
|
||||
launcher around the current monitoring, Patrol, Alerts, or Settings route,
|
||||
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 { preloadRouteModule } from '@/routing/routePreload';
|
||||
import {
|
||||
DOCKER_PATH,
|
||||
KUBERNETES_PATH,
|
||||
PROXMOX_PATH,
|
||||
STANDALONE_PATH,
|
||||
TRUENAS_PATH,
|
||||
VMWARE_PATH,
|
||||
buildDockerPath,
|
||||
buildKubernetesPath,
|
||||
buildProxmoxPath,
|
||||
|
|
@ -56,12 +62,54 @@ const ROOT_VMWARE_PATH = buildVmwarePath();
|
|||
const ROOT_STANDALONE_PATH = buildStandalonePath();
|
||||
const ROOT_INFRASTRUCTURE_SETTINGS_PATH = buildInfrastructureWorkspacePath();
|
||||
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 {
|
||||
const normalized = pathname.replace(/\/+$/, '');
|
||||
if (normalized === buildStandalonePath('availability')) return 'Availability checks';
|
||||
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 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';
|
||||
|
|
@ -258,6 +306,15 @@ export function AppLayout(props: AppLayoutProps) {
|
|||
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(() => {
|
||||
if (kioskMode()) {
|
||||
setHeaderVisible(true);
|
||||
|
|
@ -483,7 +540,7 @@ export function AppLayout(props: AppLayoutProps) {
|
|||
});
|
||||
|
||||
const handlePrimaryClick = (tab: PrimaryTab) => {
|
||||
const targetRoute = tab.enabled ? tab.route : tab.settingsRoute;
|
||||
const targetRoute = resolvePrimaryNavigationRoute(tab, primaryRouteMemory);
|
||||
void (async () => {
|
||||
try {
|
||||
await preloadRouteModule(targetRoute);
|
||||
|
|
@ -521,10 +578,7 @@ export function AppLayout(props: AppLayoutProps) {
|
|||
};
|
||||
|
||||
const getPrimaryTargetRoute = (tab: PrimaryTab) => {
|
||||
if (tab.enabled) {
|
||||
return tab.route;
|
||||
}
|
||||
return tab.settingsRoute;
|
||||
return resolvePrimaryNavigationRoute(tab, primaryRouteMemory);
|
||||
};
|
||||
|
||||
const renderPrimaryNavigationTab = (tab: PrimaryTab) => {
|
||||
|
|
|
|||
|
|
@ -237,6 +237,12 @@ describe('App architecture', () => {
|
|||
expect(appLayoutSource).toContain('settingsRoute: ROOT_INFRASTRUCTURE_SETTINGS_PATH');
|
||||
expect(appLayoutSource).not.toContain("settingsRoute: '/settings/workloads");
|
||||
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('const status = () => props.connectionStatus();');
|
||||
expect(appLayoutSource).toContain(
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import { Route, Router } from '@solidjs/router';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { State } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { AppLayout } from '@/AppLayout';
|
||||
import { AppLayout, resetPrimaryNavigationRouteMemory } from '@/AppLayout';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
window.scrollTo = vi.fn();
|
||||
|
||||
const aiIntelligenceMockState = vi.hoisted(() => ({
|
||||
patrolOpenWorkCount: 0,
|
||||
|
|
@ -20,9 +21,14 @@ vi.mock('@/stores/aiIntelligence', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/routing/routePreload', () => ({
|
||||
preloadRouteModule: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
describe('AppLayout navigation icons', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/settings/infrastructure');
|
||||
resetPrimaryNavigationRouteMemory();
|
||||
aiIntelligenceMockState.patrolOpenWorkCount = 0;
|
||||
aiChatStore.close();
|
||||
aiChatStore.setEnabled(true);
|
||||
|
|
@ -48,51 +54,65 @@ describe('AppLayout navigation icons', () => {
|
|||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
const renderLayout = (resources: Resource[] = []) =>
|
||||
render(() => (
|
||||
const renderLayout = (resources: Resource[] = [], initialPath = '/settings/infrastructure') => {
|
||||
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>
|
||||
<Route
|
||||
path="/settings/infrastructure"
|
||||
component={() => (
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<Route path="/settings/infrastructure" component={LayoutRoute} />
|
||||
<Route path="/proxmox/overview" component={LayoutRoute} />
|
||||
<Route path="/docker/overview" component={LayoutRoute} />
|
||||
</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', () => {
|
||||
const { container } = renderLayout();
|
||||
|
|
@ -193,6 +213,48 @@ describe('AppLayout navigation icons', () => {
|
|||
).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', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue