mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Adopt source-manager infrastructure settings flow
This commit is contained in:
parent
f96abc5ee0
commit
f7d9b8252e
18 changed files with 1038 additions and 599 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue