platforms: close remaining gaps — Swarm services, vSphere fixtures, TrueNAS systems, source-filter suppression

Four documented platform-page gaps from the prior round are closed:

1. **Docker Swarm services canonical projection.** The unified resource
   adapter requires `host.Swarm.ClusterID`/`ClusterName` for
   `dockerSwarmClusterKey` to produce a stable service source ID; the
   mock generator was leaving those fields empty so all generated
   services were dropped. Anchor every mock Swarm host to a single named
   cluster (`mock-swarm-cluster-1` / `edge-swarm`) so manager and worker
   hosts share Swarm identity and their services deduplicate correctly
   across managers. Live mock survey now exposes 15 docker-service rows
   (was 0).

2. **Docker Swarm services UI restored.** The `/docker/services`
   sub-tab is back. `DockerPageSurface` mounts a `PlatformResourceTable`
   with the canonical operator toolbar (search + status chips +
   counter); `dockerPageModel.ts` re-introduces the services bucket;
   the model test asserts the three-tab shape and the services bucket.

3. **TrueNAS Systems / Overview sub-tab restored.** Re-survey of the
   canonical adapter confirms `truenas.FixtureRecords` already emits
   the top-level TrueNAS appliance as a unified `agent` row tagged
   with the `truenas` platform (see `internal/truenas/provider.go::
   truenasRecordsFromSnapshot`). TrueNAS now defaults to
   `/truenas/overview` and the page model exposes a `systems` bucket.

4. **VMware fixture inventory scaled to a mature SMB lab.**
   `internal/vmware/fixtures.go::appendEdgeClusterFixtures`
   programmatically appends an Edge DC with 3 more ESXi hosts
   (esxi-05..07), 12 more VMs across Tier 1 / Stateful / Workstations /
   Observability / Archive tiers (mixed healthy/warning/powered-off,
   mixed Linux/Windows guest OS), and 4 more datastores (VMFS / NFS41 /
   vSAN / cold-iSCSI). Live mock survey now shows 43 VMs (was 31), 18
   agents (was 15), and 60 storage rows (was 55) across two datacenters.

5. **TrueNAS / vSphere Storage source filter chip suppression.**
   `StoragePageControls` gains a `suppressSourceFilter` prop and
   `Storage.tsx` automatically applies it whenever `forcedSourceFilter`
   is set, so platform-page embeds no longer render the now-locked
   Source filter chip alongside the operator toolbar.

Resource survey under the new mock baseline (live `/api/resources`):
- TOTAL 342 unique resources (was 307)
- app-container: 75, storage: 60, system-container: 44, vm: 43,
  pod: 40, physical_disk: 19, agent: 18, docker-service: 15,
  k8s-deployment: 14, docker-host: 5, network-endpoint: 5,
  pbs: 2, pmg: 1, k8s-cluster: 1

Browser verification (Playwright, chromium, live mock-mode dev runtime):
- 9 tests pass. Every populated sub-tab — Docker Hosts / Containers /
  Swarm services, Kubernetes Clusters / Nodes / Pods / Deployments,
  TrueNAS Systems / Storage / Apps, vSphere Hosts / VMs / Storage —
  asserts both populated canonical rows AND a visible operator search
  input.

Targeted vitest (77 files / 358 tests) + Go tests (./internal/vmware,
./internal/mock, ./internal/monitoring) all green.

Contracts updated:
- `storage-recovery.md` Shared Boundaries: TrueNAS defaults to the
  Systems overview now that the canonical adapter emits a TrueNAS-
  platform agent row; `suppressSourceFilter` auto-applies under
  `forcedSourceFilter`.
- `unified-resources.md` Extension Points: same; the canonical TrueNAS
  adapter emits the appliance as a unified resource so the builder
  default lands on a populated Systems sub-tab.
- `Storage.test.tsx` extended with the source-filter suppression
  contract assertion.
This commit is contained in:
rcourtman 2026-05-16 08:35:44 +01:00
parent cef057943a
commit 294ac1da04
16 changed files with 372 additions and 59 deletions

View file

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

View file

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

View file

@ -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<StorageProps> = (props) => {
@ -194,6 +197,7 @@ const Storage: Component<StorageProps> = (props) => {
sourceFilter={sourceFilter}
setSourceFilter={setSourceFilter}
sourceOptions={sourceFilterOptions}
suppressSourceFilter={props.suppressSourceFilter || Boolean(props.forcedSourceFilter)}
diskRoleFilter={diskRoleFilter}
setDiskRoleFilter={setDiskRoleFilter}
diskRoleOptions={diskRoleOptions}

View file

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

View file

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

View file

@ -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<DockerPageTabId>(DOCKER_TAB_SPECS.map((tab) => tab.id));
@ -93,6 +93,15 @@ export function DockerPageSurface() {
forcedPlatform={DOCKER_PLATFORM_FILTER}
/>
</Show>
<Show when={activeTab() === 'services'}>
<PlatformResourceTable
resources={model().services}
emptyIcon={dockerIcon()}
emptyTitle="No Swarm services"
emptyDescription="Docker Swarm services appear here when a Swarm manager reports them."
groupingMode="flat"
/>
</Show>
</Show>
</Show>
</Show>

View file

@ -14,11 +14,15 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
});
describe('dockerPageModel', () => {
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(),
);
});

View file

@ -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<ResourceType>(['agent', 'docker-host']);
const DOCKER_CONTAINER_TYPES = new Set<ResourceType>(['app-container']);
const DOCKER_SERVICE_TYPES = new Set<ResourceType>(['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,
};
}

View file

@ -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<TrueNASPageTabId>(TRUENAS_TAB_SPECS.map((tab) => tab.id));
@ -31,7 +32,7 @@ export function TrueNASPageSurface() {
});
const activeTab = createMemo<TrueNASPageTabId>(() => {
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() {
/>
}
>
<Show when={activeTab() === 'overview'}>
<PlatformResourceTable
resources={model().systems}
emptyIcon={truenasIcon()}
emptyTitle="No TrueNAS systems"
emptyDescription="TrueNAS systems appear here once a TrueNAS connection reports its top-level appliance."
/>
</Show>
<Show when={activeTab() === 'storage'}>
<StorageSurface
embedded

View file

@ -14,12 +14,13 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
});
describe('truenasPageModel', () => {
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(),
);
});
});

View file

@ -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<ResourceType>([
'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,
};
}

View file

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

View file

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

View file

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

View file

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

View file

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