From a62f185ccea603ff4f693fd15ccf7b775505af18 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 20 Apr 2026 14:24:58 +0100 Subject: [PATCH] infra: replace fixed ledger explainer with an Add infrastructure flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two teaching cards lived permanently above the ledger, so every repeat visit paid visual cost for content the user only needs when adding something new. The new shape: Ledger — just the table plus one "Add infrastructure" action. Picker — clicking Add brings up the two cards on their own screen ("Add connection" / "Install agent") with full teaching context. Sub-flow — the chosen mode runs as before: probe for Platform API, install section for the agent. Each sub-flow exposes a "Change method" button so the user can step back into the picker without leaving add mode. Legacy ?add=pick deep links still route into the new picker rather than jumping directly into the probe. --- .../Settings/InfrastructureWorkspace.tsx | 123 +++++++++++++----- .../InfrastructureWorkspace.test.tsx | 70 +++++++--- .../__tests__/settingsArchitecture.test.ts | 2 +- .../components/Settings/settingsHeaderMeta.ts | 3 +- 4 files changed, 148 insertions(+), 50 deletions(-) diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index aba05779d..33a12224b 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -5,7 +5,7 @@ import { copyToClipboard } from '@/utils/clipboard'; import { notificationStore } from '@/stores/notifications'; import { AgentProfilesPanel } from './AgentProfilesPanel'; import { ConnectionsExplainer } from './ConnectionsExplainer'; -import { ConnectionsTable } from './ConnectionsTable'; +import { ConnectionsTable, type ConnectionsTableHeaderAction } from './ConnectionsTable'; import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor'; import { NodeCredentialSlot } from './ConnectionEditor/CredentialSlots/NodeCredentialSlot'; import { TrueNASCredentialSlot } from './ConnectionEditor/CredentialSlots/TrueNASCredentialSlot'; @@ -50,6 +50,7 @@ const InfrastructureWorkspaceContent: Component = const rowActions = useConnectionRowActions({ onMutated: () => ledger.reload() }); const [addMode, setAddMode] = createSignal(false); + const [addPickerMode, setAddPickerMode] = createSignal(false); const [initialAddType, setInitialAddType] = createSignal(null); const [showAgentProfiles, setShowAgentProfiles] = createSignal(false); const [editingConnection, setEditingConnection] = createSignal(null); @@ -97,15 +98,18 @@ const InfrastructureWorkspaceContent: Component = } if (!readOnly() && step) { setAddMode(true); - if (step && step !== 'pick') { - setInitialAddType(ADD_STEP_TO_TYPE[step]); - } else { + if (step === 'pick') { + setAddPickerMode(true); setInitialAddType(null); + } else { + setAddPickerMode(false); + setInitialAddType(ADD_STEP_TO_TYPE[step]); } return; } setAddMode(false); + setAddPickerMode(false); setInitialAddType(null); setShowAgentProfiles(false); }); @@ -122,18 +126,41 @@ const InfrastructureWorkspaceContent: Component = const rows = createMemo(() => ledger.rows()); - const startAddConnection = () => { + const startAddInfrastructure = () => { setInitialAddType(null); + setAddPickerMode(true); setAddMode(true); setShowAgentProfiles(false); }; - const startInstallAgent = () => { + const pickPlatformApi = () => { + setInitialAddType(null); + setAddPickerMode(false); + }; + + const pickAgent = () => { setInitialAddType('agent'); - setAddMode(true); + setAddPickerMode(false); + }; + + const backToPicker = () => { + setInitialAddType(null); + setAddPickerMode(true); setShowAgentProfiles(false); }; + const headerActions = createMemo(() => + readOnly() + ? [] + : [ + { + label: 'Add infrastructure', + onSelect: startAddInfrastructure, + tone: 'primary' as const, + }, + ], + ); + const agentUninstallCommands = createMemo(() => ({ linux: operations.getUninstallCommand(), windows: operations.getWindowsUninstallCommand(), @@ -150,6 +177,7 @@ const InfrastructureWorkspaceContent: Component = const exitAddMode = () => { setAddMode(false); + setAddPickerMode(false); setInitialAddType(null); setShowAgentProfiles(false); }; @@ -194,20 +222,14 @@ const InfrastructureWorkspaceContent: Component =
- - void handleCopy(text)} - /> - + void handleCopy(text)} + /> } > @@ -311,22 +333,61 @@ const InfrastructureWorkspaceContent: Component = }} - + +
+
+
+
Add infrastructure
+
+ Pick how Pulse should connect to this system. +
+
+ +
+ + +
+
+ +
-
Add connection
+
+ {initialAddType() === 'agent' ? 'Install agent' : 'Add connection'} +
- Paste an address. Pulse detects the product, you enter credentials, and save. + {initialAddType() === 'agent' + ? 'Install the Pulse Unified Agent on a host for CPU / disk temps, SMART, and power, or on bare-metal Linux / Unraid / FreeBSD.' + : 'Paste an address. Pulse detects the product, you enter credentials, and save.'}
- +
+ + +
diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index cf2c80d3c..dacdbae49 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -197,7 +197,7 @@ describe('InfrastructureWorkspace', () => { await waitFor(() => expect(screen.getByRole('heading', { name: 'Monitored systems' })).toBeInTheDocument(), ); - expect(screen.getByRole('button', { name: 'Add connection' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add infrastructure' })).toBeInTheDocument(); expect(screen.getByText('zeus')).toBeInTheDocument(); expect(screen.queryByTestId('install-section')).toBeNull(); }); @@ -221,23 +221,37 @@ describe('InfrastructureWorkspace', () => { expect(screen.getByText('Paused')).toBeInTheDocument(); }); - it('opens the inline add flow when Add connection is clicked', () => { + it('opens the picker screen when Add infrastructure is clicked', () => { renderWorkspace(); - fireEvent.click(screen.getByRole('button', { name: /Add connection/i })); + fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i })); - expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Enter credentials manually/i })).toBeInTheDocument(); + // The picker screen surfaces both mode choices side-by-side; the probe flow + // has not started yet. + expect(screen.getByRole('button', { name: /^Add connection$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Install agent$/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull(); expect(navigateSpy).not.toHaveBeenCalled(); expect(setSearchParamsSpy).not.toHaveBeenCalled(); }); - it('routes to the agent install slot via the Install agent CTA inside the entry card', () => { + it('routes from the picker into the probe flow when Add connection is chosen', () => { renderWorkspace(); - // The agent path is a first-class peer entry inside the unified cards, not - // a subtext offramp hidden inside the Add screen — a user who wants - // CPU/disk temps or is on bare-metal Linux should reach it in one click. + fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i })); + fireEvent.click(screen.getByRole('button', { name: /^Add connection$/i })); + + expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Enter credentials manually/i })).toBeInTheDocument(); + }); + + it('routes from the picker into the agent install slot when Install agent is chosen', () => { + renderWorkspace(); + + // The agent path is a first-class peer entry inside the picker, not a + // subtext offramp hidden elsewhere — a user who wants CPU/disk temps or + // is on bare-metal Linux should reach it in two clicks from the ledger. + fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i })); fireEvent.click(screen.getByRole('button', { name: /^Install agent$/i })); expect(screen.getByTestId('install-section')).toBeInTheDocument(); @@ -246,7 +260,8 @@ describe('InfrastructureWorkspace', () => { it('routes to the TrueNAS credential slot when TrueNAS is picked manually', () => { renderWorkspace(); - fireEvent.click(screen.getByRole('button', { name: /Add connection/i })); + fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i })); + fireEvent.click(screen.getByRole('button', { name: /^Add connection$/i })); fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i })); fireEvent.click(screen.getByRole('button', { name: /TrueNAS SCALE/i })); @@ -256,7 +271,8 @@ describe('InfrastructureWorkspace', () => { it('routes to the VMware credential slot when VMware is picked manually', () => { renderWorkspace(); - fireEvent.click(screen.getByRole('button', { name: /Add connection/i })); + fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i })); + fireEvent.click(screen.getByRole('button', { name: /^Add connection$/i })); fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i })); fireEvent.click(screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i })); @@ -266,7 +282,8 @@ describe('InfrastructureWorkspace', () => { it('routes to the Proxmox credential slot when Proxmox VE is picked manually', () => { renderWorkspace(); - fireEvent.click(screen.getByRole('button', { name: /Add connection/i })); + fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i })); + fireEvent.click(screen.getByRole('button', { name: /^Add connection$/i })); fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i })); fireEvent.click(screen.getByRole('button', { name: /^Proxmox VE/i })); @@ -276,6 +293,7 @@ describe('InfrastructureWorkspace', () => { it('can return to the probe step from a credential slot', () => { renderWorkspace(); + fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i })); fireEvent.click(screen.getByRole('button', { name: /^Install agent$/i })); fireEvent.click(screen.getByRole('button', { name: /Back to probe/i })); @@ -286,12 +304,29 @@ describe('InfrastructureWorkspace', () => { it('toggles agent profiles inside the agent install slot', () => { renderWorkspace(); + fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i })); fireEvent.click(screen.getByRole('button', { name: /^Install agent$/i })); fireEvent.click(screen.getByRole('button', { name: 'Manage agent profiles' })); expect(screen.getByTestId('agent-profiles')).toBeInTheDocument(); }); + it('offers Change method from a sub-flow to return to the picker', () => { + renderWorkspace(); + + fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i })); + fireEvent.click(screen.getByRole('button', { name: /^Install agent$/i })); + expect(screen.getByTestId('install-section')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Change method/i })); + + // Back on the picker screen — both card CTAs are visible again, install + // section is gone. + expect(screen.getByRole('button', { name: /^Add connection$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Install agent$/i })).toBeInTheDocument(); + expect(screen.queryByTestId('install-section')).toBeNull(); + }); + it('exposes Edit / Pause / Remove on each ledger row directly', async () => { renderWorkspace(); @@ -341,15 +376,17 @@ describe('InfrastructureWorkspace', () => { await waitFor(() => expect(screen.getByTestId('install-section')).toBeInTheDocument()); }); - it('clears the canonical query onboarding route for platform picking without pre-selecting a type', async () => { + it('clears the canonical query onboarding route for platform picking and lands on the picker', async () => { routeState.search = '?add=pick'; renderWorkspace(); expect(setSearchParamsSpy).toHaveBeenCalledWith({ add: null }, { replace: true }); expect(navigateSpy).not.toHaveBeenCalled(); await waitFor(() => - expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument(), + expect(screen.getByRole('button', { name: /^Add connection$/i })).toBeInTheDocument(), ); + // Picker screen — probe hasn't started yet, install section isn't open. + expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull(); expect(screen.queryByTestId('install-section')).toBeNull(); }); @@ -366,11 +403,12 @@ describe('InfrastructureWorkspace', () => { expect(screen.queryByTestId('truenas-section')).toBeNull(); }); - it('hides Add connection, Install agent, and the add flow in read-only mode', () => { + it('hides Add infrastructure and every add sub-flow in read-only mode', () => { presentationPolicyIsReadOnlyMock.mockReturnValue(true); renderWorkspace(); - expect(screen.queryByRole('button', { name: /Add connection/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /Add infrastructure/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /^Add connection$/i })).toBeNull(); expect(screen.queryByRole('button', { name: /^Install agent$/i })).toBeNull(); expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull(); expect(screen.queryByTestId('install-section')).toBeNull(); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index dbcac8d47..2a2752ff8 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -31,7 +31,7 @@ describe('settings architecture guardrails', () => { it('keeps infrastructure onboarding route-backed under the shared settings shell', () => { expect(settingsHeaderMetaSource).toContain("'infrastructure-systems': {"); expect(settingsHeaderMetaSource).toContain( - 'Review monitored systems and add new connections or agent installs from one place', + 'Review monitored systems and add new infrastructure to Pulse', ); expect(settingsNavigationHookSource).toContain('deriveAddStepFromLegacyPath(path)'); diff --git a/frontend-modern/src/components/Settings/settingsHeaderMeta.ts b/frontend-modern/src/components/Settings/settingsHeaderMeta.ts index 043a63c9a..5d8d80cb8 100644 --- a/frontend-modern/src/components/Settings/settingsHeaderMeta.ts +++ b/frontend-modern/src/components/Settings/settingsHeaderMeta.ts @@ -9,8 +9,7 @@ import { export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = { 'infrastructure-systems': { title: 'Infrastructure', - description: - 'Review monitored systems and add new connections or agent installs from one place.', + description: 'Review monitored systems and add new infrastructure to Pulse.', }, 'system-general': { title: 'General',