Adopt source-manager infrastructure settings flow

This commit is contained in:
rcourtman 2026-04-22 16:54:17 +01:00
parent f96abc5ee0
commit f7d9b8252e
18 changed files with 1038 additions and 599 deletions

View file

@ -210,7 +210,7 @@ an add-only capacity posture.
The CLI entrypoint owns propagation of persistence context into runtime-owned helpers. When installer-selected state roots differ from the default, `cmd/pulse-agent/main.go` must pass that exact `StateDir` through both the host-agent runtime and updater startup paths instead of letting one path silently fall back to `/var/lib/pulse-agent`.
8. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
Persistence-sensitive NAS targets must keep one canonical continuity model here: installer-owned bootstraps may use flash-backed or immutable-root launch hooks only as thin trampolines, while the durable wrapper, state, and reboot-surviving binary copy stay in the governed persistent state directory that updater continuity also refreshes.
9. Add or change profile management, the extracted agent profiles runtime owner, the top-level infrastructure ledger, the pure unified-agent inventory/install model, the connections-ledger workspace shell, the unified ConnectionEditor and its per-type credential slots, route model, shared install section owner, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/ConnectionsTable.tsx`, `frontend-modern/src/components/Settings/connectionsTableModel.ts`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, `frontend-modern/src/components/Settings/useConnectionRowActions.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. Phase 9 retired the legacy reporting/inventory surface (InfrastructureOperationsController, InfrastructureInventorySection, InfrastructureActiveRowDetails, InfrastructureIgnoredRowDetails, InfrastructureStopMonitoringDialog, useInfrastructureReportingState) and the per-type shells (PlatformConnectionsWorkspace, ProxmoxSettingsPanel, ProxmoxDirectWorkspace, ProxmoxConfiguredNodesTable, ProxmoxDirectConnectionsCard, ProxmoxDiscoveryResultsCard, ProxmoxDeleteNodeDialog, ProxmoxNodeModalStack, NodeModal shell, TrueNASSettingsPanel, VMwareSettingsPanel, useProxmoxDirectWorkspaceState); lifecycle extensions must route through the unified aggregator ledger plus ConnectionEditor credential slots rather than reintroducing those retired surfaces.
9. Add or change profile management, the extracted agent profiles runtime owner, the infrastructure source-manager landing and monitored-systems summary, the pure unified-agent inventory/install model, the connections-ledger workspace shell, the unified ConnectionEditor and its per-type credential slots, route model, shared install section owner, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/ConnectionsTable.tsx`, `frontend-modern/src/components/Settings/connectionsTableModel.ts`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, `frontend-modern/src/components/Settings/useConnectionRowActions.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. Phase 9 retired the legacy reporting/inventory surface (InfrastructureOperationsController, InfrastructureInventorySection, InfrastructureActiveRowDetails, InfrastructureIgnoredRowDetails, InfrastructureStopMonitoringDialog, useInfrastructureReportingState) and the per-type shells (PlatformConnectionsWorkspace, ProxmoxSettingsPanel, ProxmoxDirectWorkspace, ProxmoxConfiguredNodesTable, ProxmoxDirectConnectionsCard, ProxmoxDiscoveryResultsCard, ProxmoxDeleteNodeDialog, ProxmoxNodeModalStack, NodeModal shell, TrueNASSettingsPanel, VMwareSettingsPanel, useProxmoxDirectWorkspaceState); lifecycle extensions must route through the unified aggregator ledger, source-manager cards, and ConnectionEditor credential slots rather than reintroducing those retired surfaces.
Those lifecycle-owned settings hooks may consume websocket state only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx` or recreate root-shell providers.
Public demo and other read-only settings posture must stay reporting-first
on that same lifecycle-owned workspace boundary: infrastructure workspace

View file

@ -234,31 +234,31 @@ work extends shared components instead of creating new local variants.
the banner shell, state owner, or shared model.
5. Keep shared infrastructure shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts` and `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source — via the unified aggregator through `frontend-modern/src/components/Settings/useConnectionsLedger.ts` — instead of creating provider-local summary fetches or VMware-only shell vocabulary. Phase 9 retired the old `PlatformConnectionsWorkspace` per-type shell, but setup guidance may still use `Platform connections` as the operator-facing label for the shared API-backed onboarding path.
That same shared shell boundary now owns the default posture for
`/settings/infrastructure`: the landing route should read as one ledger-first
workspace with one row per top-level monitored system. Configured platform
connections must stay in their own management workspace instead of showing up
as peer rows in the default table, while installer tooling, provider setup
workspaces, and profile management remain secondary flows opened by explicit
deep links or dedicated workspace routes instead of being dumped inline underneath the default
table.
Those deep-linked secondary views must stay under the same single
`Infrastructure` sidebar destination, but they should render as normal page
content with explicit back navigation instead of modal or drawer overlays.
`/settings/infrastructure`: the landing route should read as one
source-manager workspace with product-type cards first and the monitored
systems ledger as secondary summary context. Configured platform
connections must stay visible on the landing route under those product
cards instead of hiding behind a separate add screen, while installer
tooling, probe/detect flows, and profile management remain secondary
interactions opened from the same destination instead of taking over the
whole page.
Those secondary views must stay under the same single `Infrastructure`
sidebar destination, but they may open in governed modal/dialog chrome when
that preserves the persistent source-manager page behind them.
That same shared shell boundary now owns one canonical infrastructure
destination in the Settings sidebar. `InfrastructureWorkspace.tsx` owns the
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
source-manager landing plus the subordinate monitored-systems summary inside
that destination, while route-backed add flows and local edit flows stay
single-purpose instead of stacking multiple page-level workspaces at once.
`InfrastructureWorkspace.tsx` must still 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 an inline credential
form rather than diverging into a parallel add surface. The add flow
must render inline in place of the inventory table, not as a right-side
drawer, centered modal, or other overlay chrome — placing isolated
per-type forms behind a common drawer is re-wrapping, and the unified
add surface is specifically not allowed to reintroduce that pattern.
but the editor now serves as governed dialog content under the source
manager rather than replacing the page inline. The `?add=pick` route owns
the generic detect/browse dialog, while product-type add buttons may jump
straight into the matching credential slot through `initialType`.
Credential slots are dispatched by the detected or manually-selected type
and must still reach the canonical form body rather than diverging into a
revived provider-specific workspace.
For PVE, PBS, and PMG, the credential slot is
`frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`
and it must compose `NodeModalBasicInfoSection`,
@ -274,15 +274,17 @@ work extends shared components instead of creating new local variants.
editor shell — no connection list, no row actions, no surrounding
panel chrome. Showing a ledger of other systems inside the credential
slot is exactly the ledger-inside-editor drift this contract forbids.
The configured-connections ledger itself must render exclusively from
the aggregator. `InfrastructureWorkspace.tsx` composes the default
table rows from
The configured-connections summary and source-manager rows themselves must
render exclusively from the aggregator.
`InfrastructureWorkspace.tsx` composes the source cards plus the subordinate
monitored-systems table from
`frontend-modern/src/components/Settings/useConnectionsLedger.ts`
(polling `GET /api/connections`) and renders per-row Edit, Pause, and
Remove actions inline via
(polling `GET /api/connections`) and renders per-source Edit, Pause, and
Remove actions through
`frontend-modern/src/components/Settings/useConnectionRowActions.ts`;
last-error detail and agent uninstall commands surface on the row
itself rather than behind a separate detail page or `Dialog` drawer.
last-error detail and agent uninstall commands surface on the source row or
in the governed edit dialog rather than behind a revived provider-specific
detail page.
Phase 9 retired the
parallel reporting/inventory surface entirely:
`useInfrastructureReportingState`, `InfrastructureOperationsController`,

View file

@ -46,6 +46,8 @@ export interface ConnectionEditorProps {
mode?: ConnectionEditorMode;
initialType?: ConnectionType;
initialAddress?: string;
showSlotHeader?: boolean;
trackInitialCatalogSelection?: boolean;
renderCredentialSlot: CredentialSlotRenderer;
manualTypeOptions?: ConnectionType[];
onClose: () => void;
@ -107,6 +109,9 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
onboardingMetrics.recordOpened();
if (props.initialType) {
recordPathSelectedForType(props.initialType);
if (props.trackInitialCatalogSelection && props.initialType !== 'agent') {
onboardingMetrics.recordCatalogSelected(props.initialType);
}
}
});
@ -230,7 +235,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
<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
Available now
</div>
<div class="flex flex-wrap gap-1.5">
<For each={supportBadges().currentAdmissionPath}>
@ -342,7 +347,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
</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
Available now
</span>
</Show>
</div>
@ -429,7 +434,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
</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
Available now
</span>
</Show>
</div>
@ -525,27 +530,29 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
</div>
}
>
<div class="flex items-center justify-between border-b border-border px-4 py-2">
<div class="text-sm">
<span class="font-semibold text-base-content">
{activeType() === 'agent'
? 'Install Pulse Agent'
: (CONNECTION_TYPE_LABELS[activeType()!] ?? activeType())}
</span>
<Show when={selectedCandidate()}>
<span class="ml-2 text-xs text-muted">{selectedCandidate()!.host}</span>
<Show when={props.showSlotHeader ?? true}>
<div class="flex items-center justify-between border-b border-border px-4 py-2">
<div class="text-sm">
<span class="font-semibold text-base-content">
{activeType() === 'agent'
? 'Install Pulse Agent'
: (CONNECTION_TYPE_LABELS[activeType()!] ?? activeType())}
</span>
<Show when={selectedCandidate()}>
<span class="ml-2 text-xs text-muted">{selectedCandidate()!.host}</span>
</Show>
</div>
<Show when={(props.mode ?? 'add') === 'add'}>
<button
type="button"
onClick={reopenProbe}
class="inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Back to catalog
</button>
</Show>
</div>
<Show when={(props.mode ?? 'add') === 'add'}>
<button
type="button"
onClick={reopenProbe}
class="inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Back to catalog
</button>
</Show>
</div>
</Show>
<div class="flex-1 overflow-y-auto p-4">
{props.renderCredentialSlot({

View file

@ -18,6 +18,10 @@ export interface NodeCredentialSlotProps {
initialAddress?: string;
onCancel: () => void;
onSaved: () => void;
onDelete?: () => void;
deletePending?: boolean;
deleteConfirming?: boolean;
deleteError?: string | null;
}
export const NodeCredentialSlot: Component<NodeCredentialSlotProps> = (props) => {
@ -60,7 +64,14 @@ export const NodeCredentialSlot: Component<NodeCredentialSlotProps> = (props) =>
<NodeModalBasicInfoSection modalProps={modalProps} state={state} />
<NodeModalAuthenticationSection modalProps={modalProps} state={state} />
<NodeModalMonitoringSection modalProps={modalProps} state={state} />
<NodeModalStatusFooter modalProps={modalProps} state={state} />
<NodeModalStatusFooter
modalProps={modalProps}
state={state}
onDelete={props.onDelete}
deletePending={props.deletePending}
deleteConfirming={props.deleteConfirming}
deleteError={props.deleteError}
/>
</form>
);
};

View file

@ -22,6 +22,10 @@ export interface TrueNASCredentialSlotProps {
editingConnection?: TrueNASConnection | null;
onCancel: () => void;
onSaved: () => void;
onDelete?: () => void;
deletePending?: boolean;
deleteConfirming?: boolean;
deleteError?: string | null;
}
export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (props) => {
@ -50,6 +54,8 @@ export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (pro
props.onCancel();
};
const isEditing = () => Boolean(props.editingConnection);
return (
<div class="space-y-6">
<Show when={props.state.featureDisabled()}>
@ -294,12 +300,48 @@ export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (pro
errorTitle={props.state.monitoredSystemPreviewErrorTitle()}
/>
<Show when={props.deleteConfirming}>
<div class="rounded-md border border-border bg-surface-alt px-4 py-3 text-xs text-muted">
Removing forgets this connection from Pulse; credentials on the platform itself are
untouched.
</div>
</Show>
<Show when={props.deleteError}>
{(message) => (
<div
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
{message()}
</div>
)}
</Show>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Show when={isEditing() && props.onDelete}>
<button
type="button"
class={
props.deleteConfirming
? 'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-rose-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-60'
: 'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-rose-300 px-3 py-2 text-sm font-medium text-rose-700 transition-colors hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-rose-900 dark:text-rose-300 dark:hover:bg-rose-950'
}
onClick={() => props.onDelete?.()}
disabled={props.state.saving() || props.state.testing() || props.deletePending}
>
{props.deletePending
? 'Deleting…'
: props.deleteConfirming
? 'Click again to confirm'
: 'Delete connection'}
</button>
</Show>
<button
type="button"
class={buttonClass}
onClick={handleCancel}
disabled={props.state.saving() || props.state.testing()}
disabled={props.state.saving() || props.state.testing() || props.deletePending}
>
Cancel
</button>
@ -307,7 +349,7 @@ export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (pro
type="button"
class={buttonClass}
onClick={() => void props.state.testCurrentForm()}
disabled={props.state.saving() || props.state.testing()}
disabled={props.state.saving() || props.state.testing() || props.deletePending}
>
{props.state.testing() ? 'Testing…' : 'Test connection'}
</button>
@ -329,10 +371,17 @@ export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (pro
props.state.saving() ||
props.state.testing() ||
props.state.previewing() ||
props.deletePending ||
props.state.monitoredSystemAdmissionSaveBlocked()
}
>
{props.state.saving() ? 'Adding…' : 'Add connection'}
{props.state.saving()
? isEditing()
? 'Saving…'
: 'Adding…'
: isEditing()
? 'Update connection'
: 'Add connection'}
</button>
</div>
</Show>

View file

@ -23,6 +23,10 @@ export interface VMwareCredentialSlotProps {
editingConnection?: VMwareConnection | null;
onCancel: () => void;
onSaved: () => void;
onDelete?: () => void;
deletePending?: boolean;
deleteConfirming?: boolean;
deleteError?: string | null;
}
export const VMwareCredentialSlot: Component<VMwareCredentialSlotProps> = (props) => {
@ -51,6 +55,8 @@ export const VMwareCredentialSlot: Component<VMwareCredentialSlotProps> = (props
props.onCancel();
};
const isEditing = () => Boolean(props.editingConnection);
return (
<div class="space-y-6">
<Show when={props.state.featureDisabled()}>
@ -232,12 +238,48 @@ export const VMwareCredentialSlot: Component<VMwareCredentialSlotProps> = (props
errorTitle={props.state.monitoredSystemPreviewErrorTitle()}
/>
<Show when={props.deleteConfirming}>
<div class="rounded-md border border-border bg-surface-alt px-4 py-3 text-xs text-muted">
Removing forgets this connection from Pulse; credentials on the platform itself are
untouched.
</div>
</Show>
<Show when={props.deleteError}>
{(message) => (
<div
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
{message()}
</div>
)}
</Show>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Show when={isEditing() && props.onDelete}>
<button
type="button"
class={
props.deleteConfirming
? 'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-rose-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-60'
: 'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-rose-300 px-3 py-2 text-sm font-medium text-rose-700 transition-colors hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-rose-900 dark:text-rose-300 dark:hover:bg-rose-950'
}
onClick={() => props.onDelete?.()}
disabled={props.state.saving() || props.state.testing() || props.deletePending}
>
{props.deletePending
? 'Deleting…'
: props.deleteConfirming
? 'Click again to confirm'
: 'Delete connection'}
</button>
</Show>
<button
type="button"
class={buttonClass}
onClick={handleCancel}
disabled={props.state.saving() || props.state.testing()}
disabled={props.state.saving() || props.state.testing() || props.deletePending}
>
Cancel
</button>
@ -245,7 +287,7 @@ export const VMwareCredentialSlot: Component<VMwareCredentialSlotProps> = (props
type="button"
class={buttonClass}
onClick={() => void props.state.testCurrentForm()}
disabled={props.state.saving() || props.state.testing()}
disabled={props.state.saving() || props.state.testing() || props.deletePending}
>
{props.state.testing() ? 'Testing…' : 'Test connection'}
</button>
@ -267,10 +309,17 @@ export const VMwareCredentialSlot: Component<VMwareCredentialSlotProps> = (props
props.state.saving() ||
props.state.testing() ||
props.state.previewing() ||
props.deletePending ||
props.state.monitoredSystemAdmissionSaveBlocked()
}
>
{props.state.saving() ? 'Adding…' : 'Add connection'}
{props.state.saving()
? isEditing()
? 'Saving…'
: 'Adding…'
: isEditing()
? 'Update connection'
: 'Add connection'}
</button>
</div>
</Show>

View file

@ -134,7 +134,7 @@ describe('ConnectionEditor', () => {
expectNodeBefore(proxmoxButton, agentButton);
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.getAllByText('Available now').length).toBeGreaterThan(0);
expect(screen.getByText('What happens next')).toBeInTheDocument();
});

View file

@ -0,0 +1,304 @@
import { Component, For, Show, type Accessor } from 'solid-js';
import { Archive, Cpu, Database, Mail, Search, Server, ServerCog } from 'lucide-solid';
import type { Connection } from '@/api/connections';
import SettingsPanel from '@/components/shared/SettingsPanel';
import type { InfrastructureSystemRow } from './connectionsTableModel';
import type { AgentUninstallCommands } from './ConnectionsTable';
import type { ConnectionRowActions } from './useConnectionRowActions';
import {
getInfrastructureOnboardingProductPresentation,
type InfrastructureOnboardingConnectionType,
} from '@/utils/infrastructureOnboardingPresentation';
interface InfrastructureSourceManagerProps {
rows: Accessor<readonly InfrastructureSystemRow[]>;
readOnly: boolean;
actions?: ConnectionRowActions;
onAddType: (type: InfrastructureOnboardingConnectionType) => void;
onEditConnection?: (connection: Connection) => void;
onDetectFromAddress?: () => void;
agentUninstallCommands?: AgentUninstallCommands;
onCopyText?: (text: string) => void;
}
const buttonClass =
'inline-flex items-center rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const primaryButtonClass =
'inline-flex items-center rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60';
const removeButtonClass =
'inline-flex items-center rounded-md border border-rose-300 px-2.5 py-1.5 text-xs font-medium text-rose-700 transition-colors hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-rose-900 dark:text-rose-300 dark:hover:bg-rose-950';
const removeConfirmClass =
'inline-flex items-center rounded-md bg-rose-600 px-2.5 py-1.5 text-xs font-medium text-white transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-60';
const CARD_ORDER: InfrastructureOnboardingConnectionType[] = [
'vmware',
'truenas',
'pve',
'pbs',
'pmg',
'agent',
];
const CARD_ICON: Record<InfrastructureOnboardingConnectionType, Component<{ class?: string }>> = {
vmware: ServerCog,
truenas: Database,
pve: Server,
pbs: Archive,
pmg: Mail,
agent: Cpu,
};
const EMPTY_STATE_LABEL: Record<InfrastructureOnboardingConnectionType, string> = {
vmware: 'No VMware vCenter connections yet.',
truenas: 'No TrueNAS SCALE connections yet.',
pve: 'No Proxmox VE connections yet.',
pbs: 'No Proxmox Backup Server connections yet.',
pmg: 'No Proxmox Mail Gateway connections yet.',
agent: 'No Pulse Agent hosts connected yet.',
};
const ACTION_LABEL: Record<InfrastructureOnboardingConnectionType, string> = {
vmware: 'Add VMware vCenter',
truenas: 'Add TrueNAS',
pve: 'Add Proxmox VE',
pbs: 'Add Proxmox Backup Server',
pmg: 'Add Proxmox Mail Gateway',
agent: 'Install agent',
};
const pathBadge = (type: InfrastructureOnboardingConnectionType): string =>
type === 'agent' ? 'Agent path' : 'Platform API';
const additionalBadge = (type: InfrastructureOnboardingConnectionType): string | null => {
if (type === 'vmware') return 'Available now';
if (type === 'agent') return 'Docker + Kubernetes';
return null;
};
const additionalNote = (type: InfrastructureOnboardingConnectionType): string | null => {
if (type === 'agent') {
return 'Docker and Kubernetes are discovered from the host after the agent is installed.';
}
return null;
};
const confirmHelpText = (isAgent: boolean): string =>
isAgent
? 'Removing forgets this agent from Pulse. Uninstall it on the host as well if you want to detach it completely.'
: 'Removing forgets this connection from Pulse; credentials on the platform itself are untouched.';
export const InfrastructureSourceManager: Component<InfrastructureSourceManagerProps> = (props) => {
const rowsForType = (type: InfrastructureOnboardingConnectionType) =>
props.rows().filter((row) => row.connection.type === type);
return (
<SettingsPanel
title="Connection types"
description="Add and manage infrastructure sources by product type. Existing sources stay visible here while add and edit open in-place dialogs."
icon={<Server class="h-5 w-5" strokeWidth={2} />}
action={
<Show when={!props.readOnly && props.onDetectFromAddress}>
<button type="button" onClick={props.onDetectFromAddress} class={buttonClass}>
<Search class="mr-1.5 h-3.5 w-3.5" />
Detect from address
</button>
</Show>
}
>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<For each={CARD_ORDER}>
{(type) => {
const presentation = getInfrastructureOnboardingProductPresentation(type);
const Icon = CARD_ICON[type];
const groupRows = () => rowsForType(type);
const secondaryBadge = () => additionalBadge(type);
const note = () => additionalNote(type);
return (
<div class="rounded-xl border border-border bg-surface-alt p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 items-start gap-3">
<div class="flex h-10 w-10 flex-none items-center justify-center rounded-md border border-border bg-surface text-base-content">
<Icon class="h-5 w-5" />
</div>
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold text-base-content">{presentation.label}</h3>
<span class="inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
{pathBadge(type)}
</span>
<Show when={secondaryBadge()}>
{(badge) => (
<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">
{badge()}
</span>
)}
</Show>
</div>
<p class="text-xs text-muted">{presentation.bestFor}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="rounded-full border border-border bg-surface px-2 py-0.5 text-[11px] font-medium text-base-content">
{groupRows().length} configured
</span>
<Show when={!props.readOnly}>
<button
type="button"
onClick={() => props.onAddType(type)}
class={primaryButtonClass}
>
{ACTION_LABEL[type]}
</button>
</Show>
</div>
</div>
<p class="mt-3 text-xs text-muted">{presentation.coverage}</p>
<Show when={note()}>
{(value) => <p class="mt-2 text-xs text-muted">{value()}</p>}
</Show>
<div class="mt-4 space-y-2">
<Show
when={groupRows().length > 0}
fallback={
<div class="rounded-lg border border-dashed border-border bg-surface px-3 py-4 text-sm text-muted">
{EMPTY_STATE_LABEL[type]}
</div>
}
>
<For each={groupRows()}>
{(row) => {
const pauseBusy = () => props.actions?.pendingAction(row.id) === 'pause';
const removeBusy = () => props.actions?.pendingAction(row.id) === 'remove';
const anyBusy = () => props.actions?.pendingAction(row.id) !== null;
const removeConfirming = () => Boolean(props.actions?.confirmingRemove(row.id));
const rowError = () => props.actions?.actionError(row.id) ?? null;
return (
<div class="rounded-lg border border-border bg-surface px-3 py-3">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-0 truncate text-sm font-medium text-base-content">
{row.name}
</div>
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${row.statusClassName}`}
>
{row.statusLabel}
</span>
</div>
<Show when={row.host}>
<div class="break-words text-xs text-muted">{row.host}</div>
</Show>
<Show when={row.subtitle}>
<div class="break-words text-xs text-muted">{row.subtitle}</div>
</Show>
<div class="text-xs text-muted">Last activity: {row.lastActivityText}</div>
<Show when={row.lastErrorMessage}>
<div class="break-words text-xs text-rose-700 dark:text-rose-300">
{row.lastErrorMessage}
</div>
</Show>
</div>
<Show when={!props.readOnly && props.actions}>
<div class="flex flex-wrap items-center gap-1.5">
<Show when={row.canEdit && props.onEditConnection}>
<button
type="button"
disabled={anyBusy()}
onClick={() => props.onEditConnection?.(row.connection)}
class={buttonClass}
>
Edit
</button>
</Show>
<Show when={row.canPause}>
<button
type="button"
disabled={anyBusy()}
onClick={() => void props.actions?.togglePause(row.connection)}
class={buttonClass}
>
{pauseBusy()
? 'Working…'
: row.enabled
? 'Pause'
: 'Resume'}
</button>
</Show>
<Show when={row.canRemove}>
<button
type="button"
disabled={anyBusy()}
onClick={() => void props.actions?.requestRemove(row.connection)}
class={removeConfirming() ? removeConfirmClass : removeButtonClass}
>
{removeBusy()
? 'Removing…'
: removeConfirming()
? 'Click again to confirm'
: 'Remove'}
</button>
</Show>
</div>
</Show>
</div>
<Show when={removeConfirming()}>
<div class="mt-3 space-y-2 rounded-md border border-border bg-surface-alt px-3 py-2">
<p class="text-xs text-muted">{confirmHelpText(row.isAgent)}</p>
<Show when={row.isAgent && props.agentUninstallCommands}>
<div class="space-y-2">
<div class="rounded-md border border-border bg-surface px-3 py-2">
<div class="text-[11px] font-medium uppercase tracking-wide text-muted">
Linux / macOS / FreeBSD
</div>
<div class="mt-1 break-all font-mono text-[11px] text-base-content">
{props.agentUninstallCommands!.linux}
</div>
<Show when={props.onCopyText}>
<button
type="button"
class={`${buttonClass} mt-2`}
onClick={() =>
props.onCopyText?.(props.agentUninstallCommands!.linux)
}
>
Copy uninstall command
</button>
</Show>
</div>
</div>
</Show>
</div>
</Show>
<Show when={rowError()}>
<div
role="alert"
class="mt-3 rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-xs text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
{rowError()}
</div>
</Show>
</div>
);
}}
</For>
</Show>
</div>
</div>
);
}}
</For>
</div>
</SettingsPanel>
);
};
export default InfrastructureSourceManager;

View file

@ -1,10 +1,12 @@
import { Component, Match, Show, Switch, createEffect, createMemo, createSignal } from 'solid-js';
import { useLocation, useNavigate, useSearchParams } from '@solidjs/router';
import { useLocation, useNavigate } from '@solidjs/router';
import X from 'lucide-solid/icons/x';
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
import { copyToClipboard } from '@/utils/clipboard';
import { notificationStore } from '@/stores/notifications';
import { Dialog } from '@/components/shared/Dialog';
import { AgentProfilesPanel } from './AgentProfilesPanel';
import { ConnectionsTable, type ConnectionsTableHeaderAction } from './ConnectionsTable';
import { ConnectionsTable, type AgentUninstallCommands } from './ConnectionsTable';
import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';
import { NodeCredentialSlot } from './ConnectionEditor/CredentialSlots/NodeCredentialSlot';
import { TrueNASCredentialSlot } from './ConnectionEditor/CredentialSlots/TrueNASCredentialSlot';
@ -14,11 +16,11 @@ import type { TrueNASConnection } from '@/api/truenas';
import type { VMwareConnection } from '@/api/vmware';
import type { NodeConfigWithStatus } from '@/types/nodes';
import { InfrastructureInstallerSection } from './InfrastructureInstallerSection';
import { InfrastructureSourceManager } from './InfrastructureSourceManager';
import {
buildInfrastructureOnboardingPath,
buildInfrastructureWorkspacePath,
deriveAddStepFromSearch,
deriveAddStepFromLocation,
INFRASTRUCTURE_ADD_QUERY_PARAM,
type InfrastructureAddStep,
} from './infrastructureWorkspaceModel';
import type { InfrastructurePlatformSettingsProps } from './proxmoxSettingsModel';
@ -28,6 +30,7 @@ import {
InfrastructureOperationsStateProvider,
useInfrastructureOperationsContext,
} from './useInfrastructureOperationsState';
import { getInfrastructureOnboardingProductPresentation } from '@/utils/infrastructureOnboardingPresentation';
export type InfrastructureWorkspaceProps = InfrastructurePlatformSettingsProps;
@ -40,19 +43,33 @@ const ADD_STEP_TO_TYPE: Record<InfrastructureAddStep, ConnectionType> = {
vmware: 'vmware',
};
const closeButtonClass =
'inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover';
const describeManagedSourceType = (type: ConnectionType | null): string => {
if (!type) return 'Infrastructure';
return getInfrastructureOnboardingProductPresentation(type as InfrastructureAddStep).label;
};
const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> = (props) => {
const navigate = useNavigate();
const location = useLocation();
const [, setSearchParams] = useSearchParams();
const ledger = useConnectionsLedger();
const operations = useInfrastructureOperationsContext();
const rowActions = useConnectionRowActions({ onMutated: () => ledger.reload() });
const [addMode, setAddMode] = createSignal(false);
const [initialAddType, setInitialAddType] = createSignal<ConnectionType | null>(null);
const [showAgentProfiles, setShowAgentProfiles] = createSignal(false);
const [editingConnection, setEditingConnection] = createSignal<Connection | null>(null);
const readOnly = createMemo(() => presentationPolicyIsReadOnly());
const routeStep = createMemo(() => {
if (readOnly()) return null;
return deriveAddStepFromLocation(location.pathname, location.search ?? '');
});
const activeAddType = createMemo<ConnectionType | null>(() => {
const step = routeStep();
if (!step || step === 'pick') return null;
return ADD_STEP_TO_TYPE[step];
});
const findEditableNode = (connection: Connection): NodeConfigWithStatus | null => {
const accessor =
@ -82,64 +99,9 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
return props.trueNASSettings.connections().find((conn) => conn.id === suffix) ?? null;
};
// Redirect legacy deep links and pre-select the matching type in the editor.
createEffect(() => {
const path = location.pathname;
const queryStep = deriveAddStepFromSearch(location.search ?? '');
const step = deriveAddStepFromLocation(path, location.search ?? '');
if (path === '/settings/infrastructure' && !step) return;
if (queryStep) {
setSearchParams({ [INFRASTRUCTURE_ADD_QUERY_PARAM]: null }, { replace: true });
} else {
navigate(buildInfrastructureWorkspacePath(), { replace: true });
}
if (!readOnly() && step) {
setAddMode(true);
if (step === 'pick') {
setInitialAddType(null);
} else {
setInitialAddType(ADD_STEP_TO_TYPE[step]);
}
return;
}
setAddMode(false);
setInitialAddType(null);
setShowAgentProfiles(false);
});
// Drop add/edit mode in read-only sessions.
createEffect(() => {
if (readOnly() && addMode()) {
setAddMode(false);
}
if (readOnly() && editingConnection()) {
setEditingConnection(null);
}
});
const rows = createMemo(() => ledger.rows());
const startAddInfrastructure = () => {
setInitialAddType(null);
setAddMode(true);
setShowAgentProfiles(false);
};
const headerActions = createMemo<ConnectionsTableHeaderAction[]>(() =>
readOnly()
? []
: [
{
label: 'Add infrastructure',
onSelect: startAddInfrastructure,
tone: 'primary' as const,
},
],
);
const agentUninstallCommands = createMemo(() => ({
const agentUninstallCommands = createMemo<AgentUninstallCommands>(() => ({
linux: operations.getUninstallCommand(),
windows: operations.getWindowsUninstallCommand(),
}));
@ -153,253 +115,395 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
}
};
const exitAddMode = () => {
setAddMode(false);
setInitialAddType(null);
const navigateToWorkspace = (replace = false) => {
navigate(buildInfrastructureWorkspacePath(), { replace, scroll: false });
};
const resetInlineEditorState = () => {
props.vmwareSettings.closeDialog();
props.trueNASSettings.closeDialog();
setShowAgentProfiles(false);
};
const exitEditMode = () => {
setEditingConnection(null);
const openAddFlow = (type: InfrastructureAddStep | 'pick') => {
navigate(buildInfrastructureOnboardingPath(type), { scroll: false });
};
const handleEditConnection = (connection: Connection) => {
setEditingConnection(connection);
const closeAddFlow = () => {
resetInlineEditorState();
navigateToWorkspace(Boolean(routeStep()));
};
const closeEditFlow = () => {
const connection = editingConnection();
if (connection) {
rowActions.cancelRemove(connection.id);
}
resetInlineEditorState();
setEditingConnection(null);
};
const handleEditSaved = () => {
ledger.reload();
exitEditMode();
closeEditFlow();
};
createEffect(() => {
if (routeStep() !== 'pick') return;
setShowAgentProfiles(false);
});
createEffect(() => {
if (!editingConnection()) return;
if (ledger.loading()) return;
if (!ledger.findById(editingConnection()!.id)) {
closeEditFlow();
}
});
createEffect(() => {
if (readOnly() && editingConnection()) {
closeEditFlow();
}
});
const renderNodeSlot = (
type: 'pve' | 'pbs' | 'pmg',
editingNode?: NodeConfigWithStatus | null,
editingRow?: Connection | null,
) => {
const onExit = editingNode ? exitEditMode : exitAddMode;
const onSaved = editingNode ? handleEditSaved : exitAddMode;
const connectionId = editingRow?.id ?? null;
return (
<NodeCredentialSlot
nodeType={type}
settings={props}
editingNode={editingNode ?? null}
onCancel={onExit}
onSaved={onSaved}
onCancel={editingRow ? closeEditFlow : closeAddFlow}
onSaved={editingRow ? handleEditSaved : handleAddSaved}
onDelete={
editingRow
? () => {
void rowActions.requestRemove(editingRow);
}
: undefined
}
deletePending={connectionId ? rowActions.pendingAction(connectionId) === 'remove' : false}
deleteConfirming={connectionId ? rowActions.confirmingRemove(connectionId) : false}
deleteError={connectionId ? rowActions.actionError(connectionId) : null}
/>
);
};
const mode = createMemo<'ledger' | 'add' | 'edit'>(() => {
if (editingConnection()) return 'edit';
if (addMode()) return 'add';
return 'ledger';
});
const handleAddSaved = () => {
ledger.reload();
closeAddFlow();
};
return (
<div class="space-y-8">
<Switch
fallback={
<ConnectionsTable
rows={rows}
headerActions={headerActions()}
actions={readOnly() ? undefined : rowActions}
onEdit={readOnly() ? undefined : handleEditConnection}
agentUninstallCommands={agentUninstallCommands()}
onCopyText={(text) => void handleCopy(text)}
/>
const renderConnectionSlot = (context: {
mode: 'add' | 'edit';
type: ConnectionType;
onCancel: () => void;
onSaved: () => void;
}) => {
if (context.mode === 'edit') {
const connection = editingConnection();
if (!connection) {
return (
<div
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
The selected connection is no longer available. Reload and try again.
</div>
);
}
switch (context.type) {
case 'pve':
case 'pbs':
case 'pmg': {
const editableNode = findEditableNode(connection);
if (!editableNode) {
return (
<div
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
Couldn't find the saved configuration for {connection.name}. It may have already
been removed.
</div>
);
}
return renderNodeSlot(context.type, editableNode, connection);
}
>
<Match when={mode() === 'edit' && editingConnection()}>
{(accessor) => {
const connection = accessor();
const editableSlot = (() => {
switch (connection.type) {
case 'pve':
case 'pbs':
case 'pmg': {
const node = findEditableNode(connection);
return node
? {
kind: 'node' as const,
render: () =>
renderNodeSlot(connection.type as 'pve' | 'pbs' | 'pmg', node),
}
: null;
}
case 'vmware': {
const vmware = findEditableVMware(connection);
return vmware
? {
kind: 'vmware' as const,
render: () => (
<VMwareCredentialSlot
state={props.vmwareSettings}
editingConnection={vmware}
onCancel={exitEditMode}
onSaved={handleEditSaved}
/>
),
}
: null;
}
case 'truenas': {
const truenas = findEditableTrueNAS(connection);
return truenas
? {
kind: 'truenas' as const,
render: () => (
<TrueNASCredentialSlot
state={props.trueNASSettings}
editingConnection={truenas}
onCancel={exitEditMode}
onSaved={handleEditSaved}
/>
),
}
: null;
}
default:
return null;
}
})();
case 'vmware': {
const vmware = findEditableVMware(connection);
if (!vmware) {
return (
<div
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
Couldn't find the saved VMware configuration for {connection.name}. It may have
already been removed.
</div>
);
}
return (
<VMwareCredentialSlot
state={props.vmwareSettings}
editingConnection={vmware}
onCancel={context.onCancel}
onSaved={context.onSaved}
onDelete={() => void rowActions.requestRemove(connection)}
deletePending={rowActions.pendingAction(connection.id) === 'remove'}
deleteConfirming={rowActions.confirmingRemove(connection.id)}
deleteError={rowActions.actionError(connection.id)}
/>
);
}
case 'truenas': {
const truenas = findEditableTrueNAS(connection);
if (!truenas) {
return (
<div
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
Couldn't find the saved TrueNAS configuration for {connection.name}. It may have
already been removed.
</div>
);
}
return (
<TrueNASCredentialSlot
state={props.trueNASSettings}
editingConnection={truenas}
onCancel={context.onCancel}
onSaved={context.onSaved}
onDelete={() => void rowActions.requestRemove(connection)}
deletePending={rowActions.pendingAction(connection.id) === 'remove'}
deleteConfirming={rowActions.confirmingRemove(connection.id)}
deleteError={rowActions.actionError(connection.id)}
/>
);
}
default:
return (
<div class="text-sm text-muted">
Editing is not available for the {context.type} source type here yet.
</div>
);
}
}
if (!editableSlot) {
return (
<div class="space-y-4">
<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 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>
<div
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
Couldn't find the underlying configuration for {connection.name}. It may have
been removed. Reload and try again.
switch (context.type) {
case 'pve':
case 'pbs':
case 'pmg':
return renderNodeSlot(context.type);
case 'truenas':
return (
<TrueNASCredentialSlot
state={props.trueNASSettings}
onCancel={context.onCancel}
onSaved={context.onSaved}
/>
);
case 'vmware':
return (
<VMwareCredentialSlot
state={props.vmwareSettings}
onCancel={context.onCancel}
onSaved={context.onSaved}
/>
);
case 'agent':
return (
<div class="space-y-4">
<div class="flex items-center justify-end">
<button
type="button"
onClick={() => setShowAgentProfiles((value) => !value)}
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>
);
}
return (
<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">
Edit {connection.name}
</div>
<div class="mt-0.5 text-xs text-muted">{connection.address}</div>
<AgentProfilesPanel />
</div>
</Show>
<InfrastructureInstallerSection />
</div>
);
default:
return (
<div class="text-sm text-muted">
No credential form is wired up for the {context.type} source type yet.
</div>
);
}
};
const addDialogTitle = createMemo(() => {
const step = routeStep();
if (!step || step === 'pick') return 'Add infrastructure';
return `Add ${describeManagedSourceType(activeAddType())}`;
});
const addDialogDescription = createMemo(() => {
const step = routeStep();
if (!step || step === 'pick') {
return 'Detect a source from its address, or choose the source type you want to add.';
}
const presentation = getInfrastructureOnboardingProductPresentation(
activeAddType() as InfrastructureAddStep,
);
return `${presentation.bestFor}. ${presentation.coverage}.`;
});
const editDialogTitle = createMemo(() => {
const connection = editingConnection();
if (!connection) return 'Edit connection';
return `Edit ${connection.name}`;
});
const editDialogDescription = createMemo(() => {
const connection = editingConnection();
if (!connection) return 'Update this source without leaving the infrastructure manager.';
const label = describeManagedSourceType(connection.type);
return `${label}${connection.address ? ` · ${connection.address}` : ''}`;
});
const isAgentDialog = () =>
activeAddType() === 'agent' || editingConnection()?.type === 'agent';
return (
<div class="space-y-6">
<InfrastructureSourceManager
rows={rows}
readOnly={readOnly()}
actions={readOnly() ? undefined : rowActions}
onAddType={(type) => openAddFlow(type as InfrastructureAddStep)}
onEditConnection={readOnly() ? undefined : (connection) => setEditingConnection(connection)}
onDetectFromAddress={readOnly() ? undefined : () => openAddFlow('pick')}
agentUninstallCommands={agentUninstallCommands()}
onCopyText={(text) => void handleCopy(text)}
/>
<ConnectionsTable rows={rows} />
<Show when={routeStep()}>
{(stepAccessor) => {
const step = stepAccessor();
return (
<Dialog
isOpen={true}
onClose={closeAddFlow}
ariaLabel={addDialogTitle()}
panelClass={isAgentDialog() ? 'max-w-6xl' : 'max-w-5xl'}
>
<div class="flex h-full flex-col">
<div class="flex items-start justify-between gap-4 border-b border-border bg-surface-alt px-4 py-4 sm:px-6">
<div class="space-y-1">
<h2 class="text-base font-semibold text-base-content">{addDialogTitle()}</h2>
<p class="text-sm text-muted">{addDialogDescription()}</p>
</div>
<button
type="button"
onClick={exitEditMode}
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"
onClick={closeAddFlow}
class={closeButtonClass}
aria-label="Close add infrastructure dialog"
>
Back to systems
<X class="h-4 w-4" />
</button>
</div>
<div class="rounded-lg border border-border bg-surface p-4">
{editableSlot.render()}
<div class="min-h-0 flex-1 overflow-y-auto">
<Switch>
<Match when={step === 'pick'}>
<ConnectionEditor
mode="add"
onClose={closeAddFlow}
onSaved={handleAddSaved}
renderCredentialSlot={({ type, onCancel, onSaved }) =>
renderConnectionSlot({ mode: 'add', type, onCancel, onSaved })
}
/>
</Match>
<Match when={Boolean(activeAddType())}>
<ConnectionEditor
mode="add"
initialType={activeAddType() ?? undefined}
showSlotHeader={false}
trackInitialCatalogSelection={activeAddType() !== 'agent'}
onClose={closeAddFlow}
onSaved={handleAddSaved}
renderCredentialSlot={({ type, onCancel, onSaved }) =>
renderConnectionSlot({ mode: 'add', type, onCancel, onSaved })
}
/>
</Match>
</Switch>
</div>
</div>
);
}}
</Match>
</Dialog>
);
}}
</Show>
<Match when={mode() === 'add'}>
<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.
<Show when={editingConnection()}>
{(connectionAccessor) => {
const connection = connectionAccessor();
return (
<Dialog
isOpen={true}
onClose={closeEditFlow}
ariaLabel={editDialogTitle()}
panelClass={connection.type === 'agent' ? 'max-w-6xl' : 'max-w-5xl'}
>
<div class="flex h-full flex-col">
<div class="flex items-start justify-between gap-4 border-b border-border bg-surface-alt px-4 py-4 sm:px-6">
<div class="space-y-1">
<h2 class="text-base font-semibold text-base-content">{editDialogTitle()}</h2>
<p class="text-sm text-muted">{editDialogDescription()}</p>
</div>
<button
type="button"
onClick={closeEditFlow}
class={closeButtonClass}
aria-label="Close edit infrastructure dialog"
>
<X class="h-4 w-4" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<ConnectionEditor
mode="edit"
initialType={connection.type}
showSlotHeader={false}
onClose={closeEditFlow}
onSaved={handleEditSaved}
renderCredentialSlot={({ type, onCancel, onSaved }) =>
renderConnectionSlot({ mode: 'edit', type, onCancel, onSaved })
}
/>
</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>
<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>
</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>
</Match>
</Switch>
</Dialog>
);
}}
</Show>
</div>
);
};

View file

@ -6,6 +6,10 @@ import { logger } from '@/utils/logger';
interface NodeModalStatusFooterProps {
modalProps: NodeModalProps;
state: NodeModalState;
onDelete?: () => void;
deletePending?: boolean;
deleteConfirming?: boolean;
deleteError?: string | null;
}
export const NodeModalStatusFooter: Component<NodeModalStatusFooterProps> = (props) => {
@ -107,17 +111,53 @@ export const NodeModalStatusFooter: Component<NodeModalStatusFooterProps> = (pro
</div>
</Show>
<Show when={props.deleteConfirming}>
<div class="mx-6 mb-2 rounded-md border border-border bg-surface-alt px-4 py-3 text-xs text-muted">
Removing forgets this connection from Pulse; credentials on the platform itself are
untouched.
</div>
</Show>
<Show when={props.deleteError}>
{(message) => (
<div
role="alert"
class="mx-6 mb-2 rounded-md border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
{message()}
</div>
)}
</Show>
<div class="flex items-center justify-between px-6 py-4 border-t border-border">
<button
type="button"
onClick={state.handleTestConnection}
disabled={state.isTesting()}
disabled={state.isTesting() || props.deletePending}
class="px-4 py-2 text-sm border border-border text-base-content rounded-md hover:bg-surface-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{state.isTesting() ? 'Testing...' : 'Test Connection'}
</button>
<div class="flex items-center gap-3">
<Show when={state.isEditingExistingNode() && props.onDelete}>
<button
type="button"
onClick={props.onDelete}
disabled={props.deletePending}
class={`px-4 py-2 text-sm rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
props.deleteConfirming
? 'bg-rose-600 text-white hover:bg-rose-700'
: 'border border-rose-300 text-rose-700 hover:bg-rose-50 dark:border-rose-900 dark:text-rose-300 dark:hover:bg-rose-950'
}`}
>
{props.deletePending
? 'Deleting…'
: props.deleteConfirming
? 'Click again to confirm'
: 'Delete connection'}
</button>
</Show>
<Show when={modalProps.showBackToDiscovery && modalProps.onBackToDiscovery}>
<button
type="button"
@ -144,13 +184,15 @@ export const NodeModalStatusFooter: Component<NodeModalStatusFooterProps> = (pro
<button
type="button"
onClick={modalProps.onClose}
disabled={props.deletePending}
class="px-4 py-2 text-sm border border-border text-base-content rounded-md hover:bg-surface-hover transition-colors"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
disabled={props.deletePending}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{state.isEditingExistingNode() ? 'Update' : 'Add'} Node
</button>

View file

@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Connection } from '@/api/connections';
import { InfrastructureWorkspace } from '../InfrastructureWorkspace';
@ -11,25 +11,7 @@ const connectionState = vi.hoisted(() => ({
connections: [] as Connection[],
}));
const navigateSpy = vi.hoisted(() => vi.fn());
const setSearchParamsSpy = vi.hoisted(() => vi.fn());
const presentationPolicyIsReadOnlyMock = vi.hoisted(() => vi.fn(() => false));
const probeSpy = vi.hoisted(() => vi.fn().mockResolvedValue({ candidates: [], probedMs: 0 }));
const connectionsApiMock = vi.hoisted(() => ({
list: vi.fn(),
probe: probeSpy,
setEnabled: vi.fn(),
remove: vi.fn(),
}));
const onboardingMetricsTrackerMock = vi.hoisted(() => ({
recordOpened: vi.fn(),
recordPathSelected: vi.fn(),
recordProbeResult: vi.fn(),
recordCatalogSelected: vi.fn(),
recordCredentialsOpened: vi.fn(),
}));
const createInfrastructureOnboardingMetricsTrackerMock = vi.hoisted(() =>
vi.fn(() => onboardingMetricsTrackerMock),
);
vi.mock('@solidjs/router', async () => {
const actual = await vi.importActual<typeof import('@solidjs/router')>('@solidjs/router');
@ -37,7 +19,6 @@ vi.mock('@solidjs/router', async () => {
...actual,
useLocation: () => ({ pathname: routeState.pathname, search: routeState.search }),
useNavigate: () => navigateSpy,
useSearchParams: () => [{}, setSearchParamsSpy],
};
});
@ -61,11 +42,14 @@ vi.mock('../useConnectionsLedger', () => ({
connectionState.connections.map((connection) => ({
id: connection.id,
name: connection.name || connection.id,
subtitle: connection.type,
subtitle:
connection.type === 'agent'
? 'Pulse Unified Agent'
: `Platform API · ${connection.type === 'truenas' ? 'TrueNAS SCALE' : connection.type}`,
host: connection.address,
coverageLabels: ['VMs'],
statusLabel: connection.state === 'paused' ? 'Paused' : 'Active',
statusClassName: '',
statusClassName: 'bg-green-100 text-green-800',
lastActivityText: '1m ago',
lastErrorMessage: connection.lastError?.message,
enabled: connection.enabled,
@ -107,12 +91,14 @@ vi.mock('../AgentProfilesPanel', () => ({
AgentProfilesPanel: () => <div data-testid="agent-profiles">profiles</div>,
}));
vi.mock('@/api/connections', () => ({
ConnectionsAPI: connectionsApiMock,
}));
vi.mock('@/utils/infrastructureOnboardingMetrics', () => ({
createInfrastructureOnboardingMetricsTracker: createInfrastructureOnboardingMetricsTrackerMock,
createInfrastructureOnboardingMetricsTracker: () => ({
recordOpened: vi.fn(),
recordPathSelected: vi.fn(),
recordProbeResult: vi.fn(),
recordCatalogSelected: vi.fn(),
recordCredentialsOpened: vi.fn(),
}),
}));
const connectionFixture = (overrides: Partial<Connection> = {}): Connection => ({
@ -132,10 +118,6 @@ const connectionFixture = (overrides: Partial<Connection> = {}): Connection => (
...overrides,
});
function expectNodeBefore(a: Node, b: Node) {
expect(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
const baseProps = () =>
({
selectedAgent: () => 'pve',
@ -153,8 +135,14 @@ const baseProps = () =>
pveNodes: () => [],
pbsNodes: () => [],
pmgNodes: () => [],
trueNASSettings: {} as any,
vmwareSettings: {} as any,
trueNASSettings: {
closeDialog: vi.fn(),
connections: () => [],
} as any,
vmwareSettings: {
closeDialog: vi.fn(),
connections: () => [],
} as any,
temperatureMonitoringEnabled: () => false,
triggerDiscoveryScan: vi.fn(),
loadDiscoveredNodes: vi.fn(),
@ -193,10 +181,8 @@ const baseProps = () =>
describe('InfrastructureWorkspace', () => {
beforeEach(() => {
navigateSpy.mockReset();
setSearchParamsSpy.mockReset();
presentationPolicyIsReadOnlyMock.mockReset();
presentationPolicyIsReadOnlyMock.mockReturnValue(false);
probeSpy.mockClear();
routeState.pathname = '/settings/infrastructure';
routeState.search = '';
connectionState.connections = [connectionFixture()];
@ -209,216 +195,80 @@ describe('InfrastructureWorkspace', () => {
const renderWorkspace = (propOverrides: Record<string, unknown> = {}) =>
render(() => (<InfrastructureWorkspace {...{ ...baseProps(), ...propOverrides }} />) as any);
it('renders the inventory table at the base infrastructure route', async () => {
it('renders the source-manager landing and keeps the monitored systems summary below it', async () => {
renderWorkspace();
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'Monitored systems' })).toBeInTheDocument(),
expect(screen.getByRole('heading', { name: 'Connection types' })).toBeInTheDocument(),
);
expect(screen.getByRole('button', { name: 'Add infrastructure' })).toBeInTheDocument();
expect(screen.getByText('zeus')).toBeInTheDocument();
expect(screen.queryByTestId('install-section')).toBeNull();
expect(screen.getByRole('button', { name: 'Detect from address' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add VMware vCenter' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add TrueNAS' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Monitored systems' })).toBeInTheDocument();
expect(screen.getAllByText('zeus').length).toBeGreaterThan(0);
});
it('renders unified connection rows from the ledger', async () => {
connectionState.connections = [
connectionFixture({ id: 'pve:zeus', name: 'zeus', type: 'pve', state: 'active' }),
connectionFixture({
id: 'truenas:tower',
name: 'tower-nas',
type: 'truenas',
state: 'paused',
enabled: false,
}),
];
it('opens the detect dialog through the canonical onboarding route', () => {
renderWorkspace();
await waitFor(() => expect(screen.getByText('zeus')).toBeInTheDocument());
expect(screen.getByText('tower-nas')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByText('Paused')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Detect from address' }));
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure?add=pick', {
scroll: false,
});
});
it('opens the catalog landing when Add infrastructure is clicked', () => {
it('opens a type-specific add route from the source cards', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: 'Add TrueNAS' }));
// 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.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 });
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure?add=truenas', {
scroll: false,
});
});
expect(platformHeading).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
it('renders the route-backed detect dialog when the canonical pick query is present', async () => {
routeState.search = '?add=pick';
renderWorkspace();
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
expect(screen.getByText('Add infrastructure')).toBeInTheDocument();
expect(screen.getByText('Choose how Pulse should connect')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
expect(proxmoxButton).toBeInTheDocument();
expect(trueNASButton).toBeInTheDocument();
expect(vmwareButton).toBeInTheDocument();
expectNodeBefore(vmwareButton, trueNASButton);
expectNodeBefore(trueNASButton, proxmoxButton);
expectNodeBefore(proxmoxButton, agentButton);
expect(navigateSpy).not.toHaveBeenCalled();
expect(setSearchParamsSpy).not.toHaveBeenCalled();
});
it('routes to the agent install slot when Install Pulse Agent tile is clicked', () => {
it('renders the route-backed agent dialog when the canonical agent query is present', async () => {
routeState.search = '?add=agent';
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /Install Pulse Agent/i }));
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
expect(screen.getByText('Add Pulse Agent')).toBeInTheDocument();
expect(screen.getByTestId('install-section')).toBeInTheDocument();
});
it('routes to the TrueNAS credential slot when TrueNAS tile is clicked', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /TrueNAS SCALE/i }));
expect(screen.getByTestId('truenas-section')).toBeInTheDocument();
});
it('routes to the VMware credential slot when VMware tile is clicked', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /VMware vCenter/i }));
expect(screen.getByTestId('vmware-section')).toBeInTheDocument();
});
it('routes through the Proxmox family picker before opening the Proxmox credential slot', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /^Proxmox\b/i }));
expect(screen.getByText('Choose a Proxmox product')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^Proxmox VE/i }));
expect(screen.getByTestId('proxmox-section')).toBeInTheDocument();
});
it('can return to the catalog from a credential slot', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /Install Pulse Agent/i }));
fireEvent.click(screen.getByRole('button', { name: /Back to catalog/i }));
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.queryByTestId('install-section')).toBeNull();
});
it('toggles agent profiles inside the agent install slot', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /Install Pulse Agent/i }));
fireEvent.click(screen.getByRole('button', { name: 'Manage agent profiles' }));
expect(screen.getByTestId('agent-profiles')).toBeInTheDocument();
});
it('exposes Edit / Pause / Remove on each ledger row directly', async () => {
renderWorkspace();
await waitFor(() => expect(screen.getByText('zeus')).toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'View details' })).toBeNull();
});
it('opens the inline edit flow when Edit is clicked on a pve row', async () => {
it('opens the edit dialog directly from an existing source row', async () => {
renderWorkspace({
pveNodes: () => [{ name: 'zeus', host: 'https://10.0.0.1:8006' } as any],
});
await waitFor(() => expect(screen.getByText('zeus')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getAllByText('zeus').length).toBeGreaterThan(0));
fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]);
await waitFor(() => expect(screen.getByTestId('proxmox-section')).toBeInTheDocument());
expect(screen.getByRole('button', { name: /Back to systems/i })).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
expect(screen.getByText('Edit zeus')).toBeInTheDocument();
expect(screen.getByTestId('proxmox-section')).toBeInTheDocument();
});
// `within` was previously used for the detail panel test; retain a smoke reference so the
// import stays live until the broader test harness evolves.
it('isolates row content by cell', async () => {
renderWorkspace();
await waitFor(() => expect(screen.getByText('zeus')).toBeInTheDocument());
const table = screen.getByRole('table');
expect(within(table).getByText('zeus')).toBeInTheDocument();
});
it('redirects legacy install deep links and pre-selects the agent install slot', async () => {
routeState.pathname = '/settings/infrastructure/install';
renderWorkspace();
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure', { replace: true });
expect(setSearchParamsSpy).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByTestId('install-section')).toBeInTheDocument());
});
it('clears the canonical query onboarding route and pre-selects the agent install slot', async () => {
routeState.search = '?add=agent';
renderWorkspace();
expect(setSearchParamsSpy).toHaveBeenCalledWith({ add: null }, { replace: true });
expect(navigateSpy).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByTestId('install-section')).toBeInTheDocument());
});
it('clears the canonical query onboarding route for platform picking and lands on the catalog', async () => {
routeState.search = '?add=pick';
renderWorkspace();
expect(setSearchParamsSpy).toHaveBeenCalledWith({ add: null }, { replace: true });
expect(navigateSpy).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument(),
);
// Catalog landing — the shared agent-led entry is visible again, and no
// credential slot has been entered yet.
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.queryByTestId('install-section')).toBeNull();
});
it('keeps legacy platform-management paths out of add mode', async () => {
routeState.pathname = '/settings/infrastructure/platforms/truenas';
renderWorkspace();
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure', { replace: true });
expect(setSearchParamsSpy).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'Monitored systems' })).toBeInTheDocument(),
);
expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull();
expect(screen.queryByTestId('truenas-section')).toBeNull();
});
it('hides Add infrastructure and every add sub-flow in read-only mode', () => {
presentationPolicyIsReadOnlyMock.mockReturnValue(true);
renderWorkspace();
expect(screen.queryByRole('button', { name: /Add infrastructure/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Install Pulse Agent/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull();
expect(screen.queryByTestId('install-section')).toBeNull();
});
it('clears query onboarding but keeps read-only sessions on the inventory', () => {
it('hides source-manager actions and route-backed dialogs in read-only mode', () => {
presentationPolicyIsReadOnlyMock.mockReturnValue(true);
routeState.search = '?add=agent';
renderWorkspace();
expect(setSearchParamsSpy).toHaveBeenCalledWith({ add: null }, { replace: true });
expect(screen.queryByRole('button', { name: 'Detect from address' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Add VMware vCenter' })).toBeNull();
expect(screen.queryByRole('dialog')).toBeNull();
expect(screen.queryByTestId('install-section')).toBeNull();
expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull();
expect(screen.getByRole('heading', { name: 'Monitored systems' })).toBeInTheDocument();
});
});

View file

@ -6,6 +6,7 @@ import settingsHeaderMetaSource from '../settingsHeaderMeta.ts?raw';
import settingsNavigationHookSource from '../useSettingsNavigation.ts?raw';
import settingsPanelRegistryContextSource from '../settingsPanelRegistryContext.tsx?raw';
import infrastructureWorkspaceSource from '../InfrastructureWorkspace.tsx?raw';
import infrastructureSourceManagerSource from '../InfrastructureSourceManager.tsx?raw';
import infrastructureWorkspaceModelSource from '../infrastructureWorkspaceModel.ts?raw';
import connectionsTableSource from '../ConnectionsTable.tsx?raw';
import connectionEditorSource from '../ConnectionEditor/ConnectionEditor.tsx?raw';
@ -38,7 +39,7 @@ describe('settings architecture guardrails', () => {
it('keeps infrastructure onboarding route-backed under the shared settings shell', () => {
expect(settingsHeaderMetaSource).toContain("'infrastructure-systems': {");
expect(settingsHeaderMetaSource).toContain(
'Review monitored systems and add new infrastructure to Pulse',
'Manage platform connections, Pulse Agent installs, and monitored systems.',
);
expect(settingsHeaderMetaSource).toContain("'organization-access': {");
expect(settingsHeaderMetaSource).toContain(
@ -87,29 +88,36 @@ describe('settings architecture guardrails', () => {
expect(settingsPanelRegistryContextSource).toContain('getOrganizationSharingPanelProps');
});
it('keeps the infrastructure add flow inline on ConnectionEditor instead of retired overlays', () => {
it('keeps infrastructure on a source-manager landing with route-backed dialogs', () => {
expect(infrastructureWorkspaceSource).toContain(
"import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';",
);
expect(infrastructureWorkspaceSource).toContain(
"import { InfrastructureSourceManager } from './InfrastructureSourceManager';",
);
expect(infrastructureWorkspaceSource).toContain("import { Dialog } from '@/components/shared/Dialog';");
expect(infrastructureWorkspaceSource).toContain('NodeCredentialSlot');
expect(infrastructureWorkspaceSource).toContain('TrueNASCredentialSlot');
expect(infrastructureWorkspaceSource).toContain('VMwareCredentialSlot');
expect(infrastructureWorkspaceSource).toContain(
'navigate(buildInfrastructureWorkspacePath(), { replace: true });',
'navigate(buildInfrastructureOnboardingPath(type), { scroll: false });',
);
expect(infrastructureWorkspaceSource).toContain(
'const [, setSearchParams] = useSearchParams();',
'<InfrastructureSourceManager',
);
expect(infrastructureWorkspaceSource).toContain(
'setSearchParams({ [INFRASTRUCTURE_ADD_QUERY_PARAM]: null }, { replace: true });',
'<ConnectionsTable rows={rows} />',
);
expect(infrastructureWorkspaceSource).toContain("showSlotHeader={false}");
expect(infrastructureWorkspaceSource).toContain('trackInitialCatalogSelection={activeAddType() !== \'agent\'}');
expect(infrastructureWorkspaceSource).not.toContain('InfrastructureOperationsController');
expect(infrastructureWorkspaceSource).not.toContain('PlatformConnectionsWorkspace');
expect(infrastructureWorkspaceSource).not.toContain('NodeModal');
expect(infrastructureWorkspaceSource).not.toContain('layout="drawer-right"');
expect(infrastructureSourceManagerSource).toContain('Detect from address');
expect(infrastructureSourceManagerSource).toContain('Connection types');
expect(infrastructureSourceManagerSource).toContain('{groupRows().length} configured');
});
it('keeps the platform-first add landing and inline node credentials on the shared editor model', () => {
it('keeps the platform-first add dialog and inline credential bodies on the shared editor model', () => {
expect(connectionEditorSource).toContain(
"import { AddressProbeStep } from './AddressProbeStep';",
);
@ -126,7 +134,7 @@ describe('settings architecture guardrails', () => {
expect(connectionEditorSource).toContain('Connect a supported platform');
expect(connectionEditorSource).toContain('Choose a {family.label} product');
expect(connectionEditorSource).toContain('Back to platforms');
expect(connectionEditorSource).toContain('Current admission path');
expect(connectionEditorSource).toContain('Available now');
expect(connectionEditorSource).toContain('other supported services');
expect(connectionEditorSource).toContain('connects them when available');
expect(connectionEditorSource).not.toContain('On supported Proxmox hosts');
@ -159,6 +167,9 @@ describe('settings architecture guardrails', () => {
expect(infrastructureOnboardingPresentationSource).toContain(
'getInfrastructureSupportSummaryBadges',
);
expect(infrastructureOnboardingPresentationSource).toContain(
'VMware vCenter is also available now.',
);
expect(nodeCredentialSlotSource).toContain('useNodeModalState(modalProps)');
expect(nodeCredentialSlotSource).toContain('<NodeModalBasicInfoSection');

View file

@ -9,7 +9,7 @@ import {
export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = {
'infrastructure-systems': {
title: 'Infrastructure',
description: 'Review monitored systems and add new infrastructure to Pulse.',
description: 'Manage platform connections, Pulse Agent installs, and monitored systems.',
},
'system-general': {
title: 'General',

View file

@ -14,7 +14,7 @@ export const CONNECTION_TYPE_LABELS: Record<ConnectionType, string> = {
pbs: 'Proxmox Backup Server',
pmg: 'Proxmox Mail Gateway',
vmware: 'VMware vCenter',
truenas: 'TrueNAS',
truenas: 'TrueNAS SCALE',
agent: 'Pulse Unified Agent',
docker: 'Docker',
kubernetes: 'Kubernetes',

View file

@ -49,8 +49,6 @@ describe('infrastructureOnboardingPresentation', () => {
});
expect(getInfrastructureEmptyStateDetail()).toContain('Supported today: TrueNAS SCALE');
expect(getInfrastructureEmptyStateDetail()).toContain(
'VMware vCenter onboarding is also available on the current admission path.',
);
expect(getInfrastructureEmptyStateDetail()).toContain('VMware vCenter is also available now.');
});
});

View file

@ -187,4 +187,4 @@ 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.';
'Supported today: TrueNAS SCALE, Proxmox VE, Proxmox Backup Server, Proxmox Mail Gateway, Pulse Agent hosts, Docker, and Kubernetes. VMware vCenter is also available now.';

View file

@ -55,9 +55,10 @@ End-to-end Playwright tests that validate critical user flows against a running
- Mocks a blocked Patrol runtime with stale healthy summary payloads
- Proves the real `/ai` route shows Patrol as paused and suppresses stale healthy summary copy
- `tests/68-infrastructure-onboarding.spec.ts` — infrastructure onboarding browser proof:
- Proves the add-infrastructure surface renders the decision-led API-vs-agent onboarding flow in the shared settings shell
- Verifies the onboarding funnel emits `opened`, API catalog handoff, and no-match-to-agent fallback metrics on the real browser runtime
- Verifies the mobile add-infrastructure flow fits the viewport without horizontal overflow
- Proves `/settings/infrastructure` behaves as the persistent source-manager landing in the shared settings shell
- Verifies direct type-add and detect-from-address both open managed dialogs while the source-manager surface remains visible underneath
- Verifies the onboarding funnel emits catalog-driven API handoff and no-match-to-agent fallback metrics on the real browser runtime
- Verifies the mobile infrastructure manager fits the viewport without horizontal overflow
- `tests/69-diagnostics-onboarding.spec.ts` — diagnostics onboarding analytics browser proof:
- Proves the Diagnostics & Health page renders the infrastructure onboarding analytics card after a real diagnostics run
- Verifies the shared diagnostics surface shows onboarding path/platform attribution alongside the existing commercial funnel

View file

@ -165,52 +165,61 @@ async function auditHorizontalOverflow(page: Page): Promise<OverflowAudit> {
test.describe('Infrastructure onboarding', () => {
test.setTimeout(180_000);
test('desktop add flow renders the onboarding decision surface and records an open event', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only onboarding shell coverage');
const metricEvents = await recordUpgradeMetricEvents(page);
await prepareOnboardingPage(page);
await page.goto('/settings/infrastructure?add=pick', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await expect(page.getByText('Add infrastructure', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Choose how Pulse should connect', { exact: true })).toBeVisible();
await expect(page.getByText('Connect a supported platform', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Install Pulse Agent', { exact: true }).first()).toBeVisible();
await expect(page.getByText('What happens next', { exact: true })).toBeVisible();
await expect
.poll(() => countUpgradeEvents(metricEvents, 'infrastructure_onboarding_opened'))
.toBe(1);
});
test('desktop catalog selection records the API onboarding path and credential handoff', async ({
test('desktop manager landing keeps connection types visible without opening onboarding state', async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop-only onboarding catalog instrumentation coverage',
'Desktop-only infrastructure manager coverage',
);
const metricEvents = await recordUpgradeMetricEvents(page);
await prepareOnboardingPage(page);
await page.goto('/settings/infrastructure?add=pick', { waitUntil: 'domcontentloaded' });
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
const catalog = page.locator('section').filter({
has: page.getByText('Supported platform catalog', { exact: true }),
});
await catalog.getByRole('button', { name: /TrueNAS SCALE/i }).click();
await expect(page.getByRole('button', { name: /Back to catalog/i })).toBeVisible();
await expect(page.getByText('TrueNAS SCALE', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Connection types', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Detect from address', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Add VMware vCenter', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Add TrueNAS', exact: true })).toBeVisible();
await expect(page.getByText('Monitored systems', { exact: true })).toBeVisible();
await expect
.poll(() => countUpgradeEvents(metricEvents, 'infrastructure_onboarding_path_selected', 'api'))
.poll(() => countUpgradeEvents(metricEvents, 'infrastructure_onboarding_opened'))
.toBe(0);
});
test('desktop direct source add opens a manager-backed dialog and records catalog-driven onboarding', async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop-only manager add-path instrumentation coverage',
);
const metricEvents = await recordUpgradeMetricEvents(page);
await prepareOnboardingPage(page);
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await page.getByRole('button', { name: 'Add TrueNAS', exact: true }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=truenas$/, { timeout: 15_000 });
await expect(page.getByText('Connection types', { exact: true })).toBeVisible();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Add TrueNAS SCALE', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Close add infrastructure dialog', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /Back to catalog/i })).toHaveCount(0);
await expect
.poll(() => countUpgradeEvents(metricEvents, 'infrastructure_onboarding_opened'))
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_path_selected', 'api'),
)
.toBe(1);
await expect
.poll(() =>
@ -252,8 +261,10 @@ test.describe('Infrastructure onboarding', () => {
});
});
await page.goto('/settings/infrastructure?add=pick', { waitUntil: 'domcontentloaded' });
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await page.getByRole('button', { name: 'Detect from address', exact: true }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=pick$/, { timeout: 15_000 });
await page.getByLabel('Address').fill('baremetal.lab');
await page.getByRole('button', { name: 'Probe address', exact: true }).click();
@ -291,15 +302,15 @@ test.describe('Infrastructure onboarding', () => {
}, testInfo) => {
test.skip(
!testInfo.project.name.startsWith('mobile-'),
'Mobile-only onboarding overflow coverage',
'Mobile-only infrastructure manager overflow coverage',
);
await prepareOnboardingPage(page);
await page.goto('/settings/infrastructure?add=pick', { waitUntil: 'domcontentloaded' });
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await expect(page.getByText('Add infrastructure', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Choose how Pulse should connect', { exact: true })).toBeVisible();
await expect(page.getByText('Connection types', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Add TrueNAS', exact: true })).toBeVisible();
await scrollToBottom(page);
const audit = await auditHorizontalOverflow(page);