From 2aecb44d314598d25a86a5981f14dedaff5452da Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Apr 2026 13:12:26 +0100 Subject: [PATCH] Redesign infrastructure onboarding flow --- .../ConnectionEditor/AddressProbeStep.tsx | 27 +- .../ConnectionEditor/ConnectionEditor.tsx | 299 ++++++++++++++---- .../__tests__/ConnectionEditor.test.tsx | 32 +- .../ConnectionEditor/useConnectionEditor.ts | 17 +- .../components/Settings/ConnectionsTable.tsx | 27 +- .../Settings/InfrastructureWorkspace.tsx | 176 ++++++----- .../InfrastructureWorkspace.test.tsx | 6 +- .../__tests__/settingsArchitecture.test.ts | 21 +- .../__tests__/useSettingsShellState.test.ts | 15 + .../Settings/useSettingsShellState.ts | 51 ++- ...frastructureOnboardingPresentation.test.ts | 56 ++++ .../infrastructureOnboardingPresentation.ts | 190 +++++++++++ 12 files changed, 705 insertions(+), 212 deletions(-) create mode 100644 frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts create mode 100644 frontend-modern/src/utils/infrastructureOnboardingPresentation.ts diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx index 4de1b1a89..90fd1137a 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx @@ -3,6 +3,7 @@ import type { ProbeCandidate } from '@/api/connections'; import { formControl, formField, formHelpText, formLabel } from '@/components/shared/Form'; import type { ConnectionEditorState } from './useConnectionEditor'; import { CONNECTION_TYPE_LABELS } from './useConnectionEditor'; +import { getInfrastructureAutoDetectLabels } from '@/utils/infrastructureOnboardingPresentation'; export interface AddressProbeStepProps { state: ConnectionEditorState; @@ -16,6 +17,8 @@ export const AddressProbeStep: Component = (props) => { void props.state.runProbe(); }; + const autoDetectLabels = getInfrastructureAutoDetectLabels(); + return (
@@ -26,7 +29,7 @@ export const AddressProbeStep: Component = (props) => { id="connection-address" type="text" class={formControl} - placeholder="vcenter.lab, truenas.lan, https://pve.lab:8006" + placeholder="vcenter.lab.local, truenas.lan, https://pve.lab:8006" value={props.state.address()} onInput={(event) => props.state.setAddress(event.currentTarget.value)} autocomplete="off" @@ -34,8 +37,18 @@ export const AddressProbeStep: Component = (props) => { disabled={props.state.phase() === 'probing'} />

- Paste a hostname, IP, or URL. Pulse detects the product and asks for credentials next. + Paste a hostname, IP, or URL to identify a supported platform. Pulse validates the match + and asks for credentials next.

+
+ + {(label) => ( + + {label} + + )} + +
@@ -56,20 +69,20 @@ export const AddressProbeStep: Component = (props) => {
-
No supported product detected at that address.
+
No supported API-backed platform detected at that address.
- Pick your system from the catalog below, or if this is bare-metal Linux / Unraid / - FreeBSD,{' '} + Pick a supported product from the catalog below, or if this is bare-metal Linux / Unraid + / FreeBSD,{' '} install the Unified Agent instead} + fallback={install Pulse Agent instead} > . diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx index e1ac71496..27c29a04a 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx @@ -11,6 +11,14 @@ import { createConnectionEditorState, type ConnectionEditorState, } from './useConnectionEditor'; +import { + INFRASTRUCTURE_AGENT_DISCOVERY_LABELS, + INFRASTRUCTURE_ONBOARDING_PATHS, + INFRASTRUCTURE_ONBOARDING_STEPS, + getInfrastructureApiProductsByGovernanceState, + getInfrastructureOnboardingProductPresentation, + getInfrastructureSupportSummaryBadges, +} from '@/utils/infrastructureOnboardingPresentation'; export type ConnectionEditorMode = 'add' | 'edit'; @@ -36,18 +44,17 @@ export interface ConnectionEditorProps { interface TileMeta { icon: Component<{ class?: string }>; - description: string; } const PLATFORM_TILE_META: Record< Exclude, TileMeta > = { - pve: { icon: Server, description: 'VMs, containers, storage, backups' }, - pbs: { icon: Archive, description: 'Backups, sync and verify jobs' }, - pmg: { icon: Mail, description: 'Mail stats, queues, quarantine' }, - vmware: { icon: ServerCog, description: 'vCenter or ESXi clusters' }, - truenas: { icon: Database, description: 'Pools, datasets, replications' }, + pve: { icon: Server }, + pbs: { icon: Archive }, + pmg: { icon: Mail }, + vmware: { icon: ServerCog }, + truenas: { icon: Database }, }; export const ConnectionEditor: Component = (props) => { @@ -112,18 +119,135 @@ export const ConnectionEditor: Component = (props) => { props.onClose(); }; + const supportedApiProducts = createMemo(() => + getInfrastructureApiProductsByGovernanceState('supported'), + ); + const supportBadges = createMemo(() => getInfrastructureSupportSummaryBadges()); + + const renderBadge = (label: string, tone: 'neutral' | 'accent' = 'neutral') => ( + + {label} + + ); + return (
-
+
+
+
+
+
+ Choose how Pulse should connect +
+

+ Pulse can monitor supported platforms through their management APIs, or install + Pulse Agent on a host for machine telemetry and local runtime discovery. +

+
+ +
+
+
+
+
+
+ {INFRASTRUCTURE_ONBOARDING_PATHS.api.title} +
+

+ {INFRASTRUCTURE_ONBOARDING_PATHS.api.bestFor} +

+
+ + API path + +
+ +

+ {INFRASTRUCTURE_ONBOARDING_PATHS.api.description} +

+ +
+
+ Supported today +
+
+ + {(product) => renderBadge(product.label)} + +
+
+ + 0}> +
+
+ Current admission path +
+
+ + {(label) => renderBadge(label, 'accent')} + +
+
+
+ +

+ {INFRASTRUCTURE_ONBOARDING_PATHS.api.coverage} +

+
+
+ +
+
+
+
+
+ {INFRASTRUCTURE_ONBOARDING_PATHS.agent.title} +
+

+ {INFRASTRUCTURE_ONBOARDING_PATHS.agent.bestFor} +

+
+ + Agent path + +
+ +

+ {INFRASTRUCTURE_ONBOARDING_PATHS.agent.description} +

+ +
+ + {(label) => renderBadge(label)} + +
+ +

+ {INFRASTRUCTURE_ONBOARDING_PATHS.agent.coverage} +

+
+
+
+
+
+ +
-
Connect a platform
+
+ Connect a supported platform +

- Use a management API when the product exposes one. Paste an address to auto-detect - it, or pick a supported platform from the catalog. + Paste an address to identify a supported platform automatically, or pick the + product you already know from the catalog.

@@ -136,17 +260,62 @@ export const ConnectionEditor: Component = (props) => { - - {(entry) => { - if (entry.kind === 'type') { - const meta = PLATFORM_TILE_META[entry.type]; - const Icon = meta.icon; - const label = CONNECTION_TYPE_LABELS[entry.type]; +
+
+
+ Supported platform catalog +
+

+ Choose the product path that matches your environment when you already know + the system type. +

+
+ +
+ + {(entry) => { + if (entry.kind === 'type') { + const meta = PLATFORM_TILE_META[entry.type]; + const Icon = meta.icon; + const product = getInfrastructureOnboardingProductPresentation( + entry.type, + ); + return ( + + ); + } + + const familyLeadType = entry.childTypes[0]; + const Icon = PLATFORM_TILE_META[familyLeadType]?.icon ?? Server; return (
-
{meta.description}
+
{entry.description}
); - } - - const familyLeadType = entry.childTypes[0]; - const Icon = PLATFORM_TILE_META[familyLeadType]?.icon ?? Server; - return ( - - ); - }} - + }} + +
} > @@ -218,6 +366,7 @@ export const ConnectionEditor: Component = (props) => { {(type) => { const meta = PLATFORM_TILE_META[type]; const Icon = meta.icon; + const product = getInfrastructureOnboardingProductPresentation(type); return (
-
- {CONNECTION_TYPE_LABELS[type]} +
+
+ {product.label} +
+ + + Current admission path + +
-
{meta.description}
+
{product.catalogDescription}
+
{product.bestFor}
); }} @@ -247,12 +404,12 @@ export const ConnectionEditor: Component = (props) => { -
+
-
Install on a host instead
+
Install Pulse Agent

- Use the Pulse Agent when you want machine-level telemetry or the system has no - management API to connect. + Use the agent when you want machine telemetry, or when the system has no + management API Pulse can connect to directly.

@@ -275,14 +432,19 @@ export const ConnectionEditor: Component = (props) => {
- Install on bare-metal Linux, Unraid, FreeBSD, or any machine where you want CPU - temperature, disk SMART, systemd services, and network metrics from the host + Install on Linux, FreeBSD, or compatible hosts such as Unraid when you want CPU + temperature, disk SMART, system services, and network metrics from the host itself.
Also detects local Docker, Kubernetes, and other supported services on the same machine and connects them when available.
+
+ + {(label) => renderBadge(label)} + +
+ +
+
+
+
What happens next
+

+ Pulse guides each path through the same monitored-system admission flow before + the system lands in the shared infrastructure ledger. +

+
+ +
+ + {(step, index) => ( +
+
+ Step {index() + 1} +
+
{step}
+
+ )} +
+
+
+
} > diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx index 30e346e0b..beb9e5d47 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx @@ -34,7 +34,7 @@ describe('ConnectionEditor', () => { }; mockedProbe.mockResolvedValueOnce(response); - const renderSlot = vi.fn(({ type }) =>
slot:{type}
); + const renderSlot = vi.fn((props) =>
slot:{props.type}
); render(() => {}} />); @@ -46,8 +46,8 @@ describe('ConnectionEditor', () => { await waitFor(() => expect(mockedProbe).toHaveBeenCalledWith('pve.lab')); - const candidateLabel = await screen.findAllByText('Proxmox VE'); - const candidateButton = candidateLabel[0].closest('button'); + const candidateHost = await screen.findByText('https://pve.lab:8006'); + const candidateButton = candidateHost.closest('button'); expect(candidateButton).not.toBeNull(); fireEvent.click(candidateButton!); @@ -62,7 +62,7 @@ describe('ConnectionEditor', () => { it('lets the user pick a product tile when probe returns no match', async () => { mockedProbe.mockResolvedValueOnce({ candidates: [], probedMs: 203 }); - const renderSlot = vi.fn(({ type }) =>
slot:{type}
); + const renderSlot = vi.fn((props) =>
slot:{props.type}
); render(() => {}} />); @@ -72,7 +72,7 @@ describe('ConnectionEditor', () => { fireEvent.click(screen.getByRole('button', { name: /probe address/i })); await waitFor(() => expect(mockedProbe).toHaveBeenCalled()); - await screen.findByText(/no supported product detected/i); + await screen.findByText(/no supported api-backed platform detected/i); // The catalog grid is always visible below the probe, so the user picks a // tile directly — no intermediate "enter credentials manually" toggle. @@ -87,10 +87,10 @@ describe('ConnectionEditor', () => { it('renders a platform-first catalog with the host-install path beneath it', () => { render(() =>
} onClose={() => {}} />); - const platformHeading = screen.getByText('Connect a platform'); + const platformHeading = screen.getAllByText('Connect a supported platform')[0]; const agentButton = screen.getByRole('button', { name: /Install Pulse Agent/i }); const probeButton = screen.getByRole('button', { name: /probe address/i }); - const vmwareButton = screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i }); + const vmwareButton = screen.getByRole('button', { name: /VMware vCenter/i }); const trueNASButton = screen.getByRole('button', { name: /TrueNAS SCALE/i }); const proxmoxButton = screen.getByRole('button', { name: /^Proxmox\b/i }); @@ -106,12 +106,14 @@ describe('ConnectionEditor', () => { expectNodeBefore(vmwareButton, trueNASButton); expectNodeBefore(trueNASButton, proxmoxButton); expectNodeBefore(proxmoxButton, agentButton); - expect(screen.queryByText('Proxmox VE')).toBeNull(); + expect(screen.queryByRole('button', { name: /^Proxmox VE/i })).toBeNull(); expect(screen.queryByText('Recommended')).toBeNull(); + expect(screen.getAllByText('Current admission path').length).toBeGreaterThan(0); + expect(screen.getByText('What happens next')).toBeInTheDocument(); }); it('groups Proxmox products under one family step before entering credentials', async () => { - const renderSlot = vi.fn(({ type }) =>
slot:{type}
); + const renderSlot = vi.fn((props) =>
slot:{props.type}
); render(() => {}} />); @@ -144,7 +146,7 @@ describe('ConnectionEditor', () => { it('offers the agent path contextually when a probe returns no match', async () => { mockedProbe.mockResolvedValueOnce({ candidates: [], probedMs: 180 }); - const renderSlot = vi.fn(({ type }) =>
slot:{type}
); + const renderSlot = vi.fn((props) =>
slot:{props.type}
); render(() => {}} />); @@ -157,7 +159,7 @@ describe('ConnectionEditor', () => { // the agent as a first-class alternative, so a user who probed the wrong // thing isn't left in a Platform-API-only dead end. const agentButton = await screen.findByRole('button', { - name: /install the unified agent instead/i, + name: /install pulse agent instead/i, }); fireEvent.click(agentButton); @@ -168,7 +170,7 @@ describe('ConnectionEditor', () => { }); it('skips the probe step when an initialType is supplied (edit mode)', () => { - const renderSlot = vi.fn(({ type }) =>
slot:{type}
); + const renderSlot = vi.fn((props) =>
slot:{props.type}
); render(() => ( { it('resets probe state when returning to the catalog from a credential slot', async () => { mockedProbe.mockResolvedValueOnce({ candidates: [], probedMs: 203 }); - const renderSlot = vi.fn(({ type }) =>
slot:{type}
); + const renderSlot = vi.fn((props) =>
slot:{props.type}
); render(() => {}} />); @@ -210,7 +212,7 @@ describe('ConnectionEditor', () => { fireEvent.click(screen.getByRole('button', { name: /probe address/i })); await waitFor(() => expect(mockedProbe).toHaveBeenCalled()); - await screen.findByText(/no supported product detected/i); + await screen.findByText(/no supported api-backed platform detected/i); fireEvent.click(screen.getByRole('button', { name: /TrueNAS SCALE/i })); await waitFor(() => expect(screen.getByTestId('slot').textContent).toBe('slot:truenas')); @@ -219,7 +221,7 @@ describe('ConnectionEditor', () => { const resetInput = screen.getByPlaceholderText(/vcenter\.lab/) as HTMLInputElement; expect(resetInput.value).toBe(''); - expect(screen.queryByText(/no supported product detected/i)).toBeNull(); + expect(screen.queryByText(/no supported api-backed platform detected/i)).toBeNull(); expect(screen.queryByTestId('slot')).toBeNull(); }); }); diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts b/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts index 01d91e455..70f75f036 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts +++ b/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts @@ -9,6 +9,10 @@ import { DEFAULT_INFRASTRUCTURE_SOURCE_ORDER, getSourcePlatformFamily, } from '@/utils/platformSupportManifest'; +import { + INFRASTRUCTURE_API_FAMILY_DESCRIPTIONS, + getInfrastructureOnboardingProductPresentation, +} from '@/utils/infrastructureOnboardingPresentation'; const PROBE_ERROR_FALLBACK = 'Probe failed. Try again or enter credentials manually.'; @@ -123,9 +127,9 @@ export const CONNECTION_TYPE_LABELS: Record = { pve: 'Proxmox VE', pbs: 'Proxmox Backup Server', pmg: 'Proxmox Mail Gateway', - vmware: 'VMware vCenter / ESXi', - truenas: 'TrueNAS SCALE', - agent: 'Pulse Unified Agent', + vmware: getInfrastructureOnboardingProductPresentation('vmware').label, + truenas: getInfrastructureOnboardingProductPresentation('truenas').label, + agent: 'Pulse Agent', docker: 'Docker', kubernetes: 'Kubernetes', }; @@ -142,14 +146,17 @@ const getCatalogFamilyLabel = (type: PlatformConnectionType): string | null => const describeCatalogFamily = ( familyLabel: string, childTypes: readonly PlatformConnectionType[], -): string => - childTypes +): string => { + const curatedDescription = INFRASTRUCTURE_API_FAMILY_DESCRIPTIONS[familyLabel]; + if (curatedDescription) return curatedDescription; + return childTypes .map((type) => { const label = CONNECTION_TYPE_LABELS[type]; const prefix = `${familyLabel} `; return label.startsWith(prefix) ? label.slice(prefix.length) : label; }) .join(', '); +}; export const DEFAULT_CONNECTION_EDITOR_CATALOG_ENTRIES: ConnectionEditorCatalogEntry[] = (() => { return buildConnectionEditorCatalogEntries(DEFAULT_CONNECTION_EDITOR_AVAILABLE_TYPES); diff --git a/frontend-modern/src/components/Settings/ConnectionsTable.tsx b/frontend-modern/src/components/Settings/ConnectionsTable.tsx index 4de19b152..6cf852e86 100644 --- a/frontend-modern/src/components/Settings/ConnectionsTable.tsx +++ b/frontend-modern/src/components/Settings/ConnectionsTable.tsx @@ -11,6 +11,10 @@ import { import type { Connection } from '@/api/connections'; import type { InfrastructureSystemRow } from './connectionsTableModel'; import type { ConnectionRowActions } from './useConnectionRowActions'; +import { + getInfrastructureEmptyStateDetail, + getInfrastructureEmptyStateSummary, +} from '@/utils/infrastructureOnboardingPresentation'; export interface ConnectionsTableHeaderAction { label: string; @@ -45,8 +49,7 @@ const removeConfirmClass = 'inline-flex items-center rounded-md bg-rose-600 px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-60'; export const ConnectionsTable: Component = (props) => { - const hasActions = () => - Boolean(props.actions) || Boolean(props.onEdit); + const hasActions = () => Boolean(props.actions) || Boolean(props.onEdit); const colSpan = () => (hasActions() ? 5 : 4); @@ -55,7 +58,7 @@ export const ConnectionsTable: Component = (props) => {

Monitored systems

-

One row per top-level monitored system.

+

{getInfrastructureEmptyStateSummary()}

0}>
@@ -81,7 +84,12 @@ export const ConnectionsTable: Component = (props) => { 0} fallback={ -
No monitored systems yet.
+
+
Start monitoring infrastructure
+
+ {getInfrastructureEmptyStateDetail()} +
+
} > @@ -111,8 +119,7 @@ export const ConnectionsTable: Component = (props) => { const isPauseBusy = () => props.actions?.pendingAction(row.id) === 'pause'; const isRemoveBusy = () => props.actions?.pendingAction(row.id) === 'remove'; const anyBusy = () => props.actions?.pendingAction(row.id) !== null; - const isConfirmingRemove = () => - Boolean(props.actions?.confirmingRemove(row.id)); + const isConfirmingRemove = () => Boolean(props.actions?.confirmingRemove(row.id)); const rowError = () => props.actions?.actionError(row.id) ?? null; return ( @@ -231,13 +238,15 @@ export const ConnectionsTable: Component = (props) => { - +

- Removing forgets this agent from the ledger; history is retained. - To fully detach, run the uninstall command on the host: + Removing forgets this agent from the ledger; history is retained. To + fully detach, run the uninstall command on the host:

diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index ece42ec9e..0758bb777 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -219,7 +219,11 @@ const InfrastructureWorkspaceContent: Component = case 'pmg': { const node = findEditableNode(connection); return node - ? { kind: 'node' as const, render: () => renderNodeSlot(connection.type as 'pve' | 'pbs' | 'pmg', node) } + ? { + kind: 'node' as const, + render: () => + renderNodeSlot(connection.type as 'pve' | 'pbs' | 'pmg', node), + } : null; } case 'vmware': { @@ -262,14 +266,12 @@ const InfrastructureWorkspaceContent: Component = if (!editableSlot) { return (
-
-
- Edit connection -
+
+
Edit connection
@@ -286,7 +288,7 @@ const InfrastructureWorkspaceContent: Component = } return (
-
+
Edit {connection.name} @@ -296,7 +298,7 @@ const InfrastructureWorkspaceContent: Component = @@ -311,89 +313,91 @@ const InfrastructureWorkspaceContent: Component = -
-
-
-
Add infrastructure
-
- Paste an address to auto-detect, or pick your system from the catalog. +
+
+
+
Add infrastructure
+
+ Choose how Pulse should connect: supported platform API or Pulse Agent on a host. +
+
- -
-
- { - switch (type) { - case 'pve': - case 'pbs': - case 'pmg': - return renderNodeSlot(type); - case 'truenas': - return ( - - ); - case 'vmware': - return ( - - ); - case 'agent': - return ( -
-
- -
- -
-
-
- Agent profiles -
-
- Manage reusable install defaults for agent-based systems. -
-
- +
+ { + switch (type) { + case 'pve': + case 'pbs': + case 'pmg': + return renderNodeSlot(type); + case 'truenas': + return ( + + ); + case 'vmware': + return ( + + ); + case 'agent': + return ( +
+
+
- - -
- ); - default: - return ( -
- No credential form is wired up for the {type} type yet. -
- ); - } - }} - /> + +
+
+
+ Agent profiles +
+
+ Manage reusable install defaults for agent-based systems. +
+
+ +
+
+ +
+ ); + default: + return ( +
+ No credential form is wired up for the {type} type yet. +
+ ); + } + }} + /> +
-
diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index cd14cd988..93ab57ea0 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -233,8 +233,8 @@ describe('InfrastructureWorkspace', () => { // The catalog landing leads with peer platform onboarding and keeps the // host install path available beneath it in the same shared editor. - const platformHeading = screen.getByText('Connect a platform'); - const vmwareButton = screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i }); + const platformHeading = screen.getAllByText('Connect a supported platform')[0]; + const vmwareButton = screen.getByRole('button', { name: /VMware vCenter/i }); const trueNASButton = screen.getByRole('button', { name: /TrueNAS SCALE/i }); const proxmoxButton = screen.getByRole('button', { name: /^Proxmox\b/i }); const agentButton = screen.getByRole('button', { name: /Install Pulse Agent/i }); @@ -274,7 +274,7 @@ describe('InfrastructureWorkspace', () => { renderWorkspace(); fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i })); - fireEvent.click(screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i })); + fireEvent.click(screen.getByRole('button', { name: /VMware vCenter/i })); expect(screen.getByTestId('vmware-section')).toBeInTheDocument(); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 0c05c45da..531498818 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -7,6 +7,7 @@ import settingsNavigationHookSource from '../useSettingsNavigation.ts?raw'; import settingsPanelRegistryContextSource from '../settingsPanelRegistryContext.tsx?raw'; import infrastructureWorkspaceSource from '../InfrastructureWorkspace.tsx?raw'; import infrastructureWorkspaceModelSource from '../infrastructureWorkspaceModel.ts?raw'; +import connectionsTableSource from '../ConnectionsTable.tsx?raw'; import connectionEditorSource from '../ConnectionEditor/ConnectionEditor.tsx?raw'; import addressProbeStepSource from '../ConnectionEditor/AddressProbeStep.tsx?raw'; import connectionEditorStateSource from '../ConnectionEditor/useConnectionEditor.ts?raw'; @@ -15,6 +16,7 @@ import trueNASCredentialSlotSource from '../ConnectionEditor/CredentialSlots/Tru import vmwareCredentialSlotSource from '../ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx?raw'; import diagnosticsResultsPanelSource from '../DiagnosticsResultsPanel.tsx?raw'; import diagnosticsModelSource from '../diagnosticsModel.ts?raw'; +import infrastructureOnboardingPresentationSource from '../../../utils/infrastructureOnboardingPresentation.ts?raw'; describe('settings architecture guardrails', () => { it('keeps Settings on the canonical page shell boundary', () => { @@ -111,13 +113,20 @@ describe('settings architecture guardrails', () => { expect(connectionEditorSource).toContain( "import { AddressProbeStep } from './AddressProbeStep';", ); + expect(connectionEditorSource).toContain( + "from '@/utils/infrastructureOnboardingPresentation';", + ); + expect(connectionsTableSource).toContain( + "from '@/utils/infrastructureOnboardingPresentation';", + ); expect(connectionEditorSource).toContain('buildConnectionEditorCatalogEntries'); expect(connectionEditorSource).toContain('selectedFamilyId'); expect(connectionEditorSource).toContain(' { // The no-match branch must name the agent alternative so a user who // probed bare-metal Linux / Unraid / FreeBSD is not left in a // Platform-API-only dead end. - expect(addressProbeStepSource).toContain('install the Unified Agent instead'); + expect(addressProbeStepSource).toContain('install Pulse Agent instead'); expect(addressProbeStepSource).toContain('bare-metal Linux'); + expect(addressProbeStepSource).toContain('supported API-backed platform'); expect(connectionEditorStateSource).toContain('ConnectionsAPI.probe(value)'); expect(connectionEditorStateSource).toContain('export const CONNECTION_TYPE_LABELS'); @@ -144,6 +154,11 @@ describe('settings architecture guardrails', () => { 'export function buildConnectionEditorCatalogEntries', ); expect(connectionEditorStateSource).not.toContain('PROXMOX_FAMILY_TYPES'); + expect(infrastructureOnboardingPresentationSource).toContain('getSourcePlatformManifestEntry'); + expect(infrastructureOnboardingPresentationSource).toContain('currentAdmissionPath'); + expect(infrastructureOnboardingPresentationSource).toContain( + 'getInfrastructureSupportSummaryBadges', + ); expect(nodeCredentialSlotSource).toContain('useNodeModalState(modalProps)'); expect(nodeCredentialSlotSource).toContain(' { afterEach(() => { presentationPolicyIsReadOnlyMock.mockReset(); presentationPolicyIsReadOnlyMock.mockReturnValue(false); + vi.unstubAllGlobals(); }); it('uses reporting-focused infrastructure copy in read-only sessions', () => { @@ -30,4 +31,18 @@ describe('useSettingsShellState', () => { dispose(); }); }); + + it('keeps the content pane visible on mobile-sized viewports by default', () => { + vi.stubGlobal('window', { innerWidth: 390 }); + + createRoot((dispose) => { + const state = useSettingsShellState({ + activeTab: () => 'infrastructure-systems', + }); + + expect(state.isMobileMenuOpen()).toBe(false); + + dispose(); + }); + }); }); diff --git a/frontend-modern/src/components/Settings/useSettingsShellState.ts b/frontend-modern/src/components/Settings/useSettingsShellState.ts index 6ca9e80d8..dc659d0a4 100644 --- a/frontend-modern/src/components/Settings/useSettingsShellState.ts +++ b/frontend-modern/src/components/Settings/useSettingsShellState.ts @@ -8,40 +8,35 @@ interface UseSettingsShellStateParams { } export function useSettingsShellState({ activeTab }: UseSettingsShellStateParams) { - const headerMeta = createMemo( - () => { - const tab = activeTab(); - if (isInfrastructureSettingsTab(tab) && presentationPolicyIsReadOnly()) { - return { - title: 'Infrastructure', - description: - 'Review the current top-level monitored systems and reporting posture. Setup changes stay unavailable in this read-only session.', - }; - } + const headerMeta = createMemo(() => { + const tab = activeTab(); + if (isInfrastructureSettingsTab(tab) && presentationPolicyIsReadOnly()) { + return { + title: 'Infrastructure', + description: + 'Review the current top-level monitored systems and reporting posture. Setup changes stay unavailable in this read-only session.', + }; + } - if (isInfrastructureSettingsTab(tab)) { - return { - title: 'Infrastructure', - description: - SETTINGS_HEADER_META['infrastructure-systems'].description, - }; - } + if (isInfrastructureSettingsTab(tab)) { + return { + title: 'Infrastructure', + description: SETTINGS_HEADER_META['infrastructure-systems'].description, + }; + } - return ( - SETTINGS_HEADER_META[tab] ?? { - title: 'Settings', - description: 'Manage Pulse configuration.', - } - ); - }, - ); + return ( + SETTINGS_HEADER_META[tab] ?? { + title: 'Settings', + description: 'Manage Pulse configuration.', + } + ); + }); // Sidebar always starts expanded for discoverability (issue #764) // Users can collapse during session but it resets on page reload const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false); - const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal( - typeof window !== 'undefined' ? window.innerWidth < 1024 : false, - ); + const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false); const [showPasswordModal, setShowPasswordModal] = createSignal(false); const [searchQuery, setSearchQuery] = createSignal(''); diff --git a/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts b/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts new file mode 100644 index 000000000..96a1303e8 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + getInfrastructureApiProductsByGovernanceState, + getInfrastructureAutoDetectLabels, + getInfrastructureEmptyStateDetail, + getInfrastructureOnboardingProductPresentation, + getInfrastructureSupportSummaryBadges, +} from '@/utils/infrastructureOnboardingPresentation'; + +describe('infrastructureOnboardingPresentation', () => { + it('keeps VMware on the admitted vCenter-only path', () => { + const vmware = getInfrastructureOnboardingProductPresentation('vmware'); + + expect(vmware.label).toBe('VMware vCenter'); + expect(vmware.governanceState).toBe('admitted'); + }); + + it('keeps supported API products separate from the admitted VMware path', () => { + expect( + getInfrastructureApiProductsByGovernanceState('supported').map((product) => product.label), + ).toEqual(['TrueNAS SCALE', 'Proxmox VE', 'Proxmox Backup Server', 'Proxmox Mail Gateway']); + + expect( + getInfrastructureApiProductsByGovernanceState('admitted').map((product) => product.label), + ).toEqual(['VMware vCenter']); + }); + + it('derives auto-detect copy and main-page support summary from the shared helper', () => { + expect(getInfrastructureAutoDetectLabels()).toEqual([ + 'VMware vCenter', + 'TrueNAS SCALE', + 'Proxmox VE', + 'Proxmox Backup Server', + 'Proxmox Mail Gateway', + ]); + + expect(getInfrastructureSupportSummaryBadges()).toEqual({ + supportedToday: [ + 'TrueNAS SCALE', + 'Proxmox VE', + 'Proxmox Backup Server', + 'Proxmox Mail Gateway', + 'Pulse Agent hosts', + 'Docker', + 'Kubernetes', + ], + currentAdmissionPath: ['VMware vCenter'], + installPath: ['Linux', 'FreeBSD', 'Unraid', 'Pulse Agent hosts', 'Docker', 'Kubernetes'], + }); + + expect(getInfrastructureEmptyStateDetail()).toContain('Supported today: TrueNAS SCALE'); + expect(getInfrastructureEmptyStateDetail()).toContain( + 'VMware vCenter onboarding is also available on the current admission path.', + ); + }); +}); diff --git a/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts b/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts new file mode 100644 index 000000000..60aa199a1 --- /dev/null +++ b/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts @@ -0,0 +1,190 @@ +import type { ConnectionType } from '@/api/connections'; +import { + getSourcePlatformManifestEntry, + type PlatformGovernanceState, +} from '@/utils/platformSupportManifest'; + +export type InfrastructureOnboardingConnectionType = Extract< + ConnectionType, + 'agent' | 'pve' | 'pbs' | 'pmg' | 'truenas' | 'vmware' +>; + +export interface InfrastructureOnboardingProductPresentation { + type: InfrastructureOnboardingConnectionType; + label: string; + bestFor: string; + coverage: string; + catalogDescription: string; + autoDetect: boolean; + governanceState: PlatformGovernanceState; +} + +interface BaseProductPresentation { + label: string; + bestFor: string; + coverage: string; + catalogDescription: string; + autoDetect: boolean; + sourcePlatformId?: string; +} + +export interface InfrastructureOnboardingPathPresentation { + title: string; + description: string; + bestFor: string; + coverage: string; +} + +const PRODUCT_PRESENTATION: Record< + InfrastructureOnboardingConnectionType, + BaseProductPresentation +> = { + agent: { + label: 'Pulse Agent', + bestFor: 'Linux, FreeBSD, and compatible hosts such as Unraid', + coverage: 'Host telemetry, SMART, services, Docker, Kubernetes', + catalogDescription: 'Host telemetry, services, Docker, Kubernetes', + autoDetect: false, + }, + vmware: { + label: 'VMware vCenter', + bestFor: 'vCenter-managed VMware environments', + coverage: 'VM inventory, ESXi host health, datastore status', + catalogDescription: 'VM inventory, ESXi hosts, datastores', + autoDetect: true, + sourcePlatformId: 'vmware-vsphere', + }, + truenas: { + label: 'TrueNAS SCALE', + bestFor: 'TrueNAS appliances with API-backed management', + coverage: 'Pools, datasets, apps, replications', + catalogDescription: 'Pools, datasets, apps, replications', + autoDetect: true, + sourcePlatformId: 'truenas', + }, + pve: { + label: 'Proxmox VE', + bestFor: 'Virtualization clusters and standalone hypervisors', + coverage: 'VMs, containers, storage, cluster health', + catalogDescription: 'VMs, containers, storage, cluster health', + autoDetect: true, + sourcePlatformId: 'proxmox-pve', + }, + pbs: { + label: 'Proxmox Backup Server', + bestFor: 'Backup infrastructure and protected storage', + coverage: 'Backup jobs, sync, verify, prune, GC', + catalogDescription: 'Backup jobs, sync, verify, prune, GC', + autoDetect: true, + sourcePlatformId: 'proxmox-pbs', + }, + pmg: { + label: 'Proxmox Mail Gateway', + bestFor: 'Mail filtering and delivery operations', + coverage: 'Mail stats, queues, quarantine, relay health', + catalogDescription: 'Mail stats, queues, quarantine, relay health', + autoDetect: true, + sourcePlatformId: 'proxmox-pmg', + }, +}; + +const governanceStateForType = ( + type: InfrastructureOnboardingConnectionType, +): PlatformGovernanceState => { + const sourcePlatformId = PRODUCT_PRESENTATION[type].sourcePlatformId; + if (!sourcePlatformId) return 'supported'; + return getSourcePlatformManifestEntry(sourcePlatformId)?.governanceState ?? 'supported'; +}; + +const API_PRODUCT_ORDER: InfrastructureOnboardingConnectionType[] = [ + 'vmware', + 'truenas', + 'pve', + 'pbs', + 'pmg', +]; + +export const INFRASTRUCTURE_ONBOARDING_PATHS: Record< + 'api' | 'agent', + InfrastructureOnboardingPathPresentation +> = { + api: { + title: 'Connect a supported platform', + description: + 'Use a management API when the platform exposes one. Pulse validates the endpoint, requests credentials, and then starts collecting platform inventory and health.', + bestFor: 'TrueNAS, Proxmox, and the current VMware vCenter admission path', + coverage: 'Platform inventory, workloads, storage, backups, and health', + }, + agent: { + title: 'Install Pulse Agent', + description: + 'Use the agent when you want machine telemetry, or when the system does not expose a management API Pulse can connect to directly.', + bestFor: 'Linux, FreeBSD, and compatible hosts such as Unraid', + coverage: 'CPU temperature, disk SMART, services, network metrics, Docker, Kubernetes', + }, +}; + +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_API_FAMILY_DESCRIPTIONS: Partial> = { + Proxmox: 'Virtualization, backup jobs, and mail-gateway health', +}; + +export const getInfrastructureOnboardingProductPresentation = ( + type: InfrastructureOnboardingConnectionType, +): InfrastructureOnboardingProductPresentation => ({ + type, + ...PRODUCT_PRESENTATION[type], + governanceState: governanceStateForType(type), +}); + +export const getInfrastructureApiProductPresentations = + (): InfrastructureOnboardingProductPresentation[] => + API_PRODUCT_ORDER.map((type) => getInfrastructureOnboardingProductPresentation(type)); + +export const getInfrastructureApiProductsByGovernanceState = ( + governanceState: PlatformGovernanceState, +): InfrastructureOnboardingProductPresentation[] => + getInfrastructureApiProductPresentations().filter( + (product) => product.governanceState === governanceState, + ); + +export const getInfrastructureAutoDetectLabels = (): string[] => + getInfrastructureApiProductPresentations() + .filter((product) => product.autoDetect) + .map((product) => product.label); + +export const getInfrastructureSupportSummaryBadges = (): { + supportedToday: string[]; + currentAdmissionPath: string[]; + installPath: string[]; +} => ({ + supportedToday: [ + ...getInfrastructureApiProductsByGovernanceState('supported').map((product) => product.label), + ...INFRASTRUCTURE_AGENT_DISCOVERY_LABELS, + ], + currentAdmissionPath: getInfrastructureApiProductsByGovernanceState('admitted').map( + (product) => product.label, + ), + installPath: [...INFRASTRUCTURE_AGENT_HOST_LABELS, ...INFRASTRUCTURE_AGENT_DISCOVERY_LABELS], +}); + +export const getInfrastructureEmptyStateSummary = (): string => + 'Connect supported platforms by API, or install Pulse Agent on a host for machine telemetry and local runtime discovery.'; + +export const getInfrastructureEmptyStateDetail = (): string => + 'Supported today: TrueNAS SCALE, Proxmox VE, Proxmox Backup Server, Proxmox Mail Gateway, Pulse Agent hosts, Docker, and Kubernetes. VMware vCenter onboarding is also available on the current admission path.';