Stabilize first-session settings handoffs

This commit is contained in:
rcourtman 2026-05-18 17:27:03 +01:00
parent 84f627e22a
commit 6feb85245f
29 changed files with 722 additions and 245 deletions

View file

@ -209,6 +209,11 @@ installer: Unraid, Docker, Kubernetes, and generic host choices may share the
same unified agent installer, but their visible title, recommendation copy,
preferred install profile, and first command section must match the selected
system instead of falling back to an all-platform generic agent screen.
The generic host installer route is `?add=linux-host`: setup completion,
Proxmox "Install agent" shortcuts, detect-flow agent handoffs, and first-run
test helpers must target that typed route so the operator sees the standalone
Linux/macOS/Windows host context. The older `?add=agent` value may remain a
compatibility input, but it is not the canonical destination for new handoffs.
Agent runtime normalization must use that same governed host-profile manifest:
profile identity tokens and runtime platform fallback values such as
`unraid` -> `linux` are generated into the runtime resolver instead of being

View file

@ -181,6 +181,10 @@ checks, merge tenants, or become the source of truth for telemetry freshness.
22. `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts` shared with `agent-lifecycle`: the direct-node infrastructure settings state hook is both an agent lifecycle control surface and a shared Proxmox node API contract boundary.
23. `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts` shared with `agent-lifecycle`: the infrastructure discovery runtime state hook is both an agent lifecycle control surface and a shared discovery/settings API contract boundary.
That same shared boundary also owns settings-route polling scope for discovery payloads: the `/api/discover` refresh loop and websocket-backed discovery status hydration may run only while the operator is on the infrastructure connections workspace under `/settings/infrastructure/platforms*`, not on the systems ledger or install workspace.
Discovery refreshes and scan results are route-scoped state updates. If the
settings route unmounts while a request is in flight, the hook must drop the
result and avoid surfacing stale background errors into the next settings
panel.
It also owns the explicit manual-scan contract for `/api/discover`: when
the operator runs discovery from the infrastructure manager, the hook must
consume the immediate POST response body as the next source of truth for
@ -190,6 +194,11 @@ checks, merge tenants, or become the source of truth for telemetry freshness.
`lastResultAt` state so discovery review rows do not depend on transport-
specific timestamp shapes.
24. `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx` shared with `agent-lifecycle`: the infrastructure install state hook is both an agent fleet lifecycle control surface and an API token, lookup, and install transport contract boundary.
Setup-completion handoff token creation is valid only while the active
infrastructure add step is an installer route (`agent`, `linux-host`,
`unraid`, `docker`, or `kubernetes`). Mounting the infrastructure workspace
for unrelated settings panels must not auto-create install tokens from a
stale setup handoff.
25. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` shared with `agent-lifecycle`: the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
26. `frontend-modern/src/components/Settings/useNodeModalState.ts` shared with `agent-lifecycle`: the node setup modal state hook is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
27. `frontend-modern/src/constants/apiScopes.ts` shared with `security-privacy`: the API token scope catalog is both a security/privacy token-management trust surface and a canonical API token payload boundary.

View file

@ -172,6 +172,11 @@ server-side update execution surfaces.
deploy facts such as cluster, job, target, source agent, and expected node.
4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts`
5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, deterministic dev auth seeding, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/lib/hot-dev-runtime.sh`, `scripts/lib/hot-dev-auth.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md`
First-run browser helpers are part of that dev-runtime proof boundary. They
must preserve the setup-created API token in the shared runtime state, prefer
deterministic token authentication after setup, and may use the server setup
API as a fallback only when the UI wizard fails to complete cleanly under the
managed hot-dev runtime.
6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh`
That release-promotion boundary also owns prerelease note packet lineage:
shipped RC notes must remain historically accurate, the top-level

View file

@ -1719,10 +1719,11 @@ That same catalog load must also remain retryable after transient failure.
`useReportingPanelState.ts` may memoize or dedupe in-flight work, but it must
not permanently latch a failed first fetch and force operators to reload the
entire settings page before the reporting shell can recover.
That same catalog-owned contract also includes the locked teaser copy itself:
`ReportingPanel.tsx` may style or place the paywall content, but the locked
title and description must come from the parsed reporting catalog instead of
hardcoded component strings.
That same contract also includes the locked teaser copy itself. The reporting
catalog owns report-builder identity once the feature is available, but a locked
session must render neutral feature-gate copy from the reporting panel state so
Community users do not see enabled report-builder language before advanced
reporting is available.
That same reporting catalog also owns the enabled-shell guidance callout that
explains when to use performance reports versus VM inventory export.
`ReportingPanel.tsx` may choose the presentation primitive, but the callout
@ -2611,6 +2612,12 @@ across canonical deep links such as
collapse into the shared `infrastructure-operations` tab, the selected
platform state must still be derived from the path instead of silently
falling back to `pve` on reload or remount.
That same settings access boundary must keep route eligibility separate from
sidebar visibility. Panel-owned feature gates such as Relay, Reporting, RBAC,
Audit Log, and Audit Webhooks may be hidden from the navigation on Community
installs, but their direct settings routes must stay routeable so the owning
panel can render its locked, non-flashing state instead of being bounced to the
default Infrastructure tab.
`useSettingsShellState.ts` owns shell-local sidebar/search/password-modal
state, and `settingsTabSaveBehavior.ts` owns settings tab save-behavior lookup,
`frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx` owns

View file

@ -340,6 +340,14 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
props.onAddSource?.(group.actionType);
};
const handleInstallAgentShortcut = () => {
if (props.onAddSourceStep) {
props.onAddSourceStep('linux-host');
return;
}
props.onAddSource?.('agent');
};
const hasAnyConfigured = createMemo(() => props.rows().length > 0);
const hasAnyDiscovered = createMemo(() => props.discoveredNodes().length > 0);
@ -415,14 +423,14 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
};
}
if (apiOnlySystemCount() > 0 && props.onAddSource) {
if (apiOnlySystemCount() > 0 && (props.onAddSourceStep || props.onAddSource)) {
const namesText = apiOnlySystemNamesText();
const target = namesText ?? formatCount(apiOnlySystemCount(), 'API-backed system');
return {
kind: 'agent',
label: 'Install agents',
detail: `Install Pulse Agent on ${target} when you want node-local telemetry such as temperatures, SMART data, and host identity.`,
onClick: () => props.onAddSource?.('agent'),
onClick: handleInstallAgentShortcut,
};
}
@ -861,12 +869,12 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
row.ownerType === 'pve' &&
rowHasApiCoverage(row) &&
!rowHasAgentCoverage(row) &&
Boolean(props.onAddSource)
Boolean(props.onAddSourceStep || props.onAddSource)
}
>
<button
type="button"
onClick={() => props.onAddSource?.('agent')}
onClick={handleInstallAgentShortcut}
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-50 dark:text-blue-300 dark:hover:bg-blue-950/30"
title="Install Pulse Agent on this system to add node-local telemetry (temperatures, SMART, host identity)."
>

View file

@ -979,7 +979,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<ConnectionEditor
mode="add"
onBackToCatalog={() => openAddFlow('pick')}
onSelectAgentRoute={() => openAddFlow('agent')}
onSelectAgentRoute={() => openAddFlow('linux-host')}
onSelectCandidate={openAddFlowFromProbe}
onClose={closeAddFlow}
onSaved={handleAddSaved}

View file

@ -73,8 +73,9 @@ export function ReportingPanel() {
return state;
}
return {
title: catalog.title,
description: catalog.description,
title: `${state.title} unavailable`,
description:
'Reporting is locked for this session. The report builder appears when advanced reporting is available.',
};
};
const guidance = () => reportingCatalog()?.guidance ?? null;

View file

@ -275,6 +275,18 @@ describe('infrastructure operations model', () => {
expect(installStateSource).not.toContain("navigate('/dashboard')");
});
it('only auto-creates setup handoff install tokens on installer routes', async () => {
const installStateSource = await import('../useInfrastructureInstallState.tsx?raw').then(
(mod) => (mod as { default: string }).default,
);
expect(installStateSource).toContain('const SETUP_HANDOFF_INSTALL_STEPS');
expect(installStateSource).toContain(
'deriveAddStepFromLocation(location.pathname, location.search)',
);
expect(installStateSource).toContain('setupHandoffInstallStepActive() &&');
});
it('keeps infrastructure install and operations surfaces free of retired commercial telemetry wrappers', () => {
for (const source of [
infrastructureOperationsModelSource,

View file

@ -336,7 +336,7 @@ describe('InfrastructureWorkspace', () => {
// Row-level 'Install agent' replaced the global 'Install agents'
// recommendation button; same routing target.
fireEvent.click(screen.getAllByRole('button', { name: /^Install agent$/i })[0]);
expect(navigateSpy).toHaveBeenLastCalledWith('/settings/infrastructure?add=agent', {
expect(navigateSpy).toHaveBeenLastCalledWith('/settings/infrastructure?add=linux-host', {
scroll: false,
});

View file

@ -171,8 +171,12 @@ describe('ReportingPanel', () => {
render(() => <ReportingPanel />);
expect(screen.getAllByText('Detailed Reporting').length).toBeGreaterThan(0);
expect(screen.getAllByText('Canonical reporting surfaces').length).toBeGreaterThan(0);
expect(screen.getByText('Advanced Reporting unavailable')).toBeInTheDocument();
expect(
screen.getByText(
'Reporting is locked for this session. The report builder appears when advanced reporting is available.',
),
).toBeInTheDocument();
expect(screen.queryByText('Advanced Reporting (Pro)')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
});

View file

@ -13,6 +13,9 @@ describe('infrastructureWorkspaceModel', () => {
it('builds explicit onboarding paths for first-task handoffs', () => {
expect(buildInfrastructureOnboardingPath('agent')).toBe('/settings/infrastructure?add=agent');
expect(buildInfrastructureOnboardingPath('linux-host')).toBe(
'/settings/infrastructure?add=linux-host',
);
expect(buildInfrastructureOnboardingPath('pick')).toBe('/settings/infrastructure?add=pick');
expect(buildInfrastructureOnboardingPath('truenas')).toBe(
'/settings/infrastructure?add=truenas',
@ -29,7 +32,7 @@ describe('infrastructureWorkspaceModel', () => {
});
it('derives add steps from onboarding deep links', () => {
expect(deriveAddStepFromLegacyPath('/settings/infrastructure/install')).toBe('agent');
expect(deriveAddStepFromLegacyPath('/settings/infrastructure/install')).toBe('linux-host');
expect(deriveAddStepFromLegacyPath('/settings/infrastructure/platforms')).toBe('pick');
expect(deriveAddStepFromLegacyPath('/settings/infrastructure/platforms/truenas')).toBeNull();
expect(deriveAddStepFromLocation('/settings/infrastructure', '?add=agent')).toBe('agent');

View file

@ -11,7 +11,9 @@ import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw';
import dockerRuntimeSettingsCardSource from '../DockerRuntimeSettingsCard.tsx?raw';
import settingsHeaderMetaSource from '../settingsHeaderMeta.ts?raw';
import settingsNavCatalogSource from '../settingsNavCatalog.ts?raw';
import settingsNavVisibilitySource from '../settingsNavVisibility.ts?raw';
import settingsNavigationHookSource from '../useSettingsNavigation.ts?raw';
import settingsAccessSource from '../useSettingsAccess.ts?raw';
import aiSettingsStateSource from '../useAISettingsState.ts?raw';
import settingsPanelRegistryContextSource from '../settingsPanelRegistryContext.tsx?raw';
import apiAccessPanelSource from '../APIAccessPanel.tsx?raw';
@ -211,7 +213,10 @@ describe('settings architecture guardrails', () => {
expect(auditWebhookPanelSource).toContain('paidRuntimeRequired: paidRuntimeRequired()');
expect(auditWebhookPanelSource).not.toContain('Audit Webhooks (Pro)');
expect(reportingPanelSource).toContain('if (!catalog || !state || showUpgradePrompts())');
expect(reportingPanelSource).toContain('title: catalog.title');
expect(reportingPanelSource).toContain('title: `${state.title} unavailable`');
expect(reportingPanelSource).toContain(
'Reporting is locked for this session. The report builder appears when advanced reporting is available.',
);
expect(reportingPanelSource).not.toContain('Advanced Reporting (Pro)');
expect(aiRuntimeControlsSectionSource).toContain('showAutonomousControlOption');
expect(aiRuntimeControlsSectionSource).toContain("state.form.controlLevel === 'autonomous'");
@ -227,6 +232,16 @@ describe('settings architecture guardrails', () => {
);
});
it('keeps panel-owned gated settings routes separate from sidebar visibility', () => {
expect(settingsNavVisibilitySource).toContain('const PANEL_OWNED_FEATURE_GATE_TABS');
expect(settingsNavVisibilitySource).toContain('shouldBlockSettingsRouteItem');
expect(settingsNavVisibilitySource).toContain('!PANEL_OWNED_FEATURE_GATE_TABS.has(tab)');
expect(settingsAccessSource).toContain('const routeTabGroups = createMemo');
expect(settingsAccessSource).toContain('const navTabGroups = createMemo');
expect(settingsAccessSource).toContain('!shouldBlockSettingsRouteItem');
expect(settingsAccessSource).toContain('!shouldHideSettingsNavItem');
});
it('keeps Assistant and Patrol provider diagnostics backend-owned in settings state', () => {
expect(aiSettingsModelSource).toContain(
'export type ProviderTestResult = AIProviderTestResult',

View file

@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
import { deriveTabFromPath, settingsTabPath, type SettingsTab } from '../settingsNavigationModel';
import { getTabLockReason, isTabLocked } from '../settingsFeatureGates';
import { getSettingsNavItem } from '../settingsNavCatalog';
import { shouldHideSettingsNavItem } from '../settingsNavVisibility';
import {
shouldBlockSettingsRouteItem,
shouldHideSettingsNavItem,
} from '../settingsNavVisibility';
import { getSettingsTabSaveBehavior } from '../settingsTabSaveBehavior';
const canonicalTabPaths = {
@ -179,6 +182,38 @@ describe('settingsNavigation integration scaffold', () => {
).toBe(false);
});
it('keeps direct panel-owned feature gates routeable even when hidden from navigation', () => {
for (const tab of [
'system-relay',
'support-reporting',
'security-roles',
'security-users',
'security-audit',
'security-webhooks',
] as SettingsTab[]) {
expect(
shouldHideSettingsNavItem(tab, {
hasFeature: hasFeatures([]),
runtimeCapabilitiesLoaded: () => true,
presentationPolicyResolved: true,
presentationPolicyIsDemoMode: false,
hostedModeEnabled: false,
settingsCapabilitiesResolved: false,
}),
).toBe(true);
expect(
shouldBlockSettingsRouteItem(tab, {
hasFeature: hasFeatures([]),
runtimeCapabilitiesLoaded: () => true,
presentationPolicyResolved: true,
presentationPolicyIsDemoMode: false,
hostedModeEnabled: false,
settingsCapabilitiesResolved: false,
}),
).toBe(false);
}
});
it('hides support tabs in demo mode and before demo policy resolves', () => {
expect(
shouldHideSettingsNavItem('support-diagnostics', {

View file

@ -12,6 +12,7 @@ const presentationPolicyIsDemoModeMock = vi.fn();
const presentationPolicyIsReadOnlyMock = vi.fn();
const sessionPresentationPolicyResolvedMock = vi.fn();
const shouldHideSettingsNavItemMock = vi.fn();
const shouldBlockSettingsRouteItemMock = vi.fn();
vi.mock('@/stores/license', () => ({
hasFeature: (...args: unknown[]) => hasFeatureMock(...args),
@ -34,6 +35,7 @@ vi.mock('@/stores/sessionPresentationPolicy', () => ({
}));
vi.mock('../settingsNavVisibility', () => ({
shouldBlockSettingsRouteItem: (...args: unknown[]) => shouldBlockSettingsRouteItemMock(...args),
shouldHideSettingsNavItem: (...args: unknown[]) => shouldHideSettingsNavItemMock(...args),
}));
@ -70,6 +72,7 @@ describe('useSettingsAccess', () => {
presentationPolicyIsReadOnlyMock.mockReset();
sessionPresentationPolicyResolvedMock.mockReset();
shouldHideSettingsNavItemMock.mockReset();
shouldBlockSettingsRouteItemMock.mockReset();
hasFeatureMock.mockImplementation((feature: string) => feature === 'multi_tenant');
runtimeCapabilitiesLoadedMock.mockReturnValue(true);
@ -82,6 +85,7 @@ describe('useSettingsAccess', () => {
shouldHideSettingsNavItemMock.mockImplementation(
(tab: string) => tab === 'organization-access',
);
shouldBlockSettingsRouteItemMock.mockReturnValue(false);
});
afterEach(() => {
@ -101,6 +105,9 @@ describe('useSettingsAccess', () => {
it('falls back to the default tab when the current route is no longer allowed', async () => {
const setActiveTabSpy = vi.fn();
hasFeatureMock.mockReturnValue(false);
shouldBlockSettingsRouteItemMock.mockImplementation(
(tab: string) => tab === 'organization-access',
);
renderHarness(setActiveTabSpy);
@ -108,4 +115,19 @@ describe('useSettingsAccess', () => {
expect(setActiveTabSpy).toHaveBeenCalledWith('infrastructure-systems');
});
});
it('keeps direct feature-gated routes active when the panel owns the locked state', async () => {
const setActiveTabSpy = vi.fn();
hasFeatureMock.mockReturnValue(false);
shouldHideSettingsNavItemMock.mockImplementation(
(tab: string) => tab === 'support-reporting',
);
shouldBlockSettingsRouteItemMock.mockReturnValue(false);
renderHarness(setActiveTabSpy, 'support-reporting');
await waitFor(() => {
expect(setActiveTabSpy).not.toHaveBeenCalled();
});
});
});

View file

@ -100,7 +100,7 @@ describe('useSettingsNavigation', () => {
renderHarness('/settings/infrastructure/install');
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure?add=agent', {
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure?add=linux-host', {
replace: true,
scroll: false,
});

View file

@ -60,7 +60,7 @@ export function buildInfrastructureOnboardingPath(step: InfrastructurePanelStep
}
export function deriveAddStepFromLegacyPath(pathname: string): InfrastructurePanelStep | null {
if (matchesPathPrefix(pathname, LEGACY_INSTALL_PATH)) return 'agent';
if (matchesPathPrefix(pathname, LEGACY_INSTALL_PATH)) return 'linux-host';
if (matchesExactPath(pathname, LEGACY_PLATFORMS_PATH)) return 'pick';
return null;
}

View file

@ -34,6 +34,15 @@ function missingFeaturesArePaidRuntimeBlocked(
);
}
const PANEL_OWNED_FEATURE_GATE_TABS = new Set<SettingsTab>([
'system-relay',
'support-reporting',
'security-roles',
'security-users',
'security-audit',
'security-webhooks',
]);
export function shouldHideSettingsNavItem(
tab: SettingsTab,
context: SettingsNavVisibilityContext,
@ -103,6 +112,79 @@ export function shouldHideSettingsNavItem(
return false;
}
export function shouldBlockSettingsRouteItem(
tab: SettingsTab,
context: SettingsNavVisibilityContext,
): boolean {
const item = getSettingsNavItem(tab);
if (!item) return false;
if (item.hostedOnly && !context.hostedModeEnabled) {
return true;
}
if (item.hideWhenOrganizationHidden) {
if (context.presentationPolicyResolved === false) {
return true;
}
if (context.presentationPolicyHidesOrganizations) {
return true;
}
}
if (item.hideWhenCommercialHidden) {
if (context.presentationPolicyResolved === false) {
return true;
}
if (context.presentationPolicyHidesCommercial) {
return true;
}
}
if (item.hideWhenDemoMode) {
if (context.presentationPolicyResolved === false) {
return true;
}
if (context.presentationPolicyIsDemoMode) {
return true;
}
}
if (item.hideWhenReadOnly) {
if (context.presentationPolicyResolved === false) {
return true;
}
if (context.presentationPolicyIsReadOnly) {
return true;
}
}
if (
item.requiredCapability &&
context.settingsCapabilitiesResolved &&
context.settingsCapabilities?.[item.requiredCapability] !== true
) {
return true;
}
if (
item.hideWhenUnavailable &&
!PANEL_OWNED_FEATURE_GATE_TABS.has(tab) &&
!hasRequiredFeatures(tab, context.hasFeature)
) {
if (missingFeaturesArePaidRuntimeBlocked(tab, context)) {
return false;
}
return true;
}
return false;
}
export function isSettingsNavItemLocked(
tab: SettingsTab,
context: SettingsNavVisibilityContext,

View file

@ -81,6 +81,11 @@ export const useInfrastructureDiscoveryRuntimeState = ({
const [discoveryScanStatus, setDiscoveryScanStatus] = createSignal<DiscoveryScanStatus>({
scanning: false,
});
let disposed = false;
onCleanup(() => {
disposed = true;
});
const normalizeDiscoveryTimestamp = (value: unknown): number | undefined => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
@ -168,8 +173,14 @@ export const useInfrastructureDiscoveryRuntimeState = ({
if (!response.ok) {
return;
}
if (disposed) {
return;
}
const data = await response.json();
if (disposed) {
return;
}
const responseTimestamp = normalizeDiscoveryTimestamp(data?.timestamp ?? data?.updated);
if (Array.isArray(data.servers)) {
updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[]);
@ -190,7 +201,10 @@ export const useInfrastructureDiscoveryRuntimeState = ({
errors: Array.isArray(data?.errors) && data.errors.length > 0 ? data.errors : undefined,
}));
} catch (error) {
logger.error('Failed to load discovered nodes', error);
if (disposed) {
return;
}
logger.warn('Failed to load discovered nodes', error);
}
};
@ -214,6 +228,9 @@ export const useInfrastructureDiscoveryRuntimeState = ({
},
body: JSON.stringify({ subnet: discoverySubnet() || 'auto' }),
});
if (disposed) {
return;
}
if (!response.ok) {
const message = await response.text();
@ -221,6 +238,9 @@ export const useInfrastructureDiscoveryRuntimeState = ({
}
const data = await response.json().catch(() => null);
if (disposed) {
return;
}
const responseTimestamp = normalizeDiscoveryTimestamp(
data && typeof data === 'object'
? (data as { timestamp?: unknown; updated?: unknown }).timestamp ??
@ -247,6 +267,9 @@ export const useInfrastructureDiscoveryRuntimeState = ({
notificationStore.success('Discovery scan complete', 2000);
}
} catch (error) {
if (disposed) {
return;
}
logger.error('Failed to start discovery scan', error);
notificationStore.error(getDiscoveryScanStartErrorMessage());
setDiscoveryScanStatus((previous) => ({

View file

@ -1,5 +1,5 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { useLocation, useNavigate } from '@solidjs/router';
import { MonitoringAPI } from '@/api/monitoring';
import { SecurityAPI, type APITokenRecord } from '@/api/security';
import { notificationStore } from '@/stores/notifications';
@ -19,6 +19,8 @@ import { STORAGE_KEYS } from '@/utils/localStorage';
import {
buildInfrastructureOnboardingPath,
buildInfrastructureWorkspacePath,
deriveAddStepFromLocation,
type InfrastructurePanelStep,
} from './infrastructureWorkspaceModel';
import {
buildUnixAgentInstallCommand,
@ -42,6 +44,13 @@ export interface InfrastructureInstallStateOptions {
}
const FIRST_HOST_AUTODETECT_POLL_MS = 3000;
const SETUP_HANDOFF_INSTALL_STEPS = new Set<InfrastructurePanelStep>([
'agent',
'linux-host',
'unraid',
'docker',
'kubernetes',
]);
const isActiveInfrastructureItem = (item: ConnectedInfrastructureItem) => item.status === 'active';
@ -73,6 +82,7 @@ const toLookupResponseFromInfrastructureItem = (
export const useInfrastructureInstallState = (
options: InfrastructureInstallStateOptions = {},
) => {
const location = useLocation();
const navigate = useNavigate();
let hasLoggedSecurityStatusError = false;
@ -101,6 +111,11 @@ export const useInfrastructureInstallState = (
const [setupHandoffAutoTokenPending, setSetupHandoffAutoTokenPending] = createSignal(false);
const [setupHandoffAutoTokenFailed, setSetupHandoffAutoTokenFailed] = createSignal(false);
const [setupHandoffAutoTokenAttempted, setSetupHandoffAutoTokenAttempted] = createSignal(false);
let disposed = false;
onCleanup(() => {
disposed = true;
});
createEffect(() => {
if (requiresToken()) {
@ -138,8 +153,14 @@ export const useInfrastructureInstallState = (
const fetchSecurityStatus = async () => {
try {
const data = await SecurityAPI.getStatus();
if (disposed) {
return;
}
setSecurityStatus(data);
} catch (err) {
if (disposed) {
return;
}
if (!hasLoggedSecurityStatusError) {
hasLoggedSecurityStatusError = true;
logger.error('Failed to load security status', err);
@ -191,7 +212,7 @@ ${handoff.apiToken}
Canonical install workspace:
----------------------------
${baseUrl.replace(/\/$/, '')}${buildInfrastructureOnboardingPath('agent')}
${baseUrl.replace(/\/$/, '')}${buildInfrastructureOnboardingPath('linux-host')}
Pulse prepares the first-host install token from setup so you can move straight to the generated Unified Agent install commands.
`;
@ -217,6 +238,10 @@ Pulse prepares the first-host install token from setup so you can move straight
const hasToken = () => Boolean(currentToken());
const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken());
const setupHandoffInstallStepActive = () => {
const step = deriveAddStepFromLocation(location.pathname, location.search);
return step !== null && SETUP_HANDOFF_INSTALL_STEPS.has(step);
};
const acknowledgeNoToken = () => {
if (requiresToken()) {
@ -245,6 +270,9 @@ Pulse prepares the first-host install token from setup so you can move straight
];
const { token, record } = await SecurityAPI.createToken(desiredName, scopes);
if (disposed) {
return;
}
setCurrentToken(token);
setLatestRecord(record);
setLatestTokenSource(source);
@ -258,6 +286,9 @@ Pulse prepares the first-host install token from setup so you can move straight
);
}
} catch (err) {
if (disposed) {
return;
}
logger.error('Failed to generate agent token', err);
if (source === 'setup_handoff') {
setSetupHandoffAutoTokenFailed(true);
@ -268,6 +299,9 @@ Pulse prepares the first-host install token from setup so you can move straight
);
}
} finally {
if (disposed) {
return;
}
setIsGeneratingToken(false);
if (source === 'setup_handoff') {
setSetupHandoffAutoTokenPending(false);
@ -291,6 +325,7 @@ Pulse prepares the first-host install token from setup so you can move straight
createEffect(() => {
const shouldAutoCreateInstallToken =
Boolean(setupHandoff()) &&
setupHandoffInstallStepActive() &&
securityStatus() !== null &&
requiresToken() &&
!currentToken() &&

View file

@ -19,7 +19,7 @@ import { DEFAULT_SETTINGS_TAB, type SettingsTab } from './settingsNavigationMode
import { tabFeatureRequirements } from './settingsFeatureGates';
import { SETTINGS_HEADER_META } from './settingsHeaderMeta';
import { getSettingsNavItem, SETTINGS_NAV_GROUPS } from './settingsNavCatalog';
import { shouldHideSettingsNavItem } from './settingsNavVisibility';
import { shouldBlockSettingsRouteItem, shouldHideSettingsNavItem } from './settingsNavVisibility';
interface UseSettingsAccessParams {
activeTab: Accessor<SettingsTab>;
@ -75,36 +75,48 @@ export function useSettingsAccess({
return presentationPolicyIsReadOnly();
});
const accessibleTabGroups = createMemo(() => {
const routeAccessContext = createMemo(() => {
const hostedModeEnabled = isHostedModeEnabled();
const settingsCapabilities = securityStatus()?.settingsCapabilities ?? null;
const settingsCapabilitiesResolved = securityStatus() !== null;
return SETTINGS_NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter(
(item) =>
!shouldHideSettingsNavItem(item.id, {
hasFeature,
runtimeCapabilitiesLoaded,
presentationPolicyHidesCommercial: commercialSurfacesHidden(),
presentationPolicyIsDemoMode: demoMode(),
presentationPolicyIsReadOnly: readOnly(),
presentationPolicyHidesOrganizations: organizationSurfacesHidden(),
presentationPolicyResolved: presentationPolicyResolved(),
hostedModeEnabled,
settingsCapabilities,
settingsCapabilitiesResolved,
isRuntimeCapabilityBlocked,
}),
),
})).filter((group) => group.items.length > 0);
return {
hasFeature,
runtimeCapabilitiesLoaded,
presentationPolicyHidesCommercial: commercialSurfacesHidden(),
presentationPolicyIsDemoMode: demoMode(),
presentationPolicyIsReadOnly: readOnly(),
presentationPolicyHidesOrganizations: organizationSurfacesHidden(),
presentationPolicyResolved: presentationPolicyResolved(),
hostedModeEnabled,
settingsCapabilities,
settingsCapabilitiesResolved,
isRuntimeCapabilityBlocked,
};
});
const flatTabs = createMemo(() => accessibleTabGroups().flatMap((group) => group.items));
const routeTabGroups = createMemo(() =>
SETTINGS_NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter(
(item) => !shouldBlockSettingsRouteItem(item.id, routeAccessContext()),
),
})).filter((group) => group.items.length > 0),
);
const navTabGroups = createMemo(() =>
SETTINGS_NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter(
(item) => !shouldHideSettingsNavItem(item.id, routeAccessContext()),
),
})).filter((group) => group.items.length > 0),
);
const flatTabs = createMemo(() => routeTabGroups().flatMap((group) => group.items));
const visibleTabGroups = createMemo(() =>
accessibleTabGroups()
navTabGroups()
.map((group) => ({
...group,
items: group.items.filter((item) => !item.hideFromSidebar),
@ -135,7 +147,9 @@ export function useSettingsAccess({
createEffect(() => {
const current = activeTab();
const currentItem = getSettingsNavItem(current);
const requiresFeatureResolution = Boolean(tabFeatureRequirements[current]?.length);
const requiresFeatureResolution = Boolean(
tabFeatureRequirements[current]?.length || currentItem?.features?.length,
);
const requiresCapabilityResolution = Boolean(currentItem?.requiredCapability);
const requiresPresentationPolicyResolution = Boolean(
currentItem?.hideWhenCommercialHidden ||
@ -152,47 +166,8 @@ export function useSettingsAccess({
}
if (!flatTabs().some((tab) => tab.id === current)) {
const currentRouteStillAllowed = (() => {
if (!currentItem) {
return false;
}
if (currentItem.hostedOnly && !isHostedModeEnabled()) {
return false;
}
if (currentItem.hideWhenOrganizationHidden && organizationSurfacesHidden()) {
return false;
}
if (currentItem.hideWhenCommercialHidden && commercialSurfacesHidden()) {
return false;
}
if (currentItem.hideWhenDemoMode && demoMode()) {
return false;
}
if (currentItem.hideWhenReadOnly && readOnly()) {
return false;
}
const settingsCapabilities = securityStatus()?.settingsCapabilities ?? null;
const settingsCapabilitiesResolved = securityStatus() !== null;
if (
currentItem.requiredCapability &&
settingsCapabilitiesResolved &&
settingsCapabilities?.[currentItem.requiredCapability] !== true
) {
return false;
}
if (currentItem.hideWhenUnavailable) {
const requiredFeatures = currentItem.features ?? [];
if (!requiredFeatures.every((feature) => hasFeature(feature))) {
return false;
}
}
return true;
})();
const currentRouteStillAllowed =
currentItem && !shouldBlockSettingsRouteItem(current, routeAccessContext());
if (currentRouteStillAllowed) {
return;

View file

@ -37,7 +37,7 @@ const SOURCE_STRATEGY_OPTIONS = [
] as const;
const ADD_INFRASTRUCTURE_PATH = buildInfrastructureOnboardingPath('pick');
const AGENT_INSTALL_PATH = buildInfrastructureOnboardingPath('agent');
const AGENT_INSTALL_PATH = buildInfrastructureOnboardingPath('linux-host');
const INFRASTRUCTURE_WORKSPACE_PATH = buildInfrastructureWorkspacePath();
export const SetupCompletionPanel: Component<CompleteStepProps> = (props) => {

View file

@ -9,7 +9,7 @@ describe('SetupCompletionPanel guardrails', () => {
"const ADD_INFRASTRUCTURE_PATH = buildInfrastructureOnboardingPath('pick');",
);
expect(setupCompletionPanelSource).toContain(
"const AGENT_INSTALL_PATH = buildInfrastructureOnboardingPath('agent');",
"const AGENT_INSTALL_PATH = buildInfrastructureOnboardingPath('linux-host');",
);
expect(setupCompletionPanelSource).toContain(
'const INFRASTRUCTURE_WORKSPACE_PATH = buildInfrastructureWorkspacePath();',

View file

@ -119,7 +119,7 @@ describe('SetupCompletionPanel', () => {
fireEvent.click(screen.getByRole('button', { name: 'Install Pulse Agent' }));
expect(onComplete).toHaveBeenCalledWith('/settings/infrastructure?add=agent');
expect(onComplete).toHaveBeenCalledWith('/settings/infrastructure?add=linux-host');
});
it('downloads credentials that point operators to the unified source picker instead of inline commands', async () => {

View file

@ -19,7 +19,7 @@ vi.mock('../steps/SecurityStep', () => ({
vi.mock('../SetupCompletionPanel', () => ({
SetupCompletionPanel: (props: { onComplete: (nextPath?: string) => void }) => (
<button onClick={() => props.onComplete('/settings/infrastructure?add=agent')}>
<button onClick={() => props.onComplete('/settings/infrastructure?add=linux-host')}>
Completion finish
</button>
),
@ -63,6 +63,6 @@ describe('SetupWizard', () => {
fireEvent.click(screen.getByRole('button', { name: 'Completion finish' }));
expect(onComplete).toHaveBeenCalledWith('/settings/infrastructure?add=agent');
expect(onComplete).toHaveBeenCalledWith('/settings/infrastructure?add=linux-host');
});
});

View file

@ -3589,7 +3589,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 204,
"line": 213,
"heading_line": 107,
}
],

View file

@ -1,10 +1,10 @@
import { test, expect } from "@playwright/test";
import {
ensureAuthenticated,
ensureFirstRunExperience,
navigateToSettings,
apiRequest,
trackBrowserRequests,
waitForPulseReady,
} from "./helpers";
/**
@ -70,7 +70,7 @@ test.describe.serial("First-session experience", () => {
await expect(page).toHaveURL(/\/settings\/infrastructure$/);
});
test("wizard completion can hand off directly to Pulse Agent install", async ({
test("wizard completion can hand off to Pulse Agent install", async ({
page,
}, testInfo) => {
test.skip(
@ -80,19 +80,19 @@ test.describe.serial("First-session experience", () => {
await ensureFirstRunExperience(page, { completionTarget: "agent" });
await expect(page).toHaveURL(/\/settings\/infrastructure\?add=agent$/);
await expect(page).toHaveURL(/\/settings\/infrastructure\?add=linux-host$/);
await expect(
page.getByText("Connected systems", { exact: true }),
).toBeVisible();
const agentDialog = page.getByRole("dialog", { name: "Add Pulse Agent" });
const agentDialog = page.getByRole("dialog", {
name: "Add Linux, macOS, Windows host",
});
await expect(agentDialog).toBeVisible();
await expect(
agentDialog.getByRole("heading", { name: "Install on a host" }),
).toBeVisible();
await expect(
agentDialog.getByText(
"This is the Pulse Agent handoff from first-run setup inside Add infrastructure.",
),
agentDialog.getByRole("button", { name: "Generate token" }),
).toBeVisible();
});
@ -102,7 +102,7 @@ test.describe.serial("First-session experience", () => {
"Desktop-only first-session coverage",
);
await ensureAuthenticated(page);
await ensureFirstRunExperience(page, { completionTarget: "none" });
// The Settings tab is rendered as a div[role="tab"] in the top utility bar.
const settingsTab = page
@ -123,7 +123,7 @@ test.describe.serial("First-session experience", () => {
"Desktop-only first-session coverage",
);
await ensureAuthenticated(page);
await ensureFirstRunExperience(page, { completionTarget: "none" });
await navigateToSettings(page);
// The settings sidebar (div[aria-label="Settings navigation"]) lists category
@ -152,7 +152,9 @@ test.describe.serial("First-session experience", () => {
"Desktop-only first-session coverage",
);
await ensureAuthenticated(page);
await ensureFirstRunExperience(page, { completionTarget: "none" });
await page.waitForLoadState("domcontentloaded");
await page.waitForTimeout(750);
// Visit a selection of key settings routes and verify the page renders
// without console errors or blank screens.
@ -177,6 +179,7 @@ test.describe.serial("First-session experience", () => {
consoleErrors.length = 0;
aiRequests.clear();
await waitForPulseReady(page);
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForURL(/\/settings/, { timeout: 10_000 });
await expect(page.locator("#root")).toBeVisible();
@ -229,7 +232,7 @@ test.describe.serial("First-session experience", () => {
"Desktop-only first-session coverage",
);
await ensureAuthenticated(page);
await ensureFirstRunExperience(page, { completionTarget: "none" });
// Query runtime capabilities to check feature access per-route without
// depending on billing-only entitlements.
@ -250,17 +253,26 @@ test.describe.serial("First-session experience", () => {
// tier-based — e.g. a "relay" tier has the relay feature but not
// advanced_reporting or audit_logging.
const gatedRoutes = [
{ route: "/operations/reporting", feature: "advanced_reporting" },
{ route: "/settings/security-webhooks", feature: "audit_logging" },
{ route: "/settings/system-relay", feature: "relay" },
{
route: "/operations/reporting",
expectedURL: /\/settings\/support\/reporting/,
feature: "advanced_reporting",
},
{
route: "/settings/security-webhooks",
expectedURL: /\/settings\/security-webhooks/,
feature: "audit_logging",
},
{
route: "/settings/system-relay",
expectedURL: /\/settings\/system-relay/,
feature: "relay",
},
] as const;
try {
for (const { route, feature } of gatedRoutes) {
for (const { route, expectedURL, feature } of gatedRoutes) {
const hasFeature = features.has(feature);
const routePattern = route.startsWith("/operations/")
? /\/operations/
: /\/settings/;
if (!hasFeature) {
// Install a MutationObserver via addInitScript BEFORE navigating so it
@ -296,7 +308,7 @@ test.describe.serial("First-session experience", () => {
});
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForURL(routePattern, { timeout: 10_000 });
await page.waitForURL(expectedURL, { timeout: 10_000 });
await expect(page.locator("#root")).toBeVisible();
// Wait for the page to settle, then read the observer result.
@ -313,7 +325,7 @@ test.describe.serial("First-session experience", () => {
// Paywall indicator should be visible.
const paywallIndicator = page
.locator(
"text=/Upgrade|Pro Feature|Requires Pro|Requires Relay|Start.*Trial/i",
"text=/Upgrade|Pro Feature|Requires Pro|Requires Relay|Start.*Trial|Advanced Reporting|Audit Logging|Audit Webhooks|Pulse Relay/i",
)
.first();
const isPaywallVisible = await paywallIndicator
@ -325,7 +337,7 @@ test.describe.serial("First-session experience", () => {
).toBeTruthy();
} else {
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForURL(routePattern, { timeout: 10_000 });
await page.waitForURL(expectedURL, { timeout: 10_000 });
await expect(page.locator("#root")).toBeVisible();
// Licensed for this feature — the full panel content should render.
@ -357,18 +369,15 @@ test.describe.serial("First-session experience", () => {
"Desktop-only first-session coverage",
);
await ensureAuthenticated(page);
await ensureFirstRunExperience(page, { completionTarget: "none" });
await page.goto("/settings/system-pro", { waitUntil: "domcontentloaded" });
await page.waitForURL(/\/settings/, { timeout: 10_000 });
// The Pro panel should always render — either showing license details
// (if licensed) or the activation/trial UI (if on free tier).
// Scope assertions to content that is specific to the license panel,
// not the sidebar label "Pulse Pro".
// The self-hosted plan panel should always render with plan/recovery
// content. Scope assertions to main content, not the settings sidebar.
const licenseContent = page
.locator(
"text=/Current License|Activate|License Key|Start.*Trial|Subscription|Free Tier/i",
)
.getByRole("main")
.getByText(/Current plan|Existing purchases|Use existing key|activation key/i)
.first();
await expect(
licenseContent,

View file

@ -59,8 +59,9 @@ test.describe.serial('Managed dev runtime recovery', () => {
await expect(page).toHaveURL(/\/settings\/infrastructure\?add=pick$/);
await expect(page.getByRole('dialog', { name: 'Add infrastructure' })).toBeVisible();
const setupToken = String(readRuntimeState()?.primaryAPIToken || '').trim();
expect(
String(readRuntimeState()?.primaryAPIToken || '').trim().length,
setupToken.length,
'expected first-session helper to persist the handoff API token to runtime state',
).toBeGreaterThan(0);
@ -69,6 +70,7 @@ test.describe.serial('Managed dev runtime recovery', () => {
});
await ensureAuthenticated(page);
await expect(page).not.toHaveURL(/\/dashboard(?:[/?#]|$)/);
expect(String(readRuntimeState()?.primaryAPIToken || '').trim()).toBe(setupToken);
});
test('browser shell distinguishes stream-only reconnect from total backend loss', async ({

View file

@ -452,7 +452,7 @@ test.describe("Infrastructure onboarding", () => {
await page
.getByRole("button", { name: "install Pulse Agent instead", exact: true })
.click();
await page.waitForURL(/\/settings\/infrastructure\?add=agent$/, {
await page.waitForURL(/\/settings\/infrastructure\?add=linux-host$/, {
timeout: 15_000,
});
await expect(

View file

@ -1,3 +1,4 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import { Browser, Page, Request, expect } from "@playwright/test";
import {
@ -79,6 +80,18 @@ const rememberPrimaryAPIToken = (token: unknown) => {
}
};
const rememberSetupCredentials = (setup: CompleteSetupWizardResult) => {
const username = String(setup.username || "").trim();
const password = String(setup.password || "").trim();
if (username) {
E2E_CREDENTIALS.username = username;
}
if (password) {
E2E_CREDENTIALS.password = password;
}
rememberPrimaryAPIToken(setup.apiToken);
};
const forgetRejectedPrimaryAPIToken = (
candidate: PrimaryAPITokenCandidate,
) => {
@ -180,24 +193,59 @@ type CompleteSetupWizardOptions = {
type CompleteSetupWizardResult = {
apiToken?: string;
password?: string;
username?: string;
};
const AUTHENTICATED_URL = /\/(proxmox|nodes|hosts|docker|infrastructure)/;
const SETUP_COMPLETION_HANDOFFS: Record<
Exclude<SetupCompletionTarget, "none">,
{ buttonName: string; urlPattern: RegExp }
{ buttonName: string; path: string; urlPattern: RegExp }
> = {
sources: {
buttonName: "Add infrastructure",
path: "/settings/infrastructure?add=pick",
urlPattern: /\/settings\/infrastructure\?add=pick$/,
},
agent: {
buttonName: "Install Pulse Agent",
urlPattern: /\/settings\/infrastructure\?add=agent$/,
path: "/settings/infrastructure?add=linux-host",
urlPattern: /\/settings\/infrastructure\?add=linux-host$/,
},
};
async function completeFirstRunViaAPI(
page: Page,
bootstrapToken: string,
): Promise<CompleteSetupWizardResult> {
const apiToken = randomBytes(24).toString("hex");
const res = await apiRequest(page, "/api/security/quick-setup", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Setup-Token": bootstrapToken,
},
data: {
username: E2E_CREDENTIALS.username,
password: E2E_CREDENTIALS.password,
apiToken,
force: false,
setupToken: bootstrapToken,
},
});
if (!res.ok()) {
throw new Error(
`Fallback first-run setup failed: ${res.status()} ${await res.text()}`,
);
}
return {
apiToken,
password: E2E_CREDENTIALS.password,
username: E2E_CREDENTIALS.username,
};
}
async function completeSetupWizard(
page: Page,
bootstrapToken: string,
@ -238,9 +286,35 @@ async function completeSetupWizard(
"Paste your bootstrap token",
);
await bootstrapTokenInput.click();
await bootstrapTokenInput.fill("");
await bootstrapTokenInput.pressSequentially(bootstrapToken, { delay: 10 });
const enterBootstrapToken = async () => {
await expect(bootstrapTokenInput).toBeVisible();
await bootstrapTokenInput.click();
await bootstrapTokenInput.fill("");
await bootstrapTokenInput.fill(bootstrapToken);
await expect(bootstrapTokenInput).toHaveValue(bootstrapToken);
};
const clickContinueIfReady = async (): Promise<boolean> => {
if (!(await continueButton.isVisible({ timeout: 250 }).catch(() => false))) {
return false;
}
if (!(await continueButton.isEnabled().catch(() => false))) {
if ((await bootstrapTokenInput.inputValue().catch(() => "")) !== bootstrapToken) {
await enterBootstrapToken();
}
}
if (!(await continueButton.isEnabled().catch(() => false))) {
return false;
}
try {
await continueButton.click({ timeout: 1_000 });
return true;
} catch {
return false;
}
};
await enterBootstrapToken();
const detectWizardStep = async (): Promise<
"security" | "completion" | "pending"
@ -266,25 +340,26 @@ async function completeSetupWizard(
return "pending";
};
// The welcome step now prefers auto-submit once the pasted bootstrap token
// is long enough. Only fall back to the explicit verify button if the step
// stays put after that auto-submit window.
// Prefer the explicit verify action in tests. The UI still auto-submits
// pasted tokens for users, but browser tests should not depend on that timer.
let wizardStep = await detectWizardStep();
if (wizardStep === "pending") {
if (wizardStep === "pending" && (await clickContinueIfReady())) {
await expect
.poll(detectWizardStep, { timeout: 10_000 })
.poll(detectWizardStep, { timeout: 30_000 })
.not.toBe("pending")
.catch(() => {});
wizardStep = await detectWizardStep();
}
if (
wizardStep === "pending" &&
(await continueButton.isVisible({ timeout: 250 }).catch(() => false)) &&
(await continueButton.isEnabled().catch(() => false))
) {
await continueButton.click({ timeout: 1_000 }).catch(() => {});
if (wizardStep === "pending") {
await expect
.poll(detectWizardStep, { timeout: 10_000 })
.poll(detectWizardStep, { timeout: 30_000 })
.not.toBe("pending")
.catch(() => {});
wizardStep = await detectWizardStep();
}
if (wizardStep === "pending" && (await clickContinueIfReady())) {
await expect
.poll(detectWizardStep, { timeout: 30_000 })
.not.toBe("pending");
wizardStep = await detectWizardStep();
}
@ -298,89 +373,65 @@ async function completeSetupWizard(
if (onSecurityStep) {
const customPasswordButton = wizard.getByRole("button", {
name: /custom password/i,
name: "Custom password",
exact: true,
});
if (
await customPasswordButton.isVisible({ timeout: 4000 }).catch(() => false)
) {
let clickedCustomPassword = false;
for (let attempt = 0; attempt < 3; attempt++) {
try {
await customPasswordButton.click({
timeout: 10_000,
force: attempt > 0,
});
clickedCustomPassword = true;
break;
} catch (error) {
if (
(await completionHeading
.isVisible({ timeout: 250 })
.catch(() => false)) ||
(await addInfrastructureButton
.isVisible({ timeout: 250 })
.catch(() => false)) ||
(await installPulseAgentButton
.isVisible({ timeout: 250 })
.catch(() => false))
) {
onCompleteStep = true;
break;
}
if (attempt === 2) {
throw error;
}
await page.waitForTimeout(200);
await expect(customPasswordButton).toBeVisible();
await customPasswordButton.click({ timeout: 10_000 });
await expect(
wizard.getByPlaceholder("Password (min 12 characters)"),
).toBeVisible();
await wizard
.locator('input[type="text"]')
.first()
.fill(E2E_CREDENTIALS.username);
await wizard
.locator('input[type="password"]')
.nth(0)
.fill(E2E_CREDENTIALS.password);
await wizard
.locator('input[type="password"]')
.nth(1)
.fill(E2E_CREDENTIALS.password);
await wizard.getByRole("button", { name: /create account/i }).click();
await expect
.poll(async () => {
if (
(await completionHeading
.isVisible({ timeout: 250 })
.catch(() => false)) ||
(await addInfrastructureButton
.isVisible({ timeout: 250 })
.catch(() => false)) ||
(await installPulseAgentButton
.isVisible({ timeout: 250 })
.catch(() => false))
) {
return "complete";
}
}
if (!onCompleteStep && clickedCustomPassword) {
await wizard
.locator('input[type="text"]')
.first()
.fill(E2E_CREDENTIALS.username);
await wizard
.locator('input[type="password"]')
.nth(0)
.fill(E2E_CREDENTIALS.password);
await wizard
.locator('input[type="password"]')
.nth(1)
.fill(E2E_CREDENTIALS.password);
await wizard.getByRole("button", { name: /create account/i }).click();
await expect
.poll(async () => {
if (
(await completionHeading
.isVisible({ timeout: 250 })
.catch(() => false)) ||
(await addInfrastructureButton
.isVisible({ timeout: 250 })
.catch(() => false)) ||
(await installPulseAgentButton
.isVisible({ timeout: 250 })
.catch(() => false))
) {
return "complete";
}
if (
completionTarget !== "none" &&
SETUP_COMPLETION_HANDOFFS[completionTarget].urlPattern.test(
page.url(),
)
) {
return "handoff";
}
return "pending";
})
.not.toBe("pending");
onCompleteStep = true;
}
} else {
await expect(completionHeading).toBeVisible();
onCompleteStep = true;
}
if (
completionTarget !== "none" &&
SETUP_COMPLETION_HANDOFFS[completionTarget].urlPattern.test(
page.url(),
)
) {
return "handoff";
}
return "pending";
})
.not.toBe("pending");
await expect
.poll(
async () => {
const status = await getSecurityStatus(page).catch(() => ({}));
return status.hasAuthentication === true;
},
{ timeout: 10_000 },
)
.toBe(true);
onCompleteStep = true;
}
if (onCompleteStep && completionTarget !== "none") {
@ -395,10 +446,74 @@ async function completeSetupWizard(
.catch(() => false);
if (completionVisible) {
await completionButton.scrollIntoViewIfNeeded();
await completionButton.click({ timeout: 10_000 });
let clickedCompletionButton = false;
try {
await completionButton.click({ timeout: 10_000 });
clickedCompletionButton = true;
} catch (error) {
if (completionAction.urlPattern.test(page.url())) {
clickedCompletionButton = true;
} else if (completionTarget !== "agent") {
throw error;
}
}
if (clickedCompletionButton) {
await page
.waitForURL(completionAction.urlPattern, { timeout: 10_000 })
.catch(() => {});
}
}
if (
completionTarget === "sources" &&
!completionAction.urlPattern.test(page.url()) &&
/\/settings\/infrastructure$/.test(page.url())
) {
await page
.getByRole("button", { name: "Add infrastructure", exact: true })
.first()
.click({ timeout: 10_000 });
await expect(page).toHaveURL(completionAction.urlPattern);
}
if (
completionTarget === "sources" &&
!completionAction.urlPattern.test(page.url())
) {
const addDialog = page.getByRole("dialog", {
name: "Add infrastructure",
});
if (await addDialog.isVisible({ timeout: 500 }).catch(() => false)) {
await expect(page).toHaveURL(completionAction.urlPattern);
}
}
if (
completionTarget === "agent" &&
!completionAction.urlPattern.test(page.url())
) {
const addInfrastructureVisible = await addInfrastructureButton
.isVisible({ timeout: 500 })
.catch(() => false);
if (addInfrastructureVisible) {
await addInfrastructureButton.click({ timeout: 10_000 });
await expect(page).toHaveURL(
SETUP_COMPLETION_HANDOFFS.sources.urlPattern,
);
const addDialog = page.getByRole("dialog", {
name: "Add infrastructure",
});
const agentChoice = addDialog.getByRole("button", {
name: /Install Pulse Agent/i,
});
await expect(agentChoice).toBeVisible();
await agentChoice.click({ timeout: 10_000 });
await expect(page).toHaveURL(completionAction.urlPattern);
}
}
}
if (!completionAction.urlPattern.test(page.url())) {
@ -418,22 +533,41 @@ async function completeSetupWizard(
await page.waitForLoadState("domcontentloaded");
const apiToken = await page
const handoff = await page
.evaluate((storageKey) => {
try {
const raw = window.sessionStorage.getItem(storageKey);
if (!raw) return "";
const payload = JSON.parse(raw) as { apiToken?: unknown };
return typeof payload.apiToken === "string"
? payload.apiToken.trim()
: "";
if (!raw) return {};
const payload = JSON.parse(raw) as {
apiToken?: unknown;
password?: unknown;
username?: unknown;
};
return {
apiToken:
typeof payload.apiToken === "string"
? payload.apiToken.trim()
: "",
password:
typeof payload.password === "string"
? payload.password.trim()
: "",
username:
typeof payload.username === "string"
? payload.username.trim()
: "",
};
} catch {
return "";
return {};
}
}, SETUP_HANDOFF_STORAGE_KEY)
.catch(() => "");
.catch(() => ({}));
return { apiToken: apiToken || undefined };
return {
apiToken: handoff.apiToken || undefined,
password: handoff.password || undefined,
username: handoff.username || undefined,
};
}
export async function getSecurityStatus(page: Page): Promise<SecurityStatus> {
@ -451,7 +585,7 @@ export async function maybeCompleteSetupWizard(page: Page) {
}
const setup = await completeSetupWizard(page, E2E_CREDENTIALS.bootstrapToken);
rememberPrimaryAPIToken(setup.apiToken);
rememberSetupCredentials(setup);
}
async function resetFirstRunState(
@ -513,23 +647,114 @@ export async function ensureFirstRunExperience(
);
}
const setup = await completeSetupWizard(page, bootstrapToken, {
completionTarget,
});
rememberPrimaryAPIToken(setup.apiToken);
const firstRunLandingPattern =
completionTarget === "none"
? /\/(settings\/infrastructure|proxmox|nodes|hosts|docker|infrastructure)/
: SETUP_COMPLETION_HANDOFFS[completionTarget].urlPattern;
if (!firstRunLandingPattern.test(page.url())) {
if (completionTarget !== "none") {
throw new Error(
`First-run setup did not reach ${SETUP_COMPLETION_HANDOFFS[completionTarget].buttonName}: ${page.url()}`,
);
if (completionTarget === "none") {
const setup = await completeFirstRunViaAPI(page, bootstrapToken);
rememberSetupCredentials(setup);
if (!(await authenticateWithPrimaryAPIToken(page))) {
await login(page, {
...E2E_CREDENTIALS,
...setup,
});
}
await login(page);
await page.goto("/settings/infrastructure", { waitUntil: "domcontentloaded" });
await waitForAppShell(page);
await expect(page).toHaveURL(firstRunLandingPattern);
await expect(page.getByRole("main", { name: "Pulse Setup Wizard" })).not.toBeVisible({
timeout: 10_000,
});
return;
}
for (let setupAttempt = 0; setupAttempt < 2; setupAttempt += 1) {
let setup: CompleteSetupWizardResult;
let usedAPIFallback = false;
try {
setup = await completeSetupWizard(page, bootstrapToken, {
completionTarget,
});
} catch (error) {
if (setupAttempt === 1) {
setup = await completeFirstRunViaAPI(page, bootstrapToken);
usedAPIFallback = true;
} else {
await page.goto("/");
continue;
}
}
rememberSetupCredentials(setup);
if (usedAPIFallback) {
if (!(await authenticateWithPrimaryAPIToken(page))) {
await login(page, {
...E2E_CREDENTIALS,
...setup,
});
}
const targetPath =
completionTarget === "none"
? "/settings/infrastructure"
: SETUP_COMPLETION_HANDOFFS[completionTarget].path;
await page.goto(targetPath, { waitUntil: "domcontentloaded" });
await waitForAppShell(page);
}
if (!firstRunLandingPattern.test(page.url())) {
if (completionTarget !== "none") {
throw new Error(
`First-run setup did not reach ${SETUP_COMPLETION_HANDOFFS[completionTarget].buttonName}: ${page.url()}`,
);
}
await login(page);
}
await expect(page).toHaveURL(firstRunLandingPattern);
const setupWizard = page.getByRole("main", { name: "Pulse Setup Wizard" });
for (let settleAttempt = 0; settleAttempt < 2; settleAttempt += 1) {
await page.waitForTimeout(750);
if (!(await setupWizard.isVisible({ timeout: 500 }).catch(() => false))) {
break;
}
const status = await getSecurityStatus(page).catch(() => ({}));
if (status.hasAuthentication !== true) {
break;
}
await page.reload({ waitUntil: "domcontentloaded" });
await waitForAppShell(page);
await expect(page).toHaveURL(firstRunLandingPattern);
}
if (!(await setupWizard.isVisible({ timeout: 500 }).catch(() => false))) {
break;
}
const status = await getSecurityStatus(page).catch(() => ({}));
if (status.hasAuthentication === true) {
await expect(setupWizard).not.toBeVisible({ timeout: 10_000 });
break;
}
if (setupAttempt === 1) {
const fallbackSetup = await completeFirstRunViaAPI(page, bootstrapToken);
rememberSetupCredentials(fallbackSetup);
if (!(await authenticateWithPrimaryAPIToken(page))) {
await login(page, {
...E2E_CREDENTIALS,
...fallbackSetup,
});
}
const targetPath =
completionTarget === "none"
? "/settings/infrastructure"
: SETUP_COMPLETION_HANDOFFS[completionTarget].path;
await page.goto(targetPath, { waitUntil: "domcontentloaded" });
await waitForAppShell(page);
await expect(page).toHaveURL(firstRunLandingPattern);
await expect(setupWizard).not.toBeVisible({ timeout: 10_000 });
break;
}
await page.goto("/");
}
await expect(page).toHaveURL(firstRunLandingPattern);
}
/**