Unify settings infrastructure ledger

This commit is contained in:
rcourtman 2026-04-18 14:54:53 +01:00
parent a6145510d0
commit 847d09d17b
34 changed files with 1286 additions and 882 deletions

View file

@ -35,65 +35,62 @@ management, and fleet control surfaces.
11. `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`
12. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`
13. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`
14. `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`
15. `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`
16. `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`
17. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`
18. `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`
19. `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`
20. `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`
21. `frontend-modern/src/components/Settings/ProxmoxDirectWorkspace.tsx`
22. `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`
23. `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`
24. `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`
25. `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`
26. `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`
27. `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`
28. `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`
29. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
30. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`
31. `frontend-modern/src/components/Settings/useProxmoxDirectWorkspaceState.ts`
32. `frontend-modern/src/components/Settings/NodeModal.tsx`
33. `frontend-modern/src/components/Settings/NodeModalAuthenticationSection.tsx`
34. `frontend-modern/src/components/Settings/NodeModalBasicInfoSection.tsx`
35. `frontend-modern/src/components/Settings/nodeModalModel.ts`
36. `frontend-modern/src/components/Settings/NodeModalMonitoringSection.tsx`
37. `frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx`
38. `frontend-modern/src/components/Settings/NodeModalStatusFooter.tsx`
39. `frontend-modern/src/components/Settings/useNodeModalState.ts`
40. `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`
41. `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`
42. `frontend-modern/src/utils/agentProfilesPresentation.ts`
43. `frontend-modern/src/utils/agentInstallCommand.ts`
44. `frontend-modern/src/api/nodes.ts`
45. `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`
46. `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`
47. `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`
48. `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`
49. `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`
50. `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`
51. `frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx`
52. `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`
53. `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`
54. `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`
55. `frontend-modern/src/utils/infrastructureSettingsPresentation.ts`
56. `frontend-modern/src/utils/agentCapabilityPresentation.ts`
57. `frontend-modern/src/utils/agentProfileSuggestionPresentation.ts`
58. `frontend-modern/src/utils/configuredNodeCapabilityPresentation.ts`
59. `frontend-modern/src/utils/configuredNodeStatusPresentation.ts`
60. `frontend-modern/src/utils/unifiedAgentInventoryPresentation.ts`
61. `frontend-modern/src/utils/unifiedAgentStatusPresentation.ts`
62. `frontend-modern/src/utils/clusterEndpointPresentation.ts`
63. `frontend-modern/src/utils/nodeModalPresentation.ts`
64. `frontend-modern/src/utils/proxmoxSettingsPresentation.ts`
65. `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`
66. `frontend-modern/src/components/Settings/platformConnectionsModel.ts`
67. `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`
68. `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`
69. `frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx`
70. `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`
71. `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`
72. `internal/hostagent/proxmox_setup.go`
14. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`
15. `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`
16. `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`
17. `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`
18. `frontend-modern/src/components/Settings/ProxmoxDirectWorkspace.tsx`
19. `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`
20. `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`
21. `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`
22. `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`
23. `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`
24. `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`
25. `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`
26. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
27. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`
28. `frontend-modern/src/components/Settings/useProxmoxDirectWorkspaceState.ts`
29. `frontend-modern/src/components/Settings/NodeModal.tsx`
30. `frontend-modern/src/components/Settings/NodeModalAuthenticationSection.tsx`
31. `frontend-modern/src/components/Settings/NodeModalBasicInfoSection.tsx`
32. `frontend-modern/src/components/Settings/nodeModalModel.ts`
33. `frontend-modern/src/components/Settings/NodeModalMonitoringSection.tsx`
34. `frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx`
35. `frontend-modern/src/components/Settings/NodeModalStatusFooter.tsx`
36. `frontend-modern/src/components/Settings/useNodeModalState.ts`
37. `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`
38. `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`
39. `frontend-modern/src/utils/agentProfilesPresentation.ts`
40. `frontend-modern/src/utils/agentInstallCommand.ts`
41. `frontend-modern/src/api/nodes.ts`
42. `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`
43. `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`
44. `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`
45. `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`
46. `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`
47. `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`
48. `frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx`
49. `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`
50. `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`
51. `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`
52. `frontend-modern/src/utils/infrastructureSettingsPresentation.ts`
53. `frontend-modern/src/utils/agentCapabilityPresentation.ts`
54. `frontend-modern/src/utils/agentProfileSuggestionPresentation.ts`
55. `frontend-modern/src/utils/configuredNodeCapabilityPresentation.ts`
56. `frontend-modern/src/utils/configuredNodeStatusPresentation.ts`
57. `frontend-modern/src/utils/unifiedAgentInventoryPresentation.ts`
58. `frontend-modern/src/utils/unifiedAgentStatusPresentation.ts`
59. `frontend-modern/src/utils/clusterEndpointPresentation.ts`
60. `frontend-modern/src/utils/nodeModalPresentation.ts`
61. `frontend-modern/src/utils/proxmoxSettingsPresentation.ts`
62. `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`
63. `frontend-modern/src/components/Settings/platformConnectionsModel.ts`
64. `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`
65. `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`
66. `frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx`
67. `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`
68. `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`
69. `internal/hostagent/proxmox_setup.go`
## Shared Boundaries
@ -205,7 +202,7 @@ an add-only capacity posture.
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 the unified agent CLI entrypoint, version/help exit semantics, or startup argument/error routing through `cmd/pulse-agent/main.go`.
6. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
7. 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, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/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/MonitoredSystemAdmissionPreview.tsx`, `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. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the connections-ledger workspace shell, route model, shared install/inventory/dialog section owners, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.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/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `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`.
Those lifecycle-owned settings hooks may consume websocket state only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx` or recreate root-shell providers.
Public demo and other read-only settings posture must stay reporting-first
on that same lifecycle-owned workspace boundary: infrastructure workspace
@ -357,29 +354,27 @@ an add-only capacity posture.
7. Keep `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`
and `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`
aligned with that same lifecycle path. The bare
`/settings/infrastructure` route must render a unified Connections table
that lists every monitored system — Proxmox VE, PBS, PMG, TrueNAS, VMware,
and agent hosts — as sibling rows sharing a single name/kind/method/status/
last-reported shape, so operators read what is currently being monitored
in one scan instead of tab-hopping between per-kind surfaces. Adding a new
system must be a single entry point on that table: an `Add a system`
picker whose tiles route the operator into the right flow per kind
(`/settings/infrastructure/install` for the agent choice,
`/settings/infrastructure/platforms/<kind>` for Proxmox/TrueNAS/VMware
tiles). `/settings/infrastructure/install`,
`/settings/infrastructure` route must render one Connections and inventory
ledger that lists top-level infrastructure only — active or ignored
infrastructure roots plus saved Proxmox VE, PBS, PMG, TrueNAS, VMware, and
agent-managed entries — as sibling rows sharing one
system/coverage/collection/status/last-activity workspace model, so
operators can read infrastructure state in one scan instead of hopping
between install, reporting, and provider shells. Guest-linked agent rows
still belong to the reporting inventory and inline lifecycle detail, but
they must not appear as peer connection rows on that top ledger. Adding a
new system must stay a single entry point on that ledger:
one `Add a system` picker that keeps `Install on a host` explicit for the
agent path while opening the saved-connection create flow for API-backed
platforms on the same page. `/settings/infrastructure/install`,
`/settings/infrastructure/platforms`, and
`/settings/infrastructure/operations` remain reachable as detail routes
for install, platform connections, and legacy reporting/control surfaces
respectively, but the workspace shell must not gate inventory visibility
behind tab navigation. Every non-inventory detail route under
`/settings/infrastructure/*` must render a persistent breadcrumb header
that includes a one-click `Connections and Inventory` control returning
to the bare `/settings/infrastructure` route, so operators are never
stranded on a deeper install or platform-connection surface without a
visible path back to the unified table. Read-only sessions must continue
to redirect the install detail route back to the unified inventory view
and suppress the add-system entry point on the base table so
presentation-policy restrictions still hold.
`/settings/infrastructure/operations` remain valid deep links, but they
must resolve to section focus on that same single-page workspace rather
than rendering separate page shells or hiding the top ledger. Read-only
sessions must redirect any non-inventory infrastructure deep link back to
`/settings/infrastructure`, suppress the add-system entry point, and hide
configuration-only sections so presentation-policy restrictions still
hold.
8. Keep post-install lifecycle completion explicit inside
`frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`
and `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`.
@ -658,7 +653,12 @@ agent extensions. `frontend-modern/src/components/Settings/infrastructureOperati
must keep Proxmox, PBS, PMG, and TrueNAS on the shared Platform connections
path, while only machine-installed agent, Docker, and Kubernetes surfaces
participate in host stop-monitoring scope, uninstall commands, and upgrade
actions.
actions. That same lifecycle-owned reporting contract now also owns guest-link
truth for agent rows: when a host agent is actually attached to a VM or system
container, the shared connected-infrastructure payload must preserve that
linked guest identity so the top Connections and inventory ledger can stay
scoped to top-level infrastructure instead of rendering guest-backed agents as
peer infrastructure roots.
Those unified audit list endpoints also clamp oversized `limit` requests to
the governed maximum, so audit history stays bounded even when callers ask
for arbitrarily large pages.
@ -823,12 +823,10 @@ the backend contract owns the reporting validation classification.
The API-backed platform connections workspace now also lives explicitly inside
this lifecycle boundary: `InfrastructureWorkspace.tsx`,
`infrastructureWorkspaceModel.ts`,
`InfrastructureInstallPanel.tsx`, `InfrastructureInstallerSection.tsx`,
`InfrastructureReportingPanel.tsx`, `InfrastructureInventorySection.tsx`,
`InfrastructureInstallerSection.tsx`, `InfrastructureInventorySection.tsx`,
`InfrastructureActiveRowDetails.tsx`,
`InfrastructureIgnoredRowDetails.tsx`,
`InfrastructureStopMonitoringDialog.tsx`,
`InfrastructurePlatformConnectionsSummaryCard.tsx`,
`PlatformConnectionsWorkspace.tsx`, `platformConnectionsModel.ts`,
`TrueNASSettingsPanel.tsx`, `useTrueNASSettingsPanelState.ts`,
`ProxmoxSettingsPanel.tsx`, `proxmoxSettingsModel.ts`,
@ -927,10 +925,10 @@ That lifecycle-owned VMware slice now also includes the first live runtime
handoff rule. `frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx`,
`frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`,
`frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`,
`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`,
and `internal/api/router.go` must keep VMware on the shared platform-
connections workflow with one `vCenter` connection family and one summary card
count. Lifecycle-adjacent install or discovery surfaces must not fork that
connections workflow with one `vCenter` connection family under the same
saved-connection ledger. Lifecycle-adjacent install or discovery surfaces must
not fork that
into a VMware-only install wizard, direct-ESXi setup branch, or agent-first
bootstrap story just because the runtime now has a live VMware connection
panel and poller.
@ -943,19 +941,19 @@ through unified metrics targets, but emitted workload IDs must stay on the
canonical `/workloads` row contract so lifecycle settings, reporting, and
handoff surfaces never depend on provider-native metric keys.
That same lifecycle-owned settings slice now also owns the shared VMware
summary and handoff framing. `InfrastructurePlatformConnectionsSummaryCard.tsx`,
`InfrastructureReportingPanel.tsx`, `useInfrastructureSettingsState.ts`, and
handoff framing. `InfrastructureWorkspace.tsx`,
`PlatformConnectionsWorkspace.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.
the VMware panel itself, rather than letting 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
system, while still presenting `Platform connections` as the explicit
API-backed alternative path instead of leaving first-session install guidance
implicit in generic settings-shell prose or retreating to one provider's name
as the primary alternative.
handoff copy for new operators. `InfrastructureWorkspace.tsx` must keep
`Install on a host` visible as the first monitored-system path while still
presenting `Platform connections` as the explicit API-backed alternative
instead of leaving first-session install guidance implicit in generic
settings-shell prose or retreating to one provider's name as the primary
alternative.
When that infrastructure workspace needs to redirect operators to the Pulse Pro
surface for billing, monitored-system limits, or license status, it must
consume the settings-owned referral copy from

View file

@ -443,7 +443,12 @@ the canonical monitored-system blocked payload.
must preserve the transport distinction between machine-managed surfaces
(`agent`, `docker`, `kubernetes`) and platform-connections-managed
surfaces (`proxmox`, `pbs`, `pmg`, `truenas`) instead of collapsing them
into one uninstall/stop-monitoring model.
into one uninstall/stop-monitoring model. That same shared payload
contract must also preserve guest-linked host identity on connected
infrastructure and removed-host records through `linkedVmId` and
`linkedContainerId`, so settings consumers can keep the top connections
ledger scoped to top-level infrastructure without re-deriving guest status
from names or local heuristics.
24. Keep AI settings payload continuity explicit on the shared `/api/settings/ai`
surface: `internal/api/ai_handlers.go` and `internal/api/contract_test.go`
must expose masked provider-auth state such as `ollama_username` and

View file

@ -213,7 +213,7 @@ work extends shared components instead of creating new local variants.
helper plus runtime `monitored_system_capacity` reads rather than
reconstructing raw `current / limit` slash math or `0 remaining` copy in
the banner shell, state owner, or shared model.
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.
5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.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.
6. Keep Proxmox deep-link route selection on the shared settings-navigation boundary. `frontend-modern/src/components/Settings/settingsNavigationModel.ts` and `frontend-modern/src/components/Settings/useSettingsNavigation.ts` must treat the canonical PBS and PMG Proxmox deep links as agent-selection authority even though those URLs resolve to the shared `infrastructure-operations` tab. Reloading or remounting on a PBS or PMG deep link must not silently fall back to the PVE selector state.
7. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches.
8. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.generated.ts` must be the tracked frontend projection of `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, and `frontend-modern/src/utils/sourcePlatformOptions.ts` must consume that generated projection instead of embedding divergent future-label lists, setup/onboarding path allowlists, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest.
@ -518,10 +518,10 @@ connections` visible as the API-backed alternative for Proxmox and
30. Keep the infrastructure settings platform-connections summary and provider
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/InfrastructureWorkspace.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
infrastructure settings state instead of letting the top-level ledger and
the provider-specific panel issue separate connection fetches.
31. Keep alert-history feature composition on the current owned state contract.
`frontend-modern/src/features/alerts/tabs/HistoryTab.tsx` must react to the
@ -2129,7 +2129,7 @@ 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 Connections & Inventory. `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/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
@ -2138,11 +2138,11 @@ top-level `Direct Proxmox` wording or shell-local provider routes.
That same settings-shell contract also owns the shared platform-connections
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/InfrastructureWorkspace.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
of letting the top-level ledger 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`

View file

@ -698,7 +698,12 @@ carry TrueNAS hostname/version through the shared top-level system grouping,
and preserve platform-managed surfaces such as `proxmox`, `pbs`, `pmg`, and
`truenas` when host telemetry is ignored. Ignore/remove semantics on that
surface remain machine-scoped and may only strip the local `agent`, `docker`,
and `kubernetes` reporting surfaces from the grouped row.
and `kubernetes` reporting surfaces from the grouped row. That same
connected-infrastructure payload now also owns guest-link continuity for host
agents: when an agent is running inside a VM or system container, monitoring
must preserve the canonical linked guest identity on both active and ignored
connected-infrastructure rows instead of forcing settings consumers to infer
guest-backed hosts from labels or hostnames.
path or treat API-backed app workloads as second-class compared with native
Docker reports.
That same boundary now also owns native host-history fallback for API-backed

View file

@ -605,12 +605,9 @@
"frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx",
"frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx",
"frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx",
"frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx",
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx",
"frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx",
"frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx",
"frontend-modern/src/components/Settings/infrastructureSettingsModel.ts",
"frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx",
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx",
@ -901,9 +898,6 @@
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx",
"frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx",
"frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx",
"frontend-modern/src/components/Settings/infrastructureSettingsModel.ts",
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx",
"frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts",

View file

@ -27,6 +27,8 @@ export interface RemovedHostAgent {
id: string;
hostname?: string;
displayName?: string;
linkedVmId?: string;
linkedContainerId?: string;
removedAt: number;
}

View file

@ -1,16 +1,26 @@
import { Component, For } from 'solid-js';
import { Dialog } from '@/components/shared/Dialog';
import type { ConnectionKind, ConnectionMethod } from './connectionsTableModel';
export type AddSystemChoiceKind = 'pve' | 'pbs' | 'pmg' | 'truenas' | 'vmware' | 'agent';
export type AddSystemChoiceMethod = 'api' | 'agent';
export interface AddSystemChoice {
kind: ConnectionKind;
kind: AddSystemChoiceKind;
title: string;
description: string;
method: ConnectionMethod;
method: AddSystemChoiceMethod;
methodLabel: string;
}
export const ADD_SYSTEM_CHOICES: readonly AddSystemChoice[] = [
{
kind: 'agent',
title: 'Install on a host',
description:
'Generate an install token and run the Pulse agent on the host itself. Best for the first monitored system or anything without a supported API.',
method: 'agent',
methodLabel: 'Agent install',
},
{
kind: 'pve',
title: 'Proxmox VE',
@ -46,14 +56,6 @@ export const ADD_SYSTEM_CHOICES: readonly AddSystemChoice[] = [
method: 'api',
methodLabel: 'API connection',
},
{
kind: 'agent',
title: 'Linux or Docker host (agent)',
description:
'Generate an install token and run the Pulse agent on the host itself. Best for anything without a supported API.',
method: 'agent',
methodLabel: 'Agent install',
},
];
interface AddSystemPickerProps {

View file

@ -8,30 +8,23 @@ import {
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { formatRelativeTime } from '@/utils/format';
import type { ConnectionRow, ConnectionStatus } from './connectionsTableModel';
import type { ConnectionRow } from './connectionsTableModel';
interface ConnectionsTableProps {
rows: Accessor<readonly ConnectionRow[]>;
onAddSystem?: () => void;
onManageRow?: (row: ConnectionRow) => void;
}
const STATUS_DOT_CLASS: Record<ConnectionStatus, string> = {
reporting: 'bg-emerald-500',
pending: 'bg-amber-400',
offline: 'bg-slate-400',
error: 'bg-rose-500',
unknown: 'bg-slate-300',
};
export const ConnectionsTable: Component<ConnectionsTableProps> = (props) => {
return (
<Card padding="none" tone="card" class="rounded-md">
<div class="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
<div class="flex flex-col gap-3 border-b border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold text-base-content">Connections</h3>
<h3 class="text-base font-semibold text-base-content">Connections and inventory</h3>
<p class="text-xs text-muted">
Every system Pulse is configured to monitor, regardless of how it reports.
Configured platform connections, active reporting items, and ignored systems in one
ledger.
</p>
</div>
<Show when={props.onAddSystem}>
@ -44,61 +37,96 @@ export const ConnectionsTable: Component<ConnectionsTableProps> = (props) => {
</button>
</Show>
</div>
<Show
when={props.rows().length > 0}
fallback={
<div class="px-4 py-10 text-center text-sm text-muted">
No systems connected yet. Use
Nothing is configured or reporting yet. Use
<span class="mx-1 font-medium text-base-content">+ Add a system</span>
to connect your first one.
to connect the first one.
</div>
}
>
<div class="overflow-auto">
<Table class="min-w-[max-content] w-full divide-y divide-border text-sm">
<Table class="min-w-[1040px] w-full divide-y divide-border text-sm">
<TableHeader class="bg-surface-alt">
<TableRow>
<TableHead class="py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-muted">
Name
System
</TableHead>
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
Kind
Coverage
</TableHead>
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
Method
Collection
</TableHead>
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
Status
</TableHead>
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
Last reported
Last activity
</TableHead>
<Show when={props.onManageRow}>
<TableHead class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wide text-muted">
Manage
</TableHead>
</Show>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border bg-surface">
<For each={props.rows()}>
{(r) => (
{(row) => (
<TableRow class="even:bg-surface-alt">
<TableCell class="py-2.5 pl-4 pr-3">
<div class="font-medium text-base-content">{r.name}</div>
<Show when={r.host && r.host !== r.name}>
<div class="text-xs text-muted">{r.host}</div>
</Show>
<TableCell class="py-3 pl-4 pr-3 align-top">
<div class="min-w-0 space-y-1">
<div class="font-medium text-base-content">{row.name}</div>
<Show when={row.host}>
<div class="truncate text-xs text-muted">{row.host}</div>
</Show>
<div class="text-xs text-muted">{row.subtitle}</div>
</div>
</TableCell>
<TableCell class="px-3 py-2.5 text-base-content">{r.kindLabel}</TableCell>
<TableCell class="px-3 py-2.5 text-base-content">{r.methodLabel}</TableCell>
<TableCell class="px-3 py-2.5">
<span class="inline-flex items-center gap-2">
<span
aria-hidden="true"
class={`inline-block h-2 w-2 rounded-full ${STATUS_DOT_CLASS[r.status]}`}
/>
<span class="text-base-content">{r.statusLabel}</span>
<TableCell class="px-3 py-3 align-top">
<div class="flex flex-wrap gap-1.5">
<For each={row.coverageLabels}>
{(label) => (
<span class="inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-xs font-medium text-base-content">
{label}
</span>
)}
</For>
</div>
</TableCell>
<TableCell class="px-3 py-3 align-top text-base-content">
{row.collectionLabel}
</TableCell>
<TableCell class="px-3 py-3 align-top">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${row.statusClassName}`}
>
{row.statusLabel}
</span>
</TableCell>
<TableCell class="px-3 py-2.5 text-muted">
{formatRelativeTime(r.lastReportedMs, { emptyText: '—' })}
<TableCell class="px-3 py-3 align-top text-muted">
{row.lastActivityText}
</TableCell>
<Show when={props.onManageRow}>
<TableCell class="px-4 py-3 align-top text-right">
<button
type="button"
onClick={() => props.onManageRow?.(row)}
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
{row.manageLabel}
</button>
</TableCell>
</Show>
</TableRow>
)}
</For>

View file

@ -1,13 +0,0 @@
import type { Component } from 'solid-js';
import { InfrastructureInstallerSection } from './InfrastructureInstallerSection';
import { InfrastructureOperationsStateProvider } from './useInfrastructureOperationsState';
export const InfrastructureInstallPanel: Component = () => {
return (
<InfrastructureOperationsStateProvider embedded>
<InfrastructureInstallerSection />
</InfrastructureOperationsStateProvider>
);
};
export default InfrastructureInstallPanel;

View file

@ -1,98 +0,0 @@
import type { Component } from 'solid-js';
import { Card } from '@/components/shared/Card';
interface InfrastructurePlatformConnectionsSummaryCardProps {
pveCount: number;
pbsCount: number;
pmgCount: number;
truenasCount: number;
truenasAvailable: boolean;
vmwareCount: number;
vmwareAvailable: boolean;
onManagePlatformConnections: () => void;
}
export const InfrastructurePlatformConnectionsSummaryCard: Component<
InfrastructurePlatformConnectionsSummaryCardProps
> = (props) => {
return (
<Card padding="lg" class="rounded-xl border border-border shadow-sm">
<div class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<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, TrueNAS,
and VMware all live in the same shared platform-connections workspace.
</p>
</div>
<button
type="button"
onClick={props.onManagePlatformConnections}
class="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"
>
Open platform connections
</button>
</div>
<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"
>
<div class="text-sm font-medium text-base-content">PVE</div>
<div class="mt-1 text-xl font-semibold text-base-content">{props.pveCount}</div>
</div>
<div
class="rounded-lg border border-border bg-surface-alt px-4 py-3"
data-testid="platform-connections-pbs"
>
<div class="text-sm font-medium text-base-content">PBS</div>
<div class="mt-1 text-xl font-semibold text-base-content">{props.pbsCount}</div>
</div>
<div
class="rounded-lg border border-border bg-surface-alt px-4 py-3"
data-testid="platform-connections-pmg"
>
<div class="text-sm font-medium text-base-content">PMG</div>
<div class="mt-1 text-xl font-semibold text-base-content">{props.pmgCount}</div>
</div>
<div
class="rounded-lg border border-border bg-surface-alt px-4 py-3"
data-testid="platform-connections-truenas"
>
<div class="text-sm font-medium text-base-content">TrueNAS</div>
<div
class={`mt-1 ${props.truenasAvailable ? 'text-xl font-semibold text-base-content' : 'text-sm font-medium text-muted'}`}
>
{props.truenasAvailable ? props.truenasCount : 'Disabled'}
</div>
<p class="mt-1 text-xs text-muted">
{props.truenasAvailable
? 'API-backed NAS connections'
: '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>
);
};
export default InfrastructurePlatformConnectionsSummaryCard;

View file

@ -1,41 +0,0 @@
import type { Component } from 'solid-js';
import { AgentProfilesPanel } from './AgentProfilesPanel';
import { InfrastructurePlatformConnectionsSummaryCard } from './InfrastructurePlatformConnectionsSummaryCard';
import { InfrastructureInventorySection } from './InfrastructureInventorySection';
import { InfrastructureStopMonitoringDialog } from './InfrastructureStopMonitoringDialog';
import { InfrastructureOperationsStateProvider } from './useInfrastructureOperationsState';
import type { InfrastructurePlatformSettingsProps } from './proxmoxSettingsModel';
interface InfrastructureReportingPanelProps extends InfrastructurePlatformSettingsProps {
onManagePlatformConnections: () => void;
}
export const InfrastructureReportingPanel: Component<InfrastructureReportingPanelProps> = (
props,
) => {
return (
<div class="space-y-6">
<InfrastructureOperationsStateProvider>
<InfrastructureStopMonitoringDialog />
<InfrastructureInventorySection />
</InfrastructureOperationsStateProvider>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<InfrastructurePlatformConnectionsSummaryCard
pveCount={props.platformConnectionsSummary().pveCount}
pbsCount={props.platformConnectionsSummary().pbsCount}
pmgCount={props.platformConnectionsSummary().pmgCount}
truenasCount={props.platformConnectionsSummary().truenasCount}
truenasAvailable={props.platformConnectionsSummary().truenasAvailable}
vmwareCount={props.platformConnectionsSummary().vmwareCount}
vmwareAvailable={props.platformConnectionsSummary().vmwareAvailable}
onManagePlatformConnections={props.onManagePlatformConnections}
/>
</div>
<AgentProfilesPanel />
</div>
);
};
export default InfrastructureReportingPanel;

View file

@ -1,122 +1,226 @@
import { Component, Match, Show, Switch, createEffect, createMemo, createSignal } from 'solid-js';
import { Component, Show, createEffect, createMemo, createSignal } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
import { InfrastructureInstallPanel } from './InfrastructureInstallPanel';
import { InfrastructureReportingPanel } from './InfrastructureReportingPanel';
import { PlatformConnectionsWorkspace } from './PlatformConnectionsWorkspace';
import { ConnectionsTable } from './ConnectionsTable';
import { AgentProfilesPanel } from './AgentProfilesPanel';
import { AddSystemPicker, type AddSystemChoice } from './AddSystemPicker';
import { buildConnectionRows, type ConnectionRow } from './connectionsTableModel';
import { ConnectionsTable } from './ConnectionsTable';
import {
buildConnectionRows,
type ConnectionRow,
type ConnectionManageAction,
} from './connectionsTableModel';
import { InfrastructureInstallerSection } from './InfrastructureInstallerSection';
import { InfrastructureInventorySection } from './InfrastructureInventorySection';
import { InfrastructureStopMonitoringDialog } from './InfrastructureStopMonitoringDialog';
import { PlatformConnectionsWorkspace } from './PlatformConnectionsWorkspace';
import {
buildInfrastructureWorkspacePath,
getInfrastructureWorkspaceViewFromPath,
} from './infrastructureWorkspaceModel';
import type { InfrastructurePlatformSettingsProps } from './proxmoxSettingsModel';
import {
InfrastructureOperationsStateProvider,
useInfrastructureOperationsContext,
} from './useInfrastructureOperationsState';
export type InfrastructureWorkspaceProps = InfrastructurePlatformSettingsProps;
export const InfrastructureWorkspace: Component<InfrastructureWorkspaceProps> = (props) => {
const scrollSectionIntoView = (section?: HTMLDivElement) => {
if (
typeof window === 'undefined' ||
!section ||
typeof section.scrollIntoView !== 'function'
) {
return;
}
window.requestAnimationFrame(() => {
section.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
};
const proxmoxRouteForKind = (kind: 'pve' | 'pbs' | 'pmg') =>
`/settings/infrastructure/platforms/proxmox/${kind}`;
const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> = (props) => {
const navigate = useNavigate();
const location = useLocation();
const state = useInfrastructureOperationsContext();
const activeView = createMemo(() => getInfrastructureWorkspaceViewFromPath(location.pathname));
const readOnlyWorkspace = createMemo(() => presentationPolicyIsReadOnly());
const [pickerOpen, setPickerOpen] = createSignal(false);
let inventorySectionRef: HTMLDivElement | undefined;
let platformSectionRef: HTMLDivElement | undefined;
let installSectionRef: HTMLDivElement | undefined;
const rows = createMemo<ConnectionRow[]>(() =>
buildConnectionRows({
activeRows: state.activeRows(),
monitoringStoppedRows: state.monitoringStoppedRows(),
pveNodes: props.pveNodes(),
pbsNodes: props.pbsNodes(),
pmgNodes: props.pmgNodes(),
truenasConnections: props.trueNASSettings.connections(),
vmwareConnections: props.vmwareSettings.connections(),
agentResources: props.agentStateResources?.() ?? [],
includeConfigurationRows: !readOnlyWorkspace(),
}),
);
const openProxmoxNode = (nodeKind: 'pve' | 'pbs' | 'pmg', nodeId: string) => {
const nodes =
nodeKind === 'pve'
? props.pveNodes()
: nodeKind === 'pbs'
? props.pbsNodes()
: props.pmgNodes();
const node = nodes.find((candidate) => candidate.id === nodeId) ?? null;
props.onSelectAgent(nodeKind);
props.setCurrentNodeType(nodeKind);
props.setEditingNode(node);
props.setModalResetKey((value) => value + 1);
props.setShowNodeModal(true);
navigate(proxmoxRouteForKind(nodeKind));
scrollSectionIntoView(platformSectionRef);
};
const handleAddSystem = (choice: AddSystemChoice) => {
setPickerOpen(false);
if (choice.kind === 'agent') {
navigate('/settings/infrastructure/install');
navigate(buildInfrastructureWorkspacePath('install'));
scrollSectionIntoView(installSectionRef);
return;
}
if (choice.kind === 'truenas') {
props.trueNASSettings.openCreateDialog();
navigate('/settings/infrastructure/platforms/truenas');
scrollSectionIntoView(platformSectionRef);
return;
}
if (choice.kind === 'vmware') {
props.vmwareSettings.openCreateDialog();
navigate('/settings/infrastructure/platforms/vmware');
scrollSectionIntoView(platformSectionRef);
return;
}
props.onSelectAgent(choice.kind);
navigate(`/settings/infrastructure/platforms/proxmox/${choice.kind}`);
openProxmoxNode(choice.kind, '');
};
const handleManageAction = (action: ConnectionManageAction) => {
switch (action.kind) {
case 'inventory-active':
state.setExpandedRowKey(action.rowKey);
navigate(buildInfrastructureWorkspacePath('operations'));
scrollSectionIntoView(inventorySectionRef);
return;
case 'inventory-ignored':
state.setSelectedIgnoredRowKey(action.rowKey);
navigate(buildInfrastructureWorkspacePath('operations'));
scrollSectionIntoView(inventorySectionRef);
return;
case 'proxmox-node':
openProxmoxNode(action.nodeKind, action.nodeId);
return;
case 'truenas-connection': {
const connection = props
.trueNASSettings
.connections()
.find((candidate) => candidate.id === action.connectionId);
if (connection) {
props.trueNASSettings.openEditDialog(connection);
}
navigate('/settings/infrastructure/platforms/truenas');
scrollSectionIntoView(platformSectionRef);
return;
}
case 'vmware-connection': {
const connection = props
.vmwareSettings
.connections()
.find((candidate) => candidate.id === action.connectionId);
if (connection) {
props.vmwareSettings.openEditDialog(connection);
}
navigate('/settings/infrastructure/platforms/vmware');
scrollSectionIntoView(platformSectionRef);
return;
}
default:
return;
}
};
createEffect(() => {
if (readOnlyWorkspace() && activeView() === 'install') {
if (readOnlyWorkspace() && activeView() !== 'inventory') {
navigate(buildInfrastructureWorkspacePath('inventory'), { replace: true });
return;
}
});
const subviewHeading = createMemo(() => {
switch (activeView()) {
case 'install':
return 'Install on a host';
case 'platforms':
return 'Platform connections';
case 'operations':
return 'Reporting';
default:
return '';
const view = activeView();
if (view === 'inventory') {
return;
}
if (view === 'operations') {
scrollSectionIntoView(inventorySectionRef);
return;
}
if (view === 'platforms') {
scrollSectionIntoView(platformSectionRef);
return;
}
if (view === 'install') {
scrollSectionIntoView(installSectionRef);
}
});
return (
<div class="space-y-6">
<Show when={activeView() !== 'inventory'}>
<div class="flex items-center gap-2 text-sm">
<button
type="button"
onClick={() => navigate(buildInfrastructureWorkspacePath('inventory'))}
class="inline-flex items-center gap-1 rounded-md border border-border bg-surface px-2.5 py-1 text-muted hover:bg-surface-hover hover:text-base-content"
>
<span aria-hidden="true"></span>
<span>Connections and Inventory</span>
</button>
<span class="text-muted" aria-hidden="true">/</span>
<span class="font-medium text-base-content">{subviewHeading()}</span>
</div>
</Show>
<div class="space-y-8">
<ConnectionsTable
rows={rows}
onAddSystem={readOnlyWorkspace() ? undefined : () => setPickerOpen(true)}
onManageRow={(row) => handleManageAction(row.manage)}
/>
<Switch>
<Match when={activeView() === 'inventory'}>
<ConnectionsTable
rows={rows}
onAddSystem={readOnlyWorkspace() ? undefined : () => setPickerOpen(true)}
/>
<AddSystemPicker
isOpen={pickerOpen()}
onClose={() => setPickerOpen(false)}
onSelect={handleAddSystem}
/>
</Match>
<AddSystemPicker
isOpen={pickerOpen()}
onClose={() => setPickerOpen(false)}
onSelect={handleAddSystem}
/>
<Match when={activeView() === 'install'}>
<InfrastructureInstallPanel />
</Match>
<InfrastructureStopMonitoringDialog />
<Match when={activeView() === 'platforms'}>
<div ref={inventorySectionRef}>
<InfrastructureInventorySection />
</div>
<Show when={!readOnlyWorkspace()}>
<div ref={platformSectionRef} class="space-y-6">
<PlatformConnectionsWorkspace {...props} />
</Match>
</div>
<Match when={activeView() === 'operations'}>
<InfrastructureReportingPanel
{...props}
onManagePlatformConnections={() =>
navigate('/settings/infrastructure/platforms')
}
/>
</Match>
</Switch>
<div ref={installSectionRef}>
<InfrastructureInstallerSection />
</div>
<AgentProfilesPanel />
</Show>
</div>
);
};
export const InfrastructureWorkspace: Component<InfrastructureWorkspaceProps> = (props) => {
return (
<InfrastructureOperationsStateProvider embedded>
<InfrastructureWorkspaceContent {...props} />
</InfrastructureOperationsStateProvider>
);
};

View file

@ -4,16 +4,17 @@ import { ConnectionsTable } from '../ConnectionsTable';
import type { ConnectionRow } from '../connectionsTableModel';
const row = (overrides: Partial<ConnectionRow> = {}): ConnectionRow => ({
id: 'pve:pve-1',
kind: 'pve',
kindLabel: 'Proxmox VE',
method: 'api',
methodLabel: 'API',
id: 'row-1',
name: 'production-pve',
subtitle: 'Configured platform connection',
host: '10.0.0.1',
status: 'reporting',
statusLabel: 'Reporting',
lastReportedMs: Date.now() - 5_000,
coverageLabels: ['Proxmox VE data'],
collectionLabel: 'API',
statusLabel: 'Connected',
statusClassName: 'bg-green-100 text-green-800',
lastActivityText: '5s ago',
manageLabel: 'Edit connection',
manage: { kind: 'proxmox-node', nodeKind: 'pve', nodeId: 'pve-1' },
...overrides,
});
@ -24,23 +25,26 @@ describe('ConnectionsTable', () => {
render(() => (
<ConnectionsTable rows={() => []} />
) as any);
expect(screen.getByText(/No systems connected yet/i)).toBeInTheDocument();
expect(screen.getByText(/Nothing is configured or reporting yet/i)).toBeInTheDocument();
expect(screen.queryByRole('table')).toBeNull();
});
it('renders one row per connection with kind, method, and status labels', () => {
it('renders one row per connection with coverage, collection, and status labels', () => {
render(() => (
<ConnectionsTable
rows={() => [
row(),
row({
id: 'agent:tower',
kind: 'agent',
kindLabel: 'Agent host',
method: 'agent',
methodLabel: 'Agent',
id: 'row-2',
name: 'tower',
subtitle: 'Live reporting item',
host: undefined,
coverageLabels: ['Host telemetry', 'Docker runtime data'],
collectionLabel: 'Agent',
statusLabel: 'Reporting',
manageLabel: 'View details',
manage: { kind: 'inventory-active', rowKey: 'agent-tower' },
}),
]}
/>
@ -49,9 +53,13 @@ describe('ConnectionsTable', () => {
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('production-pve')).toBeInTheDocument();
expect(screen.getByText('tower')).toBeInTheDocument();
expect(screen.getAllByText('Reporting')).toHaveLength(2);
expect(screen.getByText('Proxmox VE')).toBeInTheDocument();
expect(screen.getByText('Agent host')).toBeInTheDocument();
expect(screen.getByText('Proxmox VE data')).toBeInTheDocument();
expect(screen.getByText('Host telemetry')).toBeInTheDocument();
expect(screen.getByText('Docker runtime data')).toBeInTheDocument();
expect(screen.getByText('API')).toBeInTheDocument();
expect(screen.getByText('Agent')).toBeInTheDocument();
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(screen.getByText('Reporting')).toBeInTheDocument();
});
it('surfaces the add-system action only when an onAddSystem handler is provided', () => {
@ -64,4 +72,19 @@ describe('ConnectionsTable', () => {
fireEvent.click(button);
expect(onAddSystem).toHaveBeenCalledTimes(1);
});
it('routes per-row manage actions through the provided callback', () => {
const onManageRow = vi.fn();
render(() => (
<ConnectionsTable rows={() => [row()]} onManageRow={onManageRow} />
) as any);
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }));
expect(onManageRow).toHaveBeenCalledWith(
expect.objectContaining({
id: 'row-1',
manage: { kind: 'proxmox-node', nodeKind: 'pve', nodeId: 'pve-1' },
}),
);
});
});

View file

@ -3,14 +3,10 @@ import { render, fireEvent, screen, waitFor, cleanup, within } from '@solidjs/te
import { createSignal } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Router, Route } from '@solidjs/router';
import { InfrastructurePlatformConnectionsSummaryCard } from '../InfrastructurePlatformConnectionsSummaryCard';
import { InfrastructureOperationsController } from '../InfrastructureOperationsController';
import infrastructureInstallPanelSource from '../InfrastructureInstallPanel.tsx?raw';
import infrastructureInstallerSectionSource from '../InfrastructureInstallerSection.tsx?raw';
import infrastructureOperationsControllerSource from '../InfrastructureOperationsController.tsx?raw';
import infrastructureOperationsModelSource from '../infrastructureOperationsModel.tsx?raw';
import infrastructureReportingPanelSource from '../InfrastructureReportingPanel.tsx?raw';
import infrastructurePlatformConnectionsSummaryCardSource from '../InfrastructurePlatformConnectionsSummaryCard.tsx?raw';
import infrastructureInventorySectionSource from '../InfrastructureInventorySection.tsx?raw';
import infrastructureInstallStateSource from '../useInfrastructureInstallState.tsx?raw';
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
@ -97,18 +93,6 @@ describe('InfrastructureOperationsController ownership guardrails', () => {
expect(infrastructureOperationsControllerSource).toContain(
'InfrastructureStopMonitoringDialog',
);
expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection');
expect(infrastructureReportingPanelSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureReportingPanelSource).toContain('InfrastructureInventorySection');
expect(infrastructureReportingPanelSource).toContain('InfrastructureStopMonitoringDialog');
expect(infrastructureReportingPanelSource).toContain(
'./InfrastructurePlatformConnectionsSummaryCard',
);
expect(infrastructureReportingPanelSource).not.toContain('Platform connections');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('Platform connections');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('TrueNAS');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('VMware');
expect(infrastructureOperationsStateSource).toContain('./infrastructureOperationsModel');
expect(infrastructureOperationsStateSource).toContain('./useInfrastructureInstallState');
expect(infrastructureOperationsStateSource).toContain('./useInfrastructureReportingState');
@ -164,61 +148,6 @@ describe('InfrastructureOperationsController ownership guardrails', () => {
});
});
describe('InfrastructurePlatformConnectionsSummaryCard', () => {
it('renders TrueNAS as a counted first-class platform connection', () => {
const onManagePlatformConnections = vi.fn();
render(() => (
<InfrastructurePlatformConnectionsSummaryCard
pveCount={1}
pbsCount={2}
pmgCount={3}
truenasCount={4}
truenasAvailable={true}
vmwareCount={5}
vmwareAvailable={true}
onManagePlatformConnections={onManagePlatformConnections}
/>
));
expect(screen.getByText('PVE')).toBeInTheDocument();
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);
});
it('shows when the TrueNAS integration is disabled instead of implying zero configured systems', () => {
render(() => (
<InfrastructurePlatformConnectionsSummaryCard
pveCount={0}
pbsCount={0}
pmgCount={0}
truenasCount={0}
truenasAvailable={false}
vmwareCount={0}
vmwareAvailable={false}
onManagePlatformConnections={() => {}}
/>
));
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);
});
});
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => mockWsStore,
}));

View file

@ -17,6 +17,7 @@ describe('infrastructure operations model', () => {
name: 'node-a',
hostname: 'node-a.internal',
status: 'active',
linkedVmId: '101',
scopeAgentId: 'agent-1',
surfaces: [
{
@ -51,6 +52,7 @@ describe('infrastructure operations model', () => {
expect(row.rowKey).toBe('agent-agent-1');
expect(row.capabilities).toEqual(['agent', 'pbs']);
expect(row.installFlags).toEqual(['--enable-proxmox', '--proxmox-type pbs']);
expect(row.linkedVmId).toBe('101');
expect(row.searchText).toContain('node-a.internal');
});

View file

@ -1,10 +1,16 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { UnifiedAgentRow } from '../infrastructureOperationsModel';
import { InfrastructureWorkspace } from '../InfrastructureWorkspace';
let mockPathname = '/settings/infrastructure';
const navigateSpy = vi.hoisted(() => vi.fn());
const presentationPolicyIsReadOnlyMock = vi.hoisted(() => vi.fn(() => false));
const setExpandedRowKeySpy = vi.hoisted(() => vi.fn());
const setSelectedIgnoredRowKeySpy = vi.hoisted(() => vi.fn());
let mockActiveRows: UnifiedAgentRow[] = [];
let mockIgnoredRows: UnifiedAgentRow[] = [];
vi.mock('@solidjs/router', async () => {
const actual = await vi.importActual<typeof import('@solidjs/router')>('@solidjs/router');
@ -19,39 +25,96 @@ vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyIsReadOnly: () => presentationPolicyIsReadOnlyMock(),
}));
vi.mock('../InfrastructureInstallPanel', () => ({
InfrastructureInstallPanel: () => <div data-testid="install-panel">install</div>,
vi.mock('../useInfrastructureOperationsState', () => ({
InfrastructureOperationsStateProvider: (props: { children: unknown }) => <>{props.children}</>,
useInfrastructureOperationsContext: () => ({
activeRows: () => mockActiveRows,
monitoringStoppedRows: () => mockIgnoredRows,
setExpandedRowKey: setExpandedRowKeySpy,
setSelectedIgnoredRowKey: setSelectedIgnoredRowKeySpy,
}),
}));
vi.mock('../InfrastructureReportingPanel', () => ({
InfrastructureReportingPanel: () => <div data-testid="reporting-panel">operations</div>,
vi.mock('../InfrastructureInventorySection', () => ({
InfrastructureInventorySection: () => <div data-testid="inventory-section">inventory</div>,
}));
vi.mock('../InfrastructureInstallerSection', () => ({
InfrastructureInstallerSection: () => <div data-testid="install-section">install</div>,
}));
vi.mock('../PlatformConnectionsWorkspace', () => ({
PlatformConnectionsWorkspace: () => <div data-testid="platform-connections">platforms</div>,
PlatformConnectionsWorkspace: () => <div data-testid="platform-section">platforms</div>,
}));
vi.mock('../InfrastructureStopMonitoringDialog', () => ({
InfrastructureStopMonitoringDialog: () => <div data-testid="stop-monitoring-dialog" />,
}));
vi.mock('../AgentProfilesPanel', () => ({
AgentProfilesPanel: () => <div data-testid="agent-profiles">profiles</div>,
}));
const reportingRow = (overrides: Partial<UnifiedAgentRow> = {}): UnifiedAgentRow =>
({
rowKey: 'agent:tower',
id: 'tower',
name: 'tower',
hostname: 'tower.local',
capabilities: ['agent'],
status: 'active',
healthStatus: 'online',
lastSeen: Date.now(),
upgradePlatform: 'linux',
scope: { label: 'Default', category: 'default' },
installFlags: [],
searchText: 'tower',
surfaces: [
{
key: 'agent',
kind: 'agent',
label: 'Host telemetry',
detail: 'Host telemetry',
action: 'stop-monitoring',
controlId: 'tower',
},
],
...overrides,
}) as UnifiedAgentRow;
const trueNASOpenCreateDialogSpy = vi.fn();
const trueNASOpenEditDialogSpy = vi.fn();
const vmwareOpenCreateDialogSpy = vi.fn();
const vmwareOpenEditDialogSpy = vi.fn();
const setShowNodeModalSpy = vi.fn();
const setEditingNodeSpy = vi.fn();
const setCurrentNodeTypeSpy = vi.fn();
const setModalResetKeySpy = vi.fn();
const onSelectAgentSpy = vi.fn();
const baseProps = () =>
({
pveNodes: () => [],
pveNodes: () => [{ id: 'pve-1', name: 'zeus', host: '10.0.0.1', type: 'pve', status: 'connected' }],
pbsNodes: () => [],
pmgNodes: () => [],
agentStateResources: () => [],
trueNASSettings: { connections: () => [] },
vmwareSettings: { connections: () => [] },
platformConnectionsSummary: () => ({
pveCount: 0,
pbsCount: 0,
pmgCount: 0,
truenasCount: 0,
truenasAvailable: true,
vmwareCount: 0,
vmwareAvailable: true,
}),
trueNASSettings: {
connections: () => [{ id: 'tn-1', name: 'Tower NAS', host: '10.0.0.20', enabled: true }],
openCreateDialog: trueNASOpenCreateDialogSpy,
openEditDialog: trueNASOpenEditDialogSpy,
},
vmwareSettings: {
connections: () => [{ id: 'vm-1', name: 'lab-vcenter', host: '10.0.0.30', enabled: true }],
openCreateDialog: vmwareOpenCreateDialogSpy,
openEditDialog: vmwareOpenEditDialogSpy,
},
selectedAgent: () => 'pve',
onSelectAgent: onSelectAgentSpy,
setShowNodeModal: setShowNodeModalSpy,
setEditingNode: setEditingNodeSpy,
setCurrentNodeType: setCurrentNodeTypeSpy,
setModalResetKey: setModalResetKeySpy,
}) as any;
describe('InfrastructureWorkspace', () => {
@ -59,8 +122,20 @@ describe('InfrastructureWorkspace', () => {
navigateSpy.mockReset();
presentationPolicyIsReadOnlyMock.mockReset();
presentationPolicyIsReadOnlyMock.mockReturnValue(false);
setExpandedRowKeySpy.mockReset();
setSelectedIgnoredRowKeySpy.mockReset();
trueNASOpenCreateDialogSpy.mockReset();
trueNASOpenEditDialogSpy.mockReset();
vmwareOpenCreateDialogSpy.mockReset();
vmwareOpenEditDialogSpy.mockReset();
setShowNodeModalSpy.mockReset();
setEditingNodeSpy.mockReset();
setCurrentNodeTypeSpy.mockReset();
setModalResetKeySpy.mockReset();
onSelectAgentSpy.mockReset();
mockPathname = '/settings/infrastructure';
mockActiveRows = [reportingRow()];
mockIgnoredRows = [];
});
afterEach(() => {
@ -70,34 +145,23 @@ describe('InfrastructureWorkspace', () => {
const renderWorkspace = (propOverrides: Record<string, unknown> = {}) =>
render(() => (<InfrastructureWorkspace {...{ ...baseProps(), ...propOverrides }} />) as any);
it('renders the unified connections table at the base infrastructure route', () => {
it('renders the single-page workspace sections at the base infrastructure route', () => {
renderWorkspace();
expect(screen.getByText('Connections')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Add a system/i })).toBeInTheDocument();
expect(screen.queryByTestId('install-panel')).toBeNull();
expect(screen.queryByTestId('platform-connections')).toBeNull();
expect(screen.getByText('Connections and inventory')).toBeInTheDocument();
expect(screen.getByTestId('inventory-section')).toBeInTheDocument();
expect(screen.getByTestId('platform-section')).toBeInTheDocument();
expect(screen.getByTestId('install-section')).toBeInTheDocument();
expect(screen.getByTestId('agent-profiles')).toBeInTheDocument();
});
it('merges every connection source into a single alpha-sorted table', () => {
renderWorkspace({
pveNodes: () => [
{ id: 'n1', name: 'zeus', host: '10.0.0.1', type: 'pve', status: 'connected' },
],
agentStateResources: () => [
{ id: 'a1', name: 'tower', displayName: 'tower', status: 'online', lastSeen: Date.now() },
],
trueNASSettings: {
connections: () => [
{ id: 't1', name: 'nas.home', host: '10.0.0.2', enabled: true, insecureSkipVerify: false, useHttps: true },
],
},
});
it('merges reporting and configured connection rows into the top ledger', () => {
renderWorkspace();
const rowNames = screen.getAllByText(/zeus|tower|nas\.home/).map((el) => el.textContent);
expect(rowNames).toContain('nas.home');
expect(rowNames).toContain('tower');
expect(rowNames).toContain('zeus');
expect(screen.getByText('tower')).toBeInTheDocument();
expect(screen.getByText('zeus')).toBeInTheDocument();
expect(screen.getByText('Tower NAS')).toBeInTheDocument();
expect(screen.getByText('lab-vcenter')).toBeInTheDocument();
});
it('opens the add-system picker when the add button is clicked', () => {
@ -105,113 +169,86 @@ describe('InfrastructureWorkspace', () => {
fireEvent.click(screen.getByRole('button', { name: /Add a system/i }));
expect(screen.getByText('Linux or Docker host (agent)')).toBeInTheDocument();
expect(screen.getByText('Install on a host')).toBeInTheDocument();
expect(screen.getByText('Proxmox VE')).toBeInTheDocument();
expect(screen.getByText('TrueNAS SCALE')).toBeInTheDocument();
});
it('routes the agent-host choice to the dedicated install workspace', () => {
it('routes the agent-host choice to the install section deep link', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add a system/i }));
fireEvent.click(screen.getByText('Linux or Docker host (agent)'));
fireEvent.click(screen.getByText('Install on a host'));
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/install');
});
it('routes TrueNAS and VMware choices to their platform panels', () => {
it('opens provider creation flows directly from the add-system picker', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add a system/i }));
fireEvent.click(screen.getByText('TrueNAS SCALE'));
expect(trueNASOpenCreateDialogSpy).toHaveBeenCalledTimes(1);
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/truenas');
fireEvent.click(screen.getByRole('button', { name: /Add a system/i }));
fireEvent.click(screen.getByText('VMware vSphere or ESXi'));
expect(vmwareOpenCreateDialogSpy).toHaveBeenCalledTimes(1);
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/vmware');
});
it('preselects the Proxmox kind and lands on the kind-scoped platforms route for PVE, PBS, and PMG', () => {
it('opens the proxmox node modal directly from the add-system picker', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add a system/i }));
fireEvent.click(screen.getByText('Proxmox VE'));
expect(onSelectAgentSpy).toHaveBeenCalledWith('pve');
expect(setCurrentNodeTypeSpy).toHaveBeenCalledWith('pve');
expect(setEditingNodeSpy).toHaveBeenCalledWith(null);
expect(setShowNodeModalSpy).toHaveBeenCalledWith(true);
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/proxmox/pve');
fireEvent.click(screen.getByRole('button', { name: /Add a system/i }));
fireEvent.click(screen.getByText('Proxmox Backup Server'));
expect(onSelectAgentSpy).toHaveBeenCalledWith('pbs');
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/proxmox/pbs');
fireEvent.click(screen.getByRole('button', { name: /Add a system/i }));
fireEvent.click(screen.getByText('Proxmox Mail Gateway'));
expect(onSelectAgentSpy).toHaveBeenCalledWith('pmg');
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/proxmox/pmg');
});
it('renders the install workspace when the URL is /install', () => {
mockPathname = '/settings/infrastructure/install';
renderWorkspace();
expect(screen.getByTestId('install-panel')).toBeInTheDocument();
});
it('renders the platforms workspace when the URL is under /platforms', () => {
mockPathname = '/settings/infrastructure/platforms/truenas';
renderWorkspace();
expect(screen.getByTestId('platform-connections')).toBeInTheDocument();
});
it('keeps /operations reachable as a legacy detail route', () => {
mockPathname = '/settings/infrastructure/operations';
renderWorkspace();
expect(screen.getByTestId('reporting-panel')).toBeInTheDocument();
});
it('renders a back-to-inventory header on the install subview', () => {
mockPathname = '/settings/infrastructure/install';
it('opens reporting details from the top ledger for live items', () => {
renderWorkspace();
const backButton = screen.getByRole('button', { name: /Connections and Inventory/i });
expect(backButton).toBeInTheDocument();
expect(screen.getByText('Install on a host')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'View details' }));
fireEvent.click(backButton);
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure');
expect(setExpandedRowKeySpy).toHaveBeenCalledWith('agent:tower');
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/operations');
});
it('renders a back-to-inventory header on the platforms subview', () => {
it('opens saved VMware connections from the top ledger', () => {
renderWorkspace({ pveNodes: () => [], trueNASSettings: { ...baseProps().trueNASSettings, connections: () => [] } });
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }));
expect(vmwareOpenEditDialogSpy).toHaveBeenCalledWith(
expect.objectContaining({ id: 'vm-1', name: 'lab-vcenter' }),
);
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/vmware');
});
it('keeps the same single-page surface on platform deep links', () => {
mockPathname = '/settings/infrastructure/platforms/truenas';
renderWorkspace();
const backButton = screen.getByRole('button', { name: /Connections and Inventory/i });
expect(backButton).toBeInTheDocument();
expect(screen.getByText('Platform connections')).toBeInTheDocument();
fireEvent.click(backButton);
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure');
expect(screen.getByText('Connections and inventory')).toBeInTheDocument();
expect(screen.getByTestId('inventory-section')).toBeInTheDocument();
expect(screen.getByTestId('platform-section')).toBeInTheDocument();
expect(screen.getByTestId('install-section')).toBeInTheDocument();
});
it('does not render the back-to-inventory header on the inventory landing', () => {
mockPathname = '/settings/infrastructure';
renderWorkspace();
expect(screen.queryByRole('button', { name: /^Connections and Inventory$/ })).toBeNull();
});
it('hides the add-system action and redirects install routes in read-only mode', () => {
it('collapses read-only sessions back to inventory and hides setup sections', () => {
presentationPolicyIsReadOnlyMock.mockReturnValue(true);
mockPathname = '/settings/infrastructure/install';
renderWorkspace();
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure', { replace: true });
});
it('still renders the connections table without an add button in read-only mode', () => {
presentationPolicyIsReadOnlyMock.mockReturnValue(true);
mockPathname = '/settings/infrastructure';
renderWorkspace();
expect(screen.getByText('Connections')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Add a system/i })).toBeNull();
expect(screen.queryByTestId('platform-section')).toBeNull();
expect(screen.queryByTestId('install-section')).toBeNull();
expect(screen.queryByTestId('agent-profiles')).toBeNull();
expect(screen.getByTestId('inventory-section')).toBeInTheDocument();
});
});

View file

@ -1,17 +1,55 @@
import { describe, expect, it } from 'vitest';
import type { NodeConfigWithStatus } from '@/types/nodes';
import type { Resource } from '@/types/resource';
import type { TrueNASConnection } from '@/api/truenas';
import type { VMwareConnection } from '@/api/vmware';
import {
agentConnectionRow,
buildConnectionRows,
pbsConnectionRow,
pmgConnectionRow,
pveConnectionRow,
truenasConnectionRow,
vmwareConnectionRow,
} from '../connectionsTableModel';
import type { UnifiedAgentRow } from '../infrastructureOperationsModel';
import { buildConnectionRows } from '../connectionsTableModel';
const activeRow = (overrides: Partial<UnifiedAgentRow> = {}): UnifiedAgentRow =>
({
rowKey: 'agent:tower',
id: 'tower',
name: 'tower',
hostname: 'tower.local',
capabilities: ['agent'],
status: 'active',
healthStatus: 'online',
lastSeen: Date.now(),
upgradePlatform: 'linux',
scope: { label: 'Default', category: 'default' },
installFlags: [],
searchText: 'tower tower.local',
surfaces: [
{
key: 'agent',
kind: 'agent',
label: 'Host telemetry',
detail: 'Host telemetry',
action: 'stop-monitoring',
controlId: 'tower',
},
],
...overrides,
}) as UnifiedAgentRow;
const ignoredRow = (overrides: Partial<UnifiedAgentRow> = {}): UnifiedAgentRow =>
({
...activeRow(),
rowKey: 'removed:tower',
status: 'removed',
removedAt: Date.now(),
surfaces: [
{
key: 'agent',
kind: 'agent',
label: 'Host telemetry',
detail: 'Host telemetry',
action: 'allow-reconnect',
controlId: 'tower',
},
],
...overrides,
}) as UnifiedAgentRow;
const pveNode = (overrides: Partial<NodeConfigWithStatus> = {}): NodeConfigWithStatus =>
({
@ -51,87 +89,124 @@ const vmware = (overrides: Partial<VMwareConnection> = {}): VMwareConnection =>
...overrides,
}) as VMwareConnection;
const agentResource = (overrides: Partial<Resource> = {}): Resource =>
({
id: 'agent-1',
type: 'agent',
name: 'tower',
displayName: 'tower',
platformId: 'agent-tower',
platformType: 'agent',
sourceType: 'agent',
status: 'online',
lastSeen: 1700000000000,
...overrides,
}) as Resource;
describe('connectionsTableModel', () => {
it('maps PVE / PBS / PMG node status into unified reporting states', () => {
expect(pveConnectionRow(pveNode()).status).toBe('reporting');
expect(pveConnectionRow(pveNode({ status: 'pending' })).status).toBe('pending');
expect(pveConnectionRow(pveNode({ status: 'disconnected' })).status).toBe('offline');
expect(pveConnectionRow(pveNode({ status: 'error' })).status).toBe('error');
expect(pbsConnectionRow(pveNode({ type: 'pbs', status: 'offline' })).kind).toBe('pbs');
expect(pmgConnectionRow(pveNode({ type: 'pmg', status: 'connected' })).kind).toBe('pmg');
});
it('prefers the display name over the internal name', () => {
const row = pveConnectionRow(pveNode({ displayName: 'Production cluster', name: 'pve-1' }));
expect(row.name).toBe('Production cluster');
});
it('treats a disabled TrueNAS or VMware connection as offline regardless of poll state', () => {
const tnRow = truenasConnectionRow(truenas({ enabled: false, poll: { lastSuccessAt: '2026-01-01T00:00:00Z' } }));
const vmRow = vmwareConnectionRow(vmware({ enabled: false, poll: { lastSuccessAt: '2026-01-01T00:00:00Z' } }));
expect(tnRow.status).toBe('offline');
expect(vmRow.status).toBe('offline');
});
it('reports pending when a connection has no poll success and no failures', () => {
expect(truenasConnectionRow(truenas()).status).toBe('pending');
expect(vmwareConnectionRow(vmware()).status).toBe('pending');
});
it('reports reporting with a lastReportedMs once a poll has succeeded', () => {
const row = truenasConnectionRow(
truenas({ poll: { lastSuccessAt: '2026-01-01T00:00:00Z', consecutiveFailures: 0 } }),
);
expect(row.status).toBe('reporting');
expect(row.lastReportedMs).toBe(Date.parse('2026-01-01T00:00:00Z'));
});
it('reports error when consecutive failures exceed the tolerance', () => {
expect(
truenasConnectionRow(
truenas({ poll: { lastSuccessAt: '2026-01-01T00:00:00Z', consecutiveFailures: 5 } }),
).status,
).toBe('error');
expect(
vmwareConnectionRow(vmware({ poll: { consecutiveFailures: 1 } })).status,
).toBe('error');
});
it('maps agent resource status into unified reporting states and carries lastSeen', () => {
expect(agentConnectionRow(agentResource()).status).toBe('reporting');
expect(agentConnectionRow(agentResource()).lastReportedMs).toBe(1700000000000);
expect(agentConnectionRow(agentResource({ status: 'offline' })).status).toBe('offline');
expect(agentConnectionRow(agentResource({ status: 'degraded' })).status).toBe('error');
expect(agentConnectionRow(agentResource({ status: 'unknown' })).status).toBe('unknown');
});
it('merges every source into a single alpha-sorted row set with stable composite ids', () => {
it('merges active reporting, ignored rows, and configured connections into one alpha-sorted ledger', () => {
const rows = buildConnectionRows({
activeRows: [activeRow({ name: 'tower' })],
monitoringStoppedRows: [ignoredRow({ name: 'archive' })],
pveNodes: [pveNode({ id: 'a', name: 'zeus' })],
pbsNodes: [pveNode({ id: 'b', type: 'pbs', name: 'backup' })],
pmgNodes: [],
truenasConnections: [truenas({ id: 'c', name: 'mira' })],
vmwareConnections: [vmware({ id: 'd', name: 'apex' })],
agentResources: [agentResource({ id: 'e', name: 'tower', displayName: 'tower' })],
});
expect(rows.map((r) => r.name)).toEqual(['apex', 'backup', 'mira', 'tower', 'zeus']);
expect(new Set(rows.map((r) => r.id)).size).toBe(rows.length);
expect(rows.find((r) => r.name === 'tower')?.method).toBe('agent');
expect(rows.find((r) => r.name === 'apex')?.method).toBe('api');
expect(rows.map((row) => row.name)).toEqual(['apex', 'archive', 'backup', 'mira', 'tower', 'zeus']);
expect(rows.find((row) => row.name === 'archive')).toMatchObject({
subtitle: 'Ignored by Pulse',
manageLabel: 'Review ignored',
});
expect(rows.find((row) => row.name === 'tower')).toMatchObject({
subtitle: 'Live reporting item',
manageLabel: 'View details',
});
expect(rows.find((row) => row.name === 'zeus')).toMatchObject({
subtitle: 'Configured platform connection',
collectionLabel: 'API',
});
});
it('drops configured rows that are already represented by the reporting projection', () => {
const rows = buildConnectionRows({
activeRows: [
activeRow({
name: 'tower',
capabilities: ['truenas'],
surfaces: [
{
key: 'truenas',
kind: 'truenas',
label: 'TrueNAS data',
detail: 'TrueNAS data',
idValue: '10.0.0.20',
},
],
}),
],
monitoringStoppedRows: [],
pveNodes: [],
pbsNodes: [],
pmgNodes: [],
truenasConnections: [truenas()],
vmwareConnections: [],
});
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('tower');
});
it('can collapse to reporting-only rows for read-only sessions', () => {
const rows = buildConnectionRows({
activeRows: [activeRow()],
monitoringStoppedRows: [ignoredRow()],
pveNodes: [pveNode()],
pbsNodes: [],
pmgNodes: [],
truenasConnections: [truenas()],
vmwareConnections: [vmware()],
includeConfigurationRows: false,
});
expect(rows).toHaveLength(2);
expect(rows.every((row) => row.subtitle !== 'Configured platform connection')).toBe(true);
});
it('keeps guest-linked agents out of the top-level infrastructure ledger', () => {
const rows = buildConnectionRows({
activeRows: [
activeRow({ name: 'tower' }),
activeRow({
rowKey: 'agent:guest-101',
id: 'guest-101',
name: 'debian-go',
linkedVmId: '101',
}),
],
monitoringStoppedRows: [
ignoredRow({
rowKey: 'removed:guest-102',
id: 'guest-102',
name: 'archive-guest',
linkedContainerId: '102',
}),
],
pveNodes: [],
pbsNodes: [],
pmgNodes: [],
truenasConnections: [],
vmwareConnections: [],
});
expect(rows.map((row) => row.name)).toEqual(['tower']);
});
it('keeps saved VMware connections visible even though the reporting projection does not own them yet', () => {
const rows = buildConnectionRows({
activeRows: [],
monitoringStoppedRows: [],
pveNodes: [],
pbsNodes: [],
pmgNodes: [],
truenasConnections: [],
vmwareConnections: [vmware({ name: 'lab-vcenter' })],
});
expect(rows).toEqual([
expect.objectContaining({
name: 'lab-vcenter',
coverageLabels: ['VMware data'],
manage: { kind: 'vmware-connection', connectionId: 'vm-1' },
}),
]);
});
});

View file

@ -9,12 +9,9 @@ import infrastructureWorkspaceSource from '../InfrastructureWorkspace.tsx?raw';
import infrastructureWorkspaceModelSource from '../infrastructureWorkspaceModel.ts?raw';
import platformConnectionsWorkspaceSource from '../PlatformConnectionsWorkspace.tsx?raw';
import platformConnectionsModelSource from '../platformConnectionsModel.ts?raw';
import infrastructureInstallPanelSource from '../InfrastructureInstallPanel.tsx?raw';
import infrastructureInstallerSectionSource from '../InfrastructureInstallerSection.tsx?raw';
import infrastructureOperationsControllerSource from '../InfrastructureOperationsController.tsx?raw';
import infrastructureOperationsModelSource from '../infrastructureOperationsModel.tsx?raw';
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';
@ -202,10 +199,7 @@ const extractedModules = [
'../useNodeModalState.ts',
'../InfrastructureWorkspace.tsx',
'../infrastructureWorkspaceModel.ts',
'../InfrastructureInstallPanel.tsx',
'../InfrastructureInstallerSection.tsx',
'../InfrastructureReportingPanel.tsx',
'../InfrastructurePlatformConnectionsSummaryCard.tsx',
'../InfrastructureInventorySection.tsx',
'../InfrastructureActiveRowDetails.tsx',
'../InfrastructureIgnoredRowDetails.tsx',
@ -755,7 +749,6 @@ describe('Settings architecture guardrails', () => {
expect(proxmoxSettingsModelSource).toContain(
'export interface InfrastructurePlatformSettingsProps',
);
expect(proxmoxSettingsModelSource).toContain('platformConnectionsSummary');
expect(proxmoxSettingsModelSource).toContain('trueNASSettings');
expect(proxmoxSettingsModelSource).toContain('./infrastructureSettingsModel');
expect(proxmoxDirectWorkspaceStateSource).toContain(
@ -889,11 +882,14 @@ describe('Settings architecture guardrails', () => {
);
expect(infrastructureWorkspaceSource).toContain('createEffect(() =>');
expect(infrastructureWorkspaceSource).toContain(
"if (readOnlyWorkspace() && activeView() === 'install')",
"if (readOnlyWorkspace() && activeView() !== 'inventory')",
);
expect(infrastructureWorkspaceSource).toContain('InfrastructureInstallPanel');
expect(infrastructureWorkspaceSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureWorkspaceSource).toContain('InfrastructureInventorySection');
expect(infrastructureWorkspaceSource).toContain('InfrastructureInstallerSection');
expect(infrastructureWorkspaceSource).toContain('InfrastructureStopMonitoringDialog');
expect(infrastructureWorkspaceSource).toContain('AgentProfilesPanel');
expect(infrastructureWorkspaceSource).toContain('PlatformConnectionsWorkspace');
expect(infrastructureWorkspaceSource).toContain('InfrastructureReportingPanel');
expect(platformConnectionsWorkspaceSource).toContain('./platformConnectionsModel');
expect(platformConnectionsWorkspaceSource).toContain('./ProxmoxSettingsPanel');
expect(platformConnectionsWorkspaceSource).toContain('./TrueNASSettingsPanel');
@ -902,16 +898,6 @@ describe('Settings architecture guardrails', () => {
expect(platformConnectionsModelSource).toContain(
'export function getPlatformConnectionsViewFromPath',
);
expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection');
expect(infrastructureReportingPanelSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureReportingPanelSource).toContain('InfrastructureInventorySection');
expect(infrastructureReportingPanelSource).toContain('InfrastructureStopMonitoringDialog');
expect(infrastructureReportingPanelSource).toContain(
'./InfrastructurePlatformConnectionsSummaryCard',
);
expect(infrastructureReportingPanelSource).not.toContain('Platform connections');
expect(infrastructureReportingPanelSource).not.toContain('Manage direct connections');
expect(infrastructureOperationsControllerSource).toContain(
'InfrastructureOperationsStateProvider',
);
@ -964,14 +950,6 @@ describe('Settings architecture guardrails', () => {
expect(infrastructureWorkspaceModelSource).toContain(
'export function buildInfrastructureWorkspacePath',
);
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('Platform connections');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('TrueNAS');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('VMware');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain(
'Open platform connections',
);
expect(infrastructureInstallPanelSource).not.toContain('<PageHeader');
expect(infrastructureReportingPanelSource).not.toContain('<PageHeader');
});
it('keeps the infrastructure operations model extracted from the reactive hook owner', () => {
@ -1414,9 +1392,10 @@ describe('Settings architecture guardrails', () => {
source.includes('SettingsPanel') ||
source.includes('CommercialBillingShell') ||
(panelName === 'InfrastructureWorkspace' &&
source.includes('./InfrastructureInstallPanel') &&
source.includes('./InfrastructureInstallerSection') &&
source.includes('./InfrastructureInventorySection') &&
source.includes('./PlatformConnectionsWorkspace') &&
source.includes('./InfrastructureReportingPanel'));
source.includes('./ConnectionsTable'));
expect(usesCanonicalShell, `${panelName} should use a canonical settings panel shell`).toBe(
true,
);
@ -1498,7 +1477,7 @@ describe('Settings architecture guardrails', () => {
'Setup changes stay unavailable in this read-only session.',
);
expect(infrastructureWorkspaceSource).toContain('presentationPolicyIsReadOnly');
expect(infrastructureWorkspaceSource).toContain("activeView() === 'inventory'");
expect(infrastructureWorkspaceSource).toContain("activeView() !== 'inventory'");
});
it('keeps relay shell copy on the shared relay presentation owner', () => {

View file

@ -137,39 +137,6 @@ describe('useSettingsInfrastructurePanelProps', () => {
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();
});
it('derives platform connection counts from the shared infrastructure settings state', () => {
const { hookState, dispose } = mountHook([], {
pveCount: 1,
pbsCount: 2,
pmgCount: 3,
truenasConnections: [{ id: 'truenas-1' }, { id: 'truenas-2' }],
vmwareConnections: [{ id: 'vmware-1' }],
});
const panelProps = hookState.getInfrastructurePanelProps();
expect(panelProps.platformConnectionsSummary()).toEqual({
pveCount: 1,
pbsCount: 2,
pmgCount: 3,
truenasCount: 2,
truenasAvailable: true,
vmwareCount: 1,
vmwareAvailable: true,
});
dispose();
});

View file

@ -1,173 +1,337 @@
import type { Resource } from '@/types/resource';
import type { NodeConfigWithStatus } from '@/types/nodes';
import type { TrueNASConnection } from '@/api/truenas';
import type { VMwareConnection } from '@/api/vmware';
import type { NodeConfigWithStatus } from '@/types/nodes';
import { formatRelativeTime } from '@/utils/format';
import { getUnifiedAgentLastSeenLabel } from '@/utils/unifiedAgentInventoryPresentation';
import {
getUnifiedAgentStatusPresentation,
MONITORING_STOPPED_STATUS_LABEL,
} from '@/utils/unifiedAgentStatusPresentation';
import type { UnifiedAgentRow } from './infrastructureOperationsModel';
export type ConnectionKind = 'pve' | 'pbs' | 'pmg' | 'truenas' | 'vmware' | 'agent';
export type ConnectionMethod = 'api' | 'agent';
export type ConnectionStatus = 'reporting' | 'pending' | 'offline' | 'error' | 'unknown';
type ManagedNodeKind = 'pve' | 'pbs' | 'pmg';
export type ConnectionManageAction =
| { kind: 'inventory-active'; rowKey: string }
| { kind: 'inventory-ignored'; rowKey: string }
| { kind: 'proxmox-node'; nodeKind: ManagedNodeKind; nodeId: string }
| { kind: 'truenas-connection'; connectionId: string }
| { kind: 'vmware-connection'; connectionId: string };
export interface ConnectionRow {
id: string;
kind: ConnectionKind;
kindLabel: string;
method: ConnectionMethod;
methodLabel: string;
name: string;
subtitle: string;
host?: string;
coverageLabels: string[];
collectionLabel: string;
statusLabel: string;
statusClassName: string;
lastActivityText: string;
manageLabel: string;
manage: ConnectionManageAction;
}
const SUCCESS_BADGE_CLASS =
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
const WARNING_BADGE_CLASS =
'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200';
const DANGER_BADGE_CLASS = 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300';
const MUTED_BADGE_CLASS = 'bg-surface text-muted';
const DEFAULT_BADGE_CLASS = 'bg-surface-alt text-base-content';
const PROXMOX_CAPABILITY_KEYS = ['proxmox', 'pbs', 'pmg'] as const;
const AGENT_CAPABILITY_KEYS = ['agent', 'docker', 'kubernetes'] as const;
const formatActivity = (value?: number | string | null) =>
value ? formatRelativeTime(value, { emptyText: '—' }) : '—';
const normalizeConfigKey = (value?: string | null) => value?.trim().toLowerCase() ?? '';
const buildConfiguredRow = (params: {
id: string;
name: string;
host?: string;
status: ConnectionStatus;
subtitle: string;
coverageLabels: string[];
collectionLabel: string;
statusLabel: string;
lastReportedMs?: number;
}
statusClassName: string;
lastActivityText: string;
manageLabel: string;
manage: ConnectionManageAction;
}) => ({
id: params.id,
name: params.name,
subtitle: params.subtitle,
host: params.host && params.host !== params.name ? params.host : undefined,
coverageLabels: params.coverageLabels,
collectionLabel: params.collectionLabel,
statusLabel: params.statusLabel,
statusClassName: params.statusClassName,
lastActivityText: params.lastActivityText,
manageLabel: params.manageLabel,
manage: params.manage,
});
export const CONNECTION_KIND_LABELS: Record<ConnectionKind, string> = {
pve: 'Proxmox VE',
pbs: 'PBS',
pmg: 'PMG',
truenas: 'TrueNAS',
vmware: 'VMware',
agent: 'Agent host',
const collectionLabelFromCapabilities = (capabilities: UnifiedAgentRow['capabilities']) => {
const hasAgent = capabilities.some((capability) =>
AGENT_CAPABILITY_KEYS.includes(capability as (typeof AGENT_CAPABILITY_KEYS)[number]),
);
const hasApi = capabilities.some((capability) =>
PROXMOX_CAPABILITY_KEYS.includes(capability as (typeof PROXMOX_CAPABILITY_KEYS)[number]) ||
capability === 'truenas',
);
if (hasAgent && hasApi) {
return 'Agent + API';
}
if (hasApi) {
return 'API';
}
if (hasAgent) {
return 'Agent';
}
return 'Runtime';
};
export const CONNECTION_METHOD_LABELS: Record<ConnectionMethod, string> = {
api: 'API',
agent: 'Agent',
};
const reportingRow = (row: UnifiedAgentRow): ConnectionRow => {
const statusPresentation = getUnifiedAgentStatusPresentation(row.status, row.healthStatus);
export const CONNECTION_STATUS_LABELS: Record<ConnectionStatus, string> = {
reporting: 'Reporting',
pending: 'Pending',
offline: 'Offline',
error: 'Error',
unknown: 'Unknown',
};
function row(
kind: ConnectionKind,
id: string,
name: string,
method: ConnectionMethod,
status: ConnectionStatus,
host?: string,
lastReportedMs?: number,
): ConnectionRow {
return {
id: `${kind}:${id}`,
kind,
kindLabel: CONNECTION_KIND_LABELS[kind],
method,
methodLabel: CONNECTION_METHOD_LABELS[method],
name,
host,
status,
statusLabel: CONNECTION_STATUS_LABELS[status],
lastReportedMs,
id: row.rowKey,
name: row.name,
subtitle: row.status === 'removed' ? 'Ignored by Pulse' : 'Live reporting item',
host:
row.hostname && row.hostname !== row.name && row.hostname !== row.displayName
? row.hostname
: undefined,
coverageLabels: row.surfaces.map((surface) => surface.label),
collectionLabel: collectionLabelFromCapabilities(row.capabilities),
statusLabel: statusPresentation.label,
statusClassName: statusPresentation.badgeClass,
lastActivityText: getUnifiedAgentLastSeenLabel(row, MONITORING_STOPPED_STATUS_LABEL),
manageLabel: row.status === 'removed' ? 'Review ignored' : 'View details',
manage:
row.status === 'removed'
? { kind: 'inventory-ignored', rowKey: row.rowKey }
: { kind: 'inventory-active', rowKey: row.rowKey },
};
}
};
function mapNodeStatus(status: NodeConfigWithStatus['status']): ConnectionStatus {
const nodeStatusPresentation = (status: NodeConfigWithStatus['status']) => {
switch (status) {
case 'connected':
return 'reporting';
return { label: 'Connected', className: SUCCESS_BADGE_CLASS };
case 'pending':
return 'pending';
return { label: 'Pending', className: WARNING_BADGE_CLASS };
case 'disconnected':
case 'offline':
return 'offline';
return { label: 'Offline', className: MUTED_BADGE_CLASS };
case 'error':
return 'error';
return { label: 'Error', className: DANGER_BADGE_CLASS };
default:
return 'unknown';
return { label: 'Unknown', className: DEFAULT_BADGE_CLASS };
}
}
};
export function pveConnectionRow(node: NodeConfigWithStatus): ConnectionRow {
return row('pve', node.id, node.displayName || node.name, 'api', mapNodeStatus(node.status), node.host);
}
const proxmoxNodeRow = (
node: NodeConfigWithStatus,
kindLabel: string,
nodeKind: ManagedNodeKind,
): ConnectionRow => {
const status = nodeStatusPresentation(node.status);
export function pbsConnectionRow(node: NodeConfigWithStatus): ConnectionRow {
return row('pbs', node.id, node.displayName || node.name, 'api', mapNodeStatus(node.status), node.host);
}
return buildConfiguredRow({
id: `${nodeKind}:${node.id}`,
name: node.displayName || node.name,
host: node.host,
subtitle: 'Configured platform connection',
coverageLabels: [`${kindLabel} data`],
collectionLabel: 'API',
statusLabel: status.label,
statusClassName: status.className,
lastActivityText: '—',
manageLabel: 'Edit connection',
manage: { kind: 'proxmox-node', nodeKind, nodeId: node.id },
});
};
export function pmgConnectionRow(node: NodeConfigWithStatus): ConnectionRow {
return row('pmg', node.id, node.displayName || node.name, 'api', mapNodeStatus(node.status), node.host);
}
function pollBackedStatus(
const pollBackedPresentation = (
enabled: boolean,
lastSuccessAt: string | undefined,
consecutiveFailures: number | undefined,
): { status: ConnectionStatus; lastReportedMs?: number } {
if (!enabled) return { status: 'offline' };
const lastReportedMs = lastSuccessAt ? Date.parse(lastSuccessAt) || undefined : undefined;
const fails = consecutiveFailures ?? 0;
if (fails > 2) return { status: 'error', lastReportedMs };
if (fails > 0 && !lastReportedMs) return { status: 'error' };
if (lastReportedMs) return { status: 'reporting', lastReportedMs };
return { status: 'pending' };
}
) => {
const lastActivityText = formatActivity(lastSuccessAt);
const failures = consecutiveFailures ?? 0;
export function truenasConnectionRow(conn: TrueNASConnection): ConnectionRow {
const { status, lastReportedMs } = pollBackedStatus(
conn.enabled,
conn.poll?.lastSuccessAt,
conn.poll?.consecutiveFailures,
);
return row('truenas', conn.id, conn.name || conn.host, 'api', status, conn.host, lastReportedMs);
}
export function vmwareConnectionRow(conn: VMwareConnection): ConnectionRow {
const { status, lastReportedMs } = pollBackedStatus(
conn.enabled,
conn.poll?.lastSuccessAt,
conn.poll?.consecutiveFailures,
);
return row('vmware', conn.id, conn.name || conn.host, 'api', status, conn.host, lastReportedMs);
}
function mapResourceStatus(status: Resource['status']): ConnectionStatus {
switch (status) {
case 'online':
case 'running':
return 'reporting';
case 'offline':
case 'stopped':
return 'offline';
case 'degraded':
return 'error';
default:
return 'unknown';
if (!enabled) {
return {
label: 'Disabled',
className: MUTED_BADGE_CLASS,
lastActivityText,
};
}
}
export function agentConnectionRow(resource: Resource): ConnectionRow {
return row(
'agent',
resource.id,
resource.displayName || resource.name,
'agent',
mapResourceStatus(resource.status),
undefined,
resource.lastSeen || undefined,
if (failures > 2) {
return {
label: 'Sync failing',
className: DANGER_BADGE_CLASS,
lastActivityText,
};
}
if (failures > 0 && !lastSuccessAt) {
return {
label: 'Error',
className: DANGER_BADGE_CLASS,
lastActivityText: 'No successful sync yet',
};
}
if (lastSuccessAt) {
return {
label: 'Healthy',
className: SUCCESS_BADGE_CLASS,
lastActivityText,
};
}
return {
label: 'Awaiting first sync',
className: WARNING_BADGE_CLASS,
lastActivityText: '—',
};
};
const truenasRow = (connection: TrueNASConnection): ConnectionRow => {
const health = pollBackedPresentation(
connection.enabled,
connection.poll?.lastSuccessAt,
connection.poll?.consecutiveFailures,
);
}
return buildConfiguredRow({
id: `truenas:${connection.id}`,
name: connection.name || connection.host,
host: connection.host,
subtitle: 'Configured platform connection',
coverageLabels: ['TrueNAS data'],
collectionLabel: 'API',
statusLabel: health.label,
statusClassName: health.className,
lastActivityText: health.lastActivityText,
manageLabel: 'Edit connection',
manage: { kind: 'truenas-connection', connectionId: connection.id },
});
};
const vmwareRow = (connection: VMwareConnection): ConnectionRow => {
const health = pollBackedPresentation(
connection.enabled,
connection.poll?.lastSuccessAt,
connection.poll?.consecutiveFailures,
);
return buildConfiguredRow({
id: `vmware:${connection.id}`,
name: connection.name || connection.host,
host: connection.host,
subtitle: 'Configured platform connection',
coverageLabels: ['VMware data'],
collectionLabel: 'API',
statusLabel: health.label,
statusClassName: health.className,
lastActivityText: health.lastActivityText,
manageLabel: 'Edit connection',
manage: { kind: 'vmware-connection', connectionId: connection.id },
});
};
const isTopLevelInfrastructureRow = (row: UnifiedAgentRow) =>
!row.linkedVmId?.trim() && !row.linkedContainerId?.trim();
const reportingConfigKeys = (rows: UnifiedAgentRow[]) => {
const keys = new Set<string>();
for (const row of rows) {
for (const surface of row.surfaces) {
switch (surface.kind) {
case 'proxmox':
case 'pbs':
case 'pmg': {
const rawId = surface.idValue || surface.controlId || row.id;
if (rawId) {
keys.add(`${surface.kind}:${normalizeConfigKey(rawId)}`);
}
break;
}
case 'truenas': {
const rawId = surface.idValue || row.hostname || row.id;
if (rawId) {
keys.add(`truenas:${normalizeConfigKey(rawId)}`);
}
break;
}
default:
break;
}
}
}
return keys;
};
const compareRows = (left: ConnectionRow, right: ConnectionRow) => {
const leftName = left.name.trim().toLowerCase();
const rightName = right.name.trim().toLowerCase();
if (leftName === rightName) {
return left.id.localeCompare(right.id);
}
return leftName.localeCompare(rightName);
};
export interface ConnectionsTableSources {
activeRows: readonly UnifiedAgentRow[];
monitoringStoppedRows: readonly UnifiedAgentRow[];
pveNodes: readonly NodeConfigWithStatus[];
pbsNodes: readonly NodeConfigWithStatus[];
pmgNodes: readonly NodeConfigWithStatus[];
truenasConnections: readonly TrueNASConnection[];
vmwareConnections: readonly VMwareConnection[];
agentResources: readonly Resource[];
includeConfigurationRows?: boolean;
}
export function buildConnectionRows(sources: ConnectionsTableSources): ConnectionRow[] {
return [
...sources.pveNodes.map(pveConnectionRow),
...sources.pbsNodes.map(pbsConnectionRow),
...sources.pmgNodes.map(pmgConnectionRow),
...sources.truenasConnections.map(truenasConnectionRow),
...sources.vmwareConnections.map(vmwareConnectionRow),
...sources.agentResources.map(agentConnectionRow),
].sort((a, b) => a.name.localeCompare(b.name));
const infrastructureRows = [
...sources.activeRows.filter(isTopLevelInfrastructureRow),
...sources.monitoringStoppedRows.filter(isTopLevelInfrastructureRow),
];
const reportingRows = [
...infrastructureRows.map(reportingRow),
];
if (sources.includeConfigurationRows === false) {
return reportingRows.sort(compareRows);
}
const seenConfigKeys = reportingConfigKeys(infrastructureRows);
const configuredRows: ConnectionRow[] = [
...sources.pveNodes
.filter((node) => !seenConfigKeys.has(`proxmox:${normalizeConfigKey(node.id)}`))
.map((node) => proxmoxNodeRow(node, 'Proxmox VE', 'pve')),
...sources.pbsNodes
.filter((node) => !seenConfigKeys.has(`pbs:${normalizeConfigKey(node.id)}`))
.map((node) => proxmoxNodeRow(node, 'PBS', 'pbs')),
...sources.pmgNodes
.filter((node) => !seenConfigKeys.has(`pmg:${normalizeConfigKey(node.id)}`))
.map((node) => proxmoxNodeRow(node, 'PMG', 'pmg')),
...sources.truenasConnections
.filter((connection) => !seenConfigKeys.has(`truenas:${normalizeConfigKey(connection.host)}`))
.map(truenasRow),
...sources.vmwareConnections.map(vmwareRow),
];
return [...reportingRows, ...configuredRows].sort(compareRows);
}

View file

@ -60,6 +60,8 @@ export type UnifiedAgentRow = {
version?: string;
isOutdatedBinary?: boolean;
linkedNodeId?: string;
linkedVmId?: string;
linkedContainerId?: string;
commandsEnabled?: boolean;
agentId?: string;
upgradePlatform: AgentPlatform;
@ -510,6 +512,8 @@ export const rowFromConnectedInfrastructureItem = (
version: item.version,
isOutdatedBinary: item.isOutdatedBinary,
linkedNodeId: item.linkedNodeId,
linkedVmId: item.linkedVmId,
linkedContainerId: item.linkedContainerId,
commandsEnabled: item.commandsEnabled,
agentId: item.scopeAgentId || item.uninstallAgentId,
upgradePlatform: item.upgradePlatform || 'linux',

View file

@ -13,16 +13,6 @@ import type { VMwareSettingsPanelState } from './useVMwareSettingsPanelState';
export type DiscoveryMode = 'auto' | 'custom';
export interface PlatformConnectionsSummary {
pveCount: number;
pbsCount: number;
pmgCount: number;
truenasCount: number;
truenasAvailable: boolean;
vmwareCount: number;
vmwareAvailable: boolean;
}
export interface InfrastructurePlatformSettingsProps {
selectedAgent: Accessor<NodeType>;
onSelectAgent: (agent: NodeType) => void;
@ -41,7 +31,6 @@ export interface InfrastructurePlatformSettingsProps {
pmgNodes: Accessor<NodeConfigWithStatus[]>;
trueNASSettings: TrueNASSettingsPanelState;
vmwareSettings: VMwareSettingsPanelState;
platformConnectionsSummary: Accessor<PlatformConnectionsSummary>;
temperatureMonitoringEnabled: Accessor<boolean>;
triggerDiscoveryScan: (options?: { quiet?: boolean }) => Promise<void>;
loadDiscoveredNodes: () => Promise<void>;

View file

@ -265,6 +265,8 @@ export const useInfrastructureReportingState = () => {
id: agentId,
hostname,
displayName,
linkedVmId: row.linkedVmId,
linkedContainerId: row.linkedContainerId,
removedAt,
},
...prev.filter((item) => item.id !== agentId),
@ -420,6 +422,8 @@ export const useInfrastructureReportingState = () => {
capabilities: ['agent'],
status: 'removed',
removedAt: host.removedAt,
linkedVmId: host.linkedVmId,
linkedContainerId: host.linkedContainerId,
upgradePlatform: 'linux' as AgentPlatform,
scope: getScopeInfo(undefined),
installFlags: [],
@ -986,6 +990,7 @@ export const useInfrastructureReportingState = () => {
inventoryActionNotice,
inventoryStatusSummaryText,
linkedAgents,
monitoringStoppedRows,
openStopMonitoringDialog,
outdatedAgents,
pendingScopeUpdates,

View file

@ -39,16 +39,6 @@ export function useSettingsInfrastructurePanelProps(
.filter((instance): instance is NonNullable<typeof instance> => Boolean(instance)),
);
const platformConnectionsSummary = createMemo(() => ({
pveCount: params.infrastructureSettings.pveNodes().length,
pbsCount: params.infrastructureSettings.pbsNodes().length,
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 => ({
selectedAgent: params.selectedAgent,
onSelectAgent: params.onSelectAgent,
@ -67,7 +57,6 @@ export function useSettingsInfrastructurePanelProps(
pmgNodes: params.infrastructureSettings.pmgNodes,
trueNASSettings: params.infrastructureSettings.trueNASSettings,
vmwareSettings: params.infrastructureSettings.vmwareSettings,
platformConnectionsSummary,
temperatureMonitoringEnabled: params.systemSettings.temperatureMonitoringEnabled,
triggerDiscoveryScan: params.infrastructureSettings.triggerDiscoveryScan,
loadDiscoveredNodes: params.infrastructureSettings.loadDiscoveredNodes,

View file

@ -53,6 +53,8 @@ export interface ConnectedInfrastructureItem {
version?: string;
isOutdatedBinary?: boolean;
linkedNodeId?: string;
linkedVmId?: string;
linkedContainerId?: string;
commandsEnabled?: boolean;
scopeAgentId?: string;
upgradePlatform?: 'linux' | 'macos' | 'freebsd' | 'windows';

View file

@ -720,10 +720,12 @@ type RemovedDockerHost struct {
// RemovedHostAgent tracks a host agent that was deliberately removed and blocked from reporting.
type RemovedHostAgent struct {
ID string `json:"id"`
Hostname string `json:"hostname,omitempty"`
DisplayName string `json:"displayName,omitempty"`
RemovedAt time.Time `json:"removedAt"`
ID string `json:"id"`
Hostname string `json:"hostname,omitempty"`
DisplayName string `json:"displayName,omitempty"`
LinkedVMID string `json:"linkedVmId,omitempty"`
LinkedContainerID string `json:"linkedContainerId,omitempty"`
RemovedAt time.Time `json:"removedAt"`
}
// DockerContainer represents the state of a Docker container on a monitored host.

View file

@ -254,6 +254,8 @@ type ConnectedInfrastructureItemFrontend struct {
Version string `json:"version,omitempty"`
IsOutdatedBinary bool `json:"isOutdatedBinary,omitempty"`
LinkedNodeID string `json:"linkedNodeId,omitempty"`
LinkedVMID string `json:"linkedVmId,omitempty"`
LinkedContainerID string `json:"linkedContainerId,omitempty"`
CommandsEnabled bool `json:"commandsEnabled,omitempty"`
ScopeAgentID string `json:"scopeAgentId,omitempty"`
UpgradePlatform string `json:"upgradePlatform,omitempty"`

View file

@ -516,6 +516,26 @@ func TestConnectedInfrastructureKeepsPlatformConnectionsAndProjectsTrueNAS(t *te
}
}
func TestConnectedInfrastructureSkipsChildPlatformResourceTypes(t *testing.T) {
data, err := os.ReadFile("connected_infrastructure.go")
if err != nil {
t.Fatalf("failed to read connected_infrastructure.go: %v", err)
}
source := string(data)
requiredSnippets := []string{
"func connectedInfrastructureExplicitType(resource unifiedresources.Resource) unifiedresources.ResourceType {",
"explicitType := connectedInfrastructureExplicitType(resource)",
`if explicitType != "" && explicitType != unifiedresources.ResourceTypeAgent {`,
`if explicitType != "" && explicitType != unifiedresources.ResourceTypePBS {`,
`if explicitType != "" && explicitType != unifiedresources.ResourceTypePMG {`,
}
for _, snippet := range requiredSnippets {
if !strings.Contains(source, snippet) {
t.Fatalf("connected_infrastructure.go must contain %q", snippet)
}
}
}
func TestVMwarePollerUsesCanonicalSupplementalIngestOwnership(t *testing.T) {
data, err := os.ReadFile("vmware_poller.go")
if err != nil {

View file

@ -20,6 +20,8 @@ type connectedInfrastructureGroup struct {
version string
isOutdatedBinary bool
linkedNodeID string
linkedVMID string
linkedContainerID string
commandsEnabled bool
scopeAgentID string
upgradePlatform string
@ -42,7 +44,15 @@ func buildConnectedInfrastructure(
applyConnectedInfrastructureIgnoreState(groups, snapshot)
for _, removed := range snapshot.RemovedHostAgents {
item := removedConnectedInfrastructureItem("agent", removed.ID, removed.Hostname, removed.DisplayName, removed.RemovedAt.UnixMilli())
item := removedConnectedInfrastructureItem(
"agent",
removed.ID,
removed.Hostname,
removed.DisplayName,
removed.LinkedVMID,
removed.LinkedContainerID,
removed.RemovedAt.UnixMilli(),
)
item.Surfaces = []models.ConnectedInfrastructureSurfaceFrontend{{
ID: "agent:" + removed.ID,
Kind: "agent",
@ -57,7 +67,15 @@ func buildConnectedInfrastructure(
}
for _, removed := range snapshot.RemovedDockerHosts {
item := removedConnectedInfrastructureItem("docker", removed.ID, removed.Hostname, removed.DisplayName, removed.RemovedAt.UnixMilli())
item := removedConnectedInfrastructureItem(
"docker",
removed.ID,
removed.Hostname,
removed.DisplayName,
"",
"",
removed.RemovedAt.UnixMilli(),
)
item.Surfaces = []models.ConnectedInfrastructureSurfaceFrontend{{
ID: "docker:" + removed.ID,
Kind: "docker",
@ -125,6 +143,8 @@ func buildConnectedInfrastructure(
Version: group.version,
IsOutdatedBinary: group.isOutdatedBinary,
LinkedNodeID: group.linkedNodeID,
LinkedVMID: group.linkedVMID,
LinkedContainerID: group.linkedContainerID,
CommandsEnabled: group.commandsEnabled,
ScopeAgentID: group.scopeAgentID,
UpgradePlatform: group.upgradePlatform,
@ -250,6 +270,8 @@ func addConnectedInfrastructureSurface(
version: connectedInfrastructureVersion(resource),
isOutdatedBinary: connectedInfrastructureIsOutdated(resource),
linkedNodeID: connectedInfrastructureLinkedNodeID(resource),
linkedVMID: connectedInfrastructureLinkedVMID(resource),
linkedContainerID: connectedInfrastructureLinkedContainerID(resource),
commandsEnabled: connectedInfrastructureCommandsEnabled(resource),
scopeAgentID: connectedInfrastructureScopeAgentID(resource),
upgradePlatform: connectedInfrastructureUpgradePlatform(resource),
@ -272,6 +294,12 @@ func addConnectedInfrastructureSurface(
if group.linkedNodeID == "" {
group.linkedNodeID = connectedInfrastructureLinkedNodeID(resource)
}
if group.linkedVMID == "" {
group.linkedVMID = connectedInfrastructureLinkedVMID(resource)
}
if group.linkedContainerID == "" {
group.linkedContainerID = connectedInfrastructureLinkedContainerID(resource)
}
if !group.commandsEnabled {
group.commandsEnabled = connectedInfrastructureCommandsEnabled(resource)
}
@ -319,6 +347,8 @@ func connectedInfrastructureGroupFromFrontend(
version: item.Version,
isOutdatedBinary: item.IsOutdatedBinary,
linkedNodeID: item.LinkedNodeID,
linkedVMID: item.LinkedVMID,
linkedContainerID: item.LinkedContainerID,
commandsEnabled: item.CommandsEnabled,
scopeAgentID: item.ScopeAgentID,
upgradePlatform: item.UpgradePlatform,
@ -333,6 +363,8 @@ func removedConnectedInfrastructureItem(
controlID string,
hostname string,
displayName string,
linkedVMID string,
linkedContainerID string,
removedAt int64,
) models.ConnectedInfrastructureItemFrontend {
name := strings.TrimSpace(displayName)
@ -348,6 +380,8 @@ func removedConnectedInfrastructureItem(
DisplayName: strings.TrimSpace(displayName),
Hostname: strings.TrimSpace(hostname),
Status: "ignored",
LinkedVMID: strings.TrimSpace(linkedVMID),
LinkedContainerID: strings.TrimSpace(linkedContainerID),
RemovedAt: removedAt,
UpgradePlatform: "linux",
UninstallHostname: strings.TrimSpace(hostname),
@ -452,6 +486,20 @@ func connectedInfrastructureLinkedNodeID(resource unifiedresources.Resource) str
return ""
}
func connectedInfrastructureLinkedVMID(resource unifiedresources.Resource) string {
if resource.Agent != nil {
return strings.TrimSpace(resource.Agent.LinkedVMID)
}
return ""
}
func connectedInfrastructureLinkedContainerID(resource unifiedresources.Resource) string {
if resource.Agent != nil {
return strings.TrimSpace(resource.Agent.LinkedContainerID)
}
return ""
}
func connectedInfrastructureCommandsEnabled(resource unifiedresources.Resource) bool {
return resource.Agent != nil && resource.Agent.CommandsEnabled
}
@ -493,6 +541,10 @@ func connectedInfrastructureUpgradePlatform(resource unifiedresources.Resource)
func connectedInfrastructureAgentSurface(
resource unifiedresources.Resource,
) (models.ConnectedInfrastructureSurfaceFrontend, bool) {
explicitType := connectedInfrastructureExplicitType(resource)
if explicitType != "" && explicitType != unifiedresources.ResourceTypeAgent {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
if resource.Agent == nil || strings.TrimSpace(resource.Agent.AgentID) == "" {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
@ -512,6 +564,12 @@ func connectedInfrastructureAgentSurface(
func connectedInfrastructureDockerSurface(
resource unifiedresources.Resource,
) (models.ConnectedInfrastructureSurfaceFrontend, bool) {
explicitType := connectedInfrastructureExplicitType(resource)
if explicitType != "" &&
explicitType != unifiedresources.ResourceTypeAgent &&
explicitType != unifiedresources.ResourceType("docker-host") {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
if resource.Docker == nil {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
@ -543,6 +601,12 @@ func connectedInfrastructureDockerSurface(
func connectedInfrastructureKubernetesSurface(
resource unifiedresources.Resource,
) (models.ConnectedInfrastructureSurfaceFrontend, bool) {
explicitType := connectedInfrastructureExplicitType(resource)
if explicitType != "" &&
explicitType != unifiedresources.ResourceTypeAgent &&
explicitType != unifiedresources.ResourceTypeK8sCluster {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
if resource.Kubernetes == nil || strings.TrimSpace(resource.Kubernetes.ClusterID) == "" {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
@ -562,6 +626,10 @@ func connectedInfrastructureKubernetesSurface(
func connectedInfrastructureProxmoxSurface(
resource unifiedresources.Resource,
) (models.ConnectedInfrastructureSurfaceFrontend, bool) {
explicitType := connectedInfrastructureExplicitType(resource)
if explicitType != "" && explicitType != unifiedresources.ResourceTypeAgent {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
if resource.Proxmox == nil {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
@ -582,6 +650,10 @@ func connectedInfrastructureProxmoxSurface(
func connectedInfrastructurePBSSurface(
resource unifiedresources.Resource,
) (models.ConnectedInfrastructureSurfaceFrontend, bool) {
explicitType := connectedInfrastructureExplicitType(resource)
if explicitType != "" && explicitType != unifiedresources.ResourceTypePBS {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
if resource.PBS == nil {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
@ -602,6 +674,10 @@ func connectedInfrastructurePBSSurface(
func connectedInfrastructurePMGSurface(
resource unifiedresources.Resource,
) (models.ConnectedInfrastructureSurfaceFrontend, bool) {
explicitType := connectedInfrastructureExplicitType(resource)
if explicitType != "" && explicitType != unifiedresources.ResourceTypePMG {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
if resource.PMG == nil {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
@ -622,6 +698,10 @@ func connectedInfrastructurePMGSurface(
func connectedInfrastructureTrueNASSurface(
resource unifiedresources.Resource,
) (models.ConnectedInfrastructureSurfaceFrontend, bool) {
explicitType := connectedInfrastructureExplicitType(resource)
if explicitType != "" && explicitType != unifiedresources.ResourceTypeAgent {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
if resource.TrueNAS == nil {
return models.ConnectedInfrastructureSurfaceFrontend{}, false
}
@ -663,6 +743,13 @@ func connectedInfrastructureSurfaceOrder(kind string) int {
}
}
func connectedInfrastructureExplicitType(resource unifiedresources.Resource) unifiedresources.ResourceType {
if strings.TrimSpace(string(resource.Type)) == "" {
return ""
}
return unifiedresources.CanonicalResourceType(resource.Type)
}
func connectedInfrastructureAgentHostname(resource unifiedresources.Resource) string {
if resource.Agent != nil {
return strings.TrimSpace(resource.Agent.Hostname)

View file

@ -253,3 +253,104 @@ func TestBuildConnectedInfrastructure_ProjectsTrueNASSurface(t *testing.T) {
t.Fatalf("expected truenas surface kind, got %#v", item.Surfaces[0])
}
}
func TestBuildConnectedInfrastructure_PreservesLinkedGuestIdentity(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
items := buildConnectedInfrastructure([]unifiedresources.Resource{
{
ID: "guest-agent",
Name: "debian-go",
LastSeen: now,
Status: unifiedresources.StatusOnline,
Agent: &unifiedresources.AgentData{
AgentID: "guest-agent",
AgentVersion: "1.2.3",
Hostname: "debian-go.local",
LinkedVMID: "101",
LinkedContainerID: "",
},
Identity: unifiedresources.ResourceIdentity{
Hostnames: []string{"debian-go.local"},
},
},
}, models.StateSnapshot{
RemovedHostAgents: []models.RemovedHostAgent{
{
ID: "ignored-guest-agent",
Hostname: "archive-guest.local",
DisplayName: "archive-guest",
LinkedContainerID: "102",
RemovedAt: now,
},
},
})
if len(items) != 2 {
t.Fatalf("expected active and ignored guest rows, got %d", len(items))
}
var active *models.ConnectedInfrastructureItemFrontend
var ignored *models.ConnectedInfrastructureItemFrontend
for i := range items {
item := &items[i]
switch item.Status {
case "active":
active = item
case "ignored":
ignored = item
}
}
if active == nil || ignored == nil {
t.Fatalf("expected both active and ignored connected infrastructure items, got %#v", items)
}
if active.LinkedVMID != "101" {
t.Fatalf("expected active linked VM id to round-trip, got %q", active.LinkedVMID)
}
if ignored.LinkedContainerID != "102" {
t.Fatalf("expected ignored linked container id to round-trip, got %q", ignored.LinkedContainerID)
}
}
func TestBuildConnectedInfrastructure_IgnoresChildPlatformResources(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
items := buildConnectedInfrastructure([]unifiedresources.Resource{
{
ID: "vm-100",
Type: unifiedresources.ResourceTypeVM,
Name: "cloudflared",
LastSeen: now,
Status: unifiedresources.StatusOnline,
Proxmox: &unifiedresources.ProxmoxData{
SourceID: "100",
NodeName: "pve-a",
},
},
{
ID: "storage-local-zfs",
Type: unifiedresources.ResourceTypeStorage,
Name: "local-zfs",
LastSeen: now,
Status: unifiedresources.StatusOnline,
Proxmox: &unifiedresources.ProxmoxData{
SourceID: "local-zfs",
NodeName: "pve-a",
},
},
{
ID: "pbs-datastore-main",
Type: unifiedresources.ResourceTypeStorage,
Name: "main",
LastSeen: now,
Status: unifiedresources.StatusOnline,
PBS: &unifiedresources.PBSData{
InstanceID: "pbs-main",
Hostname: "pbs.local",
},
},
}, models.StateSnapshot{})
if len(items) != 0 {
t.Fatalf("expected child platform resources to stay out of connected infrastructure, got %#v", items)
}
}

View file

@ -208,10 +208,12 @@ func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) {
m.mu.Unlock()
m.state.AddRemovedHostAgent(models.RemovedHostAgent{
ID: hostID,
Hostname: host.Hostname,
DisplayName: host.DisplayName,
RemovedAt: removedAt,
ID: hostID,
Hostname: host.Hostname,
DisplayName: host.DisplayName,
LinkedVMID: host.LinkedVMID,
LinkedContainerID: host.LinkedContainerID,
RemovedAt: removedAt,
})
m.state.RemoveConnectionHealth(hostConnectionPrefix + hostID)

View file

@ -518,6 +518,45 @@ func TestRemoveHostAgentUnbindsToken(t *testing.T) {
}
}
func TestRemoveHostAgent_PreservesLinkedGuestIdentityInRemovedState(t *testing.T) {
t.Helper()
monitor := &Monitor{
state: models.NewState(),
alertManager: alerts.NewManager(),
hostTokenBindings: make(map[string]string),
config: &config.Config{},
}
t.Cleanup(func() { monitor.alertManager.Stop() })
hostID := "host-linked-guest"
monitor.state.UpsertHost(models.Host{
ID: hostID,
Hostname: "guest-host.local",
DisplayName: "guest-host",
LinkedVMID: "101",
LinkedContainerID: "102",
})
if _, err := monitor.RemoveHostAgent(hostID); err != nil {
t.Fatalf("RemoveHostAgent: %v", err)
}
removedHosts := monitor.state.GetRemovedHostAgents()
if len(removedHosts) != 1 {
t.Fatalf("expected one removed host entry, got %d", len(removedHosts))
}
if removedHosts[0].LinkedVMID != "101" {
t.Fatalf("expected linked VM id to persist, got %q", removedHosts[0].LinkedVMID)
}
if removedHosts[0].LinkedContainerID != "102" {
t.Fatalf(
"expected linked container id to persist, got %q",
removedHosts[0].LinkedContainerID,
)
}
}
func TestRemoveHostAgent_KeepsSharedTokenUsedByDockerRuntime(t *testing.T) {
t.Helper()

View file

@ -2445,9 +2445,9 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts"
)
def test_infrastructure_reporting_summary_change_requires_agent_lifecycle(self):
def test_infrastructure_workspace_change_requires_agent_lifecycle(self):
self._assert_platform_connections_workspace_change_requires_agent_lifecycle(
"frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx"
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx"
)
def test_proxmox_direct_workspace_state_change_requires_agent_lifecycle(self):

View file

@ -1888,12 +1888,12 @@ class SubsystemLookupTest(unittest.TestCase):
PLATFORM_CONNECTIONS_WORKSPACE_EXACT_FILES,
)
def test_lookup_paths_assigns_infrastructure_reporting_summary_to_agent_lifecycle(
def test_lookup_paths_assigns_infrastructure_workspace_to_agent_lifecycle(
self,
) -> None:
result = lookup_paths(
[
"frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx"
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx"
]
)
self.assertEqual(result["unowned_runtime_files"], [])