Clarify first-run unlock guidance

This commit is contained in:
rcourtman
2026-03-27 10:13:44 +00:00
parent cefcddd14b
commit a17987691c
5 changed files with 277 additions and 60 deletions
@@ -78,50 +78,52 @@ work extends shared components instead of creating new local variants.
48. `frontend-modern/src/features/`
49. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
50. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
51. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
52. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
53. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
54. `frontend-modern/src/components/Settings/SystemLogsPanel.tsx`
55. `frontend-modern/src/components/Settings/useSystemLogsPanelState.ts`
56. `frontend-modern/src/utils/systemLogsPresentation.ts`
57. `frontend-modern/src/components/Settings/__tests__/SystemLogsPanel.test.tsx`
58. `frontend-modern/src/features/operations/OperationsPageSurface.tsx`
59. `frontend-modern/src/features/operations/operationsPageModel.ts`
60. `frontend-modern/src/pages/Operations.tsx`
61. `frontend-modern/src/components/Settings/ResourcePicker.tsx`
62. `frontend-modern/src/components/Settings/reportingResourceTypes.ts`
63. `frontend-modern/src/utils/reportableResourceTypes.ts`
64. `frontend-modern/src/utils/reportingResourceTypes.ts`
65. `frontend-modern/src/utils/problemResourcePresentation.ts`
66. `frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`
67. `frontend-modern/src/utils/dashboardGuestPresentation.ts`
68. `frontend-modern/src/utils/dashboardKpiPresentation.ts`
69. `frontend-modern/src/utils/dashboardTrendPresentation.ts`
70. `frontend-modern/src/components/Toast/Toast.tsx`
71. `frontend-modern/src/utils/toast.ts`
72. `frontend-modern/src/utils/semanticTonePresentation.ts`
73. `frontend-modern/src/utils/emptyStatePresentation.ts`
74. `frontend-modern/src/utils/typeColumnPresentation.ts`
61. `frontend-modern/src/pages/__tests__/Operations.helpers.test.ts`
59. `frontend-modern/src/components/Settings/NetworkDiscoverySection.tsx`
60. `frontend-modern/src/components/Settings/NetworkBoundarySettingsSection.tsx`
61. `frontend-modern/src/components/Settings/networkSettingsModel.ts`
62. `frontend-modern/src/components/Settings/useDiscoverySettingsState.ts`
63. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
64. `frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx`
65. `frontend-modern/src/components/Settings/settingsPanelRegistryLoaders.ts`
66. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
67. `frontend-modern/src/components/Settings/settingsNavCatalog.ts`
68. `frontend-modern/src/components/Settings/settingsNavVisibility.ts`
69. `frontend-modern/src/components/Settings/settingsRouting.ts`
70. `frontend-modern/src/components/Settings/settingsTabSaveBehavior.ts`
71. `frontend-modern/src/components/Settings/settingsTypes.ts`
72. `frontend-modern/src/components/Settings/useSettingsNavigation.ts`
73. `frontend-modern/src/components/Settings/useSettingsPanelRegistry.tsx`
74. `frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx`
75. `frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx`
76. `frontend-modern/src/components/shared/EnvironmentLockBadge.tsx`
77. `frontend-modern/src/utils/environmentLockPresentation.ts`
51. `frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx`
52. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
53. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
54. `frontend-modern/src/components/SetupWizard/__tests__/WelcomeStep.test.tsx`
55. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
56. `frontend-modern/src/components/Settings/SystemLogsPanel.tsx`
57. `frontend-modern/src/components/Settings/useSystemLogsPanelState.ts`
58. `frontend-modern/src/utils/systemLogsPresentation.ts`
59. `frontend-modern/src/components/Settings/__tests__/SystemLogsPanel.test.tsx`
60. `frontend-modern/src/features/operations/OperationsPageSurface.tsx`
61. `frontend-modern/src/features/operations/operationsPageModel.ts`
62. `frontend-modern/src/pages/Operations.tsx`
63. `frontend-modern/src/components/Settings/ResourcePicker.tsx`
64. `frontend-modern/src/components/Settings/reportingResourceTypes.ts`
65. `frontend-modern/src/utils/reportableResourceTypes.ts`
66. `frontend-modern/src/utils/reportingResourceTypes.ts`
67. `frontend-modern/src/utils/problemResourcePresentation.ts`
68. `frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`
69. `frontend-modern/src/utils/dashboardGuestPresentation.ts`
70. `frontend-modern/src/utils/dashboardKpiPresentation.ts`
71. `frontend-modern/src/utils/dashboardTrendPresentation.ts`
72. `frontend-modern/src/components/Toast/Toast.tsx`
73. `frontend-modern/src/utils/toast.ts`
74. `frontend-modern/src/utils/semanticTonePresentation.ts`
75. `frontend-modern/src/utils/emptyStatePresentation.ts`
76. `frontend-modern/src/utils/typeColumnPresentation.ts`
77. `frontend-modern/src/pages/__tests__/Operations.helpers.test.ts`
78. `frontend-modern/src/components/Settings/NetworkDiscoverySection.tsx`
79. `frontend-modern/src/components/Settings/NetworkBoundarySettingsSection.tsx`
80. `frontend-modern/src/components/Settings/networkSettingsModel.ts`
81. `frontend-modern/src/components/Settings/useDiscoverySettingsState.ts`
82. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
83. `frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx`
84. `frontend-modern/src/components/Settings/settingsPanelRegistryLoaders.ts`
85. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
86. `frontend-modern/src/components/Settings/settingsNavCatalog.ts`
87. `frontend-modern/src/components/Settings/settingsNavVisibility.ts`
88. `frontend-modern/src/components/Settings/settingsRouting.ts`
89. `frontend-modern/src/components/Settings/settingsTabSaveBehavior.ts`
90. `frontend-modern/src/components/Settings/settingsTypes.ts`
91. `frontend-modern/src/components/Settings/useSettingsNavigation.ts`
92. `frontend-modern/src/components/Settings/useSettingsPanelRegistry.tsx`
93. `frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx`
94. `frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx`
95. `frontend-modern/src/components/shared/EnvironmentLockBadge.tsx`
96. `frontend-modern/src/utils/environmentLockPresentation.ts`
## Shared Boundaries
@@ -171,13 +173,20 @@ work extends shared components instead of creating new local variants.
completion CTA language aligned with the governed infrastructure install
workspace instead of regressing to a route jump that leaves the next action
implicit.
8. Keep the settings-shell infrastructure landing path aligned with that same
8. Keep the first welcome screen in
`frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx`
explicit about operator context. The shell must explain that the bootstrap
token only unlocks first-run setup, state where the command should run, and
adapt command/help text to detected Docker or containerized deployments
instead of assuming the operator already knows which host or container owns
the Pulse install.
9. Keep the settings-shell infrastructure landing path aligned with that same
first-session story. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
must treat `/settings` and the infrastructure settings tab as the canonical
path to `/settings/infrastructure/install`, not to reporting/control, so
the shell does not send first-time operators to the wrong infrastructure
subview by default.
9. Keep dashboard onboarding copy on the shared presentation owner in
10. Keep dashboard onboarding copy on the shared presentation owner in
`frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`. Both the
infrastructure empty state and the dashboard route's no-resources state
must name the canonical install workspace explicitly and expose the same
@@ -59,7 +59,7 @@ export const SetupWizard: Component<SetupWizardProps> = (props) => {
setCurrentStep('completion');
};
const stepLabels = ['Unlock', 'Security', 'Install'];
const stepLabels = ['Unlock server', 'Security', 'Install host'];
return (
<div class="min-h-screen bg-base flex flex-col" role="main" aria-label="Pulse Setup Wizard">
@@ -47,12 +47,18 @@ describe('SetupWizard', () => {
render(() => <SetupWizard onComplete={onComplete} />);
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' }));
@@ -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(() => (
<WelcomeStep
onNext={vi.fn()}
bootstrapToken=""
setBootstrapToken={vi.fn()}
isUnlocked={false}
setIsUnlocked={vi.fn()}
/>
));
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(() => (
<WelcomeStep
onNext={vi.fn()}
bootstrapToken=""
setBootstrapToken={vi.fn()}
isUnlocked={false}
setIsUnlocked={vi.fn()}
/>
));
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();
});
});
@@ -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<WelcomeStepProps> = (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<WelcomeStepProps> = (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<WelcomeStepProps> = (props) => {
const getTokenCommand = () => {
const path = tokenPath() || '/etc/pulse/.bootstrap_token';
if (isDocker()) {
return `docker exec <container> cat ${path}`;
return `docker exec ${dockerContainerName() || '<pulse-container>'} cat ${path}`;
}
if (inContainer() && lxcCtid()) {
return `pct exec ${lxcCtid()} -- cat ${path}`;
@@ -96,6 +100,42 @@ export const WelcomeStep: Component<WelcomeStepProps> = (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 <pulse-container> 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 (
<div class="text-center relative">
{/* Logo */}
@@ -117,6 +157,36 @@ export const WelcomeStep: Component<WelcomeStepProps> = (props) => {
</p>
</div>
<div class="mb-8 grid gap-3 text-left sm:grid-cols-3">
<div class="rounded-md border border-border bg-surface px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300">
Step 1
</div>
<div class="mt-1 text-sm font-semibold text-base-content">Unlock this Pulse server</div>
<p class="mt-1 text-xs text-muted">
Read the one-time bootstrap token from the system where Pulse is installed.
</p>
</div>
<div class="rounded-md border border-border bg-surface px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300">
Step 2
</div>
<div class="mt-1 text-sm font-semibold text-base-content">Create the admin account</div>
<p class="mt-1 text-xs text-muted">
Set the first login and let Pulse generate the credentials you need to save.
</p>
</div>
<div class="rounded-md border border-border bg-surface px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300">
Step 3
</div>
<div class="mt-1 text-sm font-semibold text-base-content">Install the first host</div>
<p class="mt-1 text-xs text-muted">
Open Infrastructure Install and connect the first system you want Pulse to monitor.
</p>
</div>
</div>
{/* Bootstrap token unlock */}
<Show when={!props.isUnlocked}>
<div class="p-8 max-w-lg mx-auto bg-surface border border-border rounded-md text-left animate-slide-up delay-300 relative group">
@@ -125,10 +195,22 @@ export const WelcomeStep: Component<WelcomeStepProps> = (props) => {
Unlock Setup
</h3>
<p class="text-sm text-muted mb-6">
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:
</p>
<div class="mb-4 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-800 dark:bg-blue-950/40">
<div class="text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300">
Where to run it
</div>
<div class="mt-1 flex items-center gap-2">
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold text-blue-700 dark:bg-blue-900 dark:text-blue-200">
{deploymentLabel()}
</span>
</div>
<p class="mt-2 text-sm text-blue-900 dark:text-blue-100">{deploymentHint()}</p>
</div>
<div class="mb-8">
<div class="bg-base rounded-md p-4 font-mono text-sm text-emerald-400 border border-border-subtle flex items-center justify-between">
<div class="flex items-center space-x-3 overflow-x-auto scrollbar-hide">
@@ -147,6 +229,13 @@ export const WelcomeStep: Component<WelcomeStepProps> = (props) => {
</div>
</div>
<div class="mb-6 rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 dark:border-emerald-800 dark:bg-emerald-950/40">
<div class="text-[11px] font-semibold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
What this token does
</div>
<p class="mt-2 text-sm text-emerald-900 dark:text-emerald-100">{unlockHelp()}</p>
</div>
<div class="space-y-4">
<input
type="text"
@@ -163,12 +252,17 @@ export const WelcomeStep: Component<WelcomeStepProps> = (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
/>
<p class="text-xs text-muted">
After Pulse verifies this token, the next step is creating the admin account for
this server.
</p>
<button
onClick={handleUnlock}
disabled={isValidating() || !props.bootstrapToken.trim()}
@@ -195,10 +289,10 @@ export const WelcomeStep: Component<WelcomeStepProps> = (props) => {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Validating...
Verifying bootstrap token...
</>
) : (
'Continue to Setup →'
'Verify bootstrap token →'
)}
</button>
</div>
@@ -212,7 +306,7 @@ export const WelcomeStep: Component<WelcomeStepProps> = (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 →
</button>
</div>
</Show>