mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
Stabilize first-session settings handoffs
This commit is contained in:
parent
84f627e22a
commit
6feb85245f
29 changed files with 722 additions and 245 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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() &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue