mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 20:22:53 +00:00
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:
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
+22
-11
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user