From eb505a93715ca1c3bb8125ad8ecd0e175e48a2a2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 20 Apr 2026 11:46:10 +0100 Subject: [PATCH] infra: surface Install agent as a first-class ledger-header button The Add screen's subtext offramp was the only path to the agent install page, and that was the wrong design: the explainer teaches Platform API and Pulse Unified Agent as peer modes, but clicking Add connection funneled users into a probe-first flow where the agent path was a footnote. Users wanting CPU/disk temps, SMART, or bare-metal Linux couldn't find the page without hunting. Changes: - Ledger header now has two peer actions: Add connection (Platform API probe) and Install agent (direct to agent install slot). - Drop the always-visible "Install the Unified Agent on a host" subtext from the Add screen; the header button makes it redundant. - Enhance the probe no-match box to name bare-metal Linux / Unraid / FreeBSD and offer the agent as a first-class alternative so a user who probed the wrong thing isn't stranded on a Platform-API-only picker. - Rewrite the infrastructure description to match: "Add connection for Platform API setup; Install agent for host-level metrics or bare-metal Linux / Unraid / FreeBSD." Tests updated to match (architecture pins, workspace flow, editor behavior). 41 affected vitest cases pass. Playwright-verified against the live dev server: both header buttons route correctly, subtext is gone, no-match box surfaces the agent alternative. --- .../ConnectionEditor/AddressProbeStep.tsx | 17 +++++++++- .../ConnectionEditor/ConnectionEditor.tsx | 14 +------- .../__tests__/ConnectionEditor.test.tsx | 33 ++++++++++++------- .../Settings/InfrastructureWorkspace.tsx | 9 +++++ .../InfrastructureWorkspace.test.tsx | 23 +++++-------- .../__tests__/settingsArchitecture.test.ts | 14 +++++--- .../components/Settings/settingsHeaderMeta.ts | 2 +- 7 files changed, 68 insertions(+), 44 deletions(-) diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx index cce4143db..286a601dd 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx @@ -8,6 +8,7 @@ export interface AddressProbeStepProps { state: ConnectionEditorState; onSelectCandidate: (candidate: ProbeCandidate) => void; onChooseManually: () => void; + onInstallAgent?: () => void; } export const AddressProbeStep: Component = (props) => { @@ -65,7 +66,21 @@ export const AddressProbeStep: Component = (props) => {
No supported product detected at that address.
- Pick a type manually and enter credentials — Pulse will still verify on save. + Pick a Platform API type manually and enter credentials, or if this is + bare-metal Linux / Unraid / FreeBSD,{' '} + install the Unified Agent instead} + > + + + .
diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx index f8d24ddb5..3d69b203f 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx @@ -81,6 +81,7 @@ export const ConnectionEditor: Component = (props) => { state={state} onSelectCandidate={chooseCandidate} onChooseManually={() => setManualPickerOpen((v) => !v)} + onInstallAgent={() => chooseManualType('agent')} /> @@ -104,19 +105,6 @@ export const ConnectionEditor: Component = (props) => { - -
- Setting up bare-metal Linux, Unraid, or FreeBSD, or adding host-level - metrics (CPU/disk temps, SMART, power) on Proxmox, VMware, or TrueNAS?{' '} - - . -
} > 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 c83eb4199..d245865e1 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx @@ -94,7 +94,7 @@ describe('ConnectionEditor', () => { expect(lastCall.candidate).toBeNull(); }); - it('leads with the address probe; agent install is an offramp, not a peer fork', () => { + it('leads with the address probe and does not re-teach the two-mode split', () => { render(() => (
} @@ -104,21 +104,21 @@ describe('ConnectionEditor', () => { // Primary path is probe-the-address. The explainer on the ledger already // taught the user the two-mode split, so the editor must not restate it - // as a second decision screen — no "Platform API" card header here. + // as a second decision screen — no "Platform API" card header, no + // always-visible "install agent" subtext competing with the primary flow. + // The agent path is surfaced by the ledger header, not duplicated here. expect(screen.getByRole('button', { name: /probe address/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /enter credentials manually/i })).toBeInTheDocument(); expect(screen.queryByText('Platform API')).toBeNull(); - - // Agent install is still reachable, but as a contextual offramp beneath - // the probe, not as an equal-weight card. + expect(screen.queryByText(/host-level metrics/i)).toBeNull(); expect( - screen.getByRole('button', { name: /install the unified agent on a host/i }), - ).toBeInTheDocument(); - expect(screen.getByText(/host-level metrics/i)).toBeInTheDocument(); - expect(screen.queryByText(/host-level telemetry/i)).toBeNull(); + screen.queryByRole('button', { name: /install the unified agent on a host/i }), + ).toBeNull(); }); - it('routes the Install Unified Agent button straight to the agent credential slot', () => { + 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}
); render(() => ( @@ -128,7 +128,18 @@ describe('ConnectionEditor', () => { /> )); - fireEvent.click(screen.getByRole('button', { name: /install the unified agent on a host/i })); + const input = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement; + fireEvent.input(input, { target: { value: 'baremetal.lan' } }); + fireEvent.click(screen.getByRole('button', { name: /probe address/i })); + await waitFor(() => expect(mockedProbe).toHaveBeenCalled()); + + // The no-match box names bare-metal Linux / Unraid / FreeBSD and offers + // 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, + }); + fireEvent.click(agentButton); expect(screen.getByTestId('slot').textContent).toBe('slot:agent'); const call = renderSlot.mock.calls.at(-1)![0]; diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index 95a097845..1f1e7baed 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -135,6 +135,15 @@ const InfrastructureWorkspaceContent: Component = }, tone: 'primary' as const, }, + { + label: 'Install agent', + onSelect: () => { + setInitialAddType('agent'); + setAddMode(true); + setShowAgentProfiles(false); + }, + tone: 'secondary' as const, + }, ], ); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 4aba85643..20c2800f2 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -232,13 +232,13 @@ describe('InfrastructureWorkspace', () => { expect(setSearchParamsSpy).not.toHaveBeenCalled(); }); - it('routes to the agent install slot via the dedicated Unified Agent path', () => { + it('routes to the agent install slot via the ledger-header Install agent button', () => { renderWorkspace(); - fireEvent.click(screen.getByRole('button', { name: /Add connection/i })); - fireEvent.click( - screen.getByRole('button', { name: /Install the Unified Agent on a host/i }), - ); + // The agent path is a first-class peer action in the ledger header, 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: /^Install agent$/i })); expect(screen.getByTestId('install-section')).toBeInTheDocument(); }); @@ -276,10 +276,7 @@ describe('InfrastructureWorkspace', () => { it('can return to the probe step from a credential slot', () => { renderWorkspace(); - fireEvent.click(screen.getByRole('button', { name: /Add connection/i })); - fireEvent.click( - screen.getByRole('button', { name: /Install the Unified Agent on a host/i }), - ); + fireEvent.click(screen.getByRole('button', { name: /^Install agent$/i })); fireEvent.click(screen.getByRole('button', { name: /Back to probe/i })); expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument(); @@ -289,10 +286,7 @@ describe('InfrastructureWorkspace', () => { it('toggles agent profiles inside the agent install slot', () => { renderWorkspace(); - fireEvent.click(screen.getByRole('button', { name: /Add connection/i })); - fireEvent.click( - screen.getByRole('button', { name: /Install the Unified Agent on a host/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(); @@ -372,11 +366,12 @@ describe('InfrastructureWorkspace', () => { expect(screen.queryByTestId('truenas-section')).toBeNull(); }); - it('hides Add connection and the add flow in read-only mode', () => { + it('hides Add connection, Install agent, and the add flow in read-only mode', () => { presentationPolicyIsReadOnlyMock.mockReturnValue(true); renderWorkspace(); 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 e149768ee..a946caef9 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -31,9 +31,8 @@ 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 in one ledger'); - expect(settingsHeaderMetaSource).toContain( - 'use Add connection when you need platform setup or agent install commands.', - ); + expect(settingsHeaderMetaSource).toContain('Add connection for Platform API setup'); + expect(settingsHeaderMetaSource).toContain('Install agent for host-level metrics'); expect(settingsNavigationHookSource).toContain('deriveAddStepFromLegacyPath(path)'); expect(settingsNavigationHookSource).toContain( @@ -78,11 +77,18 @@ describe('settings architecture guardrails', () => { expect(connectionEditorSource).toContain("import { AddressProbeStep } from './AddressProbeStep';"); expect(connectionEditorSource).toContain('const DEFAULT_MANUAL_TYPES: ConnectionType[] ='); expect(connectionEditorSource).toContain('