Adopt shared platform-table sorting on TrueNAS, vSphere, and Proxmox nodes tables

v5 parity follow-up to 20cccc1a9, completing the adoption sweep after the
Kubernetes pass (f66c4b46b). Adopts createPlatformTableSortState +
PlatformSortableTableHead across the TrueNAS systems / apps / services /
network shares / protection / storage topology / VMs tables and the vSphere
hosts / datastores / networks / activity tables: per-table storage keys,
descendingFirst on metric, count, and timestamp columns, the built-in order
preserved until the user sorts, and canonical kind-based alignment via the
sortable head. Composite summary columns (inventory, ports, images, access,
clients, devices, flags, hosts, consumers) stay non-sortable.

The TrueNAS storage topology sorts sibling groups and re-emits the tree
depth-first, so pools re-order against pools and every dataset or disk stays
under its parent; a new sort test pins that plus descending-first metrics and
persistence.

ProxmoxNodesTable drops its bespoke non-persisted createSignal sort for the
same fabric, so the nodes sort now survives reloads like every other platform
table. Its existing sort assertions pass unchanged; the suite now clears
localStorage between tests since the sort persists by design.

Also repairs the platformOverviewLayout guardrails at HEAD: f66c4b46b landed
with the TrueNAS/vSphere head assertions already flipped to the kind form and
the Kubernetes assertions still on the legacy form (a staging race between
parallel agents); this commit carries the version with both sets on the kind
form, which is green against the migrated tables.
This commit is contained in:
rcourtman
2026-07-11 00:15:42 +01:00
parent f66c4b46b3
commit cf0f027b64
15 changed files with 1308 additions and 315 deletions
@@ -397,15 +397,9 @@ describe('platform overview layout guardrails', () => {
expect(dockerHostsTableSource).toMatch(/kind="metric-bar"[\s\S]{0,300}?Memory/);
expect(dockerHostsTableSource).toMatch(/kind="metric-bar"[\s\S]{0,200}?Disk/);
expect(kubernetesClustersTableSource).toMatch(
/getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?Cluster/,
);
expect(kubernetesClustersTableSource).toMatch(
/getPlatformTableHeadClassForKind\('numeric-value'\)[\s\S]{0,200}?Nodes/,
);
expect(kubernetesNodesTableSource).toMatch(
/getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?Node/,
);
expect(kubernetesClustersTableSource).toMatch(/kind="name"[\s\S]{0,200}?Cluster/);
expect(kubernetesClustersTableSource).toMatch(/kind="numeric-value"[\s\S]{0,200}?Nodes/);
expect(kubernetesNodesTableSource).toMatch(/kind="name"[\s\S]{0,200}?Node/);
expect(kubernetesNodesTableSource).toContain(
'<span class="md:hidden">{compactCapacityLabel()}</span>',
);
@@ -22,7 +22,7 @@ import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline'
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getNodeExternalUrl } from '@/utils/nodes';
import { asTrimmedString } from '@/utils/stringUtils';
@@ -31,14 +31,16 @@ import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import { useWorkloadTableMetricHistory } from '@/components/Workloads/useWorkloadTableMetricHistory';
import { getWorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel';
import {
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableMetricFallback,
PlatformTableShell,
createPlatformTableSortState,
formatPlatformTablePercentValue,
formatPlatformTableUptimeValue,
getPlatformTableFiniteMetric,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformTableSortValue,
} from '@/features/platformPage/sharedPlatformPage';
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
import { type WorkloadsMetricDisplayMode } from '@/components/Workloads/workloadsFilterModel';
@@ -125,15 +127,30 @@ const projectResourceToLegacyNode = (resource: Resource): LegacyNode => {
return projected as unknown as LegacyNode;
};
type HostSortKey = ProxmoxHostTableColumnId;
// Every host column carries a scalar to order on, so the sortable-key list is
// the full column-id list. Metric and count columns read "biggest on top", so
// their first click sorts descending; the identity/context text columns start
// ascending.
const PROXMOX_HOST_SORT_KEYS = [
'node',
'version',
'uptime',
'cpu',
'memory',
'disk',
'temp',
'vms',
'cts',
'cluster',
] as const satisfies readonly ProxmoxHostTableColumnId[];
const TEXT_SORT_KEYS = new Set<ProxmoxHostTableColumnId>(['node', 'version', 'cluster']);
type HostSortKey = (typeof PROXMOX_HOST_SORT_KEYS)[number];
const getHostSortValue = (
node: Resource,
guests: Resource[],
key: HostSortKey,
): string | number | null => {
): PlatformTableSortValue => {
switch (key) {
case 'node':
return asTrimmedString(node.name) || node.id;
@@ -164,19 +181,6 @@ const getHostSortValue = (
}
};
const isEmptyHostSortValue = (value: string | number | null): boolean =>
value === null || (typeof value === 'number' && Number.isNaN(value));
const compareHostSortValues = (a: string | number | null, b: string | number | null): number => {
const aEmpty = isEmptyHostSortValue(a);
const bEmpty = isEmptyHostSortValue(b);
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
if (typeof a === 'number' && typeof b === 'number') return a === b ? 0 : a < b ? -1 : 1;
return String(a).localeCompare(String(b), undefined, { sensitivity: 'base' });
};
export const ProxmoxNodesTable: Component<{
nodes: Resource[];
guests: Resource[];
@@ -196,40 +200,14 @@ export const ProxmoxNodesTable: Component<{
const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id));
const displayMode = () => props.metricDisplayMode?.() ?? 'bars';
const isSparklineMode = () => displayMode() === 'sparklines';
const [sortKey, setSortKey] = createSignal<HostSortKey | null>(null);
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const handleSort = (column: ProxmoxHostTableColumnId) => {
const key = column as HostSortKey;
const defaultDirection = TEXT_SORT_KEYS.has(key) ? 'asc' : 'desc';
// Cycle: default direction → flipped → cleared.
if (sortKey() === key) {
if (sortDirection() === defaultDirection) {
setSortDirection(defaultDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(null);
setSortDirection('asc');
}
return;
}
setSortKey(key);
setSortDirection(defaultDirection);
};
const sortedNodes = createMemo(() => {
const key = sortKey();
if (!key) return props.nodes;
const dir = sortDirection() === 'asc' ? 1 : -1;
const decorated = props.nodes.map(
(node) => [getHostSortValue(node, props.guests, key), node] as const,
);
decorated.sort(([a], [b]) => {
// Missing values stay last in either direction.
if (isEmptyHostSortValue(a) || isEmptyHostSortValue(b)) return compareHostSortValues(a, b);
return compareHostSortValues(a, b) * dir;
});
return decorated.map(([, node]) => node);
const sort = createPlatformTableSortState({
storageKey: 'proxmoxNodes',
sortKeys: PROXMOX_HOST_SORT_KEYS,
descendingFirst: ['uptime', 'cpu', 'memory', 'disk', 'temp', 'vms', 'cts'],
});
const sortedNodes = createMemo(() =>
sort.sortRows(props.nodes, (node, key) => getHostSortValue(node, props.guests, key)),
);
// Use the same canonical history reader the workloads table uses; cache
// keys collide so the two readers dedupe their fetches.
@@ -271,20 +249,9 @@ export const ProxmoxNodesTable: Component<{
header={
<For each={visibleColumns()}>
{(column) => (
<TableHead
class={`${getPlatformTableHeadClassForKind(column.kind)} cursor-pointer hover:bg-surface-hover`}
aria-sort={
sortKey() === column.id
? sortDirection() === 'asc'
? 'ascending'
: 'descending'
: undefined
}
onClick={() => handleSort(column.id)}
>
<PlatformSortableTableHead kind={column.kind} sort={sort} sortKey={column.id}>
{column.label}
{sortKey() === column.id && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</TableHead>
</PlatformSortableTableHead>
)}
</For>
}
@@ -92,6 +92,9 @@ beforeEach(() => {
});
afterEach(() => {
// The header sort persists per table via localStorage; clear it so one
// test's sort choice cannot leak into the next render.
window.localStorage.clear();
cleanup();
});
@@ -2,21 +2,23 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getShortImageName } from '@/utils/format';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
PlatformSortableTableHead,
PlatformTableMetricFallback,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
formatPlatformTableTitleCaseValue,
getPlatformTableFiniteMetric,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformTableFilterOption,
type PlatformTableSortValue,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import {
@@ -121,6 +123,56 @@ const UpdatePills: Component<{ app: ResourceTrueNASAppMeta | undefined }> = (pro
);
};
// Columns a user can sort by. Ports and Images summarize several values at
// once, so they carry no single scalar to order on. Updates orders on how
// many update kinds (app / image) are pending so outdated apps surface first.
const TRUENAS_APP_SORT_KEYS = [
'app',
'version',
'cpu',
'memory',
'containers',
'updates',
] as const;
type TrueNASAppSortKey = (typeof TRUENAS_APP_SORT_KEYS)[number];
const getTrueNASAppSortValue = (
resource: Resource,
key: TrueNASAppSortKey,
): PlatformTableSortValue => {
const app = appMeta(resource);
switch (key) {
case 'app':
return (
asTrimmedString(app?.name) ||
asTrimmedString(resource.displayName) ||
asTrimmedString(resource.name) ||
resource.id
);
case 'version': {
const version = appVersionLabel(app);
return version === '-' ? null : version;
}
case 'cpu':
return getPlatformTableFiniteMetric(resource.cpu?.current) ?? null;
case 'memory': {
const total = getPlatformTableFiniteMetric(resource.memory?.total) ?? 0;
if (total > 0) {
return ((getPlatformTableFiniteMetric(resource.memory?.used) ?? 0) / total) * 100;
}
return getPlatformTableFiniteMetric(resource.memory?.current) ?? null;
}
case 'containers':
return appContainerCount(app);
case 'updates':
return (app?.upgradeAvailable === true ? 1 : 0) + (app?.imageUpdatesAvailable === true ? 1 : 0);
default:
key satisfies never;
return null;
}
};
export const TrueNASAppsTable: Component<{
apps: Resource[];
scope: Resource[];
@@ -136,6 +188,12 @@ export const TrueNASAppsTable: Component<{
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-app-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
const sort = createPlatformTableSortState({
storageKey: 'truenasApps',
sortKeys: TRUENAS_APP_SORT_KEYS,
descendingFirst: ['cpu', 'memory', 'containers', 'updates'],
});
const sortedRows = createMemo(() => sort.sortRows(tableState.filtered(), getTrueNASAppSortValue));
return (
<Show
@@ -178,43 +236,60 @@ export const TrueNASAppsTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[17%]`}>
<PlatformSortableTableHead kind="name" sort={sort} sortKey="app" class="md:w-[17%]">
App
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="version"
class="hidden md:table-cell md:w-[10%]"
>
Version
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[9%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="cpu"
class="md:w-[9%]"
>
CPU
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[12%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="memory"
class="md:w-[12%]"
>
Memory
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden sm:table-cell md:w-[12%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="containers"
class="hidden sm:table-cell md:w-[12%]"
>
Containers
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[18%]">
Ports
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[12%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell md:w-[12%]">
Images
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[10%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="badge"
sort={sort}
sortKey="updates"
class="md:w-[10%]"
>
Updates
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(resource) => {
const app = () => appMeta(resource);
const name = () =>
@@ -1,17 +1,19 @@
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
formatPlatformTableTitleCaseValue,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
summarizePlatformTableValues,
type PlatformTableFilterOption,
type PlatformTableSortValue,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import {
@@ -88,6 +90,34 @@ const shareName = (resource: Resource, share: ResourceTrueNASShareMeta | undefin
asTrimmedString(resource.name) ||
resource.id;
// Columns a user can sort by. Access and Clients summarize several flags or
// endpoints at once, so they carry no single scalar to order on.
const TRUENAS_SHARE_SORT_KEYS = ['share', 'protocol', 'path', 'state'] as const;
type TrueNASShareSortKey = (typeof TRUENAS_SHARE_SORT_KEYS)[number];
const getTrueNASShareSortValue = (
resource: Resource,
key: TrueNASShareSortKey,
): PlatformTableSortValue => {
const share = shareMeta(resource);
switch (key) {
case 'share':
return shareName(resource, share);
case 'protocol': {
const protocol = formatProtocol(share);
return protocol === '-' ? null : protocol;
}
case 'path':
return asTrimmedString(share?.path) || null;
case 'state':
return formatPlatformTableTitleCaseValue(mapTrueNASShareStatus(resource));
default:
key satisfies never;
return null;
}
};
export const TrueNASNetworkSharesTable: Component<{
shares: Resource[];
scope: Resource[];
@@ -103,6 +133,13 @@ export const TrueNASNetworkSharesTable: Component<{
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-share-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
const sort = createPlatformTableSortState({
storageKey: 'truenasShares',
sortKeys: TRUENAS_SHARE_SORT_KEYS,
});
const sortedRows = createMemo(() =>
sort.sortRows(tableState.filtered(), getTrueNASShareSortValue),
);
return (
<Show
@@ -145,33 +182,49 @@ export const TrueNASNetworkSharesTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[23%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="share"
class="md:w-[23%]"
>
Share
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[11%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="protocol"
class="md:w-[11%]"
>
Protocol
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[27%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="path"
class="hidden md:table-cell md:w-[27%]"
>
Path
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[19%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="md:w-[19%]">
Access
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[13%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell md:w-[13%]">
Clients
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[7%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="badge"
sort={sort}
sortKey="state"
class="md:w-[7%]"
>
State
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(resource) => {
const share = () => shareMeta(resource);
const name = () => shareName(resource, share());
@@ -2,7 +2,7 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { Button } from '@/components/shared/Button';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { PlatformAttentionSummary } from '@/features/platformPage/PlatformAttentionSummary';
import {
getRecoveryOutcomeBadgeClass,
@@ -13,15 +13,18 @@ import { asTrimmedString } from '@/utils/stringUtils';
import type { StatusIndicatorVariant } from '@/utils/status';
import {
PlatformErrorState,
PlatformSortableTableHead,
PlatformTableDateTimeValue,
PlatformTableEmptyState,
PlatformTableLoadingState,
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
formatPlatformTableBytesValue,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
getPlatformTableDateTimeSortValue,
type PlatformTableFilterOption,
type PlatformTableSortValue,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import {
@@ -167,6 +170,49 @@ const targetSecondaryLabel = (point: RecoveryPoint): string => {
const sizeLabel = (point: RecoveryPoint): string =>
formatPlatformTableBytesValue(point.sizeBytes ?? undefined, '-');
// Columns a user can sort by. Every protection column carries a scalar:
// Completed orders chronologically and Size on raw bytes, both newest/biggest
// first on the first click.
const TRUENAS_PROTECTION_SORT_KEYS = [
'dataset',
'artifact',
'target',
'completed',
'size',
'outcome',
] as const;
type TrueNASProtectionSortKey = (typeof TRUENAS_PROTECTION_SORT_KEYS)[number];
const getTrueNASProtectionSortValue = (
point: RecoveryPoint,
key: TrueNASProtectionSortKey,
): PlatformTableSortValue => {
switch (key) {
case 'dataset':
return datasetLabel(point);
case 'artifact':
return artifactLabel(point);
case 'target': {
const target = targetLabel(point);
return target === '-' ? null : target;
}
case 'completed':
return getPlatformTableDateTimeSortValue(
asTrimmedString(point.completedAt) || asTrimmedString(point.startedAt),
);
case 'size':
return typeof point.sizeBytes === 'number' && Number.isFinite(point.sizeBytes)
? point.sizeBytes
: null;
case 'outcome':
return getRecoveryOutcomeLabel(normalizeRecoveryOutcome(point.outcome));
default:
key satisfies never;
return null;
}
};
type ProtectionDetailTone = DetailValueTone;
type ProtectionDetailSection = DetailSection;
@@ -312,6 +358,14 @@ export const TrueNASProtectionTable: Component<{
initialStatus: 'all' as TrueNASProtectionStatusFilter,
filter: filterTrueNASProtectionPoints,
});
const sort = createPlatformTableSortState({
storageKey: 'truenasProtection',
sortKeys: TRUENAS_PROTECTION_SORT_KEYS,
descendingFirst: ['completed', 'size'],
});
const sortedRows = createMemo(() =>
sort.sortRows(tableState.filtered(), getTrueNASProtectionSortValue),
);
return (
<Show
@@ -416,35 +470,59 @@ export const TrueNASProtectionTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="dataset"
class="md:w-[22%]"
>
Dataset
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[25%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="artifact"
class="md:w-[25%]"
>
Artifact
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[23%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="target"
class="hidden md:table-cell md:w-[23%]"
>
Target
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[12%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="completed"
class="md:w-[12%]"
>
Completed
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell md:w-[8%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="size"
class="hidden lg:table-cell md:w-[8%]"
>
Size
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[9%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="badge"
sort={sort}
sortKey="outcome"
class="md:w-[9%]"
>
Outcome
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(point) => {
const outcome = () => normalizeRecoveryOutcome(point.outcome);
const artifact = () => artifactLabel(point);
@@ -1,16 +1,18 @@
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
formatPlatformTableTitleCaseValue,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformTableFilterOption,
type PlatformTableSortValue,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import {
@@ -151,6 +153,36 @@ const ServiceDetailTable: Component<{ row: TrueNASServiceRow; onClose: () => voi
/>
);
// Columns a user can sort by. PIDs orders on the process count so the busiest
// services surface first.
const TRUENAS_SERVICE_SORT_KEYS = ['service', 'state', 'boot', 'pids', 'system'] as const;
type TrueNASServiceSortKey = (typeof TRUENAS_SERVICE_SORT_KEYS)[number];
const getTrueNASServiceSortValue = (
row: TrueNASServiceRow,
key: TrueNASServiceSortKey,
): PlatformTableSortValue => {
switch (key) {
case 'service':
return formatServiceName(row.service.service);
case 'state':
return asTrimmedString(row.service.state) || null;
case 'boot':
return row.service.enabled ? 'Enabled' : 'Disabled';
case 'pids': {
const count = (row.service.pids ?? []).filter((pid) => Number.isFinite(pid) && pid > 0)
.length;
return count > 0 ? count : null;
}
case 'system':
return asTrimmedString(row.systemName) || null;
default:
key satisfies never;
return null;
}
};
export const TrueNASServicesTable: Component<{
services: TrueNASServiceRow[];
emptyIcon: JSX.Element;
@@ -164,6 +196,14 @@ export const TrueNASServicesTable: Component<{
filter: filterTrueNASServices,
});
const detail = createPlatformResourceDetailState({ idPrefix: 'truenas-service-detail' });
const sort = createPlatformTableSortState({
storageKey: 'truenasServices',
sortKeys: TRUENAS_SERVICE_SORT_KEYS,
descendingFirst: ['pids'],
});
const sortedRows = createMemo(() =>
sort.sortRows(tableState.filtered(), getTrueNASServiceSortValue),
);
return (
<Show
@@ -206,28 +246,51 @@ export const TrueNASServicesTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[900px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[24%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="service"
class="md:w-[24%]"
>
Service
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[14%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="badge"
sort={sort}
sortKey="state"
class="md:w-[14%]"
>
State
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[14%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="badge"
sort={sort}
sortKey="boot"
class="md:w-[14%]"
>
Boot
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden sm:table-cell md:w-[20%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="pids"
class="hidden sm:table-cell md:w-[20%]"
>
PIDs
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[28%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="system"
class="md:w-[28%]"
>
System
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(row) => {
const status = () => mapTrueNASServiceStatus(row);
const pids = createMemo(() => formatPIDs(row.service.pids));
@@ -1,22 +1,25 @@
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableNumberValue,
PlatformTableTemperatureValue,
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
formatPlatformTableBytesValue,
formatPlatformTableTitleCaseValue,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformTableFilterOption,
type PlatformTableSortState,
type PlatformTableSortValue,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import {
@@ -162,6 +165,80 @@ const RiskPill: Component<{ row: TrueNASStorageTopologyRow }> = (props) => (
</span>
);
// Columns a user can sort by. Usage / Size orders pools and datasets on their
// used percentage and disks on their raw size, so same-kind siblings compare
// on the number their cell actually shows.
const TRUENAS_STORAGE_SORT_KEYS = ['resource', 'kind', 'usage', 'disks', 'temp', 'health'] as const;
type TrueNASStorageSortKey = (typeof TRUENAS_STORAGE_SORT_KEYS)[number];
const getTrueNASStorageSortValue = (
row: TrueNASStorageTopologyRow,
key: TrueNASStorageSortKey,
): PlatformTableSortValue => {
switch (key) {
case 'resource':
return resourceName(row.resource);
case 'kind':
return kindLabel(row.kind);
case 'usage': {
if (row.kind === 'disk') {
const size = row.resource.physicalDisk?.sizeBytes;
return typeof size === 'number' && Number.isFinite(size) ? size : null;
}
return capacityPercent(row.resource) ?? null;
}
case 'disks':
return row.kind === 'pool' ? row.counts.disks : null;
case 'temp': {
if (row.kind !== 'disk') return null;
const temperature = row.resource.physicalDisk?.temperature;
return typeof temperature === 'number' && Number.isFinite(temperature) ? temperature : null;
}
case 'health':
return riskLabel(row);
default:
key satisfies never;
return null;
}
};
// The topology renders as a flattened tree (pool → datasets → disks with
// indentation), so a flat re-order would tear children away from their
// parents. Sort each sibling group instead and re-emit depth-first: pools
// re-order against pools, and every parent keeps its subtree directly below
// it. Rows whose parent was filtered out re-root, matching how the filter
// already presents them.
const sortTrueNASStorageTopologyRows = (
rows: readonly TrueNASStorageTopologyRow[],
sort: PlatformTableSortState<TrueNASStorageSortKey>,
): readonly TrueNASStorageTopologyRow[] => {
if (!sort.sortKey()) return rows;
const present = new Set(rows.map((row) => row.id));
const roots: TrueNASStorageTopologyRow[] = [];
const childrenByParent = new Map<string, TrueNASStorageTopologyRow[]>();
for (const row of rows) {
const parentRowId = row.parentRowId && present.has(row.parentRowId) ? row.parentRowId : '';
if (!parentRowId) {
roots.push(row);
continue;
}
const siblings = childrenByParent.get(parentRowId) ?? [];
siblings.push(row);
childrenByParent.set(parentRowId, siblings);
}
const ordered: TrueNASStorageTopologyRow[] = [];
const emit = (siblings: TrueNASStorageTopologyRow[]) => {
for (const row of sort.sortRows(siblings, getTrueNASStorageSortValue)) {
ordered.push(row);
const children = childrenByParent.get(row.id);
if (children) emit(children);
}
};
emit(roots);
return ordered;
};
export const getTrueNASStorageTopologyIndentClass = (depth: number): string => {
if (depth <= 0) return '';
if (depth === 1) return 'pl-5 sm:pl-7';
@@ -212,6 +289,12 @@ export const TrueNASStorageTopologyTable: Component<{
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-storage-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
const sort = createPlatformTableSortState({
storageKey: 'truenasStorage',
sortKeys: TRUENAS_STORAGE_SORT_KEYS,
descendingFirst: ['usage', 'disks', 'temp'],
});
const sortedRows = createMemo(() => sortTrueNASStorageTopologyRows(tableState.filtered(), sort));
return (
<Show
@@ -254,33 +337,59 @@ export const TrueNASStorageTopologyTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[32%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="resource"
class="md:w-[32%]"
>
Resource
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[10%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="kind"
class="md:w-[10%]"
>
Kind
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[28%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="usage"
class="md:w-[28%]"
>
Usage / Size
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="disks"
class="hidden md:table-cell md:w-[8%]"
>
Disks
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell md:w-[8%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="temp"
class="hidden lg:table-cell md:w-[8%]"
>
Temp
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[14%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="badge"
sort={sort}
sortKey="health"
class="md:w-[14%]"
>
Health
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(row) => {
const resource = () => row.resource;
const detailRowId = () => drawer.detailRowId(resource());
@@ -2,7 +2,7 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getAlertStyles } from '@/utils/alerts';
import { useWebSocket } from '@/contexts/appRuntime';
@@ -11,6 +11,7 @@ import { asTrimmedString } from '@/utils/stringUtils';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformSortableTableHead,
PlatformTableMetricFallback,
PlatformTableNumberValue,
PlatformTablePercentValue,
@@ -18,13 +19,14 @@ import {
PlatformTableToolbar,
PlatformTableEmptyState,
createPlatformTableFilterState,
createPlatformTableSortState,
filterPlatformResources,
formatPlatformTableBytesValue,
formatPlatformTableUptimeValue,
getPlatformTableFiniteMetric,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformResourceStatusFilter,
type PlatformTableSortValue,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import {
@@ -78,6 +80,57 @@ const workloadInventoryPrimary = (counts: TrueNASSystemChildCounts): string =>
const workloadInventorySecondary = (counts: TrueNASSystemChildCounts): string =>
plural(counts.apps, 'app');
// Columns a user can sort by. The Inventory and VMs / Apps columns summarize
// several counts at once, so they carry no single scalar to order on.
const TRUENAS_SYSTEM_SORT_KEYS = [
'system',
'cpu',
'memory',
'capacity',
'temp',
'shares',
'services',
] as const;
type TrueNASSystemSortKey = (typeof TRUENAS_SYSTEM_SORT_KEYS)[number];
const getTrueNASSystemSortValue = (
system: Resource,
counts: TrueNASSystemChildCounts,
key: TrueNASSystemSortKey,
): PlatformTableSortValue => {
switch (key) {
case 'system':
return asTrimmedString(system.name) || system.id;
case 'cpu':
return getPlatformTableFiniteMetric(system.cpu?.current) ?? null;
case 'memory': {
const total = getPlatformTableFiniteMetric(system.memory?.total) ?? 0;
if (total > 0) {
return ((getPlatformTableFiniteMetric(system.memory?.used) ?? 0) / total) * 100;
}
return getPlatformTableFiniteMetric(system.memory?.current) ?? null;
}
case 'capacity': {
const current = getPlatformTableFiniteMetric(system.disk?.current);
if (current !== undefined) return current;
const used = getPlatformTableFiniteMetric(system.disk?.used);
const total = getPlatformTableFiniteMetric(system.disk?.total);
if (used !== undefined && total !== undefined && total > 0) return (used / total) * 100;
return null;
}
case 'temp':
return getPlatformTableFiniteMetric(system.temperature) ?? null;
case 'shares':
return counts.shares;
case 'services':
return counts.services;
default:
key satisfies never;
return null;
}
};
export const TrueNASSystemsTable: Component<{
systems: Resource[];
// Full TrueNAS resource scope so we can count pools / datasets / apps
@@ -103,6 +156,16 @@ export const TrueNASSystemsTable: Component<{
const countsBySystem = createMemo(() =>
buildTrueNASSystemChildCounts(props.scope, props.systems),
);
const sort = createPlatformTableSortState({
storageKey: 'truenasSystems',
sortKeys: TRUENAS_SYSTEM_SORT_KEYS,
descendingFirst: ['cpu', 'memory', 'capacity', 'temp', 'shares', 'services'],
});
const sortedRows = createMemo(() =>
sort.sortRows(tableState.filtered(), (system, key) =>
getTrueNASSystemSortValue(system, countsBySystem().get(system.id) ?? EMPTY_COUNTS, key),
),
);
return (
<Show
@@ -145,48 +208,73 @@ export const TrueNASSystemsTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[17%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="system"
class="md:w-[17%]"
>
System
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[10%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="cpu"
class="md:w-[10%]"
>
CPU
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[10%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="memory"
class="md:w-[10%]"
>
Memory
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[13%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="capacity"
class="md:w-[13%]"
>
Capacity
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[6%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="temp"
class="hidden md:table-cell md:w-[6%]"
>
Temp
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell lg:w-[15%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell lg:w-[15%]">
Inventory
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell lg:w-[8%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="shares"
class="hidden lg:table-cell lg:w-[8%]"
>
Shares
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell lg:w-[10%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell lg:w-[10%]">
VMs / Apps
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell lg:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="services"
class="hidden lg:table-cell lg:w-[10%]"
>
Services
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(system) => {
const name = () => asTrimmedString(system.name) || system.id;
const version = () => asTrimmedString(system.agent?.osVersion) || '—';
@@ -1,17 +1,19 @@
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
formatPlatformTableBytesValue,
formatPlatformTableTitleCaseValue,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformTableFilterOption,
type PlatformTableSortValue,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import {
@@ -87,6 +89,58 @@ const flagLabels = (vm: ResourceTrueNASVMMeta | undefined): string[] => {
return labels;
};
// Columns a user can sort by. Devices and Flags summarize several values at
// once, so they carry no single scalar to order on. CPU orders on the
// provisioned vCPU count (cores × threads when vCPUs are not reported) and
// Memory on the provisioned bytes.
const TRUENAS_VM_SORT_KEYS = ['vm', 'state', 'cpu', 'memory', 'boot'] as const;
type TrueNASVMSortKey = (typeof TRUENAS_VM_SORT_KEYS)[number];
const getTrueNASVMSortValue = (
resource: Resource,
key: TrueNASVMSortKey,
): PlatformTableSortValue => {
const vm = vmMeta(resource);
switch (key) {
case 'vm':
return (
asTrimmedString(vm?.name) ||
asTrimmedString(resource.displayName) ||
asTrimmedString(resource.name) ||
resource.id
);
case 'state':
return asTrimmedString(vm?.state || vm?.domainState) || null;
case 'cpu': {
const vcpus = vm?.vcpus;
if (typeof vcpus === 'number' && Number.isFinite(vcpus) && vcpus > 0) return vcpus;
const cores = vm?.cores;
const threads = vm?.threads;
if (
typeof cores === 'number' &&
Number.isFinite(cores) &&
cores > 0 &&
typeof threads === 'number' &&
Number.isFinite(threads) &&
threads > 0
) {
return cores * threads;
}
return null;
}
case 'memory':
return typeof vm?.memoryBytes === 'number' && Number.isFinite(vm.memoryBytes)
? vm.memoryBytes
: null;
case 'boot':
return asTrimmedString(vm?.bootloader) || null;
default:
key satisfies never;
return null;
}
};
export const TrueNASVirtualMachinesTable: Component<{
vms: Resource[];
scope: Resource[];
@@ -102,6 +156,12 @@ export const TrueNASVirtualMachinesTable: Component<{
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-vm-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
const sort = createPlatformTableSortState({
storageKey: 'truenasVms',
sortKeys: TRUENAS_VM_SORT_KEYS,
descendingFirst: ['cpu', 'memory'],
});
const sortedRows = createMemo(() => sort.sortRows(tableState.filtered(), getTrueNASVMSortValue));
return (
<Show
@@ -144,40 +204,52 @@ export const TrueNASVirtualMachinesTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
<PlatformSortableTableHead kind="name" sort={sort} sortKey="vm" class="md:w-[22%]">
VM
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[10%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="badge"
sort={sort}
sortKey="state"
class="md:w-[10%]"
>
State
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden sm:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="cpu"
class="hidden sm:table-cell md:w-[10%]"
>
CPU
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden sm:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="memory"
class="hidden sm:table-cell md:w-[10%]"
>
Memory
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[11%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="boot"
class="hidden md:table-cell md:w-[11%]"
>
Boot
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[18%]">
Devices
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[19%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="md:w-[19%]">
Flags
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(resource) => {
const vm = () => vmMeta(resource);
const name = () =>
@@ -0,0 +1,189 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TrueNASStorageTopologyTable } from '@/features/truenas/TrueNASStorageTopologyTable';
import type { Resource } from '@/types/resource';
// The capacity bar observes its container size; jsdom has no ResizeObserver,
// and this suite only asserts row order.
vi.mock('@/components/shared/responsive', () => ({
ResponsiveMetricCell: (props: { type: string }) => (
<div data-testid={`responsive-${props.type}-metric`} />
),
}));
const makeStorageResource = (overrides: Partial<Resource> & Pick<Resource, 'id'>): Resource =>
({
type: 'storage',
name: overrides.id,
displayName: overrides.id,
status: 'online',
platformType: 'truenas',
platformScopes: ['truenas'],
sourceType: 'api',
storage: { topology: 'dataset', platform: 'truenas' },
...overrides,
}) as Resource;
// Two pools with root datasets so both levels of the topology are sortable:
// the pools against each other and the datasets within each pool's subtree.
const FIXTURE: Resource[] = [
makeStorageResource({
id: 'pool-alpha',
name: 'alpha',
displayName: 'alpha',
disk: { current: 50 } as Resource['disk'],
storage: { topology: 'pool', platform: 'truenas', path: 'alpha' },
}),
makeStorageResource({
id: 'dataset-zeta',
name: 'alpha/zeta',
displayName: 'alpha/zeta',
disk: { current: 10 } as Resource['disk'],
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/zeta' },
}),
makeStorageResource({
id: 'dataset-mike',
name: 'alpha/mike',
displayName: 'alpha/mike',
disk: { current: 80 } as Resource['disk'],
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/mike' },
}),
// No disk metric: must sink to the bottom of its sibling group when the
// user sorts on Usage / Size.
makeStorageResource({
id: 'dataset-bravo',
name: 'alpha/bravo',
displayName: 'alpha/bravo',
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/bravo' },
}),
makeStorageResource({
id: 'pool-zulu',
name: 'zulu',
displayName: 'zulu',
disk: { current: 70 } as Resource['disk'],
storage: { topology: 'pool', platform: 'truenas', path: 'zulu' },
}),
makeStorageResource({
id: 'dataset-data',
name: 'zulu/data',
displayName: 'zulu/data',
disk: { current: 30 } as Resource['disk'],
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/zulu/data' },
}),
];
const renderTable = () =>
render(() => (
<TrueNASStorageTopologyTable
resources={FIXTURE}
scope={FIXTURE}
emptyIcon={<span />}
emptyTitle="No storage"
emptyDescription="No storage"
showToolbar={false}
/>
));
const visibleRowOrder = (container: HTMLElement): string[] =>
Array.from(container.querySelectorAll('tr[data-truenas-storage-row]')).map(
(row) => row.getAttribute('data-truenas-storage-row') ?? '',
);
const headerFor = (label: string): HTMLElement => {
const header = screen
.getAllByRole('columnheader')
.find((th) => th.textContent?.trim().startsWith(label));
if (!header) throw new Error(`No column header labelled ${label}`);
return header;
};
afterEach(() => {
window.localStorage.clear();
cleanup();
});
describe('TrueNASStorageTopologyTable user sorting', () => {
it('sorts sibling groups without tearing subtrees from their pools', () => {
const { container } = renderTable();
// Built-in order: pools alphabetically, each with its datasets below it.
expect(visibleRowOrder(container)).toEqual([
'pool:pool-alpha',
'dataset:dataset-bravo',
'dataset:dataset-mike',
'dataset:dataset-zeta',
'pool:pool-zulu',
'dataset:dataset-data',
]);
fireEvent.click(headerFor('Resource'));
expect(headerFor('Resource')).toHaveAttribute('aria-sort', 'ascending');
expect(visibleRowOrder(container)).toEqual([
'pool:pool-alpha',
'dataset:dataset-bravo',
'dataset:dataset-mike',
'dataset:dataset-zeta',
'pool:pool-zulu',
'dataset:dataset-data',
]);
// Descending re-orders the pools AND the datasets within each pool, but
// every dataset stays directly below its own pool.
fireEvent.click(headerFor('Resource'));
expect(headerFor('Resource')).toHaveAttribute('aria-sort', 'descending');
expect(visibleRowOrder(container)).toEqual([
'pool:pool-zulu',
'dataset:dataset-data',
'pool:pool-alpha',
'dataset:dataset-zeta',
'dataset:dataset-mike',
'dataset:dataset-bravo',
]);
// Third click clears back to the built-in topology order.
fireEvent.click(headerFor('Resource'));
expect(headerFor('Resource')).not.toHaveAttribute('aria-sort');
expect(visibleRowOrder(container)).toEqual([
'pool:pool-alpha',
'dataset:dataset-bravo',
'dataset:dataset-mike',
'dataset:dataset-zeta',
'pool:pool-zulu',
'dataset:dataset-data',
]);
});
it('sorts Usage / Size descending first with missing values last in each subtree', () => {
const { container } = renderTable();
fireEvent.click(headerFor('Usage / Size'));
expect(headerFor('Usage / Size')).toHaveAttribute('aria-sort', 'descending');
// Pools order on their own usage (zulu 70 > alpha 50); alpha's datasets
// order 80 > 10 with the metric-less bravo sinking to the bottom of its
// sibling group.
expect(visibleRowOrder(container)).toEqual([
'pool:pool-zulu',
'dataset:dataset-data',
'pool:pool-alpha',
'dataset:dataset-mike',
'dataset:dataset-zeta',
'dataset:dataset-bravo',
]);
});
it('persists the chosen sort across a remount', () => {
const first = renderTable();
fireEvent.click(headerFor('Resource'));
fireEvent.click(headerFor('Resource'));
expect(window.localStorage.getItem('truenasStorageSortKey')).toBe('resource');
expect(window.localStorage.getItem('truenasStorageSortDirection')).toBe('desc');
expect(visibleRowOrder(first.container)[0]).toBe('pool:pool-zulu');
cleanup();
const second = renderTable();
expect(headerFor('Resource')).toHaveAttribute('aria-sort', 'descending');
expect(visibleRowOrder(second.container)[0]).toBe('pool:pool-zulu');
});
});
@@ -1,4 +1,4 @@
import { For, Show, type Component, type JSX } from 'solid-js';
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import {
InlineDetailPanel,
compactDetailRows,
@@ -9,16 +9,19 @@ import {
} from '@/components/shared/DetailSectionTable';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import {
PlatformSortableTableHead,
PlatformTableDateTimeValue,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
getPlatformTableDateTimeSortValue,
type PlatformTableFilterOption,
type PlatformTableSortValue,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import {
@@ -193,6 +196,49 @@ const ActivityDetail: Component<{ activity: VmwareActivityRow; onClose: () => vo
/>
);
// Columns a user can sort by: every activity column carries a scalar. When
// orders chronologically, newest first on the first click.
const VSPHERE_ACTIVITY_SORT_KEYS = [
'resource',
'type',
'activity',
'state',
'actor',
'vcenter',
'when',
] as const;
type VsphereActivitySortKey = (typeof VSPHERE_ACTIVITY_SORT_KEYS)[number];
const getVsphereActivitySortValue = (
activity: VmwareActivityRow,
key: VsphereActivitySortKey,
): PlatformTableSortValue => {
switch (key) {
case 'resource':
return activity.resourceName || null;
case 'type':
return formatActivityKind(activity.activityKind);
case 'activity':
return activity.title || null;
case 'state': {
const state = formatActivityState(activity);
return state === '-' ? null : state;
}
case 'actor':
return activity.actor || null;
case 'vcenter': {
const meta = activity.resource.vmware;
return meta?.connectionName || meta?.vcenterHost || null;
}
case 'when':
return getPlatformTableDateTimeSortValue(activity.occurredAt || activity.observedAt);
default:
key satisfies never;
return null;
}
};
export const VsphereActivityTable: Component<{
activity: VmwareActivityRow[];
emptyIcon: JSX.Element;
@@ -206,6 +252,14 @@ export const VsphereActivityTable: Component<{
filter: filterVmwareActivity,
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'vsphere-activity-drawer' });
const sort = createPlatformTableSortState({
storageKey: 'vsphereActivity',
sortKeys: VSPHERE_ACTIVITY_SORT_KEYS,
descendingFirst: ['when'],
});
const sortedRows = createMemo(() =>
sort.sortRows(tableState.filtered(), getVsphereActivitySortValue),
);
return (
<Show
@@ -248,38 +302,67 @@ export const VsphereActivityTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[1120px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[20%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="resource"
class="md:w-[20%]"
>
Resource
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[8%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="badge"
sort={sort}
sortKey="type"
class="md:w-[8%]"
>
Type
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[34%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="activity"
class="md:w-[34%]"
>
Activity
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[10%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="badge"
sort={sort}
sortKey="state"
class="md:w-[10%]"
>
State
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="actor"
class="hidden md:table-cell md:w-[10%]"
>
Actor
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="vcenter"
class="hidden lg:table-cell md:w-[10%]"
>
vCenter
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden xl:table-cell md:w-[8%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="when"
class="hidden xl:table-cell md:w-[8%]"
>
When
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(activity) => {
const meta = () => activity.resource.vmware;
const detailRowId = () => drawer.detailRowId(activity);
@@ -1,20 +1,22 @@
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableMetricFallback,
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
getPlatformTableFiniteMetric,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
summarizePlatformTableValues,
type PlatformTableFilterOption,
type PlatformTableSortValue,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import {
@@ -96,6 +98,36 @@ const hasCapacityMetric = (resource: Resource): boolean => {
);
};
// Columns a user can sort by. Hosts and Consumers summarize several names at
// once, so they carry no single scalar to order on. Capacity orders on the
// used percentage the bar shows.
const VSPHERE_DATASTORE_SORT_KEYS = ['datastore', 'type', 'capacity', 'vms', 'datacenter'] as const;
type VsphereDatastoreSortKey = (typeof VSPHERE_DATASTORE_SORT_KEYS)[number];
const getVsphereDatastoreSortValue = (
resource: Resource,
key: VsphereDatastoreSortKey,
): PlatformTableSortValue => {
switch (key) {
case 'datastore':
return datastoreName(resource);
case 'type': {
const type = datastoreType(resource);
return type === '—' ? null : type;
}
case 'capacity':
return hasCapacityMetric(resource) ? capacityDisk(resource).usage : null;
case 'vms':
return consumerCount(resource);
case 'datacenter':
return asTrimmedString(resource.vmware?.datacenterName) || null;
default:
key satisfies never;
return null;
}
};
export const VsphereDatastoresTable: Component<{
datastores: Resource[];
scope: Resource[];
@@ -111,6 +143,14 @@ export const VsphereDatastoresTable: Component<{
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'vsphere-datastore-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
const sort = createPlatformTableSortState({
storageKey: 'vsphereDatastores',
sortKeys: VSPHERE_DATASTORE_SORT_KEYS,
descendingFirst: ['capacity', 'vms'],
});
const sortedRows = createMemo(() =>
sort.sortRows(tableState.filtered(), getVsphereDatastoreSortValue),
);
return (
<Show
@@ -153,40 +193,57 @@ export const VsphereDatastoresTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[1080px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[21%]`}>
Datastore
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[9%]`}>
Type
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[18%]`}>
Capacity
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="datastore"
class="md:w-[21%]"
>
Datastore
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="type"
class="md:w-[9%]"
>
Type
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="capacity"
class="md:w-[18%]"
>
Capacity
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[14%]">
Hosts
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[7%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="vms"
class="hidden md:table-cell md:w-[7%]"
>
VMs
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[13%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell md:w-[13%]">
Consumers
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="datacenter"
class="hidden md:table-cell md:w-[10%]"
>
Datacenter
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(datastore) => {
const hosts = createMemo(() => hostSummary(datastore));
const consumers = createMemo(() => consumerSummary(datastore));
@@ -2,7 +2,7 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getAlertStyles } from '@/utils/alerts';
import { useWebSocket } from '@/contexts/appRuntime';
@@ -17,17 +17,19 @@ import {
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformSortableTableHead,
PlatformTableMetricFallback,
PlatformTableEmptyState,
PlatformTableShell,
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
filterPlatformResources,
formatPlatformTableUptimeValue,
getPlatformTableFiniteMetric,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformResourceStatusFilter,
type PlatformTableSortValue,
} from '@/features/platformPage/sharedPlatformPage';
import {
PlatformResourceDetailToggleButton,
@@ -50,6 +52,65 @@ import type { Resource } from '@/types/resource';
// Per-host VM count is computed from the page scope client-side (no
// extra API calls).
// Columns a user can sort by: every host column carries a scalar. VMs orders
// on the per-host count computed from the page scope, so the getter takes the
// count map alongside the row.
const VSPHERE_HOST_SORT_KEYS = [
'host',
'version',
'datacenter',
'cluster',
'power',
'cpu',
'memory',
'datastores',
'vms',
'uptime',
'vcenter',
] as const;
type VsphereHostSortKey = (typeof VSPHERE_HOST_SORT_KEYS)[number];
const getVsphereHostSortValue = (
host: Resource,
vmCountByHost: Map<string, number>,
key: VsphereHostSortKey,
): PlatformTableSortValue => {
const meta = host.vmware;
switch (key) {
case 'host':
return asTrimmedString(host.name) || host.id;
case 'version':
return asTrimmedString(host.agent?.osVersion) || null;
case 'datacenter':
return asTrimmedString(meta?.datacenterName) || null;
case 'cluster':
return asTrimmedString(meta?.clusterName) || null;
case 'power':
return asTrimmedString(formatVmwarePowerState(meta?.powerState)) || null;
case 'cpu':
return getPlatformTableFiniteMetric(host.cpu?.current) ?? null;
case 'memory': {
const total = getPlatformTableFiniteMetric(host.memory?.total) ?? 0;
if (total > 0) {
return ((getPlatformTableFiniteMetric(host.memory?.used) ?? 0) / total) * 100;
}
return getPlatformTableFiniteMetric(host.memory?.current) ?? null;
}
case 'datastores':
return meta?.datastoreIds?.length ?? meta?.datastoreNames?.length ?? 0;
case 'vms':
return vmCountByHost.get(asTrimmedString(meta?.managedObjectId) || '') ?? 0;
case 'uptime':
return typeof host.uptime === 'number' && host.uptime > 0 ? host.uptime : null;
case 'vcenter':
return asTrimmedString(meta?.vcenterHost) || null;
default:
key satisfies never;
return null;
}
};
export const VsphereHostsTable: Component<{
hosts: Resource[];
// Full vSphere scope so we can count VMs per host without spawning
@@ -84,6 +145,17 @@ export const VsphereHostsTable: Component<{
return map;
});
const sort = createPlatformTableSortState({
storageKey: 'vsphereHosts',
sortKeys: VSPHERE_HOST_SORT_KEYS,
descendingFirst: ['cpu', 'memory', 'datastores', 'vms', 'uptime'],
});
const sortedRows = createMemo(() =>
sort.sortRows(tableState.filtered(), (host, key) =>
getVsphereHostSortValue(host, vmCountByHost(), key),
),
);
return (
<Show
when={props.hosts.length > 0}
@@ -132,58 +204,99 @@ export const VsphereHostsTable: Component<{
integer-count columns to what their content actually
needs. Mobile widths are unchanged.
*/}
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[16%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="host"
class="md:w-[16%]"
>
Host
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[6%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="version"
class="hidden md:table-cell md:w-[6%]"
>
Version
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="datacenter"
class="hidden md:table-cell md:w-[10%]"
>
Datacenter
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="cluster"
class="hidden md:table-cell md:w-[10%]"
>
Cluster
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[7%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="power"
class="hidden md:table-cell md:w-[7%]"
>
Power
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[12%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="cpu"
class="md:w-[12%]"
>
CPU
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[13%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="memory"
class="md:w-[13%]"
>
Memory
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[7%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="datastores"
class="hidden md:table-cell md:w-[7%]"
>
Datastores
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[4%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="vms"
class="md:w-[4%]"
>
VMs
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[6%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="uptime"
class="hidden md:table-cell md:w-[6%]"
>
Uptime
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[9%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="vcenter"
class="hidden md:table-cell md:w-[9%]"
>
vCenter
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(host) => {
const meta = () => host.vmware;
const name = () => asTrimmedString(host.name) || host.id;
@@ -1,17 +1,19 @@
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { TableCell, TableRow } from '@/components/shared/Table';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PlatformSortableTableHead,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
summarizePlatformTableValues,
type PlatformTableFilterOption,
type PlatformTableSortValue,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import {
@@ -75,6 +77,33 @@ const vmSummary = (resource: Resource): { label: string; title: string } =>
const vmCount = (resource: Resource): number =>
resource.vmware?.networkVmNames?.length ?? resource.vmware?.networkVmIds?.length ?? 0;
// Columns a user can sort by. Hosts and Connected VMs summarize several names
// at once, so they carry no single scalar to order on.
const VSPHERE_NETWORK_SORT_KEYS = ['network', 'type', 'vms', 'datacenter'] as const;
type VsphereNetworkSortKey = (typeof VSPHERE_NETWORK_SORT_KEYS)[number];
const getVsphereNetworkSortValue = (
resource: Resource,
key: VsphereNetworkSortKey,
): PlatformTableSortValue => {
switch (key) {
case 'network':
return networkName(resource);
case 'type': {
const type = networkType(resource);
return type === '—' ? null : type;
}
case 'vms':
return vmCount(resource);
case 'datacenter':
return asTrimmedString(resource.vmware?.datacenterName) || null;
default:
key satisfies never;
return null;
}
};
export const VsphereNetworksTable: Component<{
networks: Resource[];
scope: Resource[];
@@ -90,6 +119,14 @@ export const VsphereNetworksTable: Component<{
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'vsphere-network-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
const sort = createPlatformTableSortState({
storageKey: 'vsphereNetworks',
sortKeys: VSPHERE_NETWORK_SORT_KEYS,
descendingFirst: ['vms'],
});
const sortedRows = createMemo(() =>
sort.sortRows(tableState.filtered(), getVsphereNetworkSortValue),
);
return (
<Show
@@ -132,37 +169,49 @@ export const VsphereNetworksTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[1040px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[24%]`}>
Network
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[13%]`}>
Type
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="network"
class="md:w-[24%]"
>
Network
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="type"
class="md:w-[13%]"
>
Type
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[18%]">
Hosts
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[7%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="vms"
class="hidden md:table-cell md:w-[7%]"
>
VMs
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[18%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell md:w-[18%]">
Connected VMs
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[12%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="datacenter"
class="hidden md:table-cell md:w-[12%]"
>
Datacenter
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(network) => {
const hosts = createMemo(() => hostSummary(network));
const vms = createMemo(() => vmSummary(network));