phase 5: wire ConnectionEditor into the add drawer

The Add infrastructure drawer now opens ConnectionEditor instead of the
legacy type picker + per-type step branches. Paste an address, Pulse
probes, detected type dispatches into a credential slot. The slot still
mounts the existing per-type panels (ProxmoxSettingsPanel,
TrueNASSettingsPanel, VMwareSettingsPanel, InfrastructureInstallerSection)
so mature credential flows keep working end-to-end; slot components are
a later phase.

- InfrastructureWorkspace: panelStep state machine replaced with
  addDrawerOpen + initialAddType. Legacy deep links still redirect and
  pre-select the matching type. Agent setup handoff still auto-opens
  into the agent slot. Read-only mode still suppresses the drawer.
- AddSystemPicker + its test deleted (dead code, the picker UI is gone).
- InfrastructureWorkspace.test.tsx rewritten for the probe-first flow
  (probe button, manual-type escape hatch, back-to-probe, per-type
  credential slot routing, legacy redirects, read-only).
- settingsArchitecture guardrail swapped from panelStep string
  assertions to ConnectionEditor + renderCredentialSlot assertions.
- agent-lifecycle + frontend-primitives contracts extended to require
  the unified add surface run through ConnectionEditor.
This commit is contained in:
rcourtman 2026-04-19 13:21:45 +01:00
parent 3608bf34ff
commit e4663561dd
7 changed files with 172 additions and 423 deletions

View file

@ -313,6 +313,16 @@ an add-only capacity posture.
endpoints that own admission checks, and the `Disabled` flag on
PVE/PBS/PMG surfaced by that ledger must remain a pause-only signal
rather than an installer pre-flight gate.
The unified add surface is
`frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`,
mounted by `InfrastructureWorkspace.tsx` in place of the legacy
type-picker step tree. The editor's probe step calls the aggregator
probe endpoint and dispatches the detected or manually-selected type
into a credential slot that renders the existing per-type panel; it
must not bypass the probe endpoint or fabricate probe candidates, and
the agent credential slot must continue to reach
`InfrastructureInstallerSection.tsx` so install handoffs remain on the
canonical unified-agent install path.
## Forbidden Paths

View file

@ -230,6 +230,12 @@ work extends shared components instead of creating new local variants.
default ledger plus route-backed `Connections` and `Install` workspaces
inside that destination, while each secondary flow still stays
single-purpose instead of stacking multiple workspace surfaces at once.
`InfrastructureWorkspace.tsx` must open a new connection through
`frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`,
not through a per-type picker or per-type step tree. The editor's probe
step is the canonical entry point; credential slots are dispatched by the
detected or manually-selected type and must reach the existing per-type
settings panel rather than diverging into a parallel add surface.
6. Keep Proxmox deep-link route selection on the shared settings-navigation boundary. `frontend-modern/src/components/Settings/settingsNavigationModel.ts` and `frontend-modern/src/components/Settings/useSettingsNavigation.ts` must treat the canonical PBS and PMG Proxmox deep links as agent-selection authority even though those URLs resolve to the shared `infrastructure-operations` tab. Reloading or remounting on a PBS or PMG deep link must not silently fall back to the PVE selector state.
7. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches.
8. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.generated.ts` must be the tracked frontend projection of `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, and `frontend-modern/src/utils/sourcePlatformOptions.ts` must consume that generated projection instead of embedding divergent future-label lists, setup/onboarding path allowlists, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest.

View file

@ -1,142 +0,0 @@
import { Component, For } from 'solid-js';
import { Dialog } from '@/components/shared/Dialog';
export type AddSystemChoiceKind = 'pve' | 'pbs' | 'pmg' | 'truenas' | 'vmware' | 'agent';
export type AddSystemChoiceMethod = 'api' | 'agent';
export interface AddSystemChoice {
kind: AddSystemChoiceKind;
title: string;
description: string;
method: AddSystemChoiceMethod;
methodLabel: string;
}
export const ADD_SYSTEM_CHOICES: readonly AddSystemChoice[] = [
{
kind: 'agent',
title: 'Install on a host',
description:
'Generate an install token and run the Pulse agent on the host itself. Best for the first monitored system or anything without a supported API.',
method: 'agent',
methodLabel: 'Agent install',
},
{
kind: 'pve',
title: 'Proxmox VE',
description: 'Connect a Proxmox cluster or standalone node. Pulse polls the Proxmox API.',
method: 'api',
methodLabel: 'API connection',
},
{
kind: 'pbs',
title: 'Proxmox Backup Server',
description: 'Connect a PBS instance so Pulse can track datastores, sync, and verify jobs.',
method: 'api',
methodLabel: 'API connection',
},
{
kind: 'pmg',
title: 'Proxmox Mail Gateway',
description: 'Connect a PMG instance for queue, quarantine, and mail-stats reporting.',
method: 'api',
methodLabel: 'API connection',
},
{
kind: 'truenas',
title: 'TrueNAS SCALE',
description: 'Connect a TrueNAS system so Pulse can report pools, datasets, and apps.',
method: 'api',
methodLabel: 'API connection',
},
{
kind: 'vmware',
title: 'VMware vSphere or ESXi',
description: 'Connect a vCenter or ESXi host. Pulse polls the vSphere API.',
method: 'api',
methodLabel: 'API connection',
},
];
interface AddSystemPickerProps {
isOpen: boolean;
onClose: () => void;
onSelect: (choice: AddSystemChoice) => void;
onManageProfiles?: () => void;
}
export const AddSystemPicker: Component<AddSystemPickerProps> = (props) => {
return (
<Dialog
isOpen={props.isOpen}
onClose={props.onClose}
ariaLabelledBy="add-system-picker-title"
closeOnBackdrop
panelClass="w-full max-w-2xl rounded-lg bg-surface shadow-xl"
>
<div class="space-y-4 p-6">
<div class="flex items-start justify-between gap-4">
<div>
<h2 id="add-system-picker-title" class="text-lg font-semibold text-base-content">
Add infrastructure
</h2>
<p class="mt-1 text-sm text-muted">
Choose the system or platform you want Pulse to start monitoring.
</p>
</div>
<button
type="button"
onClick={props.onClose}
class="rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted hover:bg-surface-hover"
aria-label="Close"
>
Close
</button>
</div>
<ul class="divide-y divide-border rounded-md border border-border">
<For each={ADD_SYSTEM_CHOICES}>
{(choice) => (
<li>
<button
type="button"
onClick={() => props.onSelect(choice)}
class="flex w-full items-start justify-between gap-4 px-4 py-3 text-left hover:bg-surface-hover"
>
<div>
<div class="text-sm font-semibold text-base-content">{choice.title}</div>
<div class="mt-0.5 text-xs text-muted">{choice.description}</div>
</div>
<span
class={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${
choice.method === 'api'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100'
}`}
>
{choice.methodLabel}
</span>
</button>
</li>
)}
</For>
</ul>
<For each={props.onManageProfiles ? [props.onManageProfiles] : []}>
{(onManageProfiles) => (
<div class="flex items-center justify-between gap-3 rounded-md border border-border bg-surface-alt px-4 py-3">
<div class="text-xs text-muted">
Agent profiles change install defaults for agent-based systems.
</div>
<button
type="button"
onClick={onManageProfiles}
class="inline-flex items-center rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Manage agent profiles
</button>
</div>
)}
</For>
</div>
</Dialog>
);
};

View file

@ -1,15 +1,16 @@
import { Component, For, Show, createEffect, createMemo, createSignal } from 'solid-js';
import { Component, Show, createEffect, createMemo, createSignal } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import { Dialog } from '@/components/shared/Dialog';
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
import { AgentProfilesPanel } from './AgentProfilesPanel';
import { ADD_SYSTEM_CHOICES, type AddSystemChoice } from './AddSystemPicker';
import { ConnectionsTable, type ConnectionsTableHeaderAction } from './ConnectionsTable';
import {
buildInfrastructureSystemRows,
type InfrastructureSystemRow,
type SystemManageAction,
} from './connectionsTableModel';
import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';
import type { ConnectionType } from '@/api/connections';
import { InfrastructureActiveRowDetails } from './InfrastructureActiveRowDetails';
import { InfrastructureInstallerSection } from './InfrastructureInstallerSection';
import { InfrastructureIgnoredRowDetails } from './InfrastructureIgnoredRowDetails';
@ -21,7 +22,6 @@ import {
buildInfrastructureWorkspacePath,
deriveAddStepFromLegacyPath,
type InfrastructureAddStep,
type InfrastructurePanelStep,
} from './infrastructureWorkspaceModel';
import type { InfrastructurePlatformSettingsProps } from './proxmoxSettingsModel';
import type { NodeType } from './infrastructureSettingsModel';
@ -32,13 +32,13 @@ import {
export type InfrastructureWorkspaceProps = InfrastructurePlatformSettingsProps;
const STEP_TITLES: Record<InfrastructureAddStep, string> = {
agent: 'Install on a host',
pve: 'Proxmox VE',
pbs: 'Proxmox Backup Server',
pmg: 'Proxmox Mail Gateway',
truenas: 'TrueNAS SCALE',
vmware: 'VMware vSphere / ESXi',
const ADD_STEP_TO_TYPE: Record<InfrastructureAddStep, ConnectionType> = {
agent: 'agent',
pve: 'pve',
pbs: 'pbs',
pmg: 'pmg',
truenas: 'truenas',
vmware: 'vmware',
};
const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> = (props) => {
@ -46,33 +46,40 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const location = useLocation();
const state = useInfrastructureOperationsContext();
const [panelStep, setPanelStep] = createSignal<InfrastructurePanelStep | null>(null);
const [addDrawerOpen, setAddDrawerOpen] = createSignal(false);
const [initialAddType, setInitialAddType] = createSignal<ConnectionType | null>(null);
const [showAgentProfiles, setShowAgentProfiles] = createSignal(false);
const readOnly = createMemo(() => presentationPolicyIsReadOnly());
// Redirect legacy deep links and open the appropriate panel.
// Redirect legacy deep links and pre-select the matching type in the editor.
createEffect(() => {
const path = location.pathname;
if (path === '/settings/infrastructure') return;
const step = deriveAddStepFromLegacyPath(path);
navigate(buildInfrastructureWorkspacePath(), { replace: true });
if (step && !readOnly()) {
setPanelStep(step);
if (!readOnly()) {
setAddDrawerOpen(true);
if (step && step !== 'pick') {
setInitialAddType(ADD_STEP_TO_TYPE[step]);
} else {
setInitialAddType(null);
}
}
});
// Auto-open the agent installer when a setup handoff is waiting.
createEffect(() => {
if (state.setupHandoff?.() && panelStep() === null && !readOnly()) {
setPanelStep('agent');
if (state.setupHandoff?.() && !addDrawerOpen() && !readOnly()) {
setAddDrawerOpen(true);
setInitialAddType('agent');
}
});
// Close add panel in read-only mode.
createEffect(() => {
if (readOnly() && panelStep() !== null) {
setPanelStep(null);
if (readOnly() && addDrawerOpen()) {
setAddDrawerOpen(false);
}
});
@ -90,7 +97,8 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
{
label: 'Add infrastructure',
onSelect: () => {
setPanelStep('pick');
setInitialAddType(null);
setAddDrawerOpen(true);
setShowAgentProfiles(false);
},
tone: 'primary' as const,
@ -113,42 +121,22 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
}
};
const handleAddSystem = (choice: AddSystemChoice) => {
setShowAgentProfiles(false);
setPanelStep(choice.kind as InfrastructureAddStep);
};
const closePanel = () => {
setPanelStep(null);
const closeAddDrawer = () => {
setAddDrawerOpen(false);
setInitialAddType(null);
setShowAgentProfiles(false);
};
const goBackToPick = () => {
setShowAgentProfiles(false);
setPanelStep('pick');
};
// For Proxmox: selectedAgent is derived from the active panel step.
const proxmoxAgent = createMemo<NodeType>(() => {
const step = panelStep();
if (step === 'pbs') return 'pbs';
if (step === 'pmg') return 'pmg';
return 'pve';
});
const onSelectProxmoxAgent = (agent: NodeType) => {
setPanelStep(agent as InfrastructureAddStep);
};
const isProxmoxStep = () => {
const step = panelStep();
return step === 'pve' || step === 'pbs' || step === 'pmg';
};
const stepTitle = () => {
const step = panelStep();
if (!step || step === 'pick') return 'Add infrastructure';
return STEP_TITLES[step];
const renderProxmoxSlot = (type: NodeType) => {
const selectedAgent = () => type;
return (
<ProxmoxSettingsPanel
{...props}
selectedAgent={selectedAgent}
onSelectAgent={() => {}}
embedded
/>
);
};
return (
@ -159,139 +147,87 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
onManageRow={(row) => handleManageAction(row.manage)}
/>
{/* Add-infrastructure panel — right drawer */}
{/* Unified add-connection drawer */}
<Dialog
isOpen={panelStep() !== null}
onClose={closePanel}
isOpen={addDrawerOpen()}
onClose={closeAddDrawer}
layout="drawer-right"
panelClass="max-w-[820px]"
ariaLabel={stepTitle()}
ariaLabel="Add connection"
>
<div class="flex h-full flex-col overflow-hidden">
{/* Drawer header */}
<div class="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<div class="flex items-center gap-2">
<Show when={panelStep() !== 'pick'}>
<button
type="button"
onClick={goBackToPick}
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover"
>
← Choose different type
</button>
</Show>
<span class="text-sm font-semibold text-base-content">{stepTitle()}</span>
</div>
<div class="flex items-center gap-2">
<Show when={panelStep() === 'agent'}>
<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>
</Show>
<button
type="button"
onClick={closePanel}
class="rounded-md p-1 text-muted hover:bg-surface-hover hover:text-base-content"
aria-label="Close"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<span class="text-sm font-semibold text-base-content">Add connection</span>
<button
type="button"
onClick={closeAddDrawer}
class="rounded-md p-1 text-muted hover:bg-surface-hover hover:text-base-content"
aria-label="Close"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Drawer content */}
<div class="flex-1 overflow-y-auto">
{/* Step 1: picker */}
<Show when={panelStep() === 'pick'}>
<div class="space-y-4 p-4">
<p class="text-sm text-muted">
Choose the system or platform you want Pulse to start monitoring.
</p>
<ul class="divide-y divide-border rounded-md border border-border">
<For each={ADD_SYSTEM_CHOICES}>
{(choice) => (
<li>
<button
type="button"
onClick={() => handleAddSystem(choice)}
class="flex w-full items-start justify-between gap-4 px-4 py-3 text-left hover:bg-surface-hover"
>
<div>
<div class="text-sm font-semibold text-base-content">
{choice.title}
</div>
<div class="mt-0.5 text-xs text-muted">{choice.description}</div>
<Show when={addDrawerOpen()}>
<ConnectionEditor
mode="add"
initialType={initialAddType() ?? undefined}
onClose={closeAddDrawer}
renderCredentialSlot={({ type }) => {
switch (type) {
case 'pve':
case 'pbs':
case 'pmg':
return renderProxmoxSlot(type);
case 'truenas':
return <TrueNASSettingsPanel state={props.trueNASSettings} />;
case 'vmware':
return <VMwareSettingsPanel state={props.vmwareSettings} />;
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>
<span
class={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${
choice.method === 'api'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100'
}`}
>
{choice.methodLabel}
</span>
</button>
</li>
)}
</For>
</ul>
</div>
</Show>
{/* Step 2a: agent installer */}
<Show when={panelStep() === 'agent'}>
<div class="space-y-4 p-4">
<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>
</Show>
{/* Step 2b: Proxmox (PVE / PBS / PMG) */}
<Show when={isProxmoxStep()}>
<div class="p-4">
<ProxmoxSettingsPanel
{...props}
selectedAgent={proxmoxAgent}
onSelectAgent={onSelectProxmoxAgent}
embedded
/>
</div>
</Show>
{/* Step 2c: TrueNAS */}
<Show when={panelStep() === 'truenas'}>
<div class="p-4">
<TrueNASSettingsPanel state={props.trueNASSettings} />
</div>
</Show>
{/* Step 2d: VMware */}
<Show when={panelStep() === 'vmware'}>
<div class="p-4">
<VMwareSettingsPanel state={props.vmwareSettings} />
</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>
);
}
}}
/>
</Show>
</div>
</div>

View file

@ -1,76 +0,0 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ADD_SYSTEM_CHOICES, AddSystemPicker } from '../AddSystemPicker';
describe('AddSystemPicker', () => {
afterEach(() => cleanup());
it('does not render any dialog content when closed', () => {
render(() => (
<AddSystemPicker isOpen={false} onClose={() => {}} onSelect={() => {}} />
) as any);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('lists one button per add-system choice including Proxmox VE, PBS, PMG, TrueNAS, VMware, and agent host', () => {
render(() => (
<AddSystemPicker isOpen={true} onClose={() => {}} onSelect={() => {}} />
) as any);
for (const choice of ADD_SYSTEM_CHOICES) {
expect(screen.getByText(choice.title)).toBeInTheDocument();
}
expect(ADD_SYSTEM_CHOICES.map((c) => c.kind)).toEqual([
'agent',
'pve',
'pbs',
'pmg',
'truenas',
'vmware',
]);
});
it('invokes onSelect with the matching choice when a tile is clicked', () => {
const onSelect = vi.fn();
render(() => (
<AddSystemPicker isOpen={true} onClose={() => {}} onSelect={onSelect} />
) as any);
fireEvent.click(screen.getByText('TrueNAS SCALE'));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0].kind).toBe('truenas');
expect(onSelect.mock.calls[0][0].method).toBe('api');
});
it('invokes onClose when the close button is clicked', () => {
const onClose = vi.fn();
render(() => (
<AddSystemPicker isOpen={true} onClose={onClose} onSelect={() => {}} />
) as any);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('offers agent profile management as a secondary action when provided', () => {
const onManageProfiles = vi.fn();
render(() => (
<AddSystemPicker
isOpen={true}
onClose={() => {}}
onSelect={() => {}}
onManageProfiles={onManageProfiles}
/>
) as any);
fireEvent.click(screen.getByRole('button', { name: 'Manage agent profiles' }));
expect(onManageProfiles).toHaveBeenCalledTimes(1);
});
it('marks the agent-host choice as an agent install and everything else as an API connection', () => {
const agent = ADD_SYSTEM_CHOICES.find((c) => c.kind === 'agent');
const apis = ADD_SYSTEM_CHOICES.filter((c) => c.kind !== 'agent');
expect(agent?.method).toBe('agent');
expect(apis.every((c) => c.method === 'api')).toBe(true);
});
});

View file

@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { UnifiedAgentRow } from '../infrastructureOperationsModel';
@ -82,6 +82,17 @@ vi.mock('../AgentProfilesPanel', () => ({
AgentProfilesPanel: () => <div data-testid="agent-profiles">profiles</div>,
}));
vi.mock('@/api/connections', async () => {
const actual = await vi.importActual<typeof import('@/api/connections')>('@/api/connections');
return {
...actual,
ConnectionsAPI: {
list: vi.fn(),
probe: vi.fn().mockResolvedValue({ candidates: [], probedMs: 0 }),
},
};
});
const reportingRow = (overrides: Partial<UnifiedAgentRow> = {}): UnifiedAgentRow =>
({
rowKey: 'agent:tower',
@ -187,82 +198,85 @@ describe('InfrastructureWorkspace', () => {
expect(screen.queryByText('lab-vcenter')).toBeNull();
});
it('opens the type picker in the add panel when Add infrastructure is clicked', () => {
it('opens the unified add drawer with the probe step when Add infrastructure is clicked', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
expect(screen.getByText('Choose the system or platform you want Pulse to start monitoring.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install on a host/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
expect(screen.getByPlaceholderText(/pve01\.lan/)).toBeInTheDocument();
expect(navigateSpy).not.toHaveBeenCalled();
});
it('shows the agent installer when Install on a host is chosen', () => {
it('routes to the agent credential slot when the user picks agent manually', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /Install on a host/i }));
fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i }));
fireEvent.click(screen.getByRole('button', { name: /Agent \(install on host\)/i }));
expect(screen.getByTestId('install-section')).toBeInTheDocument();
expect(screen.queryByText('What do you want to connect?')).toBeNull();
});
it('shows the TrueNAS panel when TrueNAS SCALE is chosen', () => {
it('routes to the TrueNAS credential slot when TrueNAS is picked manually', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i }));
fireEvent.click(screen.getByRole('button', { name: /TrueNAS SCALE/i }));
expect(screen.getByTestId('truenas-section')).toBeInTheDocument();
expect(navigateSpy).not.toHaveBeenCalled();
});
it('shows the VMware panel when VMware vSphere is chosen', () => {
it('routes to the VMware credential slot when VMware is picked manually', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /VMware vSphere or ESXi/i }));
fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i }));
fireEvent.click(screen.getByRole('button', { name: /VMware vCenter/i }));
expect(screen.getByTestId('vmware-section')).toBeInTheDocument();
expect(navigateSpy).not.toHaveBeenCalled();
});
it('shows the Proxmox panel when Proxmox VE is chosen', () => {
it('routes to the Proxmox credential slot when Proxmox VE is picked manually', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /Proxmox VE/i }));
fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i }));
fireEvent.click(screen.getByRole('button', { name: /^Proxmox VE/i }));
expect(screen.getByTestId('proxmox-section')).toBeInTheDocument();
expect(navigateSpy).not.toHaveBeenCalled();
});
it('returns to the picker via back button from a step-2 form', () => {
it('can return to the probe step from a credential slot via Back to probe', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /Install on a host/i }));
fireEvent.click(screen.getByRole('button', { name: /Choose different type/i }));
fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i }));
fireEvent.click(screen.getByRole('button', { name: /Agent \(install on host\)/i }));
fireEvent.click(screen.getByRole('button', { name: /Back to probe/i }));
expect(screen.getByText('Choose the system or platform you want Pulse to start monitoring.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
expect(screen.queryByTestId('install-section')).toBeNull();
});
it('toggles agent profiles in the agent install drawer', () => {
it('toggles agent profiles inside the agent credential slot', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /Install on a host/i }));
fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i }));
fireEvent.click(screen.getByRole('button', { name: /Agent \(install on host\)/i }));
fireEvent.click(screen.getByRole('button', { name: 'Manage agent profiles' }));
expect(screen.getByTestId('agent-profiles')).toBeInTheDocument();
});
it('does not use modals or child-node setup, only sets panel state on no-modal choices', () => {
it('does not mount node-modal legacy spies when a credential slot is rendered', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /Proxmox VE/i }));
fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i }));
fireEvent.click(screen.getByRole('button', { name: /^Proxmox VE/i }));
expect(setShowNodeModalSpy).not.toHaveBeenCalled();
expect(setEditingNodeSpy).not.toHaveBeenCalled();
@ -279,28 +293,29 @@ describe('InfrastructureWorkspace', () => {
expect(screen.getByTestId('active-details')).toBeInTheDocument();
});
it('redirects legacy install deep link to the base route and opens the agent panel', () => {
it('redirects legacy install deep link and pre-selects the agent credential slot', async () => {
mockPathname = '/settings/infrastructure/install';
renderWorkspace();
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure', { replace: true });
expect(screen.getByTestId('install-section')).toBeInTheDocument();
await waitFor(() => expect(screen.getByTestId('install-section')).toBeInTheDocument());
});
it('redirects legacy platforms deep link to the base route and opens the picker', () => {
it('redirects legacy truenas deep link and pre-selects the TrueNAS credential slot', async () => {
mockPathname = '/settings/infrastructure/platforms/truenas';
renderWorkspace();
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure', { replace: true });
expect(screen.getByTestId('truenas-section')).toBeInTheDocument();
await waitFor(() => expect(screen.getByTestId('truenas-section')).toBeInTheDocument());
});
it('hides Add infrastructure and the add panel in read-only mode', () => {
it('hides Add infrastructure and the add drawer in read-only mode', () => {
presentationPolicyIsReadOnlyMock.mockReturnValue(true);
renderWorkspace();
expect(screen.queryByRole('button', { name: /Add infrastructure/i })).toBeNull();
expect(screen.queryByTestId('install-section')).toBeNull();
expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull();
});
it('collapses read-only sessions back to the inventory and redirects', () => {

View file

@ -896,8 +896,8 @@ describe('Settings architecture guardrails', () => {
"import { Subtabs } from '@/components/shared/Subtabs'",
);
expect(infrastructureWorkspaceSource).not.toContain('<Subtabs');
expect(infrastructureWorkspaceSource).toContain("panelStep() === 'pick'");
expect(infrastructureWorkspaceSource).toContain("panelStep() === 'agent'");
expect(infrastructureWorkspaceSource).toContain('./ConnectionEditor/ConnectionEditor');
expect(infrastructureWorkspaceSource).toContain('renderCredentialSlot');
expect(infrastructureWorkspaceSource).not.toContain("activeView() === 'operations'");
expect(connectionsTableSource).toContain('headerActions');
expect(connectionsTableModelSource).toContain('export function buildInfrastructureSystemRows');