diff --git a/frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx b/frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx index ad9237f4f3..2529562cd8 100644 --- a/frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx +++ b/frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx @@ -47,12 +47,18 @@ describe('SetupWizard', () => { render(() => ); - expect(screen.getByText('Step indicator 0:Unlock > Security > Install')).toBeInTheDocument(); + expect( + screen.getByText('Step indicator 0:Unlock server > Security > Install host'), + ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Welcome next' })); - expect(screen.getByText('Step indicator 1:Unlock > Security > Install')).toBeInTheDocument(); + expect( + screen.getByText('Step indicator 1:Unlock server > Security > Install host'), + ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Security complete' })); - expect(screen.getByText('Step indicator 2:Unlock > Security > Install')).toBeInTheDocument(); + expect( + screen.getByText('Step indicator 2:Unlock server > Security > Install host'), + ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Completion finish' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Completion finish' })); diff --git a/frontend-modern/src/components/SetupWizard/__tests__/WelcomeStep.test.tsx b/frontend-modern/src/components/SetupWizard/__tests__/WelcomeStep.test.tsx new file mode 100644 index 0000000000..2432a3f0e4 --- /dev/null +++ b/frontend-modern/src/components/SetupWizard/__tests__/WelcomeStep.test.tsx @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library'; +import { WelcomeStep } from '../steps/WelcomeStep'; + +const apiFetchMock = vi.fn(); +const apiFetchJSONMock = vi.fn(); +const copyToClipboardMock = vi.fn(); +const showErrorMock = vi.fn(); +const showSuccessMock = vi.fn(); + +vi.mock('@/utils/apiClient', () => ({ + apiFetch: (...args: unknown[]) => apiFetchMock(...args), + apiFetchJSON: (...args: unknown[]) => apiFetchJSONMock(...args), +})); + +vi.mock('@/utils/clipboard', () => ({ + copyToClipboard: (...args: unknown[]) => copyToClipboardMock(...args), +})); + +vi.mock('@/utils/toast', () => ({ + showError: (...args: unknown[]) => showErrorMock(...args), + showSuccess: (...args: unknown[]) => showSuccessMock(...args), +})); + +vi.mock('@/utils/logger', () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('WelcomeStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + copyToClipboardMock.mockResolvedValue(true); + apiFetchJSONMock.mockResolvedValue({ + bootstrapTokenPath: '/etc/pulse/.bootstrap_token', + isDocker: false, + inContainer: false, + lxcCtid: '', + dockerContainerName: '', + }); + }); + + afterEach(() => { + cleanup(); + }); + + it('frames first-run setup as a clear three-step journey', async () => { + render(() => ( + + )); + + await waitFor(() => { + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/security/status'); + }); + + expect(screen.getByText('Unlock this Pulse server')).toBeInTheDocument(); + expect(screen.getByText('Create the admin account')).toBeInTheDocument(); + expect(screen.getByText('Install the first host')).toBeInTheDocument(); + expect(screen.getByText('What this token does')).toBeInTheDocument(); + expect( + screen.getByText( + 'This one-time bootstrap token only unlocks first-run setup on this Pulse server. It is not your admin password and it is not the API token you will use after setup.', + ), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Verify bootstrap token →' })).toBeInTheDocument(); + }); + + it('renders environment-aware Docker unlock guidance when the runtime reports a container name', async () => { + apiFetchJSONMock.mockResolvedValue({ + bootstrapTokenPath: '/srv/pulse/bootstrap.token', + isDocker: true, + inContainer: false, + lxcCtid: '', + dockerContainerName: 'pulse-main', + }); + + render(() => ( + + )); + + await waitFor(() => { + expect(screen.getByText('Docker deployment')).toBeInTheDocument(); + }); + + expect( + screen.getByText( + 'Pulse appears to be running in Docker as container "pulse-main". Run the command on the Docker host so you can read /srv/pulse/bootstrap.token from that container.', + ), + ).toBeInTheDocument(); + expect(screen.getByText('docker exec pulse-main cat /srv/pulse/bootstrap.token')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx b/frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx index 3785908cee..88a36810d0 100644 --- a/frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx +++ b/frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx @@ -1,4 +1,4 @@ -import { Component, createSignal, Show } from 'solid-js'; +import { Component, createMemo, createSignal, onMount, Show } from 'solid-js'; import { showError, showSuccess } from '@/utils/toast'; import { apiFetch, apiFetchJSON } from '@/utils/apiClient'; import { logger } from '@/utils/logger'; @@ -19,6 +19,7 @@ export const WelcomeStep: Component = (props) => { const [isDocker, setIsDocker] = createSignal(false); const [inContainer, setInContainer] = createSignal(false); const [lxcCtid, setLxcCtid] = createSignal(''); + const [dockerContainerName, setDockerContainerName] = createSignal(''); const [copied, setCopied] = createSignal(false); const copyCommand = async () => { @@ -40,20 +41,23 @@ export const WelcomeStep: Component = (props) => { isDocker?: boolean; inContainer?: boolean; lxcCtid?: string; + dockerContainerName?: string; }>('/api/security/status'); if (data?.bootstrapTokenPath) { setTokenPath(data.bootstrapTokenPath); setIsDocker(data.isDocker || false); setInContainer(data.inContainer || false); setLxcCtid(data.lxcCtid || ''); + setDockerContainerName(data.dockerContainerName || ''); } } catch (error) { logger.error('Failed to fetch bootstrap info:', error); } }; - // Call on component load - fetchBootstrapInfo(); + onMount(() => { + void fetchBootstrapInfo(); + }); const handleUnlock = async () => { if (!props.bootstrapToken.trim()) { @@ -85,7 +89,7 @@ export const WelcomeStep: Component = (props) => { const getTokenCommand = () => { const path = tokenPath() || '/etc/pulse/.bootstrap_token'; if (isDocker()) { - return `docker exec cat ${path}`; + return `docker exec ${dockerContainerName() || ''} cat ${path}`; } if (inContainer() && lxcCtid()) { return `pct exec ${lxcCtid()} -- cat ${path}`; @@ -96,6 +100,42 @@ export const WelcomeStep: Component = (props) => { return `cat ${path}`; }; + const deploymentLabel = createMemo(() => { + if (isDocker()) { + return 'Docker deployment'; + } + if (inContainer() && lxcCtid()) { + return 'LXC container'; + } + if (inContainer()) { + return 'Containerized deployment'; + } + return 'Direct host install'; + }); + + const deploymentHint = createMemo(() => { + const path = tokenPath() || '/etc/pulse/.bootstrap_token'; + if (isDocker()) { + return dockerContainerName() + ? `Pulse appears to be running in Docker as container "${dockerContainerName()}". Run the command on the Docker host so you can read ${path} from that container.` + : `Pulse appears to be running in Docker. Run the command on the Docker host and replace with the running Pulse container name.`; + } + if (inContainer() && lxcCtid()) { + return `Pulse appears to be running in LXC container ${lxcCtid()}. Run the command on the Proxmox host so you can execute into that container and read ${path}.`; + } + if (inContainer()) { + return `Pulse appears to be running in a containerized environment. Run the command from the host that manages the container so you can read ${path}.`; + } + return `Run the command directly in a shell on the Pulse server to read ${path}.`; + }); + + const unlockHelp = createMemo(() => { + if (isDocker()) { + return 'This one-time bootstrap token only unlocks first-run setup. After verification, you will create the admin account and Pulse will generate the long-lived API token separately.'; + } + return 'This one-time bootstrap token only unlocks first-run setup on this Pulse server. It is not your admin password and it is not the API token you will use after setup.'; + }); + return (
{/* Logo */} @@ -117,6 +157,36 @@ export const WelcomeStep: Component = (props) => {

+
+
+
+ Step 1 +
+
Unlock this Pulse server
+

+ Read the one-time bootstrap token from the system where Pulse is installed. +

+
+
+
+ Step 2 +
+
Create the admin account
+

+ Set the first login and let Pulse generate the credentials you need to save. +

+
+
+
+ Step 3 +
+
Install the first host
+

+ Open Infrastructure Install and connect the first system you want Pulse to monitor. +

+
+
+ {/* Bootstrap token unlock */}
@@ -125,10 +195,22 @@ export const WelcomeStep: Component = (props) => { Unlock Setup

- Run the following command on the machine where Pulse is installed to retrieve the - secure bootstrap token: + Run the following command on the Pulse server to retrieve the one-time bootstrap + token that unlocks this wizard:

+
+
+ Where to run it +
+
+ + {deploymentLabel()} + +
+

{deploymentHint()}

+
+
@@ -147,6 +229,13 @@ export const WelcomeStep: Component = (props) => {
+
+
+ What this token does +
+

{unlockHelp()}

+
+
= (props) => { }, 400); } }} - onKeyPress={(e) => e.key === 'Enter' && handleUnlock()} + onKeyDown={(e) => e.key === 'Enter' && void handleUnlock()} class="w-full px-5 py-3.5 bg-surface border border-border rounded-md text-base-content placeholder-slate-400 focus:outline-none focus:ring-0 focus:border-blue-500 transition-colors font-mono" placeholder="Paste your bootstrap token" autofocus /> +

+ After Pulse verifies this token, the next step is creating the admin account for + this server. +

+
@@ -212,7 +306,7 @@ export const WelcomeStep: Component = (props) => { onClick={props.onNext} class="py-4 px-10 bg-blue-600 hover:bg-blue-700 text-white text-lg font-medium rounded-md transition-colors duration-200" > - Get Started → + Continue to Security →