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 {