mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Redesign infrastructure onboarding flow
This commit is contained in:
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
+17
-15
@@ -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.';
|
||||
Reference in New Issue
Block a user