Add vSphere network inventory

Project vCenter network inventory through canonical resources and add the vSphere Networks table backed by vCenter network topology. Align resource presentation coalescing so state and resource APIs share the same host contract.
This commit is contained in:
rcourtman 2026-05-22 20:26:56 +01:00
parent 6c7d64ae43
commit 5016cbc2ba
65 changed files with 2025 additions and 437 deletions

View file

@ -299,7 +299,8 @@
"canonical_projections": [
"agent",
"vm",
"storage"
"storage",
"network"
],
"support_floor": {
"setup": "supported",

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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,

View file

@ -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')",
'<Route path={INFRASTRUCTURE_PATH} component={InfrastructurePage} />',
);
expect(appSource).toContain('<Route path={INFRASTRUCTURE_PATH} component={InfrastructurePage} />');
expect(appSource).toContain('<Route path={WORKLOADS_PATH} component={WorkloadsPage} />');
expect(appSource).toContain('<Route path={STORAGE_PATH} component={StoragePage} />');
expect(appSource).toContain('<Route path={RECOVERY_PATH} component={RecoveryPage} />');
expect(appSource).toContain('<Route path="/ceph" component={() => <Navigate href="/proxmox/ceph" />} />');
expect(appSource).toContain(
'<Route path="/ceph" component={() => <Navigate href="/proxmox/ceph" />} />',
);
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

View file

@ -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())');
});

View file

@ -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,

View file

@ -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) =>

View file

@ -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',

View file

@ -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) : [];

View file

@ -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', () => {

View file

@ -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(() => (
<CommandPaletteModal
isOpen={true}
@ -93,6 +95,9 @@ describe('CommandPaletteModal', () => {
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(() => (
<CommandPaletteModal
isOpen={true}
onClose={onClose}
infrastructureVisibility={infrastructureVisibility}
/>
));
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();
});
});

View file

@ -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(

View file

@ -30,6 +30,7 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
kubernetesPodsPath: buildKubernetesPath('pods'),
trueNasPath: buildTrueNASPath(),
vmwarePath: buildVmwarePath(),
vmwareNetworksPath: buildVmwarePath('networks'),
},
infrastructureVisibility: props.infrastructureVisibility(),
navigate,

View file

@ -203,6 +203,7 @@ describe('platform overview layout guardrails', () => {
expect(vmwarePageSurfaceSource).toContain('<VsphereVirtualMachinesTable');
expect(vmwarePageSurfaceSource).not.toContain('<WorkloadsSurface');
expect(vmwarePageSurfaceSource).toContain('<VsphereDatastoresTable');
expect(vmwarePageSurfaceSource).toContain('<VsphereNetworksTable');
expect(vmwarePageSurfaceSource).toContain('<VsphereActivityTable');
expect(vmwarePageSurfaceSource).not.toContain('<StorageSurface');
expect(vmwarePageSurfaceSource).not.toContain('forcedView="pools"');

View file

@ -212,6 +212,9 @@ const matchesPlatformSearch = (resource: Resource, search: string): boolean => {
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')

View file

@ -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<VmwarePageTabId>(VMWARE_TAB_SPECS.map((tab) => tab.id));
const vmwareIcon = () => <CpuIcon class="h-6 w-6 text-slate-400" />;
@ -103,6 +105,15 @@ export function VmwarePageSurface() {
emptyDescription="Datastores appear here once the vCenter connection enumerates them."
/>
</Show>
<Show when={activeTab() === 'networks'}>
<VsphereNetworksTable
networks={model().networks}
scope={model().resources}
emptyIcon={vmwareIcon()}
emptyTitle="No vSphere networks"
emptyDescription="Networks appear here once the vCenter connection enumerates them."
/>
</Show>
<Show when={activeTab() === 'health'}>
<VsphereAlertsTable
incidents={model().incidents}

View file

@ -0,0 +1,292 @@
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformTableFilterOption,
} from '@/features/platformPage/sharedPlatformPage';
import {
PlatformResourceDetailTableRow,
createPlatformResourceDetailState,
createPlatformResourceLabelResolver,
getPlatformResourceDetailRowClass,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { Resource } from '@/types/resource';
import {
filterVmwareNetworks,
mapVmwareNetworkStatus,
type VmwareNetworkStatusFilter,
} from './vmwarePageModel';
const VSPHERE_NETWORK_STATUS_OPTIONS: PlatformTableFilterOption<VmwareNetworkStatusFilter>[] = [
{ 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 | undefined>): 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) => (
<span
class={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${statusPillClass(
props.resource,
)}`}
>
{statusLabel(props.resource)}
</span>
);
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 (
<Show
when={props.networks.length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search vSphere networks"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={VSPHERE_NETWORK_STATUS_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="networks"
/>
</Show>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No networks match current filters"
description="Adjust the search or status filter to see more vSphere networks."
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title="Networks" />
<Table class="min-w-full table-fixed text-xs md:min-w-[1040px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[24%]`}>
Network
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[13%]`}>
Type
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
>
Hosts
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[7%]`}
>
VMs
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[18%]`}
>
Connected VMs
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[12%]`}
>
Datacenter
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[8%]`}>
State
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(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 (
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-vsphere-network-row={network.id}
onClick={() => drawer.toggle(network)}
onKeyDown={drawer.handleActivationKey(network)}
tabIndex={0}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={indicator().label}
/>
<div class="min-w-0">
<div class="truncate font-medium text-base-content" title={name()}>
{name()}
</div>
<div
class="truncate text-[10px] text-muted"
title={network.vmware?.managedObjectId}
>
{network.vmware?.managedObjectId ||
network.vmware?.folderName ||
network.vmware?.vcenterHost ||
'vSphere network'}
</div>
</div>
</div>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<span class="font-mono text-[11px] text-base-content">
{networkType(network)}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
title={hosts().title}
>
<span class="block truncate">{hosts().label}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content tabular-nums md:table-cell`}
>
{vmCount(network)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
title={vms().title}
>
<span class="block truncate">{vms().label}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
title={datacenter()}
>
<span class="block truncate">{datacenter()}</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<StatusPill resource={network} />
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={network}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={7}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(network)}
/>
</>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
export default VsphereNetworksTable;

View file

@ -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<Resource> & Pick<Resource, 'id'>): 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(() => (
<VsphereNetworksTable
networks={[vmNetwork, edgeStateful]}
scope={[vmNetwork, edgeStateful]}
emptyIcon={<span />}
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');
});
});

View file

@ -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',

View file

@ -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<ResourceType>(['agent', 'vm', 'storage']);
const VMWARE_RESOURCE_TYPES = new Set<ResourceType>(['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<VmwareNetworkStatusFilter, 'all'> {
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,

View file

@ -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');

View file

@ -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;

View file

@ -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',

View file

@ -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': [],

View file

@ -12,6 +12,7 @@ export type ReportingResourceType =
| 'datastore'
| 'pool'
| 'dataset'
| 'network'
| 'network-share'
| 'network-endpoint'
| 'pbs'

View file

@ -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) {

View file

@ -72,6 +72,7 @@ func mockVMwareConnectionResponses() []vmwareConnectionResponse {
Hosts: fixture.Hosts,
VMs: fixture.VMs,
Datastores: fixture.Datastores,
Networks: fixture.Networks,
VIRelease: fixture.VIRelease,
},
}}

View file

@ -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))

View file

@ -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

View file

@ -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),
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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)",

View file

@ -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:

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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"`

View file

@ -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) {

View file

@ -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
}

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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) {

View file

@ -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"
}

View file

@ -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 {