diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 8f8ab752c..dfeeade5b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -844,12 +844,16 @@ bypass the API fail-closed execution gate. in `frontend-modern/src/routing/resourceLinks.ts`; ad hoc storage or recovery route strings inside per-platform features are not permitted. Platform-page default sub-tab choices must land the user on a - canonical surface that actually populates: when the canonical adapter - does not emit a platform's top-level infrastructure projection, the - platform's `*_DEFAULT_TAB` constant in `resourceLinks.ts` must point - at the next-most-relevant canonical surface (for TrueNAS today that is - the embedded `StorageSurface` at `/truenas/storage`) rather than at a - placeholder Hosts overview that would render empty. + canonical surface that actually populates. The canonical TrueNAS + adapter already emits the top-level TrueNAS system as a unified + `agent` row tagged with the `truenas` platform, so the platform + page defaults to `/truenas/overview` (the Systems sub-tab) and the + embedded `StorageSurface` lives at `/truenas/storage`. The Source + filter chip in `StoragePageControls` is also suppressed when a + platform page locks source scope through `forcedSourceFilter` (via + `suppressSourceFilter`, auto-applied whenever `forcedSourceFilter` + is set), so the user never sees the platform's name pinned as a + removable filter chip inside the embedded surface. Platform pages that embed `StorageSurface` reuse the canonical `StoragePageControls` toolbar through the `showFilterToolbar` prop on `StorageProps`. The page keeps `tableOnly` to hide the storage summary diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index b270df59d..6953771de 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -411,13 +411,16 @@ AI-only summary payloads, or page-local heuristics. URL vocabulary stays single-sourced; ad hoc string concatenation of platform routes inside feature directories is not permitted. The default tab for each platform path must point at a sub-tab whose - canonical unified-resource projection actually populates. TrueNAS - therefore defaults to `/truenas/storage` rather than `/truenas/overview` - because the canonical adapter does not yet emit a TrueNAS-platform - `agent` row to back a Hosts overview. Adding back an Overview/Hosts - default for a platform requires the canonical resource adapter to first - emit that platform's top-level system as a unified resource so the - builder default still resolves to a populated table. + canonical unified-resource projection actually populates. The + canonical TrueNAS adapter (`internal/truenas/provider.go:: + truenasRecordsFromSnapshot`) already emits the top-level TrueNAS + appliance as a unified `agent` row tagged with the `truenas` + platform, so TrueNAS defaults to `/truenas/overview` (the Systems + sub-tab); the embedded `StorageSurface` lives at `/truenas/storage`. + Any future platform that wants to default to a Systems / Hosts + overview must first have its canonical resource adapter project the + platform's top-level system as a unified resource so the builder + default still resolves to a populated table. ## Forbidden Paths diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 94db2c6fe..4592650cf 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -21,8 +21,11 @@ type StorageProps = { // visible alongside the table even when `tableOnly` hides the summary // section. The page owns source scope via `forcedSourceFilter`; the // controls toolbar still exposes search, status, grouping, sort, and - // node filters to the operator. + // node filters to the operator. `suppressSourceFilter` drops the + // redundant Source chip from the controls toolbar since the platform + // page already locks source scope through `forcedSourceFilter`. showFilterToolbar?: boolean; + suppressSourceFilter?: boolean; }; const Storage: Component = (props) => { @@ -194,6 +197,7 @@ const Storage: Component = (props) => { sourceFilter={sourceFilter} setSourceFilter={setSourceFilter} sourceOptions={sourceFilterOptions} + suppressSourceFilter={props.suppressSourceFilter || Boolean(props.forcedSourceFilter)} diskRoleFilter={diskRoleFilter} setDiskRoleFilter={setDiskRoleFilter} diskRoleOptions={diskRoleOptions} diff --git a/frontend-modern/src/components/Storage/StoragePageControls.tsx b/frontend-modern/src/components/Storage/StoragePageControls.tsx index 9aead21ad..923ec044b 100644 --- a/frontend-modern/src/components/Storage/StoragePageControls.tsx +++ b/frontend-modern/src/components/Storage/StoragePageControls.tsx @@ -66,6 +66,12 @@ type StoragePageControlsProps = { storageFilterGroupBy: () => StorageGroupKey; chartsCollapsed?: () => boolean; onChartsToggle?: () => void; + // Mirrors the WorkloadsFilter `suppressPlatformFilter` contract: when a + // platform page mounts StorageSurface with `forcedSourceFilter`, the + // Source filter chip is redundant (it is already locked by the page) + // and would render as a user-visible pinned filter. Setting this drops + // the Source chip from the rendered filter row. + suppressSourceFilter?: boolean; }; const VIEW_TABS = STORAGE_VIEW_OPTIONS as { value: string; label: string }[]; @@ -151,19 +157,21 @@ export const StoragePageControls: Component = (props) })), }); - filters.push({ - id: 'storage-source', - label: 'Source', - group: 'scope', - value: props.sourceFilter, - setValue: props.setSourceFilter, - defaultValue: DEFAULT_STORAGE_SOURCE_FILTER, - options: () => - props.sourceOptions().map((option) => ({ - value: option.key, - label: option.label, - })), - }); + if (!props.suppressSourceFilter) { + filters.push({ + id: 'storage-source', + label: 'Source', + group: 'scope', + value: props.sourceFilter, + setValue: props.setSourceFilter, + defaultValue: DEFAULT_STORAGE_SOURCE_FILTER, + options: () => + props.sourceOptions().map((option) => ({ + value: option.key, + label: option.label, + })), + }); + } filters.push({ id: 'storage-status', diff --git a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx index 9d8e45c0a..fda7a00d8 100644 --- a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx @@ -353,6 +353,16 @@ describe('Storage platform-page embed contract', () => { expect(storageSource).toContain('showFilterToolbar?: boolean;'); expect(storageSource).toContain('props.showFilterToolbar || !props.tableOnly'); }); + + it('auto-suppresses the Source filter chip whenever forcedSourceFilter is set', async () => { + const storageSource = (await import('../Storage.tsx?raw')).default; + expect(storageSource).toContain( + 'suppressSourceFilter={props.suppressSourceFilter || Boolean(props.forcedSourceFilter)}', + ); + const controlsSource = (await import('../StoragePageControls.tsx?raw')).default; + expect(controlsSource).toContain('suppressSourceFilter?: boolean;'); + expect(controlsSource).toContain('if (!props.suppressSourceFilter) {'); + }); }); describe('Storage', () => { diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index b0effa507..2cca7dd7c 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -15,7 +15,7 @@ import { type DockerPageTabId, } from './dockerPageModel'; -const DOCKER_RESOURCE_QUERY = 'type=agent,docker-host,app-container'; +const DOCKER_RESOURCE_QUERY = 'type=agent,docker-host,app-container,docker-service'; const DOCKER_PLATFORM_FILTER = 'docker'; const VALID_TABS = new Set(DOCKER_TAB_SPECS.map((tab) => tab.id)); @@ -93,6 +93,15 @@ export function DockerPageSurface() { forcedPlatform={DOCKER_PLATFORM_FILTER} /> + + + diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts index 5b67a4b2f..86e407a3e 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts @@ -14,11 +14,15 @@ const makeResource = (resource: Partial & Pick { - it('declares the Docker section set, omitting Swarm services until the canonical projection exists', () => { - expect(DOCKER_TAB_SPECS.map((tab) => tab.id)).toEqual(['overview', 'containers']); + it('declares the Docker section set with hosts, containers, and Swarm services', () => { + expect(DOCKER_TAB_SPECS.map((tab) => tab.id)).toEqual([ + 'overview', + 'containers', + 'services', + ]); }); - it('buckets Docker hosts and containers from canonical resources', () => { + it('buckets Docker hosts, containers, and Swarm services from canonical resources', () => { const model = buildDockerPageModel([ makeResource({ id: 'docker-host-1', type: 'agent' }), makeResource({ @@ -26,6 +30,7 @@ describe('dockerPageModel', () => { type: 'app-container', platformType: 'docker', }), + makeResource({ id: 'svc-1', type: 'docker-service' }), makeResource({ id: 'pve-node-1', type: 'agent', @@ -35,8 +40,9 @@ describe('dockerPageModel', () => { expect(model.hosts.map((r) => r.id)).toEqual(['docker-host-1']); expect(model.containers.map((r) => r.id)).toEqual(['ctr-1']); + expect(model.services.map((r) => r.id)).toEqual(['svc-1']); expect(model.resources.map((r) => r.id).sort()).toEqual( - ['ctr-1', 'docker-host-1'].sort(), + ['ctr-1', 'docker-host-1', 'svc-1'].sort(), ); }); diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts index 261cf7b52..d0a0d0231 100644 --- a/frontend-modern/src/features/docker/dockerPageModel.ts +++ b/frontend-modern/src/features/docker/dockerPageModel.ts @@ -1,11 +1,7 @@ import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; import type { Resource, ResourceType } from '@/types/resource'; -// Docker Swarm services are not yet emitted as `docker-service` resources by -// the canonical unified resource adapter at the /api/resources boundary, so -// the Swarm services sub-tab is intentionally absent from the page until -// that gap is closed. -export type DockerPageTabId = 'overview' | 'containers'; +export type DockerPageTabId = 'overview' | 'containers' | 'services'; export type DockerTabSpec = { id: DockerPageTabId; @@ -16,10 +12,12 @@ export type DockerTabSpec = { export const DOCKER_TAB_SPECS: readonly DockerTabSpec[] = [ { id: 'overview', label: 'Hosts', path: '/docker/overview' }, { id: 'containers', label: 'Containers', path: '/docker/containers' }, + { id: 'services', label: 'Swarm services', path: '/docker/services' }, ] as const; const DOCKER_HOST_TYPES = new Set(['agent', 'docker-host']); const DOCKER_CONTAINER_TYPES = new Set(['app-container']); +const DOCKER_SERVICE_TYPES = new Set(['docker-service']); const isDockerPlatform = (resource: Resource): boolean => resolveResourcePlatformType(resource) === 'docker'; @@ -28,11 +26,15 @@ export type DockerPageModel = { resources: Resource[]; hosts: Resource[]; containers: Resource[]; + services: Resource[]; }; export function buildDockerPageModel(resources: Resource[]): DockerPageModel { const dockerResources = resources.filter( - (resource) => isDockerPlatform(resource) || DOCKER_CONTAINER_TYPES.has(resource.type), + (resource) => + isDockerPlatform(resource) || + DOCKER_CONTAINER_TYPES.has(resource.type) || + DOCKER_SERVICE_TYPES.has(resource.type), ); const hosts = dockerResources.filter( @@ -41,10 +43,14 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel { const containers = dockerResources.filter( (resource) => DOCKER_CONTAINER_TYPES.has(resource.type) && isDockerPlatform(resource), ); + const services = dockerResources.filter((resource) => + DOCKER_SERVICE_TYPES.has(resource.type), + ); return { resources: dockerResources, hosts, containers, + services, }; } diff --git a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx index ec0d37799..fd1144f5e 100644 --- a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx +++ b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx @@ -6,6 +6,7 @@ import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PlatformErrorState, + PlatformResourceTable, PlatformSectionTabs, PlatformTableEmptyState, } from '@/features/platformPage/sharedPlatformPage'; @@ -16,7 +17,7 @@ import { } from './truenasPageModel'; const TRUENAS_RESOURCE_QUERY = - 'type=app-container,storage,pool,dataset,physical_disk'; + 'type=agent,app-container,storage,pool,dataset,physical_disk'; const TRUENAS_PLATFORM_FILTER = 'truenas'; const VALID_TABS = new Set(TRUENAS_TAB_SPECS.map((tab) => tab.id)); @@ -31,7 +32,7 @@ export function TrueNASPageSurface() { }); const activeTab = createMemo(() => { const segment = location.pathname.split('/').filter(Boolean)[1] as TrueNASPageTabId | undefined; - return segment && VALID_TABS.has(segment) ? segment : 'storage'; + return segment && VALID_TABS.has(segment) ? segment : 'overview'; }); const model = createMemo(() => buildTrueNASPageModel(resources())); @@ -73,6 +74,14 @@ export function TrueNASPageSurface() { /> } > + + + & Pick { - it('declares the TrueNAS section set, omitting Hosts until the canonical projection exists', () => { - expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual(['storage', 'apps']); + it('declares the TrueNAS section set with Systems, Storage, and Apps', () => { + expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual(['overview', 'storage', 'apps']); }); - it('buckets storage, apps, and disks while ignoring non-TrueNAS resources', () => { + it('buckets systems, apps, storage, and disks while ignoring non-TrueNAS resources', () => { const model = buildTrueNASPageModel([ + makeResource({ id: 'truenas-system', type: 'agent' }), makeResource({ id: 'truenas-app', type: 'app-container' }), makeResource({ id: 'truenas-pool', type: 'pool' }), makeResource({ id: 'truenas-disk', type: 'physical_disk' }), @@ -27,9 +28,10 @@ describe('truenasPageModel', () => { makeResource({ id: 'pve-node', type: 'agent', platformType: 'proxmox-pve' }), ]); + expect(model.systems.map((r) => r.id)).toEqual(['truenas-system']); expect(model.apps.map((r) => r.id)).toEqual(['truenas-app']); expect(model.resources.map((r) => r.id).sort()).toEqual( - ['truenas-app', 'truenas-disk', 'truenas-pool'].sort(), + ['truenas-app', 'truenas-disk', 'truenas-pool', 'truenas-system'].sort(), ); }); }); diff --git a/frontend-modern/src/features/truenas/truenasPageModel.ts b/frontend-modern/src/features/truenas/truenasPageModel.ts index 21fe6858d..d61902ba1 100644 --- a/frontend-modern/src/features/truenas/truenasPageModel.ts +++ b/frontend-modern/src/features/truenas/truenasPageModel.ts @@ -1,12 +1,7 @@ import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; import type { Resource, ResourceType } from '@/types/resource'; -// The canonical unified resource adapter does not yet project a top-level -// TrueNAS system (`agent` row tagged with the `truenas` platform) at the -// /api/resources boundary, so the Hosts overview tab is intentionally absent -// from the page until that gap is closed. Storage and Apps remain the -// canonical operator entry points. -export type TrueNASPageTabId = 'storage' | 'apps'; +export type TrueNASPageTabId = 'overview' | 'storage' | 'apps'; export type TrueNASTabSpec = { id: TrueNASPageTabId; @@ -15,11 +10,13 @@ export type TrueNASTabSpec = { }; export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [ + { id: 'overview', label: 'Systems', path: '/truenas/overview' }, { id: 'storage', label: 'Storage', path: '/truenas/storage' }, { id: 'apps', label: 'Apps', path: '/truenas/apps' }, ] as const; const TRUENAS_RESOURCE_TYPES = new Set([ + 'agent', 'app-container', 'storage', 'pool', @@ -32,6 +29,7 @@ const isTrueNASPlatform = (resource: Resource): boolean => export type TrueNASPageModel = { resources: Resource[]; + systems: Resource[]; apps: Resource[]; }; @@ -40,10 +38,12 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel { (resource) => isTrueNASPlatform(resource) && TRUENAS_RESOURCE_TYPES.has(resource.type), ); + const systems = trueNasResources.filter((resource) => resource.type === 'agent'); const apps = trueNasResources.filter((resource) => resource.type === 'app-container'); return { resources: trueNasResources, + systems, apps, }; } diff --git a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts index 4f066bdc3..2b6f78c23 100644 --- a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts +++ b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts @@ -86,10 +86,8 @@ describe('resource link routing contract', () => { expect(buildKubernetesPath()).toBe('/kubernetes/overview'); expect(buildKubernetesPath('pods')).toBe('/kubernetes/pods'); - // TrueNAS defaults to Storage because the canonical adapter does not - // emit a TrueNAS-platform agent row to back an Overview/Hosts tab. expect(TRUENAS_PATH).toBe('/truenas'); - expect(buildTrueNASPath()).toBe('/truenas/storage'); + expect(buildTrueNASPath()).toBe('/truenas/overview'); expect(buildTrueNASPath('apps')).toBe('/truenas/apps'); expect(VMWARE_PATH).toBe('/vmware'); diff --git a/frontend-modern/src/routing/resourceLinks.ts b/frontend-modern/src/routing/resourceLinks.ts index 92cdc6ba2..8e03c1b93 100644 --- a/frontend-modern/src/routing/resourceLinks.ts +++ b/frontend-modern/src/routing/resourceLinks.ts @@ -45,7 +45,7 @@ export const DOCKER_DEFAULT_TAB = 'overview'; export const KUBERNETES_PATH = '/kubernetes'; export const KUBERNETES_DEFAULT_TAB = 'overview'; export const TRUENAS_PATH = '/truenas'; -export const TRUENAS_DEFAULT_TAB = 'storage'; +export const TRUENAS_DEFAULT_TAB = 'overview'; export const VMWARE_PATH = '/vmware'; export const VMWARE_DEFAULT_TAB = 'overview'; export const PMG_THRESHOLDS_PATH = '/alerts/thresholds/mail-gateway'; diff --git a/internal/mock/generator.go b/internal/mock/generator.go index 3760f76eb..7841bb9c9 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -2139,11 +2139,19 @@ func generateDockerHosts(config MockConfig) []models.DockerHost { var services []models.DockerService var tasks []models.DockerTask if !isPodman { + // ClusterID/ClusterName are required for the unified resource + // adapter to project Swarm services as docker-service rows + // (`dockerSwarmClusterKey` returns empty without them). Anchor + // the mock estate to a single named cluster so all manager and + // worker hosts share Swarm identity and their services + // deduplicate correctly across managers. swarmInfo = &models.DockerSwarmInfo{ NodeID: fmt.Sprintf("%s-node", hostID), NodeRole: "worker", LocalState: "active", ControlAvailable: false, + ClusterID: "mock-swarm-cluster-1", + ClusterName: "edge-swarm", Scope: "node", } diff --git a/internal/vmware/fixtures.go b/internal/vmware/fixtures.go index abd4fabd3..202eb8f51 100644 --- a/internal/vmware/fixtures.go +++ b/internal/vmware/fixtures.go @@ -1,6 +1,9 @@ package vmware -import "time" +import ( + "fmt" + "time" +) func DefaultFixtures() InventorySnapshot { collectedAt := time.Date(2026, time.March, 31, 9, 45, 0, 0, time.UTC) @@ -8,6 +11,16 @@ func DefaultFixtures() InventorySnapshot { accessible := true multipleHostAccess := true + snapshot := defaultFixturesPrimaryCluster(collectedAt, accessible, multipleHostAccess) + appendEdgeClusterFixtures(&snapshot, collectedAt, accessible, multipleHostAccess) + return snapshot +} + +func defaultFixturesPrimaryCluster( + collectedAt time.Time, + accessible bool, + multipleHostAccess bool, +) InventorySnapshot { return InventorySnapshot{ ConnectionID: "vc-mock-1", ConnectionName: "Lab vCenter", @@ -506,3 +519,234 @@ func DefaultFixtures() InventorySnapshot { }, } } + +// appendEdgeClusterFixtures grows the canonical VMware mock inventory toward +// a mature SMB lab. The edge cluster lives in a second datacenter and adds +// 3 more ESXi hosts, 12 more VMs across a tier-1 application set + a +// stateful tier + a windows fleet, and 4 more datastores (one of each +// canonical storage technology) so the platform-page tables exercise +// grouping, sorting, and responsive density beyond a single small cluster. +func appendEdgeClusterFixtures( + snapshot *InventorySnapshot, + collectedAt time.Time, + accessible bool, + multipleHostAccess bool, +) { + const ( + edgeDatacenterID = "datacenter-2" + edgeDatacenterName = "Edge DC" + edgeClusterID = "domain-c201" + edgeClusterName = "Edge Services" + edgeFolderHosts = "group-h6" + edgeFolderHostsLbl = "Edge Cluster Hosts" + edgeFolderVMs = "group-v9" + edgeFolderVMsLbl = "Edge Production VMs" + edgeFolderDS = "group-s6" + edgeFolderDSLbl = "Edge Datastores" + ) + + edgeDatastoreIDs := []string{"datastore-301", "datastore-302", "datastore-303", "datastore-304"} + edgeDatastoreNames := []string{"edge-nvme-tier", "edge-warm-nfs", "edge-vsan", "edge-cold-iscsi"} + + hostSpecs := []struct { + ID string + Name string + UUID string + CPUPct float64 + MemPct float64 + MemUsed int64 + MemTotal int64 + Status string + }{ + {"host-201", "esxi-05.lab.local", "uuid-host-201", 27.4, 51.6, 44_277_059_584, 85_899_345_920, "green"}, + {"host-202", "esxi-06.lab.local", "uuid-host-202", 41.2, 67.9, 58_271_233_120, 85_899_345_920, "green"}, + {"host-203", "esxi-07.lab.local", "uuid-host-203", 19.8, 38.4, 32_968_499_584, 85_899_345_920, "yellow"}, + } + + hostNames := make([]string, 0, len(hostSpecs)) + for _, h := range hostSpecs { + hostNames = append(hostNames, h.Name) + } + + for _, h := range hostSpecs { + snapshot.Hosts = append(snapshot.Hosts, InventoryHost{ + Host: h.ID, + Name: h.Name, + ConnectionState: "CONNECTED", + PowerState: "POWERED_ON", + HostUUID: h.UUID, + DatacenterID: edgeDatacenterID, + DatacenterName: edgeDatacenterName, + ComputeResourceID: edgeClusterID, + ComputeResourceName: edgeClusterName, + ClusterID: edgeClusterID, + ClusterName: edgeClusterName, + FolderID: edgeFolderHosts, + FolderName: edgeFolderHostsLbl, + DatastoreIDs: edgeDatastoreIDs, + DatastoreNames: edgeDatastoreNames, + OverallStatus: h.Status, + Metrics: &InventoryMetrics{ + CPUPercent: float64Ptr(h.CPUPct), + MemoryPercent: float64Ptr(h.MemPct), + MemoryUsedBytes: int64Ptr(h.MemUsed), + MemoryTotalBytes: int64Ptr(h.MemTotal), + NetInBytesPerSecond: float64Ptr(960_000), + NetOutBytesPerSecond: float64Ptr(820_000), + DiskReadBytesPerSecond: float64Ptr(1_820_000), + DiskWriteBytesPerSecond: float64Ptr(1_540_000), + }, + }) + } + + vmSpecs := []struct { + ID string + Name string + Host string + CPUCount int + MemMiB int64 + Tier string + Datastores []string + PowerState string + Status string + Guest string + }{ + {"vm-301", "edge-api-01", "host-201", 4, 16 * 1024, "Tier 1", []string{"datastore-301"}, "POWERED_ON", "green", "edge-api-01.internal"}, + {"vm-302", "edge-api-02", "host-202", 4, 16 * 1024, "Tier 1", []string{"datastore-301"}, "POWERED_ON", "green", "edge-api-02.internal"}, + {"vm-303", "mariadb-replica-01", "host-203", 8, 32 * 1024, "Stateful", []string{"datastore-302"}, "POWERED_ON", "yellow", "mariadb-replica-01.internal"}, + {"vm-304", "redis-cache-01", "host-201", 2, 8 * 1024, "Stateful", []string{"datastore-303"}, "POWERED_ON", "green", "redis-cache-01.internal"}, + {"vm-305", "redis-cache-02", "host-202", 2, 8 * 1024, "Stateful", []string{"datastore-303"}, "POWERED_ON", "green", "redis-cache-02.internal"}, + {"vm-306", "ingress-proxy-01", "host-201", 4, 12 * 1024, "Tier 1", []string{"datastore-301"}, "POWERED_ON", "green", "ingress-proxy-01.internal"}, + {"vm-307", "ingress-proxy-02", "host-203", 4, 12 * 1024, "Tier 1", []string{"datastore-301"}, "POWERED_ON", "green", "ingress-proxy-02.internal"}, + {"vm-308", "win-fleet-rdp-01", "host-202", 4, 16 * 1024, "Workstations", []string{"datastore-303"}, "POWERED_ON", "green", "win-fleet-rdp-01.lab.local"}, + {"vm-309", "win-fleet-rdp-02", "host-203", 4, 16 * 1024, "Workstations", []string{"datastore-303"}, "POWERED_ON", "green", "win-fleet-rdp-02.lab.local"}, + {"vm-310", "logging-collector-01", "host-201", 4, 24 * 1024, "Observability", []string{"datastore-302"}, "POWERED_ON", "green", "logging-collector-01.internal"}, + {"vm-311", "logging-collector-02", "host-202", 4, 24 * 1024, "Observability", []string{"datastore-302"}, "POWERED_ON", "yellow", "logging-collector-02.internal"}, + {"vm-312", "cold-archive-01", "host-203", 2, 8 * 1024, "Archive", []string{"datastore-304"}, "POWERED_OFF", "gray", ""}, + } + + for _, v := range vmSpecs { + hostName := "" + for _, hs := range hostSpecs { + if hs.ID == v.Host { + hostName = hs.Name + break + } + } + dsNames := make([]string, 0, len(v.Datastores)) + for _, dsID := range v.Datastores { + for i, id := range edgeDatastoreIDs { + if id == dsID { + dsNames = append(dsNames, edgeDatastoreNames[i]) + } + } + } + cpuPct := 28.0 + memPct := 60.0 + if v.Status == "yellow" { + cpuPct = 64.0 + memPct = 81.0 + } else if v.Status == "gray" { + cpuPct = 0 + memPct = 0 + } + guestIPs := []string{} + if v.PowerState == "POWERED_ON" { + guestIPs = []string{fmt.Sprintf("10.50.20.%d", 10+len(snapshot.VMs))} + } + vm := InventoryVM{ + VM: v.ID, + Name: v.Name, + PowerState: v.PowerState, + CPUCount: v.CPUCount, + MemorySizeMiB: v.MemMiB, + DatacenterID: edgeDatacenterID, + DatacenterName: edgeDatacenterName, + ComputeResourceID: edgeClusterID, + ComputeResourceName: edgeClusterName, + ClusterID: edgeClusterID, + ClusterName: edgeClusterName, + FolderID: edgeFolderVMs, + FolderName: edgeFolderVMsLbl, + ResourcePoolID: fmt.Sprintf("resgroup-edge-%s", v.Tier), + ResourcePoolName: v.Tier, + RuntimeHostID: v.Host, + RuntimeHostName: hostName, + DatastoreIDs: v.Datastores, + DatastoreNames: dsNames, + InstanceUUID: fmt.Sprintf("vm-instance-%s", v.ID), + BIOSUUID: fmt.Sprintf("vm-bios-%s", v.ID), + GuestOSFamily: guestOSFamily(v.Name), + GuestHostname: v.Guest, + GuestIPAddresses: guestIPs, + OverallStatus: v.Status, + } + if v.PowerState == "POWERED_ON" { + vm.Metrics = &InventoryMetrics{ + CPUPercent: float64Ptr(cpuPct), + MemoryPercent: float64Ptr(memPct), + MemoryUsedBytes: int64Ptr(int64(float64(v.MemMiB) * float64(memPct) / 100.0 * 1024 * 1024)), + MemoryTotalBytes: int64Ptr(int64(v.MemMiB) * 1024 * 1024), + NetInBytesPerSecond: float64Ptr(360_000), + NetOutBytesPerSecond: float64Ptr(420_000), + DiskReadBytesPerSecond: float64Ptr(720_000), + DiskWriteBytesPerSecond: float64Ptr(610_000), + } + } + snapshot.VMs = append(snapshot.VMs, vm) + } + + type datastoreSpec struct { + ID string + Name string + Type string + Free int64 + Cap int64 + HostIDs []string + HostName []string + VMIDs []string + VMNames []string + URL string + Status string + } + dsList := []datastoreSpec{ + {"datastore-301", "edge-nvme-tier", "VMFS", 3_200_000_000_000, 6_000_000_000_000, []string{"host-201", "host-202", "host-203"}, hostNames, []string{"vm-301", "vm-302", "vm-306", "vm-307"}, []string{"edge-api-01", "edge-api-02", "ingress-proxy-01", "ingress-proxy-02"}, "ds:///vmfs/volumes/datastore-301/", "green"}, + {"datastore-302", "edge-warm-nfs", "NFS41", 9_100_000_000_000, 12_000_000_000_000, []string{"host-201", "host-203"}, []string{"esxi-05.lab.local", "esxi-07.lab.local"}, []string{"vm-303", "vm-310", "vm-311"}, []string{"mariadb-replica-01", "logging-collector-01", "logging-collector-02"}, "ds:///nfs/edge-warm-nfs/", "green"}, + {"datastore-303", "edge-vsan", "vSAN", 4_900_000_000_000, 9_000_000_000_000, []string{"host-201", "host-202", "host-203"}, hostNames, []string{"vm-304", "vm-305", "vm-308", "vm-309"}, []string{"redis-cache-01", "redis-cache-02", "win-fleet-rdp-01", "win-fleet-rdp-02"}, "ds:///vsan/edge-vsan/", "green"}, + {"datastore-304", "edge-cold-iscsi", "VMFS", 18_500_000_000_000, 24_000_000_000_000, []string{"host-202", "host-203"}, []string{"esxi-06.lab.local", "esxi-07.lab.local"}, []string{"vm-312"}, []string{"cold-archive-01"}, "ds:///vmfs/volumes/datastore-304/", "yellow"}, + } + for _, ds := range dsList { + snapshot.Datastores = append(snapshot.Datastores, InventoryDatastore{ + Datastore: ds.ID, + Name: ds.Name, + Type: ds.Type, + FreeSpace: ds.Free, + Capacity: ds.Cap, + DatacenterID: edgeDatacenterID, + DatacenterName: edgeDatacenterName, + FolderID: edgeFolderDS, + FolderName: edgeFolderDSLbl, + HostIDs: ds.HostIDs, + HostNames: ds.HostName, + VMIDs: ds.VMIDs, + VMNames: ds.VMNames, + Accessible: &accessible, + MultipleHostAccess: &multipleHostAccess, + MaintenanceMode: "normal", + URL: ds.URL, + OverallStatus: ds.Status, + }) + } +} + +func guestOSFamily(name string) string { + if name == "" { + return "" + } + switch name[:3] { + case "win": + return "WINDOWS" + default: + return "LINUX" + } +} diff --git a/tests/integration/tests/68-platform-pages-shell.spec.ts b/tests/integration/tests/68-platform-pages-shell.spec.ts index ac1ba39d4..bad2e1900 100644 --- a/tests/integration/tests/68-platform-pages-shell.spec.ts +++ b/tests/integration/tests/68-platform-pages-shell.spec.ts @@ -56,8 +56,8 @@ const PLATFORM_PAGES: readonly PlatformPageCase[] = [ rootPath: '/docker', testId: 'docker-page', ariaLabel: 'Docker sections', - tabPaths: ['/docker/overview', '/docker/containers'], - populatedTabPaths: ['/docker/overview', '/docker/containers'], + tabPaths: ['/docker/overview', '/docker/containers', '/docker/services'], + populatedTabPaths: ['/docker/overview', '/docker/containers', '/docker/services'], }, { id: 'kubernetes', @@ -82,8 +82,8 @@ const PLATFORM_PAGES: readonly PlatformPageCase[] = [ rootPath: '/truenas', testId: 'truenas-page', ariaLabel: 'TrueNAS sections', - tabPaths: ['/truenas/storage', '/truenas/apps'], - populatedTabPaths: ['/truenas/storage', '/truenas/apps'], + tabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/apps'], + populatedTabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/apps'], }, { id: 'vmware', @@ -174,10 +174,12 @@ test.describe('Platform pages shell', () => { const cases: ReadonlyArray<{ path: string; testId: string }> = [ { path: '/docker/overview', testId: 'docker-page' }, { path: '/docker/containers', testId: 'docker-page' }, + { path: '/docker/services', testId: 'docker-page' }, { path: '/kubernetes/overview', testId: 'kubernetes-page' }, { path: '/kubernetes/nodes', testId: 'kubernetes-page' }, { path: '/kubernetes/pods', testId: 'kubernetes-page' }, { path: '/kubernetes/deployments', testId: 'kubernetes-page' }, + { path: '/truenas/overview', testId: 'truenas-page' }, { path: '/truenas/storage', testId: 'truenas-page' }, { path: '/truenas/apps', testId: 'truenas-page' }, { path: '/vmware/overview', testId: 'vmware-page' },