Carry the Platform API / Unified Agent vocabulary into the add flow

The explainer at the top of the ledger teaches two paths (Platform API
and Pulse Unified Agent), but clicking Add dropped that vocabulary: the
user landed on a generic address box with no label and could only reach
the agent by clicking "Enter credentials manually" and picking it from
a flat list of six raw-token types.

Reshape the pre-credential step into two labeled sections that mirror
the explainer:

- "Platform API" section wraps the probe input and the manual-type
  fallback (now filtered to the five API types; heading reads "Choose
  Platform API type manually").
- "Pulse Unified Agent" section with a direct "Install the Unified
  Agent on a host" button, tinted to match the explainer's agent card.

Also aligns the CONNECTION_TYPE_LABELS entry for agent to "Pulse Unified
Agent" so the header on the install surface matches the brand used in
the ledger and the explainer.
This commit is contained in:
rcourtman
2026-04-20 09:47:32 +01:00
parent da23b5fb9e
commit 2e1e682f70
4 changed files with 122 additions and 35 deletions
@@ -29,7 +29,7 @@ export interface ConnectionEditorProps {
onSaved?: () => void;
}
const DEFAULT_MANUAL_TYPES: ConnectionType[] = ['pve', 'pbs', 'pmg', 'truenas', 'vmware', 'agent'];
const DEFAULT_MANUAL_TYPES: ConnectionType[] = ['pve', 'pbs', 'pmg', 'truenas', 'vmware'];
export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
const state: ConnectionEditorState = createConnectionEditorState();
@@ -80,38 +80,68 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
<div>
<div class="text-sm font-semibold text-base-content">Add a connection</div>
<div class="mt-0.5 text-xs text-muted">
Paste an address and Pulse detects the product. One flow for every supported
platform.
Paste a platform address to connect its API, or install the Unified Agent on a
host.
</div>
</div>
<AddressProbeStep
state={state}
onSelectCandidate={chooseCandidate}
onChooseManually={() => setManualPickerOpen((v) => !v)}
/>
<Show when={manualPickerOpen()}>
<div class="space-y-2 rounded-md border border-border bg-surface p-3">
<section class="space-y-3 rounded-md border border-border bg-surface-alt/30 p-3">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-muted">
Choose type manually
Platform API
</div>
<div class="text-[11px] text-muted">
Proxmox VE / PBS / PMG, VMware, TrueNAS
</div>
<ul class="divide-y divide-border rounded-md border border-border">
{manualOptions().map((type) => (
<li>
<button
type="button"
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-base-content transition-colors hover:bg-surface-hover"
onClick={() => chooseManualType(type)}
>
<span>{CONNECTION_TYPE_LABELS[type] ?? type}</span>
<span class="text-xs text-muted">{type}</span>
</button>
</li>
))}
</ul>
</div>
</Show>
<AddressProbeStep
state={state}
onSelectCandidate={chooseCandidate}
onChooseManually={() => setManualPickerOpen((v) => !v)}
/>
<Show when={manualPickerOpen()}>
<div class="space-y-2 rounded-md border border-border bg-surface p-3">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">
Choose Platform API type manually
</div>
<ul class="divide-y divide-border rounded-md border border-border">
{manualOptions().map((type) => (
<li>
<button
type="button"
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-base-content transition-colors hover:bg-surface-hover"
onClick={() => chooseManualType(type)}
>
<span>{CONNECTION_TYPE_LABELS[type] ?? type}</span>
<span class="text-xs text-muted">{type}</span>
</button>
</li>
))}
</ul>
</div>
</Show>
</section>
<section class="space-y-3 rounded-md border border-blue-200 bg-blue-50/40 p-3 dark:border-blue-900 dark:bg-blue-950/20">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-muted">
Pulse Unified Agent
</div>
<div class="text-[11px] text-muted">
Host-level telemetry on Proxmox / VMware / TrueNAS, or the only path on
bare-metal Linux, Unraid, FreeBSD.
</div>
</div>
<button
type="button"
onClick={() => chooseManualType('agent')}
class="inline-flex items-center rounded-md border border-blue-600 bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-500"
>
Install the Unified Agent on a host
</button>
</section>
</div>
}
>
@@ -94,6 +94,60 @@ describe('ConnectionEditor', () => {
expect(lastCall.candidate).toBeNull();
});
it('surfaces Platform API and Pulse Unified Agent as the two add-paths, mirroring the explainer', () => {
render(() => (
<ConnectionEditor
renderCredentialSlot={() => <div />}
onClose={() => {}}
/>
));
expect(screen.getByText('Platform API')).toBeInTheDocument();
expect(screen.getByText('Pulse Unified Agent')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /install the unified agent on a host/i }),
).toBeInTheDocument();
});
it('routes the Install Unified Agent button straight to the agent credential slot', () => {
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
render(() => (
<ConnectionEditor
renderCredentialSlot={renderSlot}
onClose={() => {}}
/>
));
fireEvent.click(screen.getByRole('button', { name: /install the unified agent on a host/i }));
expect(screen.getByTestId('slot').textContent).toBe('slot:agent');
const call = renderSlot.mock.calls.at(-1)![0];
expect(call.type).toBe('agent');
expect(call.candidate).toBeNull();
});
it('keeps agent out of the Platform API manual picker (it has its own path)', async () => {
mockedProbe.mockResolvedValueOnce({ candidates: [], probedMs: 150 });
render(() => (
<ConnectionEditor
renderCredentialSlot={() => <div />}
onClose={() => {}}
/>
));
const input = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
fireEvent.input(input, { target: { value: 'example.lan' } });
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
await waitFor(() => expect(mockedProbe).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', { name: /enter credentials manually/i }));
expect(screen.getByText(/choose platform api type manually/i)).toBeInTheDocument();
expect(screen.queryByText(/Agent \(install on host\)/i)).toBeNull();
});
it('skips the probe step when an initialType is supplied (edit mode)', () => {
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
@@ -102,7 +102,7 @@ export const CONNECTION_TYPE_LABELS: Record<ConnectionType, string> = {
pmg: 'Proxmox Mail Gateway',
vmware: 'VMware vCenter / ESXi',
truenas: 'TrueNAS SCALE',
agent: 'Agent (install on host)',
agent: 'Pulse Unified Agent',
docker: 'Docker',
kubernetes: 'Kubernetes',
};
@@ -233,12 +233,13 @@ describe('InfrastructureWorkspace', () => {
expect(setSearchParamsSpy).not.toHaveBeenCalled();
});
it('routes to the agent install slot when Pulse agent is picked manually', () => {
it('routes to the agent install slot via the dedicated Unified Agent path', () => {
renderWorkspace();
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: /Agent \(install on host\)/i }));
fireEvent.click(
screen.getByRole('button', { name: /Install the Unified Agent on a host/i }),
);
expect(screen.getByTestId('install-section')).toBeInTheDocument();
});
@@ -277,8 +278,9 @@ describe('InfrastructureWorkspace', () => {
renderWorkspace();
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: /Agent \(install on host\)/i }));
fireEvent.click(
screen.getByRole('button', { name: /Install the Unified Agent on a host/i }),
);
fireEvent.click(screen.getByRole('button', { name: /Back to probe/i }));
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
@@ -289,8 +291,9 @@ describe('InfrastructureWorkspace', () => {
renderWorkspace();
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: /Agent \(install on host\)/i }));
fireEvent.click(
screen.getByRole('button', { name: /Install the Unified Agent on a host/i }),
);
fireEvent.click(screen.getByRole('button', { name: 'Manage agent profiles' }));
expect(screen.getByTestId('agent-profiles')).toBeInTheDocument();