Gate platform workflow tabs by inventory

Keep Overview as the stable platform landing tab, hide workflow tabs without current inventory or signal, and route legacy Docker containers URLs back to the host-plus-workloads overview.
This commit is contained in:
rcourtman
2026-06-01 08:41:49 +01:00
parent a13218a39a
commit 64cbdd6e42
16 changed files with 330 additions and 70 deletions
@@ -179,12 +179,21 @@ and Kubernetes platform pages may add native API-backed sections, but the tabs
must use `PlatformSectionTabs`, canonical table alignment helpers, and shared
resource type presentation/reporting helpers rather than page-local tab shells,
alignment classes, or ad hoc report-category coercion. Platform tabs are
workflow-level navigation, not one visible tab per API resource kind: Docker /
Podman exposes `Overview`, `Containers`, `Images`, `Storage`, `Networks`, and
`Swarm`, while Kubernetes exposes `Overview`, `Nodes`, `Workloads`, `Services`,
`Storage`, `Configuration`, and `Events`. API-native tables remain bespoke under
those workflows, so Docker `Storage` owns engine disk usage plus volumes and
Docker `Swarm` owns services, tasks, nodes, secrets, and configs; Kubernetes
workflow-level navigation, not one visible tab per API resource kind, and they
must be evidence-gated by their owning row or signal model. `Overview` is the
stable landing surface; supporting workflow tabs appear only when the current
setup has native inventory or signal for that workflow, and legacy object URLs
resolve to their owning workflow only when that workflow is visible. Docker /
Podman may expose `Overview`, `Images`, `Storage`, `Networks`, and `Swarm`,
while legacy `/docker/containers` resolves to the Overview landing surface
rather than remaining a separate visible tab. Kubernetes may expose `Overview`,
`Nodes`, `Workloads`, `Services`, `Storage`, `Configuration`, and `Events`;
TrueNAS and vSphere follow the same evidence-gated primitive for native
storage, service, app, VM, protection, datastore, network, health, and activity
workflows. API-native tables remain bespoke under those workflows, so Docker
`Overview` owns runtime hosts plus primary container workloads, Docker `Storage`
owns engine disk usage plus volumes, and Docker `Swarm` owns services, tasks,
nodes, secrets, and configs; Kubernetes
`Workloads` owns Pods, Deployments, controllers, and autoscaling, `Services`
owns Services plus ingress/endpoint inventory, and `Configuration` owns config
plus policy inventory. Backup and recovery platform pages follow the same
@@ -194,9 +203,11 @@ not grow one top-level tab for each API source merely because that source has a
table. Legacy object-specific URLs may resolve to the owning
workflow tab, but they must not reappear as top-level platform navigation unless
the product IA is intentionally changed. Overview tabs must stay deliberately
narrow instead of repeating every detail table: Docker / Podman Overview owns
runtime hosts, while Kubernetes Overview owns cluster/control-plane rollup; the
object tables live in their dedicated workflow tabs. Docker / Podman native subsections now
shaped around the primary operator job instead of repeating every detail table:
Docker / Podman Overview owns runtime hosts and primary container workloads in
the proven host-then-workloads pattern, while Kubernetes Overview owns
cluster/control-plane rollup; supporting object tables live in their dedicated
workflow tabs. Docker / Podman native subsections now
include runtime containers, engine storage usage, Swarm node inventory, and
metadata-only Swarm secret/config inventory where the documented Docker APIs
report those resources; Podman-only libpod pod inventory must not be represented
@@ -160,20 +160,32 @@ generic controller/event inventory table.
Container runtime navigation is a unified-resource consumer boundary: the
`/docker` route is the Docker / Podman runtime lens, not an exclusive owning
platform. Its subtabs may be shown only from canonical resource evidence
(`app-container` for containers, `docker-service` for Swarm services), and
inactive standalone Swarm metadata must not be interpreted as host-role or
service-surface proof. The unified-resource adapter is the backend fail-closed
layer for that rule, so persisted or older-agent inactive Swarm payloads cannot
reintroduce false Swarm capability surfaces.
platform. Overview is the primary Docker / Podman landing surface and carries
runtime host rows plus primary `app-container` workload rows so small and
medium estates preserve the proven Pulse host-then-workloads interaction. The
legacy `/docker/containers` route is a compatibility alias for that Overview
surface, not a separate visible tab. Supporting subtabs such as Images,
Storage, Networks, and Swarm may be shown only from canonical resource
evidence, and inactive standalone Swarm metadata must not be interpreted as
host-role or service-surface proof. The unified-resource adapter is the backend
fail-closed layer for that rule, so persisted or older-agent inactive Swarm
payloads cannot reintroduce false Swarm capability surfaces.
Platform Overview tabs are rollup boundaries, not duplicate inventory dumps.
Docker / Podman Overview owns runtime host rows only; container, image, storage,
network, and Swarm object rows belong in their workflow tabs. Kubernetes
Overview owns cluster/control-plane rollup rows only; node, workload, service,
storage, configuration, policy, and event object rows belong in their workflow
tabs. If a future Overview repeats a detailed table, the owning workflow must be
retired or the Overview content must be reduced to aggregate signal.
Docker / Podman Overview owns runtime host rows and primary container workload
rows; image, storage, network, and Swarm object rows belong in their workflow
tabs. Kubernetes Overview owns cluster/control-plane rollup rows only; node,
workload, service, storage, configuration, policy, and event object rows belong
in their workflow tabs. If a future Overview repeats a detailed table, the
owning workflow must be retired or the Overview content must be reduced to
aggregate signal.
Across platform/runtime pages, workflow tabs are evidence-gated from the
canonical model that owns their rows. `Overview` is the stable landing tab;
supporting tabs appear only when their native inventory or signal exists, and
legacy/direct object routes fall back to `Overview` when the requested workflow
has no rows for the current setup. Signals outside unified-resource inventory,
such as TrueNAS recovery protection points or vSphere activity timeline rows,
must be treated as explicit tab evidence rather than permanent navigation.
Kubernetes configuration and policy inventory are unified-resource consumer
boundaries: the `/kubernetes/configuration` workflow tab must render Namespace,
@@ -302,8 +314,9 @@ proof can distinguish a populated disk-usage tab from an empty fixture.
generic workload rows and Swarm services. Runtime containers must preserve
Docker Engine container identity, owning host/runtime context, image,
state/health/restart/update status, ports, networks, and mounts through
`DockerData` so `/docker/containers` can use the native
`DockerContainersTable` rather than `WorkloadsSurface`. Runtime image,
`DockerData` so Docker Overview and its `/docker/containers` compatibility
route can use the native `DockerContainersTable` rather than
`WorkloadsSurface`. Runtime image,
volume, network, task, Swarm node, Swarm secret, Swarm config, and
storage-usage evidence must enter through `DockerData` and the typed Docker
resource records (`docker-image`, `docker-volume`, `docker-network`,
@@ -566,7 +579,9 @@ AI-only summary payloads, or page-local heuristics.
hostname, `agent` platform scope, or agent telemetry alone; those facts
surface as facets on the owning provider page.
The default tab for each platform path must point at a sub-tab whose
canonical unified-resource projection actually populates. The
canonical unified-resource projection actually populates, and visible
workflow subtabs must stay evidence-gated by the same canonical row or
signal source instead of advertising empty object browsers. 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`
@@ -99,17 +99,10 @@ export function DockerPageSurface() {
<DockerOverview
hosts={model().hosts}
hostSourceCount={model().hosts.length}
containers={model().containers}
incidents={model().incidents}
/>
</Show>
<Show when={activeTab() === 'containers'}>
<DockerContainersTable
resources={model().containers}
emptyIcon={dockerIcon()}
emptyTitle="No Docker or Podman containers"
emptyDescription="Containers appear here when a Docker or Podman host reports workload inventory."
/>
</Show>
<Show when={activeTab() === 'images'}>
<DockerImagesTable
resources={model().images}
@@ -275,6 +268,7 @@ function DockerSwarm(props: { model: DockerPageModel }) {
function DockerOverview(props: {
hosts: ReturnType<typeof buildDockerPageModel>['hosts'];
hostSourceCount: number;
containers: ReturnType<typeof buildDockerPageModel>['containers'];
incidents: ReturnType<typeof buildDockerPageModel>['incidents'];
}) {
return (
@@ -287,6 +281,12 @@ function DockerOverview(props: {
emptyDescription="Container hosts appear here once a Pulse agent registers them."
showToolbar={false}
/>
<DockerContainersTable
resources={props.containers}
emptyIcon={dockerIcon()}
emptyTitle="No Docker or Podman containers"
emptyDescription="Containers appear here when a Docker or Podman host reports workload inventory."
/>
<Show when={props.incidents.length > 0}>
<DockerAlertsTable
incidents={props.incidents}
@@ -218,7 +218,7 @@ afterEach(() => {
});
describe('DockerPageSurface', () => {
it('keeps overview focused on runtime hosts while detailed inventory owns the object tabs', () => {
it('keeps overview focused on runtime hosts plus primary container workloads', () => {
render(() => <DockerPageSurface />);
expect(mocks.useUnifiedResources).toHaveBeenCalledWith(
@@ -240,9 +240,16 @@ describe('DockerPageSurface', () => {
expect(screen.getByTestId('docker-hosts-table')).toHaveAttribute('data-show-toolbar', 'false');
expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute(
'data-tabs',
'overview,containers',
'overview',
);
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
'data-resource-count',
'1',
);
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
'data-show-toolbar',
'undefined',
);
expect(screen.queryByTestId('docker-containers-table')).toBeNull();
});
it('shows Docker object tabs only when the matching inventory exists', () => {
@@ -263,15 +270,17 @@ describe('DockerPageSurface', () => {
expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute(
'data-tabs',
'overview,containers,images,storage,networks',
'overview,images,storage,networks',
);
});
it('renders the dedicated containers route through the Docker containers table', () => {
it('keeps the legacy containers route on the Overview landing surface', () => {
mocks.pathname = '/docker/containers';
render(() => <DockerPageSurface />);
expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute('data-active', 'overview');
expect(screen.getByTestId('docker-hosts-table')).toHaveAttribute('data-resource-count', '1');
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
'data-resource-count',
'1',
@@ -473,6 +482,9 @@ describe('DockerPageSurface', () => {
expect(screen.queryByTestId('docker-volumes-table')).toBeNull();
expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute('data-active', 'overview');
expect(screen.getByTestId('docker-hosts-table')).toHaveAttribute('data-resource-count', '1');
expect(screen.queryByTestId('platform-table-empty-state')).toBeNull();
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
'data-resource-count',
'0',
);
});
});
@@ -88,7 +88,6 @@ describe('dockerPageModel', () => {
it('declares operator workflow tabs for Docker runtime inventory', () => {
expect(DOCKER_TAB_SPECS.map((tab) => tab.id)).toEqual([
'overview',
'containers',
'images',
'storage',
'networks',
@@ -98,7 +97,7 @@ describe('dockerPageModel', () => {
it('keeps legacy Docker object routes mapped to workflow tabs', () => {
expect(resolveDockerPageTabId(undefined)).toBe('overview');
expect(resolveDockerPageTabId('containers')).toBe('containers');
expect(resolveDockerPageTabId('containers')).toBe('overview');
expect(resolveDockerPageTabId('volumes')).toBe('storage');
expect(resolveDockerPageTabId('storage')).toBe('storage');
expect(resolveDockerPageTabId('services')).toBe('swarm');
@@ -241,7 +240,6 @@ describe('dockerPageModel', () => {
]);
expect(getDockerPageTabSpecs(runtimeInventoryModel).map((tab) => tab.id)).toEqual([
'overview',
'containers',
'images',
'storage',
'networks',
@@ -24,7 +24,6 @@ const DOCKER_CONFIG_TYPES = new Set<ResourceType>(['docker-config']);
export type DockerPageTabId =
| 'overview'
| 'containers'
| 'images'
| 'storage'
| 'networks'
@@ -38,11 +37,9 @@ export type DockerTabSpec = {
export const DOCKER_TAB_SPECS: readonly DockerTabSpec[] = [
// Keep the runtime lens at operator-workflow granularity. Overview owns
// runtime hosts; detailed object inventory belongs in the Containers,
// Images, Storage, Networks, and Swarm workflows so the page does not repeat
// the same tables in multiple places.
// runtime hosts plus primary container workloads; detailed supporting object
// inventory belongs in the Images, Storage, Networks, and Swarm workflows.
{ id: 'overview', label: 'Overview', path: '/docker/overview' },
{ id: 'containers', label: 'Containers', path: '/docker/containers' },
{ id: 'images', label: 'Images', path: '/docker/images' },
{ id: 'storage', label: 'Storage', path: '/docker/storage' },
{ id: 'networks', label: 'Networks', path: '/docker/networks' },
@@ -52,6 +49,7 @@ export const DOCKER_TAB_SPECS: readonly DockerTabSpec[] = [
const asTrimmedString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
const DOCKER_ROUTE_TAB_ALIASES: Record<string, DockerPageTabId> = {
containers: 'overview',
configs: 'swarm',
secrets: 'swarm',
services: 'swarm',
@@ -610,8 +608,6 @@ const hasDockerTabInventory = (model: DockerPageModel, tab: DockerPageTabId): bo
switch (tab) {
case 'overview':
return true;
case 'containers':
return model.containers.length > 0;
case 'images':
return model.images.length > 0;
case 'storage':
@@ -26,9 +26,9 @@ import { KubernetesPolicyTable } from './KubernetesPolicyTable';
import { KubernetesServicesTable } from './KubernetesServicesTable';
import { KubernetesStorageTable } from './KubernetesStorageTable';
import {
KUBERNETES_TAB_SPECS,
buildKubernetesPageModel,
filterKubernetesResources,
getKubernetesPageTabSpecs,
resolveKubernetesPageTabId,
type KubernetesPageModel,
type KubernetesPageTabId,
@@ -52,17 +52,21 @@ export function KubernetesPageSurface() {
cacheKey: 'kubernetes-workspace',
initialHydration: 'prefer-ws-then-rest',
});
const activeTab = createMemo<KubernetesPageTabId>(() => {
const requestedTab = createMemo<KubernetesPageTabId>(() => {
const segment = location.pathname.split('/').filter(Boolean)[1];
return resolveKubernetesPageTabId(segment);
});
const model = createMemo(() => buildKubernetesPageModel(resources()));
const tabs = createMemo(() => getKubernetesPageTabSpecs(model()));
const activeTab = createMemo<KubernetesPageTabId>(() =>
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
);
const controllerResources = createMemo(() => getKubernetesControllerResources(model()));
return (
<div data-testid="kubernetes-page" class="space-y-3">
<PlatformSectionTabs
tabs={KUBERNETES_TAB_SPECS}
tabs={tabs()}
active={activeTab()}
ariaLabel="Kubernetes sections"
/>
@@ -153,7 +153,7 @@ describe('KubernetesPageSurface contract', () => {
vi.clearAllMocks();
});
it('declares workflow tabs while querying API-native Kubernetes resources', () => {
it('declares inventory-backed workflow tabs while querying API-native Kubernetes resources', () => {
setResources([
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
makeResource({ id: 'pod-1', type: 'pod' }),
@@ -185,7 +185,7 @@ describe('KubernetesPageSurface contract', () => {
);
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute(
'data-tabs',
'overview,nodes,workloads,services,storage,configuration,events',
'overview,workloads',
);
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'overview');
expect(screen.getByTestId('clusters-table')).toHaveAttribute('data-rows', '1');
@@ -194,6 +194,18 @@ describe('KubernetesPageSurface contract', () => {
expect(screen.queryByTestId('controllers-table')).toBeNull();
});
it('falls back to Overview when a requested Kubernetes workflow has no inventory', () => {
mockPathname.mockReturnValue('/kubernetes/services');
setResources([makeResource({ id: 'cluster-1', type: 'k8s-cluster' })]);
renderSurface();
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-tabs', 'overview');
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'overview');
expect(screen.getByTestId('clusters-table')).toHaveAttribute('data-rows', '1');
expect(screen.queryByTestId('services-table')).toBeNull();
});
it('groups workload API tables under the Workloads tab', () => {
mockPathname.mockReturnValue('/kubernetes/workloads');
setResources([
@@ -12,6 +12,7 @@ import {
compareKubernetesEvents,
compareKubernetesNodes,
compareKubernetesPods,
getKubernetesPageTabSpecs,
mapKubernetesControllerStatus,
mapKubernetesCronJobStatus,
mapKubernetesDaemonSetStatus,
@@ -64,6 +65,32 @@ describe('kubernetesPageModel', () => {
expect(resolveKubernetesPageTabId('unknown')).toBe('overview');
});
it('shows Kubernetes workflow tabs only when the workflow has inventory', () => {
const clusterOnlyModel = buildKubernetesPageModel([
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
]);
const workloadModel = buildKubernetesPageModel([
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
makeResource({ id: 'node-1', type: 'k8s-node' }),
makeResource({ id: 'pod-1', type: 'pod' }),
makeResource({ id: 'svc-1', type: 'k8s-service' }),
makeResource({ id: 'pvc-1', type: 'k8s-persistent-volume-claim' }),
makeResource({ id: 'cm-1', type: 'k8s-configmap' }),
makeResource({ id: 'event-1', type: 'k8s-event' }),
]);
expect(getKubernetesPageTabSpecs(clusterOnlyModel).map((tab) => tab.id)).toEqual(['overview']);
expect(getKubernetesPageTabSpecs(workloadModel).map((tab) => tab.id)).toEqual([
'overview',
'nodes',
'workloads',
'services',
'storage',
'configuration',
'events',
]);
});
it('buckets clusters, nodes, workloads, services, storage, config, policy, autoscaling, and events', () => {
const model = buildKubernetesPageModel([
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
@@ -566,6 +566,33 @@ export const resolveKubernetesPageTabId = (segment: string | undefined): Kuberne
return KUBERNETES_ROUTE_TAB_ALIASES[normalized] ?? 'overview';
};
const hasKubernetesTabInventory = (
model: KubernetesPageModel,
tab: KubernetesPageTabId,
): boolean => {
switch (tab) {
case 'overview':
return true;
case 'nodes':
return model.nodes.length > 0;
case 'workloads':
return model.workloads.length > 0 || model.autoscaling.length > 0;
case 'services':
return model.services.length > 0 || model.serviceNetworking.length > 0;
case 'storage':
return model.storage.length > 0;
case 'configuration':
return model.config.length > 0 || model.policy.length > 0;
case 'events':
return model.events.length > 0;
}
};
export const getKubernetesPageTabSpecs = (
model: KubernetesPageModel,
): readonly KubernetesTabSpec[] =>
KUBERNETES_TAB_SPECS.filter((tab) => hasKubernetesTabInventory(model, tab.id));
const KUBERNETES_RESOURCE_TYPES = new Set<ResourceType>([
'k8s-cluster',
'k8s-node',
@@ -20,6 +20,7 @@ import { TrueNASVirtualMachinesTable } from './TrueNASVirtualMachinesTable';
import {
TRUENAS_TAB_SPECS,
buildTrueNASPageModel,
getTrueNASPageTabSpecs,
type TrueNASPageModel,
type TrueNASPageTabId,
} from './truenasPageModel';
@@ -46,16 +47,34 @@ export function TrueNASPageSurface() {
cacheKey: 'truenas-workspace',
initialHydration: 'prefer-ws-then-rest',
});
const activeTab = createMemo<TrueNASPageTabId>(() => {
const requestedTab = createMemo<TrueNASPageTabId>(() => {
const segment = location.pathname.split('/').filter(Boolean)[1] as TrueNASPageTabId | undefined;
return segment && VALID_TABS.has(segment) ? segment : 'overview';
});
const model = createMemo(() => buildTrueNASPageModel(resources()));
const protection = useRecoveryPoints(() =>
model().resources.length > 0
? {
platform: TRUENAS_PLATFORM_FILTER,
page: 1,
limit: 200,
}
: null,
);
const hasProtectionInventory = createMemo(
() => protection.meta().total > 0 || protection.points().length > 0,
);
const tabs = createMemo(() =>
getTrueNASPageTabSpecs(model(), { hasProtectionInventory: hasProtectionInventory() }),
);
const activeTab = createMemo<TrueNASPageTabId>(() =>
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
);
return (
<div data-testid="truenas-page" class="space-y-3">
<PlatformSectionTabs
tabs={TRUENAS_TAB_SPECS}
tabs={tabs()}
active={activeTab()}
ariaLabel="TrueNAS sections"
/>
@@ -108,7 +127,7 @@ export function TrueNASPageSurface() {
<TrueNASShares model={model} />
</Show>
<Show when={activeTab() === 'protection'}>
<TrueNASProtection />
<TrueNASProtection recoveryPoints={protection} />
</Show>
</Show>
</Show>
@@ -180,19 +199,13 @@ function TrueNASShares(props: TrueNASOverviewProps) {
);
}
function TrueNASProtection() {
const recoveryPoints = useRecoveryPoints(() => ({
platform: TRUENAS_PLATFORM_FILTER,
page: 1,
limit: 200,
}));
function TrueNASProtection(props: { recoveryPoints: ReturnType<typeof useRecoveryPoints> }) {
return (
<TrueNASProtectionTable
points={recoveryPoints.points()}
loading={recoveryPoints.response.loading}
error={recoveryPoints.response.error}
onRefresh={() => void recoveryPoints.refetch()}
points={props.recoveryPoints.points()}
loading={props.recoveryPoints.response.loading}
error={props.recoveryPoints.response.error}
onRefresh={() => void props.recoveryPoints.refetch()}
emptyIcon={truenasIcon()}
emptyTitle="No TrueNAS protection activity"
emptyDescription="ZFS snapshots and replication tasks appear here once the TrueNAS API reports snapshot or replication activity."
@@ -15,6 +15,7 @@ import {
filterTrueNASStorageTopologyRows,
filterTrueNASShares,
filterTrueNASVMs,
getTrueNASPageTabSpecs,
mapTrueNASAppStatus,
mapTrueNASIncidentSeverity,
mapTrueNASProtectionKind,
@@ -60,6 +61,34 @@ describe('truenasPageModel', () => {
]);
});
it('shows TrueNAS workflow tabs only when their native facet has inventory', () => {
const systemOnlyModel = buildTrueNASPageModel([
makeResource({ id: 'truenas-system', type: 'agent' }),
]);
const inventoryModel = buildTrueNASPageModel([
makeResource({
id: 'truenas-system',
type: 'agent',
truenas: { services: [{ id: '1', service: 'smb', enabled: true, state: 'RUNNING' }] },
}),
makeResource({ id: 'truenas-vm', type: 'vm' }),
makeResource({ id: 'truenas-app', type: 'app-container' }),
makeResource({ id: 'truenas-share', type: 'network-share' }),
makeResource({
id: 'truenas-pool',
type: 'storage',
storage: { topology: 'pool', platform: 'truenas' },
}),
]);
expect(getTrueNASPageTabSpecs(systemOnlyModel).map((tab) => tab.id)).toEqual(['overview']);
expect(
getTrueNASPageTabSpecs(inventoryModel, { hasProtectionInventory: true }).map(
(tab) => tab.id,
),
).toEqual(['overview', 'storage', 'services', 'apps', 'vms', 'shares', 'protection']);
});
it('buckets systems, workloads, and native storage inventory by TrueNAS API facet', () => {
const model = buildTrueNASPageModel([
makeResource({
@@ -52,6 +52,10 @@ export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [
{ id: 'protection', label: 'Protection', path: '/truenas/protection' },
] as const;
export type TrueNASTabInventoryOptions = {
hasProtectionInventory?: boolean;
};
const TRUENAS_RESOURCE_TYPES = new Set<ResourceType>([
'agent',
'vm',
@@ -176,6 +180,38 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel {
};
}
const hasTrueNASStorageInventory = (model: TrueNASPageModel): boolean =>
model.pools.length > 0 || model.datasets.length > 0 || model.disks.length > 0;
const hasTrueNASTabInventory = (
model: TrueNASPageModel,
tab: TrueNASPageTabId,
options: TrueNASTabInventoryOptions = {},
): boolean => {
switch (tab) {
case 'overview':
return true;
case 'storage':
return hasTrueNASStorageInventory(model);
case 'services':
return model.services.length > 0;
case 'apps':
return model.apps.length > 0;
case 'vms':
return model.vms.length > 0;
case 'shares':
return model.shares.length > 0;
case 'protection':
return Boolean(options.hasProtectionInventory);
}
};
export const getTrueNASPageTabSpecs = (
model: TrueNASPageModel,
options: TrueNASTabInventoryOptions = {},
): readonly TrueNASTabSpec[] =>
TRUENAS_TAB_SPECS.filter((tab) => hasTrueNASTabInventory(model, tab.id, options));
const isTrueNASPoolResource = (resource: Resource): boolean =>
resource.type === 'pool' || resource.storage?.topology === 'pool';
@@ -28,6 +28,7 @@ import { VsphereHostsTable } from './VsphereHostsTable';
import {
VMWARE_TAB_SPECS,
buildVmwarePageModel,
getVmwarePageTabSpecs,
type VmwarePageModel,
type VmwarePageTabId,
} from './vmwarePageModel';
@@ -71,12 +72,12 @@ export function VmwarePageSurface() {
cacheKey: 'vmware-workspace',
initialHydration: 'prefer-ws-then-rest',
});
const activeTab = createMemo<VmwarePageTabId>(() => {
const requestedTab = createMemo<VmwarePageTabId>(() => {
const segment = location.pathname.split('/').filter(Boolean)[1] as VmwarePageTabId | undefined;
return segment && VALID_TABS.has(segment) ? segment : 'overview';
});
const [activityTimeline, { refetch: refetchActivityTimeline }] = createResource(
() => (activeTab() === 'activity' ? 'vmware-activity' : undefined),
() => (resources().length > 0 ? 'vmware-activity' : undefined),
async () => {
const response = await ResourceAPI.getGlobalTimeline({
limit: 100,
@@ -88,6 +89,10 @@ export function VmwarePageSurface() {
},
);
const model = createMemo(() => buildVmwarePageModel(resources(), activityTimeline() ?? []));
const tabs = createMemo(() => getVmwarePageTabSpecs(model()));
const activeTab = createMemo<VmwarePageTabId>(() =>
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
);
// Hosts table on top and the embedded WorkloadsSurface below share the
// bars/sparklines toggle (and the sparkline history range that ships with
@@ -114,7 +119,7 @@ export function VmwarePageSurface() {
return (
<div data-testid="vmware-page" class="space-y-3">
<PlatformSectionTabs
tabs={VMWARE_TAB_SPECS}
tabs={tabs()}
active={activeTab()}
ariaLabel="VMware sections"
/>
@@ -8,6 +8,7 @@ import {
filterVmwareIncidents,
filterVmwareNetworks,
filterVmwareVirtualMachines,
getVmwarePageTabSpecs,
mapVmwareActivityStateBucket,
mapVmwareDatastoreStatus,
mapVmwareIncidentSeverity,
@@ -44,6 +45,62 @@ describe('vmwarePageModel', () => {
]);
});
it('shows vSphere workflow tabs only when their inventory or signal exists', () => {
const hostOnlyModel = buildVmwarePageModel([makeResource({ id: 'esxi-host-1', type: 'agent' })]);
const fullModel = buildVmwarePageModel(
[
makeResource({ id: 'esxi-host-1', type: 'agent' }),
makeResource({
id: 'datastore-1',
type: 'storage',
storage: { topology: 'datastore', platform: 'vmware-vsphere' },
vmware: { entityType: 'datastore' },
}),
makeResource({
id: 'network-1',
type: 'network',
vmware: { entityType: 'network', networkType: 'STANDARD_PORTGROUP' },
}),
makeResource({
id: 'alarm-1',
type: 'vm',
incidents: [
{
code: 'alarm-1',
severity: 'warning',
summary: 'Triggered alarm',
startedAt: '2026-05-20T00:00:00Z',
},
],
}),
],
[
{
id: 'task-1',
resourceId: 'esxi-host-1',
observedAt: '2026-05-20T00:00:00Z',
sourceType: 'platform_event',
sourceAdapter: 'vmware_adapter',
confidence: 'high',
kind: 'activity',
metadata: {
activity_title: 'vCenter task',
activity_message: 'Task completed',
},
},
],
);
expect(getVmwarePageTabSpecs(hostOnlyModel).map((tab) => tab.id)).toEqual(['overview']);
expect(getVmwarePageTabSpecs(fullModel).map((tab) => tab.id)).toEqual([
'overview',
'storage',
'networks',
'health',
'activity',
]);
});
it('buckets canonical vSphere hosts, VMs, datastores, and networks', () => {
const model = buildVmwarePageModel([
makeResource({ id: 'esxi-host-1', type: 'agent' }),
@@ -135,6 +135,24 @@ export function buildVmwarePageModel(
};
}
const hasVmwareTabInventory = (model: VmwarePageModel, tab: VmwarePageTabId): boolean => {
switch (tab) {
case 'overview':
return true;
case 'storage':
return model.datastores.length > 0;
case 'networks':
return model.networks.length > 0;
case 'health':
return model.incidents.length > 0;
case 'activity':
return model.activity.length > 0;
}
};
export const getVmwarePageTabSpecs = (model: VmwarePageModel): readonly VmwareTabSpec[] =>
VMWARE_TAB_SPECS.filter((tab) => hasVmwareTabInventory(model, tab.id));
const normalize = (value: unknown): string =>
typeof value === 'string' ? value.trim().toLowerCase() : '';