Neutralize infrastructure add catalog posture

This commit is contained in:
rcourtman
2026-04-21 16:56:14 +01:00
parent 0d5bec8a81
commit 55df0041ec
10 changed files with 172 additions and 130 deletions
@@ -1,13 +1,13 @@
{
"schema_version": 1,
"default_infrastructure_source_order": [
"proxmox-pve",
"agent",
"docker",
"truenas",
"proxmox-pve",
"proxmox-pbs",
"proxmox-pmg",
"kubernetes",
"truenas"
"docker",
"kubernetes"
],
"platforms": [
{
@@ -1001,18 +1001,21 @@ surfaces grow a second VMware availability fetch or a VMware-only handoff
path.
That same infrastructure workspace boundary now also owns the first-run
handoff copy for new operators. `InfrastructureWorkspace.tsx` must keep
`Install on a host` visible as the first monitored-system path while still
presenting `Platform connections` as the explicit API-backed alternative
instead of leaving first-session install guidance implicit in generic
settings-shell prose or retreating to one provider's name as the primary
alternative.
`Install on a host` and `Platform connections` explicit in the shared
workspace instead of leaving first-session guidance implicit in generic
settings-shell prose or retreating to one provider's name or one onboarding
mode as the primary story.
That same first-run infrastructure handoff now also owns the inline
add-infrastructure landing in `ConnectionEditor.tsx`: when the target can run
the Unified Agent, the landing must lead with the recommended host-install
card above the platform-API probe and picker instead of teaching probe-first
as the default story, and returning `Back to catalog` must reset probe input
and result state rather than reopening the landing with stale no-match or
detected-product state already rendered.
the Unified Agent, the landing must lead with the platform-API probe and
picker as the primary catalog story while keeping the host-install path in a
separate secondary section instead of teaching one provider-specific shortcut
as the frame for the whole screen. Product-specific host advantages such as
Proxmox auto-registration may be explained inside that host-install section,
but they must not become the headline copy or top visual weight for the
shared add-infrastructure landing. Returning `Back to catalog` must reset
probe input and result state rather than reopening the landing with stale
no-match or detected-product state already rendered.
Render-order proof for that landing belongs to DOM-backed settings tests, not
raw source-string position checks, so lifecycle ownership continues to guard
the operator-visible order after reasonable component extraction or copy
@@ -26,7 +26,7 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
id="connection-address"
type="text"
class={formControl}
placeholder="pve01.lan, 10.0.0.4:8006, https://pbs.lab:8007"
placeholder="vcenter.lab, truenas.lan, https://pve.lab:8006"
value={props.state.address()}
onInput={(event) => props.state.setAddress(event.currentTarget.value)}
autocomplete="off"
@@ -4,6 +4,7 @@ import type { ConnectionType, ProbeCandidate } from '@/api/connections';
import { AddressProbeStep } from './AddressProbeStep';
import {
CONNECTION_TYPE_LABELS,
DEFAULT_CONNECTION_EDITOR_PLATFORM_TYPES,
createConnectionEditorState,
type ConnectionEditorState,
} from './useConnectionEditor';
@@ -30,12 +31,6 @@ export interface ConnectionEditorProps {
onSaved?: () => void;
}
// Platform integrations — connect to a product's management API. Peers of
// each other. The agent is NOT in this list: it is a different kind of
// integration (see the dedicated section below the grid) and surfacing it
// as a tile alongside these hides what it actually adds.
const DEFAULT_PLATFORM_TYPES: ConnectionType[] = ['pve', 'pbs', 'pmg', 'vmware', 'truenas'];
interface TileMeta {
icon: Component<{ class?: string }>;
description: string;
@@ -61,7 +56,9 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
const [selectedCandidate, setSelectedCandidate] = createSignal<ProbeCandidate | null>(null);
const platformOptions = createMemo(() =>
(props.manualTypeOptions ?? DEFAULT_PLATFORM_TYPES).filter((type) => type !== 'agent'),
(props.manualTypeOptions ?? DEFAULT_CONNECTION_EDITOR_PLATFORM_TYPES).filter(
(type) => type !== 'agent',
),
);
const activeType = () => selectedType();
@@ -94,62 +91,14 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
when={showCredentialSlot()}
fallback={
<div class="space-y-8 p-4">
<section class="space-y-3">
<button
type="button"
onClick={() => chooseManualType('agent')}
class="group flex w-full items-start gap-4 rounded-lg border-2 border-blue-300 bg-blue-50/40 p-4 text-left transition-colors hover:border-blue-500 hover:bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20 dark:hover:border-blue-600 dark:hover:bg-blue-950/40"
>
<div
aria-hidden="true"
class="flex h-10 w-10 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"
>
<Cpu class="h-5 w-5" />
</div>
<div class="flex-1 space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-semibold text-base-content">Install Pulse Agent</div>
<div class="rounded-full border border-blue-300 bg-blue-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-blue-800 dark:border-blue-700 dark:bg-blue-900/60 dark:text-blue-200">
Recommended
</div>
<div class="rounded-full border border-border bg-surface px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
Runs on a host
</div>
</div>
<div class="text-xs text-muted">
<span class="font-medium text-base-content">
On a Proxmox host, this is the fastest path.
</span>{' '}
The installer auto-detects Proxmox on the machine, creates the needed API
token(s), and auto-registers any detected PVE / PBS services — no address or
credentials to paste.
</div>
<div class="text-xs text-muted">
It also reports CPU temperature, disk SMART, systemd services, and network
metrics from the host, and auto-detects Docker and Kubernetes on that machine.
Required for bare-metal Linux / Unraid / FreeBSD targets that have no platform
API to connect.
</div>
</div>
<div
aria-hidden="true"
class="flex-none self-center text-blue-600 transition-colors group-hover:text-blue-700 dark:text-blue-400 dark:group-hover:text-blue-300"
>
<ArrowRight class="h-4 w-4" />
</div>
</button>
</section>
<section class="space-y-4">
<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 connect a platform API directly
<span class="h-px flex-1 bg-border" aria-hidden="true" />
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">Connect a platform</div>
<p class="text-xs text-muted">
Use a management API when the product exposes one. Paste an address to auto-detect
it, or pick a supported platform from the catalog.
</p>
</div>
<p class="text-xs text-muted">
For VMware, TrueNAS, PMG, or a remote Proxmox you can't install the agent on. Paste
the address and Pulse will auto-detect the product, or pick it below.
</p>
<AddressProbeStep
state={state}
@@ -187,6 +136,53 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
</For>
</div>
</section>
<section class="space-y-3 border-t border-border pt-6">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">Install on a host instead</div>
<p class="text-xs text-muted">
Use the Pulse Agent when you want machine-level telemetry or the system has no
management API to connect.
</p>
</div>
<button
type="button"
onClick={() => chooseManualType('agent')}
class="group flex w-full items-start gap-4 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
aria-hidden="true"
class="flex h-10 w-10 flex-none items-center justify-center rounded-md border border-emerald-200 bg-emerald-100 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-900/40 dark:text-emerald-300"
>
<Cpu class="h-5 w-5" />
</div>
<div class="flex-1 space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-semibold text-base-content">Install Pulse Agent</div>
<div class="rounded-full border border-border bg-surface px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
Runs on a host
</div>
</div>
<div class="text-xs text-muted">
Install on bare-metal Linux, Unraid, FreeBSD, or any machine where you want CPU
temperature, disk SMART, systemd services, and network metrics from the host
itself.
</div>
<div class="text-xs text-muted">
On supported Proxmox hosts, the installer can also detect local PVE / PBS
services, create the needed API token(s), and register them automatically.
Docker and Kubernetes on that machine are detected too.
</div>
</div>
<div
aria-hidden="true"
class="flex-none self-center text-muted transition-colors group-hover:text-blue-700 dark:group-hover:text-blue-300"
>
<ArrowRight class="h-4 w-4" />
</div>
</button>
</section>
</div>
}
>
@@ -38,7 +38,7 @@ describe('ConnectionEditor', () => {
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
const input = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
const input = screen.getByPlaceholderText(/vcenter\.lab/) as HTMLInputElement;
fireEvent.input(input, { target: { value: 'pve.lab' } });
const probeButton = screen.getByRole('button', { name: /probe address/i });
@@ -66,7 +66,7 @@ describe('ConnectionEditor', () => {
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
const input = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
const input = screen.getByPlaceholderText(/vcenter\.lab/) as HTMLInputElement;
fireEvent.input(input, { target: { value: '192.168.1.50' } });
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
@@ -84,26 +84,29 @@ describe('ConnectionEditor', () => {
expect(lastCall.candidate).toBeNull();
});
it('renders an agent-led catalog with the API fallback beneath it', () => {
it('renders a platform-first catalog with the host-install path beneath it', () => {
render(() => <ConnectionEditor renderCredentialSlot={() => <div />} onClose={() => {}} />);
const platformHeading = screen.getByText('Connect a platform');
const agentButton = screen.getByRole('button', { name: /Install Pulse Agent/i });
const apiHeading = screen.getByText('Or connect a platform API directly');
const probeButton = screen.getByRole('button', { name: /probe address/i });
const vmwareButton = screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i });
const trueNASButton = screen.getByRole('button', { name: /TrueNAS SCALE/i });
const proxmoxButton = screen.getByRole('button', { name: /^Proxmox VE/i });
// Catalog landing — lead with the agent card, then collapse the probe and
// direct platform options into one API fallback section below it.
// Catalog landing — lead with management-platform onboarding, then keep
// host install as a secondary path below it.
expect(platformHeading).toBeInTheDocument();
expect(agentButton).toBeInTheDocument();
expect(apiHeading).toBeInTheDocument();
expect(probeButton).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();
expectNodeBefore(agentButton, apiHeading);
expectNodeBefore(apiHeading, probeButton);
expect(
screen.queryByRole('button', { name: /install the unified agent on a host/i }),
).toBeNull();
expect(vmwareButton).toBeInTheDocument();
expect(trueNASButton).toBeInTheDocument();
expect(proxmoxButton).toBeInTheDocument();
expectNodeBefore(platformHeading, probeButton);
expectNodeBefore(vmwareButton, trueNASButton);
expectNodeBefore(trueNASButton, proxmoxButton);
expectNodeBefore(proxmoxButton, agentButton);
expect(screen.queryByText('Recommended')).toBeNull();
});
it('offers the agent path contextually when a probe returns no match', async () => {
@@ -113,7 +116,7 @@ describe('ConnectionEditor', () => {
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
const input = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
const input = screen.getByPlaceholderText(/vcenter\.lab/) as HTMLInputElement;
fireEvent.input(input, { target: { value: 'baremetal.lan' } });
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
await waitFor(() => expect(mockedProbe).toHaveBeenCalled());
@@ -158,7 +161,7 @@ describe('ConnectionEditor', () => {
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
const input = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
const input = screen.getByPlaceholderText(/vcenter\.lab/) as HTMLInputElement;
fireEvent.input(input, { target: { value: '192.168.1.50' } });
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
@@ -170,7 +173,7 @@ describe('ConnectionEditor', () => {
fireEvent.click(screen.getByRole('button', { name: /back to catalog/i }));
const resetInput = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
const resetInput = screen.getByPlaceholderText(/vcenter\.lab/) as HTMLInputElement;
expect(resetInput.value).toBe('');
expect(screen.queryByText(/no supported product detected/i)).toBeNull();
expect(screen.queryByTestId('slot')).toBeNull();
@@ -5,6 +5,7 @@ import {
type ProbeCandidate,
type ProbeResponse,
} from '@/api/connections';
import { DEFAULT_INFRASTRUCTURE_SOURCE_ORDER } from '@/utils/platformSupportManifest';
const PROBE_ERROR_FALLBACK = 'Probe failed. Try again or enter credentials manually.';
@@ -31,6 +32,38 @@ export interface ConnectionEditorState {
runProbe: () => Promise<void>;
}
type PlatformConnectionType = Extract<ConnectionType, 'pve' | 'pbs' | 'pmg' | 'truenas' | 'vmware'>;
const SOURCE_PLATFORM_TO_CONNECTION_TYPE: Partial<Record<string, PlatformConnectionType>> = {
'vmware-vsphere': 'vmware',
truenas: 'truenas',
'proxmox-pve': 'pve',
'proxmox-pbs': 'pbs',
'proxmox-pmg': 'pmg',
};
// The supported-source manifest order is reused where it applies, but the
// add-infrastructure catalog still needs to surface the admitted vSphere path.
const CONNECTION_EDITOR_PRIORITY_TYPES: PlatformConnectionType[] = ['vmware'];
const PLATFORM_CONNECTION_TYPE_FALLBACK_ORDER: PlatformConnectionType[] = [
'truenas',
'pve',
'pbs',
'pmg',
];
export const DEFAULT_CONNECTION_EDITOR_PLATFORM_TYPES: PlatformConnectionType[] = Array.from(
new Set([
...CONNECTION_EDITOR_PRIORITY_TYPES,
...DEFAULT_INFRASTRUCTURE_SOURCE_ORDER.flatMap((platformKey) => {
const type = SOURCE_PLATFORM_TO_CONNECTION_TYPE[platformKey];
return type ? [type] : [];
}),
...PLATFORM_CONNECTION_TYPE_FALLBACK_ORDER,
]),
);
// Validation on the client side is intentionally lenient: the backend is the
// real authority on what constitutes a probeable address. We only reject the
// obviously empty case so the API does not see a payload it will always
@@ -119,6 +119,10 @@ const connectionFixture = (overrides: Partial<Connection> = {}): Connection => (
...overrides,
});
function expectNodeBefore(a: Node, b: Node) {
expect(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
const baseProps = () =>
({
selectedAgent: () => 'pve',
@@ -227,14 +231,23 @@ describe('InfrastructureWorkspace', () => {
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
// The catalog landing leads with the agent path and keeps the direct API
// probe/catalog visible below it in the same shared editor.
// The catalog landing leads with peer platform onboarding and keeps the
// host install path available beneath it in the same shared editor.
const platformHeading = screen.getByText('Connect a platform');
const vmwareButton = screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i });
const trueNASButton = screen.getByRole('button', { name: /TrueNAS SCALE/i });
const proxmoxButton = screen.getByRole('button', { name: /^Proxmox VE/i });
const agentButton = screen.getByRole('button', { name: /Install Pulse Agent/i });
expect(platformHeading).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.getByText('Or connect a platform API directly')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Probe address/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(proxmoxButton).toBeInTheDocument();
expect(trueNASButton).toBeInTheDocument();
expect(vmwareButton).toBeInTheDocument();
expectNodeBefore(vmwareButton, trueNASButton);
expectNodeBefore(trueNASButton, proxmoxButton);
expectNodeBefore(proxmoxButton, agentButton);
expect(navigateSpy).not.toHaveBeenCalled();
expect(setSearchParamsSpy).not.toHaveBeenCalled();
});
@@ -81,28 +81,18 @@ describe('settings architecture guardrails', () => {
expect(infrastructureWorkspaceSource).not.toContain('layout="drawer-right"');
});
it('keeps the agent-led add landing and inline node credentials on the shared editor model', () => {
it('keeps the platform-first add landing and inline node credentials on the shared editor model', () => {
expect(connectionEditorSource).toContain(
"import { AddressProbeStep } from './AddressProbeStep';",
);
// Platform integrations render as peer tiles; the agent lives in its own
// section below so it can explain what host-level telemetry adds instead
// of being mistaken for one more peer of Proxmox / VMware / TrueNAS.
expect(connectionEditorSource).toContain('const DEFAULT_PLATFORM_TYPES: ConnectionType[] =');
expect(connectionEditorSource).not.toContain("'agent'] =");
expect(connectionEditorSource).toContain('DEFAULT_CONNECTION_EDITOR_PLATFORM_TYPES');
expect(connectionEditorSource).toContain('<AddressProbeStep');
expect(connectionEditorSource).toContain('Or connect a platform API directly');
// Keep the landing source aligned with the canonical agent-first story, but
// leave actual DOM ordering to the render test rather than raw source
// string positions.
expect(connectionEditorSource).not.toContain('Or install the agent on the host');
expect(connectionEditorSource).toContain('On a Proxmox host, this is the');
expect(connectionEditorSource).toContain('auto-registers any detected PVE / PBS services');
expect(connectionEditorSource).toContain('Connect a platform');
expect(connectionEditorSource).toContain('Install on a host instead');
expect(connectionEditorSource).toContain('On supported Proxmox hosts');
expect(connectionEditorSource).toContain('register them automatically');
expect(connectionEditorSource).not.toContain('auto-registers the node');
expect(connectionEditorSource).toContain('Recommended');
// 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('Recommended');
expect(connectionEditorSource).not.toContain('NodeModal');
expect(addressProbeStepSource).toContain('Probe address');
@@ -114,6 +104,10 @@ describe('settings architecture guardrails', () => {
expect(connectionEditorStateSource).toContain('ConnectionsAPI.probe(value)');
expect(connectionEditorStateSource).toContain('export const CONNECTION_TYPE_LABELS');
expect(connectionEditorStateSource).toContain('DEFAULT_INFRASTRUCTURE_SOURCE_ORDER');
expect(connectionEditorStateSource).toContain(
'export const DEFAULT_CONNECTION_EDITOR_PLATFORM_TYPES',
);
expect(nodeCredentialSlotSource).toContain('useNodeModalState(modalProps)');
expect(nodeCredentialSlotSource).toContain('<NodeModalBasicInfoSection');
@@ -9,9 +9,9 @@ describe('sourcePlatformOptions', () => {
it('orders canonical source platform keys with preferred source ordering', () => {
expect(orderSourcePlatformKeys(['truenas', 'pbs', 'agent', 'docker'])).toEqual([
'agent',
'docker',
'proxmox-pbs',
'truenas',
'proxmox-pbs',
'docker',
]);
});
@@ -25,13 +25,13 @@ describe('sourcePlatformOptions', () => {
it('exports the default infrastructure source options in canonical order', () => {
expect(DEFAULT_INFRASTRUCTURE_SOURCE_OPTIONS.map((option) => option.key)).toEqual([
'proxmox-pve',
'agent',
'docker',
'truenas',
'proxmox-pve',
'proxmox-pbs',
'proxmox-pmg',
'docker',
'kubernetes',
'truenas',
]);
});
});
@@ -1,22 +1,22 @@
// This file is generated by scripts/release_control/generate_platform_support_frontend_module.py.
// Do not edit by hand.
// Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
// Source SHA256: 79568ec8c69f13d8d49afe2b7a82b28c921b1885a90ebfb08cc98284d48b80e9
// Source SHA256: afe3841efbb355a9d6245e3aea6af47d31d87a870a44cba5c79ec749448cd78f
export const PLATFORM_SUPPORT_MANIFEST_SOURCE = {
path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json',
sha256: '79568ec8c69f13d8d49afe2b7a82b28c921b1885a90ebfb08cc98284d48b80e9',
sha256: 'afe3841efbb355a9d6245e3aea6af47d31d87a870a44cba5c79ec749448cd78f',
} as const;
export const PLATFORM_SUPPORT_MANIFEST = {
schemaVersion: 1,
defaultInfrastructureSourceOrder: [
'proxmox-pve',
'agent',
'docker',
'truenas',
'proxmox-pve',
'proxmox-pbs',
'proxmox-pmg',
'docker',
'kubernetes',
'truenas',
],
platforms: [
{
@@ -208,13 +208,13 @@ export const KNOWN_SOURCE_PLATFORM_KEYS = [
'generic',
] as const;
export const DEFAULT_INFRASTRUCTURE_SOURCE_ORDER = [
'proxmox-pve',
'agent',
'docker',
'truenas',
'proxmox-pve',
'proxmox-pbs',
'proxmox-pmg',
'docker',
'kubernetes',
'truenas',
] as const;
export const SOURCE_PLATFORM_ALIAS_MAP = {
k8s: 'kubernetes',