mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-17 21:13:33 +00:00
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:
parent
cef057943a
commit
294ac1da04
16 changed files with 372 additions and 59 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue