diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index abeea5e42..9eed37dc4 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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/` 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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 951d908f3..2892694c9 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 9e2f6c630..acdb6ebfb 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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` diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index c67f70dec..484a105f2 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 8d30a1725..e8d9b7a3f 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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", diff --git a/frontend-modern/src/api/monitoring.ts b/frontend-modern/src/api/monitoring.ts index 1641cdff2..4ce14a226 100644 --- a/frontend-modern/src/api/monitoring.ts +++ b/frontend-modern/src/api/monitoring.ts @@ -27,6 +27,8 @@ export interface RemovedHostAgent { id: string; hostname?: string; displayName?: string; + linkedVmId?: string; + linkedContainerId?: string; removedAt: number; } diff --git a/frontend-modern/src/components/Settings/AddSystemPicker.tsx b/frontend-modern/src/components/Settings/AddSystemPicker.tsx index 1234732e4..faab794a4 100644 --- a/frontend-modern/src/components/Settings/AddSystemPicker.tsx +++ b/frontend-modern/src/components/Settings/AddSystemPicker.tsx @@ -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 { diff --git a/frontend-modern/src/components/Settings/ConnectionsTable.tsx b/frontend-modern/src/components/Settings/ConnectionsTable.tsx index 350af6dcf..372daf1cc 100644 --- a/frontend-modern/src/components/Settings/ConnectionsTable.tsx +++ b/frontend-modern/src/components/Settings/ConnectionsTable.tsx @@ -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; onAddSystem?: () => void; + onManageRow?: (row: ConnectionRow) => void; } -const STATUS_DOT_CLASS: Record = { - reporting: 'bg-emerald-500', - pending: 'bg-amber-400', - offline: 'bg-slate-400', - error: 'bg-rose-500', - unknown: 'bg-slate-300', -}; - export const ConnectionsTable: Component = (props) => { return ( -
+
-

Connections

+

Connections and inventory

- Every system Pulse is configured to monitor, regardless of how it reports. + Configured platform connections, active reporting items, and ignored systems in one + ledger.

@@ -44,61 +37,96 @@ export const ConnectionsTable: Component = (props) => {
+ 0} fallback={
- No systems connected yet. Use + Nothing is configured or reporting yet. Use + Add a system - to connect your first one. + to connect the first one.
} >
- +
- Name + System - Kind + Coverage - Method + Collection Status - Last reported + Last activity + + + Manage + + - {(r) => ( + {(row) => ( - -
{r.name}
- -
{r.host}
-
+ +
+
{row.name}
+ +
{row.host}
+
+
{row.subtitle}
+
- {r.kindLabel} - {r.methodLabel} - - -
)}
diff --git a/frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx b/frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx deleted file mode 100644 index 8b525f18a..000000000 --- a/frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { Component } from 'solid-js'; -import { InfrastructureInstallerSection } from './InfrastructureInstallerSection'; -import { InfrastructureOperationsStateProvider } from './useInfrastructureOperationsState'; - -export const InfrastructureInstallPanel: Component = () => { - return ( - - - - ); -}; - -export default InfrastructureInstallPanel; diff --git a/frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx b/frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx deleted file mode 100644 index 3e7466c71..000000000 --- a/frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx +++ /dev/null @@ -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 ( - -
-
-
-

Platform connections

-

- Manage the API-backed platforms Pulse polls directly. Proxmox VE, PBS, PMG, TrueNAS, - and VMware all live in the same shared platform-connections workspace. -

-
- -
- -
-
-
PVE
-
{props.pveCount}
-
-
-
PBS
-
{props.pbsCount}
-
-
-
PMG
-
{props.pmgCount}
-
-
-
TrueNAS
-
- {props.truenasAvailable ? props.truenasCount : 'Disabled'} -
-

- {props.truenasAvailable - ? 'API-backed NAS connections' - : 'Explicitly disabled on this Pulse server.'} -

-
-
-
VMware
-
- {props.vmwareAvailable ? props.vmwareCount : 'Disabled'} -
-

- {props.vmwareAvailable - ? 'vCenter platform connections' - : 'Explicitly disabled on this Pulse server.'} -

-
-
-
-
- ); -}; - -export default InfrastructurePlatformConnectionsSummaryCard; diff --git a/frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx b/frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx deleted file mode 100644 index 45389775c..000000000 --- a/frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx +++ /dev/null @@ -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 = ( - props, -) => { - return ( -
- - - - - -
- -
- - -
- ); -}; - -export default InfrastructureReportingPanel; diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index 8d570b2d4..9597f3edb 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -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 = (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 = (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(() => 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 ( -
- -
- - - {subviewHeading()} -
-
+
+ setPickerOpen(true)} + onManageRow={(row) => handleManageAction(row.manage)} + /> - - - setPickerOpen(true)} - /> - setPickerOpen(false)} - onSelect={handleAddSystem} - /> - + setPickerOpen(false)} + onSelect={handleAddSystem} + /> - - - + - +
+ +
+ + +
- +
- - - navigate('/settings/infrastructure/platforms') - } - /> - -
+
+ +
+ + +
); }; + +export const InfrastructureWorkspace: Component = (props) => { + return ( + + + + ); +}; diff --git a/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx b/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx index 7eabca4a3..c4237b689 100644 --- a/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx @@ -4,16 +4,17 @@ import { ConnectionsTable } from '../ConnectionsTable'; import type { ConnectionRow } from '../connectionsTableModel'; const row = (overrides: Partial = {}): 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(() => ( []} /> ) 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(() => ( [ 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(() => ( + [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' }, + }), + ); + }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx index 188a141ab..5ab3f0b94 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx @@ -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(() => ( - - )); - - 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(() => ( - {}} - /> - )); - - 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, })); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index eba6714c0..293cee995 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -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'); }); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 9dbdfa74a..bd7a856f1 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -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('@solidjs/router'); @@ -19,39 +25,96 @@ vi.mock('@/stores/sessionPresentationPolicy', () => ({ presentationPolicyIsReadOnly: () => presentationPolicyIsReadOnlyMock(), })); -vi.mock('../InfrastructureInstallPanel', () => ({ - InfrastructureInstallPanel: () =>
install
, +vi.mock('../useInfrastructureOperationsState', () => ({ + InfrastructureOperationsStateProvider: (props: { children: unknown }) => <>{props.children}, + useInfrastructureOperationsContext: () => ({ + activeRows: () => mockActiveRows, + monitoringStoppedRows: () => mockIgnoredRows, + setExpandedRowKey: setExpandedRowKeySpy, + setSelectedIgnoredRowKey: setSelectedIgnoredRowKeySpy, + }), })); -vi.mock('../InfrastructureReportingPanel', () => ({ - InfrastructureReportingPanel: () =>
operations
, +vi.mock('../InfrastructureInventorySection', () => ({ + InfrastructureInventorySection: () =>
inventory
, +})); + +vi.mock('../InfrastructureInstallerSection', () => ({ + InfrastructureInstallerSection: () =>
install
, })); vi.mock('../PlatformConnectionsWorkspace', () => ({ - PlatformConnectionsWorkspace: () =>
platforms
, + PlatformConnectionsWorkspace: () =>
platforms
, })); +vi.mock('../InfrastructureStopMonitoringDialog', () => ({ + InfrastructureStopMonitoringDialog: () =>
, +})); + +vi.mock('../AgentProfilesPanel', () => ({ + AgentProfilesPanel: () =>
profiles
, +})); + +const reportingRow = (overrides: Partial = {}): 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 = {}) => render(() => () 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(); }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/connectionsTableModel.test.ts b/frontend-modern/src/components/Settings/__tests__/connectionsTableModel.test.ts index 34218bb4b..54096fc7f 100644 --- a/frontend-modern/src/components/Settings/__tests__/connectionsTableModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/connectionsTableModel.test.ts @@ -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 => + ({ + 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 => + ({ + ...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 => ({ @@ -51,87 +89,124 @@ const vmware = (overrides: Partial = {}): VMwareConnection => ...overrides, }) as VMwareConnection; -const agentResource = (overrides: Partial = {}): 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' }, + }), + ]); }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 57e51e162..7bd233210 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -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(' { @@ -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', () => { diff --git a/frontend-modern/src/components/Settings/__tests__/useSettingsInfrastructurePanelProps.test.ts b/frontend-modern/src/components/Settings/__tests__/useSettingsInfrastructurePanelProps.test.ts index b8c16b80f..44b1b5926 100644 --- a/frontend-modern/src/components/Settings/__tests__/useSettingsInfrastructurePanelProps.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useSettingsInfrastructurePanelProps.test.ts @@ -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(); }); diff --git a/frontend-modern/src/components/Settings/connectionsTableModel.ts b/frontend-modern/src/components/Settings/connectionsTableModel.ts index f15270f2e..fbc3dee72 100644 --- a/frontend-modern/src/components/Settings/connectionsTableModel.ts +++ b/frontend-modern/src/components/Settings/connectionsTableModel.ts @@ -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 = { - 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 = { - api: 'API', - agent: 'Agent', -}; +const reportingRow = (row: UnifiedAgentRow): ConnectionRow => { + const statusPresentation = getUnifiedAgentStatusPresentation(row.status, row.healthStatus); -export const CONNECTION_STATUS_LABELS: Record = { - 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(); + + 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); } diff --git a/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx b/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx index d3fe6ce93..7dbad2abe 100644 --- a/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx +++ b/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx @@ -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', diff --git a/frontend-modern/src/components/Settings/proxmoxSettingsModel.ts b/frontend-modern/src/components/Settings/proxmoxSettingsModel.ts index ee2fc7e95..60beffe61 100644 --- a/frontend-modern/src/components/Settings/proxmoxSettingsModel.ts +++ b/frontend-modern/src/components/Settings/proxmoxSettingsModel.ts @@ -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; onSelectAgent: (agent: NodeType) => void; @@ -41,7 +31,6 @@ export interface InfrastructurePlatformSettingsProps { pmgNodes: Accessor; trueNASSettings: TrueNASSettingsPanelState; vmwareSettings: VMwareSettingsPanelState; - platformConnectionsSummary: Accessor; temperatureMonitoringEnabled: Accessor; triggerDiscoveryScan: (options?: { quiet?: boolean }) => Promise; loadDiscoveredNodes: () => Promise; diff --git a/frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx b/frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx index 3658fdc1d..039eb8172 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx +++ b/frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx @@ -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, diff --git a/frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts b/frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts index 386f6ff1d..327ec1f34 100644 --- a/frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts +++ b/frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts @@ -39,16 +39,6 @@ export function useSettingsInfrastructurePanelProps( .filter((instance): instance is NonNullable => 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, diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 29263a19c..dfd1ab4d7 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -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'; diff --git a/internal/models/models.go b/internal/models/models.go index a45e68cfd..b0cb51e97 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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. diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index 87e0cae4a..4381f0c97 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -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"` diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 78cab83b9..58adccde7 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -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 { diff --git a/internal/monitoring/connected_infrastructure.go b/internal/monitoring/connected_infrastructure.go index 580ba84b8..38486df05 100644 --- a/internal/monitoring/connected_infrastructure.go +++ b/internal/monitoring/connected_infrastructure.go @@ -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) diff --git a/internal/monitoring/connected_infrastructure_test.go b/internal/monitoring/connected_infrastructure_test.go index 9c7b4ff31..9533d6d10 100644 --- a/internal/monitoring/connected_infrastructure_test.go +++ b/internal/monitoring/connected_infrastructure_test.go @@ -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) + } +} diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index b9a66e2d3..a78fbeb1b 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -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) diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 287e3ffa6..3d54c6131 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -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() diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 8e296627f..577a89436 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -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): diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index cb6602d64..f9372a65b 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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"], [])