mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-08 18:21:55 +00:00
Implement VMware vCenter connections slice
This commit is contained in:
parent
ca49305cb5
commit
9b19cb4446
32 changed files with 4060 additions and 123 deletions
|
|
@ -89,6 +89,8 @@ management, and fleet control surfaces.
|
|||
65. `frontend-modern/src/components/Settings/platformConnectionsModel.ts`
|
||||
66. `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`
|
||||
67. `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`
|
||||
68. `frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx`
|
||||
69. `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
|
|
@ -125,7 +127,7 @@ management, and fleet control surfaces.
|
|||
4. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice.
|
||||
4. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance.
|
||||
5. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
|
||||
6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the API-backed platform connections workspace shell, route model, reporting summary owner, shared install/inventory/dialog section owners, the split infrastructure install/reporting state owners, the split direct-node/discovery infrastructure settings owners plus their shared model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, 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/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`, `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ProxmoxDirectWorkspace.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`, `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`, `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`, `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/useInfrastructureReportingState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/useProxmoxDirectWorkspaceState.ts`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `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`.
|
||||
6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the API-backed platform connections workspace shell, route model, reporting summary owner, shared install/inventory/dialog section owners, the split infrastructure install/reporting state owners, the split direct-node/discovery infrastructure settings owners plus their shared model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, 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/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`, `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ProxmoxDirectWorkspace.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`, `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`, `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`, `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/useInfrastructureReportingState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/useProxmoxDirectWorkspaceState.ts`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `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`.
|
||||
7. Preserve canonical token-lifecycle reads in shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows do not revoke a displayed relay pairing token after `lastUsedAt` proves that an already paired device is actively depending on that credential.
|
||||
8. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows reuse the canonical mobile token route instead of reintroducing wildcard or browser-authored runtime token bundles.
|
||||
9. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so lifecycle-adjacent setup and install flows do not widen the mobile device credential back into general AI chat/execute scope ownership.
|
||||
|
|
@ -531,6 +533,13 @@ may show one VMware connection's poll health, last error classification, and
|
|||
observed contribution summary, but it must not force the operator to manage
|
||||
separate Automation API versus VI JSON sessions or understand multi-client
|
||||
runtime wiring just to use the shared platform-connections path.
|
||||
That same lifecycle-owned settings slice now also owns the shared VMware
|
||||
summary and handoff framing. `InfrastructurePlatformConnectionsSummaryCard.tsx`,
|
||||
`InfrastructureReportingPanel.tsx`, `useInfrastructureSettingsState.ts`, and
|
||||
`useSettingsInfrastructurePanelProps.ts` must surface VMware availability and
|
||||
connection counts from the same shared infrastructure settings state that owns
|
||||
the VMware panel itself, rather than letting reporting cards or adjacent setup
|
||||
surfaces grow a second VMware availability fetch or a VMware-only handoff path.
|
||||
That same infrastructure workspace boundary now also owns the first-run
|
||||
handoff copy for new operators. `InfrastructureWorkspace.tsx` must tell a new
|
||||
Pulse user to start with `Install on a host` to add the first monitored
|
||||
|
|
|
|||
|
|
@ -700,6 +700,21 @@ summary surface. Phase 1 must also keep the negative space explicit: no public
|
|||
`/api/vmware/events`, `/api/vmware/tasks`, or VMware control routes should be
|
||||
introduced while inventory, alerts, history, and Assistant reads still route
|
||||
through the shared canonical Pulse surfaces.
|
||||
That same `/api/vmware/connections` family now also owns the current phase-1
|
||||
implementation contract under `internal/api/vmware_handlers.go`,
|
||||
`internal/api/router.go`, `internal/api/router_routes_registration.go`, and
|
||||
`frontend-modern/src/api/vmware.ts`. The list response must carry one redacted
|
||||
stored connection shape plus canonical last-test status and observed
|
||||
contribution summary (`hosts`, `vms`, `datastores`, `viRelease`) so the shared
|
||||
settings workspace can render VMware status without another provider-local
|
||||
inventory route. `POST /api/vmware/connections/test` must stay the draft test
|
||||
surface, while `POST /api/vmware/connections/{id}/test` remains the saved
|
||||
connection retest surface: a row-level saved retest with no payload should
|
||||
refresh the stored runtime summary, but an edit-form test overlay must preserve
|
||||
the stored summary until a real save succeeds. The explicit disabled path also
|
||||
stays on this boundary: `404 vmware_disabled` means the operator or runtime has
|
||||
opted out of the default-on VMware candidate, not that the platform requires a
|
||||
different onboarding contract.
|
||||
That same backend API boundary now also owns the negative space around
|
||||
assistant control. Wiring native TrueNAS app actions into
|
||||
`internal/api/router.go`, `internal/api/ai_handler.go`, or adjacent backend
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"contract_file": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
|
||||
"status_file": "docs/release-control/v6/internal/status.json",
|
||||
"registry_file": "docs/release-control/v6/internal/subsystems/registry.json",
|
||||
"dependency_subsystem_ids": []
|
||||
"dependency_subsystem_ids": ["agent-lifecycle"]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -78,6 +78,7 @@ work extends shared components instead of creating new local variants.
|
|||
47. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
|
||||
48. `frontend-modern/src/features/`
|
||||
49. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
|
||||
50. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
|
||||
50. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
|
||||
51. `frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx`
|
||||
52. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
|
||||
|
|
@ -141,6 +142,7 @@ work extends shared components instead of creating new local variants.
|
|||
instead of introducing page-local framing
|
||||
3. Add feature-specific presentation only when no shared primitive should own it
|
||||
4. Add guardrail tests when a new shared pattern is introduced
|
||||
5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
|
@ -257,16 +259,18 @@ work extends shared components instead of creating new local variants.
|
|||
`GeneralSettingsPanel.tsx` must state those facts plainly instead of
|
||||
reverting to a stronger but inaccurate shorthand.
|
||||
16. Keep infrastructure settings-shell API alternatives on the shared shell
|
||||
contract. `InfrastructureWorkspace.tsx`, `settingsHeaderMeta.ts`,
|
||||
`settingsNavigationModel.ts`, and shared empty-state/setup guidance must
|
||||
contract. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`,
|
||||
`frontend-modern/src/components/Settings/settingsHeaderMeta.ts`,
|
||||
`frontend-modern/src/components/Settings/settingsNavigationModel.ts`, and
|
||||
shared empty-state/setup guidance must
|
||||
present `Platform connections` as the canonical API-backed alternative for
|
||||
Proxmox, TrueNAS, and future provider integrations instead of reviving
|
||||
top-level `Direct Proxmox` wording or shell-local provider routes.
|
||||
17. Keep the infrastructure settings platform-connections summary and provider
|
||||
workspaces on one shared state source. `useInfrastructureSettingsState.ts`,
|
||||
`useSettingsInfrastructurePanelProps.ts`,
|
||||
`InfrastructurePlatformConnectionsSummaryCard.tsx`,
|
||||
`PlatformConnectionsWorkspace.tsx`, and `TrueNASSettingsPanel.tsx` must
|
||||
workspaces on one shared state source. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`,
|
||||
`frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`,
|
||||
`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`,
|
||||
`frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, and `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx` must
|
||||
derive TrueNAS connection counts and availability from the shared
|
||||
infrastructure settings state instead of letting the reporting summary and
|
||||
the provider-specific panel issue separate connection fetches.
|
||||
|
|
@ -1594,21 +1598,28 @@ reference cases, and
|
|||
locks that direct-root contract so single-surface pages do not quietly regain
|
||||
redundant outer spacing chrome.
|
||||
The same shared settings-shell boundary now also owns the API-backed
|
||||
alternative path inside Infrastructure Operations. `InfrastructureWorkspace.tsx`,
|
||||
`InfrastructurePlatformConnectionsSummaryCard.tsx`, `settingsHeaderMeta.ts`,
|
||||
`settingsNavigationModel.ts`, `dashboardEmptyStatePresentation.ts`,
|
||||
`infrastructureEmptyStatePresentation.ts`, and adjacent setup guidance must
|
||||
alternative path inside Infrastructure Operations. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`,
|
||||
`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`,
|
||||
`frontend-modern/src/components/Settings/settingsNavigationModel.ts`, `frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`,
|
||||
`frontend-modern/src/utils/infrastructureEmptyStatePresentation.ts`, and adjacent setup guidance must
|
||||
treat `Platform connections` as the canonical API-backed alternative for
|
||||
Proxmox, TrueNAS, and future provider integrations instead of reviving
|
||||
top-level `Direct Proxmox` wording or shell-local provider routes.
|
||||
That same settings-shell contract also owns the shared platform-connections
|
||||
summary state. `useInfrastructureSettingsState.ts`,
|
||||
`useSettingsInfrastructurePanelProps.ts`,
|
||||
`InfrastructurePlatformConnectionsSummaryCard.tsx`,
|
||||
`PlatformConnectionsWorkspace.tsx`, and `TrueNASSettingsPanel.tsx` must derive
|
||||
Proxmox/PBS/PMG/TrueNAS counts and availability from one shared infrastructure
|
||||
settings state source instead of letting the reporting summary and the
|
||||
provider-specific panel fetch the same TrueNAS connection state separately.
|
||||
summary state. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`,
|
||||
`frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`,
|
||||
`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`,
|
||||
`frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`, and
|
||||
`frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx` must derive Proxmox/PBS/PMG/TrueNAS/VMware counts
|
||||
and availability from one shared infrastructure settings state source instead
|
||||
of letting the reporting summary and the provider-specific panels fetch the
|
||||
same connection state separately.
|
||||
That same shared settings-shell boundary also owns provider parity inside the
|
||||
platform workspace. Adding VMware to the shared `Platform connections`
|
||||
subtabs may extend the same card, empty-state, dialog, and summary-shell
|
||||
patterns used by TrueNAS, but it must not introduce a VMware-only outer page
|
||||
shell, alternate settings route hierarchy, or another summary vocabulary for
|
||||
connection health and contribution counts.
|
||||
That same shared filter-presentation boundary also owns infrastructure
|
||||
route-filter continuity. `frontend-modern/src/features/infrastructure/`
|
||||
must keep a route-owned canonical source option such as `truenas` visible in
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ querying, and the operator-facing storage health presentation layer.
|
|||
25. Keep provider-backed poll cadence and settings-runtime health on the adjacent platform-connections contract. When shared `internal/api/` and poller wiring expose TrueNAS last-sync status, failure summaries, discovered contribution counts, manual saved-test status refresh, or platform handoff links in settings, storage and recovery may consume the resulting datasets, apps, disks, and recovery artifacts but must not redefine those settings-runtime health semantics or connection-level handoffs in storage/recovery-local transport or page flows.
|
||||
26. Keep cross-surface recovery handoffs on the shared route-helper contract. When infrastructure or other unified-resource consumers expose TrueNAS recovery links, they must reuse the canonical `frontend-modern/src/routing/resourceLinks.ts` recovery builder with owned `platform` and `node` queries instead of inventing drawer-local recovery URLs or treating PBS services as the only infrastructure-to-recovery path.
|
||||
27. Keep alert-side recovery drill-ins on that same shared route-helper contract. When alert investigation surfaces such as resource-incident panels expose recovery follow-up links for TrueNAS or future API-backed platforms, they must route through the canonical `frontend-modern/src/routing/resourceLinks.ts` recovery builder instead of freezing alert-local recovery URLs or introducing another provider-shaped recovery handoff vocabulary.
|
||||
28. Keep VMware onboarding runtime and recovery semantics separate on that same adjacent platform-connections contract. When `internal/api/router.go`, `internal/api/router_routes_registration.go`, or `internal/api/vmware_handlers.go` evolve VMware connection CRUD, saved-test refresh, or observed datastore/VM snapshot visibility, storage and recovery may consume the resulting shared context but must not treat those onboarding/runtime payloads as canonical recovery artifacts, restore capability, or recovery-local control transport.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
|
@ -1635,3 +1636,11 @@ Storage and recovery must not treat vSphere snapshots, changed-disk/block
|
|||
visibility, or datastore presence as canonical Pulse recovery artifacts,
|
||||
restore capability, or recovery-backed Assistant control until a later
|
||||
governed slice adds those contracts explicitly.
|
||||
That same storage/recovery boundary also keeps the onboarding runtime separate
|
||||
from recovery semantics. `internal/api/router.go`,
|
||||
`internal/api/router_routes_registration.go`, and
|
||||
`internal/api/vmware_handlers.go` may expose VMware connection CRUD, saved-test
|
||||
health refresh, and observed datastore/VM snapshot visibility on the shared
|
||||
platform-connections surface, but storage and recovery must treat that data as
|
||||
setup/runtime context only, not as proof that VMware has joined the canonical
|
||||
recovery artifact or restore plane.
|
||||
|
|
|
|||
186
frontend-modern/src/api/__tests__/vmware.test.ts
Normal file
186
frontend-modern/src/api/__tests__/vmware.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import { VMwareAPI, isRedactedVMwareSecret } from '@/api/vmware';
|
||||
|
||||
vi.mock('@/utils/apiClient', () => ({
|
||||
apiFetchJSON: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('VMwareAPI', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('normalizes listed connections from the API contract', async () => {
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce([
|
||||
{
|
||||
id: ' conn-1 ',
|
||||
name: ' lab-vcenter ',
|
||||
host: ' vcsa.lab.local ',
|
||||
port: 443,
|
||||
username: ' administrator@vsphere.local ',
|
||||
password: ' ******** ',
|
||||
insecureSkipVerify: true,
|
||||
enabled: true,
|
||||
test: {
|
||||
lastAttemptAt: ' 2026-03-30T12:00:00Z ',
|
||||
lastSuccessAt: ' 2026-03-30T12:00:01Z ',
|
||||
},
|
||||
observed: {
|
||||
collectedAt: ' 2026-03-30T12:00:02Z ',
|
||||
hosts: 3,
|
||||
vms: 42,
|
||||
datastores: 6,
|
||||
viRelease: ' 8.0.3 ',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(VMwareAPI.listConnections()).resolves.toEqual([
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: true,
|
||||
enabled: true,
|
||||
test: {
|
||||
lastAttemptAt: '2026-03-30T12:00:00Z',
|
||||
lastSuccessAt: '2026-03-30T12:00:01Z',
|
||||
lastError: undefined,
|
||||
},
|
||||
observed: {
|
||||
collectedAt: '2026-03-30T12:00:02Z',
|
||||
hosts: 3,
|
||||
vms: 42,
|
||||
datastores: 6,
|
||||
viRelease: '8.0.3',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates, updates, deletes, and tests connections through canonical routes', async () => {
|
||||
vi.mocked(apiFetchJSON)
|
||||
.mockResolvedValueOnce({
|
||||
id: 'conn-1',
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'conn-1',
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
insecureSkipVerify: true,
|
||||
enabled: false,
|
||||
})
|
||||
.mockResolvedValueOnce({ success: true, id: 'conn-1' })
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
.mockResolvedValueOnce({ success: true });
|
||||
|
||||
await VMwareAPI.createConnection({
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: 'secret',
|
||||
enabled: true,
|
||||
});
|
||||
await VMwareAPI.updateConnection('conn/1', {
|
||||
host: 'vcsa.lab.local',
|
||||
port: 8443,
|
||||
username: 'operator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: true,
|
||||
enabled: false,
|
||||
});
|
||||
await VMwareAPI.deleteConnection('conn/1');
|
||||
await expect(
|
||||
VMwareAPI.testConnection({
|
||||
host: 'vcsa.lab.local',
|
||||
username: 'administrator@vsphere.local',
|
||||
password: 'secret',
|
||||
}),
|
||||
).resolves.toEqual({ success: true });
|
||||
await expect(
|
||||
VMwareAPI.testSavedConnection('conn/1', {
|
||||
host: 'vcsa.lab.local',
|
||||
username: 'operator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: true,
|
||||
}),
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(apiFetchJSON).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/vmware/connections',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: 'secret',
|
||||
enabled: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(apiFetchJSON).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/vmware/connections/conn%2F1',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
host: 'vcsa.lab.local',
|
||||
port: 8443,
|
||||
username: 'operator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: true,
|
||||
enabled: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(apiFetchJSON).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/vmware/connections/conn%2F1',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
expect(apiFetchJSON).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'/api/vmware/connections/test',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
host: 'vcsa.lab.local',
|
||||
username: 'administrator@vsphere.local',
|
||||
password: 'secret',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(apiFetchJSON).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
'/api/vmware/connections/conn%2F1/test',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
host: 'vcsa.lab.local',
|
||||
username: 'operator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('recognizes the masked-secret placeholder used for update preservation', () => {
|
||||
expect(isRedactedVMwareSecret('********')).toBe(true);
|
||||
expect(isRedactedVMwareSecret(' ******** ')).toBe(true);
|
||||
expect(isRedactedVMwareSecret('secret')).toBe(false);
|
||||
expect(isRedactedVMwareSecret(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
194
frontend-modern/src/api/vmware.ts
Normal file
194
frontend-modern/src/api/vmware.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import {
|
||||
arrayOrUndefined,
|
||||
finiteNumberOrUndefined,
|
||||
optionalTrimmedString,
|
||||
strictBoolean,
|
||||
trimmedString,
|
||||
} from './responseUtils';
|
||||
|
||||
const VMWARE_CONNECTIONS_PATH = '/api/vmware/connections';
|
||||
|
||||
const REDACTED_SECRET = '********';
|
||||
|
||||
type RawVMwareConnection = Partial<VMwareConnection>;
|
||||
type RawVMwareConnectionTestError = Partial<VMwareConnectionTestError>;
|
||||
type RawVMwareConnectionTest = Partial<VMwareConnectionTestStatus>;
|
||||
type RawVMwareConnectionObservedSummary = Partial<VMwareConnectionObservedSummary>;
|
||||
|
||||
export interface VMwareConnectionTestError {
|
||||
at?: string;
|
||||
message?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface VMwareConnectionTestStatus {
|
||||
lastAttemptAt?: string;
|
||||
lastSuccessAt?: string;
|
||||
lastError?: VMwareConnectionTestError;
|
||||
}
|
||||
|
||||
export interface VMwareConnectionObservedSummary {
|
||||
collectedAt?: string;
|
||||
hosts: number;
|
||||
vms: number;
|
||||
datastores: number;
|
||||
viRelease?: string;
|
||||
}
|
||||
|
||||
export interface VMwareConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
insecureSkipVerify: boolean;
|
||||
enabled: boolean;
|
||||
test?: VMwareConnectionTestStatus;
|
||||
observed?: VMwareConnectionObservedSummary;
|
||||
}
|
||||
|
||||
export interface VMwareConnectionInput {
|
||||
name?: string;
|
||||
host: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
insecureSkipVerify?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface VMwareConnectionTestResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const normalizeVMwareConnectionTestError = (
|
||||
error: RawVMwareConnectionTestError | undefined,
|
||||
): VMwareConnectionTestError | undefined => {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
return {
|
||||
at: optionalTrimmedString(error.at),
|
||||
message: optionalTrimmedString(error.message),
|
||||
category: optionalTrimmedString(error.category),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeVMwareConnectionTest = (
|
||||
test: RawVMwareConnectionTest | undefined,
|
||||
): VMwareConnectionTestStatus | undefined => {
|
||||
if (!test || typeof test !== 'object') return undefined;
|
||||
return {
|
||||
lastAttemptAt: optionalTrimmedString(test.lastAttemptAt),
|
||||
lastSuccessAt: optionalTrimmedString(test.lastSuccessAt),
|
||||
lastError: normalizeVMwareConnectionTestError(test.lastError),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeVMwareConnectionObservedSummary = (
|
||||
observed: RawVMwareConnectionObservedSummary | undefined,
|
||||
): VMwareConnectionObservedSummary | undefined => {
|
||||
if (!observed || typeof observed !== 'object') return undefined;
|
||||
return {
|
||||
collectedAt: optionalTrimmedString(observed.collectedAt),
|
||||
hosts: finiteNumberOrUndefined(observed.hosts) ?? 0,
|
||||
vms: finiteNumberOrUndefined(observed.vms) ?? 0,
|
||||
datastores: finiteNumberOrUndefined(observed.datastores) ?? 0,
|
||||
viRelease: optionalTrimmedString(observed.viRelease),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeVMwareConnection = (connection: RawVMwareConnection): VMwareConnection => ({
|
||||
id: trimmedString(connection.id),
|
||||
name: optionalTrimmedString(connection.name) ?? '',
|
||||
host: trimmedString(connection.host),
|
||||
port: finiteNumberOrUndefined(connection.port),
|
||||
username: optionalTrimmedString(connection.username),
|
||||
password: optionalTrimmedString(connection.password),
|
||||
insecureSkipVerify: strictBoolean(connection.insecureSkipVerify),
|
||||
enabled: strictBoolean(connection.enabled),
|
||||
test: normalizeVMwareConnectionTest(connection.test),
|
||||
observed: normalizeVMwareConnectionObservedSummary(connection.observed),
|
||||
});
|
||||
|
||||
const serializeVMwareConnectionInput = (input: VMwareConnectionInput) => ({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
host: input.host,
|
||||
...(input.port !== undefined ? { port: input.port } : {}),
|
||||
...(input.username !== undefined ? { username: input.username } : {}),
|
||||
...(input.password !== undefined ? { password: input.password } : {}),
|
||||
...(input.insecureSkipVerify !== undefined
|
||||
? { insecureSkipVerify: input.insecureSkipVerify }
|
||||
: {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
});
|
||||
|
||||
export const isRedactedVMwareSecret = (value: string | null | undefined) =>
|
||||
(value || '').trim() === REDACTED_SECRET;
|
||||
|
||||
export class VMwareAPI {
|
||||
static async listConnections(): Promise<VMwareConnection[]> {
|
||||
const response = await apiFetchJSON<RawVMwareConnection[]>(VMWARE_CONNECTIONS_PATH);
|
||||
const list = arrayOrUndefined<RawVMwareConnection>(response) ?? [];
|
||||
return list.map(normalizeVMwareConnection);
|
||||
}
|
||||
|
||||
static async createConnection(input: VMwareConnectionInput): Promise<VMwareConnection> {
|
||||
const response = await apiFetchJSON<RawVMwareConnection>(VMWARE_CONNECTIONS_PATH, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(serializeVMwareConnectionInput(input)),
|
||||
});
|
||||
return normalizeVMwareConnection(response);
|
||||
}
|
||||
|
||||
static async updateConnection(
|
||||
id: string,
|
||||
input: VMwareConnectionInput,
|
||||
): Promise<VMwareConnection> {
|
||||
const response = await apiFetchJSON<RawVMwareConnection>(
|
||||
`${VMWARE_CONNECTIONS_PATH}/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(serializeVMwareConnectionInput(input)),
|
||||
},
|
||||
);
|
||||
return normalizeVMwareConnection(response);
|
||||
}
|
||||
|
||||
static async deleteConnection(id: string): Promise<{ success: boolean; id: string }> {
|
||||
return apiFetchJSON(`${VMWARE_CONNECTIONS_PATH}/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
static async testConnection(input: VMwareConnectionInput): Promise<VMwareConnectionTestResult> {
|
||||
const response = await apiFetchJSON<Partial<VMwareConnectionTestResult>>(
|
||||
`${VMWARE_CONNECTIONS_PATH}/test`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(serializeVMwareConnectionInput(input)),
|
||||
},
|
||||
);
|
||||
return {
|
||||
success: strictBoolean(response.success),
|
||||
};
|
||||
}
|
||||
|
||||
static async testSavedConnection(
|
||||
id: string,
|
||||
input?: VMwareConnectionInput,
|
||||
): Promise<VMwareConnectionTestResult> {
|
||||
const response = await apiFetchJSON<Partial<VMwareConnectionTestResult>>(
|
||||
`${VMWARE_CONNECTIONS_PATH}/${encodeURIComponent(id)}/test`,
|
||||
{
|
||||
method: 'POST',
|
||||
...(input !== undefined
|
||||
? { body: JSON.stringify(serializeVMwareConnectionInput(input)) }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
return {
|
||||
success: strictBoolean(response.success),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ interface InfrastructurePlatformConnectionsSummaryCardProps {
|
|||
pmgCount: number;
|
||||
truenasCount: number;
|
||||
truenasAvailable: boolean;
|
||||
vmwareCount: number;
|
||||
vmwareAvailable: boolean;
|
||||
onManagePlatformConnections: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -20,8 +22,8 @@ export const InfrastructurePlatformConnectionsSummaryCard: Component<
|
|||
<div>
|
||||
<h3 class="text-base font-semibold text-base-content">Platform connections</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Manage the API-backed platforms Pulse polls directly. Proxmox VE, PBS, PMG, and
|
||||
TrueNAS all live in the same shared platform-connections workspace.
|
||||
Manage the API-backed platforms Pulse polls directly. Proxmox VE, PBS, PMG, TrueNAS,
|
||||
and VMware all live in the same shared platform-connections workspace.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -33,7 +35,7 @@ export const InfrastructurePlatformConnectionsSummaryCard: Component<
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div
|
||||
class="rounded-lg border border-border bg-surface-alt px-4 py-3"
|
||||
data-testid="platform-connections-pve"
|
||||
|
|
@ -71,6 +73,22 @@ export const InfrastructurePlatformConnectionsSummaryCard: Component<
|
|||
: 'Explicitly disabled on this Pulse server.'}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-border bg-surface-alt px-4 py-3"
|
||||
data-testid="platform-connections-vmware"
|
||||
>
|
||||
<div class="text-sm font-medium text-base-content">VMware</div>
|
||||
<div
|
||||
class={`mt-1 ${props.vmwareAvailable ? 'text-xl font-semibold text-base-content' : 'text-sm font-medium text-muted'}`}
|
||||
>
|
||||
{props.vmwareAvailable ? props.vmwareCount : 'Disabled'}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{props.vmwareAvailable
|
||||
? 'vCenter platform connections'
|
||||
: 'Explicitly disabled on this Pulse server.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export const InfrastructureReportingPanel: Component<InfrastructureReportingPane
|
|||
pmgCount={props.platformConnectionsSummary().pmgCount}
|
||||
truenasCount={props.platformConnectionsSummary().truenasCount}
|
||||
truenasAvailable={props.platformConnectionsSummary().truenasAvailable}
|
||||
vmwareCount={props.platformConnectionsSummary().vmwareCount}
|
||||
vmwareAvailable={props.platformConnectionsSummary().vmwareAvailable}
|
||||
onManagePlatformConnections={props.onManagePlatformConnections}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getPlatformConnectionsViewFromPath,
|
||||
} from './platformConnectionsModel';
|
||||
import { TrueNASSettingsPanel } from './TrueNASSettingsPanel';
|
||||
import { VMwareSettingsPanel } from './VMwareSettingsPanel';
|
||||
import type { InfrastructurePlatformSettingsProps } from './proxmoxSettingsModel';
|
||||
|
||||
export const PlatformConnectionsWorkspace: Component<InfrastructurePlatformSettingsProps> = (
|
||||
|
|
@ -22,7 +23,9 @@ export const PlatformConnectionsWorkspace: Component<InfrastructurePlatformSetti
|
|||
<div class="space-y-6">
|
||||
<Subtabs
|
||||
value={activeView()}
|
||||
onChange={(value) => navigate(buildPlatformConnectionsPath(value as 'proxmox' | 'truenas'))}
|
||||
onChange={(value) =>
|
||||
navigate(buildPlatformConnectionsPath(value as 'proxmox' | 'truenas' | 'vmware'))
|
||||
}
|
||||
ariaLabel="Platform connections"
|
||||
tabs={PLATFORM_CONNECTIONS_TABS.map((tab) => ({
|
||||
value: tab.id,
|
||||
|
|
@ -35,6 +38,10 @@ export const PlatformConnectionsWorkspace: Component<InfrastructurePlatformSetti
|
|||
<TrueNASSettingsPanel state={props.trueNASSettings} />
|
||||
</Match>
|
||||
|
||||
<Match when={activeView() === 'vmware'}>
|
||||
<VMwareSettingsPanel state={props.vmwareSettings} />
|
||||
</Match>
|
||||
|
||||
<Match when={activeView() === 'proxmox'}>
|
||||
<ProxmoxSettingsPanel {...props} embedded />
|
||||
</Match>
|
||||
|
|
|
|||
500
frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx
Normal file
500
frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import Boxes from 'lucide-solid/icons/boxes';
|
||||
import ShieldAlert from 'lucide-solid/icons/shield-alert';
|
||||
import { CalloutCard } from '@/components/shared/CalloutCard';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import type { VMwareConnection } from '@/api/vmware';
|
||||
import { formatNumber, formatRelativeTime } from '@/utils/format';
|
||||
import {
|
||||
formCheckbox,
|
||||
formControl,
|
||||
formField,
|
||||
formHelpText,
|
||||
formLabel,
|
||||
} from '@/components/shared/Form';
|
||||
import { getSettingsConfigurationLoadingState } from '@/utils/settingsShellPresentation';
|
||||
import type { VMwareSettingsPanelState } from './useVMwareSettingsPanelState';
|
||||
|
||||
const buttonClass =
|
||||
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
|
||||
const primaryButtonClass =
|
||||
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60';
|
||||
const dangerButtonClass =
|
||||
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-red-300 px-3 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/30';
|
||||
|
||||
const connectionMetaBadgeClass =
|
||||
'inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-xs font-medium text-muted';
|
||||
|
||||
const getConnectionHealthPresentation = (connection: VMwareConnection) => {
|
||||
if (!connection.enabled) {
|
||||
return {
|
||||
label: 'Disabled',
|
||||
className: 'bg-surface text-muted',
|
||||
detail: 'Manual validation paused',
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const test = connection.test;
|
||||
const lastSuccessAt = test?.lastSuccessAt;
|
||||
const lastError = test?.lastError;
|
||||
const lastErrorAfterSuccess =
|
||||
!!lastError?.at &&
|
||||
(!lastSuccessAt || new Date(lastError.at).getTime() >= new Date(lastSuccessAt).getTime());
|
||||
|
||||
if (lastError && lastErrorAfterSuccess) {
|
||||
return {
|
||||
label: 'Validation failing',
|
||||
className: 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300',
|
||||
detail: lastError.at
|
||||
? `Last validation ${formatRelativeTime(lastError.at, { compact: true })}`
|
||||
: 'Last validation failed',
|
||||
error: lastError.message || null,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastSuccessAt) {
|
||||
return {
|
||||
label: 'Validated',
|
||||
className: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
|
||||
detail: `Last validation ${formatRelativeTime(lastSuccessAt, { compact: true })}`,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Awaiting first validation',
|
||||
className: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
|
||||
detail: 'Pulse has not completed the first vCenter validation yet',
|
||||
error: null,
|
||||
};
|
||||
};
|
||||
|
||||
const getConnectionObservedMetrics = (connection: VMwareConnection) => {
|
||||
const observed = connection.observed;
|
||||
if (!observed) return [];
|
||||
return [
|
||||
{ label: 'host', value: observed.hosts },
|
||||
{ label: 'vm', value: observed.vms },
|
||||
{ label: 'datastore', value: observed.datastores },
|
||||
].filter((item) => item.value > 0);
|
||||
};
|
||||
|
||||
const pluralize = (count: number, singular: string): string =>
|
||||
count === 1 ? singular : `${singular}s`;
|
||||
|
||||
interface VMwareSettingsPanelProps {
|
||||
state: VMwareSettingsPanelState;
|
||||
}
|
||||
|
||||
export const VMwareSettingsPanel: Component<VMwareSettingsPanelProps> = (props) => {
|
||||
const state = props.state;
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<CalloutCard
|
||||
tone="info"
|
||||
title="VMware vSphere platform integration"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
Connect VMware through vCenter so Pulse can validate API-backed access and stage the
|
||||
canonical phase-1 floor for hosts, virtual machines, datastores, and shared alert or
|
||||
assistant read paths.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Phase 1 is vCenter-only and API-first. Direct ESXi onboarding and write-path control
|
||||
stay out of scope here.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
icon={<Boxes class="h-5 w-5" />}
|
||||
/>
|
||||
|
||||
<Show when={state.featureDisabled()}>
|
||||
<CalloutCard
|
||||
tone="warning"
|
||||
title="VMware integration is disabled"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
{state.featureDisabledMessage() ||
|
||||
'VMware integration has been explicitly disabled on this Pulse server.'}
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Remove <code>PULSE_ENABLE_VMWARE=false</code> or set it back to <code>true</code> on
|
||||
the Pulse server, then restart the service before managing VMware connections.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
icon={<ShieldAlert class="h-5 w-5" />}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={!state.featureDisabled()}>
|
||||
<Card padding="lg" class="rounded-xl border border-border shadow-sm">
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-base-content">VMware connections</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Manage the vCenter endpoints Pulse should validate and use as the shared VMware
|
||||
platform onboarding boundary.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class={buttonClass}
|
||||
onClick={() => void state.loadConnections()}
|
||||
disabled={state.loading()}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button type="button" class={primaryButtonClass} onClick={state.openCreateDialog}>
|
||||
Add VMware connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={state.loading()}>
|
||||
<div class="flex items-center justify-center rounded-md border border-dashed border-border bg-surface-alt py-12 text-sm text-muted">
|
||||
{getSettingsConfigurationLoadingState().text}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!state.loading() && state.loadingError()}>
|
||||
<CalloutCard
|
||||
tone="danger"
|
||||
title="Failed to load VMware connections"
|
||||
description={
|
||||
<>
|
||||
<p>{state.loadingError()}</p>
|
||||
<button
|
||||
type="button"
|
||||
class={`mt-3 ${buttonClass}`}
|
||||
onClick={() => void state.loadConnections()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!state.loading() && !state.loadingError() && state.connections().length === 0}
|
||||
>
|
||||
<div class="rounded-lg border border-dashed border-border bg-surface-alt px-6 py-12 text-center">
|
||||
<p class="text-base font-medium text-base-content">No VMware connections yet</p>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Add the first vCenter endpoint Pulse should validate.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!state.loading() && !state.loadingError() && state.connections().length > 0}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<For each={state.connections()}>
|
||||
{(connection) => {
|
||||
const health = () => getConnectionHealthPresentation(connection);
|
||||
const observedMetrics = () => getConnectionObservedMetrics(connection);
|
||||
|
||||
return (
|
||||
<div
|
||||
class="rounded-lg border border-border bg-surface-alt px-4 py-4"
|
||||
data-testid={`vmware-connection-${connection.id}`}
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
{connection.name || connection.host}
|
||||
</h4>
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
connection.enabled
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
|
||||
: 'bg-surface text-muted'
|
||||
}`}
|
||||
>
|
||||
{connection.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${health().className}`}
|
||||
>
|
||||
{health().label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted">
|
||||
{connection.host}
|
||||
{connection.port ? `:${connection.port}` : ''}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<span>{connection.username || 'Username not set'}</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>vCenter</span>
|
||||
<Show when={connection.insecureSkipVerify}>
|
||||
<>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>Skip TLS verification</span>
|
||||
</>
|
||||
</Show>
|
||||
<Show when={connection.test?.lastAttemptAt || health().detail}>
|
||||
<>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{health().detail}</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={health().error}>
|
||||
<p class="text-xs font-medium text-red-700 dark:text-red-300">
|
||||
{health().error}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={connection.observed}>
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Show when={connection.observed?.collectedAt}>
|
||||
<span class={connectionMetaBadgeClass}>
|
||||
Validated{' '}
|
||||
{formatRelativeTime(connection.observed?.collectedAt, {
|
||||
compact: true,
|
||||
})}
|
||||
</span>
|
||||
</Show>
|
||||
<For each={observedMetrics()}>
|
||||
{(item) => (
|
||||
<span class={connectionMetaBadgeClass}>
|
||||
{formatNumber(item.value)}{' '}
|
||||
{pluralize(item.value, item.label)}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={connection.observed?.viRelease}>
|
||||
<span class={connectionMetaBadgeClass}>
|
||||
VI JSON {connection.observed?.viRelease}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class={buttonClass}
|
||||
onClick={() => void state.testSavedConnection(connection)}
|
||||
disabled={state.testing()}
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={buttonClass}
|
||||
onClick={() => state.openEditDialog(connection)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={dangerButtonClass}
|
||||
onClick={() => state.openDeleteDialog(connection)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Dialog
|
||||
isOpen={state.dialogOpen()}
|
||||
onClose={state.closeDialog}
|
||||
ariaLabel={state.editingConnection() ? 'Edit VMware connection' : 'Add VMware connection'}
|
||||
panelClass="w-full max-w-2xl"
|
||||
>
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-lg font-semibold text-base-content">
|
||||
{state.editingConnection() ? 'Edit VMware connection' : 'Add VMware connection'}
|
||||
</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Configure the vCenter endpoint Pulse should validate for the VMware platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class={formField}>
|
||||
<span class={formLabel}>Name</span>
|
||||
<input
|
||||
class={formControl}
|
||||
value={state.form().name}
|
||||
onInput={(event) => state.updateForm({ name: event.currentTarget.value })}
|
||||
placeholder="lab-vcenter"
|
||||
/>
|
||||
</label>
|
||||
<label class={formField}>
|
||||
<span class={formLabel}>Host</span>
|
||||
<input
|
||||
class={formControl}
|
||||
value={state.form().host}
|
||||
onInput={(event) => state.updateForm({ host: event.currentTarget.value })}
|
||||
placeholder="vcsa.lab.local"
|
||||
/>
|
||||
</label>
|
||||
<label class={formField}>
|
||||
<span class={formLabel}>Port</span>
|
||||
<input
|
||||
class={formControl}
|
||||
inputMode="numeric"
|
||||
value={state.form().port}
|
||||
onInput={(event) => state.updateForm({ port: event.currentTarget.value })}
|
||||
placeholder="443"
|
||||
/>
|
||||
</label>
|
||||
<label class={formField}>
|
||||
<span class={formLabel}>Username</span>
|
||||
<input
|
||||
class={formControl}
|
||||
value={state.form().username}
|
||||
onInput={(event) => state.updateForm({ username: event.currentTarget.value })}
|
||||
placeholder="administrator@vsphere.local"
|
||||
/>
|
||||
</label>
|
||||
<label class={`${formField} sm:col-span-2`}>
|
||||
<span class={formLabel}>Password</span>
|
||||
<input
|
||||
class={formControl}
|
||||
type="password"
|
||||
value={state.form().password}
|
||||
onInput={(event) => state.updateForm({ password: event.currentTarget.value })}
|
||||
placeholder={
|
||||
state.form().hasStoredPassword ? 'Saved password retained unless replaced' : ''
|
||||
}
|
||||
/>
|
||||
<Show when={state.form().hasStoredPassword}>
|
||||
<span class={formHelpText}>Leave this blank to keep the saved password.</span>
|
||||
</Show>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-md border border-border bg-surface-alt px-4 py-3">
|
||||
<label class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class={formCheckbox}
|
||||
checked={state.form().insecureSkipVerify}
|
||||
onChange={(event) =>
|
||||
state.updateForm({ insecureSkipVerify: event.currentTarget.checked })
|
||||
}
|
||||
/>
|
||||
<span class="text-sm text-base-content">Skip TLS verification</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class={formCheckbox}
|
||||
checked={state.form().enabled}
|
||||
onChange={(event) => state.updateForm({ enabled: event.currentTarget.checked })}
|
||||
/>
|
||||
<span class="text-sm text-base-content">Enable this vCenter connection</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class={buttonClass}
|
||||
onClick={state.closeDialog}
|
||||
disabled={state.saving() || state.testing()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={buttonClass}
|
||||
onClick={() => void state.testCurrentForm()}
|
||||
disabled={state.saving() || state.testing()}
|
||||
>
|
||||
{state.testing() ? 'Testing…' : 'Test connection'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={primaryButtonClass}
|
||||
onClick={() => void state.saveCurrentForm()}
|
||||
disabled={state.saving() || state.testing()}
|
||||
>
|
||||
{state.saving()
|
||||
? state.editingConnection()
|
||||
? 'Saving…'
|
||||
: 'Adding…'
|
||||
: state.editingConnection()
|
||||
? 'Save connection'
|
||||
: 'Add connection'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
isOpen={state.deleteDialogOpen()}
|
||||
onClose={state.closeDeleteDialog}
|
||||
ariaLabel="Delete VMware connection"
|
||||
panelClass="w-full max-w-lg"
|
||||
>
|
||||
<div class="space-y-5 p-6">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-lg font-semibold text-base-content">Delete VMware connection</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Remove{' '}
|
||||
<span class="font-medium text-base-content">
|
||||
{state.pendingDeleteConnection()?.name || state.pendingDeleteConnection()?.host}
|
||||
</span>{' '}
|
||||
from the configured VMware platform connections.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class={buttonClass}
|
||||
onClick={state.closeDeleteDialog}
|
||||
disabled={state.deleting()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={dangerButtonClass}
|
||||
onClick={() => void state.deletePendingConnection()}
|
||||
disabled={state.deleting()}
|
||||
>
|
||||
{state.deleting() ? 'Deleting…' : 'Delete connection'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VMwareSettingsPanel;
|
||||
|
|
@ -89,10 +89,14 @@ let securityStatusResponse = { requiresAuth: true, apiTokenConfigured: false };
|
|||
|
||||
describe('InfrastructureOperationsController ownership guardrails', () => {
|
||||
it('routes controller and workspace panels through the shared infrastructure operations state owner', () => {
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureOperationsStateProvider');
|
||||
expect(infrastructureOperationsControllerSource).toContain(
|
||||
'InfrastructureOperationsStateProvider',
|
||||
);
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInstallerSection');
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInventorySection');
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureStopMonitoringDialog');
|
||||
expect(infrastructureOperationsControllerSource).toContain(
|
||||
'InfrastructureStopMonitoringDialog',
|
||||
);
|
||||
expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider');
|
||||
expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection');
|
||||
expect(infrastructureReportingPanelSource).toContain('InfrastructureOperationsStateProvider');
|
||||
|
|
@ -102,11 +106,10 @@ describe('InfrastructureOperationsController ownership guardrails', () => {
|
|||
'./InfrastructurePlatformConnectionsSummaryCard',
|
||||
);
|
||||
expect(infrastructureReportingPanelSource).not.toContain('Platform connections');
|
||||
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain(
|
||||
'Platform connections',
|
||||
);
|
||||
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('Platform connections');
|
||||
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('TrueNAS');
|
||||
expect(infrastructureOperationsStateSource).toContain("./infrastructureOperationsModel");
|
||||
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('VMware');
|
||||
expect(infrastructureOperationsStateSource).toContain('./infrastructureOperationsModel');
|
||||
expect(infrastructureOperationsStateSource).toContain('./useInfrastructureInstallState');
|
||||
expect(infrastructureOperationsStateSource).toContain('./useInfrastructureReportingState');
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
|
|
@ -118,7 +121,9 @@ describe('InfrastructureOperationsController ownership guardrails', () => {
|
|||
expect(infrastructureOperationsStateSource).not.toContain('renderInstallerSection');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('renderInventorySection');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('renderStopMonitoringDialog');
|
||||
expect(infrastructureInstallStateSource).toContain('export const useInfrastructureInstallState');
|
||||
expect(infrastructureInstallStateSource).toContain(
|
||||
'export const useInfrastructureInstallState',
|
||||
);
|
||||
expect(infrastructureInstallStateSource).toContain('MonitoringAPI.getState()');
|
||||
expect(infrastructureInstallStateSource).toContain('./infrastructureWorkspaceModel');
|
||||
expect(infrastructureInstallStateSource).toContain(
|
||||
|
|
@ -140,16 +145,18 @@ describe('InfrastructureOperationsController ownership guardrails', () => {
|
|||
'export const useInfrastructureDiscoveryRuntimeState',
|
||||
);
|
||||
expect(infrastructureDiscoveryRuntimeStateSource).toContain("apiFetch('/api/discover'");
|
||||
expect(infrastructureDiscoveryRuntimeStateSource).toContain(
|
||||
'SettingsAPI.updateSystemSettings',
|
||||
);
|
||||
expect(infrastructureDiscoveryRuntimeStateSource).toContain('SettingsAPI.updateSystemSettings');
|
||||
expect(infrastructureInstallerSectionSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureInstallerSectionSource).toContain('Advanced connection and install options');
|
||||
expect(infrastructureInstallerSectionSource).toContain(
|
||||
'Advanced connection and install options',
|
||||
);
|
||||
expect(infrastructureInstallerSectionSource).toContain(
|
||||
'Show advanced connection and install options',
|
||||
);
|
||||
expect(infrastructureInventorySectionSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureStopMonitoringDialogSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureStopMonitoringDialogSource).toContain(
|
||||
'useInfrastructureOperationsContext',
|
||||
);
|
||||
expect(infrastructureOperationsModelSource).toContain('export const getRowReportingSummary');
|
||||
expect(infrastructureOperationsModelSource).toContain(
|
||||
'export const getPowerShellInstallProfileEnvFromFlags',
|
||||
|
|
@ -168,6 +175,8 @@ describe('InfrastructurePlatformConnectionsSummaryCard', () => {
|
|||
pmgCount={3}
|
||||
truenasCount={4}
|
||||
truenasAvailable={true}
|
||||
vmwareCount={5}
|
||||
vmwareAvailable={true}
|
||||
onManagePlatformConnections={onManagePlatformConnections}
|
||||
/>
|
||||
));
|
||||
|
|
@ -176,8 +185,11 @@ describe('InfrastructurePlatformConnectionsSummaryCard', () => {
|
|||
expect(screen.getByText('PBS')).toBeInTheDocument();
|
||||
expect(screen.getByText('PMG')).toBeInTheDocument();
|
||||
expect(screen.getByText('TrueNAS')).toBeInTheDocument();
|
||||
expect(screen.getByText('VMware')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('platform-connections-truenas')).toHaveTextContent('4');
|
||||
expect(screen.getByTestId('platform-connections-vmware')).toHaveTextContent('5');
|
||||
expect(screen.getByText('API-backed NAS connections')).toBeInTheDocument();
|
||||
expect(screen.getByText('vCenter platform connections')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open platform connections' }));
|
||||
expect(onManagePlatformConnections).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -191,12 +203,19 @@ describe('InfrastructurePlatformConnectionsSummaryCard', () => {
|
|||
pmgCount={0}
|
||||
truenasCount={0}
|
||||
truenasAvailable={false}
|
||||
vmwareCount={0}
|
||||
vmwareAvailable={false}
|
||||
onManagePlatformConnections={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Disabled')).toBeInTheDocument();
|
||||
expect(screen.getByText('Explicitly disabled on this Pulse server.')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('platform-connections-truenas')).getByText('Disabled'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('platform-connections-vmware')).getByText('Disabled'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Explicitly disabled on this Pulse server.')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -633,7 +652,10 @@ const setupComponent = (
|
|||
));
|
||||
};
|
||||
|
||||
const setupWithResources = (resources: any[], connectedInfrastructure: ConnectedInfrastructureItem[]) => {
|
||||
const setupWithResources = (
|
||||
resources: any[],
|
||||
connectedInfrastructure: ConnectedInfrastructureItem[],
|
||||
) => {
|
||||
setMockResources(resources);
|
||||
|
||||
const [state] = createStore<Pick<State, 'connectedInfrastructure'>>({
|
||||
|
|
@ -730,10 +752,13 @@ beforeEach(() => {
|
|||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as unknown as Navigator);
|
||||
vi.stubGlobal('Blob', MockBlob as unknown as typeof Blob);
|
||||
vi.stubGlobal('URL', Object.assign(URL, {
|
||||
createObjectURL: createObjectURLMock,
|
||||
revokeObjectURL: revokeObjectURLMock,
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
Object.assign(URL, {
|
||||
createObjectURL: createObjectURLMock,
|
||||
revokeObjectURL: revokeObjectURLMock,
|
||||
}),
|
||||
);
|
||||
|
||||
listProfilesMock.mockResolvedValue([]);
|
||||
listAssignmentsMock.mockResolvedValue([]);
|
||||
|
|
@ -790,7 +815,9 @@ describe('InfrastructureOperationsController token generation', () => {
|
|||
'setup_handoff',
|
||||
);
|
||||
expect(notificationSuccessMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/Security configured\. Save these first-run credentials now\./i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Security configured\. Save these first-run credentials now\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Pulse already prepared the first scoped install token for this handoff/i),
|
||||
).toBeInTheDocument();
|
||||
|
|
@ -933,7 +960,9 @@ describe('InfrastructureOperationsController agent lookup', () => {
|
|||
const host = createAgent({ hostname: 'first-host.local', displayName: 'First Host' });
|
||||
getStateMock
|
||||
.mockResolvedValueOnce(buildMonitoringState())
|
||||
.mockResolvedValue(buildMonitoringState(buildConnectedInfrastructureFromFixtures({ hosts: [host] })));
|
||||
.mockResolvedValue(
|
||||
buildMonitoringState(buildConnectedInfrastructureFromFixtures({ hosts: [host] })),
|
||||
);
|
||||
|
||||
setupComponent();
|
||||
|
||||
|
|
@ -1099,7 +1128,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
|
|||
expect(detailsRow).not.toBeNull();
|
||||
const details = within(detailsRow as HTMLElement);
|
||||
expect(screen.getByText('Browse reporting items')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select a reporting item to open its details drawer.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Select a reporting item to open its details drawer.'),
|
||||
).toBeInTheDocument();
|
||||
expect(details.getByText('Selected reporting item')).toBeInTheDocument();
|
||||
expect(details.getByText('Machine overview')).toBeInTheDocument();
|
||||
expect(details.getByText('Surface controls')).toBeInTheDocument();
|
||||
|
|
@ -1261,45 +1292,44 @@ describe('InfrastructureOperationsController managed agents table', () => {
|
|||
expect(
|
||||
details.getByText('Container runtime coverage reported from this machine.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
details.getByText('Proxmox node telemetry linked to this machine.'),
|
||||
).toBeInTheDocument();
|
||||
expect(details.getByText('Proxmox node telemetry linked to this machine.')).toBeInTheDocument();
|
||||
expect(details.getByText('Agent ID')).toBeInTheDocument();
|
||||
expect(details.getByText('Docker runtime ID')).toBeInTheDocument();
|
||||
expect(details.getByText('Node ID')).toBeInTheDocument();
|
||||
expect(details.getAllByText('delly-resource').length).toBeGreaterThan(0);
|
||||
expect(
|
||||
details.getByRole('button', { name: /Open platform connections/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(details.getByRole('button', { name: /Open platform connections/i })).toBeInTheDocument();
|
||||
expect(details.getAllByText('delly-agent').length).toBeGreaterThan(0);
|
||||
expect(details.getAllByText('delly-docker').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('routes api-backed truenas rows to platform connections instead of machine uninstall actions', async () => {
|
||||
setupWithResources([], [
|
||||
{
|
||||
id: 'truenas-main',
|
||||
name: 'Tower NAS',
|
||||
displayName: 'Tower NAS',
|
||||
hostname: 'truenas.local',
|
||||
status: 'active',
|
||||
healthStatus: 'online',
|
||||
lastSeen: Date.now(),
|
||||
version: '25.04.0',
|
||||
upgradePlatform: 'linux',
|
||||
surfaces: [
|
||||
{
|
||||
id: 'truenas:truenas.local',
|
||||
kind: 'truenas',
|
||||
label: 'TrueNAS data',
|
||||
detail:
|
||||
'System, storage, app, and recovery telemetry polled through the configured TrueNAS connection.',
|
||||
idLabel: 'Hostname',
|
||||
idValue: 'truenas.local',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
setupWithResources(
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'truenas-main',
|
||||
name: 'Tower NAS',
|
||||
displayName: 'Tower NAS',
|
||||
hostname: 'truenas.local',
|
||||
status: 'active',
|
||||
healthStatus: 'online',
|
||||
lastSeen: Date.now(),
|
||||
version: '25.04.0',
|
||||
upgradePlatform: 'linux',
|
||||
surfaces: [
|
||||
{
|
||||
id: 'truenas:truenas.local',
|
||||
kind: 'truenas',
|
||||
label: 'TrueNAS data',
|
||||
detail:
|
||||
'System, storage, app, and recovery telemetry polled through the configured TrueNAS connection.',
|
||||
idLabel: 'Hostname',
|
||||
idValue: 'truenas.local',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Reporting now').length).toBeGreaterThan(0);
|
||||
|
|
@ -1313,7 +1343,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
|
|||
const details = within(detailsRow as HTMLElement);
|
||||
|
||||
expect(details.queryByText('Machine actions')).not.toBeInTheDocument();
|
||||
expect(details.queryByRole('button', { name: /Copy uninstall command/i })).not.toBeInTheDocument();
|
||||
expect(
|
||||
details.queryByRole('button', { name: /Copy uninstall command/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const openPlatformConnectionsButton = details.getByRole('button', {
|
||||
name: /Open platform connections/i,
|
||||
|
|
@ -1427,7 +1459,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
|
|||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Proxmox data')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Pulse is currently receiving live reports from 1 host, 1 Docker runtime, and 1 PBS server.'),
|
||||
screen.getByText(
|
||||
'Pulse is currently receiving live reports from 1 host, 1 Docker runtime, and 1 PBS server.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /details for Tower/i }));
|
||||
|
|
@ -1569,7 +1603,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
|
|||
expect(screen.getAllByText('delly').length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(
|
||||
screen.queryByText('Pulse is receiving host telemetry, Docker runtime data, and Proxmox data from this item.'),
|
||||
screen.queryByText(
|
||||
'Pulse is receiving host telemetry, Docker runtime data, and Proxmox data from this item.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -2209,12 +2245,12 @@ describe('InfrastructureOperationsController managed agents table', () => {
|
|||
expect(screen.getByText('Docker runtime')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 item(s) are currently ignored by Pulse.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Pulse is currently receiving live reports from 1 host.',
|
||||
),
|
||||
screen.getByText('Pulse is currently receiving live reports from 1 host.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Missing expected coverage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('No Kubernetes reporter is currently connected.')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('No Kubernetes reporter is currently connected.'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Items you explicitly told Pulse to ignore stay out of live reporting until reconnect is allowed\./i,
|
||||
|
|
@ -2263,8 +2299,12 @@ describe('InfrastructureOperationsController managed agents table', () => {
|
|||
expect(screen.getAllByText('Docker runtime data').length).toBeGreaterThan(0);
|
||||
const ignoredDrawer = screen.getByRole('dialog', { name: 'Ignored item details' });
|
||||
const ignoredDetails = within(ignoredDrawer);
|
||||
expect(ignoredDetails.getByRole('button', { name: /Allow Docker reconnect/i })).toBeInTheDocument();
|
||||
expect(ignoredDetails.queryByRole('button', { name: 'Stop monitoring' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
ignoredDetails.getByRole('button', { name: /Allow Docker reconnect/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
ignoredDetails.queryByRole('button', { name: 'Stop monitoring' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Showing 0 of 0 active records.')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 item(s) are currently ignored by Pulse.')).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -2354,7 +2394,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
|
|||
);
|
||||
expect(screen.getAllByText('Tower').length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getAllByText('Tower')[0]);
|
||||
expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(notificationSuccessMock).toHaveBeenCalledWith(
|
||||
'Monitoring stopped for Tower. Pulse will ignore future reports until reconnect is allowed.',
|
||||
);
|
||||
|
|
@ -2421,7 +2463,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
|
|||
});
|
||||
expect(screen.getAllByText('Ignored by Pulse').length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getAllByText('Tower')[0]);
|
||||
expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('force-removes docker runtimes from Pulse inventory', async () => {
|
||||
|
|
@ -2451,7 +2495,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
|
|||
},
|
||||
);
|
||||
fireEvent.click(screen.getAllByText('Tower')[0]);
|
||||
expect(screen.getAllByRole('button', { name: 'Allow Docker reconnect' }).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: 'Allow Docker reconnect' }).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(notificationSuccessMock).toHaveBeenCalledWith(
|
||||
'Monitoring stopped for Tower. Pulse will ignore future reports until reconnect is allowed.',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { PlatformConnectionsWorkspace } from '../PlatformConnectionsWorkspace';
|
|||
let mockPathname = '/settings/infrastructure/platforms/proxmox';
|
||||
const navigateSpy = vi.hoisted(() => vi.fn());
|
||||
const trueNASStateSpy = vi.hoisted(() => vi.fn());
|
||||
const vmwareStateSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@solidjs/router', async () => {
|
||||
const actual = await vi.importActual<typeof import('@solidjs/router')>('@solidjs/router');
|
||||
|
|
@ -26,10 +27,18 @@ vi.mock('../TrueNASSettingsPanel', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('../VMwareSettingsPanel', () => ({
|
||||
VMwareSettingsPanel: (props: { state: unknown }) => {
|
||||
vmwareStateSpy(props.state);
|
||||
return <div data-testid="vmware-settings">vmware</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PlatformConnectionsWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
navigateSpy.mockReset();
|
||||
trueNASStateSpy.mockReset();
|
||||
vmwareStateSpy.mockReset();
|
||||
mockPathname = '/settings/infrastructure/platforms/proxmox';
|
||||
});
|
||||
|
||||
|
|
@ -47,6 +56,7 @@ describe('PlatformConnectionsWorkspace', () => {
|
|||
pbsNodes: () => [],
|
||||
pmgNodes: () => [],
|
||||
trueNASSettings: { connections: () => [], featureDisabled: () => false },
|
||||
vmwareSettings: { connections: () => [], featureDisabled: () => false },
|
||||
} as any)}
|
||||
/>
|
||||
) as any,
|
||||
|
|
@ -57,6 +67,7 @@ describe('PlatformConnectionsWorkspace', () => {
|
|||
|
||||
expect(screen.getByRole('tab', { name: 'Proxmox' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: 'TrueNAS' })).toHaveAttribute('aria-selected', 'false');
|
||||
expect(screen.getByRole('tab', { name: 'VMware' })).toHaveAttribute('aria-selected', 'false');
|
||||
expect(screen.getByTestId('proxmox-settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -68,6 +79,14 @@ describe('PlatformConnectionsWorkspace', () => {
|
|||
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/truenas');
|
||||
});
|
||||
|
||||
it('navigates to the canonical VMware route from the shared subtabs', () => {
|
||||
renderWorkspace();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'VMware' }));
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/vmware');
|
||||
});
|
||||
|
||||
it('treats legacy TrueNAS routes as the TrueNAS workspace', () => {
|
||||
mockPathname = '/settings/infrastructure/truenas';
|
||||
renderWorkspace();
|
||||
|
|
@ -81,4 +100,18 @@ describe('PlatformConnectionsWorkspace', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats the canonical VMware route as the VMware workspace', () => {
|
||||
mockPathname = '/settings/infrastructure/platforms/vmware';
|
||||
renderWorkspace();
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'VMware' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByTestId('vmware-settings')).toBeInTheDocument();
|
||||
expect(vmwareStateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connections: expect.any(Function),
|
||||
featureDisabled: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { VMwareConnection } from '@/api/vmware';
|
||||
import { VMwareSettingsPanel } from '../VMwareSettingsPanel';
|
||||
import type { VMwareSettingsPanelState } from '../useVMwareSettingsPanelState';
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
closeDeleteDialog: vi.fn(),
|
||||
closeDialog: vi.fn(),
|
||||
connections: vi.fn((): VMwareConnection[] => []),
|
||||
deleteDialogOpen: vi.fn(() => false),
|
||||
deletePendingConnection: vi.fn(),
|
||||
deleting: vi.fn(() => false),
|
||||
dialogOpen: vi.fn(() => false),
|
||||
editingConnection: vi.fn((): VMwareConnection | null => null),
|
||||
featureDisabled: vi.fn(() => false),
|
||||
featureDisabledMessage: vi.fn(() => ''),
|
||||
form: vi.fn(() => ({
|
||||
name: '',
|
||||
host: '',
|
||||
port: '443',
|
||||
username: '',
|
||||
password: '',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
hasStoredPassword: false,
|
||||
})),
|
||||
loadConnections: vi.fn(),
|
||||
loading: vi.fn(() => false),
|
||||
loadingError: vi.fn(() => null),
|
||||
openCreateDialog: vi.fn(),
|
||||
openDeleteDialog: vi.fn(),
|
||||
openEditDialog: vi.fn(),
|
||||
pendingDeleteConnection: vi.fn((): VMwareConnection | null => null),
|
||||
saveCurrentForm: vi.fn(),
|
||||
saving: vi.fn(() => false),
|
||||
testCurrentForm: vi.fn(),
|
||||
testSavedConnection: vi.fn(),
|
||||
testing: vi.fn(() => false),
|
||||
updateForm: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('VMwareSettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(mockState).forEach((value) => {
|
||||
if (typeof value === 'function' && 'mockReset' in value) {
|
||||
value.mockReset();
|
||||
}
|
||||
});
|
||||
mockState.connections.mockReturnValue([]);
|
||||
mockState.deleteDialogOpen.mockReturnValue(false);
|
||||
mockState.deleting.mockReturnValue(false);
|
||||
mockState.dialogOpen.mockReturnValue(false);
|
||||
mockState.editingConnection.mockReturnValue(null);
|
||||
mockState.featureDisabled.mockReturnValue(false);
|
||||
mockState.featureDisabledMessage.mockReturnValue('');
|
||||
mockState.form.mockReturnValue({
|
||||
name: '',
|
||||
host: '',
|
||||
port: '443',
|
||||
username: '',
|
||||
password: '',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
hasStoredPassword: false,
|
||||
});
|
||||
mockState.loading.mockReturnValue(false);
|
||||
mockState.loadingError.mockReturnValue(null);
|
||||
mockState.pendingDeleteConnection.mockReturnValue(null);
|
||||
mockState.saving.mockReturnValue(false);
|
||||
mockState.testing.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const renderPanel = () =>
|
||||
render(() => <VMwareSettingsPanel state={mockState as unknown as VMwareSettingsPanelState} />);
|
||||
|
||||
it('renders the settings shell and existing connections', () => {
|
||||
mockState.connections.mockReturnValue([
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
test: {
|
||||
lastSuccessAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
},
|
||||
observed: {
|
||||
collectedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
hosts: 3,
|
||||
vms: 42,
|
||||
datastores: 6,
|
||||
viRelease: '8.0.3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'conn-2',
|
||||
name: 'staging-vcenter',
|
||||
host: 'staging.lab.local',
|
||||
username: 'operator@vsphere.local',
|
||||
insecureSkipVerify: true,
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText('VMware vSphere platform integration')).toBeInTheDocument();
|
||||
expect(screen.getByText('VMware connections')).toBeInTheDocument();
|
||||
expect(screen.getByText('lab-vcenter')).toBeInTheDocument();
|
||||
expect(screen.getByText('Validated')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('vmware-connection-conn-2')).getAllByText('Disabled'),
|
||||
).toHaveLength(2);
|
||||
expect(screen.getByText('3 hosts')).toBeInTheDocument();
|
||||
expect(screen.getByText('42 vms')).toBeInTheDocument();
|
||||
expect(screen.getByText('6 datastores')).toBeInTheDocument();
|
||||
expect(screen.getByText('VI JSON 8.0.3')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Add VMware connection' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add VMware connection' }));
|
||||
expect(mockState.openCreateDialog).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId('vmware-connection-conn-1')).getByRole('button', { name: 'Test' }),
|
||||
);
|
||||
expect(mockState.testSavedConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'conn-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the feature gate warning when the backend path is disabled', () => {
|
||||
mockState.featureDisabled.mockReturnValue(true);
|
||||
mockState.featureDisabledMessage.mockReturnValue(
|
||||
'VMware integration has been explicitly disabled',
|
||||
);
|
||||
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText('VMware integration is disabled')).toBeInTheDocument();
|
||||
expect(screen.getByText('VMware integration has been explicitly disabled')).toBeInTheDocument();
|
||||
expect(screen.getByText(/PULSE_ENABLE_VMWARE=false/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('VMware connections')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ import infrastructureOperationsModelSource from '../infrastructureOperationsMode
|
|||
import infrastructureReportingPanelSource from '../InfrastructureReportingPanel.tsx?raw';
|
||||
import infrastructurePlatformConnectionsSummaryCardSource from '../InfrastructurePlatformConnectionsSummaryCard.tsx?raw';
|
||||
import trueNASSettingsPanelSource from '../TrueNASSettingsPanel.tsx?raw';
|
||||
import vmwareSettingsPanelSource from '../VMwareSettingsPanel.tsx?raw';
|
||||
import infrastructureInventorySectionSource from '../InfrastructureInventorySection.tsx?raw';
|
||||
import infrastructureActiveRowDetailsSource from '../InfrastructureActiveRowDetails.tsx?raw';
|
||||
import infrastructureIgnoredRowDetailsSource from '../InfrastructureIgnoredRowDetails.tsx?raw';
|
||||
|
|
@ -31,6 +32,7 @@ import infrastructureInstallStateSource from '../useInfrastructureInstallState.t
|
|||
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
|
||||
import infrastructureReportingStateSource from '../useInfrastructureReportingState.tsx?raw';
|
||||
import trueNASSettingsStateSource from '../useTrueNASSettingsPanelState.ts?raw';
|
||||
import vmwareSettingsStateSource from '../useVMwareSettingsPanelState.ts?raw';
|
||||
import infrastructureSettingsStateSource from '../useInfrastructureSettingsState.ts?raw';
|
||||
import infrastructureSettingsModelSource from '../infrastructureSettingsModel.ts?raw';
|
||||
import infrastructureConfiguredNodesStateSource from '../useInfrastructureConfiguredNodesState.ts?raw';
|
||||
|
|
@ -171,10 +173,12 @@ const extractedModules = [
|
|||
'../PlatformConnectionsWorkspace.tsx',
|
||||
'../platformConnectionsModel.ts',
|
||||
'../TrueNASSettingsPanel.tsx',
|
||||
'../VMwareSettingsPanel.tsx',
|
||||
'../useInfrastructureInstallState.tsx',
|
||||
'../useInfrastructureOperationsState.tsx',
|
||||
'../useInfrastructureReportingState.tsx',
|
||||
'../useTrueNASSettingsPanelState.ts',
|
||||
'../useVMwareSettingsPanelState.ts',
|
||||
'../infrastructureSettingsModel.ts',
|
||||
'../useInfrastructureConfiguredNodesState.ts',
|
||||
'../useInfrastructureDiscoveryRuntimeState.ts',
|
||||
|
|
@ -498,7 +502,7 @@ describe('Settings architecture guardrails', () => {
|
|||
);
|
||||
expect(settingsNavigationModelSource).toContain('export function resolveCanonicalSettingsPath');
|
||||
expect(settingsNavigationModelSource).toContain('export function settingsTabPath');
|
||||
expect(settingsNavigationModelSource).toContain("return INFRASTRUCTURE_INSTALL_PREFIX;");
|
||||
expect(settingsNavigationModelSource).toContain('return INFRASTRUCTURE_INSTALL_PREFIX;');
|
||||
expect(settingsNavigationHookSource).toContain('deriveTabFromPath');
|
||||
expect(settingsNavigationHookSource).toContain('resolveCanonicalSettingsPath');
|
||||
expect(settingsNavigationHookSource).toContain('settingsTabPath');
|
||||
|
|
@ -512,7 +516,7 @@ describe('Settings architecture guardrails', () => {
|
|||
expect(settingsShellStateSource).toContain('showPasswordModal');
|
||||
expect(settingsNavCatalogSource).toContain('export const SETTINGS_NAV_GROUPS');
|
||||
expect(settingsNavCatalogSource).toContain('export function getSettingsNavItem');
|
||||
expect(settingsNavCatalogSource).toContain("./selfHostedBillingPresentation");
|
||||
expect(settingsNavCatalogSource).toContain('./selfHostedBillingPresentation');
|
||||
expect(settingsNavCatalogSource).toContain('SELF_HOSTED_PRO_BILLING_PRESENTATION.shellTitle');
|
||||
expect(settingsNavCatalogSource).not.toContain("label: 'Pulse Pro'");
|
||||
expect(settingsNavVisibilitySource).toContain('export function shouldHideSettingsNavItem');
|
||||
|
|
@ -587,18 +591,12 @@ describe('Settings architecture guardrails', () => {
|
|||
expect(monitoredSystemPresentationSource).toContain(
|
||||
'export function getMonitoredSystemDisclosureToggleLabel',
|
||||
);
|
||||
expect(selfHostedCommercialActivationSectionSource).toContain(
|
||||
'@/utils/licensePresentation',
|
||||
);
|
||||
expect(selfHostedCommercialActivationSectionSource).toContain('@/utils/licensePresentation');
|
||||
expect(selfHostedCommercialActivationSectionSource).toContain(
|
||||
'SELF_HOSTED_ACTIVATION_PRESENTATION',
|
||||
);
|
||||
expect(selfHostedCommercialActivationSectionSource).not.toContain(
|
||||
'License / Activation Key',
|
||||
);
|
||||
expect(selfHostedCommercialActivationSectionSource).not.toContain(
|
||||
'Start 14-day Pro Trial',
|
||||
);
|
||||
expect(selfHostedCommercialActivationSectionSource).not.toContain('License / Activation Key');
|
||||
expect(selfHostedCommercialActivationSectionSource).not.toContain('Start 14-day Pro Trial');
|
||||
expect(organizationBillingPanelSource).toContain('./CommercialBillingSections');
|
||||
expect(organizationBillingPanelSource).toContain('./OrganizationBillingLoadingState');
|
||||
expect(organizationBillingPanelSource).toContain('./useOrganizationBillingPanelState');
|
||||
|
|
@ -872,9 +870,10 @@ describe('Settings architecture guardrails', () => {
|
|||
expect(platformConnectionsWorkspaceSource).toContain('./platformConnectionsModel');
|
||||
expect(platformConnectionsWorkspaceSource).toContain('./ProxmoxSettingsPanel');
|
||||
expect(platformConnectionsWorkspaceSource).toContain('./TrueNASSettingsPanel');
|
||||
expect(platformConnectionsWorkspaceSource).toContain('./VMwareSettingsPanel');
|
||||
expect(platformConnectionsModelSource).toContain('export const PLATFORM_CONNECTIONS_TABS');
|
||||
expect(platformConnectionsModelSource).toContain(
|
||||
"export function getPlatformConnectionsViewFromPath",
|
||||
'export function getPlatformConnectionsViewFromPath',
|
||||
);
|
||||
expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider');
|
||||
expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection');
|
||||
|
|
@ -906,9 +905,13 @@ describe('Settings architecture guardrails', () => {
|
|||
'export const useInfrastructureOperationsContext',
|
||||
);
|
||||
expect(trueNASSettingsPanelSource).toContain('state: TrueNASSettingsPanelState');
|
||||
expect(vmwareSettingsPanelSource).toContain('state: VMwareSettingsPanelState');
|
||||
expect(trueNASSettingsStateSource).toContain('@/api/truenas');
|
||||
expect(trueNASSettingsStateSource).toContain('export function useTrueNASSettingsPanelState');
|
||||
expect(vmwareSettingsStateSource).toContain('@/api/vmware');
|
||||
expect(vmwareSettingsStateSource).toContain('export function useVMwareSettingsPanelState');
|
||||
expect(infrastructureSettingsStateSource).toContain('./useTrueNASSettingsPanelState');
|
||||
expect(infrastructureSettingsStateSource).toContain('./useVMwareSettingsPanelState');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const renderInstallerSection =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const renderInventorySection =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const renderStopMonitoringDialog =');
|
||||
|
|
@ -934,10 +937,9 @@ describe('Settings architecture guardrails', () => {
|
|||
expect(infrastructureWorkspaceModelSource).toContain(
|
||||
'export function buildInfrastructureWorkspacePath',
|
||||
);
|
||||
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain(
|
||||
'Platform connections',
|
||||
);
|
||||
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('Platform connections');
|
||||
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('TrueNAS');
|
||||
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('VMware');
|
||||
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain(
|
||||
'Open platform connections',
|
||||
);
|
||||
|
|
@ -1186,7 +1188,9 @@ describe('Settings architecture guardrails', () => {
|
|||
expect(reportingPanelStateSource).not.toContain("'advanced_reporting'");
|
||||
expect(reportingPanelStateSource).not.toContain('getTrialAlreadyUsedMessage()');
|
||||
expect(reportingCatalogModelSource).toContain('export function buildReportingCatalogRequest');
|
||||
expect(reportingCatalogModelSource).toContain('export function buildLegacyReportingCatalogFallback');
|
||||
expect(reportingCatalogModelSource).toContain(
|
||||
'export function buildLegacyReportingCatalogFallback',
|
||||
);
|
||||
expect(reportingCatalogModelSource).toContain('export function parseReportingCatalog');
|
||||
expect(reportingCatalogModelSource).toContain('interface ReportingLockedStateDefinition');
|
||||
expect(reportingCatalogModelSource).toContain('interface ReportingGuidanceDefinition');
|
||||
|
|
@ -1312,14 +1316,18 @@ describe('Settings architecture guardrails', () => {
|
|||
'export function getStartUpdateErrorMessage',
|
||||
);
|
||||
expect(docsLinksSource).toContain("export const SHIPPED_DOCS_ROOT = '/docs'");
|
||||
expect(docsLinksSource).toContain("export const PRIVACY_DOC_URL = getShippedDocUrl('PRIVACY.md')");
|
||||
expect(docsLinksSource).toContain(
|
||||
"export const PRIVACY_DOC_URL = getShippedDocUrl('PRIVACY.md')",
|
||||
);
|
||||
expect(docsLinksSource).toContain(
|
||||
"export const CONFIGURATION_DOC_URL = getShippedDocUrl('CONFIGURATION.md')",
|
||||
);
|
||||
expect(docsLinksSource).toContain(
|
||||
"export const PROXY_AUTH_DOC_URL = getShippedDocUrl('PROXY_AUTH.md')",
|
||||
);
|
||||
expect(docsLinksSource).toContain("export const SECURITY_DOC_URL = getShippedDocUrl('SECURITY.md')");
|
||||
expect(docsLinksSource).toContain(
|
||||
"export const SECURITY_DOC_URL = getShippedDocUrl('SECURITY.md')",
|
||||
);
|
||||
expect(docsLinksSource).toContain("export const TERMS_DOC_URL = getShippedDocUrl('TERMS.md')");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||
import type { Resource } from '@/types/resource';
|
||||
import { useSettingsInfrastructurePanelProps } from '../useSettingsInfrastructurePanelProps';
|
||||
|
||||
const createServiceResource = (
|
||||
type: 'pbs' | 'pmg',
|
||||
overrides: Partial<Resource> = {},
|
||||
): Resource =>
|
||||
const createServiceResource = (type: 'pbs' | 'pmg', overrides: Partial<Resource> = {}): Resource =>
|
||||
({
|
||||
id: `${type}-1`,
|
||||
type,
|
||||
|
|
@ -31,6 +28,8 @@ const mountHook = (
|
|||
options?: {
|
||||
truenasConnections?: Array<{ id: string }>;
|
||||
truenasFeatureDisabled?: boolean;
|
||||
vmwareConnections?: Array<{ id: string }>;
|
||||
vmwareFeatureDisabled?: boolean;
|
||||
pveCount?: number;
|
||||
pbsCount?: number;
|
||||
pmgCount?: number;
|
||||
|
|
@ -65,18 +64,22 @@ const mountHook = (
|
|||
disableDockerUpdateActionsLocked: () => false,
|
||||
savingDockerUpdateActions: () => false,
|
||||
handleDisableDockerUpdateActionsChange: async () => {},
|
||||
} as Parameters<typeof useSettingsInfrastructurePanelProps>[0]['systemSettings'],
|
||||
} as unknown as Parameters<typeof useSettingsInfrastructurePanelProps>[0]['systemSettings'],
|
||||
infrastructureSettings: {
|
||||
initialLoadComplete: () => true,
|
||||
discoveryScanStatus: () => ({ scanning: false }),
|
||||
discoveredNodes: () => [],
|
||||
pveNodes: () => Array.from({ length: options?.pveCount ?? 0 }, () => ({} as any)),
|
||||
pbsNodes: () => Array.from({ length: options?.pbsCount ?? 0 }, () => ({} as any)),
|
||||
pmgNodes: () => Array.from({ length: options?.pmgCount ?? 0 }, () => ({} as any)),
|
||||
pveNodes: () => Array.from({ length: options?.pveCount ?? 0 }, () => ({}) as any),
|
||||
pbsNodes: () => Array.from({ length: options?.pbsCount ?? 0 }, () => ({}) as any),
|
||||
pmgNodes: () => Array.from({ length: options?.pmgCount ?? 0 }, () => ({}) as any),
|
||||
trueNASSettings: {
|
||||
connections: () => options?.truenasConnections ?? [],
|
||||
featureDisabled: () => options?.truenasFeatureDisabled ?? false,
|
||||
},
|
||||
vmwareSettings: {
|
||||
connections: () => options?.vmwareConnections ?? [],
|
||||
featureDisabled: () => options?.vmwareFeatureDisabled ?? false,
|
||||
},
|
||||
triggerDiscoveryScan: async () => {},
|
||||
loadDiscoveredNodes: async () => {},
|
||||
handleDiscoveryEnabledChange: async () => true,
|
||||
|
|
@ -101,7 +104,9 @@ const mountHook = (
|
|||
nodePendingDeleteHost: () => '',
|
||||
nodePendingDeleteType: () => '',
|
||||
nodePendingDeleteTypeLabel: () => '',
|
||||
} as Parameters<typeof useSettingsInfrastructurePanelProps>[0]['infrastructureSettings'],
|
||||
} as unknown as Parameters<
|
||||
typeof useSettingsInfrastructurePanelProps
|
||||
>[0]['infrastructureSettings'],
|
||||
securityStatus: () => null,
|
||||
});
|
||||
});
|
||||
|
|
@ -134,18 +139,16 @@ describe('useSettingsInfrastructurePanelProps', () => {
|
|||
|
||||
const panelProps = hookState.getInfrastructurePanelProps();
|
||||
|
||||
expect(panelProps.pbsInstances()).toEqual([
|
||||
expect.objectContaining({ name: 'PBS Main' }),
|
||||
]);
|
||||
expect(panelProps.pmgInstances()).toEqual([
|
||||
expect.objectContaining({ name: 'PMG Main' }),
|
||||
]);
|
||||
expect(panelProps.pbsInstances()).toEqual([expect.objectContaining({ name: 'PBS Main' })]);
|
||||
expect(panelProps.pmgInstances()).toEqual([expect.objectContaining({ name: 'PMG Main' })]);
|
||||
expect(panelProps.platformConnectionsSummary()).toMatchObject({
|
||||
pveCount: 0,
|
||||
pbsCount: 0,
|
||||
pmgCount: 0,
|
||||
truenasCount: 0,
|
||||
truenasAvailable: true,
|
||||
vmwareCount: 0,
|
||||
vmwareAvailable: true,
|
||||
});
|
||||
|
||||
dispose();
|
||||
|
|
@ -157,6 +160,7 @@ describe('useSettingsInfrastructurePanelProps', () => {
|
|||
pbsCount: 2,
|
||||
pmgCount: 3,
|
||||
truenasConnections: [{ id: 'truenas-1' }, { id: 'truenas-2' }],
|
||||
vmwareConnections: [{ id: 'vmware-1' }],
|
||||
});
|
||||
|
||||
const panelProps = hookState.getInfrastructurePanelProps();
|
||||
|
|
@ -167,6 +171,8 @@ describe('useSettingsInfrastructurePanelProps', () => {
|
|||
pmgCount: 3,
|
||||
truenasCount: 2,
|
||||
truenasAvailable: true,
|
||||
vmwareCount: 1,
|
||||
vmwareAvailable: true,
|
||||
});
|
||||
|
||||
dispose();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
import { renderHook, waitFor } from '@solidjs/testing-library';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { VMwareAPI } from '@/api/vmware';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { useVMwareSettingsPanelState } from '../useVMwareSettingsPanelState';
|
||||
|
||||
vi.mock('@/api/vmware', () => ({
|
||||
VMwareAPI: {
|
||||
listConnections: vi.fn(),
|
||||
createConnection: vi.fn(),
|
||||
updateConnection: vi.fn(),
|
||||
deleteConnection: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
testSavedConnection: vi.fn(),
|
||||
},
|
||||
isRedactedVMwareSecret: (value: string | null | undefined) => (value || '').trim() === '********',
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useVMwareSettingsPanelState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('treats a 404 list response as a feature-disabled integration state', async () => {
|
||||
vi.mocked(VMwareAPI.listConnections).mockRejectedValueOnce({
|
||||
status: 404,
|
||||
message: 'VMware integration has been explicitly disabled',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVMwareSettingsPanelState());
|
||||
|
||||
await waitFor(() => expect(result.featureDisabled()).toBe(true));
|
||||
expect(result.featureDisabledMessage()).toBe('VMware integration has been explicitly disabled');
|
||||
expect(result.connections()).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves the masked password when editing an existing connection without replacing the secret', async () => {
|
||||
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(VMwareAPI.updateConnection).mockResolvedValueOnce({
|
||||
id: 'conn-1',
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
} as never);
|
||||
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const { result } = renderHook(() => useVMwareSettingsPanelState());
|
||||
|
||||
await waitFor(() => expect(result.connections()).toHaveLength(1));
|
||||
|
||||
result.openEditDialog(result.connections()[0]);
|
||||
expect(result.dialogOpen()).toBe(true);
|
||||
await result.saveCurrentForm();
|
||||
|
||||
expect(VMwareAPI.updateConnection).toHaveBeenCalledWith(
|
||||
'conn-1',
|
||||
expect.objectContaining({
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
expect(result.dialogOpen()).toBe(false);
|
||||
expect(notificationStore.success).toHaveBeenCalledWith('VMware connection updated');
|
||||
});
|
||||
|
||||
it('tests saved connections through the canonical saved-connection API path', async () => {
|
||||
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
test: {
|
||||
lastSuccessAt: '2026-03-30T10:00:00Z',
|
||||
},
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(VMwareAPI.testSavedConnection).mockResolvedValueOnce({ success: true } as never);
|
||||
|
||||
const { result } = renderHook(() => useVMwareSettingsPanelState());
|
||||
|
||||
await waitFor(() => expect(result.connections()).toHaveLength(1));
|
||||
|
||||
await result.testSavedConnection(result.connections()[0]);
|
||||
|
||||
expect(VMwareAPI.testSavedConnection).toHaveBeenCalledWith('conn-1');
|
||||
expect(VMwareAPI.testConnection).not.toHaveBeenCalled();
|
||||
expect(notificationStore.success).toHaveBeenCalledWith(
|
||||
'VMware connection successful for lab-vcenter',
|
||||
);
|
||||
expect(VMwareAPI.listConnections).toHaveBeenCalledTimes(2);
|
||||
expect(result.connections()[0].test?.lastSuccessAt).toBe('2026-03-30T10:00:00Z');
|
||||
});
|
||||
|
||||
it('tests edited saved connections through the canonical saved-connection API path', async () => {
|
||||
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'lab-vcenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(VMwareAPI.testSavedConnection).mockResolvedValueOnce({ success: true } as never);
|
||||
|
||||
const { result } = renderHook(() => useVMwareSettingsPanelState());
|
||||
|
||||
await waitFor(() => expect(result.connections()).toHaveLength(1));
|
||||
|
||||
result.openEditDialog(result.connections()[0]);
|
||||
result.updateForm({ host: 'edited.lab.local', port: '8443', insecureSkipVerify: true });
|
||||
await result.testCurrentForm();
|
||||
|
||||
expect(VMwareAPI.testSavedConnection).toHaveBeenCalledWith(
|
||||
'conn-1',
|
||||
expect.objectContaining({
|
||||
host: 'edited.lab.local',
|
||||
port: 8443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: true,
|
||||
}),
|
||||
);
|
||||
expect(VMwareAPI.testConnection).not.toHaveBeenCalled();
|
||||
expect(notificationStore.success).toHaveBeenCalledWith('VMware connection successful');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export type PlatformConnectionsView = 'proxmox' | 'truenas';
|
||||
export type PlatformConnectionsView = 'proxmox' | 'truenas' | 'vmware';
|
||||
|
||||
export interface PlatformConnectionsTabDefinition {
|
||||
id: PlatformConnectionsView;
|
||||
|
|
@ -9,9 +9,11 @@ export interface PlatformConnectionsTabDefinition {
|
|||
export const PLATFORM_CONNECTIONS_PREFIX = '/settings/infrastructure/platforms';
|
||||
const PLATFORM_CONNECTIONS_PROXMOX_PREFIX = `${PLATFORM_CONNECTIONS_PREFIX}/proxmox`;
|
||||
const PLATFORM_CONNECTIONS_TRUENAS_PREFIX = `${PLATFORM_CONNECTIONS_PREFIX}/truenas`;
|
||||
const PLATFORM_CONNECTIONS_VMWARE_PREFIX = `${PLATFORM_CONNECTIONS_PREFIX}/vmware`;
|
||||
const LEGACY_PROXMOX_PREFIX = '/settings/infrastructure/proxmox';
|
||||
const LEGACY_PROXMOX_API_PREFIX = '/settings/infrastructure/api';
|
||||
const LEGACY_TRUENAS_PREFIX = '/settings/infrastructure/truenas';
|
||||
const LEGACY_VMWARE_PREFIX = '/settings/infrastructure/vmware';
|
||||
|
||||
export const PLATFORM_CONNECTIONS_TABS: readonly PlatformConnectionsTabDefinition[] = [
|
||||
{
|
||||
|
|
@ -24,12 +26,23 @@ export const PLATFORM_CONNECTIONS_TABS: readonly PlatformConnectionsTabDefinitio
|
|||
label: 'TrueNAS',
|
||||
path: PLATFORM_CONNECTIONS_TRUENAS_PREFIX,
|
||||
},
|
||||
{
|
||||
id: 'vmware',
|
||||
label: 'VMware',
|
||||
path: PLATFORM_CONNECTIONS_VMWARE_PREFIX,
|
||||
},
|
||||
];
|
||||
|
||||
export function getPlatformConnectionsViewFromPath(pathname: string): PlatformConnectionsView {
|
||||
if (pathname.startsWith(PLATFORM_CONNECTIONS_VMWARE_PREFIX)) {
|
||||
return 'vmware';
|
||||
}
|
||||
if (pathname.startsWith(PLATFORM_CONNECTIONS_TRUENAS_PREFIX)) {
|
||||
return 'truenas';
|
||||
}
|
||||
if (pathname.startsWith(LEGACY_VMWARE_PREFIX)) {
|
||||
return 'vmware';
|
||||
}
|
||||
if (pathname.startsWith(LEGACY_TRUENAS_PREFIX)) {
|
||||
return 'truenas';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
NodeType,
|
||||
} from './infrastructureSettingsModel';
|
||||
import type { TrueNASSettingsPanelState } from './useTrueNASSettingsPanelState';
|
||||
import type { VMwareSettingsPanelState } from './useVMwareSettingsPanelState';
|
||||
|
||||
export type DiscoveryMode = 'auto' | 'custom';
|
||||
|
||||
|
|
@ -18,6 +19,8 @@ export interface PlatformConnectionsSummary {
|
|||
pmgCount: number;
|
||||
truenasCount: number;
|
||||
truenasAvailable: boolean;
|
||||
vmwareCount: number;
|
||||
vmwareAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface InfrastructurePlatformSettingsProps {
|
||||
|
|
@ -37,6 +40,7 @@ export interface InfrastructurePlatformSettingsProps {
|
|||
pbsNodes: Accessor<NodeConfigWithStatus[]>;
|
||||
pmgNodes: Accessor<NodeConfigWithStatus[]>;
|
||||
trueNASSettings: TrueNASSettingsPanelState;
|
||||
vmwareSettings: VMwareSettingsPanelState;
|
||||
platformConnectionsSummary: Accessor<PlatformConnectionsSummary>;
|
||||
temperatureMonitoringEnabled: Accessor<boolean>;
|
||||
triggerDiscoveryScan: (options?: { quiet?: boolean }) => Promise<void>;
|
||||
|
|
@ -57,6 +61,10 @@ export interface InfrastructurePlatformSettingsProps {
|
|||
temperatureMonitoringLocked: Accessor<boolean>;
|
||||
savingTemperatureSetting: Accessor<boolean>;
|
||||
handleTemperatureMonitoringChange: (enabled: boolean) => Promise<void>;
|
||||
disableDockerUpdateActions: Accessor<boolean>;
|
||||
disableDockerUpdateActionsLocked: Accessor<boolean>;
|
||||
savingDockerUpdateActions: Accessor<boolean>;
|
||||
handleDisableDockerUpdateActionsChange: (enabled: boolean) => Promise<void>;
|
||||
handleNodeTemperatureMonitoringChange: (nodeId: string, enabled: boolean | null) => Promise<void>;
|
||||
saveNode: (nodeData: Partial<NodeConfig>) => Promise<void>;
|
||||
showDeleteNodeModal: Accessor<boolean>;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { SettingsTab } from './settingsTypes';
|
|||
import { useInfrastructureConfiguredNodesState } from './useInfrastructureConfiguredNodesState';
|
||||
import { useInfrastructureDiscoveryRuntimeState } from './useInfrastructureDiscoveryRuntimeState';
|
||||
import { useTrueNASSettingsPanelState } from './useTrueNASSettingsPanelState';
|
||||
import { useVMwareSettingsPanelState } from './useVMwareSettingsPanelState';
|
||||
|
||||
export type {
|
||||
DiscoveryScanStatus,
|
||||
|
|
@ -77,6 +78,7 @@ export function useInfrastructureSettingsState({
|
|||
setSavingTemperatureSetting,
|
||||
});
|
||||
const trueNASSettings = useTrueNASSettingsPanelState();
|
||||
const vmwareSettings = useVMwareSettingsPanelState();
|
||||
|
||||
const discoveryRuntime = useInfrastructureDiscoveryRuntimeState({
|
||||
eventBus,
|
||||
|
|
@ -156,6 +158,7 @@ export function useInfrastructureSettingsState({
|
|||
return {
|
||||
initialLoadComplete,
|
||||
trueNASSettings,
|
||||
vmwareSettings,
|
||||
...configuredNodes,
|
||||
...discoveryRuntime,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,13 +25,15 @@ export function useSettingsInfrastructurePanelProps(
|
|||
params.resources().filter((resource) => resource.type === 'agent'),
|
||||
);
|
||||
const pbsInstances = createMemo(() =>
|
||||
params.resources()
|
||||
params
|
||||
.resources()
|
||||
.filter((resource) => resource.type === 'pbs')
|
||||
.map(pbsInstanceFromResource)
|
||||
.filter((instance): instance is NonNullable<typeof instance> => Boolean(instance)),
|
||||
);
|
||||
const pmgInstances = createMemo(() =>
|
||||
params.resources()
|
||||
params
|
||||
.resources()
|
||||
.filter((resource) => resource.type === 'pmg')
|
||||
.map(pmgInstanceFromResource)
|
||||
.filter((instance): instance is NonNullable<typeof instance> => Boolean(instance)),
|
||||
|
|
@ -43,6 +45,8 @@ export function useSettingsInfrastructurePanelProps(
|
|||
pmgCount: params.infrastructureSettings.pmgNodes().length,
|
||||
truenasCount: params.infrastructureSettings.trueNASSettings.connections().length,
|
||||
truenasAvailable: !params.infrastructureSettings.trueNASSettings.featureDisabled(),
|
||||
vmwareCount: params.infrastructureSettings.vmwareSettings.connections().length,
|
||||
vmwareAvailable: !params.infrastructureSettings.vmwareSettings.featureDisabled(),
|
||||
}));
|
||||
|
||||
const getInfrastructurePanelProps = (): InfrastructurePlatformSettingsProps => ({
|
||||
|
|
@ -62,6 +66,7 @@ export function useSettingsInfrastructurePanelProps(
|
|||
pbsNodes: params.infrastructureSettings.pbsNodes,
|
||||
pmgNodes: params.infrastructureSettings.pmgNodes,
|
||||
trueNASSettings: params.infrastructureSettings.trueNASSettings,
|
||||
vmwareSettings: params.infrastructureSettings.vmwareSettings,
|
||||
platformConnectionsSummary,
|
||||
temperatureMonitoringEnabled: params.systemSettings.temperatureMonitoringEnabled,
|
||||
triggerDiscoveryScan: params.infrastructureSettings.triggerDiscoveryScan,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,290 @@
|
|||
import { createMemo, createSignal, onMount } from 'solid-js';
|
||||
import {
|
||||
VMwareAPI,
|
||||
isRedactedVMwareSecret,
|
||||
type VMwareConnection,
|
||||
type VMwareConnectionInput,
|
||||
} from '@/api/vmware';
|
||||
import { apiErrorStatus } from '@/api/responseUtils';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
export interface VMwareConnectionFormState {
|
||||
name: string;
|
||||
host: string;
|
||||
port: string;
|
||||
username: string;
|
||||
password: string;
|
||||
insecureSkipVerify: boolean;
|
||||
enabled: boolean;
|
||||
hasStoredPassword: boolean;
|
||||
}
|
||||
|
||||
const REDACTED_SECRET = '********';
|
||||
|
||||
const createEmptyFormState = (): VMwareConnectionFormState => ({
|
||||
name: '',
|
||||
host: '',
|
||||
port: '443',
|
||||
username: '',
|
||||
password: '',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
hasStoredPassword: false,
|
||||
});
|
||||
|
||||
const buildFormStateFromConnection = (connection: VMwareConnection): VMwareConnectionFormState => ({
|
||||
name: connection.name || '',
|
||||
host: connection.host || '',
|
||||
port: connection.port ? String(connection.port) : '443',
|
||||
username: connection.username || '',
|
||||
password: '',
|
||||
insecureSkipVerify: connection.insecureSkipVerify,
|
||||
enabled: connection.enabled,
|
||||
hasStoredPassword: isRedactedVMwareSecret(connection.password),
|
||||
});
|
||||
|
||||
const parseOptionalPort = (value: string): number | undefined => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error('Port must be a whole number between 1 and 65535');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'message' in error &&
|
||||
typeof (error as { message?: unknown }).message === 'string'
|
||||
) {
|
||||
const message = (error as { message: string }).message.trim();
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const buildConnectionInput = (form: VMwareConnectionFormState): VMwareConnectionInput => {
|
||||
const port = parseOptionalPort(form.port);
|
||||
const name = form.name.trim();
|
||||
const host = form.host.trim();
|
||||
const username = form.username.trim();
|
||||
|
||||
return {
|
||||
...(name ? { name } : {}),
|
||||
host,
|
||||
...(port !== undefined ? { port } : {}),
|
||||
username,
|
||||
password: form.password.trim() || (form.hasStoredPassword ? REDACTED_SECRET : ''),
|
||||
insecureSkipVerify: form.insecureSkipVerify,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
};
|
||||
|
||||
export function useVMwareSettingsPanelState() {
|
||||
const [connections, setConnections] = createSignal<VMwareConnection[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [loadingError, setLoadingError] = createSignal<string | null>(null);
|
||||
const [featureDisabled, setFeatureDisabled] = createSignal(false);
|
||||
const [featureDisabledMessage, setFeatureDisabledMessage] = createSignal('');
|
||||
const [dialogOpen, setDialogOpen] = createSignal(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = createSignal(false);
|
||||
const [editingConnectionId, setEditingConnectionId] = createSignal<string | null>(null);
|
||||
const [pendingDeleteConnection, setPendingDeleteConnection] =
|
||||
createSignal<VMwareConnection | null>(null);
|
||||
const [form, setForm] = createSignal<VMwareConnectionFormState>(createEmptyFormState());
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [testing, setTesting] = createSignal(false);
|
||||
const [deleting, setDeleting] = createSignal(false);
|
||||
|
||||
const editingConnection = createMemo(
|
||||
() => connections().find((connection) => connection.id === editingConnectionId()) ?? null,
|
||||
);
|
||||
|
||||
const loadConnections = async () => {
|
||||
setLoading(true);
|
||||
setLoadingError(null);
|
||||
try {
|
||||
const nextConnections = await VMwareAPI.listConnections();
|
||||
setConnections(nextConnections);
|
||||
setFeatureDisabled(false);
|
||||
setFeatureDisabledMessage('');
|
||||
} catch (error) {
|
||||
if (apiErrorStatus(error) === 404) {
|
||||
setFeatureDisabled(true);
|
||||
setFeatureDisabledMessage(
|
||||
getErrorMessage(error, 'VMware integration has been explicitly disabled'),
|
||||
);
|
||||
setConnections([]);
|
||||
return;
|
||||
}
|
||||
const message = getErrorMessage(error, 'Failed to load VMware connections');
|
||||
setLoadingError(message);
|
||||
logger.error('[VMware Settings] Failed to load connections', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void loadConnections();
|
||||
});
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingConnectionId(null);
|
||||
setForm(createEmptyFormState());
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (connection: VMwareConnection) => {
|
||||
setEditingConnectionId(connection.id);
|
||||
setForm(buildFormStateFromConnection(connection));
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const resetDialogState = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingConnectionId(null);
|
||||
setForm(createEmptyFormState());
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
if (saving() || testing()) return;
|
||||
resetDialogState();
|
||||
};
|
||||
|
||||
const openDeleteDialog = (connection: VMwareConnection) => {
|
||||
setPendingDeleteConnection(connection);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const resetDeleteDialogState = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setPendingDeleteConnection(null);
|
||||
};
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
if (deleting()) return;
|
||||
resetDeleteDialogState();
|
||||
};
|
||||
|
||||
const updateForm = (patch: Partial<VMwareConnectionFormState>) =>
|
||||
setForm((current) => ({ ...current, ...patch }));
|
||||
|
||||
const testCurrentForm = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const payload = buildConnectionInput(form());
|
||||
if (editingConnectionId()) {
|
||||
await VMwareAPI.testSavedConnection(editingConnectionId()!, payload);
|
||||
} else {
|
||||
await VMwareAPI.testConnection(payload);
|
||||
}
|
||||
notificationStore.success('VMware connection successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'VMware connection failed');
|
||||
notificationStore.error(message);
|
||||
logger.error('[VMware Settings] Connection test failed', error);
|
||||
return false;
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testSavedConnection = async (connection: VMwareConnection) => {
|
||||
setTesting(true);
|
||||
try {
|
||||
await VMwareAPI.testSavedConnection(connection.id);
|
||||
notificationStore.success(
|
||||
`VMware connection successful for ${connection.name || connection.host}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'VMware connection failed');
|
||||
notificationStore.error(message);
|
||||
logger.error('[VMware Settings] Saved connection test failed', error);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
await loadConnections();
|
||||
}
|
||||
};
|
||||
|
||||
const saveCurrentForm = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = buildConnectionInput(form());
|
||||
if (editingConnectionId()) {
|
||||
await VMwareAPI.updateConnection(editingConnectionId()!, payload);
|
||||
notificationStore.success('VMware connection updated');
|
||||
} else {
|
||||
await VMwareAPI.createConnection(payload);
|
||||
notificationStore.success('VMware connection added');
|
||||
}
|
||||
resetDialogState();
|
||||
await loadConnections();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Failed to save VMware connection');
|
||||
notificationStore.error(message);
|
||||
logger.error('[VMware Settings] Save failed', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePendingConnection = async () => {
|
||||
const connection = pendingDeleteConnection();
|
||||
if (!connection) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await VMwareAPI.deleteConnection(connection.id);
|
||||
notificationStore.success(`Removed ${connection.name || connection.host}`);
|
||||
resetDeleteDialogState();
|
||||
await loadConnections();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Failed to remove VMware connection');
|
||||
notificationStore.error(message);
|
||||
logger.error('[VMware Settings] Delete failed', error);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
closeDeleteDialog,
|
||||
closeDialog,
|
||||
connections,
|
||||
deleteDialogOpen,
|
||||
deletePendingConnection,
|
||||
deleting,
|
||||
dialogOpen,
|
||||
editingConnection,
|
||||
featureDisabled,
|
||||
featureDisabledMessage,
|
||||
form,
|
||||
loadConnections,
|
||||
loading,
|
||||
loadingError,
|
||||
openCreateDialog,
|
||||
openDeleteDialog,
|
||||
openEditDialog,
|
||||
pendingDeleteConnection,
|
||||
saveCurrentForm,
|
||||
saving,
|
||||
testCurrentForm,
|
||||
testSavedConnection,
|
||||
testing,
|
||||
updateForm,
|
||||
};
|
||||
}
|
||||
|
||||
export type VMwareSettingsPanelState = ReturnType<typeof useVMwareSettingsPanelState>;
|
||||
|
||||
export default useVMwareSettingsPanelState;
|
||||
|
|
@ -32,6 +32,7 @@ import (
|
|||
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
|
||||
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth"
|
||||
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
||||
|
|
@ -137,6 +138,62 @@ func TestContract_TrueNASSavedConnectionTestsUpdateRuntimeSummary(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestContract_VMwareConnectionsDisabledMessageIsExplicit(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, false)
|
||||
handler, _ := newVMwareHandlersForTest(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/vmware/connections", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 when VMware integration is disabled, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "explicitly disabled") {
|
||||
t.Fatalf("expected explicit disable message, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_VMwareSavedConnectionTestsUpdateRuntimeSummary(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, true)
|
||||
|
||||
connection := config.VMwareVCenterInstance{
|
||||
ID: "conn-1",
|
||||
Name: "lab-vcenter",
|
||||
Host: "vcsa.lab.local",
|
||||
Port: 443,
|
||||
Username: "administrator@vsphere.local",
|
||||
Password: "super-secret",
|
||||
Enabled: true,
|
||||
}
|
||||
handler, persistence := newVMwareHandlersForTest(t)
|
||||
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{connection}); err != nil {
|
||||
t.Fatalf("seed vmware config: %v", err)
|
||||
}
|
||||
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
|
||||
return &fakeVMwareClient{
|
||||
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
|
||||
return &vmware.InventorySummary{Hosts: 3, VMs: 20, Datastores: 4, VIRelease: "8.0.3"}, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/conn-1/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleTestSavedConnection(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
status := handler.runtimeStatus(connection.ID)
|
||||
if status.Test == nil || status.Test.LastSuccessAt == nil {
|
||||
t.Fatalf("expected saved manual test to refresh runtime summary, got %+v", status.Test)
|
||||
}
|
||||
if status.Observed == nil || status.Observed.VMs != 20 {
|
||||
t.Fatalf("expected saved manual test to refresh observed summary, got %+v", status.Observed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_SSOTestRejectsMetadataURLWithUserinfo(t *testing.T) {
|
||||
called := make(chan struct{}, 1)
|
||||
metadataServer := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -265,6 +265,9 @@ var bareRouteAllowlist = []string{
|
|||
"/api/truenas/connections",
|
||||
"/api/truenas/connections/test",
|
||||
"/api/truenas/connections/",
|
||||
"/api/vmware/connections",
|
||||
"/api/vmware/connections/test",
|
||||
"/api/vmware/connections/",
|
||||
"/api/health",
|
||||
"/api/login",
|
||||
"/api/logout",
|
||||
|
|
@ -390,8 +393,13 @@ var allRouteAllowlist = []string{
|
|||
"/api/truenas/connections",
|
||||
"/api/truenas/connections/test",
|
||||
"/api/truenas/connections/",
|
||||
"/api/vmware/connections",
|
||||
"/api/vmware/connections/test",
|
||||
"/api/vmware/connections/",
|
||||
"/api/admin/profiles/",
|
||||
"/api/config/system",
|
||||
"/api/system/settings/telemetry-preview",
|
||||
"/api/system/settings/telemetry-reset-id",
|
||||
"/api/system/mock-mode",
|
||||
"/api/license/status",
|
||||
"/api/webhooks/stripe",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import (
|
|||
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
|
|
@ -70,6 +71,7 @@ type Router struct {
|
|||
alertHandlers *AlertHandlers
|
||||
configHandlers *ConfigHandlers
|
||||
trueNASHandlers *TrueNASHandlers
|
||||
vmwareHandlers *VMwareHandlers
|
||||
notificationHandlers *NotificationHandlers
|
||||
notificationQueueHandlers *NotificationQueueHandlers
|
||||
dockerAgentHandlers *DockerAgentHandlers
|
||||
|
|
@ -350,6 +352,9 @@ func (r *Router) setupRoutes() {
|
|||
getMonitor: r.configHandlers.getMonitor,
|
||||
getPoller: func(context.Context) *monitoring.TrueNASPoller { return r.trueNASPoller },
|
||||
}
|
||||
r.vmwareHandlers = &VMwareHandlers{
|
||||
getPersistence: r.configHandlers.getPersistence,
|
||||
}
|
||||
recoveryManager := recoverymanager.New(r.multiTenant)
|
||||
r.recoveryHandlers = NewRecoveryHandlers(recoveryManager)
|
||||
if r.mtMonitor != nil {
|
||||
|
|
@ -373,6 +378,7 @@ func (r *Router) setupRoutes() {
|
|||
}
|
||||
if mock.IsMockEnabled() {
|
||||
truenas.SetFeatureEnabled(true)
|
||||
vmware.SetFeatureEnabled(true)
|
||||
mockTrueNASProvider := truenas.NewDefaultProvider()
|
||||
adapter := trueNASRecordsAdapter{provider: mockTrueNASProvider}
|
||||
r.resourceHandlers.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, adapter)
|
||||
|
|
|
|||
|
|
@ -223,6 +223,53 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
|
|||
}
|
||||
})
|
||||
|
||||
// VMware vCenter connection management
|
||||
r.mux.HandleFunc("/api/vmware/connections", func(w http.ResponseWriter, req *http.Request) {
|
||||
if r.vmwareHandlers == nil {
|
||||
writeErrorResponse(w, http.StatusServiceUnavailable, "vmware_unavailable", "VMware service unavailable", nil)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.vmwareHandlers.HandleList))(w, req)
|
||||
case http.MethodPost:
|
||||
RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleAdd))(w, req)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
r.mux.HandleFunc("/api/vmware/connections/test", func(w http.ResponseWriter, req *http.Request) {
|
||||
if r.vmwareHandlers == nil {
|
||||
writeErrorResponse(w, http.StatusServiceUnavailable, "vmware_unavailable", "VMware service unavailable", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == http.MethodPost {
|
||||
RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleTestConnection))(w, req)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
r.mux.HandleFunc("/api/vmware/connections/", func(w http.ResponseWriter, req *http.Request) {
|
||||
if r.vmwareHandlers == nil {
|
||||
writeErrorResponse(w, http.StatusServiceUnavailable, "vmware_unavailable", "VMware service unavailable", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == http.MethodPost && strings.HasSuffix(strings.Trim(req.URL.Path, "/"), "/test") {
|
||||
RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleTestSavedConnection))(w, req)
|
||||
} else if req.Method == http.MethodDelete {
|
||||
RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleDelete))(w, req)
|
||||
} else if req.Method == http.MethodPut {
|
||||
RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleUpdate))(w, req)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
|
||||
// Config Profile Routes - Protected by Admin Auth, Settings Scope, and Pro License
|
||||
// SECURITY: Require settings:write scope to prevent low-privilege tokens from modifying agent profiles
|
||||
// r.configProfileHandler.ServeHTTP implements http.Handler, so we wrap it
|
||||
|
|
|
|||
|
|
@ -1756,6 +1756,38 @@ func TestTrueNASConnectionMutationsRequireSettingsWriteScope(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVMwareConnectionMutationsRequireSettingsWriteScope(t *testing.T) {
|
||||
rawToken := "vmware-mutate-token-123.12345678"
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||
|
||||
paths := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{method: http.MethodPost, path: "/api/vmware/connections", body: `{}`},
|
||||
{method: http.MethodPost, path: "/api/vmware/connections/test", body: `{}`},
|
||||
{method: http.MethodPut, path: "/api/vmware/connections/conn-1", body: `{}`},
|
||||
{method: http.MethodDelete, path: "/api/vmware/connections/conn-1", body: ""},
|
||||
{method: http.MethodPost, path: "/api/vmware/connections/conn-1/test", body: ""},
|
||||
}
|
||||
|
||||
for _, tc := range paths {
|
||||
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
||||
req.Header.Set("X-API-Token", rawToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for missing settings:write scope on %s %s, got %d", tc.method, tc.path, rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
|
||||
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigExportRequiresSettingsReadScope(t *testing.T) {
|
||||
rawToken := "config-export-token-123.12345678"
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
|
||||
|
|
|
|||
643
internal/api/vmware_handlers.go
Normal file
643
internal/api/vmware_handlers.go
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
|
||||
)
|
||||
|
||||
const vmwareConnectionsPathPrefix = "/api/vmware/connections/"
|
||||
|
||||
// VMwareHandlers manages VMware vCenter connection CRUD and connectivity checks.
|
||||
type VMwareHandlers struct {
|
||||
getPersistence func(ctx context.Context) *config.ConfigPersistence
|
||||
newClient func(vmware.ClientConfig) (vmwareClient, error)
|
||||
|
||||
statusMu sync.RWMutex
|
||||
statuses map[string]vmwareConnectionRuntimeStatus
|
||||
}
|
||||
|
||||
type vmwareClient interface {
|
||||
TestConnection(ctx context.Context) (*vmware.InventorySummary, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
type vmwareConnectionResponse struct {
|
||||
config.VMwareVCenterInstance
|
||||
Test *vmwareConnectionTestStatus `json:"test,omitempty"`
|
||||
Observed *vmwareConnectionObservedStatus `json:"observed,omitempty"`
|
||||
}
|
||||
|
||||
type vmwareConnectionTestStatus struct {
|
||||
LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"`
|
||||
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
||||
LastError *vmwareConnectionTestError `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
type vmwareConnectionTestError struct {
|
||||
At *time.Time `json:"at,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
}
|
||||
|
||||
type vmwareConnectionObservedStatus struct {
|
||||
CollectedAt *time.Time `json:"collectedAt,omitempty"`
|
||||
Hosts int `json:"hosts"`
|
||||
VMs int `json:"vms"`
|
||||
Datastores int `json:"datastores"`
|
||||
VIRelease string `json:"viRelease,omitempty"`
|
||||
}
|
||||
|
||||
type vmwareConnectionRuntimeStatus struct {
|
||||
Test *vmwareConnectionTestStatus
|
||||
Observed *vmwareConnectionObservedStatus
|
||||
}
|
||||
|
||||
// HandleAdd stores a new VMware vCenter connection.
|
||||
func (h *VMwareHandlers) HandleAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.featureEnabled(w) {
|
||||
return
|
||||
}
|
||||
if mock.IsMockEnabled() {
|
||||
writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
||||
defer r.Body.Close()
|
||||
|
||||
var instance config.VMwareVCenterInstance
|
||||
if err := json.NewDecoder(r.Body).Decode(&instance); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
instance.ID = strings.TrimSpace(instance.ID)
|
||||
if instance.ID == "" {
|
||||
instance.ID = config.NewVMwareVCenterInstance().ID
|
||||
}
|
||||
normalizeVMwareInstance(&instance)
|
||||
|
||||
if err := instance.Validate(); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
persistence := h.persistenceForRequest(w, r.Context())
|
||||
if persistence == nil {
|
||||
return
|
||||
}
|
||||
|
||||
instances, err := persistence.LoadVMwareConfig()
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
instances = append(instances, instance)
|
||||
if err := persistence.SaveVMwareConfig(instances); err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, instance.Redacted())
|
||||
}
|
||||
|
||||
// HandleList returns all configured VMware connections with sensitive fields redacted.
|
||||
func (h *VMwareHandlers) HandleList(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.featureEnabled(w) {
|
||||
return
|
||||
}
|
||||
|
||||
persistence := h.persistenceForRequest(w, r.Context())
|
||||
if persistence == nil {
|
||||
return
|
||||
}
|
||||
|
||||
instances, err := persistence.LoadVMwareConfig()
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]vmwareConnectionResponse, 0, len(instances))
|
||||
for i := range instances {
|
||||
item := instances[i]
|
||||
item.ApplyDefaults()
|
||||
status := h.runtimeStatus(strings.TrimSpace(item.ID))
|
||||
responses = append(responses, vmwareConnectionResponse{
|
||||
VMwareVCenterInstance: item.Redacted(),
|
||||
Test: status.Test,
|
||||
Observed: status.Observed,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
// HandleDelete removes a configured VMware vCenter connection by ID.
|
||||
func (h *VMwareHandlers) HandleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.featureEnabled(w) {
|
||||
return
|
||||
}
|
||||
if mock.IsMockEnabled() {
|
||||
writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil)
|
||||
return
|
||||
}
|
||||
|
||||
connectionID, ok := vmwareConnectionIDFromPath(r.URL.Path)
|
||||
if !ok {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
persistence := h.persistenceForRequest(w, r.Context())
|
||||
if persistence == nil {
|
||||
return
|
||||
}
|
||||
|
||||
instances, err := persistence.LoadVMwareConfig()
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i := range instances {
|
||||
if strings.TrimSpace(instances[i].ID) == connectionID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
instances = append(instances[:index], instances[index+1:]...)
|
||||
if err := persistence.SaveVMwareConfig(instances); err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.clearRuntimeStatus(connectionID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"id": connectionID,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleUpdate replaces a configured VMware vCenter connection by ID while
|
||||
// preserving unchanged masked secrets from the stored record.
|
||||
func (h *VMwareHandlers) HandleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.featureEnabled(w) {
|
||||
return
|
||||
}
|
||||
if mock.IsMockEnabled() {
|
||||
writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil)
|
||||
return
|
||||
}
|
||||
|
||||
connectionID, ok := vmwareConnectionIDFromPath(r.URL.Path)
|
||||
if !ok {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
||||
defer r.Body.Close()
|
||||
|
||||
var instance config.VMwareVCenterInstance
|
||||
if err := json.NewDecoder(r.Body).Decode(&instance); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
instance.ID = connectionID
|
||||
normalizeVMwareInstance(&instance)
|
||||
|
||||
persistence := h.persistenceForRequest(w, r.Context())
|
||||
if persistence == nil {
|
||||
return
|
||||
}
|
||||
|
||||
instances, err := persistence.LoadVMwareConfig()
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i := range instances {
|
||||
if strings.TrimSpace(instances[i].ID) == connectionID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
instance.PreserveMaskedSecrets(instances[index])
|
||||
if err := instance.Validate(); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
instances[index] = instance
|
||||
if err := persistence.SaveVMwareConfig(instances); err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, instance.Redacted())
|
||||
}
|
||||
|
||||
// HandleTestConnection validates connectivity for a proposed VMware vCenter connection.
|
||||
func (h *VMwareHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.featureEnabled(w) {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
||||
defer r.Body.Close()
|
||||
|
||||
var instance config.VMwareVCenterInstance
|
||||
if err := json.NewDecoder(r.Body).Decode(&instance); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
normalizeVMwareInstance(&instance)
|
||||
if err := instance.Validate(); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeConnectionTestResult(w, r, instance)
|
||||
}
|
||||
|
||||
// HandleTestSavedConnection validates connectivity for one saved VMware
|
||||
// connection using the server-owned stored secret material instead of relying
|
||||
// on frontend redaction placeholders.
|
||||
func (h *VMwareHandlers) HandleTestSavedConnection(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.featureEnabled(w) {
|
||||
return
|
||||
}
|
||||
|
||||
connectionID, ok := vmwareConnectionIDFromTestPath(r.URL.Path)
|
||||
if !ok {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
persistence := h.persistenceForRequest(w, r.Context())
|
||||
if persistence == nil {
|
||||
return
|
||||
}
|
||||
|
||||
instances, err := persistence.LoadVMwareConfig()
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for i := range instances {
|
||||
instance := instances[i]
|
||||
if strings.TrimSpace(instance.ID) != connectionID {
|
||||
continue
|
||||
}
|
||||
normalizeVMwareInstance(&instance)
|
||||
payload, hasPayload, ok := decodeOptionalVMwareInstanceRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if hasPayload {
|
||||
payload.ID = connectionID
|
||||
normalizeVMwareInstance(&payload)
|
||||
payload.PreserveMaskedSecrets(instance)
|
||||
if err := payload.Validate(); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
instance = payload
|
||||
}
|
||||
|
||||
summary, invalidConfig, err := h.testConnectionInstance(r, instance)
|
||||
if err != nil {
|
||||
if !hasPayload {
|
||||
h.recordTestFailure(connectionID, err, time.Now().UTC())
|
||||
}
|
||||
h.writeConnectionFailure(w, invalidConfig, err)
|
||||
return
|
||||
}
|
||||
if !hasPayload {
|
||||
h.recordTestSuccess(connectionID, summary, time.Now().UTC())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
|
||||
writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil)
|
||||
}
|
||||
|
||||
func (h *VMwareHandlers) writeConnectionTestResult(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
instance config.VMwareVCenterInstance,
|
||||
) {
|
||||
_, invalidConfig, err := h.testConnectionInstance(r, instance)
|
||||
if err != nil {
|
||||
h.writeConnectionFailure(w, invalidConfig, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
func (h *VMwareHandlers) writeConnectionFailure(w http.ResponseWriter, invalidConfig bool, err error) {
|
||||
if invalidConfig {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "vmware_invalid_config", "Invalid VMware vCenter connection configuration", connectionFailureDetails(err))
|
||||
return
|
||||
}
|
||||
writeErrorResponse(w, http.StatusBadRequest, "vmware_connection_failed", "Failed to connect to VMware vCenter", connectionFailureDetails(err))
|
||||
}
|
||||
|
||||
func connectionFailureDetails(err error) map[string]string {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
details := map[string]string{"error": err.Error()}
|
||||
if category := connectionErrorCategory(err); category != "" {
|
||||
details["category"] = category
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func connectionErrorCategory(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if connectionErr, ok := err.(*vmware.ConnectionError); ok {
|
||||
return strings.TrimSpace(connectionErr.Category)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *VMwareHandlers) testConnectionInstance(
|
||||
r *http.Request,
|
||||
instance config.VMwareVCenterInstance,
|
||||
) (*vmware.InventorySummary, bool, error) {
|
||||
newClient := h.newClient
|
||||
if newClient == nil {
|
||||
newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { return vmware.NewClient(cfg) }
|
||||
}
|
||||
|
||||
client, err := newClient(vmware.ClientConfig{
|
||||
Host: instance.Host,
|
||||
Port: instance.Port,
|
||||
Username: instance.Username,
|
||||
Password: instance.Password,
|
||||
InsecureSkipVerify: instance.InsecureSkipVerify,
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
summary, err := client.TestConnection(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return summary, false, nil
|
||||
}
|
||||
|
||||
func normalizeVMwareInstance(instance *config.VMwareVCenterInstance) {
|
||||
if instance == nil {
|
||||
return
|
||||
}
|
||||
instance.Name = strings.TrimSpace(instance.Name)
|
||||
instance.Host = strings.TrimSpace(instance.Host)
|
||||
instance.Username = strings.TrimSpace(instance.Username)
|
||||
instance.Password = strings.TrimSpace(instance.Password)
|
||||
instance.ApplyDefaults()
|
||||
}
|
||||
|
||||
func decodeOptionalVMwareInstanceRequest(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) (config.VMwareVCenterInstance, bool, bool) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
||||
defer r.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
|
||||
return config.VMwareVCenterInstance{}, false, false
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return config.VMwareVCenterInstance{}, false, true
|
||||
}
|
||||
|
||||
var instance config.VMwareVCenterInstance
|
||||
if err := json.Unmarshal(body, &instance); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
|
||||
return config.VMwareVCenterInstance{}, false, false
|
||||
}
|
||||
|
||||
return instance, true, true
|
||||
}
|
||||
|
||||
func (h *VMwareHandlers) featureEnabled(w http.ResponseWriter) bool {
|
||||
if vmware.IsFeatureEnabled() {
|
||||
return true
|
||||
}
|
||||
writeErrorResponse(w, http.StatusNotFound, "vmware_disabled", "VMware integration has been explicitly disabled", nil)
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *VMwareHandlers) persistenceForRequest(w http.ResponseWriter, ctx context.Context) *config.ConfigPersistence {
|
||||
if h == nil || h.getPersistence == nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "vmware_unavailable", "VMware service unavailable", nil)
|
||||
return nil
|
||||
}
|
||||
persistence := h.getPersistence(ctx)
|
||||
if persistence == nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "vmware_unavailable", "VMware service unavailable", nil)
|
||||
return nil
|
||||
}
|
||||
return persistence
|
||||
}
|
||||
|
||||
func (h *VMwareHandlers) runtimeStatus(connectionID string) vmwareConnectionRuntimeStatus {
|
||||
if h == nil {
|
||||
return vmwareConnectionRuntimeStatus{}
|
||||
}
|
||||
h.statusMu.RLock()
|
||||
defer h.statusMu.RUnlock()
|
||||
if h.statuses == nil {
|
||||
return vmwareConnectionRuntimeStatus{}
|
||||
}
|
||||
status, ok := h.statuses[connectionID]
|
||||
if !ok {
|
||||
return vmwareConnectionRuntimeStatus{}
|
||||
}
|
||||
return cloneVMwareRuntimeStatus(status)
|
||||
}
|
||||
|
||||
func (h *VMwareHandlers) recordTestSuccess(connectionID string, summary *vmware.InventorySummary, at time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.statusMu.Lock()
|
||||
defer h.statusMu.Unlock()
|
||||
if h.statuses == nil {
|
||||
h.statuses = make(map[string]vmwareConnectionRuntimeStatus)
|
||||
}
|
||||
current := h.statuses[connectionID]
|
||||
current.Test = &vmwareConnectionTestStatus{
|
||||
LastAttemptAt: timePointer(at),
|
||||
LastSuccessAt: timePointer(at),
|
||||
}
|
||||
if summary != nil {
|
||||
current.Observed = &vmwareConnectionObservedStatus{
|
||||
CollectedAt: timePointer(at),
|
||||
Hosts: summary.Hosts,
|
||||
VMs: summary.VMs,
|
||||
Datastores: summary.Datastores,
|
||||
VIRelease: strings.TrimSpace(summary.VIRelease),
|
||||
}
|
||||
}
|
||||
h.statuses[connectionID] = current
|
||||
}
|
||||
|
||||
func (h *VMwareHandlers) recordTestFailure(connectionID string, err error, at time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.statusMu.Lock()
|
||||
defer h.statusMu.Unlock()
|
||||
if h.statuses == nil {
|
||||
h.statuses = make(map[string]vmwareConnectionRuntimeStatus)
|
||||
}
|
||||
current := h.statuses[connectionID]
|
||||
test := current.Test
|
||||
if test == nil {
|
||||
test = &vmwareConnectionTestStatus{}
|
||||
}
|
||||
test.LastAttemptAt = timePointer(at)
|
||||
test.LastError = &vmwareConnectionTestError{
|
||||
At: timePointer(at),
|
||||
Message: err.Error(),
|
||||
Category: connectionErrorCategory(err),
|
||||
}
|
||||
current.Test = test
|
||||
h.statuses[connectionID] = current
|
||||
}
|
||||
|
||||
func (h *VMwareHandlers) clearRuntimeStatus(connectionID string) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.statusMu.Lock()
|
||||
defer h.statusMu.Unlock()
|
||||
if h.statuses == nil {
|
||||
return
|
||||
}
|
||||
delete(h.statuses, connectionID)
|
||||
}
|
||||
|
||||
func cloneVMwareRuntimeStatus(status vmwareConnectionRuntimeStatus) vmwareConnectionRuntimeStatus {
|
||||
cloned := vmwareConnectionRuntimeStatus{}
|
||||
if status.Test != nil {
|
||||
test := *status.Test
|
||||
if test.LastAttemptAt != nil {
|
||||
test.LastAttemptAt = timePointer(*test.LastAttemptAt)
|
||||
}
|
||||
if test.LastSuccessAt != nil {
|
||||
test.LastSuccessAt = timePointer(*test.LastSuccessAt)
|
||||
}
|
||||
if test.LastError != nil {
|
||||
errCopy := *test.LastError
|
||||
if errCopy.At != nil {
|
||||
errCopy.At = timePointer(*errCopy.At)
|
||||
}
|
||||
test.LastError = &errCopy
|
||||
}
|
||||
cloned.Test = &test
|
||||
}
|
||||
if status.Observed != nil {
|
||||
observed := *status.Observed
|
||||
if observed.CollectedAt != nil {
|
||||
observed.CollectedAt = timePointer(*observed.CollectedAt)
|
||||
}
|
||||
cloned.Observed = &observed
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *time.Time {
|
||||
v := value
|
||||
return &v
|
||||
}
|
||||
|
||||
func vmwareConnectionIDFromPath(path string) (string, bool) {
|
||||
if !strings.HasPrefix(path, vmwareConnectionsPathPrefix) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
raw := strings.Trim(strings.TrimPrefix(path, vmwareConnectionsPathPrefix), "/")
|
||||
if raw == "" || strings.Contains(raw, "/") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
connectionID, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
connectionID = strings.TrimSpace(connectionID)
|
||||
if connectionID == "" || strings.Contains(connectionID, "/") {
|
||||
return "", false
|
||||
}
|
||||
return connectionID, true
|
||||
}
|
||||
|
||||
func vmwareConnectionIDFromTestPath(path string) (string, bool) {
|
||||
if !strings.HasPrefix(path, vmwareConnectionsPathPrefix) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
raw := strings.Trim(strings.TrimPrefix(path, vmwareConnectionsPathPrefix), "/")
|
||||
if !strings.HasSuffix(raw, "/test") {
|
||||
return "", false
|
||||
}
|
||||
raw = strings.TrimSuffix(raw, "/test")
|
||||
if raw == "" || strings.Contains(raw, "/") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
connectionID, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
connectionID = strings.TrimSpace(connectionID)
|
||||
if connectionID == "" || strings.Contains(connectionID, "/") {
|
||||
return "", false
|
||||
}
|
||||
return connectionID, true
|
||||
}
|
||||
547
internal/api/vmware_handlers_test.go
Normal file
547
internal/api/vmware_handlers_test.go
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
|
||||
)
|
||||
|
||||
type fakeVMwareClient struct {
|
||||
testConnection func(context.Context) (*vmware.InventorySummary, error)
|
||||
}
|
||||
|
||||
func (c *fakeVMwareClient) TestConnection(ctx context.Context) (*vmware.InventorySummary, error) {
|
||||
if c == nil || c.testConnection == nil {
|
||||
return &vmware.InventorySummary{}, nil
|
||||
}
|
||||
return c.testConnection(ctx)
|
||||
}
|
||||
|
||||
func (c *fakeVMwareClient) Close() {}
|
||||
|
||||
func TestVMwareHandlers_HandleAdd_Success(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, true)
|
||||
setMockModeForVMwareTest(t, false)
|
||||
|
||||
handler, persistence := newVMwareHandlersForTest(t)
|
||||
|
||||
body := marshalVMwareRequest(t, map[string]any{
|
||||
"name": "lab-vcenter",
|
||||
"host": "vcsa.lab.local",
|
||||
"port": 443,
|
||||
"username": "administrator@vsphere.local",
|
||||
"password": "super-secret",
|
||||
"enabled": true,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleAdd(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var created config.VMwareVCenterInstance
|
||||
if err := json.NewDecoder(rec.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode create response: %v", err)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Fatalf("expected generated ID, got empty")
|
||||
}
|
||||
if created.Password != "********" {
|
||||
t.Fatalf("expected password redacted, got %q", created.Password)
|
||||
}
|
||||
|
||||
stored, err := persistence.LoadVMwareConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load saved config: %v", err)
|
||||
}
|
||||
if len(stored) != 1 {
|
||||
t.Fatalf("expected 1 saved instance, got %d", len(stored))
|
||||
}
|
||||
if stored[0].Password != "super-secret" {
|
||||
t.Fatalf("expected unredacted password persisted, got %q", stored[0].Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMwareHandlers_HandleAdd_ValidationAndFeatureGate(t *testing.T) {
|
||||
t.Run("missing host", func(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, true)
|
||||
setMockModeForVMwareTest(t, false)
|
||||
handler, _ := newVMwareHandlersForTest(t)
|
||||
|
||||
body := marshalVMwareRequest(t, map[string]any{
|
||||
"username": "administrator@vsphere.local",
|
||||
"password": "super-secret",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleAdd(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("feature disabled", func(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, false)
|
||||
setMockModeForVMwareTest(t, false)
|
||||
handler, _ := newVMwareHandlersForTest(t)
|
||||
|
||||
body := marshalVMwareRequest(t, map[string]any{
|
||||
"host": "vcsa.lab.local",
|
||||
"username": "administrator@vsphere.local",
|
||||
"password": "super-secret",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleAdd(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "explicitly disabled") {
|
||||
t.Fatalf("expected explicit disable message, got %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVMwareHandlers_HandleList_RedactsSensitiveFieldsAndIncludesRuntimeSummary(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, true)
|
||||
|
||||
handler, persistence := newVMwareHandlersForTest(t)
|
||||
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{
|
||||
{
|
||||
ID: "vc-1",
|
||||
Name: "lab-a",
|
||||
Host: "vcsa-a.lab.local",
|
||||
Port: 443,
|
||||
Username: "administrator@vsphere.local",
|
||||
Password: "secret-a",
|
||||
InsecureSkipVerify: false,
|
||||
Enabled: true,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed vmware config: %v", err)
|
||||
}
|
||||
|
||||
recordedAt := time.Date(2026, 3, 30, 10, 11, 12, 0, time.UTC)
|
||||
handler.recordTestSuccess("vc-1", &vmware.InventorySummary{
|
||||
Hosts: 3,
|
||||
VMs: 42,
|
||||
Datastores: 6,
|
||||
VIRelease: "8.0.3",
|
||||
}, recordedAt)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/vmware/connections", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var listed []vmwareConnectionResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&listed); err != nil {
|
||||
t.Fatalf("decode list response: %v", err)
|
||||
}
|
||||
if len(listed) != 1 {
|
||||
t.Fatalf("expected 1 listed instance, got %d", len(listed))
|
||||
}
|
||||
if listed[0].Password != "********" {
|
||||
t.Fatalf("expected password to be redacted, got %q", listed[0].Password)
|
||||
}
|
||||
if listed[0].Test == nil || listed[0].Test.LastSuccessAt == nil {
|
||||
t.Fatalf("expected saved test runtime summary, got %+v", listed[0].Test)
|
||||
}
|
||||
if listed[0].Observed == nil {
|
||||
t.Fatalf("expected observed summary, got nil")
|
||||
}
|
||||
if listed[0].Observed.Hosts != 3 || listed[0].Observed.VMs != 42 || listed[0].Observed.Datastores != 6 {
|
||||
t.Fatalf("unexpected observed counts: %+v", listed[0].Observed)
|
||||
}
|
||||
if listed[0].Observed.VIRelease != "8.0.3" {
|
||||
t.Fatalf("unexpected VI release: %+v", listed[0].Observed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMwareHandlers_HandleDelete_RemovesAndClearsRuntimeSummary(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, true)
|
||||
setMockModeForVMwareTest(t, false)
|
||||
|
||||
handler, persistence := newVMwareHandlersForTest(t)
|
||||
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{
|
||||
{ID: "alpha", Host: "vcsa-a.lab.local", Username: "admin", Password: "a"},
|
||||
{ID: "beta", Host: "vcsa-b.lab.local", Username: "admin", Password: "b"},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed vmware config: %v", err)
|
||||
}
|
||||
handler.recordTestSuccess("alpha", &vmware.InventorySummary{Hosts: 1}, time.Now().UTC())
|
||||
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/vmware/connections/alpha", nil)
|
||||
deleteRec := httptest.NewRecorder()
|
||||
handler.HandleDelete(deleteRec, deleteReq)
|
||||
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
|
||||
stored, err := persistence.LoadVMwareConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load persisted config: %v", err)
|
||||
}
|
||||
if len(stored) != 1 || stored[0].ID != "beta" {
|
||||
t.Fatalf("expected only beta to remain, got %+v", stored)
|
||||
}
|
||||
if status := handler.runtimeStatus("alpha"); status.Test != nil || status.Observed != nil {
|
||||
t.Fatalf("expected runtime summary to be cleared, got %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMwareHandlers_HandleUpdate_PreservesMaskedSecretsAndReplacesFields(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, true)
|
||||
setMockModeForVMwareTest(t, false)
|
||||
|
||||
handler, persistence := newVMwareHandlersForTest(t)
|
||||
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{
|
||||
{
|
||||
ID: "alpha",
|
||||
Name: "old-name",
|
||||
Host: "old.lab.local",
|
||||
Port: 443,
|
||||
Username: "administrator@vsphere.local",
|
||||
Password: "super-secret",
|
||||
InsecureSkipVerify: false,
|
||||
Enabled: true,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed vmware config: %v", err)
|
||||
}
|
||||
|
||||
body := marshalVMwareRequest(t, map[string]any{
|
||||
"id": "ignored-id",
|
||||
"name": "new-name",
|
||||
"host": "new.lab.local",
|
||||
"port": 8443,
|
||||
"username": "operator@vsphere.local",
|
||||
"password": "********",
|
||||
"insecureSkipVerify": true,
|
||||
"enabled": true,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/vmware/connections/alpha", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdate(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var updated config.VMwareVCenterInstance
|
||||
if err := json.NewDecoder(rec.Body).Decode(&updated); err != nil {
|
||||
t.Fatalf("decode update response: %v", err)
|
||||
}
|
||||
if updated.ID != "alpha" {
|
||||
t.Fatalf("expected path ID to win, got %q", updated.ID)
|
||||
}
|
||||
if updated.Password != "********" {
|
||||
t.Fatalf("expected password to remain redacted, got %q", updated.Password)
|
||||
}
|
||||
|
||||
stored, err := persistence.LoadVMwareConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load persisted config: %v", err)
|
||||
}
|
||||
if len(stored) != 1 {
|
||||
t.Fatalf("expected 1 stored instance, got %d", len(stored))
|
||||
}
|
||||
if stored[0].Host != "new.lab.local" || stored[0].Port != 8443 {
|
||||
t.Fatalf("expected updated endpoint to persist, got %+v", stored[0])
|
||||
}
|
||||
if stored[0].Password != "super-secret" {
|
||||
t.Fatalf("expected masked password to preserve stored secret, got %q", stored[0].Password)
|
||||
}
|
||||
if !stored[0].InsecureSkipVerify {
|
||||
t.Fatalf("expected insecureSkipVerify update to persist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMwareHandlers_HandleTestConnection_SuccessAndFailure(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, true)
|
||||
|
||||
handler, _ := newVMwareHandlersForTest(t)
|
||||
var gotConfig vmware.ClientConfig
|
||||
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
|
||||
gotConfig = cfg
|
||||
return &fakeVMwareClient{
|
||||
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
|
||||
return &vmware.InventorySummary{Hosts: 1, VMs: 2, Datastores: 3, VIRelease: "8.0.3"}, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
successBody := marshalVMwareRequest(t, map[string]any{
|
||||
"host": "vcsa.lab.local",
|
||||
"port": 8443,
|
||||
"username": "administrator@vsphere.local",
|
||||
"password": "super-secret",
|
||||
})
|
||||
successReq := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/test", bytes.NewReader(successBody))
|
||||
successRec := httptest.NewRecorder()
|
||||
handler.HandleTestConnection(successRec, successReq)
|
||||
|
||||
if successRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", successRec.Code, successRec.Body.String())
|
||||
}
|
||||
if gotConfig.Host != "vcsa.lab.local" || gotConfig.Port != 8443 {
|
||||
t.Fatalf("unexpected client config: %+v", gotConfig)
|
||||
}
|
||||
|
||||
handler.newClient = nil
|
||||
failureBody := marshalVMwareRequest(t, map[string]any{
|
||||
"host": "http://127.0.0.1/path",
|
||||
"username": "administrator@vsphere.local",
|
||||
"password": "super-secret",
|
||||
})
|
||||
failureReq := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/test", bytes.NewReader(failureBody))
|
||||
failureRec := httptest.NewRecorder()
|
||||
handler.HandleTestConnection(failureRec, failureReq)
|
||||
|
||||
if failureRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for bad host, got %d: %s", failureRec.Code, failureRec.Body.String())
|
||||
}
|
||||
|
||||
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
|
||||
return &fakeVMwareClient{
|
||||
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
errorBody := marshalVMwareRequest(t, map[string]any{
|
||||
"host": "vcsa.lab.local",
|
||||
"username": "administrator@vsphere.local",
|
||||
"password": "super-secret",
|
||||
})
|
||||
errorReq := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/test", bytes.NewReader(errorBody))
|
||||
errorRec := httptest.NewRecorder()
|
||||
handler.HandleTestConnection(errorRec, errorReq)
|
||||
|
||||
if errorRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for failing connection, got %d: %s", errorRec.Code, errorRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMwareHandlers_HandleTestSavedConnection_UsesStoredSecretsAndUpdatesRuntimeSummary(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, true)
|
||||
|
||||
connection := config.VMwareVCenterInstance{
|
||||
ID: "conn-1",
|
||||
Name: "lab-vcenter",
|
||||
Host: "vcsa.lab.local",
|
||||
Port: 443,
|
||||
Username: "administrator@vsphere.local",
|
||||
Password: "super-secret",
|
||||
InsecureSkipVerify: false,
|
||||
Enabled: true,
|
||||
}
|
||||
handler, persistence := newVMwareHandlersForTest(t)
|
||||
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{connection}); err != nil {
|
||||
t.Fatalf("seed vmware config: %v", err)
|
||||
}
|
||||
|
||||
var gotConfig vmware.ClientConfig
|
||||
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
|
||||
gotConfig = cfg
|
||||
return &fakeVMwareClient{
|
||||
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
|
||||
return &vmware.InventorySummary{Hosts: 4, VMs: 25, Datastores: 5, VIRelease: "8.0.3"}, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/conn-1/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleTestSavedConnection(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gotConfig.Host != "vcsa.lab.local" || gotConfig.Password != "super-secret" {
|
||||
t.Fatalf("unexpected saved client config: %+v", gotConfig)
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/vmware/connections", nil)
|
||||
listRec := httptest.NewRecorder()
|
||||
handler.HandleList(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list expected 200, got %d: %s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
|
||||
var listed []vmwareConnectionResponse
|
||||
if err := json.NewDecoder(listRec.Body).Decode(&listed); err != nil {
|
||||
t.Fatalf("decode list response: %v", err)
|
||||
}
|
||||
if len(listed) != 1 || listed[0].Test == nil || listed[0].Test.LastSuccessAt == nil {
|
||||
t.Fatalf("expected saved retest to update runtime status, got %+v", listed)
|
||||
}
|
||||
if listed[0].Observed == nil || listed[0].Observed.VMs != 25 {
|
||||
t.Fatalf("expected saved retest to update observed summary, got %+v", listed[0].Observed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMwareHandlers_HandleTestSavedConnection_UpdatesRuntimeSummaryFailure(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, true)
|
||||
|
||||
connection := config.VMwareVCenterInstance{
|
||||
ID: "conn-1",
|
||||
Host: "vcsa.lab.local",
|
||||
Username: "administrator@vsphere.local",
|
||||
Password: "super-secret",
|
||||
Enabled: true,
|
||||
}
|
||||
handler, persistence := newVMwareHandlersForTest(t)
|
||||
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{connection}); err != nil {
|
||||
t.Fatalf("seed vmware config: %v", err)
|
||||
}
|
||||
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
|
||||
return &fakeVMwareClient{
|
||||
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
|
||||
return nil, &vmware.ConnectionError{Category: "auth", Message: "authentication failed"}
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/conn-1/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleTestSavedConnection(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
status := handler.runtimeStatus(connection.ID)
|
||||
if status.Test == nil || status.Test.LastError == nil {
|
||||
t.Fatalf("expected saved retest failure to update runtime summary, got %+v", status.Test)
|
||||
}
|
||||
if status.Test.LastError.Message != "authentication failed" || status.Test.LastError.Category != "auth" {
|
||||
t.Fatalf("unexpected failure summary: %+v", status.Test.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMwareHandlers_HandleTestSavedConnection_MergesEditedPayloadWithoutOverwritingRuntimeSummary(t *testing.T) {
|
||||
setVMwareFeatureForTest(t, true)
|
||||
|
||||
handler, persistence := newVMwareHandlersForTest(t)
|
||||
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{
|
||||
{
|
||||
ID: "conn-1",
|
||||
Name: "lab-vcenter",
|
||||
Host: "vcsa.lab.local",
|
||||
Port: 443,
|
||||
Username: "administrator@vsphere.local",
|
||||
Password: "super-secret",
|
||||
InsecureSkipVerify: false,
|
||||
Enabled: true,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed vmware config: %v", err)
|
||||
}
|
||||
|
||||
previousAt := time.Date(2026, 3, 30, 9, 0, 0, 0, time.UTC)
|
||||
handler.recordTestSuccess("conn-1", &vmware.InventorySummary{Hosts: 1, VMs: 2, Datastores: 3, VIRelease: "8.0.2.0"}, previousAt)
|
||||
|
||||
var gotConfig vmware.ClientConfig
|
||||
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
|
||||
gotConfig = cfg
|
||||
return &fakeVMwareClient{
|
||||
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
|
||||
return &vmware.InventorySummary{Hosts: 9, VMs: 99, Datastores: 12, VIRelease: "8.0.3"}, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
body := marshalVMwareRequest(t, map[string]any{
|
||||
"name": "edited-vcenter",
|
||||
"host": "edited.lab.local",
|
||||
"port": 8443,
|
||||
"username": "operator@vsphere.local",
|
||||
"password": "********",
|
||||
"insecureSkipVerify": true,
|
||||
"enabled": true,
|
||||
})
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/vmware/connections/conn-1/test",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleTestSavedConnection(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gotConfig.Host != "edited.lab.local" || gotConfig.Port != 8443 {
|
||||
t.Fatalf("expected edited endpoint, got %+v", gotConfig)
|
||||
}
|
||||
if gotConfig.Password != "super-secret" {
|
||||
t.Fatalf("expected stored password to be reused, got %+v", gotConfig)
|
||||
}
|
||||
|
||||
status := handler.runtimeStatus("conn-1")
|
||||
if status.Observed == nil || status.Observed.VMs != 2 || status.Observed.VIRelease != "8.0.2.0" {
|
||||
t.Fatalf("expected existing runtime summary to remain unchanged, got %+v", status.Observed)
|
||||
}
|
||||
if status.Test == nil || status.Test.LastSuccessAt == nil || !status.Test.LastSuccessAt.Equal(previousAt) {
|
||||
t.Fatalf("expected existing last success timestamp to remain unchanged, got %+v", status.Test)
|
||||
}
|
||||
}
|
||||
|
||||
func newVMwareHandlersForTest(t *testing.T) (*VMwareHandlers, *config.ConfigPersistence) {
|
||||
t.Helper()
|
||||
|
||||
persistence := config.NewConfigPersistence(t.TempDir())
|
||||
handler := &VMwareHandlers{
|
||||
getPersistence: func(context.Context) *config.ConfigPersistence { return persistence },
|
||||
}
|
||||
|
||||
return handler, persistence
|
||||
}
|
||||
|
||||
func setVMwareFeatureForTest(t *testing.T, enabled bool) {
|
||||
t.Helper()
|
||||
previous := vmware.IsFeatureEnabled()
|
||||
vmware.SetFeatureEnabled(enabled)
|
||||
t.Cleanup(func() {
|
||||
vmware.SetFeatureEnabled(previous)
|
||||
})
|
||||
}
|
||||
|
||||
func setMockModeForVMwareTest(t *testing.T, enabled bool) {
|
||||
t.Helper()
|
||||
previous := mock.IsMockEnabled()
|
||||
mock.SetEnabled(enabled)
|
||||
t.Cleanup(func() {
|
||||
mock.SetEnabled(previous)
|
||||
})
|
||||
}
|
||||
|
||||
func marshalVMwareRequest(t *testing.T, payload map[string]any) []byte {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ type ConfigPersistence struct {
|
|||
appriseFile string
|
||||
nodesFile string
|
||||
trueNASFile string
|
||||
vmwareFile string
|
||||
systemFile string
|
||||
ssoFile string
|
||||
apiTokensFile string
|
||||
|
|
@ -95,6 +96,7 @@ type resolvedConfigPersistencePaths struct {
|
|||
appriseFile string
|
||||
nodesFile string
|
||||
trueNASFile string
|
||||
vmwareFile string
|
||||
systemFile string
|
||||
ssoFile string
|
||||
apiTokensFile string
|
||||
|
|
@ -143,6 +145,10 @@ func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPers
|
|||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve truenas.enc: %w", err)
|
||||
}
|
||||
vmwareFile, err := resolveLeaf("vmware.enc")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve vmware.enc: %w", err)
|
||||
}
|
||||
systemFile, err := resolveLeaf("system.json")
|
||||
if err != nil {
|
||||
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve system.json: %w", err)
|
||||
|
|
@ -199,6 +205,7 @@ func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPers
|
|||
appriseFile: appriseFile,
|
||||
nodesFile: nodesFile,
|
||||
trueNASFile: trueNASFile,
|
||||
vmwareFile: vmwareFile,
|
||||
systemFile: systemFile,
|
||||
ssoFile: ssoFile,
|
||||
apiTokensFile: apiTokensFile,
|
||||
|
|
@ -248,6 +255,7 @@ func newConfigPersistence(configDir string) (*ConfigPersistence, error) {
|
|||
appriseFile: resolvedPaths.appriseFile,
|
||||
nodesFile: resolvedPaths.nodesFile,
|
||||
trueNASFile: resolvedPaths.trueNASFile,
|
||||
vmwareFile: resolvedPaths.vmwareFile,
|
||||
systemFile: resolvedPaths.systemFile,
|
||||
ssoFile: resolvedPaths.ssoFile,
|
||||
apiTokensFile: resolvedPaths.apiTokensFile,
|
||||
|
|
@ -604,6 +612,18 @@ func (c *ConfigPersistence) LoadTrueNASConfig() ([]TrueNASInstance, error) {
|
|||
return loadSlice[TrueNASInstance](c, c.trueNASFile, true)
|
||||
}
|
||||
|
||||
// SaveVMwareConfig persists VMware vCenter instance configuration to encrypted
|
||||
// storage.
|
||||
func (c *ConfigPersistence) SaveVMwareConfig(instances []VMwareVCenterInstance) error {
|
||||
return saveJSON(c, c.vmwareFile, instances, true)
|
||||
}
|
||||
|
||||
// LoadVMwareConfig loads VMware vCenter instance configuration from encrypted
|
||||
// storage.
|
||||
func (c *ConfigPersistence) LoadVMwareConfig() ([]VMwareVCenterInstance, error) {
|
||||
return loadSlice[VMwareVCenterInstance](c, c.vmwareFile, true)
|
||||
}
|
||||
|
||||
// SaveAPITokens persists API token metadata to disk.
|
||||
func (c *ConfigPersistence) SaveAPITokens(tokens []APITokenRecord) error {
|
||||
c.mu.Lock()
|
||||
|
|
|
|||
87
internal/config/vmware.go
Normal file
87
internal/config/vmware.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const vmwareSensitiveMask = "********"
|
||||
const defaultVMwarePort = 443
|
||||
|
||||
// IsVMwareSensitiveMask reports whether the value is the redacted placeholder
|
||||
// used by the VMware settings API.
|
||||
func IsVMwareSensitiveMask(value string) bool {
|
||||
return strings.TrimSpace(value) == vmwareSensitiveMask
|
||||
}
|
||||
|
||||
// VMwareVCenterInstance represents a configured VMware vCenter endpoint.
|
||||
type VMwareVCenterInstance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// NewVMwareVCenterInstance returns a new instance with generated ID and sane
|
||||
// defaults.
|
||||
func NewVMwareVCenterInstance() VMwareVCenterInstance {
|
||||
return VMwareVCenterInstance{
|
||||
ID: uuid.NewString(),
|
||||
Port: defaultVMwarePort,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyDefaults normalizes legacy zero-value config onto the canonical stored
|
||||
// defaults without changing explicitly configured values.
|
||||
func (v *VMwareVCenterInstance) ApplyDefaults() {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if v.Port <= 0 {
|
||||
v.Port = defaultVMwarePort
|
||||
}
|
||||
}
|
||||
|
||||
// Validate performs required VMware configuration checks.
|
||||
func (v *VMwareVCenterInstance) Validate() error {
|
||||
if v == nil {
|
||||
return fmt.Errorf("vmware vcenter instance is required")
|
||||
}
|
||||
if strings.TrimSpace(v.Host) == "" {
|
||||
return fmt.Errorf("vmware vcenter host is required")
|
||||
}
|
||||
if strings.TrimSpace(v.Username) == "" || strings.TrimSpace(v.Password) == "" {
|
||||
return fmt.Errorf("vmware credentials are required: provide username and password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Redacted returns a copy with sensitive credentials masked.
|
||||
func (v *VMwareVCenterInstance) Redacted() VMwareVCenterInstance {
|
||||
if v == nil {
|
||||
return VMwareVCenterInstance{}
|
||||
}
|
||||
redacted := *v
|
||||
if strings.TrimSpace(redacted.Password) != "" {
|
||||
redacted.Password = vmwareSensitiveMask
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
// PreserveMaskedSecrets restores stored credentials when an update payload uses
|
||||
// the API redaction placeholder for unchanged secret fields.
|
||||
func (v *VMwareVCenterInstance) PreserveMaskedSecrets(existing VMwareVCenterInstance) {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if IsVMwareSensitiveMask(v.Password) {
|
||||
v.Password = existing.Password
|
||||
}
|
||||
}
|
||||
387
internal/vmware/client.go
Normal file
387
internal/vmware/client.go
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
package vmware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// FeatureVMware allows explicit opt-out of the default-on VMware vCenter
|
||||
// platform integration.
|
||||
FeatureVMware = "PULSE_ENABLE_VMWARE"
|
||||
defaultTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var featureVMwareEnabled atomic.Bool
|
||||
|
||||
func init() {
|
||||
featureVMwareEnabled.Store(parseFeatureEnabled(os.Getenv(FeatureVMware)))
|
||||
}
|
||||
|
||||
// IsFeatureEnabled returns whether the VMware vCenter integration is enabled.
|
||||
func IsFeatureEnabled() bool {
|
||||
return featureVMwareEnabled.Load()
|
||||
}
|
||||
|
||||
// SetFeatureEnabled allows tests to control the feature flag.
|
||||
func SetFeatureEnabled(enabled bool) {
|
||||
featureVMwareEnabled.Store(enabled)
|
||||
}
|
||||
|
||||
func parseFeatureEnabled(raw string) bool {
|
||||
switch strings.TrimSpace(strings.ToLower(raw)) {
|
||||
case "", "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectionError classifies VMware connection failures for API consumers.
|
||||
type ConnectionError struct {
|
||||
Category string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ConnectionError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// InventorySummary captures the minimum read-side floor proven by a successful
|
||||
// VMware connection test.
|
||||
type InventorySummary struct {
|
||||
Hosts int
|
||||
VMs int
|
||||
Datastores int
|
||||
VIRelease string
|
||||
}
|
||||
|
||||
// ClientConfig configures a VMware vCenter client.
|
||||
type ClientConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
InsecureSkipVerify bool
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Client executes phase-1 VMware connection validation.
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewClient constructs a VMware client from saved connection input.
|
||||
func NewClient(cfg ClientConfig) (*Client, error) {
|
||||
baseURL, err := normalizeBaseURL(cfg.Host, cfg.Port)
|
||||
if err != nil {
|
||||
return nil, &ConnectionError{Category: "invalid_config", Message: err.Error()}
|
||||
}
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cookie jar: %w", err)
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify, // operator-controlled onboarding setting
|
||||
},
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
Jar: jar,
|
||||
},
|
||||
username: strings.TrimSpace(cfg.Username),
|
||||
password: strings.TrimSpace(cfg.Password),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases idle resources held by the underlying HTTP client.
|
||||
func (c *Client) Close() {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return
|
||||
}
|
||||
if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection validates both the Automation API and VI JSON API families and
|
||||
// returns a minimal inventory summary on success.
|
||||
func (c *Client) TestConnection(ctx context.Context) (*InventorySummary, error) {
|
||||
automationSessionID, err := c.createAutomationSession(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts, err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/host", "host inventory")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vms, err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/vm", "vm inventory")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
datastores, err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/datastore", "datastore inventory")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release, sessionManagerMoID, err := c.resolveVIJSONRelease(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.loginVIJSON(ctx, release, sessionManagerMoID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &InventorySummary{
|
||||
Hosts: len(hosts),
|
||||
VMs: len(vms),
|
||||
Datastores: len(datastores),
|
||||
VIRelease: release,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeBaseURL(rawHost string, port int) (*url.URL, error) {
|
||||
host := strings.TrimSpace(rawHost)
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("vmware vcenter host is required")
|
||||
}
|
||||
if !strings.Contains(host, "://") {
|
||||
host = "https://" + host
|
||||
}
|
||||
parsed, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid vmware vcenter host: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("vmware vcenter connections must use https")
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return nil, fmt.Errorf("vmware vcenter host is required")
|
||||
}
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
return nil, fmt.Errorf("vmware vcenter host must not include a path")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, fmt.Errorf("vmware vcenter host must not include query or fragment data")
|
||||
}
|
||||
if parsed.Port() == "" {
|
||||
if port <= 0 {
|
||||
port = 443
|
||||
}
|
||||
parsed.Host = net.JoinHostPort(parsed.Hostname(), strconv.Itoa(port))
|
||||
}
|
||||
parsed.Path = ""
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func (c *Client) createAutomationSession(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL.String()+"/api/session", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build automation session request: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(c.username, c.password)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", classifyTransportError("automation session", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return "", fmt.Errorf("read automation session response: %w", readErr)
|
||||
}
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusCreated:
|
||||
default:
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return "", &ConnectionError{Category: "auth", Message: "VMware authentication failed while creating the Automation API session"}
|
||||
}
|
||||
return "", &ConnectionError{
|
||||
Category: "endpoint",
|
||||
Message: fmt.Sprintf("VMware Automation API session request failed with HTTP %d", resp.StatusCode),
|
||||
}
|
||||
}
|
||||
var sessionID string
|
||||
if err := json.Unmarshal(body, &sessionID); err != nil || strings.TrimSpace(sessionID) == "" {
|
||||
return "", &ConnectionError{Category: "endpoint", Message: "VMware Automation API returned an invalid session payload"}
|
||||
}
|
||||
return strings.TrimSpace(sessionID), nil
|
||||
}
|
||||
|
||||
func (c *Client) listAutomationResources(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
path string,
|
||||
label string,
|
||||
) ([]json.RawMessage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.String()+path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build %s request: %w", label, err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("vmware-api-session-id", sessionID)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, classifyTransportError(label, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read %s response: %w", label, readErr)
|
||||
}
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusUnauthorized:
|
||||
return nil, &ConnectionError{Category: "auth", Message: fmt.Sprintf("VMware authentication failed while reading %s", label)}
|
||||
case http.StatusForbidden:
|
||||
return nil, &ConnectionError{Category: "permission", Message: fmt.Sprintf("VMware permissions are insufficient for %s", label)}
|
||||
default:
|
||||
return nil, &ConnectionError{
|
||||
Category: "endpoint",
|
||||
Message: fmt.Sprintf("VMware %s request failed with HTTP %d", label, resp.StatusCode),
|
||||
}
|
||||
}
|
||||
var items []json.RawMessage
|
||||
if err := json.Unmarshal(body, &items); err != nil {
|
||||
return nil, &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware %s response was not valid JSON", label)}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (c *Client) resolveVIJSONRelease(ctx context.Context) (string, string, error) {
|
||||
releases := []string{"9.0.0.0", "8.0.3", "8.0.2.0", "8.0.1.0"}
|
||||
var lastErr error
|
||||
for _, release := range releases {
|
||||
moID, err := c.fetchSessionManagerMoID(ctx, release)
|
||||
if err == nil {
|
||||
return release, moID, nil
|
||||
}
|
||||
lastErr = err
|
||||
connectionErr, ok := err.(*ConnectionError)
|
||||
if !ok || connectionErr.Category != "endpoint" {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", "", lastErr
|
||||
}
|
||||
return "", "", &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API release negotiation failed"}
|
||||
}
|
||||
|
||||
func (c *Client) fetchSessionManagerMoID(ctx context.Context, release string) (string, error) {
|
||||
endpoint := fmt.Sprintf("%s/sdk/vim25/%s/ServiceInstance/ServiceInstance/content", c.baseURL.String(), release)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build vi-json service instance request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", classifyTransportError("vi-json service content", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return "", fmt.Errorf("read vi-json service content response: %w", readErr)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware VI JSON API release %s is unavailable", release)}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware VI JSON API service-instance request failed with HTTP %d", resp.StatusCode)}
|
||||
}
|
||||
var payload struct {
|
||||
SessionManager struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"sessionManager"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API service-instance response was not valid JSON"}
|
||||
}
|
||||
moID := strings.TrimSpace(payload.SessionManager.Value)
|
||||
if moID == "" {
|
||||
return "", &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API service-instance response did not include a session manager reference"}
|
||||
}
|
||||
return moID, nil
|
||||
}
|
||||
|
||||
func (c *Client) loginVIJSON(ctx context.Context, release string, sessionManagerMoID string) error {
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"userName": c.username,
|
||||
"password": c.password,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal vi-json login request: %w", err)
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/sdk/vim25/%s/SessionManager/%s/Login", c.baseURL.String(), release, sessionManagerMoID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build vi-json login request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return classifyTransportError("vi-json login", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
if strings.TrimSpace(resp.Header.Get("vmware-api-session-id")) == "" {
|
||||
return &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API login succeeded without returning a session id"}
|
||||
}
|
||||
return nil
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return &ConnectionError{Category: "auth", Message: "VMware authentication failed while creating the VI JSON API session"}
|
||||
default:
|
||||
return &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware VI JSON API login failed with HTTP %d", resp.StatusCode)}
|
||||
}
|
||||
}
|
||||
|
||||
func classifyTransportError(stage string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(err.Error())
|
||||
if strings.Contains(lower, "x509") || strings.Contains(lower, "certificate") || strings.Contains(lower, "tls") {
|
||||
return &ConnectionError{Category: "tls", Message: fmt.Sprintf("VMware TLS validation failed during %s", stage)}
|
||||
}
|
||||
var unknownAuthority *x509.UnknownAuthorityError
|
||||
if errors.As(err, &unknownAuthority) {
|
||||
return &ConnectionError{Category: "tls", Message: fmt.Sprintf("VMware TLS validation failed during %s", stage)}
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return &ConnectionError{Category: "network", Message: fmt.Sprintf("VMware network error during %s", stage)}
|
||||
}
|
||||
return &ConnectionError{Category: "network", Message: fmt.Sprintf("VMware connection failed during %s", stage)}
|
||||
}
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { createAuthenticatedStorageState } from './helpers';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
type WorkerFixtures = {
|
||||
authStorageStatePath: string;
|
||||
};
|
||||
|
||||
const SCREENSHOT_PATH = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'tmp',
|
||||
'vmware-settings-platform-connections.png',
|
||||
);
|
||||
const WORKFLOW_SCREENSHOT_PATH = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'tmp',
|
||||
'vmware-settings-platform-workflow.png',
|
||||
);
|
||||
|
||||
const test = base.extend<{}, WorkerFixtures>({
|
||||
storageState: async ({ authStorageStatePath }, use) => {
|
||||
await use(authStorageStatePath);
|
||||
},
|
||||
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
|
||||
const storageStatePath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'tmp',
|
||||
'playwright-auth',
|
||||
`vmware-settings-platform-connections-${workerInfo.project.name}.json`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
|
||||
await createAuthenticatedStorageState(browser, storageStatePath);
|
||||
try {
|
||||
await use(storageStatePath);
|
||||
} finally {
|
||||
fs.rmSync(storageStatePath, { force: true });
|
||||
}
|
||||
}, { scope: 'worker' }],
|
||||
});
|
||||
|
||||
test.describe('VMware platform connections settings', () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test('renders the platform-connections workspace with the VMware integration shell', async ({
|
||||
page,
|
||||
}) => {
|
||||
const healthyAt = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const failingAt = new Date(Date.now() - 2 * 60_000).toISOString();
|
||||
|
||||
await page.route('**/api/vmware/connections', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 'vmware-1',
|
||||
name: 'Primary vCenter',
|
||||
host: 'vcsa-primary.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: false,
|
||||
enabled: true,
|
||||
test: {
|
||||
lastSuccessAt: healthyAt,
|
||||
},
|
||||
observed: {
|
||||
collectedAt: healthyAt,
|
||||
hosts: 6,
|
||||
vms: 120,
|
||||
datastores: 18,
|
||||
viRelease: '8.0.3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vmware-2',
|
||||
name: 'Staging vCenter',
|
||||
host: 'vcsa-staging.local',
|
||||
port: 443,
|
||||
username: 'operator@vsphere.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: true,
|
||||
enabled: true,
|
||||
test: {
|
||||
lastAttemptAt: failingAt,
|
||||
lastError: {
|
||||
at: failingAt,
|
||||
message: 'authentication failed',
|
||||
category: 'auth',
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/settings/infrastructure/platforms/vmware', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForURL(/\/settings\/infrastructure\/platforms\/vmware/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Infrastructure Operations' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Platform connections' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await expect(page.getByRole('tab', { name: 'VMware' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await expect(page.getByRole('tab', { name: 'TrueNAS' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
await expect(page.getByRole('tab', { name: 'Proxmox' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
|
||||
await expect(page.getByText('VMware vSphere platform integration')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Add VMware connection' })).toBeVisible();
|
||||
|
||||
const primaryCard = page.getByTestId('vmware-connection-vmware-1');
|
||||
await expect(primaryCard).toContainText('Primary vCenter');
|
||||
await expect(primaryCard).toContainText('Validated');
|
||||
await expect(primaryCard).toContainText('6 hosts');
|
||||
await expect(primaryCard).toContainText('120 vms');
|
||||
await expect(primaryCard).toContainText('18 datastores');
|
||||
await expect(primaryCard).toContainText('VI JSON 8.0.3');
|
||||
|
||||
const failingCard = page.getByTestId('vmware-connection-vmware-2');
|
||||
await expect(failingCard).toContainText('Staging vCenter');
|
||||
await expect(failingCard).toContainText('Validation failing');
|
||||
await expect(failingCard).toContainText('authentication failed');
|
||||
await expect(failingCard).toContainText('Skip TLS verification');
|
||||
|
||||
fs.mkdirSync(path.dirname(SCREENSHOT_PATH), { recursive: true });
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
});
|
||||
|
||||
test('adds, edits, retests, and deletes VMware connections through the canonical settings workflow', async ({
|
||||
page,
|
||||
}) => {
|
||||
const validatedAt = new Date(Date.now() - 60_000).toISOString();
|
||||
let connections: Record<string, unknown>[] = [];
|
||||
let draftTestPayload: Record<string, unknown> | null = null;
|
||||
let createPayload: Record<string, unknown> | null = null;
|
||||
let updatePayload: Record<string, unknown> | null = null;
|
||||
let draftTestCalls = 0;
|
||||
const savedTestRequests: Array<{ path: string; payload: Record<string, unknown> | null }> = [];
|
||||
const deletePaths: string[] = [];
|
||||
|
||||
await page.route('**/api/vmware/connections**', async (route) => {
|
||||
const request = route.request();
|
||||
const method = request.method();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
if (pathname === '/api/vmware/connections' && method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(connections),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/vmware/connections/test' && method === 'POST') {
|
||||
draftTestCalls += 1;
|
||||
draftTestPayload = JSON.parse(request.postData() || '{}');
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/vmware/connections' && method === 'POST') {
|
||||
createPayload = JSON.parse(request.postData() || '{}');
|
||||
connections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: createPayload.name,
|
||||
host: createPayload.host,
|
||||
port: createPayload.port,
|
||||
username: createPayload.username,
|
||||
password: '********',
|
||||
insecureSkipVerify: createPayload.insecureSkipVerify,
|
||||
enabled: createPayload.enabled,
|
||||
test: {
|
||||
lastSuccessAt: validatedAt,
|
||||
},
|
||||
observed: {
|
||||
collectedAt: validatedAt,
|
||||
hosts: 4,
|
||||
vms: 48,
|
||||
datastores: 8,
|
||||
viRelease: '8.0.3',
|
||||
},
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(connections[0]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/vmware/connections/conn-1/test' && method === 'POST') {
|
||||
savedTestRequests.push({
|
||||
path: pathname,
|
||||
payload: request.postData() ? JSON.parse(request.postData() || '{}') : null,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/vmware/connections/conn-1' && method === 'PUT') {
|
||||
updatePayload = JSON.parse(request.postData() || '{}');
|
||||
connections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: updatePayload.name,
|
||||
host: updatePayload.host,
|
||||
port: updatePayload.port,
|
||||
username: updatePayload.username,
|
||||
password: '********',
|
||||
insecureSkipVerify: updatePayload.insecureSkipVerify,
|
||||
enabled: updatePayload.enabled,
|
||||
test: {
|
||||
lastSuccessAt: validatedAt,
|
||||
},
|
||||
observed: {
|
||||
collectedAt: validatedAt,
|
||||
hosts: 4,
|
||||
vms: 48,
|
||||
datastores: 8,
|
||||
viRelease: '8.0.3',
|
||||
},
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(connections[0]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/vmware/connections/conn-1' && method === 'DELETE') {
|
||||
deletePaths.push(pathname);
|
||||
connections = [];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true, id: 'conn-1' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/settings/infrastructure/platforms/vmware', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForURL(/\/settings\/infrastructure\/platforms\/vmware/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await expect(page.getByText('No VMware connections yet')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Add VMware connection' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Add VMware connection' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByPlaceholder('lab-vcenter').fill('Lab vCenter');
|
||||
await dialog.getByPlaceholder('vcsa.lab.local').fill('vcsa.lab.local');
|
||||
await dialog.getByPlaceholder('443').fill('443');
|
||||
await dialog.getByPlaceholder('administrator@vsphere.local').fill(
|
||||
'administrator@vsphere.local',
|
||||
);
|
||||
await dialog.locator('input[type="password"]').first().fill('super-secret');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Test connection' }).click();
|
||||
await expect.poll(() => draftTestPayload).toMatchObject({
|
||||
name: 'Lab vCenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: 'super-secret',
|
||||
enabled: true,
|
||||
insecureSkipVerify: false,
|
||||
});
|
||||
|
||||
await dialog.getByRole('button', { name: 'Add connection' }).click();
|
||||
|
||||
const connectionCard = page.getByTestId('vmware-connection-conn-1');
|
||||
await expect(connectionCard).toBeVisible();
|
||||
await expect(connectionCard).toContainText('Lab vCenter');
|
||||
await expect(connectionCard).toContainText('Validated');
|
||||
await expect.poll(() => createPayload).toMatchObject({
|
||||
name: 'Lab vCenter',
|
||||
host: 'vcsa.lab.local',
|
||||
port: 443,
|
||||
username: 'administrator@vsphere.local',
|
||||
password: 'super-secret',
|
||||
enabled: true,
|
||||
insecureSkipVerify: false,
|
||||
});
|
||||
|
||||
await connectionCard.getByRole('button', { name: 'Edit' }).click();
|
||||
const editDialog = page.getByRole('dialog', { name: 'Edit VMware connection' });
|
||||
await expect(editDialog).toBeVisible();
|
||||
await expect(
|
||||
editDialog.getByPlaceholder('Saved password retained unless replaced'),
|
||||
).toBeVisible();
|
||||
|
||||
await editDialog.getByPlaceholder('lab-vcenter').fill('Lab vCenter Edited');
|
||||
await editDialog.getByPlaceholder('vcsa.lab.local').fill('edited.lab.local');
|
||||
await editDialog.getByPlaceholder('443').fill('8443');
|
||||
await editDialog.getByPlaceholder('administrator@vsphere.local').fill('operator@vsphere.local');
|
||||
await editDialog.getByLabel('Skip TLS verification').check();
|
||||
|
||||
await editDialog.getByRole('button', { name: 'Test connection' }).click();
|
||||
await expect.poll(() => savedTestRequests[0]).toMatchObject({
|
||||
path: '/api/vmware/connections/conn-1/test',
|
||||
payload: {
|
||||
name: 'Lab vCenter Edited',
|
||||
host: 'edited.lab.local',
|
||||
port: 8443,
|
||||
username: 'operator@vsphere.local',
|
||||
password: '********',
|
||||
enabled: true,
|
||||
insecureSkipVerify: true,
|
||||
},
|
||||
});
|
||||
await expect.poll(() => draftTestCalls).toBe(1);
|
||||
|
||||
await editDialog.getByRole('button', { name: 'Save connection' }).click();
|
||||
await expect.poll(() => updatePayload).toMatchObject({
|
||||
name: 'Lab vCenter Edited',
|
||||
host: 'edited.lab.local',
|
||||
port: 8443,
|
||||
username: 'operator@vsphere.local',
|
||||
password: '********',
|
||||
enabled: true,
|
||||
insecureSkipVerify: true,
|
||||
});
|
||||
await expect(connectionCard).toContainText('Lab vCenter Edited');
|
||||
|
||||
await connectionCard.getByRole('button', { name: 'Test' }).click();
|
||||
await expect.poll(() => savedTestRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
path: '/api/vmware/connections/conn-1/test',
|
||||
payload: expect.objectContaining({
|
||||
host: 'edited.lab.local',
|
||||
password: '********',
|
||||
insecureSkipVerify: true,
|
||||
}),
|
||||
}),
|
||||
{ path: '/api/vmware/connections/conn-1/test', payload: null },
|
||||
]);
|
||||
await expect.poll(() => draftTestCalls).toBe(1);
|
||||
|
||||
await connectionCard.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete connection' }).click();
|
||||
await expect.poll(() => deletePaths).toEqual(['/api/vmware/connections/conn-1']);
|
||||
await expect(page.getByText('No VMware connections yet')).toBeVisible();
|
||||
|
||||
fs.mkdirSync(path.dirname(WORKFLOW_SCREENSHOT_PATH), { recursive: true });
|
||||
await page.screenshot({ path: WORKFLOW_SCREENSHOT_PATH, fullPage: true });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue