infra: replace Add picker with Datadog-style catalog landing

The intermediate two-card picker screen ("Add connection" vs "Install
agent") restated the same decision twice and framed the agent path as a
walled-off alternative. Replace it with a single catalog landing that
matches how Datadog, Grafana, and GCP Marketplace present "add an
integration" — an address probe at the top, and a tile grid below
listing Proxmox VE / PBS / PMG / VMware / TrueNAS / Install Pulse Agent
as peer entries. Picking a tile or a probed candidate drops straight
into that product's credential slot; "Back to catalog" returns to the
landing.

Retires the ConnectionsExplainer component, the picker-mode state in
InfrastructureWorkspace, and the "Enter credentials manually" toggle
in AddressProbeStep. Tests updated for the shortened path (one click
from ledger header to any credential slot).
This commit is contained in:
rcourtman
2026-04-20 15:01:51 +01:00
parent c4a4d175ce
commit b3abb746bc
8 changed files with 116 additions and 433 deletions
@@ -7,7 +7,6 @@ import { CONNECTION_TYPE_LABELS } from './useConnectionEditor';
export interface AddressProbeStepProps {
state: ConnectionEditorState;
onSelectCandidate: (candidate: ProbeCandidate) => void;
onChooseManually: () => void;
onInstallAgent?: () => void;
}
@@ -47,13 +46,6 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
>
{props.state.phase() === 'probing' ? 'Probing…' : 'Probe address'}
</button>
<button
type="button"
class="inline-flex items-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
onClick={props.onChooseManually}
>
Enter credentials manually
</button>
</div>
<Show when={props.state.phase() === 'error' && props.state.errorMessage().length > 0}>
@@ -66,8 +58,8 @@ 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 Platform API type manually and enter credentials, or if this is
bare-metal Linux / Unraid / FreeBSD,{' '}
Pick your system from the catalog below, or if this is bare-metal Linux / Unraid /
FreeBSD,{' '}
<Show
when={props.onInstallAgent}
fallback={<span class="font-medium">install the Unified Agent instead</span>}
@@ -1,4 +1,5 @@
import { Component, type JSX, Show, createMemo, createSignal } from 'solid-js';
import { Component, For, type JSX, Show, createMemo, createSignal } from 'solid-js';
import { Archive, Cpu, Database, Mail, Server, ServerCog } from 'lucide-solid';
import type { ConnectionType, ProbeCandidate } from '@/api/connections';
import { AddressProbeStep } from './AddressProbeStep';
import {
@@ -29,7 +30,24 @@ export interface ConnectionEditorProps {
onSaved?: () => void;
}
const DEFAULT_MANUAL_TYPES: ConnectionType[] = ['pve', 'pbs', 'pmg', 'truenas', 'vmware'];
const DEFAULT_MANUAL_TYPES: ConnectionType[] = ['pve', 'pbs', 'pmg', 'vmware', 'truenas', 'agent'];
interface TileMeta {
icon: Component<{ class?: string }>;
description: string;
}
const TILE_META: Partial<Record<ConnectionType, TileMeta>> = {
pve: { icon: Server, description: 'VMs, containers, storage, backups' },
pbs: { icon: Archive, description: 'Backups, sync and verify jobs' },
pmg: { icon: Mail, description: 'Mail stats, queues, quarantine' },
vmware: { icon: ServerCog, description: 'vCenter or ESXi clusters' },
truenas: { icon: Database, description: 'Pools, datasets, replications' },
agent: {
icon: Cpu,
description: 'Host metrics, or bare-metal Linux / Unraid / FreeBSD',
},
};
export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
const state: ConnectionEditorState = createConnectionEditorState();
@@ -41,7 +59,6 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
props.initialType ?? null,
);
const [selectedCandidate, setSelectedCandidate] = createSignal<ProbeCandidate | null>(null);
const [manualPickerOpen, setManualPickerOpen] = createSignal(false);
const manualOptions = createMemo(() => props.manualTypeOptions ?? DEFAULT_MANUAL_TYPES);
@@ -51,19 +68,16 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
const chooseCandidate = (candidate: ProbeCandidate) => {
setSelectedCandidate(candidate);
setSelectedType(candidate.type);
setManualPickerOpen(false);
};
const chooseManualType = (type: ConnectionType) => {
setSelectedCandidate(null);
setSelectedType(type);
setManualPickerOpen(false);
};
const reopenProbe = () => {
setSelectedCandidate(null);
setSelectedType(null);
setManualPickerOpen(false);
};
const handleSaved = () => {
@@ -76,42 +90,64 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
<Show
when={showCredentialSlot()}
fallback={
<div class="space-y-5 p-4">
<div class="space-y-6 p-4">
<AddressProbeStep
state={state}
onSelectCandidate={chooseCandidate}
onChooseManually={() => setManualPickerOpen((v) => !v)}
onInstallAgent={() => chooseManualType('agent')}
/>
<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>
<div class="flex items-center gap-3 text-xs font-semibold uppercase tracking-wide text-muted">
<span class="h-px flex-1 bg-border" aria-hidden="true" />
Or pick your system directly
<span class="h-px flex-1 bg-border" aria-hidden="true" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={manualOptions()}>
{(type) => {
const meta = TILE_META[type];
const Icon = meta?.icon ?? Server;
const label = CONNECTION_TYPE_LABELS[type] ?? type;
const isAgent = type === 'agent';
return (
<button
type="button"
onClick={() => chooseManualType(type)}
class="group flex h-full flex-col gap-2 rounded-lg border border-border bg-surface p-4 text-left transition-colors hover:border-blue-500 hover:bg-blue-50/40 dark:hover:bg-blue-950/20"
>
<div class="flex items-center gap-2.5">
<div
aria-hidden="true"
class={
isAgent
? 'flex h-8 w-8 flex-none items-center justify-center rounded-md border border-blue-200 bg-blue-100 text-blue-700 dark:border-blue-900 dark:bg-blue-900/40 dark:text-blue-300'
: 'flex h-8 w-8 flex-none items-center justify-center rounded-md border border-border bg-surface-alt text-base-content'
}
>
<Icon class="h-4 w-4" />
</div>
<div class="text-sm font-semibold text-base-content">
{isAgent ? 'Install Pulse Agent' : label}
</div>
</div>
<Show when={meta?.description}>
<div class="text-xs text-muted">{meta!.description}</div>
</Show>
</button>
);
}}
</For>
</div>
</div>
}
>
<div class="flex items-center justify-between border-b border-border px-4 py-2">
<div class="text-sm">
<span class="font-semibold text-base-content">
{CONNECTION_TYPE_LABELS[activeType()!] ?? activeType()}
{activeType() === 'agent'
? 'Install Pulse Agent'
: CONNECTION_TYPE_LABELS[activeType()!] ?? activeType()}
</span>
<Show when={selectedCandidate()}>
<span class="ml-2 text-xs text-muted">{selectedCandidate()!.host}</span>
@@ -123,7 +159,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
onClick={reopenProbe}
class="inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Back to probe
Back to catalog
</button>
</Show>
</div>
@@ -62,7 +62,7 @@ describe('ConnectionEditor', () => {
expect(lastCall.mode).toBe('add');
});
it('falls back to manual type selection when probe returns no match', async () => {
it('lets the user pick a product tile when probe returns no match', async () => {
mockedProbe.mockResolvedValueOnce({ candidates: [], probedMs: 203 });
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
@@ -84,9 +84,9 @@ describe('ConnectionEditor', () => {
await screen.findByText(/no supported product detected/i);
fireEvent.click(screen.getByRole('button', { name: /enter credentials manually/i }));
fireEvent.click(screen.getByText('TrueNAS SCALE'));
// The catalog grid is always visible below the probe, so the user picks a
// tile directly — no intermediate "enter credentials manually" toggle.
fireEvent.click(screen.getByRole('button', { name: /TrueNAS SCALE/i }));
await waitFor(() => expect(screen.getByTestId('slot').textContent).toBe('slot:truenas'));
const lastCall = renderSlot.mock.calls.at(-1)![0];
@@ -94,7 +94,7 @@ describe('ConnectionEditor', () => {
expect(lastCall.candidate).toBeNull();
});
it('leads with the address probe and does not re-teach the two-mode split', () => {
it('surfaces the probe input and the full product catalog as peers', () => {
render(() => (
<ConnectionEditor
renderCredentialSlot={() => <div />}
@@ -102,15 +102,15 @@ 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, no
// always-visible "install agent" subtext competing with the primary flow.
// The agent path is surfaced by the ledger header, not duplicated here.
// Catalog landing — the probe input sits above a tile grid that includes
// every supported product as a peer, Install Pulse Agent included. There
// is no intermediate mode picker or "Platform API" framing.
expect(screen.getByRole('button', { name: /probe address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /enter credentials manually/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Proxmox VE/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /TrueNAS SCALE/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.queryByText('Platform API')).toBeNull();
expect(screen.queryByText(/host-level metrics/i)).toBeNull();
expect(
screen.queryByRole('button', { name: /install the unified agent on a host/i }),
).toBeNull();
@@ -147,27 +147,6 @@ describe('ConnectionEditor', () => {
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>);
@@ -1,143 +0,0 @@
import { Component, For, Show } from 'solid-js';
import { ArrowRight, Cloud, Cpu } from 'lucide-solid';
const OPT_IN_CAPABILITIES = ['Assistant commands', 'Patrol remediation'];
const AGENT_FACTS = [
'Single Go binary',
'~13 MB download',
'No runtime dependencies',
'Open source',
];
interface ConnectionsExplainerProps {
onAddConnection?: () => void;
onInstallAgent?: () => void;
readOnly?: boolean;
}
export const ConnectionsExplainer: Component<ConnectionsExplainerProps> = (props) => {
const interactive = () => !props.readOnly;
const ctaClass =
'mt-4 inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
return (
<section
aria-label="Add infrastructure"
class="relative overflow-hidden rounded-lg border border-border bg-surface"
>
<div class="border-b border-border px-5 py-3">
<h3 class="text-sm font-semibold text-base-content">How Pulse collects data</h3>
<p class="mt-0.5 text-xs text-muted">
The platform API covers workloads. The Unified Agent adds host-level metrics, and stands
alone where there's no API.
</p>
</div>
<div class="grid grid-cols-1 divide-border md:grid-cols-2 md:divide-x">
<div class="flex flex-col p-5">
<div class="flex items-start gap-3">
<div
aria-hidden="true"
class="flex h-9 w-9 flex-none items-center justify-center rounded-lg border border-border bg-surface-alt text-base-content"
>
<Cloud class="h-4 w-4" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-base-content">Platform API</div>
<div class="text-xs text-muted">Primary source for workloads</div>
</div>
</div>
<p class="mt-3 text-xs leading-relaxed text-muted">
Pulse polls the platform's own API (Proxmox VE / PBS / PMG, VMware, TrueNAS) for VMs,
containers, storage, backups, and other workload data. Required for every API-backed
target; fastest to set up.
</p>
<div class="mt-auto">
<Show when={interactive()} fallback={<div class="mt-4 h-0" />}>
<button
type="button"
onClick={() => props.onAddConnection?.()}
disabled={!props.onAddConnection}
class={ctaClass}
>
Add connection
<ArrowRight class="h-4 w-4" aria-hidden="true" />
</button>
</Show>
</div>
</div>
<div class="relative flex flex-col bg-blue-50/40 p-5 dark:bg-blue-950/20">
<div
aria-hidden="true"
class="absolute inset-y-0 left-0 w-0.5 bg-blue-500 md:hidden"
/>
<div class="flex items-start gap-3">
<div
aria-hidden="true"
class="flex h-9 w-9 flex-none items-center justify-center rounded-lg border border-blue-200 bg-blue-100 text-blue-700 dark:border-blue-900 dark:bg-blue-900/40 dark:text-blue-300"
>
<Cpu class="h-4 w-4" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-base-content">Pulse Unified Agent</div>
<div class="text-xs text-muted">Runs on the host, alongside the API</div>
</div>
</div>
<p class="mt-3 text-xs leading-relaxed text-muted">
On Proxmox / VMware / TrueNAS, the agent supplements the API with data it can't expose
(CPU and disk temperatures, SMART, power, Ceph/RAID). On bare-metal Linux, Unraid, or
FreeBSD with no platform API, it's the only path.
</p>
<div class="mt-4">
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wide text-muted">
Off by default, opt in per host
</div>
<div class="flex flex-wrap gap-1.5">
<For each={OPT_IN_CAPABILITIES}>
{(label) => (
<span class="inline-flex items-center rounded-full border border-dashed border-border bg-surface px-2 py-0.5 text-[11px] font-medium text-muted">
{label}
</span>
)}
</For>
</div>
</div>
<ul class="mt-4 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted">
<For each={AGENT_FACTS}>
{(fact, index) => (
<li class="flex items-center gap-1.5">
<Show when={index() > 0}>
<span aria-hidden="true" class="text-border">
·
</span>
</Show>
{fact}
</li>
)}
</For>
</ul>
<div class="mt-auto">
<Show when={interactive()} fallback={<div class="mt-4 h-0" />}>
<button
type="button"
onClick={() => props.onInstallAgent?.()}
disabled={!props.onInstallAgent}
class={ctaClass}
>
Install agent
<ArrowRight class="h-4 w-4" aria-hidden="true" />
</button>
</Show>
</div>
</div>
</div>
</section>
);
};
@@ -4,7 +4,6 @@ import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy
import { copyToClipboard } from '@/utils/clipboard';
import { notificationStore } from '@/stores/notifications';
import { AgentProfilesPanel } from './AgentProfilesPanel';
import { ConnectionsExplainer } from './ConnectionsExplainer';
import { ConnectionsTable, type ConnectionsTableHeaderAction } from './ConnectionsTable';
import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';
import { NodeCredentialSlot } from './ConnectionEditor/CredentialSlots/NodeCredentialSlot';
@@ -50,7 +49,6 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const rowActions = useConnectionRowActions({ onMutated: () => ledger.reload() });
const [addMode, setAddMode] = createSignal(false);
const [addPickerMode, setAddPickerMode] = createSignal(false);
const [initialAddType, setInitialAddType] = createSignal<ConnectionType | null>(null);
const [showAgentProfiles, setShowAgentProfiles] = createSignal(false);
const [editingConnection, setEditingConnection] = createSignal<Connection | null>(null);
@@ -99,17 +97,14 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
if (!readOnly() && step) {
setAddMode(true);
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);
});
@@ -128,27 +123,10 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const startAddInfrastructure = () => {
setInitialAddType(null);
setAddPickerMode(true);
setAddMode(true);
setShowAgentProfiles(false);
};
const pickPlatformApi = () => {
setInitialAddType(null);
setAddPickerMode(false);
};
const pickAgent = () => {
setInitialAddType('agent');
setAddPickerMode(false);
};
const backToPicker = () => {
setInitialAddType(null);
setAddPickerMode(true);
setShowAgentProfiles(false);
};
const headerActions = createMemo<ConnectionsTableHeaderAction[]>(() =>
readOnly()
? []
@@ -177,7 +155,6 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const exitAddMode = () => {
setAddMode(false);
setAddPickerMode(false);
setInitialAddType(null);
setShowAgentProfiles(false);
};
@@ -333,61 +310,22 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
}}
</Match>
<Match when={mode() === 'add' && addPickerMode()}>
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-base font-semibold text-base-content">Add infrastructure</div>
<div class="mt-0.5 text-xs text-muted">
Pick how Pulse should connect to this system.
</div>
</div>
<button
type="button"
onClick={exitAddMode}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
← Back to systems
</button>
</div>
<ConnectionsExplainer
readOnly={readOnly()}
onAddConnection={readOnly() ? undefined : pickPlatformApi}
onInstallAgent={readOnly() ? undefined : pickAgent}
/>
</div>
</Match>
<Match when={mode() === 'add' && !addPickerMode()}>
<Match when={mode() === 'add'}>
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-base font-semibold text-base-content">
{initialAddType() === 'agent' ? 'Install agent' : 'Add connection'}
</div>
<div class="text-base font-semibold text-base-content">Add infrastructure</div>
<div class="mt-0.5 text-xs text-muted">
{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.'}
Paste an address to auto-detect, or pick your system from the catalog.
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onClick={backToPicker}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
← Change method
</button>
<button
type="button"
onClick={exitAddMode}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Back to systems
</button>
</div>
<button
type="button"
onClick={exitAddMode}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
← Back to systems
</button>
</div>
<div class="rounded-lg border border-border bg-surface">
@@ -1,87 +0,0 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConnectionsExplainer } from '../ConnectionsExplainer';
describe('ConnectionsExplainer', () => {
afterEach(() => {
cleanup();
});
it('names both ingestion modes with their branded labels', () => {
render(() => <ConnectionsExplainer />);
expect(screen.getByText('Platform API')).toBeInTheDocument();
expect(screen.getByText('Pulse Unified Agent')).toBeInTheDocument();
});
it('frames the agent as supplementary to the API, not a replacement', () => {
render(() => <ConnectionsExplainer />);
expect(screen.queryByText(/Recommended/i)).toBeNull();
expect(screen.getByText(/supplements the API/i)).toBeInTheDocument();
expect(screen.getByText(/Primary source for workloads/i)).toBeInTheDocument();
});
it('calls out that Assistant / Patrol command execution is opt-in, not default', () => {
render(() => <ConnectionsExplainer />);
expect(screen.getByText(/off by default, opt in per host/i)).toBeInTheDocument();
for (const capability of ['Assistant commands', 'Patrol remediation']) {
expect(screen.getByText(capability)).toBeInTheDocument();
}
// The old "Always on: Hardware metrics" framing read as unavoidable
// surveillance. The agent paragraph already names what it collects;
// we don't restate it as an always-on chip.
expect(screen.queryByText(/^Always on$/i)).toBeNull();
expect(screen.queryByText('Hardware metrics')).toBeNull();
});
it('surfaces trust facts users care about', () => {
render(() => <ConnectionsExplainer />);
for (const fact of ['Single Go binary', '~13 MB download', 'No runtime dependencies', 'Open source']) {
expect(screen.getByText(fact)).toBeInTheDocument();
}
});
it('exposes Add connection and Install agent as the entry CTAs, not as duplicate buttons elsewhere', () => {
const onAddConnection = vi.fn();
const onInstallAgent = vi.fn();
render(() => (
<ConnectionsExplainer
onAddConnection={onAddConnection}
onInstallAgent={onInstallAgent}
/>
));
const addBtn = screen.getByRole('button', { name: /^Add connection$/i });
const installBtn = screen.getByRole('button', { name: /^Install agent$/i });
fireEvent.click(addBtn);
expect(onAddConnection).toHaveBeenCalledTimes(1);
expect(onInstallAgent).not.toHaveBeenCalled();
fireEvent.click(installBtn);
expect(onInstallAgent).toHaveBeenCalledTimes(1);
});
it('does not render the action CTAs in read-only mode', () => {
render(() => (
<ConnectionsExplainer
readOnly
onAddConnection={() => {}}
onInstallAgent={() => {}}
/>
));
expect(screen.queryByRole('button', { name: /^Add connection$/i })).toBeNull();
expect(screen.queryByRole('button', { name: /^Install agent$/i })).toBeNull();
});
it('no longer offers a dismiss affordance — the cards are the entry path, not a tutorial banner', () => {
render(() => <ConnectionsExplainer />);
expect(screen.queryByRole('button', { name: /Dismiss/i })).toBeNull();
});
});
@@ -221,83 +221,68 @@ describe('InfrastructureWorkspace', () => {
expect(screen.getByText('Paused')).toBeInTheDocument();
});
it('opens the picker screen when Add infrastructure is clicked', () => {
it('opens the catalog landing when Add infrastructure is clicked', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
// 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();
// The catalog landing surfaces the probe input and a tile grid of every
// supported product — including Install Pulse Agent as a peer tile — with
// no intermediate picker screen.
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Proxmox VE/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /TrueNAS SCALE/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i })).toBeInTheDocument();
expect(navigateSpy).not.toHaveBeenCalled();
expect(setSearchParamsSpy).not.toHaveBeenCalled();
});
it('routes from the picker into the probe flow when Add connection is chosen', () => {
it('routes to the agent install slot when Install Pulse Agent tile is clicked', () => {
renderWorkspace();
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 }));
fireEvent.click(screen.getByRole('button', { name: /Install Pulse Agent/i }));
expect(screen.getByTestId('install-section')).toBeInTheDocument();
});
it('routes to the TrueNAS credential slot when TrueNAS is picked manually', () => {
it('routes to the TrueNAS credential slot when TrueNAS tile is clicked', () => {
renderWorkspace();
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 }));
expect(screen.getByTestId('truenas-section')).toBeInTheDocument();
});
it('routes to the VMware credential slot when VMware is picked manually', () => {
it('routes to the VMware credential slot when VMware tile is clicked', () => {
renderWorkspace();
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 }));
expect(screen.getByTestId('vmware-section')).toBeInTheDocument();
});
it('routes to the Proxmox credential slot when Proxmox VE is picked manually', () => {
it('routes to the Proxmox credential slot when Proxmox VE tile is clicked', () => {
renderWorkspace();
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 }));
expect(screen.getByTestId('proxmox-section')).toBeInTheDocument();
});
it('can return to the probe step from a credential slot', () => {
it('can return to the catalog 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 }));
fireEvent.click(screen.getByRole('button', { name: /Install Pulse Agent/i }));
fireEvent.click(screen.getByRole('button', { name: /Back to catalog/i }));
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.queryByTestId('install-section')).toBeNull();
});
@@ -305,28 +290,12 @@ describe('InfrastructureWorkspace', () => {
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: /Install Pulse 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();
@@ -376,17 +345,18 @@ describe('InfrastructureWorkspace', () => {
await waitFor(() => expect(screen.getByTestId('install-section')).toBeInTheDocument());
});
it('clears the canonical query onboarding route for platform picking and lands on the picker', async () => {
it('clears the canonical query onboarding route for platform picking and lands on the catalog', async () => {
routeState.search = '?add=pick';
renderWorkspace();
expect(setSearchParamsSpy).toHaveBeenCalledWith({ add: null }, { replace: true });
expect(navigateSpy).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.getByRole('button', { name: /^Add connection$/i })).toBeInTheDocument(),
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument(),
);
// Picker screen — probe hasn't started yet, install section isn't open.
expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull();
// Catalog landing — probe input + tile grid are visible, no credential
// slot has been entered yet.
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.queryByTestId('install-section')).toBeNull();
});
@@ -408,8 +378,7 @@ describe('InfrastructureWorkspace', () => {
renderWorkspace();
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: /Install Pulse Agent/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull();
expect(screen.queryByTestId('install-section')).toBeNull();
});
@@ -83,7 +83,6 @@ describe('settings architecture guardrails', () => {
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.