mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-18 21:43:37 +00:00
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:
parent
6c7d64ae43
commit
5016cbc2ba
65 changed files with 2025 additions and 437 deletions
|
|
@ -299,7 +299,8 @@
|
|||
"canonical_projections": [
|
||||
"agent",
|
||||
"vm",
|
||||
"storage"
|
||||
"storage",
|
||||
"network"
|
||||
],
|
||||
"support_floor": {
|
||||
"setup": "supported",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) : [];
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
|
|||
kubernetesPodsPath: buildKubernetesPath('pods'),
|
||||
trueNasPath: buildTrueNASPath(),
|
||||
vmwarePath: buildVmwarePath(),
|
||||
vmwareNetworksPath: buildVmwarePath('networks'),
|
||||
},
|
||||
infrastructureVisibility: props.infrastructureVisibility(),
|
||||
navigate,
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
292
frontend-modern/src/features/vmware/VsphereNetworksTable.tsx
Normal file
292
frontend-modern/src/features/vmware/VsphereNetworksTable.tsx
Normal 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;
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': [],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type ReportingResourceType =
|
|||
| 'datastore'
|
||||
| 'pool'
|
||||
| 'dataset'
|
||||
| 'network'
|
||||
| 'network-share'
|
||||
| 'network-endpoint'
|
||||
| 'pbs'
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ func mockVMwareConnectionResponses() []vmwareConnectionResponse {
|
|||
Hosts: fixture.Hosts,
|
||||
VMs: fixture.VMs,
|
||||
Datastores: fixture.Datastores,
|
||||
Networks: fixture.Networks,
|
||||
VIRelease: fixture.VIRelease,
|
||||
},
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
353
internal/unifiedresources/presentation_coalesce.go
Normal file
353
internal/unifiedresources/presentation_coalesce.go
Normal 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
|
||||
}
|
||||
175
internal/unifiedresources/presentation_coalesce_test.go
Normal file
175
internal/unifiedresources/presentation_coalesce_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue