truenas: bespoke Systems + Disks tables; project uptime/temp on the system row

Audit pass on the TrueNAS pages turned up three concrete issues:

1. **TrueNAS system Uptime/Temperature were dashes** even though the
   data lives on `truenas.uptimeSeconds` and on the
   `agent.temperature` max-sensor projection. The system row is
   emitted by `internal/truenas/provider.go::truenasRecordsFromSnapshot`,
   which is a separate path from the host/node adapters that the
   prior commit (c7bdd11e0) updated. Project the same top-level
   `Resource.Uptime` and `Resource.Temperature` here so the canonical
   table renders real values (live mock now reports `uptime: 3628800,
   temperature: 61.5` on the TrueNAS appliance row).

2. **The Systems tab needed NAS-native columns.** A TrueNAS appliance
   is a storage box — operators want at-a-glance pool / dataset /
   disk / app counts on the same row as CPU / Memory / Storage /
   Temperature / Uptime. New `TrueNASSystemsTable` reuses canonical
   shared primitives (Card, Table, SearchInput, FilterButtonGroup,
   StatusDot) and computes per-system child counts client-side from
   the same TrueNAS scope already fetched by the page.

3. **Physical disks weren't surfaced anywhere on the page**, even
   though the canonical TrueNAS adapter emits SMART-instrumented
   `physical_disk` rows with model / serial / size / health /
   temperature / wearout. Add a `disks` sub-tab (between Storage and
   Apps) backed by a new `TrueNASDisksTable` that surfaces those
   columns — generic Disk I/O / Uptime / Temperature from the infra
   table aren't meaningful for individual disks, but disk-native
   columns (model, type, size, health, temp, wearout, serial) are.

Browser verification (Playwright, chromium, live mock-mode dev runtime):
- 9 tests pass. The every-sub-tab operator-controls audit now covers
  /truenas/disks too; the search input on every TrueNAS sub-tab is
  the canonical SearchInput primitive.

Targeted vitest: `src/features/truenas/__tests__/truenasPageModel.test.ts`
(2 tests) green — covers the four-tab spec and the new `disks`
bucket on the page model.

Targeted Go: `go test ./internal/truenas/...` green.

Contract-neutral bypass: PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT set.
This completes the platform-page column-fit audit for TrueNAS. New
top-level `Resource.Uptime` / `Resource.Temperature` projection is
strictly additive (already documented as the adapter responsibility
in unified-resources.md by c7bdd11e0). The two new bespoke tables
live inside features/truenas/ and reuse canonical primitives only.
This commit is contained in:
rcourtman 2026-05-16 12:51:36 +01:00
parent c7bdd11e04
commit 5b94724bf2
7 changed files with 480 additions and 16 deletions

View file

@ -0,0 +1,201 @@
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
import { SearchInput } from '@/components/shared/SearchInput';
import { StatusDot } from '@/components/shared/StatusDot';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { asTrimmedString } from '@/utils/stringUtils';
import {
filterPlatformResources,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
// TrueNAS physical disks are SMART-instrumented storage devices, not
// compute hosts. The canonical infrastructure table's CPU / Memory /
// Disk I/O / Uptime columns are conceptually N/A; the operator columns
// are model, serial, type, size, health, temperature, and wearout.
// This bespoke table surfaces those from the canonical `physicalDisk`
// payload already attached to each row.
const STATUS_FILTER_OPTIONS: FilterOption<PlatformResourceStatusFilter>[] = [
{ value: 'all', label: 'All' },
{ value: 'online', label: 'Healthy' },
{ value: 'degraded', label: 'Degraded' },
{ value: 'offline', label: 'Offline' },
];
const formatBytes = (bytes: number | undefined): string => {
if (!bytes || bytes <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let value = bytes;
let unitIdx = 0;
while (value >= 1024 && unitIdx < units.length - 1) {
value /= 1024;
unitIdx += 1;
}
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIdx]}`;
};
const formatTemperature = (celsius: number | undefined): JSX.Element => {
if (typeof celsius !== 'number' || celsius <= 0) return <span class="text-muted"></span>;
return <span class="tabular-nums">{celsius.toFixed(1)}°C</span>;
};
const formatWearout = (wearout: number | undefined): JSX.Element => {
if (typeof wearout !== 'number' || wearout < 0) return <span class="text-muted"></span>;
return <span class="tabular-nums">{wearout.toFixed(0)}%</span>;
};
const healthVariant = (
health: string | undefined,
): 'success' | 'warning' | 'danger' | 'muted' => {
const normalized = (health || '').trim().toLowerCase();
if (normalized === 'healthy' || normalized === 'passed' || normalized === 'pass') return 'success';
if (normalized === 'warning' || normalized === 'degraded') return 'warning';
if (normalized === 'failed' || normalized === 'fail' || normalized === 'critical') return 'danger';
return 'muted';
};
export const TrueNASDisksTable: Component<{
resources: Resource[];
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
}> = (props) => {
const [search, setSearch] = createSignal('');
const [status, setStatus] = createSignal<PlatformResourceStatusFilter>('all');
const filtered = createMemo(() => filterPlatformResources(props.resources, search(), status()));
const visible = createMemo(() => filtered().length);
const total = createMemo(() => props.resources.length);
return (
<Show
when={props.resources.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
</Card>
}
>
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-[200px] flex-1 sm:max-w-xs">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search disks"
/>
</div>
<FilterButtonGroup
options={STATUS_FILTER_OPTIONS}
value={status()}
onChange={setStatus}
/>
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
<Show when={visible() !== total()} fallback={<>{total()} disks</>}>
{visible()} of {total()} disks
</Show>
</span>
</div>
<Show
when={filtered().length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title="No disks match current filters"
description="Adjust the search or status filter to see more disks."
/>
</Card>
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="w-full min-w-[880px] border-collapse text-xs">
<TableHeader class="bg-surface-alt text-muted border-b border-border">
<TableRow class="text-left text-[10px] uppercase tracking-wide">
<TableHead class="px-3 py-2 font-medium">Disk</TableHead>
<TableHead class="px-3 py-2 font-medium">Model</TableHead>
<TableHead class="px-3 py-2 font-medium">Type</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Size</TableHead>
<TableHead class="px-3 py-2 font-medium">Health</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Temp</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Wearout</TableHead>
<TableHead class="px-3 py-2 font-medium">Serial</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={filtered()}>
{(disk) => {
const meta = () => disk.physicalDisk;
const name = () => asTrimmedString(disk.name) || disk.id;
const model = () => asTrimmedString(meta()?.model) || '—';
const type = () => asTrimmedString(meta()?.diskType) || '—';
const health = () => asTrimmedString(meta()?.health) || '—';
const serial = () => asTrimmedString(meta()?.serial) || '—';
return (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2">
<span class="font-semibold text-base-content truncate" title={name()}>
{name()}
</span>
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<span class="truncate inline-block max-w-[14rem]" title={model()}>
{model()}
</span>
</TableCell>
<TableCell class="px-3 py-2 text-base-content">{type()}</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{formatBytes(meta()?.sizeBytes)}
</TableCell>
<TableCell class="px-3 py-2">
<div class="flex items-center gap-2">
<StatusDot
size="sm"
variant={healthVariant(meta()?.health)}
title={meta()?.health || 'unknown'}
ariaHidden
/>
<span class="text-base-content">{health()}</span>
</div>
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{formatTemperature(meta()?.temperature)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{formatWearout(meta()?.wearout)}
</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
<span class="truncate inline-block max-w-[10rem]" title={serial()}>
{serial()}
</span>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</Card>
</Show>
</div>
</Show>
);
};
export default TrueNASDisksTable;

View file

@ -6,10 +6,11 @@ import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import {
PlatformErrorState,
PlatformResourceTable,
PlatformSectionTabs,
PlatformTableEmptyState,
} from '@/features/platformPage/sharedPlatformPage';
import { TrueNASDisksTable } from './TrueNASDisksTable';
import { TrueNASSystemsTable } from './TrueNASSystemsTable';
import {
TRUENAS_TAB_SPECS,
buildTrueNASPageModel,
@ -75,8 +76,9 @@ export function TrueNASPageSurface() {
}
>
<Show when={activeTab() === 'overview'}>
<PlatformResourceTable
resources={model().systems}
<TrueNASSystemsTable
systems={model().systems}
scope={model().resources}
emptyIcon={truenasIcon()}
emptyTitle="No TrueNAS systems"
emptyDescription="TrueNAS systems appear here once a TrueNAS connection reports its top-level appliance."
@ -90,6 +92,14 @@ export function TrueNASPageSurface() {
forcedSourceFilter={TRUENAS_PLATFORM_FILTER}
/>
</Show>
<Show when={activeTab() === 'disks'}>
<TrueNASDisksTable
resources={model().disks}
emptyIcon={truenasIcon()}
emptyTitle="No TrueNAS disks reported"
emptyDescription="Physical disks appear here once a TrueNAS connection enumerates them."
/>
</Show>
<Show when={activeTab() === 'apps'}>
<WorkloadsSurface
vms={[]}

View file

@ -0,0 +1,234 @@
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
import { SearchInput } from '@/components/shared/SearchInput';
import { StatusDot } from '@/components/shared/StatusDot';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
filterPlatformResources,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
// TrueNAS systems are storage appliances, not generic compute hosts.
// The generic infrastructure table's CPU / Memory / Disk columns are
// helpful (the agent payload carries them), but operators also want at-
// a-glance pool count, dataset count, app count, uptime, version, and
// max-sensor temperature on the same row. This bespoke table reuses
// canonical shared primitives (Card, Table, SearchInput,
// FilterButtonGroup, StatusDot) and counts the per-system children
// client-side from the same TrueNAS resource scope already fetched by
// the page (no extra API calls).
const STATUS_FILTER_OPTIONS: FilterOption<PlatformResourceStatusFilter>[] = [
{ value: 'all', label: 'All' },
{ value: 'online', label: 'Healthy' },
{ value: 'degraded', label: 'Degraded' },
{ value: 'offline', label: 'Offline' },
];
const formatUptime = (seconds: number | undefined): string => {
if (!seconds || seconds <= 0) return '—';
const days = Math.floor(seconds / 86_400);
if (days > 0) return `${days}d`;
const hours = Math.floor(seconds / 3_600);
if (hours > 0) return `${hours}h`;
const mins = Math.floor(seconds / 60);
return `${mins}m`;
};
const formatBytes = (bytes: number | undefined): string => {
if (!bytes || bytes <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let value = bytes;
let unitIdx = 0;
while (value >= 1024 && unitIdx < units.length - 1) {
value /= 1024;
unitIdx += 1;
}
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIdx]}`;
};
const formatPercent = (percent?: number): JSX.Element => {
if (typeof percent !== 'number' || Number.isNaN(percent)) return <span class="text-muted"></span>;
return <span class="tabular-nums">{percent.toFixed(1)}%</span>;
};
const formatTemperature = (celsius: number | undefined): JSX.Element => {
if (typeof celsius !== 'number' || celsius <= 0) return <span class="text-muted"></span>;
return <span class="tabular-nums">{celsius.toFixed(1)}°C</span>;
};
export const TrueNASSystemsTable: Component<{
systems: Resource[];
// Full TrueNAS resource scope so we can count pools / datasets / apps
// per system without spawning additional fetches.
scope: Resource[];
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
}> = (props) => {
const [search, setSearch] = createSignal('');
const [status, setStatus] = createSignal<PlatformResourceStatusFilter>('all');
const filtered = createMemo(() => filterPlatformResources(props.systems, search(), status()));
const visible = createMemo(() => filtered().length);
const total = createMemo(() => props.systems.length);
// Single-system canonical mock is the common case today; counts span
// the full TrueNAS resource scope per row. When multi-system support
// arrives, the canonical adapter should attach a parent system id to
// child resources and we can filter by it.
const counts = createMemo(() => {
const pools = props.scope.filter(
(r) => r.type === 'pool' || (r.type === 'storage' && r.storage?.topology === 'pool'),
).length;
const datasets = props.scope.filter(
(r) => r.type === 'dataset' || (r.type === 'storage' && r.storage?.topology === 'dataset'),
).length;
const apps = props.scope.filter((r) => r.type === 'app-container').length;
const disks = props.scope.filter((r) => r.type === 'physical_disk').length;
return { pools, datasets, apps, disks };
});
return (
<Show
when={props.systems.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
</Card>
}
>
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-[200px] flex-1 sm:max-w-xs">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search TrueNAS systems"
/>
</div>
<FilterButtonGroup
options={STATUS_FILTER_OPTIONS}
value={status()}
onChange={setStatus}
/>
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
<Show when={visible() !== total()} fallback={<>{total()} systems</>}>
{visible()} of {total()} systems
</Show>
</span>
</div>
<Show
when={filtered().length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title="No systems match current filters"
description="Adjust the search or status filter to see more systems."
/>
</Card>
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="w-full min-w-[960px] border-collapse text-xs">
<TableHeader class="bg-surface-alt text-muted border-b border-border">
<TableRow class="text-left text-[10px] uppercase tracking-wide">
<TableHead class="px-3 py-2 font-medium">System</TableHead>
<TableHead class="px-3 py-2 font-medium">Version</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Uptime</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">CPU</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Memory</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Storage</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Temp</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Pools</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Datasets</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Disks</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Apps</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={filtered()}>
{(system) => {
const name = () => asTrimmedString(system.name) || system.id;
const version = () => asTrimmedString(system.agent?.osVersion) || '—';
const indicator = () => getSimpleStatusIndicator(system.status);
const c = counts();
return (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
size="sm"
variant={indicator().variant}
title={system.status || 'unknown'}
ariaHidden
/>
<span class="font-semibold text-base-content truncate" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
{version()}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{formatUptime(system.uptime)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{formatPercent(system.cpu?.current)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{formatPercent(system.memory?.current)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{typeof system.disk?.used === 'number' && typeof system.disk?.total === 'number'
? `${formatBytes(system.disk.used)} / ${formatBytes(system.disk.total)}`
: formatPercent(system.disk?.current)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{formatTemperature(system.temperature)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
{c.pools}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
{c.datasets}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
{c.disks}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
{c.apps}
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</Card>
</Show>
</div>
</Show>
);
};
export default TrueNASSystemsTable;

View file

@ -14,11 +14,16 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
});
describe('truenasPageModel', () => {
it('declares the TrueNAS section set with Systems, Storage, and Apps', () => {
expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual(['overview', 'storage', 'apps']);
it('declares the TrueNAS section set with Systems, Storage, Disks, and Apps', () => {
expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual([
'overview',
'storage',
'disks',
'apps',
]);
});
it('buckets systems, apps, storage, and disks while ignoring non-TrueNAS resources', () => {
it('buckets systems, disks, apps, storage while ignoring non-TrueNAS resources', () => {
const model = buildTrueNASPageModel([
makeResource({ id: 'truenas-system', type: 'agent' }),
makeResource({ id: 'truenas-app', type: 'app-container' }),
@ -29,6 +34,7 @@ describe('truenasPageModel', () => {
]);
expect(model.systems.map((r) => r.id)).toEqual(['truenas-system']);
expect(model.disks.map((r) => r.id)).toEqual(['truenas-disk']);
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', 'truenas-system'].sort(),

View file

@ -1,7 +1,7 @@
import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
import type { Resource, ResourceType } from '@/types/resource';
export type TrueNASPageTabId = 'overview' | 'storage' | 'apps';
export type TrueNASPageTabId = 'overview' | 'storage' | 'disks' | 'apps';
export type TrueNASTabSpec = {
id: TrueNASPageTabId;
@ -12,6 +12,7 @@ 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: 'disks', label: 'Disks', path: '/truenas/disks' },
{ id: 'apps', label: 'Apps', path: '/truenas/apps' },
] as const;
@ -30,6 +31,7 @@ const isTrueNASPlatform = (resource: Resource): boolean =>
export type TrueNASPageModel = {
resources: Resource[];
systems: Resource[];
disks: Resource[];
apps: Resource[];
};
@ -40,10 +42,12 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel {
const systems = trueNasResources.filter((resource) => resource.type === 'agent');
const apps = trueNasResources.filter((resource) => resource.type === 'app-container');
const disks = trueNasResources.filter((resource) => resource.type === 'physical_disk');
return {
resources: trueNasResources,
systems,
disks,
apps,
};
}

View file

@ -460,16 +460,19 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, now func() time.Time)
records := make([]unifiedresources.IngestRecord, 0, 1+len(snapshot.Pools)+len(snapshot.Datasets)+len(snapshot.Apps)+len(snapshot.Disks))
totalCapacity, totalUsed := aggregatePoolUsage(snapshot.Pools)
systemAgent := agentDataFromTrueNASSystem(snapshot.System, systemRisk, protectionReduced, protectionSummary, rebuildInProgress, rebuildSummary)
records = append(records, unifiedresources.IngestRecord{
SourceID: systemSourceID,
Resource: unifiedresources.Resource{
Type: unifiedresources.ResourceTypeAgent,
Name: strings.TrimSpace(snapshot.System.Hostname),
Status: systemStatus(snapshot.System, systemRisk, systemIncidents),
LastSeen: collectedAt,
UpdatedAt: collectedAt,
Metrics: metricsFromTrueNASSystem(snapshot.System, totalCapacity, totalUsed),
Agent: agentDataFromTrueNASSystem(snapshot.System, systemRisk, protectionReduced, protectionSummary, rebuildInProgress, rebuildSummary),
Type: unifiedresources.ResourceTypeAgent,
Name: strings.TrimSpace(snapshot.System.Hostname),
Status: systemStatus(snapshot.System, systemRisk, systemIncidents),
LastSeen: collectedAt,
UpdatedAt: collectedAt,
Metrics: metricsFromTrueNASSystem(snapshot.System, totalCapacity, totalUsed),
Uptime: snapshot.System.UptimeSeconds,
Temperature: systemAgent.Temperature,
Agent: systemAgent,
TrueNAS: &unifiedresources.TrueNASData{
Hostname: strings.TrimSpace(snapshot.System.Hostname),
Version: snapshot.System.Version,

View file

@ -82,8 +82,13 @@ const PLATFORM_PAGES: readonly PlatformPageCase[] = [
rootPath: '/truenas',
testId: 'truenas-page',
ariaLabel: 'TrueNAS sections',
tabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/apps'],
populatedTabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/apps'],
tabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/disks', '/truenas/apps'],
populatedTabPaths: [
'/truenas/overview',
'/truenas/storage',
'/truenas/disks',
'/truenas/apps',
],
},
{
id: 'vmware',
@ -181,6 +186,7 @@ test.describe('Platform pages shell', () => {
{ path: '/kubernetes/deployments', testId: 'kubernetes-page' },
{ path: '/truenas/overview', testId: 'truenas-page' },
{ path: '/truenas/storage', testId: 'truenas-page' },
{ path: '/truenas/disks', testId: 'truenas-page' },
{ path: '/truenas/apps', testId: 'truenas-page' },
{ path: '/vmware/overview', testId: 'vmware-page' },
{ path: '/vmware/vms', testId: 'vmware-page' },