From 5016cbc2ba0faaecc62190b4e668beedd537151e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 22 May 2026 20:26:56 +0100 Subject: [PATCH] Add vSphere network inventory Project vCenter network inventory through canonical resources and add the vSphere Networks table backed by vCenter network topology. Align resource presentation coalescing so state and resource APIs share the same host contract. --- .../internal/PLATFORM_SUPPORT_MANIFEST.json | 3 +- .../v6/internal/PLATFORM_SUPPORT_MODEL.md | 2 +- ...VCENTER_PHASE1_RESOURCE_PROJECTION_SPEC.md | 55 ++- .../v6/internal/subsystems/agent-lifecycle.md | 9 + .../v6/internal/subsystems/ai-runtime.md | 7 + .../v6/internal/subsystems/api-contracts.md | 39 +- .../v6/internal/subsystems/cloud-paid.md | 6 + .../subsystems/frontend-primitives.md | 8 + .../v6/internal/subsystems/monitoring.md | 19 +- .../internal/subsystems/storage-recovery.md | 17 +- .../internal/subsystems/unified-resources.md | 30 +- frontend-modern/src/AppLayout.tsx | 2 +- .../src/__tests__/App.architecture.test.ts | 11 +- .../resourcesHotPath.guardrails.test.ts | 14 +- .../src/api/__tests__/vmware.test.ts | 2 + frontend-modern/src/api/vmware.ts | 6 +- .../resourceDetailDrawerVmwareModel.test.ts | 29 ++ .../resourceDetailDrawerVmwareModel.ts | 29 +- .../__tests__/reportingResourceTypes.test.ts | 1 + .../__tests__/CommandPaletteModal.test.tsx | 24 +- .../components/shared/commandPaletteModel.ts | 26 +- .../shared/useCommandPaletteState.ts | 1 + .../platformOverviewLayout.guardrails.test.ts | 1 + .../platformPage/sharedPlatformPage.tsx | 3 + .../src/features/vmware/VmwarePageSurface.tsx | 17 +- .../features/vmware/VsphereNetworksTable.tsx | 292 +++++++++++++++ .../__tests__/VsphereNetworksTable.test.tsx | 82 ++++ .../vmware/__tests__/vmwarePageModel.test.ts | 57 ++- .../src/features/vmware/vmwarePageModel.ts | 84 ++++- .../src/types/__tests__/resource.test.ts | 9 + frontend-modern/src/types/resource.ts | 6 + ...frastructureOnboardingPresentation.test.ts | 2 +- .../platformSupportManifest.generated.ts | 8 +- .../src/utils/reportingResourceTypes.ts | 1 + internal/api/contract_test.go | 9 +- internal/api/platform_mock_connections.go | 1 + internal/api/resources.go | 59 ++- internal/api/resources_frontend_types_test.go | 1 + internal/api/vmware_handlers.go | 1 + internal/api/vmware_handlers_test.go | 9 +- internal/mock/fixture_graph.go | 21 ++ internal/mock/platform_fixtures.go | 7 + internal/mock/platform_fixtures_test.go | 16 + .../mock/platform_support_contract_test.go | 4 +- .../monitoring/canonical_guardrails_test.go | 6 +- internal/monitoring/monitor.go | 317 +--------------- internal/monitoring/vmware_poller.go | 4 + internal/monitoring/vmware_poller_test.go | 3 +- internal/unifiedresources/clone.go | 4 + internal/unifiedresources/clone_test.go | 10 + .../unifiedresources/code_standards_test.go | 10 +- internal/unifiedresources/policy_metadata.go | 3 + .../unifiedresources/presentation_coalesce.go | 353 ++++++++++++++++++ .../presentation_coalesce_test.go | 175 +++++++++ internal/unifiedresources/registry.go | 39 ++ internal/unifiedresources/registry_test.go | 13 + internal/unifiedresources/types.go | 10 +- internal/vmware/activity_changes.go | 4 + internal/vmware/client.go | 8 + internal/vmware/client_signals.go | 23 ++ internal/vmware/client_test.go | 78 +++- internal/vmware/client_topology.go | 69 ++++ internal/vmware/fixtures.go | 129 +++++++ internal/vmware/provider.go | 109 +++++- internal/vmware/provider_test.go | 65 +++- 65 files changed, 2025 insertions(+), 437 deletions(-) create mode 100644 frontend-modern/src/features/vmware/VsphereNetworksTable.tsx create mode 100644 frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx create mode 100644 internal/unifiedresources/presentation_coalesce.go create mode 100644 internal/unifiedresources/presentation_coalesce_test.go diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json index 8acffb798..bedf32a45 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json @@ -299,7 +299,8 @@ "canonical_projections": [ "agent", "vm", - "storage" + "storage", + "network" ], "support_floor": { "setup": "supported", diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md index 0676d5cd6..c929b3cce 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md @@ -450,7 +450,7 @@ acceptable phase-1 model for implementation and proof. | Platform | Family | Entry point | Primary mode | Optional augmentation | Canonical projections | Admission state | Readiness stage | | ---------------- | ------ | ------------------------- | ------------ | -------------------------------------- | ------------------------ | ---------------------------------------------- | ----------------- | -| `vmware-vsphere` | VMware | `vCenter` only in phase 1 | `api-backed` | host or guest agent later, not phase 1 | `agent`, `vm`, `storage` | architecture locked, not yet in support matrix | `first-lab-ready` | +| `vmware-vsphere` | VMware | `vCenter` only in phase 1 | `api-backed` | host or guest agent later, not phase 1 | `agent`, `vm`, `storage`, `network` | architecture locked, not yet in support matrix | `first-lab-ready` | ## VMware vSphere Proposed Phase-1 Floor diff --git a/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_RESOURCE_PROJECTION_SPEC.md b/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_RESOURCE_PROJECTION_SPEC.md index 1898bdb9d..439e0bf7b 100644 --- a/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_RESOURCE_PROJECTION_SPEC.md +++ b/docs/release-control/v6/internal/VMWARE_VCENTER_PHASE1_RESOURCE_PROJECTION_SPEC.md @@ -25,10 +25,11 @@ Phase-1 VMware support is only valid if all of these stay true: 3. ESXi hosts project as canonical `agent` 4. virtual machines project as canonical `vm` 5. datastores project as canonical `storage` -6. datacenter, cluster, cluster HA/DRS service state, folder, resource pool, +6. vCenter networks project as canonical `network` +7. datacenter, cluster, cluster HA/DRS service state, folder, resource pool, and `vCenter` itself remain topology or relationship metadata, not top-level Pulse resources -7. `physical-disk`, `system-container`, `app-container`, and recovery +8. `physical-disk`, `system-container`, `app-container`, and recovery artifacts remain out of phase 1 ## Canonical Source Contract @@ -59,6 +60,7 @@ distinct `vCenter` environments. | `HostSystem` / ESXi host | `agent` | yes | yes | host inventory, runtime state, health, metrics/history | | `VirtualMachine` | `vm` | yes | yes | workload inventory, runtime state, guest identity when available, snapshot-tree visibility | | `Datastore` | `storage` | yes | yes | inventory, capacity/free-space, accessibility, relationships | +| `Network` | `network` | yes | yes | inventory, type, placement, host attachments, VM attachments, health signals | | `vCenter` | none | no | no | connection and poll authority only | | `Datacenter` | none | metadata only | no | placement and topology context | | `ClusterComputeResource` | none | metadata only | no | placement, grouping, HA/DRS service context | @@ -176,6 +178,30 @@ official APIs. Exact phase-1 extraction of host mounts and VM-to-datastore usage needs live validation so Pulse does not promise more placement fidelity than the chosen collection path can actually deliver. +## vCenter Network To `network` + +What the APIs clearly support: + +1. `GET /api/vcenter/network` returns network identifier, name, and type +2. VI JSON `vim.Network` exposes inventory placement through `parent`, attached + hosts through `host`, and attached VMs through `vm` +3. VI JSON managed-entity signal paths can expose overall status, alarms, + recent tasks, and recent events for network objects when the vCenter + account has permission + +Phase-1 projection rule: + +1. one vCenter network becomes one canonical `network` +2. the provider-scoped network identifier is the VMware-side primary identity + for that resource inside the VMware source +3. network type, datacenter/folder placement, host attachments, and VM + attachments belong under the shared `vmware` facet for read-side monitoring +4. network rows are descriptive topology and health inventory; phase 1 must not + introduce switch, portgroup, distributed-switch, or network-control + resource types +5. network telemetry/history remains out of scope unless a later governed slice + proves a shared `network` metrics contract + ## Topology And Relationship Rules These VMware concepts remain metadata or relationships in phase 1: @@ -186,7 +212,6 @@ These VMware concepts remain metadata or relationships in phase 1: 4. folder 5. resource pool 6. datastore cluster / storage pod -7. network objects Cluster HA and DRS flags are properties of the cluster placement context. They may be rendered on canonical hosts and VMs whose placement resolves to that @@ -197,9 +222,10 @@ That means phase-1 VMware work must not add: 1. `esxi-host` 2. `vsphere-vm` 3. `vsphere-datastore` -4. `vsphere-cluster` -5. `vsphere-datacenter` -6. `vsphere-resource-pool` +4. `vsphere-network` +5. `vsphere-cluster` +6. `vsphere-datacenter` +7. `vsphere-resource-pool` If a future slice wants one of those to become top-level, it needs a separate governed admission decision because it would expand the shared Pulse resource @@ -218,8 +244,8 @@ Phase-1 alert rule: 1. VMware alarm and health signals may surface only through the shared alert and incident model -2. alert-backed investigation must attach to canonical `agent`, `vm`, or - `storage` resources +2. alert-backed investigation must attach to canonical `agent`, `vm`, + `storage`, or `network` resources 3. cluster-, datacenter-, or folder-scoped VMware alarm context may inform the incident, but it must not create synthetic top-level VMware incident resources in phase 1 @@ -244,8 +270,10 @@ Phase-1 telemetry rule: 2. VM telemetry must land on the shared `vm` metrics/history path 3. datastore state or capacity-history signals must land on the shared `storage` path -4. phase-1 VMware work must not create a `vmware-host`, `vmware-vm`, or - `vmware-datastore` history store +4. vCenter network inventory may land on the shared `network` resource path, + but phase 1 does not claim network metrics/history +5. phase-1 VMware work must not create a `vmware-host`, `vmware-vm`, + `vmware-datastore`, or `vmware-network` history store Validation note: @@ -277,7 +305,8 @@ The architecture is stable, but these points still require live proof: 1. exact cross-version identity floor for ESXi hosts when `host_uuid` is not available from the chosen supported version -2. exact relationship extraction path for VM-to-datastore usage and +2. exact relationship extraction path for VM-to-datastore usage, + network-to-host attachment, network-to-VM attachment, and datastore-to-host mount fidelity 3. exact alarm-to-canonical-resource attachment rule for cluster- or datacenter-scoped alarms @@ -313,3 +342,7 @@ not compensate by inventing provider-local resource types or sidecar products. [Performance Manager Query Perf Composite](https://developer.broadcom.com/xapis/virtual-infrastructure-json-api/latest/sdk/vim25/release/PerformanceManager/moId/QueryPerfComposite/post/) 12. cluster inventory and HA/DRS service state: [Vcenter Cluster list](https://developer.broadcom.com/xapis/vsphere-automation-api/latest/api/vcenter/cluster/get/) +13. network inventory: + [Vcenter Network list](https://developer.broadcom.com/xapis/vsphere-automation-api/latest/api/vcenter/network/get/) +14. network placement and attachments: + [vim.Network](https://developer.broadcom.com/xapis/virtual-storage-lifecycle-management-api/latest/vim.Network.html) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index eb414297a..8cea889fa 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -180,6 +180,15 @@ impact previews, or cross-organization sharing only through the API and unified-resource contracts; lifecycle code must not treat that share as an agent install target, a command-agent authority, or a reason to widen setup tokens. +VMware vSphere `network` resources follow that same lifecycle boundary. A +vCenter connection may project networks into platform tables, resource +pickers, Assistant context, or monitored-system previews through the shared +resource contract, but those rows are provider inventory facts only. Lifecycle +surfaces must not interpret a vSphere network as a host enrollment candidate, +Pulse Agent install target, fleet command authority, or setup-token scope. When +host-shaped records are coalesced for presentation, lifecycle consumers must +use the API/unified-resource presentation result and must not create their own +merge that bypasses registry-owned report exclusions. The node setup modal boundary must keep guided setup and manual credential submission separate. For new PVE/PBS setup, API Inventory and Host Telemetry diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 43db56491..1c8f7d9e3 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -138,6 +138,13 @@ runtime cost control, and shared AI transport surfaces. `AppLayout.tsx` and via the canonical Patrol path, and platform pages must not replicate Patrol findings, Assistant prompts, or AI launcher affordances inside their own chrome. + The vSphere Networks sub-route follows the same AI runtime boundary as the + vSphere overview, datastore, health, and activity routes. Network rows may + seed Assistant or Patrol context only as shared `network` unified-resource + references read through `/api/resources*` and the common handoff payloads; + the VMware page must not introduce VMware-local AI prompts, a provider + model picker, or a vSphere-specific chat/runtime route just because + networks are now rendered as a first-class API-native table. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 01f10952a..2242d2e39 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1521,6 +1521,16 @@ the canonical monitored-system blocked payload. ## Current State +VMware vSphere phase-1 inventory now reaches the product through shared API +contracts rather than provider-local read routes. `/api/vmware/connections` +owns saved vCenter connection health and observed counts, while canonical +`agent`, `vm`, `storage`, and `network` resources flow through +`/api/resources`, `/api/resources/stats`, `/api/state`, and shared Assistant +mention payloads. Host-shaped presentation also shares the +`ResourceRegistry.ListForPresentation` / `CoalescePresentationHostResources` +boundary so state and resource list responses agree without bypassing +registry-owned report exclusions. + TrueNAS platform-connections responses treat native VMs and network shares as first-class observed contribution facets alongside systems, pools, datasets, apps, disks, and recovery artifacts. The frontend TrueNAS API client must @@ -2622,9 +2632,9 @@ implementation contract under `internal/api/vmware_handlers.go`, `internal/api/router.go`, `internal/api/router_routes_registration.go`, and `frontend-modern/src/api/vmware.ts`. The list response must carry one redacted stored connection shape plus canonical `poll` health and `observed` -contribution summary (`hosts`, `vms`, `datastores`, `viRelease`) so the shared -settings workspace can render VMware status without another provider-local -inventory route. When base inventory succeeds but optional signal or topology +contribution summary (`hosts`, `vms`, `datastores`, `networks`, `viRelease`) so +the shared settings workspace can render VMware status without another +provider-local inventory route. When base inventory succeeds but optional signal or topology reads degrade, that same `observed` payload must carry the canonical partial-success shape (`degraded`, `issueCount`, summarized `issues`) instead of collapsing the whole connection to `poll.lastError` or pretending the @@ -2683,8 +2693,8 @@ helpers without dropping them on edit-save. That same VMware API boundary now also owns the phase-1 runtime negative space around inventory projection. `internal/api/router.go` may wire VMware's supplemental ingest into the shared `/api/resources` surface so canonical -`agent`, `vm`, and `storage` records can appear elsewhere in Pulse, but the -public backend contract must still stop at `/api/vmware/connections*` for +`agent`, `vm`, `storage`, and `network` records can appear elsewhere in Pulse, +but the public backend contract must still stop at `/api/vmware/connections*` for provider-local routes. Phase 1 must not add public `/api/vmware/resources`, `/api/vmware/history`, `/api/vmware/alerts`, or VMware-specific recovery transport just because the internal poller now projects VMware-backed @@ -2693,10 +2703,12 @@ That same shared API contract now also owns Assistant mention transport for those canonical resources. `frontend-modern/src/api/aiChat.ts`, `internal/api/ai_handler.go`, and `internal/api/ai_handlers.go` must preserve structured mention payloads for canonical `agent`, `vm`, `storage`, and -`app-container` resources as shared unified-resource IDs plus shared mention -types, so VMware-backed reads stay on `/api/ai/*` and `/api/resources*` -instead of introducing VMware-only mention payloads or provider-local -inventory reads under `/api/vmware/*`. +`network` resources as shared unified-resource IDs plus shared mention types, +so VMware-backed reads stay on `/api/ai/*` and `/api/resources*` instead of +introducing VMware-only mention payloads or provider-local inventory reads +under `/api/vmware/*`. Runtime-specific container/app mentions remain shared +unified-resource mentions as well; VMware network inventory does not create a +provider-local mention family. That same `/api/ai/chat` payload boundary owns per-request execution-mode overrides. Dashboard Pulse Brief and other scoped handoffs may include `autonomous_mode:false` on the chat request to force approval-required command @@ -4391,6 +4403,15 @@ registry-clone work on the hot path. That same governed resource contract now also includes backend-derived `policy` and `aiSafeSummary` fields, and list, detail, and child payloads must source those values from canonical unified resource metadata rather than from frontend- or AI-local heuristics. +`/api/resources`, `/api/resources/stats`, and `/api/state` also share the same +presentation coalescing boundary for host-shaped resources. When multiple +authoritative reports describe the same host identity, resource handlers and +state serialization must consume `ResourceRegistry.ListForPresentation` or the +shared `CoalescePresentationHostResources` helper rather than reimplementing a +route-local merge. Report-merge exclusions created from canonical ingestion +remain authoritative at that boundary, so presentation coalescing may remove +duplicate host fragments but must not rejoin resources the registry has already +recorded as intentionally separate. That same resource-handler seed contract must also stay on canonical unified resource ownership for tenant-scoped requests: once a tenant state provider implements `UnifiedResourceSnapshotForTenant`, `/api/resources` may not fall diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index ad9057a22..0f2d29343 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -140,6 +140,12 @@ cloud-specific enforcement rules. zero-delta and removal-only TrueNAS or VMware previews as non-consuming or capacity-freeing changes rather than warning users that a disabled connection still grows monitored-system usage. + VMware vSphere network inventory is product navigation and resource context, + not a separate commercial unit. The `/vmware/networks` route may display + API-native network rows and those rows may contribute to connection + previews, but cloud-paid surfaces must continue to meter only the governed + monitored-system grouping result. Network child-resource volume must not + become a hosted usage cap, upgrade prompt, or billing-admission condition. That same shared signup boundary also owns the public privacy floor: syntactically valid `/api/public/signup` requests resolve to one uniform `202 Accepted` Pulse Account response whether provisioning/email side diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 2af709d9f..9e02d73e5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -268,6 +268,14 @@ filter chip or an explicit page-owned advanced selector. Platform-owned filter selectors must also exclude facet options from other platform scopes, even when the underlying shared surface is mounted from the same Workloads or Storage component. +Platform sub-routes that add native provider inventory must stay on the shared +platform page and table primitives. The vSphere Networks surface routes through +`/vmware/networks`, the shared platform tab model, the command palette +navigation model, and the canonical table/detail primitives rather than a card +deck or VMware-local page shell. Its rows are canonical `network` resources in +the shared reportable/resource vocabulary, so source badges, resource pickers, +command-palette search, table chrome, and detail disclosure must all consume +shared primitives before VMware-specific presentation logic. Patrol's primary assessment strip is descriptive only; it must not render a Patrol-authored recommended next step, suggested prompt chips, or a secondary action band inside the assessment shell. If the same assessment opens diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index b613442ab..c13fd6f26 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -306,9 +306,10 @@ That same VMware monitoring boundary now also includes the canonical telemetry rule. ESXi host metrics and history belong on the shared `agent` path, VM metrics and history belong on the shared `vm` path, and datastore capacity/accessibility history belongs on the shared `storage` path. VMware -phase-1 work must not create `vmware-host`, `vmware-vm`, or -`vmware-datastore` history stores just because the collection APIs differ from -other platforms. +network inventory belongs on the shared `network` resource path, but phase 1 +does not claim VMware network metrics or history. VMware phase-1 work must not +create `vmware-host`, `vmware-vm`, `vmware-datastore`, or `vmware-network` +history stores just because the collection APIs differ from other platforms. That same VMware monitoring boundary now also includes the source and identity rule. Runtime collection may authenticate to `vCenter`, call multiple VMware API families, and gather several object classes, but the emitted state must @@ -592,19 +593,19 @@ parallel VMware event store or provider-only incident timeline. That same VMware monitoring boundary also includes the topology-signal rule. Signals collected from non-projected VMware topology objects such as clusters, folders, or datacenters may inform investigation only when they can be -attached honestly to canonical `agent`, `vm`, or `storage` resources; the -collector must not solve that ambiguity by creating VMware-only top-level -incident targets. +attached honestly to canonical `agent`, `vm`, `storage`, or `network` +resources; the collector must not solve that ambiguity by creating VMware-only +top-level incident targets. That same monitoring boundary now also has a concrete detail-enrichment seam. `internal/vmware/client.go`, `internal/vmware/client_topology.go`, and `internal/vmware/provider.go` may use the official vCenter Automation API plus VI JSON `name`, `parent`, `runtime`, `resourcePool`, `datastore`, `host`, -`vm`, and datastore-summary paths to enrich canonical VMware-backed resources +`vm`, `Network.host`, `Network.vm`, and datastore-summary paths to enrich canonical VMware-backed resources with placement, guest identity, and storage consumer context. That enrichment remains best-effort provider detail on the shared VMware source: it must not create a second topology cache, a VMware-only placement store, or a -parallel guest-identity model outside the canonical `agent` / `vm` / `storage` -resource graph. +parallel guest-identity model outside the canonical `agent` / `vm` / +`storage` / `network` resource graph. The monitor adapter now also acts as the canonical bridge from live registry rebuilds and supplemental ingest into the unified-resource timeline. That means diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 26fc414a8..050651455 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -736,7 +736,7 @@ a separate Docker-only or TrueNAS-local inventory path. 27. Keep alert-side recovery drill-ins on that same shared route-helper contract. When alert investigation surfaces such as resource-incident panels expose recovery follow-up links for TrueNAS or future API-backed platforms, they must route through the canonical `frontend-modern/src/routing/resourceLinks.ts` recovery builder instead of freezing alert-local recovery URLs or introducing another provider-shaped recovery handoff vocabulary. 28. Keep VMware onboarding runtime and recovery semantics separate on that same adjacent platform-connections contract. When `internal/api/router.go`, `internal/api/router_routes_registration.go`, or `internal/api/vmware_handlers.go` evolve VMware connection CRUD, poller-owned `poll` / `observed` summary payloads, saved-test refresh, or observed datastore/VM snapshot visibility, storage and recovery may consume the resulting shared context but must not treat those onboarding/runtime payloads as canonical recovery artifacts, restore capability, or recovery-local control transport. 29. Keep VMware datastore projection on the shared unified-resource and storage-source contracts. When `frontend-modern/src/hooks/useUnifiedResources.ts` or shared `internal/api/router.go` wiring starts surfacing VMware-backed canonical `storage` resources, storage and recovery may expose those datastores through the owned `vmware-vsphere` source/platform vocabulary for inventory, capacity, and handoff flows only; they must not reinterpret that projection as VMware recovery support, restore semantics, or a provider-local protection surface. -30. Keep VMware placement, cluster service state, guest-detail, VM snapshot-tree, VM virtual-hardware configuration, VMware Tools, VM hardware Ethernet, and VM hardware disk enrichment descriptive on that same shared unified-resource contract. When `internal/vmware/provider.go`, `internal/unifiedresources/types.go`, and `frontend-modern/src/hooks/useUnifiedResources.ts` project datacenter, cluster, `vmware.clusterHaEnabled`, `vmware.clusterDrsEnabled`, folder, runtime-host, datastore-attachment, guest-hostname, guest-IP, `vmware.currentSnapshotId`, `vmware.snapshotTree`, snapshot creation/state/quiesce/current markers, child snapshot metadata, `vmware.hardware`, virtual hardware version, hardware upgrade policy/version/status/error, boot type/order/retry/setup-mode flags, CPU cores-per-socket and hot-add/remove flags, memory hot-add settings, `vmware.tools`, Tools run state, version status, version number/string, install type, upgrade policy, auto-update support, install-attempt count, guest reboot requests, `vmware.networkAdapters`, adapter MAC address/type, backing network id/name, backing type, connection state, start-connected / guest-control flags, `vmware.virtualDisks`, virtual disk label/type, IDE/SCSI/SATA/NVMe placement, VMDK path, backing type, datastore name, or capacity onto canonical VMware `agent` / `vm` / `storage` resources, storage and recovery may use that detail for labeling, navigation, and VM investigation context only; they must not promote those topology, cluster-service, guest, snapshot-tree, virtual-hardware, VMware Tools, vNIC, or virtual disk fields into recovery ownership, restore targeting, protection grouping, compliance scoring, or a VMware-local recovery taxonomy without a separately governed slice. +30. Keep VMware placement, cluster service state, guest-detail, VM snapshot-tree, VM virtual-hardware configuration, VMware Tools, VM hardware Ethernet, VM hardware disk, and network enrichment descriptive on that same shared unified-resource contract. When `internal/vmware/provider.go`, `internal/unifiedresources/types.go`, and `frontend-modern/src/hooks/useUnifiedResources.ts` project datacenter, cluster, `vmware.clusterHaEnabled`, `vmware.clusterDrsEnabled`, folder, runtime-host, datastore-attachment, guest-hostname, guest-IP, `vmware.currentSnapshotId`, `vmware.snapshotTree`, snapshot creation/state/quiesce/current markers, child snapshot metadata, `vmware.hardware`, virtual hardware version, hardware upgrade policy/version/status/error, boot type/order/retry/setup-mode flags, CPU cores-per-socket and hot-add/remove flags, memory hot-add settings, `vmware.tools`, Tools run state, version status, version number/string, install type, upgrade policy, auto-update support, install-attempt count, guest reboot requests, `vmware.networkAdapters`, adapter MAC address/type, backing network id/name, backing type, connection state, start-connected / guest-control flags, `vmware.virtualDisks`, virtual disk label/type, IDE/SCSI/SATA/NVMe placement, VMDK path, backing type, datastore name, capacity, `vmware.networkType`, `vmware.networkHostNames`, or `vmware.networkVmNames` onto canonical VMware `agent` / `vm` / `storage` / `network` resources, storage and recovery may use that detail for labeling, navigation, and VM investigation context only; they must not promote those topology, cluster-service, guest, snapshot-tree, virtual-hardware, VMware Tools, vNIC, virtual disk, or network fields into recovery ownership, restore targeting, protection grouping, compliance scoring, or a VMware-local recovery taxonomy without a separately governed slice. 31. Keep VMware datastore classification neutral on the shared storage adapter contract. When `frontend-modern/src/features/storageBackups/resourceStorageMapping.ts`, `frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts`, and `frontend-modern/src/features/storageBackups/storageAdapters.ts` evolve canonical storage-record mapping, VMware-backed datastores must continue to land on the shared storage route as inventory-only datastores with neutral protection fallback, not as backup repositories, backup targets, or recovery-protected resources. That same shared storage adapter boundary also owns canonical platform family vocabulary through the governed platform manifest. @@ -3157,18 +3157,19 @@ Storage and recovery must not infer VMware restore support, recovery rollups, or VMware-local protection semantics from the presence of those datastores or VM snapshot-read context on the shared pages. That same shared adapter floor also now carries richer VMware placement, -cluster-service, guest-detail, VM virtual-hardware, and VMware Tools metadata through the -canonical `agent` / `vm` / `storage` resources that storage and recovery can -inspect on shared pages. +cluster-service, guest-detail, VM virtual-hardware, VMware Tools, and network +metadata through the canonical `agent` / `vm` / `storage` / `network` +resources that storage and recovery can inspect on shared pages. `internal/vmware/provider.go`, `internal/unifiedresources/types.go`, and `frontend-modern/src/hooks/useUnifiedResources.ts` may expose datacenter, cluster, cluster HA/DRS service state, folder, runtime-host, datastore-attachment, guest-hostname, and guest-IP detail plus VM virtual-hardware version, boot, CPU/memory hot-add, VMware Tools run-state, -version, policy, install-attempt, error, and guest-reboot context as inventory -context, but those fields stay descriptive only. Storage and recovery must not -treat topology labels, cluster-service flags, datastore attachments, guest -identity, virtual-hardware posture, or VMware Tools posture as recovery +version, policy, install-attempt, error, guest-reboot context, and network +attachment context as inventory context, but those fields stay descriptive +only. Storage and recovery must not treat topology labels, cluster-service +flags, datastore attachments, guest identity, network attachments, +virtual-hardware posture, or VMware Tools posture as recovery ownership, restore targeting, protection grouping, or a new VMware-local storage/recovery taxonomy until a separately governed slice explicitly promotes them into recovery contracts. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 2c9ffc477..e34328816 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -562,7 +562,7 @@ AI-only summary payloads, or page-local heuristics. 8. Keep provider-backed signal metadata on shared canonical resource fields. VMware status, alarm, task, and snapshot signals must flow through shared `vmware` metadata plus shared `resource-incident` timeline entries on - canonical `agent`, `vm`, and `storage` resources instead of creating + canonical `agent`, `vm`, `storage`, and `network` resources instead of creating provider-only resource kinds, identities, or history schemas. 9. Keep summary-surface emphasis on canonical resource IDs. Infrastructure summary row-hover, chart-hover, and route-focus behavior must keep using the @@ -1001,9 +1001,9 @@ That same VMware contract now also includes the shared source boundary. When runtime work starts, VMware-backed records must flow through one canonical VMware source key plus `platformType: vmware-vsphere`, not through separate `vcenter` and `esxi` source forks or provider-local raw type aliases. One -host, VM, or datastore from VMware should therefore still look like one shared -Pulse `agent`, `vm`, or `storage` resource to downstream selectors, drawers, -alerts, AI, and route filters. +host, VM, datastore, or network from VMware should therefore still look like +one shared Pulse `agent`, `vm`, `storage`, or `network` resource to downstream +selectors, drawers, alerts, AI, and route filters. That shared source boundary now also has a concrete frontend/runtime adapter floor. `internal/unifiedresources/types.go`, `internal/unifiedresources/registry.go`, `internal/unifiedresources/views.go`, `frontend-modern/src/hooks/useUnifiedResources.ts`, @@ -1139,14 +1139,15 @@ VM `instance_uuid` / `bios_uuid` and host UUID when available belong under the shared canonical identity model for future merge or assistant reasoning, not inside a VMware-only dedupe lane. That same VMware contract now also includes the topology rule. `vCenter`, -datacenter, cluster, folder, resource pool, datastore cluster, and network -objects may enrich canonical `agent`, `vm`, and `storage` resources as +datacenter, cluster, folder, resource pool, and datastore cluster objects may +enrich canonical `agent`, `vm`, `storage`, and `network` resources as placement metadata or relationships, but they must not appear as synthetic top-level VMware resource types just to mirror the upstream inventory tree. Snapshot trees and VMware alarm/event/task context are also governed by that -same rule: they may enrich canonical `vm`, `agent`, or `storage` resources and -their timelines, but they do not become shared recovery artifacts, new -resource kinds, or a parallel VMware incident model. +same rule: they may enrich canonical `vm`, `agent`, `storage`, or `network` +resources and their timelines, but they do not become shared recovery +artifacts, new provider-local resource kinds, or a parallel VMware incident +model. That same topology contract now also has a concrete projection seam. `internal/vmware/provider.go` must preserve VMware placement and identity detail on the shared `vmware` facet only: hosts may carry datacenter, @@ -1158,7 +1159,10 @@ Ethernet adapter plus VM hardware disk metadata plus canonical parentage to the owning ESXi `agent`; datastores may carry datacenter/folder placement plus shared storage-node and workload consumer metadata through `storage.nodes`, `storage.consumerCount`, and -`storage.topConsumers`. VMs may also carry VI JSON snapshot-tree context under +`storage.topConsumers`; networks may carry network type, datacenter/folder +placement, host attachments, VM attachments, and VMware health/task/event +signal summaries under the shared `vmware` facet on canonical `network` +resources. VMs may also carry VI JSON snapshot-tree context under `vmware.currentSnapshotId` and `vmware.snapshotTree`, including snapshot managed-object reference, display name, description, creation time, power state, quiesce flag, current marker, replay support, and child snapshots. @@ -1191,9 +1195,9 @@ Cluster HA and DRS state belongs under `vmware.clusterHaEnabled` and cluster. It is API-native monitoring context from the vCenter cluster summary, not a synthetic cluster resource, lifecycle command surface, scheduling policy model, or recovery/protection signal. -Those enrichments must remain subordinate to shared `agent`, `vm`, and -`storage` resources rather than becoming a VMware-only topology graph, recovery -artifact, canonical identity alias, or separate provider detail drawer +Those enrichments must remain subordinate to shared `agent`, `vm`, `storage`, +and `network` resources rather than becoming a VMware-only topology graph, +recovery artifact, canonical identity alias, or separate provider detail drawer contract. TrueNAS disk telemetry now follows the same rule. API-backed TrueNAS disks must populate canonical `physicalDisk.temperature` and reuse the shared diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index ce38ff52f..19b5fa984 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -376,7 +376,7 @@ export function AppLayout(props: AppLayoutProps) { label: 'vSphere', route: ROOT_VMWARE_PATH, settingsRoute: '/settings/infrastructure', - tooltip: 'VMware vSphere hosts, virtual machines, and datastores', + tooltip: 'VMware vSphere hosts, virtual machines, datastores, and networks', enabled: isVisible('vmware'), live: isVisible('vmware'), icon: CpuIcon, diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 5ac121685..4cb118998 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -50,14 +50,16 @@ describe('App architecture', () => { ); expect(appSource).toContain("import('./components/Workloads/WorkloadsSurface')"); expect(appSource).toContain("import('./components/Storage/Storage')"); + expect(appSource).toContain("import('./components/Recovery/Recovery')"); expect(appSource).toContain( - "import('./components/Recovery/Recovery')", + '', ); - expect(appSource).toContain(''); expect(appSource).toContain(''); expect(appSource).toContain(''); expect(appSource).toContain(''); - expect(appSource).toContain(' } />'); + expect(appSource).toContain( + ' } />', + ); expect(appSource).toContain('await preloadRouteModule(route);'); expect(appRuntimeStateSource).not.toContain('preloadLazyRoutes'); expect(appRuntimeStateSource).not.toContain("import('@/pages/Alerts')"); @@ -82,6 +84,9 @@ describe('App architecture', () => { expect(appLayoutSource).toContain("id: 'kubernetes',"); expect(appLayoutSource).toContain("id: 'truenas',"); expect(appLayoutSource).toContain("id: 'vmware',"); + expect(appLayoutSource).toContain( + "tooltip: 'VMware vSphere hosts, virtual machines, datastores, and networks'", + ); // Governed platform/runtime primary nav: Infrastructure / Workloads / // Storage / Recovery are not duplicated as equal primary tab // entries, and the Docker / Podman route is presented as the Containers diff --git a/frontend-modern/src/api/__tests__/resourcesHotPath.guardrails.test.ts b/frontend-modern/src/api/__tests__/resourcesHotPath.guardrails.test.ts index 4b4df068c..6fd02ea29 100644 --- a/frontend-modern/src/api/__tests__/resourcesHotPath.guardrails.test.ts +++ b/frontend-modern/src/api/__tests__/resourcesHotPath.guardrails.test.ts @@ -2,15 +2,23 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -const resourcesHandlerSource = readFileSync(resolve(process.cwd(), '../internal/api/resources.go'), 'utf8'); +const resourcesHandlerSource = readFileSync( + resolve(process.cwd(), '../internal/api/resources.go'), + 'utf8', +); describe('resource API hot-path guardrails', () => { it('reuses one registry snapshot per request when deriving canonical by-type aggregations', () => { expect(resourcesHandlerSource.match(/allResources := registry\.List\(\)/g) ?? []).toHaveLength( - 2, + 0, ); expect( - resourcesHandlerSource.match(/computeResourceContractByType\(allResources\)/g) ?? [], + resourcesHandlerSource.match( + /allResources := presentationResourcesFromRegistry\(registry\)/g, + ) ?? [], + ).toHaveLength(2); + expect( + resourcesHandlerSource.match(/computeResourceContractStats\(allResources\)/g) ?? [], ).toHaveLength(2); expect(resourcesHandlerSource).not.toContain('computeResourceContractByType(registry.List())'); }); diff --git a/frontend-modern/src/api/__tests__/vmware.test.ts b/frontend-modern/src/api/__tests__/vmware.test.ts index 2cc597139..47dc404a0 100644 --- a/frontend-modern/src/api/__tests__/vmware.test.ts +++ b/frontend-modern/src/api/__tests__/vmware.test.ts @@ -33,6 +33,7 @@ describe('VMwareAPI', () => { hosts: 3, vms: 42, datastores: 6, + networks: 8, viRelease: ' 8.0.3 ', degraded: true, issueCount: 3, @@ -70,6 +71,7 @@ describe('VMwareAPI', () => { hosts: 3, vms: 42, datastores: 6, + networks: 8, viRelease: '8.0.3', degraded: true, issueCount: 3, diff --git a/frontend-modern/src/api/vmware.ts b/frontend-modern/src/api/vmware.ts index 4e0e94593..f7526ee29 100644 --- a/frontend-modern/src/api/vmware.ts +++ b/frontend-modern/src/api/vmware.ts @@ -41,6 +41,7 @@ export interface VMwareConnectionObservedSummary { hosts: number; vms: number; datastores: number; + networks: number; viRelease?: string; degraded?: boolean; issueCount?: number; @@ -120,6 +121,7 @@ const normalizeVMwareConnectionObservedSummary = ( hosts: finiteNumberOrUndefined(observed.hosts) ?? 0, vms: finiteNumberOrUndefined(observed.vms) ?? 0, datastores: finiteNumberOrUndefined(observed.datastores) ?? 0, + networks: finiteNumberOrUndefined(observed.networks) ?? 0, viRelease: optionalTrimmedString(observed.viRelease), degraded: strictBoolean(observed.degraded), issueCount: finiteNumberOrUndefined(observed.issueCount), @@ -162,9 +164,7 @@ const serializeVMwareConnectionInput = (input: VMwareConnectionInput) => ({ ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), ...(input.monitorVms !== undefined ? { monitorVms: input.monitorVms } : {}), ...(input.monitorHosts !== undefined ? { monitorHosts: input.monitorHosts } : {}), - ...(input.monitorDatastores !== undefined - ? { monitorDatastores: input.monitorDatastores } - : {}), + ...(input.monitorDatastores !== undefined ? { monitorDatastores: input.monitorDatastores } : {}), }); export const isRedactedVMwareSecret = (value: string | null | undefined) => diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.test.ts index e86bacba3..a8b441397 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerVmwareModel.test.ts @@ -33,6 +33,35 @@ describe('resourceDetailDrawerVmwareModel', () => { ]); }); + it('surfaces vCenter network resources as read-only topology context', () => { + const vmware: ResourceVMwareMeta = { + connectionName: 'Lab VC', + entityType: 'network', + overallStatus: 'yellow', + networkType: 'DISTRIBUTED_PORTGROUP', + datacenterName: 'Primary DC', + folderName: 'Networks', + networkHostNames: ['esxi-01.lab.local', 'esxi-02.lab.local'], + networkVmNames: ['warehouse-api-01', 'etl-batch-01'], + activeAlarmCount: 1, + activeAlarmSummary: 'Network uplink redundancy (yellow)', + }; + + expect(buildVMwareDetailsSummary('network', vmware)).toBe( + 'Lab VC · Read-only vCenter context · 2 hosts · 2 VMs · 1 alarm', + ); + + const sections = buildVMwareDetailSections('network', vmware); + expect(sections.find((section) => section.id === 'state')?.rows).toContainEqual({ + label: 'Network type', + value: 'Distributed Portgroup', + }); + expect(sections.find((section) => section.id === 'network')?.rows).toEqual([ + { label: 'Hosts', value: 'esxi-01.lab.local, esxi-02.lab.local' }, + { label: 'VMs', value: 'warehouse-api-01, etl-batch-01' }, + ]); + }); + it('surfaces vSphere snapshot trees as read-only VM detail context', () => { const vmware: ResourceVMwareMeta = { connectionName: 'Lab VC', diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts index 3f3529ba8..0a25f87ee 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts @@ -38,6 +38,9 @@ const asTrimmedString = (value?: string | null): string => (value || '').trim(); const formatCount = (count: number, label: string): string => `${count} ${label}${count === 1 ? '' : 's'}`; +const summarizeList = (values: string[] | undefined): string => + (values ?? []).map(asTrimmedString).filter(Boolean).join(', '); + const formatBoolLabel = (value?: boolean): string => { if (value === undefined) return ''; return value ? 'Yes' : 'No'; @@ -483,6 +486,8 @@ const vmwareEntityLabel = (entityType?: string): string => { return 'VM'; case 'datastore': return 'Datastore'; + case 'network': + return 'Network'; default: return asTrimmedString(entityType); } @@ -540,6 +545,12 @@ export const buildVMwareDetailsSummary = ( if (resourceType === 'vm' && virtualDiskCount > 0) { parts.push(formatCount(virtualDiskCount, 'disk')); } + if (resourceType === 'network') { + const hostCount = vmware.networkHostNames?.length ?? vmware.networkHostIds?.length ?? 0; + const vmCount = vmware.networkVmNames?.length ?? vmware.networkVmIds?.length ?? 0; + if (hostCount > 0) parts.push(formatCount(hostCount, 'host')); + if (vmCount > 0) parts.push(formatCount(vmCount, 'VM')); + } const hardware = resourceType === 'vm' ? hardwareSummary(vmware.hardware) : ''; if (hardware) { parts.push(hardware); @@ -610,6 +621,10 @@ export const buildVMwareDetailSections = ( value: asTrimmedString(vmware.maintenanceMode), tone: getWarningTone(Boolean(asTrimmedString(vmware.maintenanceMode))), }, + { + label: 'Network type', + value: formatEnumLabel(vmware.networkType), + }, ]); const placementRows = filterNonEmptyRows([ @@ -678,7 +693,19 @@ export const buildVMwareDetailSections = ( }, ]); - const networkRows = resourceType === 'vm' ? networkAdapterRows(vmware.networkAdapters) : []; + const networkRows = + resourceType === 'vm' + ? networkAdapterRows(vmware.networkAdapters) + : filterNonEmptyRows([ + { + label: 'Hosts', + value: summarizeList(vmware.networkHostNames), + }, + { + label: 'VMs', + value: summarizeList(vmware.networkVmNames), + }, + ]); const vmwareHardwareRows = resourceType === 'vm' ? hardwareRows(vmware) : []; const vmwareToolsRows = resourceType === 'vm' ? toolsRows(vmware.tools) : []; const diskRows = resourceType === 'vm' ? virtualDiskRows(vmware.virtualDisks) : []; diff --git a/frontend-modern/src/components/Settings/__tests__/reportingResourceTypes.test.ts b/frontend-modern/src/components/Settings/__tests__/reportingResourceTypes.test.ts index ecf8f3f45..8f2f0e418 100644 --- a/frontend-modern/src/components/Settings/__tests__/reportingResourceTypes.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/reportingResourceTypes.test.ts @@ -10,6 +10,7 @@ describe('toReportingResourceType', () => { expect(toReportingResourceType('docker-host')).toBe('docker-host'); expect(toReportingResourceType('network-endpoint')).toBe('network-endpoint'); expect(toReportingResourceType('storage')).toBe('storage'); + expect(toReportingResourceType('network')).toBe('network'); }); it('adapts kubernetes resource kinds to the current reporting API token at the edge', () => { diff --git a/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx b/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx index a94c7174e..5ecb0ff11 100644 --- a/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx @@ -39,6 +39,7 @@ const infrastructureVisibility = () => makeResource({ id: 'pve-1', type: 'agent', platformType: 'proxmox-pve' }), makeResource({ id: 'docker-1', type: 'docker-host', platformType: 'docker' }), makeResource({ id: 'k8s-1', type: 'k8s-cluster', platformType: 'kubernetes' }), + makeResource({ id: 'vc-1', type: 'network', platformType: 'vmware-vsphere' }), ]); describe('CommandPaletteModal', () => { @@ -74,13 +75,14 @@ describe('CommandPaletteModal', () => { expect(commandPaletteModelSource).toContain("id: 'nav-kubernetes'"); expect(commandPaletteModelSource).toContain("id: 'nav-truenas'"); expect(commandPaletteModelSource).toContain("id: 'nav-vmware'"); + expect(commandPaletteModelSource).toContain("id: 'nav-vmware-networks'"); expect(commandPaletteModelSource).not.toContain("id: 'nav-infrastructure'"); expect(commandPaletteModelSource).not.toContain("id: 'nav-workloads'"); expect(commandPaletteModelSource).not.toContain("id: 'nav-storage'"); expect(commandPaletteModelSource).not.toContain("id: 'nav-recovery'"); }); - it('renders the platform entries, container runtime lens, and dedicated Kubernetes pods command', () => { + it('renders platform entries, runtime lens commands, and vSphere network inventory', () => { render(() => ( { expect(screen.getByText('Go to Containers')).toBeInTheDocument(); expect(screen.getByText('Go to Kubernetes Pods')).toBeInTheDocument(); expect(screen.getByText('/kubernetes/pods')).toBeInTheDocument(); + expect(screen.getByText('Go to vSphere')).toBeInTheDocument(); + expect(screen.getByText('Go to vSphere Networks')).toBeInTheDocument(); + expect(screen.getByText('/vmware/networks')).toBeInTheDocument(); }); it('navigates to the Kubernetes pods sub-tab', async () => { @@ -111,6 +116,22 @@ describe('CommandPaletteModal', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('navigates to the vSphere networks sub-tab', async () => { + const onClose = vi.fn(); + render(() => ( + + )); + + await fireEvent.click(screen.getByText('Go to vSphere Networks')); + + expect(navigateMock).toHaveBeenCalledWith('/vmware/networks'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it('uses the shared search input and keeps Enter selection behavior', async () => { const onClose = vi.fn(); render(() => ( @@ -147,5 +168,6 @@ describe('CommandPaletteModal', () => { expect(screen.queryByText('Go to Kubernetes')).not.toBeInTheDocument(); expect(screen.queryByText('Go to TrueNAS')).not.toBeInTheDocument(); expect(screen.queryByText('Go to vSphere')).not.toBeInTheDocument(); + expect(screen.queryByText('Go to vSphere Networks')).not.toBeInTheDocument(); }); }); diff --git a/frontend-modern/src/components/shared/commandPaletteModel.ts b/frontend-modern/src/components/shared/commandPaletteModel.ts index 5bc6e2320..8fc8620e9 100644 --- a/frontend-modern/src/components/shared/commandPaletteModel.ts +++ b/frontend-modern/src/components/shared/commandPaletteModel.ts @@ -25,6 +25,7 @@ export type CommandPaletteCommandPaths = { kubernetesPodsPath: string; trueNasPath: string; vmwarePath: string; + vmwareNetworksPath: string; }; export function buildCommandPaletteCommands(options: { @@ -88,14 +89,23 @@ export function buildCommandPaletteCommands(options: { } if (primaryInfrastructureNavigationIsVisible(options.infrastructureVisibility, 'vmware')) { - commands.push({ - id: 'nav-vmware', - label: 'Go to vSphere', - description: options.paths.vmwarePath, - shortcut: 'g v', - keywords: ['vmware', 'vsphere', 'esxi', 'vms', 'datastores'], - action: () => options.navigate(options.paths.vmwarePath), - }); + commands.push( + { + id: 'nav-vmware', + label: 'Go to vSphere', + description: options.paths.vmwarePath, + shortcut: 'g v', + keywords: ['vmware', 'vsphere', 'esxi', 'vms', 'datastores', 'networks'], + action: () => options.navigate(options.paths.vmwarePath), + }, + { + id: 'nav-vmware-networks', + label: 'Go to vSphere Networks', + description: options.paths.vmwareNetworksPath, + keywords: ['vmware', 'vsphere', 'esxi', 'networks', 'portgroups'], + action: () => options.navigate(options.paths.vmwareNetworksPath), + }, + ); } commands.push( diff --git a/frontend-modern/src/components/shared/useCommandPaletteState.ts b/frontend-modern/src/components/shared/useCommandPaletteState.ts index d4723480e..3db532373 100644 --- a/frontend-modern/src/components/shared/useCommandPaletteState.ts +++ b/frontend-modern/src/components/shared/useCommandPaletteState.ts @@ -30,6 +30,7 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) { kubernetesPodsPath: buildKubernetesPath('pods'), trueNasPath: buildTrueNASPath(), vmwarePath: buildVmwarePath(), + vmwareNetworksPath: buildVmwarePath('networks'), }, infrastructureVisibility: props.infrastructureVisibility(), navigate, diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 28b223b25..3f105d3b4 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -203,6 +203,7 @@ describe('platform overview layout guardrails', () => { expect(vmwarePageSurfaceSource).toContain(' { resource.vmware?.clusterName, formatVmwareClusterServices(resource.vmware), resource.vmware?.datastoreNames?.join(' '), + resource.vmware?.networkType, + resource.vmware?.networkHostNames?.join(' '), + resource.vmware?.networkVmNames?.join(' '), ...(resource.tags ?? []), ] .filter((value): value is string => typeof value === 'string') diff --git a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx index 005b62aa6..69d63aa17 100644 --- a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx +++ b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx @@ -19,12 +19,14 @@ import { import { VsphereAlertsTable } from './VsphereAlertsTable'; import { VsphereActivityTable } from './VsphereActivityTable'; import { VsphereDatastoresTable } from './VsphereDatastoresTable'; +import { VsphereNetworksTable } from './VsphereNetworksTable'; import { VsphereVirtualMachinesTable } from './VsphereVirtualMachinesTable'; // vSphere phase 1 projects ESXi hosts as canonical `agent`, virtual machines -// as canonical `vm`, and datastores as canonical `storage`; provider-native -// topology stays in VMware metadata under those shared resources. -const VMWARE_RESOURCE_QUERY = 'type=agent,vm,storage'; +// as canonical `vm`, datastores as canonical `storage`, and vCenter networks +// as canonical `network`; provider-native topology stays in VMware metadata +// under those shared resources. +const VMWARE_RESOURCE_QUERY = 'type=agent,vm,storage,network'; const VALID_TABS = new Set(VMWARE_TAB_SPECS.map((tab) => tab.id)); const vmwareIcon = () => ; @@ -103,6 +105,15 @@ export function VmwarePageSurface() { emptyDescription="Datastores appear here once the vCenter connection enumerates them." /> + + + [] = [ + { value: 'all', label: 'All' }, + { value: 'healthy', label: 'Healthy', tone: 'success' }, + { value: 'attention', label: 'Attention', tone: 'warning' }, + { value: 'unknown', label: 'Unknown' }, +]; + +const networkName = (resource: Resource): string => + asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id; + +const networkType = (resource: Resource): string => + asTrimmedString(resource.vmware?.networkType) || '-'; + +const compactList = (values: Array): string[] => + values.map((value) => asTrimmedString(value)).filter((value): value is string => Boolean(value)); + +const summarizeValues = ( + values: string[], + empty = '-', + visibleCount = 2, +): { label: string; title: string } => { + if (values.length === 0) return { label: empty, title: '' }; + const visible = values.slice(0, visibleCount); + const suffix = values.length > visible.length ? ` +${values.length - visible.length}` : ''; + return { label: `${visible.join(', ')}${suffix}`, title: values.join(', ') }; +}; + +const hostSummary = (resource: Resource): { label: string; title: string } => + summarizeValues(compactList(resource.vmware?.networkHostNames ?? []), '-', 2); + +const vmSummary = (resource: Resource): { label: string; title: string } => + summarizeValues(compactList(resource.vmware?.networkVmNames ?? []), '-', 2); + +const vmCount = (resource: Resource): number => + resource.vmware?.networkVmNames?.length ?? resource.vmware?.networkVmIds?.length ?? 0; + +const statusLabel = (resource: Resource): string => { + switch (mapVmwareNetworkStatus(resource)) { + case 'healthy': + return 'Healthy'; + case 'attention': + return 'Attention'; + case 'unknown': + return 'Unknown'; + } +}; + +const statusPillClass = (resource: Resource): string => { + switch (mapVmwareNetworkStatus(resource)) { + case 'healthy': + return 'border-emerald-300/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; + case 'attention': + return 'border-amber-300/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'; + case 'unknown': + return 'border-border bg-surface-alt text-muted'; + } +}; + +const StatusPill: Component<{ resource: Resource }> = (props) => ( + + {statusLabel(props.resource)} + +); + +export const VsphereNetworksTable: Component<{ + networks: Resource[]; + scope: Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; + showToolbar?: boolean; +}> = (props) => { + const tableState = createPlatformTableFilterState({ + resources: () => props.networks, + initialStatus: 'all' as VmwareNetworkStatusFilter, + filter: filterVmwareNetworks, + }); + const drawer = createPlatformResourceDetailState({ idPrefix: 'vsphere-network-drawer' }); + const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope); + + return ( + 0} + fallback={ + + } + > +
+ + + + + 0} + fallback={ + + } + > + + + + + + + Network + + + Type + + + + + + + State + + + + + + {(network) => { + const hosts = createMemo(() => hostSummary(network)); + const vms = createMemo(() => vmSummary(network)); + const indicator = () => getSimpleStatusIndicator(network.status); + const name = () => networkName(network); + const datacenter = () => asTrimmedString(network.vmware?.datacenterName) || '-'; + const detailRowId = () => drawer.detailRowId(network); + const isExpanded = () => drawer.isExpanded(network); + return ( + <> + drawer.toggle(network)} + onKeyDown={drawer.handleActivationKey(network)} + tabIndex={0} + > + +
+ +
+
+ {name()} +
+
+ {network.vmware?.managedObjectId || + network.vmware?.folderName || + network.vmware?.vcenterHost || + 'vSphere network'} +
+
+
+
+ + + {networkType(network)} + + + + + + + + + +
+ drawer.close(network)} + /> + + ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default VsphereNetworksTable; diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx new file mode 100644 index 000000000..5ae51eec8 --- /dev/null +++ b/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { VsphereNetworksTable } from '@/features/vmware/VsphereNetworksTable'; +import type { Resource } from '@/types/resource'; + +const makeNetwork = (overrides: Partial & Pick): Resource => + ({ + type: 'network', + name: overrides.id, + displayName: overrides.id, + status: 'online', + platformType: 'vmware-vsphere', + platformScopes: ['vmware-vsphere'], + sourceType: 'api', + vmware: { + entityType: 'network', + managedObjectId: 'network-101', + networkType: 'STANDARD_PORTGROUP', + datacenterName: 'Primary DC', + folderName: 'Networks', + vcenterHost: 'vcsa.lab.local', + networkHostNames: ['esxi-01.lab.local', 'esxi-02.lab.local'], + networkVmNames: ['warehouse-api-01', 'etl-batch-01'], + overallStatus: 'green', + }, + ...overrides, + }) as Resource; + +afterEach(() => { + cleanup(); +}); + +describe('VsphereNetworksTable', () => { + it('renders vCenter network topology as a table', async () => { + const vmNetwork = makeNetwork({ id: 'VM Network', name: 'VM Network' }); + const edgeStateful = makeNetwork({ + id: 'Edge Stateful', + name: 'Edge Stateful', + status: 'degraded', + vmware: { + entityType: 'network', + managedObjectId: 'network-302', + networkType: 'DISTRIBUTED_PORTGROUP', + datacenterName: 'Edge DC', + networkHostNames: ['esxi-06.lab.local'], + networkVmNames: ['mariadb-replica-01'], + activeAlarmCount: 1, + }, + }); + + render(() => ( + } + emptyTitle="No networks" + emptyDescription="No networks" + showToolbar={false} + /> + )); + + const table = screen.getByRole('table'); + expect(within(table).getByText('Network')).toBeInTheDocument(); + expect(within(table).getByText('Type')).toBeInTheDocument(); + expect(within(table).getByText('Hosts')).toBeInTheDocument(); + expect(within(table).getByText('Connected VMs')).toBeInTheDocument(); + expect(screen.getByText('STANDARD_PORTGROUP')).toBeInTheDocument(); + expect(screen.getByText('DISTRIBUTED_PORTGROUP')).toBeInTheDocument(); + expect(screen.getByText('esxi-01.lab.local, esxi-02.lab.local')).toBeInTheDocument(); + expect(screen.getByText('warehouse-api-01, etl-batch-01')).toBeInTheDocument(); + expect(screen.getByText('Healthy')).toBeInTheDocument(); + expect(screen.getByText('Attention')).toBeInTheDocument(); + + const row = screen.getByText('VM Network').closest('tr'); + expect(row).toHaveAttribute('aria-expanded', 'false'); + + await fireEvent.click(row!); + + expect(row).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts index 984dfc17e..d5e3940f1 100644 --- a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts +++ b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts @@ -6,10 +6,12 @@ import { filterVmwareActivity, filterVmwareDatastores, filterVmwareIncidents, + filterVmwareNetworks, filterVmwareVirtualMachines, mapVmwareActivityStateBucket, mapVmwareDatastoreStatus, mapVmwareIncidentSeverity, + mapVmwareNetworkStatus, mapVmwareVirtualMachineStatus, } from '../vmwarePageModel'; @@ -29,18 +31,20 @@ describe('vmwarePageModel', () => { expect(VMWARE_TAB_SPECS.map((tab) => tab.id)).toEqual([ 'overview', 'storage', + 'networks', 'health', 'activity', ]); expect(VMWARE_TAB_SPECS.map((tab) => tab.label)).toEqual([ 'Overview', 'Datastores', + 'Networks', 'Health', 'Activity', ]); }); - it('buckets canonical vSphere hosts, VMs, and datastores', () => { + it('buckets canonical vSphere hosts, VMs, datastores, and networks', () => { const model = buildVmwarePageModel([ makeResource({ id: 'esxi-host-1', type: 'agent' }), makeResource({ id: 'vsphere-vm-1', type: 'vm' }), @@ -50,6 +54,11 @@ describe('vmwarePageModel', () => { storage: { topology: 'datastore', platform: 'vmware-vsphere' }, vmware: { entityType: 'datastore' }, }), + makeResource({ + id: 'network-1', + type: 'network', + vmware: { entityType: 'network', networkType: 'STANDARD_PORTGROUP' }, + }), makeResource({ id: 'legacy-provider-datastore', type: 'datastore' }), makeResource({ id: 'pve-vm', type: 'vm', platformType: 'proxmox-pve' }), ]); @@ -57,8 +66,9 @@ describe('vmwarePageModel', () => { expect(model.hosts.map((r) => r.id)).toEqual(['esxi-host-1']); expect(model.vms.map((r) => r.id)).toEqual(['vsphere-vm-1']); expect(model.datastores.map((r) => r.id)).toEqual(['datastore-1']); + expect(model.networks.map((r) => r.id)).toEqual(['network-1']); expect(model.resources.map((r) => r.id).sort()).toEqual( - ['datastore-1', 'esxi-host-1', 'vsphere-vm-1'].sort(), + ['datastore-1', 'esxi-host-1', 'network-1', 'vsphere-vm-1'].sort(), ); }); @@ -125,6 +135,49 @@ describe('vmwarePageModel', () => { ).toEqual(['ds-inaccessible']); }); + it('filters vSphere networks using vCenter network topology', () => { + const healthy = makeResource({ + id: 'network-healthy', + type: 'network', + name: 'VM Network', + vmware: { + entityType: 'network', + networkType: 'STANDARD_PORTGROUP', + datacenterName: 'Primary DC', + networkHostNames: ['esxi-01.lab.local'], + networkVmNames: ['warehouse-api-01'], + overallStatus: 'green', + }, + }); + const attention = makeResource({ + id: 'network-attention', + type: 'network', + name: 'Edge Stateful', + status: 'degraded', + vmware: { + entityType: 'network', + networkType: 'DISTRIBUTED_PORTGROUP', + datacenterName: 'Edge DC', + networkHostNames: ['esxi-06.lab.local'], + networkVmNames: ['mariadb-replica-01'], + activeAlarmCount: 1, + }, + }); + + expect(mapVmwareNetworkStatus(healthy)).toBe('healthy'); + expect(mapVmwareNetworkStatus(attention)).toBe('attention'); + expect( + filterVmwareNetworks([healthy, attention], 'warehouse', 'healthy').map( + (resource) => resource.id, + ), + ).toEqual(['network-healthy']); + expect( + filterVmwareNetworks([healthy, attention], 'distributed', 'attention').map( + (resource) => resource.id, + ), + ).toEqual(['network-attention']); + }); + it('filters vSphere VMs using vCenter VM metadata', () => { const poweredOn = makeResource({ id: 'vm-powered-on', diff --git a/frontend-modern/src/features/vmware/vmwarePageModel.ts b/frontend-modern/src/features/vmware/vmwarePageModel.ts index 71114a2c8..ebbd6f127 100644 --- a/frontend-modern/src/features/vmware/vmwarePageModel.ts +++ b/frontend-modern/src/features/vmware/vmwarePageModel.ts @@ -2,7 +2,7 @@ import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; import { formatVmwareClusterServices } from '@/utils/vmwareDisplay'; import type { Resource, ResourceChange, ResourceIncident, ResourceType } from '@/types/resource'; -export type VmwarePageTabId = 'overview' | 'storage' | 'health' | 'activity'; +export type VmwarePageTabId = 'overview' | 'storage' | 'networks' | 'health' | 'activity'; export type VmwareDatastoreStatusFilter = | 'all' | 'accessible' @@ -17,6 +17,7 @@ export type VmwareVirtualMachineStatusFilter = | 'powered-off' | 'suspended' | 'unknown'; +export type VmwareNetworkStatusFilter = 'all' | 'healthy' | 'attention' | 'unknown'; export type VmwareIncidentSeverityFilter = 'all' | 'critical' | 'warning' | 'info'; export type VmwareActivityStatusFilter = 'all' | 'tasks' | 'events' | 'failed'; export type VmwareActivityKind = 'task' | 'event' | 'activity'; @@ -35,11 +36,12 @@ export type VmwareTabSpec = { export const VMWARE_TAB_SPECS: readonly VmwareTabSpec[] = [ { id: 'overview', label: 'Overview', path: '/vmware/overview' }, { id: 'storage', label: 'Datastores', path: '/vmware/storage' }, + { id: 'networks', label: 'Networks', path: '/vmware/networks' }, { id: 'health', label: 'Health', path: '/vmware/health' }, { id: 'activity', label: 'Activity', path: '/vmware/activity' }, ] as const; -const VMWARE_RESOURCE_TYPES = new Set(['agent', 'vm', 'storage']); +const VMWARE_RESOURCE_TYPES = new Set(['agent', 'vm', 'storage', 'network']); const isVmwarePlatform = (resource: Resource): boolean => resolveResourcePlatformType(resource) === 'vmware-vsphere'; @@ -49,6 +51,7 @@ export type VmwarePageModel = { hosts: Resource[]; vms: Resource[]; datastores: Resource[]; + networks: Resource[]; incidents: VmwareIncidentRow[]; activity: VmwareActivityRow[]; }; @@ -115,6 +118,9 @@ export function buildVmwarePageModel( (resource.storage?.topology === 'datastore' || resource.vmware?.entityType === 'datastore'), ) .sort(compareVmwareDatastores); + const networks = vmwareResources + .filter((resource) => resource.type === 'network' && resource.vmware?.entityType === 'network') + .sort(compareVmwareNetworks); const incidents = buildVmwareIncidentRows(vmwareResources); const activity = buildVmwareActivityRows(vmwareResources, activityChanges); @@ -123,6 +129,7 @@ export function buildVmwarePageModel( hosts, vms, datastores, + networks, incidents, activity, }; @@ -204,6 +211,26 @@ const compareVmwareDatastores = (left: Resource, right: Resource): number => { return vmwareDatastoreDisplayName(left).localeCompare(vmwareDatastoreDisplayName(right)); }; +const vmwareNetworkDisplayName = (resource: Resource): string => + resource.displayName?.trim() || resource.name?.trim() || resource.id; + +const vmwareNetworkStatusRank = (resource: Resource): number => { + switch (mapVmwareNetworkStatus(resource)) { + case 'attention': + return 0; + case 'unknown': + return 1; + case 'healthy': + return 2; + } +}; + +const compareVmwareNetworks = (left: Resource, right: Resource): number => { + const rankDelta = vmwareNetworkStatusRank(left) - vmwareNetworkStatusRank(right); + if (rankDelta !== 0) return rankDelta; + return vmwareNetworkDisplayName(left).localeCompare(vmwareNetworkDisplayName(right)); +}; + const vmwareVirtualMachineHostKey = (resource: Resource): string => normalize(resource.vmware?.runtimeHostName || resource.parentName || 'unknown'); @@ -313,6 +340,24 @@ export function mapVmwareDatastoreStatus( return 'unknown'; } +export function mapVmwareNetworkStatus( + resource: Resource, +): Exclude { + const status = normalize(resource.status); + const overall = normalize(resource.vmware?.overallStatus); + const activeAlarms = resource.vmware?.activeAlarmCount ?? 0; + + if ( + activeAlarms > 0 || + ['red', 'yellow', 'degraded', 'warning', 'critical', 'paused'].includes(overall) || + ['degraded', 'warning', 'critical', 'paused', 'offline'].includes(status) + ) { + return 'attention'; + } + if (['online', 'running'].includes(status) || overall === 'green') return 'healthy'; + return 'unknown'; +} + const titleize = (value: string): string => value .split(/[\s_-]+/) @@ -686,6 +731,41 @@ export function filterVmwareDatastores( }); } +const vmwareNetworkSearchHaystack = (resource: Resource): string => + [ + resource.id, + resource.name, + resource.displayName, + resource.parentName, + resource.status, + resource.vmware?.connectionName, + resource.vmware?.vcenterHost, + resource.vmware?.managedObjectId, + resource.vmware?.datacenterName, + resource.vmware?.folderName, + resource.vmware?.networkType, + resource.vmware?.overallStatus, + resource.vmware?.networkHostNames?.join(' '), + resource.vmware?.networkVmNames?.join(' '), + ...(resource.tags ?? []), + ] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join(' ') + .toLowerCase(); + +export function filterVmwareNetworks( + networks: Resource[], + search: string, + status: VmwareNetworkStatusFilter, +): Resource[] { + const needle = normalize(search); + return networks.filter((network) => { + if (status !== 'all' && mapVmwareNetworkStatus(network) !== status) return false; + if (!needle) return true; + return vmwareNetworkSearchHaystack(network).includes(needle); + }); +} + const vmwareVirtualMachineSearchHaystack = (resource: Resource): string => [ resource.id, diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts index 82b015c0c..56015d90c 100644 --- a/frontend-modern/src/types/__tests__/resource.test.ts +++ b/frontend-modern/src/types/__tests__/resource.test.ts @@ -77,6 +77,7 @@ describe('Resource Type Guards', () => { 'pod', 'jail', 'storage', + 'network', ]; it.each(infrastructureTypes)('returns true for %s', (type) => { @@ -102,6 +103,7 @@ describe('Resource Type Guards', () => { 'agent', 'docker-host', 'network-endpoint', + 'network', 'storage', 'pbs', ]; @@ -131,6 +133,7 @@ describe('Resource Type Guards', () => { 'system-container', 'docker-host', 'network-endpoint', + 'network', ]; it.each(storageTypes)('returns true for %s', (type) => { @@ -262,6 +265,9 @@ describe('Resource Helper Functions', () => { runtimeHostName: 'esxi-01.lab.local', overallStatus: 'yellow', datastoreNames: ['primary-vmfs'], + networkType: 'STANDARD_PORTGROUP', + networkHostNames: ['esxi-01.lab.local'], + networkVmNames: ['app-01'], instanceUuid: 'vm-instance-101', guestHostname: 'app-01.internal', guestIpAddresses: ['10.0.0.21'], @@ -361,6 +367,9 @@ describe('Resource Helper Functions', () => { expect(vmware.clusterDrsEnabled).toBe(false); expect(vmware.runtimeHostName).toBe('esxi-01.lab.local'); expect(vmware.datastoreNames).toEqual(['primary-vmfs']); + expect(vmware.networkType).toBe('STANDARD_PORTGROUP'); + expect(vmware.networkHostNames).toEqual(['esxi-01.lab.local']); + expect(vmware.networkVmNames).toEqual(['app-01']); expect(vmware.guestIpAddresses).toEqual(['10.0.0.21']); expect(vmware.activeAlarmCount).toBe(2); expect(vmware.recentTaskSummary).toBe('Clone VM task finished'); diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 961a66cf4..25385c917 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -41,6 +41,7 @@ export type ResourceType = | 'k8s-deployment' // Kubernetes deployment | 'k8s-service' // Kubernetes service | 'storage' // Storage resource + | 'network' // Virtual/network topology resource | 'datastore' // PBS datastore | 'pool' // ZFS/Ceph pool | 'dataset' // ZFS dataset @@ -957,6 +958,11 @@ export interface ResourceVMwareMeta { datastoreAccessible?: boolean; multipleHostAccess?: boolean; maintenanceMode?: string; + networkType?: string; + networkHostIds?: string[]; + networkHostNames?: string[]; + networkVmIds?: string[]; + networkVmNames?: string[]; instanceUuid?: string; biosUuid?: string; guestOsFamily?: string; diff --git a/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts b/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts index d9a1ebe0e..2196ff9b7 100644 --- a/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts @@ -24,7 +24,7 @@ describe('infrastructureOnboardingPresentation', () => { expect(vmware.governanceState).toBe('admitted'); expect(vmware.readinessStage).toBe('first-lab-ready'); expect(vmware.primaryMode).toBe('api-backed'); - expect(vmware.canonicalProjections).toEqual(['agent', 'vm', 'storage']); + expect(vmware.canonicalProjections).toEqual(['agent', 'vm', 'storage', 'network']); expect(vmware.supportFloor).toMatchObject({ setup: 'supported', visibility: 'supported', diff --git a/frontend-modern/src/utils/platformSupportManifest.generated.ts b/frontend-modern/src/utils/platformSupportManifest.generated.ts index 1fae56fe0..e2140bcaf 100644 --- a/frontend-modern/src/utils/platformSupportManifest.generated.ts +++ b/frontend-modern/src/utils/platformSupportManifest.generated.ts @@ -1,11 +1,11 @@ // This file is generated by scripts/release_control/generate_platform_support_frontend_module.py. // Do not edit by hand. // Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json -// Source SHA256: 97b7ac58c2c981867dc4052728587d323fc541221135e6bada1191d9d723206b +// Source SHA256: 8d290e9e9f71ae0f62de8881b21c6f3350da976229538dd5da87213674ba673a export const PLATFORM_SUPPORT_MANIFEST_SOURCE = { path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json', - sha256: '97b7ac58c2c981867dc4052728587d323fc541221135e6bada1191d9d723206b', + sha256: '8d290e9e9f71ae0f62de8881b21c6f3350da976229538dd5da87213674ba673a', } as const; export const PLATFORM_SUPPORT_MANIFEST = { schemaVersion: 2, @@ -231,7 +231,7 @@ export const PLATFORM_SUPPORT_MANIFEST = { readinessStage: 'first-lab-ready', primaryMode: 'api-backed', onboardingPaths: ['platform-connections'], - canonicalProjections: ['agent', 'vm', 'storage'], + canonicalProjections: ['agent', 'vm', 'storage', 'network'], supportFloor: { setup: 'supported', visibility: 'supported', @@ -631,7 +631,7 @@ export const SOURCE_PLATFORM_CANONICAL_PROJECTIONS = { 'proxmox-pbs': ['pbs', 'storage'], 'proxmox-pmg': ['pmg'], truenas: ['agent', 'vm', 'app-container', 'network-share', 'storage', 'physical-disk'], - 'vmware-vsphere': ['agent', 'vm', 'storage'], + 'vmware-vsphere': ['agent', 'vm', 'storage', 'network'], unraid: [], 'synology-dsm': [], 'microsoft-hyperv': [], diff --git a/frontend-modern/src/utils/reportingResourceTypes.ts b/frontend-modern/src/utils/reportingResourceTypes.ts index 32f2886a5..f67a499db 100644 --- a/frontend-modern/src/utils/reportingResourceTypes.ts +++ b/frontend-modern/src/utils/reportingResourceTypes.ts @@ -12,6 +12,7 @@ export type ReportingResourceType = | 'datastore' | 'pool' | 'dataset' + | 'network' | 'network-share' | 'network-endpoint' | 'pbs' diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 1908ebf27..18fceb8c8 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -1655,7 +1655,7 @@ func TestContract_VMwareSavedConnectionTestsUpdateRuntimeSummary(t *testing.T) { handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { return &fakeVMwareClient{ testConnection: func(context.Context) (*vmware.InventorySummary, error) { - return &vmware.InventorySummary{Hosts: 3, VMs: 20, Datastores: 4, VIRelease: "8.0.3"}, nil + return &vmware.InventorySummary{Hosts: 3, VMs: 20, Datastores: 4, Networks: 6, VIRelease: "8.0.3"}, nil }, }, nil } @@ -1671,7 +1671,7 @@ func TestContract_VMwareSavedConnectionTestsUpdateRuntimeSummary(t *testing.T) { if summary.Poll == nil || summary.Poll.LastSuccessAt == nil { t.Fatalf("expected saved manual test to refresh runtime summary, got %+v", summary.Poll) } - if summary.Observed == nil || summary.Observed.VMs != 20 { + if summary.Observed == nil || summary.Observed.VMs != 20 || summary.Observed.Networks != 6 { t.Fatalf("expected saved manual test to refresh observed summary, got %+v", summary.Observed) } } @@ -1700,6 +1700,7 @@ func TestContract_VMwareConnectionListCarriesObservedSummary(t *testing.T) { Hosts: 4, VMs: 24, Datastores: 6, + Networks: 8, VIRelease: "8.0.3", }, collectedAt) @@ -1736,6 +1737,9 @@ func TestContract_VMwareConnectionListCarriesObservedSummary(t *testing.T) { if got := responses[0].Observed.Datastores; got != 6 { t.Fatalf("observed datastores = %d, want 6", got) } + if got := responses[0].Observed.Networks; got != 8 { + t.Fatalf("observed networks = %d, want 8", got) + } if got := responses[0].Observed.VIRelease; got != "8.0.3" { t.Fatalf("observed viRelease = %q, want 8.0.3", got) } @@ -2492,6 +2496,7 @@ func TestContract_PlatformMockToggleRebindsRuntimeConnectionsAndResources(t *tes assertResourceCount("/api/resources?source=truenas&type=app-container", len(truenas.DefaultFixtures().Apps)) assertResourceCount("/api/resources?source=truenas&type=network-share", len(truenas.DefaultFixtures().Shares)) assertResourceCount("/api/resources?source=vmware-vsphere&type=storage", len(vmware.DefaultFixtures().Datastores)) + assertResourceCount("/api/resources?source=vmware-vsphere&type=network", len(vmware.DefaultFixtures().Networks)) } func TestContract_PlatformMockConnectionListsUseSharedFixtureMetadata(t *testing.T) { diff --git a/internal/api/platform_mock_connections.go b/internal/api/platform_mock_connections.go index e5a7998d5..9872eb388 100644 --- a/internal/api/platform_mock_connections.go +++ b/internal/api/platform_mock_connections.go @@ -72,6 +72,7 @@ func mockVMwareConnectionResponses() []vmwareConnectionResponse { Hosts: fixture.Hosts, VMs: fixture.VMs, Datastores: fixture.Datastores, + Networks: fixture.Networks, VIRelease: fixture.VIRelease, }, }} diff --git a/internal/api/resources.go b/internal/api/resources.go index 9800bbbb7..fdde8e693 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -135,7 +135,7 @@ func (h *ResourceHandlers) HandleListResources(w http.ResponseWriter, r *http.Re return } - allResources := registry.List() + allResources := presentationResourcesFromRegistry(registry) resources := allResources if unsupported := unsupportedResourceTypeFilterTokens(r.URL.Query().Get("type")); len(unsupported) > 0 { http.Error(w, "unsupported type filter token(s): "+strings.Join(unsupported, ", "), http.StatusBadRequest) @@ -152,11 +152,9 @@ func (h *ResourceHandlers) HandleListResources(w http.ResponseWriter, r *http.Re paged = unified.RefreshCanonicalMetadataSlice(paged) pruneResourcesForListResponse(paged) - // Build aggregations: use registry.Stats() for Total/ByStatus/BySource (unfiltered, - // no conversion needed), but recompute ByType from the full registry list so keys - // match the canonical REST resource contract. - stats := registry.Stats() - stats.ByType = computeResourceContractByType(allResources) + // Build aggregations from the same presentation resource set returned by + // the list, so top-level host coalescing cannot drift between counts and rows. + stats := computeResourceContractStats(allResources) stats.PolicyPosture = resourcePolicyPostureAggregation(allResources) applyResourceContractTypes(paged) @@ -275,7 +273,7 @@ func (h *ResourceHandlers) HandleGetResource(w http.ResponseWriter, r *http.Requ return } - resource, ok := registry.Get(resourceID) + resource, ok := presentationResourceByID(registry, resourceID) if !ok { http.Error(w, "Resource not found", http.StatusNotFound) return @@ -291,6 +289,29 @@ func (h *ResourceHandlers) HandleGetResource(w http.ResponseWriter, r *http.Requ json.NewEncoder(w).Encode(resourceCopy) } +func presentationResourcesFromRegistry(registry *unified.ResourceRegistry) []unified.Resource { + if registry == nil { + return nil + } + return registry.ListForPresentation() +} + +func presentationResourceByID(registry *unified.ResourceRegistry, resourceID string) (*unified.Resource, bool) { + resourceID = unified.CanonicalResourceID(resourceID) + if resourceID == "" || registry == nil { + return nil, false + } + + for _, resource := range presentationResourcesFromRegistry(registry) { + if unified.CanonicalResourceID(resource.ID) == resourceID { + resourceCopy := resource + return &resourceCopy, true + } + } + + return registry.Get(resourceID) +} + type resourceFacetCountsResponse = unified.ResourceFacetCounts type resourceFacetBundleResponse struct { @@ -633,9 +654,8 @@ func (h *ResourceHandlers) HandleStats(w http.ResponseWriter, r *http.Request) { return } - allResources := registry.List() - stats := registry.Stats() - stats.ByType = computeResourceContractByType(allResources) + allResources := presentationResourcesFromRegistry(registry) + stats := computeResourceContractStats(allResources) stats.PolicyPosture = resourcePolicyPostureAggregation(allResources) w.Header().Set("Content-Type", "application/json") @@ -1855,6 +1875,8 @@ func resourceTypeFilterAdapter(token string) []unified.ResourceType { return []unified.ResourceType{unified.ResourceTypeK8sDeployment} case "storage": return []unified.ResourceType{unified.ResourceTypeStorage} + case "network", "networks": + return []unified.ResourceType{unified.ResourceTypeNetwork} case "pbs": return []unified.ResourceType{unified.ResourceTypePBS} case "pmg": @@ -1977,6 +1999,23 @@ func computeResourceContractByType(resources []unified.Resource) map[unified.Res return m } +func computeResourceContractStats(resources []unified.Resource) unified.ResourceStats { + stats := unified.ResourceStats{ + Total: len(resources), + ByType: make(map[unified.ResourceType]int, 8), + ByStatus: make(map[unified.ResourceStatus]int, 8), + BySource: make(map[unified.DataSource]int, 8), + } + for _, resource := range resources { + stats.ByType[resourceContractType(resource)]++ + stats.ByStatus[resource.Status]++ + for _, source := range resource.Sources { + stats.BySource[source]++ + } + } + return stats +} + func resourcePolicyPostureAggregation(resources []unified.Resource) *unified.ResourcePolicyPostureSummary { canonicalResources := unified.RefreshCanonicalMetadataSlice(resources) return unified.ResourcePolicyPostureContract(unified.SummarizePolicyPosture(canonicalResources)) diff --git a/internal/api/resources_frontend_types_test.go b/internal/api/resources_frontend_types_test.go index 4b319ba61..66edac09d 100644 --- a/internal/api/resources_frontend_types_test.go +++ b/internal/api/resources_frontend_types_test.go @@ -191,6 +191,7 @@ func TestParseResourceTypesNodeAlias(t *testing.T) { {name: "unsupported k8s-pod ignored by parser", input: "k8s-pod", want: map[unified.ResourceType]struct{}{}}, {name: "unsupported deployment alias ignored by parser", input: "deployment", want: map[unified.ResourceType]struct{}{}}, {name: "pool", input: "pool", want: map[unified.ResourceType]struct{}{unified.ResourceTypeCeph: {}}}, + {name: "network", input: "network", want: map[unified.ResourceType]struct{}{unified.ResourceTypeNetwork: {}}}, {name: "network share", input: "network-share", want: map[unified.ResourceType]struct{}{unified.ResourceTypeNetworkShare: {}}}, {name: "vm", input: "vm", want: map[unified.ResourceType]struct{}{unified.ResourceTypeVM: {}}}, // CSV with multiple types diff --git a/internal/api/vmware_handlers.go b/internal/api/vmware_handlers.go index db4f20823..e87629370 100644 --- a/internal/api/vmware_handlers.go +++ b/internal/api/vmware_handlers.go @@ -750,6 +750,7 @@ func (h *VMwareHandlers) recordTestSuccess(connectionID string, summary *vmware. Hosts: summary.Hosts, VMs: summary.VMs, Datastores: summary.Datastores, + Networks: summary.Networks, VIRelease: strings.TrimSpace(summary.VIRelease), } } diff --git a/internal/api/vmware_handlers_test.go b/internal/api/vmware_handlers_test.go index d8c1e00aa..3432d8e7d 100644 --- a/internal/api/vmware_handlers_test.go +++ b/internal/api/vmware_handlers_test.go @@ -481,6 +481,7 @@ func TestVMwareHandlers_HandleList_RedactsSensitiveFieldsAndIncludesRuntimeSumma Hosts: 3, VMs: 42, Datastores: 6, + Networks: 5, VIRelease: "8.0.3", }, recordedAt) @@ -508,7 +509,7 @@ func TestVMwareHandlers_HandleList_RedactsSensitiveFieldsAndIncludesRuntimeSumma if listed[0].Observed == nil { t.Fatalf("expected observed summary, got nil") } - if listed[0].Observed.Hosts != 3 || listed[0].Observed.VMs != 42 || listed[0].Observed.Datastores != 6 { + if listed[0].Observed.Hosts != 3 || listed[0].Observed.VMs != 42 || listed[0].Observed.Datastores != 6 || listed[0].Observed.Networks != 5 { t.Fatalf("unexpected observed counts: %+v", listed[0].Observed) } if listed[0].Observed.VIRelease != "8.0.3" { @@ -546,7 +547,7 @@ func TestVMwareHandlers_HandleList_ReturnsMockConnectionsInMockMode(t *testing.T if listed[0].Poll == nil || listed[0].Poll.LastSuccessAt == nil { t.Fatalf("expected mock VMware poll summary, got %+v", listed[0].Poll) } - if listed[0].Observed == nil || listed[0].Observed.Hosts == 0 || listed[0].Observed.VMs == 0 { + if listed[0].Observed == nil || listed[0].Observed.Hosts == 0 || listed[0].Observed.VMs == 0 || listed[0].Observed.Networks == 0 { t.Fatalf("expected populated mock VMware observed summary, got %+v", listed[0].Observed) } } @@ -1515,7 +1516,7 @@ func TestVMwareHandlers_HandleTestSavedConnection_UsesStoredSecretsAndUpdatesRun gotConfig = cfg return &fakeVMwareClient{ testConnection: func(context.Context) (*vmware.InventorySummary, error) { - return &vmware.InventorySummary{Hosts: 4, VMs: 25, Datastores: 5, VIRelease: "8.0.3"}, nil + return &vmware.InventorySummary{Hosts: 4, VMs: 25, Datastores: 5, Networks: 7, VIRelease: "8.0.3"}, nil }, }, nil } @@ -1545,7 +1546,7 @@ func TestVMwareHandlers_HandleTestSavedConnection_UsesStoredSecretsAndUpdatesRun if len(listed) != 1 || listed[0].Poll == nil || listed[0].Poll.LastSuccessAt == nil { t.Fatalf("expected saved retest to update runtime status, got %+v", listed) } - if listed[0].Observed == nil || listed[0].Observed.VMs != 25 { + if listed[0].Observed == nil || listed[0].Observed.VMs != 25 || listed[0].Observed.Networks != 7 { t.Fatalf("expected saved retest to update observed summary, got %+v", listed[0].Observed) } } diff --git a/internal/mock/fixture_graph.go b/internal/mock/fixture_graph.go index ac3c8f572..f3515a25f 100644 --- a/internal/mock/fixture_graph.go +++ b/internal/mock/fixture_graph.go @@ -248,6 +248,7 @@ func cloneVMwareInventorySnapshot(in vmware.InventorySnapshot) vmware.InventoryS out.Hosts = cloneVMwareInventoryHosts(in.Hosts) out.VMs = cloneVMwareInventoryVMs(in.VMs) out.Datastores = cloneVMwareInventoryDatastores(in.Datastores) + out.Networks = cloneVMwareInventoryNetworks(in.Networks) out.EnrichmentIssues = append([]vmware.InventoryEnrichmentIssue(nil), in.EnrichmentIssues...) return out } @@ -313,6 +314,26 @@ func cloneVMwareInventoryDatastores(in []vmware.InventoryDatastore) []vmware.Inv return out } +func cloneVMwareInventoryNetworks(in []vmware.InventoryNetwork) []vmware.InventoryNetwork { + if in == nil { + return nil + } + + out := make([]vmware.InventoryNetwork, len(in)) + for i := range in { + out[i] = in[i] + out[i].HostIDs = append([]string(nil), in[i].HostIDs...) + out[i].HostNames = append([]string(nil), in[i].HostNames...) + out[i].VMIDs = append([]string(nil), in[i].VMIDs...) + out[i].VMNames = append([]string(nil), in[i].VMNames...) + out[i].TriggeredAlarms = append([]vmware.InventoryAlarm(nil), in[i].TriggeredAlarms...) + out[i].RecentTasks = append([]vmware.InventoryTask(nil), in[i].RecentTasks...) + out[i].RecentEvents = append([]vmware.InventoryEvent(nil), in[i].RecentEvents...) + } + + return out +} + func cloneVMwareInventoryMetrics(in *vmware.InventoryMetrics) *vmware.InventoryMetrics { if in == nil { return nil diff --git a/internal/mock/platform_fixtures.go b/internal/mock/platform_fixtures.go index 9575fbb3c..30bb323c8 100644 --- a/internal/mock/platform_fixtures.go +++ b/internal/mock/platform_fixtures.go @@ -49,6 +49,7 @@ type VMwareConnectionFixture struct { Hosts int VMs int Datastores int + Networks int VIRelease string } @@ -87,6 +88,7 @@ func DefaultVMwareConnectionFixture() VMwareConnectionFixture { Hosts: len(fixtures.Hosts), VMs: len(fixtures.VMs), Datastores: len(fixtures.Datastores), + Networks: len(fixtures.Networks), VIRelease: strings.TrimSpace(fixtures.VIRelease), } } @@ -296,6 +298,11 @@ func rebaseVMwarePlatformFixture(snapshot vmware.InventorySnapshot, target time. rebaseVMwareTasks(out.Datastores[i].RecentTasks, snapshot.Datastores[i].RecentTasks, shift, target) rebaseVMwareEvents(out.Datastores[i].RecentEvents, snapshot.Datastores[i].RecentEvents, shift, target) } + for i := range out.Networks { + rebaseVMwareAlarms(out.Networks[i].TriggeredAlarms, snapshot.Networks[i].TriggeredAlarms, shift, target) + rebaseVMwareTasks(out.Networks[i].RecentTasks, snapshot.Networks[i].RecentTasks, shift, target) + rebaseVMwareEvents(out.Networks[i].RecentEvents, snapshot.Networks[i].RecentEvents, shift, target) + } return out } diff --git a/internal/mock/platform_fixtures_test.go b/internal/mock/platform_fixtures_test.go index bb574cc4f..c215156bf 100644 --- a/internal/mock/platform_fixtures_test.go +++ b/internal/mock/platform_fixtures_test.go @@ -28,6 +28,9 @@ func TestUnifiedResourceSnapshotIncludesPlatformFixtures(t *testing.T) { if len(graph.PlatformFixtures.VMware.Hosts) == 0 { t.Fatal("expected canonical mock graph to include VMware host fixtures") } + if len(graph.PlatformFixtures.VMware.Networks) == 0 { + t.Fatal("expected canonical mock graph to include VMware network fixtures") + } resources, freshness := UnifiedResourceSnapshot() if len(resources) == 0 { @@ -40,18 +43,31 @@ func TestUnifiedResourceSnapshotIncludesPlatformFixtures(t *testing.T) { wantNames := map[string]bool{ graph.PlatformFixtures.TrueNAS.System.Hostname: false, graph.PlatformFixtures.VMware.Hosts[0].Name: false, + graph.PlatformFixtures.VMware.Networks[0].Name: false, legacyName: false, } + vmwareNetworkProjected := false for _, resource := range resources { if _, ok := wantNames[resource.Name]; ok { wantNames[resource.Name] = true } + if resource.Name == graph.PlatformFixtures.VMware.Networks[0].Name && + resource.Type == unifiedresources.ResourceTypeNetwork && + slices.Contains(resource.Sources, unifiedresources.SourceVMware) { + vmwareNetworkProjected = true + } } for name, found := range wantNames { if !found { t.Fatalf("expected mock unified resources to include %q", name) } } + if !vmwareNetworkProjected { + t.Fatalf( + "expected VMware fixture network %q to project as a canonical VMware network resource", + graph.PlatformFixtures.VMware.Networks[0].Name, + ) + } } func TestUnifiedResourceSnapshotParentsDemoProxmoxWorkloads(t *testing.T) { diff --git a/internal/mock/platform_support_contract_test.go b/internal/mock/platform_support_contract_test.go index 8d08e33c6..289865232 100644 --- a/internal/mock/platform_support_contract_test.go +++ b/internal/mock/platform_support_contract_test.go @@ -242,7 +242,7 @@ func TestVMwareFixturesRemainAdmittedAtPhase1Floor(t *testing.T) { if vmwareManifest.PrimaryMode != "api-backed" { t.Fatalf("vmware primary mode = %q, want api-backed", vmwareManifest.PrimaryMode) } - if diff := diffPlatformSets([]string{"agent", "storage", "vm"}, vmwareManifest.CanonicalProjections); diff != "" { + if diff := diffPlatformSets([]string{"agent", "network", "storage", "vm"}, vmwareManifest.CanonicalProjections); diff != "" { t.Fatalf("vmware canonical projections drifted from the support model:\n%s", diff) } if got := vmwareManifest.SupportFloor["recovery"]; got != "n/a" { @@ -259,7 +259,7 @@ func TestVMwareFixturesRemainAdmittedAtPhase1Floor(t *testing.T) { if fixture.CollectedAt.IsZero() { t.Fatal("expected VMware mock connection fixture freshness") } - if fixture.Hosts == 0 || fixture.VMs == 0 || fixture.Datastores == 0 { + if fixture.Hosts == 0 || fixture.VMs == 0 || fixture.Datastores == 0 || fixture.Networks == 0 { t.Fatalf("expected VMware mock connection fixture to describe canonical inventory, got %+v", fixture) } } diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 4264c7bf7..f7060fb21 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -250,13 +250,11 @@ func TestBroadcastResourceProjectionCoalescesSplitHostIdentities(t *testing.T) { for _, snippet := range []string{ "metricsTargetResolver := broadcastMetricsTargetResolver(unifiedView.readState)", - "broadcastResources := coalesceBroadcastResources(unifiedView.resources)", + "broadcastResources := unifiedresources.CoalescePresentationHostResources(unifiedView.resources)", "frontendState.Resources = convertResourcesForBroadcast(broadcastResources, metricsTargetResolver)", "frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(broadcastResources, snapshot)", - "func coalesceBroadcastResources(resources []unifiedresources.Resource) []unifiedresources.Resource {", "func attachBroadcastMetricsTargets(", - "func shouldMergeBroadcastHostResources(left, right unifiedresources.Resource) bool {", - "broadcastHasSource(sources, unifiedresources.SourceAgent) && broadcastHasRuntimePlatformSource(sources)", + "allResources = unifiedresources.CoalescePresentationHostResources(allResources)", } { if !strings.Contains(source, snippet) { t.Fatalf("monitor.go must contain %q", snippet) diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index d7263b362..6febadc2f 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -3728,7 +3728,7 @@ func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateS } unifiedView := m.currentUnifiedStateView() metricsTargetResolver := broadcastMetricsTargetResolver(unifiedView.readState) - broadcastResources := coalesceBroadcastResources(unifiedView.resources) + broadcastResources := unifiedresources.CoalescePresentationHostResources(unifiedView.resources) frontendState.Resources = convertResourcesForBroadcast(broadcastResources, metricsTargetResolver) frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(broadcastResources, snapshot) if !unifiedView.freshness.IsZero() { @@ -4909,319 +4909,6 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend { ) } -func coalesceBroadcastResources(resources []unifiedresources.Resource) []unifiedresources.Resource { - if len(resources) == 0 { - return resources - } - - coalesced := make([]unifiedresources.Resource, 0, len(resources)) - indexByHostKey := make(map[string]int, len(resources)) - for _, resource := range resources { - resource.Type = unifiedresources.CanonicalResourceType(resource.Type) - hostKey := broadcastHostMergeKey(resource) - if hostKey == "" { - coalesced = append(coalesced, resource) - continue - } - - existingIndex, ok := indexByHostKey[hostKey] - if !ok { - indexByHostKey[hostKey] = len(coalesced) - coalesced = append(coalesced, resource) - continue - } - - existing := coalesced[existingIndex] - if !shouldMergeBroadcastHostResources(existing, resource) { - coalesced = append(coalesced, resource) - continue - } - coalesced[existingIndex] = mergeBroadcastHostResources(existing, resource) - } - - return coalesced -} - -func broadcastHostMergeKey(resource unifiedresources.Resource) string { - if unifiedresources.CanonicalResourceType(resource.Type) != unifiedresources.ResourceTypeAgent { - return "" - } - - candidates := []string{} - if resource.Canonical != nil { - candidates = append(candidates, resource.Canonical.PlatformID, resource.Canonical.Hostname) - } - candidates = append(candidates, resource.Identity.Hostnames...) - if resource.Agent != nil { - candidates = append(candidates, resource.Agent.Hostname) - } - if resource.Proxmox != nil { - candidates = append(candidates, resource.Proxmox.NodeName) - } - candidates = append(candidates, resource.Name) - - for _, candidate := range candidates { - normalized := unifiedresources.NormalizeHostname(candidate) - if normalized != "" { - return "agent:" + normalized - } - } - return "" -} - -func shouldMergeBroadcastHostResources(left, right unifiedresources.Resource) bool { - if unifiedresources.CanonicalResourceType(left.Type) != unifiedresources.ResourceTypeAgent || - unifiedresources.CanonicalResourceType(right.Type) != unifiedresources.ResourceTypeAgent { - return false - } - sources := mergeBroadcastSources(broadcastResourceSources(left), broadcastResourceSources(right)) - return broadcastHasSource(sources, unifiedresources.SourceAgent) && broadcastHasRuntimePlatformSource(sources) -} - -func broadcastResourceSources(resource unifiedresources.Resource) []unifiedresources.DataSource { - sources := append([]unifiedresources.DataSource(nil), resource.Sources...) - for source := range resource.SourceStatus { - sources = append(sources, source) - } - if resource.Agent != nil { - sources = append(sources, unifiedresources.SourceAgent) - } - if resource.Proxmox != nil { - sources = append(sources, unifiedresources.SourceProxmox) - } - if resource.Docker != nil { - sources = append(sources, unifiedresources.SourceDocker) - } - if resource.Kubernetes != nil { - sources = append(sources, unifiedresources.SourceK8s) - } - if resource.VMware != nil { - sources = append(sources, unifiedresources.SourceVMware) - } - if resource.TrueNAS != nil { - sources = append(sources, unifiedresources.SourceTrueNAS) - } - return mergeBroadcastSources(nil, sources) -} - -func broadcastHasRuntimePlatformSource(sources []unifiedresources.DataSource) bool { - for _, source := range []unifiedresources.DataSource{ - unifiedresources.SourceProxmox, - unifiedresources.SourceDocker, - unifiedresources.SourceK8s, - unifiedresources.SourceVMware, - unifiedresources.SourceTrueNAS, - } { - if broadcastHasSource(sources, source) { - return true - } - } - return false -} - -func broadcastHasSource(sources []unifiedresources.DataSource, target unifiedresources.DataSource) bool { - for _, source := range sources { - if source == target { - return true - } - } - return false -} - -func mergeBroadcastSources(left, right []unifiedresources.DataSource) []unifiedresources.DataSource { - merged := make([]unifiedresources.DataSource, 0, len(left)+len(right)) - seen := make(map[unifiedresources.DataSource]struct{}, len(left)+len(right)) - for _, source := range append(append([]unifiedresources.DataSource(nil), left...), right...) { - if strings.TrimSpace(string(source)) == "" { - continue - } - if _, ok := seen[source]; ok { - continue - } - seen[source] = struct{}{} - merged = append(merged, source) - } - return merged -} - -func mergeBroadcastHostResources(left, right unifiedresources.Resource) unifiedresources.Resource { - primary, secondary := left, right - if preferBroadcastHostPrimary(right, left) { - primary, secondary = right, left - } - - merged := primary - merged.Sources = mergeBroadcastSources(broadcastResourceSources(primary), broadcastResourceSources(secondary)) - merged.SourceStatus = mergeBroadcastSourceStatus(primary.SourceStatus, secondary.SourceStatus, merged.Sources, primary.LastSeen, secondary.LastSeen) - merged.Identity = mergeBroadcastIdentity(primary.Identity, secondary.Identity) - - if merged.Agent == nil { - merged.Agent = secondary.Agent - } - if merged.Proxmox == nil { - merged.Proxmox = secondary.Proxmox - } - if merged.Docker == nil { - merged.Docker = secondary.Docker - } - if merged.Kubernetes == nil { - merged.Kubernetes = secondary.Kubernetes - } - if merged.VMware == nil { - merged.VMware = secondary.VMware - } - if merged.TrueNAS == nil { - merged.TrueNAS = secondary.TrueNAS - } - if merged.Storage == nil { - merged.Storage = secondary.Storage - } - if merged.Metrics == nil { - merged.Metrics = secondary.Metrics - } else if secondary.Metrics != nil { - merged.Metrics = mergeBroadcastMetrics(merged.Metrics, secondary.Metrics) - } - if merged.DiscoveryTarget == nil { - merged.DiscoveryTarget = secondary.DiscoveryTarget - } - if merged.MetricsTarget == nil { - merged.MetricsTarget = secondary.MetricsTarget - } - if merged.Canonical == nil { - merged.Canonical = secondary.Canonical - } - merged.Tags = uniqueBroadcastStrings(append(append([]string(nil), secondary.Tags...), primary.Tags...)) - merged.Incidents = append(append([]unifiedresources.ResourceIncident(nil), secondary.Incidents...), primary.Incidents...) - if secondary.LastSeen.After(merged.LastSeen) { - merged.LastSeen = secondary.LastSeen - } - if secondary.UpdatedAt.After(merged.UpdatedAt) { - merged.UpdatedAt = secondary.UpdatedAt - } - merged.Status = betterBroadcastStatus(merged.Status, secondary.Status) - return merged -} - -func preferBroadcastHostPrimary(candidate, other unifiedresources.Resource) bool { - candidateHasAgent := broadcastHasSource(broadcastResourceSources(candidate), unifiedresources.SourceAgent) - otherHasAgent := broadcastHasSource(broadcastResourceSources(other), unifiedresources.SourceAgent) - if candidateHasAgent != otherHasAgent { - return candidateHasAgent - } - if candidate.LastSeen.Equal(other.LastSeen) { - return strings.TrimSpace(candidate.ID) < strings.TrimSpace(other.ID) - } - return candidate.LastSeen.After(other.LastSeen) -} - -func mergeBroadcastSourceStatus( - left, right map[unifiedresources.DataSource]unifiedresources.SourceStatus, - sources []unifiedresources.DataSource, - leftLastSeen time.Time, - rightLastSeen time.Time, -) map[unifiedresources.DataSource]unifiedresources.SourceStatus { - merged := make(map[unifiedresources.DataSource]unifiedresources.SourceStatus, len(sources)) - for source, status := range right { - merged[source] = status - } - for source, status := range left { - merged[source] = status - } - for _, source := range sources { - if _, ok := merged[source]; ok { - continue - } - lastSeen := leftLastSeen - if rightLastSeen.After(lastSeen) { - lastSeen = rightLastSeen - } - merged[source] = unifiedresources.SourceStatus{Status: "online", LastSeen: lastSeen} - } - return merged -} - -func mergeBroadcastIdentity(left, right unifiedresources.ResourceIdentity) unifiedresources.ResourceIdentity { - merged := left - if merged.MachineID == "" { - merged.MachineID = right.MachineID - } - if merged.DMIUUID == "" { - merged.DMIUUID = right.DMIUUID - } - if merged.ClusterName == "" { - merged.ClusterName = right.ClusterName - } - merged.Hostnames = uniqueBroadcastStrings(append(append([]string(nil), left.Hostnames...), right.Hostnames...)) - merged.IPAddresses = uniqueBroadcastStrings(append(append([]string(nil), left.IPAddresses...), right.IPAddresses...)) - merged.MACAddresses = uniqueBroadcastStrings(append(append([]string(nil), left.MACAddresses...), right.MACAddresses...)) - return merged -} - -func uniqueBroadcastStrings(values []string) []string { - if len(values) == 0 { - return nil - } - unique := make([]string, 0, len(values)) - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - continue - } - if _, ok := seen[trimmed]; ok { - continue - } - seen[trimmed] = struct{}{} - unique = append(unique, trimmed) - } - return unique -} - -func mergeBroadcastMetrics(left, right *unifiedresources.ResourceMetrics) *unifiedresources.ResourceMetrics { - if left == nil { - return right - } - if right == nil { - return left - } - merged := *left - if merged.CPU == nil { - merged.CPU = right.CPU - } - if merged.Memory == nil { - merged.Memory = right.Memory - } - if merged.Disk == nil { - merged.Disk = right.Disk - } - if merged.NetIn == nil { - merged.NetIn = right.NetIn - } - if merged.NetOut == nil { - merged.NetOut = right.NetOut - } - if merged.DiskRead == nil { - merged.DiskRead = right.DiskRead - } - if merged.DiskWrite == nil { - merged.DiskWrite = right.DiskWrite - } - return &merged -} - -func betterBroadcastStatus(left, right unifiedresources.ResourceStatus) unifiedresources.ResourceStatus { - rank := map[unifiedresources.ResourceStatus]int{ - unifiedresources.StatusOnline: 4, - unifiedresources.StatusWarning: 3, - unifiedresources.StatusUnknown: 2, - unifiedresources.StatusOffline: 1, - } - if rank[right] > rank[left] { - return right - } - return left -} - // convertResourcesForBroadcast converts unified resources into the frontend payload shape. func convertResourcesForBroadcast( allResources []unifiedresources.Resource, @@ -5234,7 +4921,7 @@ func convertResourcesForBroadcast( allResources, firstBroadcastMetricsTargetResolver(metricsTargetResolvers), ) - allResources = coalesceBroadcastResources(allResources) + allResources = unifiedresources.CoalescePresentationHostResources(allResources) type broadcastResource struct { input models.ResourceConvertInput sortKey string diff --git a/internal/monitoring/vmware_poller.go b/internal/monitoring/vmware_poller.go index 3e912e098..360b205aa 100644 --- a/internal/monitoring/vmware_poller.go +++ b/internal/monitoring/vmware_poller.go @@ -42,6 +42,7 @@ type VMwareConnectionObservedSummary struct { Hosts int `json:"hosts"` VMs int `json:"vms"` Datastores int `json:"datastores"` + Networks int `json:"networks"` VIRelease string `json:"viRelease,omitempty"` Degraded bool `json:"degraded,omitempty"` IssueCount int `json:"issueCount,omitempty"` @@ -867,6 +868,7 @@ func (p *VMwarePoller) RecordConnectionTestSuccess(orgID, connID string, summary Hosts: summary.Hosts, VMs: summary.VMs, Datastores: summary.Datastores, + Networks: summary.Networks, VIRelease: strings.TrimSpace(summary.VIRelease), } status.observedIssueKey = "" @@ -901,6 +903,7 @@ func buildVMwareObservedSummary(snapshot *vmware.InventorySnapshot) *VMwareConne Hosts: len(snapshot.Hosts), VMs: len(snapshot.VMs), Datastores: len(snapshot.Datastores), + Networks: len(snapshot.Networks), VIRelease: strings.TrimSpace(snapshot.VIRelease), } if len(snapshot.EnrichmentIssues) > 0 { @@ -977,6 +980,7 @@ func cloneVMwareObservedSummary(value *VMwareConnectionObservedSummary) *VMwareC Hosts: value.Hosts, VMs: value.VMs, Datastores: value.Datastores, + Networks: value.Networks, VIRelease: strings.TrimSpace(value.VIRelease), Degraded: value.Degraded, IssueCount: value.IssueCount, diff --git a/internal/monitoring/vmware_poller_test.go b/internal/monitoring/vmware_poller_test.go index 1f768f54f..448562421 100644 --- a/internal/monitoring/vmware_poller_test.go +++ b/internal/monitoring/vmware_poller_test.go @@ -206,6 +206,7 @@ func TestVMwarePollerConnectionSummariesCaptureFailuresWithoutClearingObservedSu Hosts: 2, VMs: 14, Datastores: 3, + Networks: 5, VIRelease: "8.0.3", }, successAt) poller.RecordConnectionTestFailure("default", connection.ID, &vmware.ConnectionError{ @@ -220,7 +221,7 @@ func TestVMwarePollerConnectionSummariesCaptureFailuresWithoutClearingObservedSu if summary.Poll.ConsecutiveFailures != 1 || summary.Poll.LastError.Category != "permission" { t.Fatalf("unexpected poll failure details: %+v", summary.Poll) } - if summary.Observed == nil || summary.Observed.VMs != 14 || summary.Observed.VIRelease != "8.0.3" { + if summary.Observed == nil || summary.Observed.VMs != 14 || summary.Observed.Networks != 5 || summary.Observed.VIRelease != "8.0.3" { t.Fatalf("expected observed summary to be preserved after failure, got %+v", summary.Observed) } } diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index 2049b51fb..d44fa4381 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -268,6 +268,10 @@ func cloneVMwareData(in *VMwareData) *VMwareData { out.ClusterDRSEnabled = cloneBoolPtr(in.ClusterDRSEnabled) out.DatastoreAccessible = cloneBoolPtr(in.DatastoreAccessible) out.MultipleHostAccess = cloneBoolPtr(in.MultipleHostAccess) + out.NetworkHostIDs = cloneStringSlice(in.NetworkHostIDs) + out.NetworkHostNames = cloneStringSlice(in.NetworkHostNames) + out.NetworkVMIDs = cloneStringSlice(in.NetworkVMIDs) + out.NetworkVMNames = cloneStringSlice(in.NetworkVMNames) out.GuestIPAddresses = cloneStringSlice(in.GuestIPAddresses) out.SnapshotTree = cloneVMwareSnapshotDataSlice(in.SnapshotTree) out.NetworkAdapters = cloneVMwareNetworkAdapterDataSlice(in.NetworkAdapters) diff --git a/internal/unifiedresources/clone_test.go b/internal/unifiedresources/clone_test.go index 42caa76c1..3bcd0e2d5 100644 --- a/internal/unifiedresources/clone_test.go +++ b/internal/unifiedresources/clone_test.go @@ -171,6 +171,8 @@ func TestCloneResource_MutateVMwareDetailSlices(t *testing.T) { VMware: &VMwareData{ ClusterHAEnabled: &clusterHAEnabled, ClusterDRSEnabled: &clusterDRSEnabled, + NetworkHostNames: []string{"esxi-01.lab.local"}, + NetworkVMNames: []string{"app-01"}, SnapshotTree: []VMwareSnapshotData{{ Snapshot: "snapshot-201", Name: "pre-upgrade", @@ -233,6 +235,8 @@ func TestCloneResource_MutateVMwareDetailSlices(t *testing.T) { *cloned.VMware.VirtualDisks[0].CapacityBytes = 1 *cloned.VMware.ClusterHAEnabled = false *cloned.VMware.ClusterDRSEnabled = true + cloned.VMware.NetworkHostNames[0] = "mutated-host" + cloned.VMware.NetworkVMNames[0] = "mutated-vm" *cloned.VMware.Tools.AutoUpdateSupported = false *cloned.VMware.Tools.VersionNumber = 1 cloned.VMware.Tools.GuestRebootComponents[0] = "mutated" @@ -253,6 +257,12 @@ func TestCloneResource_MutateVMwareDetailSlices(t *testing.T) { if original.VMware.NetworkAdapters[0].NetworkName != "VM Network" { t.Fatalf("mutating cloned VMware adapter should not affect original: %+v", original.VMware.NetworkAdapters) } + if original.VMware.NetworkHostNames[0] != "esxi-01.lab.local" { + t.Fatalf("mutating cloned VMware network hosts should not affect original: %+v", original.VMware.NetworkHostNames) + } + if original.VMware.NetworkVMNames[0] != "app-01" { + t.Fatalf("mutating cloned VMware network VMs should not affect original: %+v", original.VMware.NetworkVMNames) + } if *original.VMware.NetworkAdapters[0].PCISlotNumber != 160 { t.Fatalf("mutating cloned VMware adapter PCI slot should not affect original: %+v", original.VMware.NetworkAdapters[0].PCISlotNumber) } diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 52a4dc2e5..209eb05a3 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -1918,11 +1918,11 @@ func TestResourceAPIHotPathUsesSingleRegistryListSnapshot(t *testing.T) { source := string(data) normalizedPath := filepath.ToSlash(path) - if strings.Count(source, "allResources := registry.List()") != 2 { - t.Fatalf("%s: expected HandleListResources and HandleStats to each seed exactly one registry list snapshot", normalizedPath) + if strings.Count(source, "allResources := presentationResourcesFromRegistry(registry)") != 2 { + t.Fatalf("%s: expected HandleListResources and HandleStats to each seed exactly one presentation resource snapshot", normalizedPath) } - if strings.Count(source, "computeResourceContractByType(allResources)") != 2 { - t.Fatalf("%s: expected canonical by-type aggregations to reuse the seeded registry snapshot in both handlers", normalizedPath) + if strings.Count(source, "computeResourceContractStats(allResources)") != 2 { + t.Fatalf("%s: expected canonical aggregations to reuse the seeded presentation snapshot in both handlers", normalizedPath) } if strings.Contains(source, "computeResourceContractByType(registry.List())") { t.Fatalf("%s: duplicate registry.List() hot-path aggregation detected", normalizedPath) @@ -2009,6 +2009,8 @@ func TestCloneVMwareDataKeepsNestedRuntimeDetailsIsolated(t *testing.T) { "out.VirtualDisks = cloneVMwareVirtualDiskDataSlice(in.VirtualDisks)", "out.ClusterHAEnabled = cloneBoolPtr(in.ClusterHAEnabled)", "out.ClusterDRSEnabled = cloneBoolPtr(in.ClusterDRSEnabled)", + "out.NetworkHostNames = cloneStringSlice(in.NetworkHostNames)", + "out.NetworkVMNames = cloneStringSlice(in.NetworkVMNames)", "out.Tools = cloneVMwareToolsData(in.Tools)", "out.Hardware = cloneVMwareVMHardwareData(in.Hardware)", "out[i].CreatedAt = cloneTimePtr(in[i].CreatedAt)", diff --git a/internal/unifiedresources/policy_metadata.go b/internal/unifiedresources/policy_metadata.go index c0e2f781f..29ea4f695 100644 --- a/internal/unifiedresources/policy_metadata.go +++ b/internal/unifiedresources/policy_metadata.go @@ -139,6 +139,7 @@ func classifyResourceSensitivity(resource Resource) ResourceSensitivity { ResourceTypeK8sDeployment, ResourceTypeDockerService, ResourceTypeStorage, + ResourceTypeNetwork, ResourceTypePBS, ResourceTypePhysicalDisk, ResourceTypeCeph, @@ -272,6 +273,8 @@ func resourceSummaryType(resource Resource) string { return "kubernetes deployment" case ResourceTypeStorage: return "storage" + case ResourceTypeNetwork: + return "network" case ResourceTypePBS: return "backup server" case ResourceTypePMG: diff --git a/internal/unifiedresources/presentation_coalesce.go b/internal/unifiedresources/presentation_coalesce.go new file mode 100644 index 000000000..230f57ccb --- /dev/null +++ b/internal/unifiedresources/presentation_coalesce.go @@ -0,0 +1,353 @@ +package unifiedresources + +import ( + "strings" + "time" +) + +// CoalescePresentationHostResources collapses split top-level host views for +// API and broadcast presentation. The registry keeps source-native records so +// raw provenance remains available; presentation surfaces should show one +// monitored host when a runtime/platform view and the Pulse agent view share a +// canonical hostname. +func CoalescePresentationHostResources(resources []Resource) []Resource { + return CoalescePresentationHostResourcesWithExclusions(resources, nil) +} + +// CoalescePresentationHostResourcesWithExclusions applies the presentation +// host coalesce while honoring caller-owned split decisions. +func CoalescePresentationHostResourcesWithExclusions( + resources []Resource, + excluded func(left, right Resource) bool, +) []Resource { + coalesced := coalescePresentationHostResourcesOnce(resources, excluded) + for len(coalesced) < len(resources) { + next := coalescePresentationHostResourcesOnce(coalesced, excluded) + if len(next) == len(coalesced) { + return next + } + resources = coalesced + coalesced = next + } + return coalesced +} + +func coalescePresentationHostResourcesOnce( + resources []Resource, + excluded func(left, right Resource) bool, +) []Resource { + if len(resources) == 0 { + return resources + } + + coalesced := make([]Resource, 0, len(resources)) + indexByHostKey := make(map[string]int, len(resources)) + for _, resource := range resources { + resource.Type = CanonicalResourceType(resource.Type) + hostKey := presentationHostMergeKey(resource) + if hostKey == "" { + coalesced = append(coalesced, resource) + continue + } + + existingIndex, ok := indexByHostKey[hostKey] + if !ok { + indexByHostKey[hostKey] = len(coalesced) + coalesced = append(coalesced, resource) + continue + } + + existing := coalesced[existingIndex] + if excluded != nil && excluded(existing, resource) { + coalesced = append(coalesced, resource) + continue + } + if !shouldMergePresentationHostResources(existing, resource) { + coalesced = append(coalesced, resource) + continue + } + coalesced[existingIndex] = mergePresentationHostResources(existing, resource) + } + + return coalesced +} + +func presentationHostMergeKey(resource Resource) string { + if CanonicalResourceType(resource.Type) != ResourceTypeAgent { + return "" + } + + candidates := []string{} + if resource.Canonical != nil { + candidates = append(candidates, resource.Canonical.PlatformID, resource.Canonical.Hostname) + } + candidates = append(candidates, resource.Identity.Hostnames...) + if resource.Agent != nil { + candidates = append(candidates, resource.Agent.Hostname) + } + if resource.Proxmox != nil { + candidates = append(candidates, resource.Proxmox.NodeName) + } + candidates = append(candidates, resource.Name) + + for _, candidate := range candidates { + normalized := NormalizeHostname(candidate) + if normalized != "" { + return "agent:" + normalized + } + } + return "" +} + +func shouldMergePresentationHostResources(left, right Resource) bool { + if CanonicalResourceType(left.Type) != ResourceTypeAgent || + CanonicalResourceType(right.Type) != ResourceTypeAgent { + return false + } + sources := mergePresentationSources(presentationResourceSources(left), presentationResourceSources(right)) + return presentationHasSource(sources, SourceAgent) && presentationHasRuntimePlatformSource(sources) +} + +func presentationResourceSources(resource Resource) []DataSource { + sources := append([]DataSource(nil), resource.Sources...) + for source := range resource.SourceStatus { + sources = append(sources, source) + } + if resource.Agent != nil { + sources = append(sources, SourceAgent) + } + if resource.Proxmox != nil { + sources = append(sources, SourceProxmox) + } + if resource.Docker != nil { + sources = append(sources, SourceDocker) + } + if resource.Kubernetes != nil { + sources = append(sources, SourceK8s) + } + if resource.VMware != nil { + sources = append(sources, SourceVMware) + } + if resource.TrueNAS != nil { + sources = append(sources, SourceTrueNAS) + } + return mergePresentationSources(nil, sources) +} + +func presentationHasRuntimePlatformSource(sources []DataSource) bool { + for _, source := range []DataSource{ + SourceProxmox, + SourceDocker, + SourceK8s, + SourceVMware, + SourceTrueNAS, + } { + if presentationHasSource(sources, source) { + return true + } + } + return false +} + +func presentationHasSource(sources []DataSource, target DataSource) bool { + for _, source := range sources { + if source == target { + return true + } + } + return false +} + +func mergePresentationSources(left, right []DataSource) []DataSource { + merged := make([]DataSource, 0, len(left)+len(right)) + seen := make(map[DataSource]struct{}, len(left)+len(right)) + for _, source := range append(append([]DataSource(nil), left...), right...) { + if strings.TrimSpace(string(source)) == "" { + continue + } + if _, ok := seen[source]; ok { + continue + } + seen[source] = struct{}{} + merged = append(merged, source) + } + return merged +} + +func mergePresentationHostResources(left, right Resource) Resource { + primary, secondary := left, right + if preferPresentationHostPrimary(right, left) { + primary, secondary = right, left + } + + merged := primary + merged.Sources = mergePresentationSources(presentationResourceSources(primary), presentationResourceSources(secondary)) + merged.SourceStatus = mergePresentationSourceStatus(primary.SourceStatus, secondary.SourceStatus, merged.Sources, primary.LastSeen, secondary.LastSeen) + merged.Identity = mergePresentationIdentity(primary.Identity, secondary.Identity) + + if merged.Agent == nil { + merged.Agent = secondary.Agent + } + if merged.Proxmox == nil { + merged.Proxmox = secondary.Proxmox + } + if merged.Docker == nil { + merged.Docker = secondary.Docker + } + if merged.Kubernetes == nil { + merged.Kubernetes = secondary.Kubernetes + } + if merged.VMware == nil { + merged.VMware = secondary.VMware + } + if merged.TrueNAS == nil { + merged.TrueNAS = secondary.TrueNAS + } + if merged.Storage == nil { + merged.Storage = secondary.Storage + } + if merged.Metrics == nil { + merged.Metrics = secondary.Metrics + } else if secondary.Metrics != nil { + merged.Metrics = mergePresentationMetrics(merged.Metrics, secondary.Metrics) + } + if merged.DiscoveryTarget == nil { + merged.DiscoveryTarget = secondary.DiscoveryTarget + } + if merged.MetricsTarget == nil { + merged.MetricsTarget = secondary.MetricsTarget + } + if merged.Canonical == nil { + merged.Canonical = secondary.Canonical + } + merged.Tags = uniquePresentationStrings(append(append([]string(nil), secondary.Tags...), primary.Tags...)) + merged.Incidents = append(append([]ResourceIncident(nil), secondary.Incidents...), primary.Incidents...) + if secondary.LastSeen.After(merged.LastSeen) { + merged.LastSeen = secondary.LastSeen + } + if secondary.UpdatedAt.After(merged.UpdatedAt) { + merged.UpdatedAt = secondary.UpdatedAt + } + merged.Status = betterPresentationStatus(merged.Status, secondary.Status) + return merged +} + +func preferPresentationHostPrimary(candidate, other Resource) bool { + candidateHasAgent := presentationHasSource(presentationResourceSources(candidate), SourceAgent) + otherHasAgent := presentationHasSource(presentationResourceSources(other), SourceAgent) + if candidateHasAgent != otherHasAgent { + return candidateHasAgent + } + if candidate.LastSeen.Equal(other.LastSeen) { + return strings.TrimSpace(candidate.ID) < strings.TrimSpace(other.ID) + } + return candidate.LastSeen.After(other.LastSeen) +} + +func mergePresentationSourceStatus( + left, right map[DataSource]SourceStatus, + sources []DataSource, + leftLastSeen time.Time, + rightLastSeen time.Time, +) map[DataSource]SourceStatus { + merged := make(map[DataSource]SourceStatus, len(sources)) + for source, status := range right { + merged[source] = status + } + for source, status := range left { + merged[source] = status + } + for _, source := range sources { + if _, ok := merged[source]; ok { + continue + } + lastSeen := leftLastSeen + if rightLastSeen.After(lastSeen) { + lastSeen = rightLastSeen + } + merged[source] = SourceStatus{Status: "online", LastSeen: lastSeen} + } + return merged +} + +func mergePresentationIdentity(left, right ResourceIdentity) ResourceIdentity { + merged := left + if merged.MachineID == "" { + merged.MachineID = right.MachineID + } + if merged.DMIUUID == "" { + merged.DMIUUID = right.DMIUUID + } + if merged.ClusterName == "" { + merged.ClusterName = right.ClusterName + } + merged.Hostnames = uniquePresentationStrings(append(append([]string(nil), left.Hostnames...), right.Hostnames...)) + merged.IPAddresses = uniquePresentationStrings(append(append([]string(nil), left.IPAddresses...), right.IPAddresses...)) + merged.MACAddresses = uniquePresentationStrings(append(append([]string(nil), left.MACAddresses...), right.MACAddresses...)) + return merged +} + +func uniquePresentationStrings(values []string) []string { + if len(values) == 0 { + return nil + } + unique := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + unique = append(unique, trimmed) + } + return unique +} + +func mergePresentationMetrics(left, right *ResourceMetrics) *ResourceMetrics { + if left == nil { + return right + } + if right == nil { + return left + } + merged := *left + if merged.CPU == nil { + merged.CPU = right.CPU + } + if merged.Memory == nil { + merged.Memory = right.Memory + } + if merged.Disk == nil { + merged.Disk = right.Disk + } + if merged.NetIn == nil { + merged.NetIn = right.NetIn + } + if merged.NetOut == nil { + merged.NetOut = right.NetOut + } + if merged.DiskRead == nil { + merged.DiskRead = right.DiskRead + } + if merged.DiskWrite == nil { + merged.DiskWrite = right.DiskWrite + } + return &merged +} + +func betterPresentationStatus(left, right ResourceStatus) ResourceStatus { + rank := map[ResourceStatus]int{ + StatusOnline: 4, + StatusWarning: 3, + StatusUnknown: 2, + StatusOffline: 1, + } + if rank[right] > rank[left] { + return right + } + return left +} diff --git a/internal/unifiedresources/presentation_coalesce_test.go b/internal/unifiedresources/presentation_coalesce_test.go new file mode 100644 index 000000000..df1d7ffc7 --- /dev/null +++ b/internal/unifiedresources/presentation_coalesce_test.go @@ -0,0 +1,175 @@ +package unifiedresources + +import ( + "slices" + "testing" + "time" +) + +func TestCoalescePresentationHostResourcesMergesSplitRuntimeAndPlatformHost(t *testing.T) { + now := time.Date(2026, 5, 22, 10, 30, 0, 0, time.UTC) + resources := []Resource{ + { + ID: "agent-proxmox-delly", + Type: ResourceTypeAgent, + Name: "delly", + Status: StatusWarning, + LastSeen: now.Add(-1 * time.Minute), + Sources: []DataSource{SourceProxmox}, + Identity: ResourceIdentity{Hostnames: []string{"delly"}}, + Proxmox: &ProxmoxData{ + NodeName: "delly", + ClusterName: "homelab", + }, + }, + { + ID: "agent-runtime-delly", + Type: ResourceTypeAgent, + Name: "delly", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceAgent}, + Identity: ResourceIdentity{ + MachineID: "agent-machine-delly", + Hostnames: []string{"delly"}, + }, + Agent: &AgentData{ + AgentID: "agent-machine-delly", + Hostname: "delly", + OSName: "Proxmox VE", + }, + }, + } + + coalesced := CoalescePresentationHostResources(resources) + if len(coalesced) != 1 { + t.Fatalf("expected split host resources to coalesce into 1 resource, got %d: %#v", len(coalesced), coalesced) + } + + resource := coalesced[0] + if resource.ID != "agent-runtime-delly" { + t.Fatalf("expected agent-backed resource ID, got %q", resource.ID) + } + if resource.Agent == nil || resource.Proxmox == nil { + t.Fatalf("expected merged agent and Proxmox facets, got agent=%+v proxmox=%+v", resource.Agent, resource.Proxmox) + } + if !slices.Contains(resource.Sources, SourceAgent) || !slices.Contains(resource.Sources, SourceProxmox) { + t.Fatalf("expected merged agent and Proxmox sources, got %+v", resource.Sources) + } +} + +func TestCoalescePresentationHostResourcesDoesNotMergeRuntimeOnlyNameCollision(t *testing.T) { + now := time.Date(2026, 5, 22, 10, 30, 0, 0, time.UTC) + resources := []Resource{ + { + ID: "agent-left", + Type: ResourceTypeAgent, + Name: "shared-host", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceAgent}, + Identity: ResourceIdentity{Hostnames: []string{"shared-host"}}, + Agent: &AgentData{AgentID: "agent-left", Hostname: "shared-host"}, + }, + { + ID: "agent-right", + Type: ResourceTypeAgent, + Name: "shared-host", + Status: StatusOnline, + LastSeen: now.Add(time.Second), + Sources: []DataSource{SourceAgent}, + Identity: ResourceIdentity{Hostnames: []string{"shared-host"}}, + Agent: &AgentData{AgentID: "agent-right", Hostname: "shared-host"}, + }, + } + + if coalesced := CoalescePresentationHostResources(resources); len(coalesced) != 2 { + t.Fatalf("expected runtime-only host collision to stay split, got %#v", coalesced) + } +} + +func TestCoalescePresentationHostResourcesConvergesOrderSensitiveFragments(t *testing.T) { + now := time.Date(2026, 5, 22, 10, 30, 0, 0, time.UTC) + resources := []Resource{ + { + ID: "agent-k8s-cluster-a-worker-1", + Type: ResourceTypeAgent, + Name: "worker-1", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceK8s}, + Identity: ResourceIdentity{Hostnames: []string{"worker-1"}}, + Kubernetes: &K8sData{ + ClusterID: "cluster-a", + ClusterName: "production", + NodeName: "worker-1", + }, + }, + { + ID: "agent-k8s-cluster-b-worker-1", + Type: ResourceTypeAgent, + Name: "worker-1", + Status: StatusOnline, + LastSeen: now.Add(time.Second), + Sources: []DataSource{SourceK8s}, + Identity: ResourceIdentity{Hostnames: []string{"worker-1"}}, + Kubernetes: &K8sData{ + ClusterID: "cluster-b", + ClusterName: "production", + NodeName: "worker-1", + }, + }, + { + ID: "agent-runtime-worker-1", + Type: ResourceTypeAgent, + Name: "worker-1", + Status: StatusOnline, + LastSeen: now.Add(2 * time.Second), + Sources: []DataSource{SourceAgent}, + Identity: ResourceIdentity{Hostnames: []string{"worker-1"}}, + Agent: &AgentData{AgentID: "agent-worker-1", Hostname: "worker-1"}, + }, + } + + coalesced := CoalescePresentationHostResources(resources) + if len(coalesced) != 1 { + t.Fatalf("expected order-sensitive host fragments to converge into 1 resource, got %d: %#v", len(coalesced), coalesced) + } + if !slices.Contains(coalesced[0].Sources, SourceAgent) || !slices.Contains(coalesced[0].Sources, SourceK8s) { + t.Fatalf("expected merged agent and kubernetes sources, got %+v", coalesced[0].Sources) + } +} + +func TestCoalescePresentationHostResourcesWithExclusionsHonorsManualSplit(t *testing.T) { + now := time.Date(2026, 5, 22, 10, 30, 0, 0, time.UTC) + resources := []Resource{ + { + ID: "agent-runtime-alpha", + Type: ResourceTypeAgent, + Name: "alpha", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceAgent}, + Identity: ResourceIdentity{Hostnames: []string{"alpha"}}, + Agent: &AgentData{AgentID: "agent-alpha", Hostname: "alpha"}, + }, + { + ID: "agent-docker-alpha", + Type: ResourceTypeAgent, + Name: "alpha", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceDocker}, + Identity: ResourceIdentity{Hostnames: []string{"alpha"}}, + Docker: &DockerData{Hostname: "alpha"}, + }, + } + + coalesced := CoalescePresentationHostResourcesWithExclusions(resources, func(left, right Resource) bool { + return (left.ID == "agent-runtime-alpha" && right.ID == "agent-docker-alpha") || + (left.ID == "agent-docker-alpha" && right.ID == "agent-runtime-alpha") + }) + if len(coalesced) != 2 { + t.Fatalf("expected manual split exclusion to keep resources separate, got %#v", coalesced) + } +} diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 95e301a3f..0b2356b2d 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -633,6 +633,30 @@ func (rr *ResourceRegistry) List() []Resource { return out } +// ListForPresentation returns resources in the canonical API/broadcast +// presentation shape, including top-level host coalescing that respects manual +// merge exclusions. +func (rr *ResourceRegistry) ListForPresentation() []Resource { + resources := rr.List() + + rr.mu.RLock() + exclusions := make(map[string]struct{}, len(rr.exclusions)) + for key := range rr.exclusions { + exclusions[key] = struct{}{} + } + rr.mu.RUnlock() + + return CoalescePresentationHostResourcesWithExclusions(resources, func(left, right Resource) bool { + leftID := CanonicalResourceID(left.ID) + rightID := CanonicalResourceID(right.ID) + if leftID == "" || rightID == "" { + return false + } + _, ok := exclusions[exclusionKey(leftID, rightID)] + return ok + }) +} + // ListByType returns all resources of the provided type. // // The returned slice is sorted by resource ID to provide deterministic results. @@ -1808,6 +1832,21 @@ func mergeVMwareData(existing *VMwareData, incoming *VMwareData) *VMwareData { if incoming.MaintenanceMode != "" { merged.MaintenanceMode = incoming.MaintenanceMode } + if incoming.NetworkType != "" { + merged.NetworkType = incoming.NetworkType + } + if len(incoming.NetworkHostIDs) > 0 { + merged.NetworkHostIDs = uniqueStrings(append(cloneStringSlice(merged.NetworkHostIDs), incoming.NetworkHostIDs...)) + } + if len(incoming.NetworkHostNames) > 0 { + merged.NetworkHostNames = uniqueStrings(append(cloneStringSlice(merged.NetworkHostNames), incoming.NetworkHostNames...)) + } + if len(incoming.NetworkVMIDs) > 0 { + merged.NetworkVMIDs = uniqueStrings(append(cloneStringSlice(merged.NetworkVMIDs), incoming.NetworkVMIDs...)) + } + if len(incoming.NetworkVMNames) > 0 { + merged.NetworkVMNames = uniqueStrings(append(cloneStringSlice(merged.NetworkVMNames), incoming.NetworkVMNames...)) + } if incoming.InstanceUUID != "" { merged.InstanceUUID = incoming.InstanceUUID } diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index 40b39d242..936d93bba 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -750,6 +750,7 @@ func TestMergeVMwareDataMergesSignalFieldsWithoutDroppingExistingIdentity(t *tes DatacenterName: "DC1", ClusterName: "Cluster A", DatastoreNames: []string{"primary-vmfs"}, + NetworkHostNames: []string{"esxi-01.lab.local"}, ConnectionState: "connected", PowerState: "poweredOn", OverallStatus: "green", @@ -770,6 +771,9 @@ func TestMergeVMwareDataMergesSignalFieldsWithoutDroppingExistingIdentity(t *tes ClusterDRSEnabled: &clusterDRS, RuntimeHostName: "esxi-01.lab.local", DatastoreNames: []string{"backup-nfs"}, + NetworkType: "STANDARD_PORTGROUP", + NetworkHostNames: []string{"esxi-02.lab.local"}, + NetworkVMNames: []string{"app-01"}, DatastoreAccessible: &accessible, GuestIPAddresses: []string{"10.0.0.21"}, OverallStatus: "yellow", @@ -833,6 +837,15 @@ func TestMergeVMwareDataMergesSignalFieldsWithoutDroppingExistingIdentity(t *tes if got := merged.DatastoreNames; !reflect.DeepEqual(got, []string{"primary-vmfs", "backup-nfs"}) { t.Fatalf("datastore names = %#v", got) } + if got := merged.NetworkType; got != "STANDARD_PORTGROUP" { + t.Fatalf("network type = %q, want STANDARD_PORTGROUP", got) + } + if got := merged.NetworkHostNames; !reflect.DeepEqual(got, []string{"esxi-01.lab.local", "esxi-02.lab.local"}) { + t.Fatalf("network host names = %#v", got) + } + if got := merged.NetworkVMNames; !reflect.DeepEqual(got, []string{"app-01"}) { + t.Fatalf("network VM names = %#v", got) + } if merged.DatastoreAccessible == nil || *merged.DatastoreAccessible { t.Fatalf("datastore accessible = %#v, want false", merged.DatastoreAccessible) } diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 31881cb25..fa75c2515 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -130,6 +130,7 @@ const ( ResourceTypePod ResourceType = "pod" ResourceTypeK8sDeployment ResourceType = "k8s-deployment" ResourceTypeStorage ResourceType = "storage" + ResourceTypeNetwork ResourceType = "network" ResourceTypePBS ResourceType = "pbs" ResourceTypePMG ResourceType = "pmg" ResourceTypeCeph ResourceType = "ceph" @@ -992,8 +993,8 @@ type PMGData struct { DomainStatsAsOf time.Time `json:"domainStatsAsOf,omitempty"` } -// VMwareData contains VMware vSphere metadata for canonical agent, vm, and -// storage resources projected from one vCenter connection. +// VMwareData contains VMware vSphere metadata for canonical agent, vm, +// storage, and network resources projected from one vCenter connection. type VMwareData struct { ConnectionID string `json:"connectionId,omitempty"` ConnectionName string `json:"connectionName,omitempty"` @@ -1027,6 +1028,11 @@ type VMwareData struct { DatastoreAccessible *bool `json:"datastoreAccessible,omitempty"` MultipleHostAccess *bool `json:"multipleHostAccess,omitempty"` MaintenanceMode string `json:"maintenanceMode,omitempty"` + NetworkType string `json:"networkType,omitempty"` + NetworkHostIDs []string `json:"networkHostIds,omitempty"` + NetworkHostNames []string `json:"networkHostNames,omitempty"` + NetworkVMIDs []string `json:"networkVmIds,omitempty"` + NetworkVMNames []string `json:"networkVmNames,omitempty"` InstanceUUID string `json:"instanceUuid,omitempty"` BIOSUUID string `json:"biosUuid,omitempty"` GuestOSFamily string `json:"guestOsFamily,omitempty"` diff --git a/internal/vmware/activity_changes.go b/internal/vmware/activity_changes.go index fd0ead315..5f600f926 100644 --- a/internal/vmware/activity_changes.go +++ b/internal/vmware/activity_changes.go @@ -43,6 +43,10 @@ func activityChangesFromSnapshot(snapshot *InventorySnapshot) []unifiedresources resourceID := vmwareSourceID(snapshot.ConnectionID, "datastore", datastore.Datastore) changes = append(changes, entityActivityChanges(resourceID, snapshot.ConnectionID, "datastore", datastore.Datastore, datastore.RecentTasks, datastore.RecentEvents)...) } + for _, network := range snapshot.Networks { + resourceID := vmwareSourceID(snapshot.ConnectionID, "network", network.Network) + changes = append(changes, entityActivityChanges(resourceID, snapshot.ConnectionID, "network", network.Network, network.RecentTasks, network.RecentEvents)...) + } sort.SliceStable(changes, func(i, j int) bool { if !changes[i].ObservedAt.Equal(changes[j].ObservedAt) { diff --git a/internal/vmware/client.go b/internal/vmware/client.go index b97ec1b18..fd6a6b9db 100644 --- a/internal/vmware/client.go +++ b/internal/vmware/client.go @@ -81,6 +81,7 @@ type InventorySummary struct { Hosts int VMs int Datastores int + Networks int VIRelease string } @@ -175,6 +176,7 @@ func (c *Client) TestConnection(ctx context.Context) (*InventorySummary, error) Hosts: len(inventory.Hosts), VMs: len(inventory.VMs), Datastores: len(inventory.Datastores), + Networks: len(inventory.Networks), VIRelease: release, }, nil } @@ -239,10 +241,16 @@ func (c *Client) collectInventoryBaseWithSession(ctx context.Context) (*Inventor return nil, "", err } + var networks []InventoryNetwork + if err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/network", "network inventory", &networks); err != nil { + return nil, "", err + } + return &InventorySnapshot{ Hosts: hosts, VMs: vms, Datastores: datastores, + Networks: networks, }, automationSessionID, nil } diff --git a/internal/vmware/client_signals.go b/internal/vmware/client_signals.go index 3c9e6c7dc..91fb9e63b 100644 --- a/internal/vmware/client_signals.go +++ b/internal/vmware/client_signals.go @@ -143,6 +143,12 @@ func (c *Client) validateSignalFloor( return err } } + if len(snapshot.Networks) > 0 { + network := snapshot.Networks[0] + if _, err := c.collectManagedEntitySignals(ctx, release, sessionID, "Network", network.Network, perfManagerMoID, eventManagerMoID, cache, false); err != nil { + return err + } + } return nil } @@ -267,6 +273,23 @@ func (c *Client) enrichInventorySnapshot( }) } + for i := range snapshot.Networks { + i := i + run(func() error { + signals, err := c.collectManagedEntitySignals(ctx, release, sessionID, "Network", snapshot.Networks[i].Network, perfManagerMoID, eventManagerMoID, cache, true) + if issue, ok := classifyInventoryEnrichmentIssue("signals", "network", snapshot.Networks[i].Network, err); ok { + recordIssue(issue) + } else if err != nil { + return err + } + snapshot.Networks[i].OverallStatus = signals.OverallStatus + snapshot.Networks[i].TriggeredAlarms = signals.Alarms + snapshot.Networks[i].RecentTasks = signals.RecentTasks + snapshot.Networks[i].RecentEvents = signals.RecentEvents + return nil + }) + } + wg.Wait() firstErrMu.Lock() diff --git a/internal/vmware/client_test.go b/internal/vmware/client_test.go index 87d6677e4..9485a2d8d 100644 --- a/internal/vmware/client_test.go +++ b/internal/vmware/client_test.go @@ -32,8 +32,8 @@ func TestClientCollectInventoryEnrichesSignals(t *testing.T) { if snapshot == nil { t.Fatal("expected inventory snapshot") } - if len(snapshot.Hosts) != 1 || len(snapshot.VMs) != 1 || len(snapshot.Datastores) != 1 { - t.Fatalf("unexpected inventory sizes: hosts=%d vms=%d datastores=%d", len(snapshot.Hosts), len(snapshot.VMs), len(snapshot.Datastores)) + if len(snapshot.Hosts) != 1 || len(snapshot.VMs) != 1 || len(snapshot.Datastores) != 1 || len(snapshot.Networks) != 1 { + t.Fatalf("unexpected inventory sizes: hosts=%d vms=%d datastores=%d networks=%d", len(snapshot.Hosts), len(snapshot.VMs), len(snapshot.Datastores), len(snapshot.Networks)) } host := snapshot.Hosts[0] @@ -184,6 +184,23 @@ func TestClientCollectInventoryEnrichesSignals(t *testing.T) { if len(datastore.RecentEvents) != 1 || datastore.RecentEvents[0].Type != "DatastoreRenamedEvent" { t.Fatalf("expected datastore recent event info, got %+v", datastore.RecentEvents) } + + network := snapshot.Networks[0] + if network.OverallStatus != "green" { + t.Fatalf("network overall status = %q, want green", network.OverallStatus) + } + if network.DatacenterName != "DC1" || network.FolderName != "Networks" { + t.Fatalf("expected network placement enrichment, got datacenter=%q folder=%q", network.DatacenterName, network.FolderName) + } + if len(network.HostNames) != 1 || network.HostNames[0] != "esxi-01.lab.local" { + t.Fatalf("expected network host attachment enrichment, got %+v", network.HostNames) + } + if len(network.VMNames) != 1 || network.VMNames[0] != "app-01" { + t.Fatalf("expected network VM attachment enrichment, got %+v", network.VMNames) + } + if len(network.RecentEvents) != 1 || network.RecentEvents[0].Type != "NetworkEvent" { + t.Fatalf("expected network recent event info, got %+v", network.RecentEvents) + } } func TestClientCollectInventoryPreservesBaseInventoryWhenOptionalEnrichmentDegrades(t *testing.T) { @@ -212,8 +229,8 @@ func TestClientCollectInventoryPreservesBaseInventoryWhenOptionalEnrichmentDegra if snapshot == nil { t.Fatal("expected inventory snapshot") } - if len(snapshot.Hosts) != 1 || len(snapshot.VMs) != 1 || len(snapshot.Datastores) != 1 { - t.Fatalf("unexpected inventory sizes: hosts=%d vms=%d datastores=%d", len(snapshot.Hosts), len(snapshot.VMs), len(snapshot.Datastores)) + if len(snapshot.Hosts) != 1 || len(snapshot.VMs) != 1 || len(snapshot.Datastores) != 1 || len(snapshot.Networks) != 1 { + t.Fatalf("unexpected inventory sizes: hosts=%d vms=%d datastores=%d networks=%d", len(snapshot.Hosts), len(snapshot.VMs), len(snapshot.Datastores), len(snapshot.Networks)) } if len(snapshot.EnrichmentIssues) != 3 { t.Fatalf("expected 3 enrichment issues, got %+v", snapshot.EnrichmentIssues) @@ -422,6 +439,14 @@ func newVMwareTestServer(t *testing.T, cfg vmwareTestServerConfig) *httptest.Ser Capacity: 100, }}) }) + mux.HandleFunc("/api/vcenter/network", func(w http.ResponseWriter, r *http.Request) { + requireAutomationSession(t, r) + writeJSON(w, []InventoryNetwork{{ + Network: "network-101", + Name: "VM Network", + Type: "STANDARD_PORTGROUP", + }}) + }) mux.HandleFunc("/api/vcenter/cluster", func(w http.ResponseWriter, r *http.Request) { requireAutomationSession(t, r) if cfg.denyClusterInventory { @@ -717,6 +742,14 @@ func newVMwareTestServer(t *testing.T, cfg vmwareTestServerConfig) *httptest.Ser "fullFormattedMessage": "Datastore metadata refreshed", "eventTypeId": "DatastoreRenamedEvent", }}) + case "Network": + writeJSON(w, []map[string]any{{ + "key": 401, + "userName": "network-admin", + "createdTime": "2026-03-30T18:07:00Z", + "fullFormattedMessage": "Network metadata refreshed", + "eventTypeId": "NetworkEvent", + }}) default: writeJSON(w, []map[string]any{}) } @@ -856,6 +889,35 @@ func newVMwareTestServer(t *testing.T, cfg vmwareTestServerConfig) *httptest.Ser writeJSON(w, []map[string]any{{"type": "VirtualMachine", "value": "vm-201"}}) }) + mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/overallStatus", func(w http.ResponseWriter, r *http.Request) { + requireVISession(t, r) + writeJSON(w, "green") + }) + mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/triggeredAlarmState", func(w http.ResponseWriter, r *http.Request) { + requireVISession(t, r) + writeJSON(w, []map[string]any{}) + }) + mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/recentTask", func(w http.ResponseWriter, r *http.Request) { + requireVISession(t, r) + writeJSON(w, []map[string]any{}) + }) + mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/name", func(w http.ResponseWriter, r *http.Request) { + requireVISession(t, r) + writeJSON(w, "VM Network") + }) + mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/parent", func(w http.ResponseWriter, r *http.Request) { + requireVISession(t, r) + writeJSON(w, map[string]any{"type": "Folder", "value": "group-n4"}) + }) + mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/host", func(w http.ResponseWriter, r *http.Request) { + requireVISession(t, r) + writeJSON(w, []map[string]any{{"type": "HostSystem", "value": "host-101"}}) + }) + mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/vm", func(w http.ResponseWriter, r *http.Request) { + requireVISession(t, r) + writeJSON(w, []map[string]any{{"type": "VirtualMachine", "value": "vm-201"}}) + }) + mux.HandleFunc("/sdk/vim25/9.0.0.0/ClusterComputeResource/domain-c101/name", func(w http.ResponseWriter, r *http.Request) { requireVISession(t, r) writeJSON(w, "Prod Compute") @@ -889,6 +951,14 @@ func newVMwareTestServer(t *testing.T, cfg vmwareTestServerConfig) *httptest.Ser requireVISession(t, r) writeJSON(w, map[string]any{"type": "Datacenter", "value": "datacenter-1"}) }) + mux.HandleFunc("/sdk/vim25/9.0.0.0/Folder/group-n4/name", func(w http.ResponseWriter, r *http.Request) { + requireVISession(t, r) + writeJSON(w, "Networks") + }) + mux.HandleFunc("/sdk/vim25/9.0.0.0/Folder/group-n4/parent", func(w http.ResponseWriter, r *http.Request) { + requireVISession(t, r) + writeJSON(w, map[string]any{"type": "Datacenter", "value": "datacenter-1"}) + }) mux.HandleFunc("/sdk/vim25/9.0.0.0/Datacenter/datacenter-1/name", func(w http.ResponseWriter, r *http.Request) { requireVISession(t, r) diff --git a/internal/vmware/client_topology.go b/internal/vmware/client_topology.go index 3154375e8..26ff68ac1 100644 --- a/internal/vmware/client_topology.go +++ b/internal/vmware/client_topology.go @@ -322,6 +322,19 @@ func (c *Client) enrichInventoryTopology( }) } + for i := range snapshot.Networks { + i := i + run(func() error { + network, networkIssues, err := c.enrichNetworkTopology(ctx, release, sessionID, snapshot.Networks[i], cache, hostNamesByID, vmNamesByID) + if err != nil { + return err + } + recordIssues(networkIssues) + snapshot.Networks[i] = network + return nil + }) + } + wg.Wait() firstErrMu.Lock() @@ -563,6 +576,52 @@ func (c *Client) collectClusterInventory( return clusters, nil } +func (c *Client) enrichNetworkTopology( + ctx context.Context, + release string, + sessionID string, + network InventoryNetwork, + cache *vmwareTopologyCache, + hostNamesByID map[string]string, + vmNamesByID map[string]string, +) (InventoryNetwork, []InventoryEnrichmentIssue, error) { + var issues []InventoryEnrichmentIssue + recordIssue := func(issue *InventoryEnrichmentIssue) { + if issue != nil { + issues = append(issues, *issue) + } + } + + ref := viJSONReference{Type: "Network", Value: strings.TrimSpace(network.Network)} + placement, err := cache.resolvePlacement(ctx, c, release, sessionID, ref) + if issue, ok := classifyInventoryEnrichmentIssue("topology", "network", network.Network, err); ok { + recordIssue(issue) + } else if err != nil && !isVIJSONNotFound(err) { + return network, nil, err + } + applyPlacementToNetwork(&network, placement) + + hostRefs, err := c.collectEntityReferenceList(ctx, release, sessionID, "Network", network.Network, "host", "network host attachments") + if issue, ok := classifyInventoryEnrichmentIssue("topology", "network", network.Network, err); ok { + recordIssue(issue) + } else if err != nil && !isVIJSONNotFound(err) { + return network, nil, err + } + network.HostIDs = idsForReferences(hostRefs) + network.HostNames = namesForReferences(hostRefs, hostNamesByID) + + vmRefs, err := c.collectEntityReferenceList(ctx, release, sessionID, "Network", network.Network, "vm", "network vm attachments") + if issue, ok := classifyInventoryEnrichmentIssue("topology", "network", network.Network, err); ok { + recordIssue(issue) + } else if err != nil && !isVIJSONNotFound(err) { + return network, nil, err + } + network.VMIDs = idsForReferences(vmRefs) + network.VMNames = namesForReferences(vmRefs, vmNamesByID) + + return network, issues, nil +} + func (c *Client) enrichDatastoreTopology( ctx context.Context, release string, @@ -1237,6 +1296,16 @@ func applyPlacementToDatastore(datastore *InventoryDatastore, placement vmwarePl datastore.FolderName = firstNonEmptyTrimmed(datastore.FolderName, placement.FolderName) } +func applyPlacementToNetwork(network *InventoryNetwork, placement vmwarePlacement) { + if network == nil { + return + } + network.DatacenterID = firstNonEmptyTrimmed(network.DatacenterID, placement.DatacenterID) + network.DatacenterName = firstNonEmptyTrimmed(network.DatacenterName, placement.DatacenterName) + network.FolderID = firstNonEmptyTrimmed(network.FolderID, placement.FolderID) + network.FolderName = firstNonEmptyTrimmed(network.FolderName, placement.FolderName) +} + func mergePlacement(dst *vmwarePlacement, src vmwarePlacement) { if dst == nil { return diff --git a/internal/vmware/fixtures.go b/internal/vmware/fixtures.go index e1a560d4c..d6e35c543 100644 --- a/internal/vmware/fixtures.go +++ b/internal/vmware/fixtures.go @@ -585,6 +585,56 @@ func defaultFixturesPrimaryCluster( }}, }, }, + Networks: []InventoryNetwork{ + { + Network: "network-101", + Name: "VM Network", + Type: "STANDARD_PORTGROUP", + DatacenterID: "datacenter-1", + DatacenterName: "Primary DC", + FolderID: "group-n4", + FolderName: "Networks", + HostIDs: []string{"host-101", "host-102", "host-103", "host-104"}, + HostNames: []string{"esxi-01.lab.local", "esxi-02.lab.local", "esxi-03.lab.local", "esxi-04.lab.local"}, + VMIDs: []string{"vm-201", "vm-202", "vm-203"}, + VMNames: []string{"orders-api-01", "postgres-ha-01", "web-frontend-01"}, + OverallStatus: "green", + }, + { + Network: "network-102", + Name: "Utility Network", + Type: "DISTRIBUTED_PORTGROUP", + DatacenterID: "datacenter-1", + DatacenterName: "Primary DC", + FolderID: "group-n4", + FolderName: "Networks", + HostIDs: []string{"host-101", "host-103", "host-104"}, + HostNames: []string{"esxi-01.lab.local", "esxi-03.lab.local", "esxi-04.lab.local"}, + VMIDs: []string{"vm-204", "vm-205"}, + VMNames: []string{"observability-01", "windows-jump-01"}, + OverallStatus: "yellow", + TriggeredAlarms: []InventoryAlarm{{ + Alarm: "alarm-404", + Name: "Distributed portgroup uplink redundancy", + OverallStatus: "yellow", + TriggeredAt: collectedAt.Add(-17 * time.Minute), + }}, + }, + { + Network: "network-103", + Name: "Archive Network", + Type: "STANDARD_PORTGROUP", + DatacenterID: "datacenter-1", + DatacenterName: "Primary DC", + FolderID: "group-n4", + FolderName: "Networks", + HostIDs: []string{"host-101", "host-104"}, + HostNames: []string{"esxi-01.lab.local", "esxi-04.lab.local"}, + VMIDs: []string{"vm-206"}, + VMNames: []string{"batch-worker-01"}, + OverallStatus: "green", + }, + }, } } @@ -818,6 +868,85 @@ func appendEdgeClusterFixtures( OverallStatus: ds.Status, }) } + + snapshot.Networks = append(snapshot.Networks, + InventoryNetwork{ + Network: "network-301", + Name: "Edge App", + Type: "DISTRIBUTED_PORTGROUP", + DatacenterID: edgeDatacenterID, + DatacenterName: edgeDatacenterName, + FolderID: "group-n6", + FolderName: "Edge Networks", + HostIDs: []string{"host-201", "host-202", "host-203"}, + HostNames: hostNames, + VMIDs: []string{"vm-301", "vm-302", "vm-306", "vm-307"}, + VMNames: []string{"edge-api-01", "edge-api-02", "ingress-proxy-01", "ingress-proxy-02"}, + OverallStatus: "green", + }, + InventoryNetwork{ + Network: "network-302", + Name: "Edge Stateful", + Type: "DISTRIBUTED_PORTGROUP", + DatacenterID: edgeDatacenterID, + DatacenterName: edgeDatacenterName, + FolderID: "group-n6", + FolderName: "Edge Networks", + HostIDs: []string{"host-201", "host-202", "host-203"}, + HostNames: hostNames, + VMIDs: []string{"vm-303", "vm-304", "vm-305"}, + VMNames: []string{"mariadb-replica-01", "redis-cache-01", "redis-cache-02"}, + OverallStatus: "yellow", + TriggeredAlarms: []InventoryAlarm{{ + Alarm: "alarm-405", + Name: "Network packet loss above threshold", + OverallStatus: "yellow", + TriggeredAt: collectedAt.Add(-22 * time.Minute), + }}, + }, + InventoryNetwork{ + Network: "network-303", + Name: "Edge Workstations", + Type: "STANDARD_PORTGROUP", + DatacenterID: edgeDatacenterID, + DatacenterName: edgeDatacenterName, + FolderID: "group-n6", + FolderName: "Edge Networks", + HostIDs: []string{"host-202", "host-203"}, + HostNames: []string{"esxi-06.lab.local", "esxi-07.lab.local"}, + VMIDs: []string{"vm-308", "vm-309"}, + VMNames: []string{"win-fleet-rdp-01", "win-fleet-rdp-02"}, + OverallStatus: "green", + }, + InventoryNetwork{ + Network: "network-304", + Name: "Edge Observability", + Type: "DISTRIBUTED_PORTGROUP", + DatacenterID: edgeDatacenterID, + DatacenterName: edgeDatacenterName, + FolderID: "group-n6", + FolderName: "Edge Networks", + HostIDs: []string{"host-201", "host-202"}, + HostNames: []string{"esxi-05.lab.local", "esxi-06.lab.local"}, + VMIDs: []string{"vm-310", "vm-311"}, + VMNames: []string{"logging-collector-01", "logging-collector-02"}, + OverallStatus: "green", + }, + InventoryNetwork{ + Network: "network-305", + Name: "Edge Archive", + Type: "STANDARD_PORTGROUP", + DatacenterID: edgeDatacenterID, + DatacenterName: edgeDatacenterName, + FolderID: "group-n6", + FolderName: "Edge Networks", + HostIDs: []string{"host-203"}, + HostNames: []string{"esxi-07.lab.local"}, + VMIDs: []string{"vm-312"}, + VMNames: []string{"cold-archive-01"}, + OverallStatus: "green", + }, + ) } func applyInventoryClusterServices(snapshot *InventorySnapshot) { diff --git a/internal/vmware/provider.go b/internal/vmware/provider.go index f27ef837e..ca3873bf3 100644 --- a/internal/vmware/provider.go +++ b/internal/vmware/provider.go @@ -183,6 +183,27 @@ type InventoryCluster struct { DRSEnabled *bool `json:"drs_enabled,omitempty"` } +// InventoryNetwork is the vCenter Automation API network summary enriched with +// VI JSON topology. Pulse keeps networks as first-class read-side resources +// because vCenter exposes them as inventory objects used by hosts and VMs. +type InventoryNetwork struct { + Network string `json:"network"` + Name string `json:"name"` + Type string `json:"type"` + DatacenterID string `json:"datacenter_id,omitempty"` + DatacenterName string `json:"datacenter_name,omitempty"` + FolderID string `json:"folder_id,omitempty"` + FolderName string `json:"folder_name,omitempty"` + HostIDs []string `json:"host_ids,omitempty"` + HostNames []string `json:"host_names,omitempty"` + VMIDs []string `json:"vm_ids,omitempty"` + VMNames []string `json:"vm_names,omitempty"` + OverallStatus string `json:"overall_status,omitempty"` + TriggeredAlarms []InventoryAlarm `json:"triggered_alarms,omitempty"` + RecentTasks []InventoryTask `json:"recent_tasks,omitempty"` + RecentEvents []InventoryEvent `json:"recent_events,omitempty"` +} + // InventoryHost is the canonical phase-1 host summary returned by the vCenter // Automation API list endpoint. type InventoryHost struct { @@ -291,6 +312,7 @@ type InventorySnapshot struct { VMs []InventoryVM Datastores []InventoryDatastore Clusters []InventoryCluster + Networks []InventoryNetwork EnrichmentIssues []InventoryEnrichmentIssue } @@ -474,7 +496,7 @@ func vmwareRecordsFromSnapshot(snapshot *InventorySnapshot, now func() time.Time connectionName := firstNonEmptyTrimmed(snapshot.ConnectionName, snapshot.VCenterHost, snapshot.ConnectionID) vcenterHost := strings.TrimSpace(snapshot.VCenterHost) - records := make([]unifiedresources.IngestRecord, 0, len(snapshot.Hosts)+len(snapshot.VMs)+len(snapshot.Datastores)) + records := make([]unifiedresources.IngestRecord, 0, len(snapshot.Hosts)+len(snapshot.VMs)+len(snapshot.Datastores)+len(snapshot.Networks)) hostSourceIDsByManagedObject := make(map[string]string, len(snapshot.Hosts)) for _, host := range snapshot.Hosts { hostID := strings.TrimSpace(host.Host) @@ -701,6 +723,56 @@ func vmwareRecordsFromSnapshot(snapshot *InventorySnapshot, now func() time.Time }) } + for _, network := range snapshot.Networks { + name := firstNonEmptyTrimmed(network.Name, network.Network) + if name == "" { + continue + } + incidents := networkIncidents(network) + resource := unifiedresources.Resource{ + Type: unifiedresources.ResourceTypeNetwork, + Technology: "vmware", + Name: name, + Status: unifiedresources.IncidentsStatus(networkStatus(network), incidents), + LastSeen: collectedAt, + UpdatedAt: collectedAt, + Incidents: incidents, + VMware: &unifiedresources.VMwareData{ + ConnectionID: strings.TrimSpace(snapshot.ConnectionID), + ConnectionName: connectionName, + VCenterHost: vcenterHost, + ManagedObjectID: strings.TrimSpace(network.Network), + EntityType: "network", + DatacenterID: strings.TrimSpace(network.DatacenterID), + DatacenterName: strings.TrimSpace(network.DatacenterName), + FolderID: strings.TrimSpace(network.FolderID), + FolderName: strings.TrimSpace(network.FolderName), + NetworkType: strings.TrimSpace(network.Type), + NetworkHostIDs: cloneStringSlice(network.HostIDs), + NetworkHostNames: cloneStringSlice(network.HostNames), + NetworkVMIDs: cloneStringSlice(network.VMIDs), + NetworkVMNames: cloneStringSlice(network.VMNames), + OverallStatus: strings.TrimSpace(network.OverallStatus), + ActiveAlarmCount: len(network.TriggeredAlarms), + ActiveAlarmSummary: vmwareAlarmSummary(network.TriggeredAlarms), + RecentTaskCount: len(network.RecentTasks), + RecentTaskSummary: vmwareRecentTaskSummary(network.RecentTasks), + }, + Tags: filterNonEmptyStrings( + "vmware", + "vsphere", + "network", + "source:vcenter", + tagWithValue("connection", strings.ToLower(connectionName)), + tagWithValue("type", strings.ToLower(strings.TrimSpace(network.Type))), + ), + } + records = append(records, unifiedresources.IngestRecord{ + SourceID: vmwareSourceID(snapshot.ConnectionID, "network", network.Network), + Resource: resource, + }) + } + return records } @@ -720,6 +792,9 @@ func sortInventorySnapshot(snapshot *InventorySnapshot) { sort.Slice(snapshot.Clusters, func(i, j int) bool { return vmwareSortKey(snapshot.Clusters[i].Cluster, snapshot.Clusters[i].Name) < vmwareSortKey(snapshot.Clusters[j].Cluster, snapshot.Clusters[j].Name) }) + sort.Slice(snapshot.Networks, func(i, j int) bool { + return vmwareSortKey(snapshot.Networks[i].Network, snapshot.Networks[i].Name) < vmwareSortKey(snapshot.Networks[j].Network, snapshot.Networks[j].Name) + }) sort.Slice(snapshot.EnrichmentIssues, func(i, j int) bool { return inventoryEnrichmentIssueSortKey(snapshot.EnrichmentIssues[i]) < inventoryEnrichmentIssueSortKey(snapshot.EnrichmentIssues[j]) @@ -735,6 +810,7 @@ func cloneInventorySnapshot(in *InventorySnapshot) *InventorySnapshot { out.VMs = cloneInventoryVMs(in.VMs) out.Datastores = cloneInventoryDatastores(in.Datastores) out.Clusters = cloneInventoryClusters(in.Clusters) + out.Networks = cloneInventoryNetworks(in.Networks) out.EnrichmentIssues = cloneInventoryEnrichmentIssues(in.EnrichmentIssues) return &out } @@ -816,6 +892,24 @@ func cloneInventoryClusters(in []InventoryCluster) []InventoryCluster { return out } +func cloneInventoryNetworks(in []InventoryNetwork) []InventoryNetwork { + if in == nil { + return nil + } + out := make([]InventoryNetwork, len(in)) + for i := range in { + out[i] = in[i] + out[i].HostIDs = cloneStringSlice(in[i].HostIDs) + out[i].HostNames = cloneStringSlice(in[i].HostNames) + out[i].VMIDs = cloneStringSlice(in[i].VMIDs) + out[i].VMNames = cloneStringSlice(in[i].VMNames) + out[i].TriggeredAlarms = cloneInventoryAlarms(in[i].TriggeredAlarms) + out[i].RecentTasks = cloneInventoryTasks(in[i].RecentTasks) + out[i].RecentEvents = cloneInventoryEvents(in[i].RecentEvents) + } + return out +} + func cloneInventoryAlarms(in []InventoryAlarm) []InventoryAlarm { if in == nil { return nil @@ -1028,6 +1122,13 @@ func datastoreStatus(datastore InventoryDatastore) unifiedresources.ResourceStat return unifiedresources.StatusOnline } +func networkStatus(network InventoryNetwork) unifiedresources.ResourceStatus { + if strings.TrimSpace(network.Network) == "" && strings.TrimSpace(network.Name) == "" { + return unifiedresources.StatusUnknown + } + return unifiedresources.StatusOnline +} + func hostIncidents(host InventoryHost) []unifiedresources.ResourceIncident { return appendVMwareAlarmsAndHealthIncidents("host", host.Host, strings.TrimSpace(host.OverallStatus), host.TriggeredAlarms) } @@ -1040,6 +1141,10 @@ func datastoreIncidents(datastore InventoryDatastore) []unifiedresources.Resourc return appendVMwareAlarmsAndHealthIncidents("datastore", datastore.Datastore, strings.TrimSpace(datastore.OverallStatus), datastore.TriggeredAlarms) } +func networkIncidents(network InventoryNetwork) []unifiedresources.ResourceIncident { + return appendVMwareAlarmsAndHealthIncidents("network", network.Network, strings.TrimSpace(network.OverallStatus), network.TriggeredAlarms) +} + func appendVMwareAlarmsAndHealthIncidents(entityType, managedObjectID, overallStatus string, alarms []InventoryAlarm) []unifiedresources.ResourceIncident { incidents := make([]unifiedresources.ResourceIncident, 0, len(alarms)+1) for _, alarm := range alarms { @@ -1119,6 +1224,8 @@ func vmwareEntityLabel(entityType string) string { return "VM" case "datastore": return "Datastore" + case "network": + return "Network" default: return "Resource" } diff --git a/internal/vmware/provider_test.go b/internal/vmware/provider_test.go index e8b99836d..4153f03f5 100644 --- a/internal/vmware/provider_test.go +++ b/internal/vmware/provider_test.go @@ -221,11 +221,31 @@ func TestProviderRecords_ProjectCanonicalVMwareResources(t *testing.T) { StartedAt: collectedAt.Add(-4 * time.Minute), }}, }}, + Networks: []InventoryNetwork{{ + Network: "network-101", + Name: "VM Network", + Type: "STANDARD_PORTGROUP", + DatacenterID: "datacenter-1", + DatacenterName: "DC1", + FolderID: "group-n4", + FolderName: "Networks", + HostIDs: []string{"host-101"}, + HostNames: []string{"esxi-01.lab.local"}, + VMIDs: []string{"vm-201"}, + VMNames: []string{"app-01"}, + OverallStatus: "yellow", + TriggeredAlarms: []InventoryAlarm{{ + Alarm: "alarm-41", + Name: "Network uplink redundancy", + OverallStatus: "yellow", + TriggeredAt: collectedAt.Add(-6 * time.Minute), + }}, + }}, }) records := provider.Records() - if len(records) != 3 { - t.Fatalf("expected 3 VMware records, got %d", len(records)) + if len(records) != 4 { + t.Fatalf("expected 4 VMware records, got %d", len(records)) } hostRecord := records[0] @@ -419,6 +439,29 @@ func TestProviderRecords_ProjectCanonicalVMwareResources(t *testing.T) { if datastoreRecord.Resource.VMware.DatastoreAccessible == nil || !*datastoreRecord.Resource.VMware.DatastoreAccessible { t.Fatalf("datastore accessible = %#v, want true", datastoreRecord.Resource.VMware.DatastoreAccessible) } + + networkRecord := records[3] + if networkRecord.SourceID != "vc-1:network:network-101" { + t.Fatalf("network source id = %q, want vc-1:network:network-101", networkRecord.SourceID) + } + if networkRecord.Resource.Type != unifiedresources.ResourceTypeNetwork { + t.Fatalf("network resource type = %q, want %q", networkRecord.Resource.Type, unifiedresources.ResourceTypeNetwork) + } + if networkRecord.Resource.Status != unifiedresources.StatusWarning { + t.Fatalf("network status = %q, want %q", networkRecord.Resource.Status, unifiedresources.StatusWarning) + } + if networkRecord.Resource.VMware == nil || networkRecord.Resource.VMware.NetworkType != "STANDARD_PORTGROUP" { + t.Fatalf("expected VMware network metadata, got %+v", networkRecord.Resource.VMware) + } + if got := networkRecord.Resource.VMware.NetworkHostNames; len(got) != 1 || got[0] != "esxi-01.lab.local" { + t.Fatalf("network host names = %#v, want [esxi-01.lab.local]", got) + } + if got := networkRecord.Resource.VMware.NetworkVMNames; len(got) != 1 || got[0] != "app-01" { + t.Fatalf("network VM names = %#v, want [app-01]", got) + } + if len(networkRecord.Resource.Incidents) != 1 || networkRecord.Resource.Incidents[0].Code != "vmware_alarm_state" { + t.Fatalf("expected VMware network incident projection, got %+v", networkRecord.Resource.Incidents) + } } func TestProviderActivityChanges_ProjectCanonicalTimelineEntries(t *testing.T) { @@ -464,11 +507,22 @@ func TestProviderActivityChanges_ProjectCanonicalTimelineEntries(t *testing.T) { CreatedAt: collectedAt.Add(-3 * time.Minute), }}, }}, + Networks: []InventoryNetwork{{ + Network: "network-101", + Name: "VM Network", + RecentEvents: []InventoryEvent{{ + Event: "event-401", + Type: "NetworkEvent", + Message: "Network metadata refreshed", + User: "network-admin", + CreatedAt: collectedAt.Add(-4 * time.Minute), + }}, + }}, }) changes := provider.ActivityChanges() - if len(changes) != 3 { - t.Fatalf("expected 3 VMware activity changes, got %d", len(changes)) + if len(changes) != 4 { + t.Fatalf("expected 4 VMware activity changes, got %d", len(changes)) } if changes[0].Kind != unifiedresources.ChangeActivity { t.Fatalf("latest change kind = %q, want %q", changes[0].Kind, unifiedresources.ChangeActivity) @@ -494,6 +548,9 @@ func TestProviderActivityChanges_ProjectCanonicalTimelineEntries(t *testing.T) { if got := changes[2].Metadata["vmwareEventUser"]; got != "storage-admin" { t.Fatalf("datastore change user = %#v, want storage-admin", got) } + if got := changes[3].Metadata["vmwareEntityType"]; got != "network" { + t.Fatalf("network change entity type = %#v, want network", got) + } } func boolPtr(value bool) *bool {