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.
This commit is contained in:
rcourtman
2026-04-20 11:46:10 +01:00
parent 1eccdde2b4
commit eb505a9371
7 changed files with 68 additions and 44 deletions
@@ -8,6 +8,7 @@ export interface AddressProbeStepProps {
state: ConnectionEditorState;
onSelectCandidate: (candidate: ProbeCandidate) => void;
onChooseManually: () => void;
onInstallAgent?: () => void;
}
export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
@@ -65,7 +66,21 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
<div class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
<div class="font-medium">No supported product detected at that address.</div>
<div class="mt-1 text-xs">
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,{' '}
<Show
when={props.onInstallAgent}
fallback={<span class="font-medium">install the Unified Agent instead</span>}
>
<button
type="button"
onClick={props.onInstallAgent}
class="font-medium underline underline-offset-2 hover:text-amber-950 dark:hover:text-amber-50"
>
install the Unified Agent instead
</button>
</Show>
.
</div>
</div>
</Show>
@@ -81,6 +81,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
state={state}
onSelectCandidate={chooseCandidate}
onChooseManually={() => setManualPickerOpen((v) => !v)}
onInstallAgent={() => chooseManualType('agent')}
/>
<Show when={manualPickerOpen()}>
@@ -104,19 +105,6 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
</ul>
</div>
</Show>
<div class="border-t border-border pt-3 text-xs text-muted">
Setting up bare-metal Linux, Unraid, or FreeBSD, or adding host-level
metrics (CPU/disk temps, SMART, power) on Proxmox, VMware, or TrueNAS?{' '}
<button
type="button"
onClick={() => chooseManualType('agent')}
class="font-medium text-blue-600 underline underline-offset-2 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Install the Unified Agent on a host
</button>
.
</div>
</div>
}
>
@@ -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(() => (
<ConnectionEditor
renderCredentialSlot={() => <div />}
@@ -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 }) => <div data-testid="slot">slot:{type}</div>);
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];
@@ -135,6 +135,15 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
},
tone: 'primary' as const,
},
{
label: 'Install agent',
onSelect: () => {
setInitialAddType('agent');
setAddMode(true);
setShowAgentProfiles(false);
},
tone: 'secondary' as const,
},
],
);
@@ -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();
});
@@ -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('<AddressProbeStep');
expect(connectionEditorSource).toContain('Install the Unified Agent on a host');
// The agent install path is a first-class ledger-header action, not a
// subtext offramp inside the editor — make sure it doesn't drift back.
expect(connectionEditorSource).not.toContain('Install the Unified Agent on a host');
expect(connectionEditorSource).not.toContain('NodeModal');
expect(addressProbeStepSource).toContain('Probe address');
expect(addressProbeStepSource).toContain('Enter credentials manually');
// 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('bare-metal Linux');
expect(connectionEditorStateSource).toContain('ConnectionsAPI.probe(value)');
expect(connectionEditorStateSource).toContain('export const CONNECTION_TYPE_LABELS');
@@ -10,7 +10,7 @@ export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = {
'infrastructure-systems': {
title: 'Infrastructure',
description:
'Review monitored systems in one ledger, then use Add connection when you need platform setup or agent install commands.',
'Review monitored systems in one ledger. Add connection for Platform API setup; Install agent for host-level metrics or bare-metal Linux / Unraid / FreeBSD.',
},
'system-general': {
title: 'General',