Redesign infrastructure onboarding flow

This commit is contained in:
rcourtman
2026-04-22 13:12:26 +01:00
parent a43476a696
commit 2aecb44d31
12 changed files with 705 additions and 212 deletions
@@ -3,6 +3,7 @@ import type { ProbeCandidate } from '@/api/connections';
import { formControl, formField, formHelpText, formLabel } from '@/components/shared/Form';
import type { ConnectionEditorState } from './useConnectionEditor';
import { CONNECTION_TYPE_LABELS } from './useConnectionEditor';
import { getInfrastructureAutoDetectLabels } from '@/utils/infrastructureOnboardingPresentation';
export interface AddressProbeStepProps {
state: ConnectionEditorState;
@@ -16,6 +17,8 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
void props.state.runProbe();
};
const autoDetectLabels = getInfrastructureAutoDetectLabels();
return (
<form class="space-y-4" onSubmit={handleSubmit}>
<div class={formField}>
@@ -26,7 +29,7 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
id="connection-address"
type="text"
class={formControl}
placeholder="vcenter.lab, truenas.lan, https://pve.lab:8006"
placeholder="vcenter.lab.local, truenas.lan, https://pve.lab:8006"
value={props.state.address()}
onInput={(event) => props.state.setAddress(event.currentTarget.value)}
autocomplete="off"
@@ -34,8 +37,18 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
disabled={props.state.phase() === 'probing'}
/>
<p class={formHelpText}>
Paste a hostname, IP, or URL. Pulse detects the product and asks for credentials next.
Paste a hostname, IP, or URL to identify a supported platform. Pulse validates the match
and asks for credentials next.
</p>
<div class="mt-2 flex flex-wrap gap-1.5">
<For each={autoDetectLabels}>
{(label) => (
<span class="inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-[11px] font-medium text-base-content">
{label}
</span>
)}
</For>
</div>
</div>
<div class="flex items-center gap-2">
@@ -56,20 +69,20 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
<Show when={props.state.phase() === 'no-match'}>
<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="font-medium">No supported API-backed platform detected at that address.</div>
<div class="mt-1 text-xs">
Pick your system from the catalog below, or if this is bare-metal Linux / Unraid /
FreeBSD,{' '}
Pick a supported product 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>}
fallback={<span class="font-medium">install Pulse 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
install Pulse Agent instead
</button>
</Show>
.
@@ -11,6 +11,14 @@ import {
createConnectionEditorState,
type ConnectionEditorState,
} from './useConnectionEditor';
import {
INFRASTRUCTURE_AGENT_DISCOVERY_LABELS,
INFRASTRUCTURE_ONBOARDING_PATHS,
INFRASTRUCTURE_ONBOARDING_STEPS,
getInfrastructureApiProductsByGovernanceState,
getInfrastructureOnboardingProductPresentation,
getInfrastructureSupportSummaryBadges,
} from '@/utils/infrastructureOnboardingPresentation';
export type ConnectionEditorMode = 'add' | 'edit';
@@ -36,18 +44,17 @@ export interface ConnectionEditorProps {
interface TileMeta {
icon: Component<{ class?: string }>;
description: string;
}
const PLATFORM_TILE_META: Record<
Exclude<ConnectionType, 'agent' | 'docker' | 'kubernetes'>,
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' },
pve: { icon: Server },
pbs: { icon: Archive },
pmg: { icon: Mail },
vmware: { icon: ServerCog },
truenas: { icon: Database },
};
export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
@@ -112,18 +119,135 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
props.onClose();
};
const supportedApiProducts = createMemo(() =>
getInfrastructureApiProductsByGovernanceState('supported'),
);
const supportBadges = createMemo(() => getInfrastructureSupportSummaryBadges());
const renderBadge = (label: string, tone: 'neutral' | 'accent' = 'neutral') => (
<span
class={
tone === 'accent'
? 'inline-flex items-center rounded-full border border-blue-200 bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200'
: 'inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-[11px] font-medium text-base-content'
}
>
{label}
</span>
);
return (
<div class="flex h-full flex-col">
<Show
when={showCredentialSlot()}
fallback={
<div class="space-y-8 p-4">
<section class="space-y-4">
<div class="space-y-6 p-4">
<section class="rounded-xl border border-border bg-surface-alt p-4">
<div class="space-y-4">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">
Choose how Pulse should connect
</div>
<p class="text-sm text-muted">
Pulse can monitor supported platforms through their management APIs, or install
Pulse Agent on a host for machine telemetry and local runtime discovery.
</p>
</div>
<div class="grid grid-cols-1 gap-3 xl:grid-cols-2">
<div class="rounded-lg border border-border bg-surface p-4">
<div class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">
{INFRASTRUCTURE_ONBOARDING_PATHS.api.title}
</div>
<p class="text-xs text-muted">
{INFRASTRUCTURE_ONBOARDING_PATHS.api.bestFor}
</p>
</div>
<span class="rounded-full border border-border bg-surface-alt px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
API path
</span>
</div>
<p class="text-xs text-muted">
{INFRASTRUCTURE_ONBOARDING_PATHS.api.description}
</p>
<div class="space-y-1.5">
<div class="text-[11px] font-medium uppercase tracking-wide text-muted">
Supported today
</div>
<div class="flex flex-wrap gap-1.5">
<For each={supportedApiProducts()}>
{(product) => renderBadge(product.label)}
</For>
</div>
</div>
<Show when={supportBadges().currentAdmissionPath.length > 0}>
<div class="space-y-1.5">
<div class="text-[11px] font-medium uppercase tracking-wide text-muted">
Current admission path
</div>
<div class="flex flex-wrap gap-1.5">
<For each={supportBadges().currentAdmissionPath}>
{(label) => renderBadge(label, 'accent')}
</For>
</div>
</div>
</Show>
<p class="text-xs text-muted">
{INFRASTRUCTURE_ONBOARDING_PATHS.api.coverage}
</p>
</div>
</div>
<div class="rounded-lg border border-emerald-200 bg-surface p-4 dark:border-emerald-900">
<div class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">
{INFRASTRUCTURE_ONBOARDING_PATHS.agent.title}
</div>
<p class="text-xs text-muted">
{INFRASTRUCTURE_ONBOARDING_PATHS.agent.bestFor}
</p>
</div>
<span class="rounded-full border border-emerald-200 bg-emerald-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-800 dark:border-emerald-900 dark:bg-emerald-900/40 dark:text-emerald-200">
Agent path
</span>
</div>
<p class="text-xs text-muted">
{INFRASTRUCTURE_ONBOARDING_PATHS.agent.description}
</p>
<div class="flex flex-wrap gap-1.5">
<For each={supportBadges().installPath}>
{(label) => renderBadge(label)}
</For>
</div>
<p class="text-xs text-muted">
{INFRASTRUCTURE_ONBOARDING_PATHS.agent.coverage}
</p>
</div>
</div>
</div>
</div>
</section>
<section class="space-y-4 rounded-xl border border-border bg-surface p-4">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">Connect a platform</div>
<div class="text-sm font-semibold text-base-content">
Connect a supported 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.
Paste an address to identify a supported platform automatically, or pick the
product you already know from the catalog.
</p>
</div>
@@ -136,17 +260,62 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
<Show
when={activeFamily()}
fallback={
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={platformCatalogEntries()}>
{(entry) => {
if (entry.kind === 'type') {
const meta = PLATFORM_TILE_META[entry.type];
const Icon = meta.icon;
const label = CONNECTION_TYPE_LABELS[entry.type];
<div class="space-y-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">
Supported platform catalog
</div>
<p class="text-xs text-muted">
Choose the product path that matches your environment when you already know
the system type.
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<For each={platformCatalogEntries()}>
{(entry) => {
if (entry.kind === 'type') {
const meta = PLATFORM_TILE_META[entry.type];
const Icon = meta.icon;
const product = getInfrastructureOnboardingProductPresentation(
entry.type,
);
return (
<button
type="button"
onClick={() => chooseManualType(entry.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="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="space-y-1">
<div class="text-sm font-semibold text-base-content">
{product.label}
</div>
<Show when={product.governanceState === 'admitted'}>
<span class="inline-flex items-center rounded-full border border-blue-200 bg-blue-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200">
Current admission path
</span>
</Show>
</div>
</div>
<div class="text-xs text-muted">{product.catalogDescription}</div>
<div class="text-[11px] text-muted">{product.bestFor}</div>
</button>
);
}
const familyLeadType = entry.childTypes[0];
const Icon = PLATFORM_TILE_META[familyLeadType]?.icon ?? Server;
return (
<button
type="button"
onClick={() => chooseManualType(entry.type)}
onClick={() => chooseFamily(entry.id)}
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">
@@ -156,37 +325,16 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
>
<Icon class="h-4 w-4" />
</div>
<div class="text-sm font-semibold text-base-content">{label}</div>
<div class="text-sm font-semibold text-base-content">
{entry.label}
</div>
</div>
<div class="text-xs text-muted">{meta.description}</div>
<div class="text-xs text-muted">{entry.description}</div>
</button>
);
}
const familyLeadType = entry.childTypes[0];
const Icon = PLATFORM_TILE_META[familyLeadType]?.icon ?? Server;
return (
<button
type="button"
onClick={() => chooseFamily(entry.id)}
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="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">
{entry.label}
</div>
</div>
<div class="text-xs text-muted">{entry.description}</div>
</button>
);
}}
</For>
}}
</For>
</div>
</div>
}
>
@@ -218,6 +366,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
{(type) => {
const meta = PLATFORM_TILE_META[type];
const Icon = meta.icon;
const product = getInfrastructureOnboardingProductPresentation(type);
return (
<button
type="button"
@@ -231,11 +380,19 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
>
<Icon class="h-4 w-4" />
</div>
<div class="text-sm font-semibold text-base-content">
{CONNECTION_TYPE_LABELS[type]}
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">
{product.label}
</div>
<Show when={product.governanceState === 'admitted'}>
<span class="inline-flex items-center rounded-full border border-blue-200 bg-blue-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200">
Current admission path
</span>
</Show>
</div>
</div>
<div class="text-xs text-muted">{meta.description}</div>
<div class="text-xs text-muted">{product.catalogDescription}</div>
<div class="text-[11px] text-muted">{product.bestFor}</div>
</button>
);
}}
@@ -247,12 +404,12 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
</Show>
</section>
<section class="space-y-3 border-t border-border pt-6">
<section class="space-y-3 rounded-xl border border-border bg-surface p-4">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">Install on a host instead</div>
<div class="text-sm font-semibold text-base-content">Install Pulse Agent</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.
Use the agent when you want machine telemetry, or when the system has no
management API Pulse can connect to directly.
</p>
</div>
@@ -275,14 +432,19 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
</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
Install on Linux, FreeBSD, or compatible hosts such as Unraid when you want CPU
temperature, disk SMART, system services, and network metrics from the host
itself.
</div>
<div class="text-xs text-muted">
Also detects local Docker, Kubernetes, and other supported services on the same
machine and connects them when available.
</div>
<div class="flex flex-wrap gap-1.5 pt-1">
<For each={INFRASTRUCTURE_AGENT_DISCOVERY_LABELS}>
{(label) => renderBadge(label)}
</For>
</div>
</div>
<div
aria-hidden="true"
@@ -292,6 +454,31 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
</div>
</button>
</section>
<section class="rounded-xl border border-border bg-surface-alt p-4">
<div class="space-y-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-base-content">What happens next</div>
<p class="text-xs text-muted">
Pulse guides each path through the same monitored-system admission flow before
the system lands in the shared infrastructure ledger.
</p>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-5">
<For each={INFRASTRUCTURE_ONBOARDING_STEPS}>
{(step, index) => (
<div class="rounded-lg border border-border bg-surface px-3 py-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-muted">
Step {index() + 1}
</div>
<div class="mt-1 text-sm font-medium text-base-content">{step}</div>
</div>
)}
</For>
</div>
</div>
</section>
</div>
}
>
@@ -34,7 +34,7 @@ describe('ConnectionEditor', () => {
};
mockedProbe.mockResolvedValueOnce(response);
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
const renderSlot = vi.fn((props) => <div data-testid="slot">slot:{props.type}</div>);
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
@@ -46,8 +46,8 @@ describe('ConnectionEditor', () => {
await waitFor(() => expect(mockedProbe).toHaveBeenCalledWith('pve.lab'));
const candidateLabel = await screen.findAllByText('Proxmox VE');
const candidateButton = candidateLabel[0].closest('button');
const candidateHost = await screen.findByText('https://pve.lab:8006');
const candidateButton = candidateHost.closest('button');
expect(candidateButton).not.toBeNull();
fireEvent.click(candidateButton!);
@@ -62,7 +62,7 @@ describe('ConnectionEditor', () => {
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>);
const renderSlot = vi.fn((props) => <div data-testid="slot">slot:{props.type}</div>);
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
@@ -72,7 +72,7 @@ describe('ConnectionEditor', () => {
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
await waitFor(() => expect(mockedProbe).toHaveBeenCalled());
await screen.findByText(/no supported product detected/i);
await screen.findByText(/no supported api-backed platform detected/i);
// The catalog grid is always visible below the probe, so the user picks a
// tile directly — no intermediate "enter credentials manually" toggle.
@@ -87,10 +87,10 @@ describe('ConnectionEditor', () => {
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 platformHeading = screen.getAllByText('Connect a supported platform')[0];
const agentButton = screen.getByRole('button', { name: /Install Pulse Agent/i });
const probeButton = screen.getByRole('button', { name: /probe address/i });
const vmwareButton = screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i });
const vmwareButton = screen.getByRole('button', { name: /VMware vCenter/i });
const trueNASButton = screen.getByRole('button', { name: /TrueNAS SCALE/i });
const proxmoxButton = screen.getByRole('button', { name: /^Proxmox\b/i });
@@ -106,12 +106,14 @@ describe('ConnectionEditor', () => {
expectNodeBefore(vmwareButton, trueNASButton);
expectNodeBefore(trueNASButton, proxmoxButton);
expectNodeBefore(proxmoxButton, agentButton);
expect(screen.queryByText('Proxmox VE')).toBeNull();
expect(screen.queryByRole('button', { name: /^Proxmox VE/i })).toBeNull();
expect(screen.queryByText('Recommended')).toBeNull();
expect(screen.getAllByText('Current admission path').length).toBeGreaterThan(0);
expect(screen.getByText('What happens next')).toBeInTheDocument();
});
it('groups Proxmox products under one family step before entering credentials', async () => {
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
const renderSlot = vi.fn((props) => <div data-testid="slot">slot:{props.type}</div>);
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
@@ -144,7 +146,7 @@ describe('ConnectionEditor', () => {
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>);
const renderSlot = vi.fn((props) => <div data-testid="slot">slot:{props.type}</div>);
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
@@ -157,7 +159,7 @@ describe('ConnectionEditor', () => {
// 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,
name: /install pulse agent instead/i,
});
fireEvent.click(agentButton);
@@ -168,7 +170,7 @@ describe('ConnectionEditor', () => {
});
it('skips the probe step when an initialType is supplied (edit mode)', () => {
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
const renderSlot = vi.fn((props) => <div data-testid="slot">slot:{props.type}</div>);
render(() => (
<ConnectionEditor
@@ -201,7 +203,7 @@ describe('ConnectionEditor', () => {
it('resets probe state when returning to the catalog from a credential slot', async () => {
mockedProbe.mockResolvedValueOnce({ candidates: [], probedMs: 203 });
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
const renderSlot = vi.fn((props) => <div data-testid="slot">slot:{props.type}</div>);
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
@@ -210,7 +212,7 @@ describe('ConnectionEditor', () => {
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
await waitFor(() => expect(mockedProbe).toHaveBeenCalled());
await screen.findByText(/no supported product detected/i);
await screen.findByText(/no supported api-backed platform detected/i);
fireEvent.click(screen.getByRole('button', { name: /TrueNAS SCALE/i }));
await waitFor(() => expect(screen.getByTestId('slot').textContent).toBe('slot:truenas'));
@@ -219,7 +221,7 @@ describe('ConnectionEditor', () => {
const resetInput = screen.getByPlaceholderText(/vcenter\.lab/) as HTMLInputElement;
expect(resetInput.value).toBe('');
expect(screen.queryByText(/no supported product detected/i)).toBeNull();
expect(screen.queryByText(/no supported api-backed platform detected/i)).toBeNull();
expect(screen.queryByTestId('slot')).toBeNull();
});
});
@@ -9,6 +9,10 @@ import {
DEFAULT_INFRASTRUCTURE_SOURCE_ORDER,
getSourcePlatformFamily,
} from '@/utils/platformSupportManifest';
import {
INFRASTRUCTURE_API_FAMILY_DESCRIPTIONS,
getInfrastructureOnboardingProductPresentation,
} from '@/utils/infrastructureOnboardingPresentation';
const PROBE_ERROR_FALLBACK = 'Probe failed. Try again or enter credentials manually.';
@@ -123,9 +127,9 @@ export const CONNECTION_TYPE_LABELS: Record<ConnectionType, string> = {
pve: 'Proxmox VE',
pbs: 'Proxmox Backup Server',
pmg: 'Proxmox Mail Gateway',
vmware: 'VMware vCenter / ESXi',
truenas: 'TrueNAS SCALE',
agent: 'Pulse Unified Agent',
vmware: getInfrastructureOnboardingProductPresentation('vmware').label,
truenas: getInfrastructureOnboardingProductPresentation('truenas').label,
agent: 'Pulse Agent',
docker: 'Docker',
kubernetes: 'Kubernetes',
};
@@ -142,14 +146,17 @@ const getCatalogFamilyLabel = (type: PlatformConnectionType): string | null =>
const describeCatalogFamily = (
familyLabel: string,
childTypes: readonly PlatformConnectionType[],
): string =>
childTypes
): string => {
const curatedDescription = INFRASTRUCTURE_API_FAMILY_DESCRIPTIONS[familyLabel];
if (curatedDescription) return curatedDescription;
return childTypes
.map((type) => {
const label = CONNECTION_TYPE_LABELS[type];
const prefix = `${familyLabel} `;
return label.startsWith(prefix) ? label.slice(prefix.length) : label;
})
.join(', ');
};
export const DEFAULT_CONNECTION_EDITOR_CATALOG_ENTRIES: ConnectionEditorCatalogEntry[] = (() => {
return buildConnectionEditorCatalogEntries(DEFAULT_CONNECTION_EDITOR_AVAILABLE_TYPES);
@@ -11,6 +11,10 @@ import {
import type { Connection } from '@/api/connections';
import type { InfrastructureSystemRow } from './connectionsTableModel';
import type { ConnectionRowActions } from './useConnectionRowActions';
import {
getInfrastructureEmptyStateDetail,
getInfrastructureEmptyStateSummary,
} from '@/utils/infrastructureOnboardingPresentation';
export interface ConnectionsTableHeaderAction {
label: string;
@@ -45,8 +49,7 @@ const removeConfirmClass =
'inline-flex items-center rounded-md bg-rose-600 px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-60';
export const ConnectionsTable: Component<ConnectionsTableProps> = (props) => {
const hasActions = () =>
Boolean(props.actions) || Boolean(props.onEdit);
const hasActions = () => Boolean(props.actions) || Boolean(props.onEdit);
const colSpan = () => (hasActions() ? 5 : 4);
@@ -55,7 +58,7 @@ export const ConnectionsTable: Component<ConnectionsTableProps> = (props) => {
<div class="flex flex-col gap-3 border-b border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold text-base-content">Monitored systems</h3>
<p class="text-xs text-muted">One row per top-level monitored system.</p>
<p class="text-xs text-muted">{getInfrastructureEmptyStateSummary()}</p>
</div>
<Show when={(props.headerActions?.length ?? 0) > 0}>
<div class="flex flex-wrap items-center gap-2">
@@ -81,7 +84,12 @@ export const ConnectionsTable: Component<ConnectionsTableProps> = (props) => {
<Show
when={props.rows().length > 0}
fallback={
<div class="px-4 py-10 text-center text-sm text-muted">No monitored systems yet.</div>
<div class="space-y-2 px-4 py-10 text-center">
<div class="text-sm font-medium text-base-content">Start monitoring infrastructure</div>
<div class="mx-auto max-w-3xl text-sm text-muted">
{getInfrastructureEmptyStateDetail()}
</div>
</div>
}
>
<Table class="w-full table-fixed divide-y divide-border text-sm !whitespace-normal">
@@ -111,8 +119,7 @@ export const ConnectionsTable: Component<ConnectionsTableProps> = (props) => {
const isPauseBusy = () => props.actions?.pendingAction(row.id) === 'pause';
const isRemoveBusy = () => props.actions?.pendingAction(row.id) === 'remove';
const anyBusy = () => props.actions?.pendingAction(row.id) !== null;
const isConfirmingRemove = () =>
Boolean(props.actions?.confirmingRemove(row.id));
const isConfirmingRemove = () => Boolean(props.actions?.confirmingRemove(row.id));
const rowError = () => props.actions?.actionError(row.id) ?? null;
return (
@@ -231,13 +238,15 @@ export const ConnectionsTable: Component<ConnectionsTableProps> = (props) => {
</TableRow>
</Show>
<Show when={row.isAgent && isConfirmingRemove() && props.agentUninstallCommands}>
<Show
when={row.isAgent && isConfirmingRemove() && props.agentUninstallCommands}
>
<TableRow>
<TableCell colspan={colSpan()} class="bg-surface-alt px-4 pb-4 pt-1">
<div class="space-y-3">
<p class="text-xs text-muted">
Removing forgets this agent from the ledger; history is retained.
To fully detach, run the uninstall command on the host:
Removing forgets this agent from the ledger; history is retained. To
fully detach, run the uninstall command on the host:
</p>
<div class="space-y-1">
<span class="text-xs font-medium text-muted">
@@ -219,7 +219,11 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
case 'pmg': {
const node = findEditableNode(connection);
return node
? { kind: 'node' as const, render: () => renderNodeSlot(connection.type as 'pve' | 'pbs' | 'pmg', node) }
? {
kind: 'node' as const,
render: () =>
renderNodeSlot(connection.type as 'pve' | 'pbs' | 'pmg', node),
}
: null;
}
case 'vmware': {
@@ -262,14 +266,12 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
if (!editableSlot) {
return (
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="text-base font-semibold text-base-content">
Edit connection
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-base font-semibold text-base-content">Edit connection</div>
<button
type="button"
onClick={exitEditMode}
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"
class="inline-flex w-full items-center justify-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 sm:w-auto"
>
Back to systems
</button>
@@ -286,7 +288,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
}
return (
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-base font-semibold text-base-content">
Edit {connection.name}
@@ -296,7 +298,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<button
type="button"
onClick={exitEditMode}
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"
class="inline-flex w-full items-center justify-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 sm:w-auto"
>
← Back to systems
</button>
@@ -311,89 +313,91 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
</Match>
<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">Add infrastructure</div>
<div class="mt-0.5 text-xs text-muted">
Paste an address to auto-detect, or pick your system from the catalog.
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-base font-semibold text-base-content">Add infrastructure</div>
<div class="mt-0.5 text-xs text-muted">
Choose how Pulse should connect: supported platform API or Pulse Agent on a host.
</div>
</div>
<button
type="button"
onClick={exitAddMode}
class="inline-flex w-full items-center justify-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 sm:w-auto"
>
← 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">
<ConnectionEditor
mode="add"
initialType={initialAddType() ?? undefined}
onClose={exitAddMode}
renderCredentialSlot={({ type }) => {
switch (type) {
case 'pve':
case 'pbs':
case 'pmg':
return renderNodeSlot(type);
case 'truenas':
return (
<TrueNASCredentialSlot
state={props.trueNASSettings}
onCancel={exitAddMode}
onSaved={exitAddMode}
/>
);
case 'vmware':
return (
<VMwareCredentialSlot
state={props.vmwareSettings}
onCancel={exitAddMode}
onSaved={exitAddMode}
/>
);
case 'agent':
return (
<div class="space-y-4">
<div class="flex items-center justify-end">
<button
type="button"
onClick={() => setShowAgentProfiles((v) => !v)}
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"
>
{showAgentProfiles() ? 'Hide agent profiles' : 'Manage agent profiles'}
</button>
</div>
<Show when={showAgentProfiles()}>
<div class="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div class="mb-4 space-y-1">
<div class="text-base font-semibold text-base-content">
Agent profiles
</div>
<div class="text-sm text-muted">
Manage reusable install defaults for agent-based systems.
</div>
</div>
<AgentProfilesPanel />
<div class="rounded-lg border border-border bg-surface">
<ConnectionEditor
mode="add"
initialType={initialAddType() ?? undefined}
onClose={exitAddMode}
renderCredentialSlot={({ type }) => {
switch (type) {
case 'pve':
case 'pbs':
case 'pmg':
return renderNodeSlot(type);
case 'truenas':
return (
<TrueNASCredentialSlot
state={props.trueNASSettings}
onCancel={exitAddMode}
onSaved={exitAddMode}
/>
);
case 'vmware':
return (
<VMwareCredentialSlot
state={props.vmwareSettings}
onCancel={exitAddMode}
onSaved={exitAddMode}
/>
);
case 'agent':
return (
<div class="space-y-4">
<div class="flex items-center justify-end">
<button
type="button"
onClick={() => setShowAgentProfiles((v) => !v)}
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"
>
{showAgentProfiles()
? 'Hide agent profiles'
: 'Manage agent profiles'}
</button>
</div>
</Show>
<InfrastructureInstallerSection />
</div>
);
default:
return (
<div class="text-sm text-muted">
No credential form is wired up for the {type} type yet.
</div>
);
}
}}
/>
<Show when={showAgentProfiles()}>
<div class="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div class="mb-4 space-y-1">
<div class="text-base font-semibold text-base-content">
Agent profiles
</div>
<div class="text-sm text-muted">
Manage reusable install defaults for agent-based systems.
</div>
</div>
<AgentProfilesPanel />
</div>
</Show>
<InfrastructureInstallerSection />
</div>
);
default:
return (
<div class="text-sm text-muted">
No credential form is wired up for the {type} type yet.
</div>
);
}
}}
/>
</div>
</div>
</div>
</Match>
</Switch>
</div>
@@ -233,8 +233,8 @@ describe('InfrastructureWorkspace', () => {
// 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 platformHeading = screen.getAllByText('Connect a supported platform')[0];
const vmwareButton = screen.getByRole('button', { name: /VMware vCenter/i });
const trueNASButton = screen.getByRole('button', { name: /TrueNAS SCALE/i });
const proxmoxButton = screen.getByRole('button', { name: /^Proxmox\b/i });
const agentButton = screen.getByRole('button', { name: /Install Pulse Agent/i });
@@ -274,7 +274,7 @@ describe('InfrastructureWorkspace', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i }));
fireEvent.click(screen.getByRole('button', { name: /VMware vCenter/i }));
expect(screen.getByTestId('vmware-section')).toBeInTheDocument();
});
@@ -7,6 +7,7 @@ import settingsNavigationHookSource from '../useSettingsNavigation.ts?raw';
import settingsPanelRegistryContextSource from '../settingsPanelRegistryContext.tsx?raw';
import infrastructureWorkspaceSource from '../InfrastructureWorkspace.tsx?raw';
import infrastructureWorkspaceModelSource from '../infrastructureWorkspaceModel.ts?raw';
import connectionsTableSource from '../ConnectionsTable.tsx?raw';
import connectionEditorSource from '../ConnectionEditor/ConnectionEditor.tsx?raw';
import addressProbeStepSource from '../ConnectionEditor/AddressProbeStep.tsx?raw';
import connectionEditorStateSource from '../ConnectionEditor/useConnectionEditor.ts?raw';
@@ -15,6 +16,7 @@ import trueNASCredentialSlotSource from '../ConnectionEditor/CredentialSlots/Tru
import vmwareCredentialSlotSource from '../ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx?raw';
import diagnosticsResultsPanelSource from '../DiagnosticsResultsPanel.tsx?raw';
import diagnosticsModelSource from '../diagnosticsModel.ts?raw';
import infrastructureOnboardingPresentationSource from '../../../utils/infrastructureOnboardingPresentation.ts?raw';
describe('settings architecture guardrails', () => {
it('keeps Settings on the canonical page shell boundary', () => {
@@ -111,13 +113,20 @@ describe('settings architecture guardrails', () => {
expect(connectionEditorSource).toContain(
"import { AddressProbeStep } from './AddressProbeStep';",
);
expect(connectionEditorSource).toContain(
"from '@/utils/infrastructureOnboardingPresentation';",
);
expect(connectionsTableSource).toContain(
"from '@/utils/infrastructureOnboardingPresentation';",
);
expect(connectionEditorSource).toContain('buildConnectionEditorCatalogEntries');
expect(connectionEditorSource).toContain('selectedFamilyId');
expect(connectionEditorSource).toContain('<AddressProbeStep');
expect(connectionEditorSource).toContain('Connect a platform');
expect(connectionEditorSource).toContain('Choose how Pulse should connect');
expect(connectionEditorSource).toContain('Connect a supported platform');
expect(connectionEditorSource).toContain('Choose a {family.label} product');
expect(connectionEditorSource).toContain('Back to platforms');
expect(connectionEditorSource).toContain('Install on a host instead');
expect(connectionEditorSource).toContain('Current admission path');
expect(connectionEditorSource).toContain('other supported services');
expect(connectionEditorSource).toContain('connects them when available');
expect(connectionEditorSource).not.toContain('On supported Proxmox hosts');
@@ -130,8 +139,9 @@ describe('settings architecture guardrails', () => {
// 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('install Pulse Agent instead');
expect(addressProbeStepSource).toContain('bare-metal Linux');
expect(addressProbeStepSource).toContain('supported API-backed platform');
expect(connectionEditorStateSource).toContain('ConnectionsAPI.probe(value)');
expect(connectionEditorStateSource).toContain('export const CONNECTION_TYPE_LABELS');
@@ -144,6 +154,11 @@ describe('settings architecture guardrails', () => {
'export function buildConnectionEditorCatalogEntries',
);
expect(connectionEditorStateSource).not.toContain('PROXMOX_FAMILY_TYPES');
expect(infrastructureOnboardingPresentationSource).toContain('getSourcePlatformManifestEntry');
expect(infrastructureOnboardingPresentationSource).toContain('currentAdmissionPath');
expect(infrastructureOnboardingPresentationSource).toContain(
'getInfrastructureSupportSummaryBadges',
);
expect(nodeCredentialSlotSource).toContain('useNodeModalState(modalProps)');
expect(nodeCredentialSlotSource).toContain('<NodeModalBasicInfoSection');
@@ -12,6 +12,7 @@ describe('useSettingsShellState', () => {
afterEach(() => {
presentationPolicyIsReadOnlyMock.mockReset();
presentationPolicyIsReadOnlyMock.mockReturnValue(false);
vi.unstubAllGlobals();
});
it('uses reporting-focused infrastructure copy in read-only sessions', () => {
@@ -30,4 +31,18 @@ describe('useSettingsShellState', () => {
dispose();
});
});
it('keeps the content pane visible on mobile-sized viewports by default', () => {
vi.stubGlobal('window', { innerWidth: 390 });
createRoot((dispose) => {
const state = useSettingsShellState({
activeTab: () => 'infrastructure-systems',
});
expect(state.isMobileMenuOpen()).toBe(false);
dispose();
});
});
});
@@ -8,40 +8,35 @@ interface UseSettingsShellStateParams {
}
export function useSettingsShellState({ activeTab }: UseSettingsShellStateParams) {
const headerMeta = createMemo(
() => {
const tab = activeTab();
if (isInfrastructureSettingsTab(tab) && presentationPolicyIsReadOnly()) {
return {
title: 'Infrastructure',
description:
'Review the current top-level monitored systems and reporting posture. Setup changes stay unavailable in this read-only session.',
};
}
const headerMeta = createMemo(() => {
const tab = activeTab();
if (isInfrastructureSettingsTab(tab) && presentationPolicyIsReadOnly()) {
return {
title: 'Infrastructure',
description:
'Review the current top-level monitored systems and reporting posture. Setup changes stay unavailable in this read-only session.',
};
}
if (isInfrastructureSettingsTab(tab)) {
return {
title: 'Infrastructure',
description:
SETTINGS_HEADER_META['infrastructure-systems'].description,
};
}
if (isInfrastructureSettingsTab(tab)) {
return {
title: 'Infrastructure',
description: SETTINGS_HEADER_META['infrastructure-systems'].description,
};
}
return (
SETTINGS_HEADER_META[tab] ?? {
title: 'Settings',
description: 'Manage Pulse configuration.',
}
);
},
);
return (
SETTINGS_HEADER_META[tab] ?? {
title: 'Settings',
description: 'Manage Pulse configuration.',
}
);
});
// Sidebar always starts expanded for discoverability (issue #764)
// Users can collapse during session but it resets on page reload
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(
typeof window !== 'undefined' ? window.innerWidth < 1024 : false,
);
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
const [showPasswordModal, setShowPasswordModal] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal('');
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
getInfrastructureApiProductsByGovernanceState,
getInfrastructureAutoDetectLabels,
getInfrastructureEmptyStateDetail,
getInfrastructureOnboardingProductPresentation,
getInfrastructureSupportSummaryBadges,
} from '@/utils/infrastructureOnboardingPresentation';
describe('infrastructureOnboardingPresentation', () => {
it('keeps VMware on the admitted vCenter-only path', () => {
const vmware = getInfrastructureOnboardingProductPresentation('vmware');
expect(vmware.label).toBe('VMware vCenter');
expect(vmware.governanceState).toBe('admitted');
});
it('keeps supported API products separate from the admitted VMware path', () => {
expect(
getInfrastructureApiProductsByGovernanceState('supported').map((product) => product.label),
).toEqual(['TrueNAS SCALE', 'Proxmox VE', 'Proxmox Backup Server', 'Proxmox Mail Gateway']);
expect(
getInfrastructureApiProductsByGovernanceState('admitted').map((product) => product.label),
).toEqual(['VMware vCenter']);
});
it('derives auto-detect copy and main-page support summary from the shared helper', () => {
expect(getInfrastructureAutoDetectLabels()).toEqual([
'VMware vCenter',
'TrueNAS SCALE',
'Proxmox VE',
'Proxmox Backup Server',
'Proxmox Mail Gateway',
]);
expect(getInfrastructureSupportSummaryBadges()).toEqual({
supportedToday: [
'TrueNAS SCALE',
'Proxmox VE',
'Proxmox Backup Server',
'Proxmox Mail Gateway',
'Pulse Agent hosts',
'Docker',
'Kubernetes',
],
currentAdmissionPath: ['VMware vCenter'],
installPath: ['Linux', 'FreeBSD', 'Unraid', 'Pulse Agent hosts', 'Docker', 'Kubernetes'],
});
expect(getInfrastructureEmptyStateDetail()).toContain('Supported today: TrueNAS SCALE');
expect(getInfrastructureEmptyStateDetail()).toContain(
'VMware vCenter onboarding is also available on the current admission path.',
);
});
});
@@ -0,0 +1,190 @@
import type { ConnectionType } from '@/api/connections';
import {
getSourcePlatformManifestEntry,
type PlatformGovernanceState,
} from '@/utils/platformSupportManifest';
export type InfrastructureOnboardingConnectionType = Extract<
ConnectionType,
'agent' | 'pve' | 'pbs' | 'pmg' | 'truenas' | 'vmware'
>;
export interface InfrastructureOnboardingProductPresentation {
type: InfrastructureOnboardingConnectionType;
label: string;
bestFor: string;
coverage: string;
catalogDescription: string;
autoDetect: boolean;
governanceState: PlatformGovernanceState;
}
interface BaseProductPresentation {
label: string;
bestFor: string;
coverage: string;
catalogDescription: string;
autoDetect: boolean;
sourcePlatformId?: string;
}
export interface InfrastructureOnboardingPathPresentation {
title: string;
description: string;
bestFor: string;
coverage: string;
}
const PRODUCT_PRESENTATION: Record<
InfrastructureOnboardingConnectionType,
BaseProductPresentation
> = {
agent: {
label: 'Pulse Agent',
bestFor: 'Linux, FreeBSD, and compatible hosts such as Unraid',
coverage: 'Host telemetry, SMART, services, Docker, Kubernetes',
catalogDescription: 'Host telemetry, services, Docker, Kubernetes',
autoDetect: false,
},
vmware: {
label: 'VMware vCenter',
bestFor: 'vCenter-managed VMware environments',
coverage: 'VM inventory, ESXi host health, datastore status',
catalogDescription: 'VM inventory, ESXi hosts, datastores',
autoDetect: true,
sourcePlatformId: 'vmware-vsphere',
},
truenas: {
label: 'TrueNAS SCALE',
bestFor: 'TrueNAS appliances with API-backed management',
coverage: 'Pools, datasets, apps, replications',
catalogDescription: 'Pools, datasets, apps, replications',
autoDetect: true,
sourcePlatformId: 'truenas',
},
pve: {
label: 'Proxmox VE',
bestFor: 'Virtualization clusters and standalone hypervisors',
coverage: 'VMs, containers, storage, cluster health',
catalogDescription: 'VMs, containers, storage, cluster health',
autoDetect: true,
sourcePlatformId: 'proxmox-pve',
},
pbs: {
label: 'Proxmox Backup Server',
bestFor: 'Backup infrastructure and protected storage',
coverage: 'Backup jobs, sync, verify, prune, GC',
catalogDescription: 'Backup jobs, sync, verify, prune, GC',
autoDetect: true,
sourcePlatformId: 'proxmox-pbs',
},
pmg: {
label: 'Proxmox Mail Gateway',
bestFor: 'Mail filtering and delivery operations',
coverage: 'Mail stats, queues, quarantine, relay health',
catalogDescription: 'Mail stats, queues, quarantine, relay health',
autoDetect: true,
sourcePlatformId: 'proxmox-pmg',
},
};
const governanceStateForType = (
type: InfrastructureOnboardingConnectionType,
): PlatformGovernanceState => {
const sourcePlatformId = PRODUCT_PRESENTATION[type].sourcePlatformId;
if (!sourcePlatformId) return 'supported';
return getSourcePlatformManifestEntry(sourcePlatformId)?.governanceState ?? 'supported';
};
const API_PRODUCT_ORDER: InfrastructureOnboardingConnectionType[] = [
'vmware',
'truenas',
'pve',
'pbs',
'pmg',
];
export const INFRASTRUCTURE_ONBOARDING_PATHS: Record<
'api' | 'agent',
InfrastructureOnboardingPathPresentation
> = {
api: {
title: 'Connect a supported platform',
description:
'Use a management API when the platform exposes one. Pulse validates the endpoint, requests credentials, and then starts collecting platform inventory and health.',
bestFor: 'TrueNAS, Proxmox, and the current VMware vCenter admission path',
coverage: 'Platform inventory, workloads, storage, backups, and health',
},
agent: {
title: 'Install Pulse Agent',
description:
'Use the agent when you want machine telemetry, or when the system does not expose a management API Pulse can connect to directly.',
bestFor: 'Linux, FreeBSD, and compatible hosts such as Unraid',
coverage: 'CPU temperature, disk SMART, services, network metrics, Docker, Kubernetes',
},
};
export const INFRASTRUCTURE_ONBOARDING_STEPS = [
'Probe address',
'Identify platform',
'Request credentials',
'Validate access',
'Start monitoring',
] as const;
export const INFRASTRUCTURE_AGENT_DISCOVERY_LABELS = [
'Pulse Agent hosts',
'Docker',
'Kubernetes',
] as const;
export const INFRASTRUCTURE_AGENT_HOST_LABELS = ['Linux', 'FreeBSD', 'Unraid'] as const;
export const INFRASTRUCTURE_API_FAMILY_DESCRIPTIONS: Partial<Record<string, string>> = {
Proxmox: 'Virtualization, backup jobs, and mail-gateway health',
};
export const getInfrastructureOnboardingProductPresentation = (
type: InfrastructureOnboardingConnectionType,
): InfrastructureOnboardingProductPresentation => ({
type,
...PRODUCT_PRESENTATION[type],
governanceState: governanceStateForType(type),
});
export const getInfrastructureApiProductPresentations =
(): InfrastructureOnboardingProductPresentation[] =>
API_PRODUCT_ORDER.map((type) => getInfrastructureOnboardingProductPresentation(type));
export const getInfrastructureApiProductsByGovernanceState = (
governanceState: PlatformGovernanceState,
): InfrastructureOnboardingProductPresentation[] =>
getInfrastructureApiProductPresentations().filter(
(product) => product.governanceState === governanceState,
);
export const getInfrastructureAutoDetectLabels = (): string[] =>
getInfrastructureApiProductPresentations()
.filter((product) => product.autoDetect)
.map((product) => product.label);
export const getInfrastructureSupportSummaryBadges = (): {
supportedToday: string[];
currentAdmissionPath: string[];
installPath: string[];
} => ({
supportedToday: [
...getInfrastructureApiProductsByGovernanceState('supported').map((product) => product.label),
...INFRASTRUCTURE_AGENT_DISCOVERY_LABELS,
],
currentAdmissionPath: getInfrastructureApiProductsByGovernanceState('admitted').map(
(product) => product.label,
),
installPath: [...INFRASTRUCTURE_AGENT_HOST_LABELS, ...INFRASTRUCTURE_AGENT_DISCOVERY_LABELS],
});
export const getInfrastructureEmptyStateSummary = (): string =>
'Connect supported platforms by API, or install Pulse Agent on a host for machine telemetry and local runtime discovery.';
export const getInfrastructureEmptyStateDetail = (): string =>
'Supported today: TrueNAS SCALE, Proxmox VE, Proxmox Backup Server, Proxmox Mail Gateway, Pulse Agent hosts, Docker, and Kubernetes. VMware vCenter onboarding is also available on the current admission path.';