Refine infrastructure onboarding flow

This commit is contained in:
rcourtman
2026-04-23 15:49:54 +01:00
parent 42d657caf5
commit 211f84d49d
6 changed files with 92 additions and 98 deletions
@@ -3,7 +3,6 @@ import type { ProbeCandidate } from '@/api/connections';
import { formControl, formField, formHelpText, formLabel } from '@/components/shared/Form';
import type { CompletedProbePhase, ConnectionEditorState } from './useConnectionEditor';
import { CONNECTION_TYPE_LABELS } from './useConnectionEditor';
import { getInfrastructureAutoDetectLabels } from '@/utils/infrastructureOnboardingPresentation';
export interface AddressProbeStepProps {
state: ConnectionEditorState;
@@ -26,8 +25,6 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
props.onProbeResolved?.(outcome);
};
const autoDetectLabels = getInfrastructureAutoDetectLabels();
return (
<form class="space-y-4" onSubmit={handleSubmit}>
<div class={formField}>
@@ -49,15 +46,6 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
Paste a hostname, IP, or URL to identify a supported platform. Pulse validates the match
and asks for credentials next.
</p>
<div class="mt-2 flex flex-wrap gap-1.5">
<For each={autoDetectLabels}>
{(label) => (
<span class="inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-[11px] font-medium text-base-content">
{label}
</span>
)}
</For>
</div>
</div>
<div class="flex items-center gap-2">
@@ -93,7 +81,7 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
</button>
<span>, or if this is </span>
</Show>
bare-metal Linux / Unraid / FreeBSD,{' '}
a Linux, macOS, Windows, FreeBSD, or Unraid host,{' '}
<Show
when={props.onInstallAgent}
fallback={<span class="font-medium">install Pulse Agent instead</span>}
@@ -6,10 +6,7 @@ import {
createConnectionEditorState,
type ConnectionEditorState,
} from './useConnectionEditor';
import {
INFRASTRUCTURE_ONBOARDING_STEPS,
getInfrastructureAutoDetectLabels,
} from '@/utils/infrastructureOnboardingPresentation';
import { getInfrastructureAutoDetectLabels } from '@/utils/infrastructureOnboardingPresentation';
import {
createInfrastructureOnboardingMetricsTracker,
type InfrastructureOnboardingMetricsTracker,
@@ -31,10 +28,13 @@ export interface ConnectionEditorProps {
mode?: ConnectionEditorMode;
initialType?: ConnectionType;
initialAddress?: string;
initialCandidate?: ProbeCandidate | null;
showSlotHeader?: boolean;
trackInitialCatalogSelection?: boolean;
onboardingMetricsTracker?: InfrastructureOnboardingMetricsTracker | null;
onBackToCatalog?: () => void;
onSelectAgentRoute?: () => void;
onSelectCandidate?: (candidate: ProbeCandidate) => void;
renderCredentialSlot: CredentialSlotRenderer;
onClose: () => void;
onSaved?: () => void;
@@ -49,7 +49,9 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
const [selectedType, setSelectedType] = createSignal<ConnectionType | null>(
props.initialType ?? null,
);
const [selectedCandidate, setSelectedCandidate] = createSignal<ProbeCandidate | null>(null);
const [selectedCandidate, setSelectedCandidate] = createSignal<ProbeCandidate | null>(
props.initialCandidate ?? null,
);
const ownsOnboardingMetricsTracker =
(props.mode ?? 'add') === 'add' && !props.onboardingMetricsTracker;
const onboardingMetrics =
@@ -86,6 +88,10 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
const chooseCandidate = (candidate: ProbeCandidate) => {
onboardingMetrics?.recordPathSelected('api');
if (props.onSelectCandidate) {
props.onSelectCandidate(candidate);
return;
}
setSelectedCandidate(candidate);
setSelectedType(candidate.type);
};
@@ -96,6 +102,15 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
setSelectedType(type);
};
const installAgent = () => {
if (props.onSelectAgentRoute) {
onboardingMetrics?.recordPathSelected('agent');
props.onSelectAgentRoute();
return;
}
chooseManualType('agent');
};
const reopenProbe = () => {
state.reset();
setSelectedCandidate(null);
@@ -119,74 +134,51 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
when={showCredentialSlot()}
fallback={
<div class="space-y-6 p-4">
<section class="rounded-xl border border-border bg-surface-alt p-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<section class="space-y-4 rounded-xl border border-border bg-surface p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">Detect from address</div>
<p class="text-sm text-muted">
Enter a hostname, IP, or URL and Pulse will try to identify a supported
platform automatically before opening the matching credential form.
<div class="text-sm font-semibold text-base-content">Address probe</div>
<p class="text-xs text-muted">
Pulse can auto-detect these platforms from an address when their management API
is reachable.
</p>
</div>
<Show when={props.onBackToCatalog}>
<button
type="button"
onClick={props.onBackToCatalog}
class="inline-flex items-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
class="inline-flex items-center self-start rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Back to source types
</button>
</Show>
</div>
</section>
<section class="space-y-4 rounded-xl border border-border bg-surface p-4">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">Address probe</div>
<p class="text-xs text-muted">
Pulse can auto-detect these platforms from an address when their management API
is reachable.
</p>
</div>
<div class="flex flex-wrap gap-1.5">
<For each={autoDetectLabels()}>{(label) => renderBadge(label)}</For>
</div>
<p class="text-xs text-muted">
Not in this list?{' '}
<button
type="button"
onClick={installAgent}
class="font-medium text-blue-600 underline underline-offset-2 hover:text-blue-500 dark:text-blue-300 dark:hover:text-blue-200"
>
Install Pulse Agent
</button>{' '}
for Linux, macOS, Windows, FreeBSD, or Unraid hosts.
</p>
<AddressProbeStep
state={state}
onSelectCandidate={chooseCandidate}
onInstallAgent={() => chooseManualType('agent')}
onInstallAgent={installAgent}
onChooseSourceTypeInstead={props.onBackToCatalog}
onProbeSubmitted={() => onboardingMetrics?.recordPathSelected('api')}
onProbeResolved={(outcome) => onboardingMetrics?.recordProbeResult(outcome)}
/>
</section>
<section class="rounded-xl border border-border bg-surface-alt p-4">
<div class="space-y-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">What happens next</div>
<p class="text-xs text-muted">
Pulse validates the connection before the system lands in the shared
infrastructure ledger.
</p>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-5">
<For each={INFRASTRUCTURE_ONBOARDING_STEPS}>
{(step, index) => (
<div class="rounded-lg border border-border bg-surface px-3 py-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-muted">
Step {index() + 1}
</div>
<div class="mt-1 text-sm font-medium text-base-content">{step}</div>
</div>
)}
</For>
</div>
</div>
</section>
</div>
}
>
@@ -214,6 +206,24 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
</div>
</Show>
<Show
when={
!(props.showSlotHeader ?? true) &&
(props.mode ?? 'add') === 'add' &&
props.onBackToCatalog
}
>
<div class="border-b border-border bg-surface-alt px-4 py-2">
<button
type="button"
onClick={props.onBackToCatalog}
class="inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Back to source types
</button>
</div>
</Show>
<div class="flex-1 overflow-y-auto p-4">
{props.renderCredentialSlot({
mode: props.mode ?? 'add',
@@ -82,10 +82,9 @@ describe('ConnectionEditor', () => {
/>
));
expect(screen.getByText('Detect from address')).toBeInTheDocument();
expect(screen.getByText('Address probe')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Back to source types/i })).toBeInTheDocument();
expect(screen.getByText('What happens next')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Back to source types/i }));
expect(onBackToCatalog).toHaveBeenCalledTimes(1);
@@ -164,7 +163,7 @@ describe('ConnectionEditor', () => {
const resetInput = screen.getByPlaceholderText(/vcenter\.lab/) as HTMLInputElement;
expect(resetInput.value).toBe('');
expect(screen.queryByTestId('slot')).toBeNull();
expect(screen.getByText('Detect from address')).toBeInTheDocument();
expect(screen.getByText('Address probe')).toBeInTheDocument();
});
it('uses an injected tracker for direct type routes without creating another one', async () => {
@@ -28,28 +28,18 @@ export const InfrastructureSourcePicker: Component<InfrastructureSourcePickerPro
return (
<div class="space-y-6 p-4">
<section class="rounded-xl border border-border bg-surface-alt p-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">Choose a source type</div>
<p class="text-sm text-muted">
Add the kind of infrastructure you want Pulse to connect. Existing sources stay
visible on the page behind this dialog so you can open one, close it, and keep
managing the same list.
</p>
</div>
<Show when={props.onDetectFromAddress}>
<button
type="button"
onClick={props.onDetectFromAddress}
class={detectButtonClass}
>
<Search class="mr-2 h-4 w-4" />
Detect from address
</button>
</Show>
<Show when={props.onDetectFromAddress}>
<div class="flex justify-end">
<button
type="button"
onClick={props.onDetectFromAddress}
class={detectButtonClass}
>
<Search class="mr-2 h-4 w-4" />
Detect from address
</button>
</div>
</section>
</Show>
<For each={groups()}>
{(group) => (
@@ -17,6 +17,7 @@ export interface InfrastructureOnboardingProductPresentation {
catalogDescription: string;
autoDetect: boolean;
governanceState: PlatformGovernanceState;
defaultSurfaceKeys: readonly string[];
}
export interface InfrastructureSourceManagerProductPresentation extends InfrastructureOnboardingProductPresentation {
@@ -37,6 +38,7 @@ interface BaseProductPresentation {
catalogDescription: string;
autoDetect: boolean;
sourcePlatformId?: string;
defaultSurfaceKeys: readonly string[];
}
export interface InfrastructureOnboardingPathPresentation {
@@ -57,6 +59,7 @@ const PRODUCT_PRESENTATION: Record<
coverage: 'Low-overhead host telemetry, SMART, services, Docker, and Kubernetes',
catalogDescription: 'Low-overhead host telemetry, services, Docker, Kubernetes',
autoDetect: false,
defaultSurfaceKeys: ['host'],
},
vmware: {
label: 'VMware vCenter',
@@ -65,6 +68,7 @@ const PRODUCT_PRESENTATION: Record<
catalogDescription: 'VM inventory, ESXi hosts, datastores',
autoDetect: true,
sourcePlatformId: 'vmware-vsphere',
defaultSurfaceKeys: ['vms', 'hosts', 'datastores'],
},
truenas: {
label: 'TrueNAS SCALE',
@@ -73,6 +77,7 @@ const PRODUCT_PRESENTATION: Record<
catalogDescription: 'Pools, datasets, apps, replications',
autoDetect: true,
sourcePlatformId: 'truenas',
defaultSurfaceKeys: ['datasets', 'pools', 'replication'],
},
pve: {
label: 'Proxmox VE',
@@ -82,6 +87,7 @@ const PRODUCT_PRESENTATION: Record<
catalogDescription: 'VMs, containers, storage, cluster health',
autoDetect: true,
sourcePlatformId: 'proxmox-pve',
defaultSurfaceKeys: ['vms', 'containers', 'storage', 'backups'],
},
pbs: {
label: 'Proxmox Backup Server',
@@ -90,6 +96,7 @@ const PRODUCT_PRESENTATION: Record<
catalogDescription: 'Backup jobs, sync, verify, prune, GC',
autoDetect: true,
sourcePlatformId: 'proxmox-pbs',
defaultSurfaceKeys: ['backups', 'datastores', 'syncJobs', 'verifyJobs', 'pruneJobs', 'garbageJobs'],
},
pmg: {
label: 'Proxmox Mail Gateway',
@@ -98,6 +105,7 @@ const PRODUCT_PRESENTATION: Record<
catalogDescription: 'Mail stats, queues, quarantine, relay health',
autoDetect: true,
sourcePlatformId: 'proxmox-pmg',
defaultSurfaceKeys: ['mailStats', 'queues', 'quarantine', 'domainStats'],
},
};
@@ -159,21 +167,19 @@ export const INFRASTRUCTURE_ONBOARDING_PATHS: Record<
},
};
export const INFRASTRUCTURE_ONBOARDING_STEPS = [
'Probe address',
'Identify platform',
'Request credentials',
'Validate access',
'Start monitoring',
] as const;
export const INFRASTRUCTURE_AGENT_DISCOVERY_LABELS = [
'Pulse Agent hosts',
'Docker',
'Kubernetes',
] as const;
export const INFRASTRUCTURE_AGENT_HOST_LABELS = ['Linux', 'FreeBSD', 'Unraid'] as const;
export const INFRASTRUCTURE_AGENT_HOST_LABELS = [
'Linux',
'macOS',
'Windows',
'FreeBSD',
'Unraid',
] as const;
const SOURCE_PICKER_GROUPS: InfrastructureSourcePickerGroupPresentation[] = [
{
+5 -4
View File
@@ -55,10 +55,11 @@ End-to-end Playwright tests that validate critical user flows against a running
- Mocks a blocked Patrol runtime with stale healthy summary payloads
- Proves the real `/ai` route shows Patrol as paused and suppresses stale healthy summary copy
- `tests/68-infrastructure-onboarding.spec.ts` — infrastructure onboarding browser proof:
- Proves `/settings/infrastructure` behaves as the persistent source-manager landing in the shared settings shell
- Verifies direct type-add and detect-from-address both open managed dialogs while the source-manager surface remains visible underneath
- Verifies the onboarding funnel emits catalog-driven API handoff and no-match-to-agent fallback metrics on the real browser runtime
- Verifies the mobile infrastructure manager fits the viewport without horizontal overflow
- Proves `/settings/infrastructure` stays instance-first until the user opens the add flow
- Verifies the add tile opens a grouped source-type picker, with detect-from-address as a secondary utility inside that modal flow
- Verifies an explicit discovery run surfaces Proxmox-family candidates in the source-manager table and opens the matching prefilled review dialog
- Verifies the onboarding funnel emits picker-driven API handoff and no-match-to-agent fallback metrics on the real browser runtime
- Verifies the mobile landing and picker modal fit the viewport without horizontal overflow
- `tests/69-diagnostics-onboarding.spec.ts` — diagnostics onboarding analytics browser proof:
- Proves the Diagnostics & Health page renders the infrastructure onboarding analytics card after a real diagnostics run
- Verifies the shared diagnostics surface shows onboarding path/platform attribution alongside the existing commercial funnel