mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Refine infrastructure onboarding flow
This commit is contained in:
@@ -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',
|
||||
|
||||
+2
-3
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user