diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts
index a1d1915ab..88fd59ae0 100644
--- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts
+++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts
@@ -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(
'{compactCapacityLabel()}',
);
diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx
index ebcf1c7d0..59b6f32ae 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx
@@ -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(['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(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={
{(column) => (
- handleSort(column.id)}
- >
+
{column.label}
- {sortKey() === column.id && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
-
+
)}
}
diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx
index ca802f005..d330b11de 100644
--- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx
+++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx
@@ -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();
});
diff --git a/frontend-modern/src/features/truenas/TrueNASAppsTable.tsx b/frontend-modern/src/features/truenas/TrueNASAppsTable.tsx
index b7fddb231..66458702e 100644
--- a/frontend-modern/src/features/truenas/TrueNASAppsTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASAppsTable.tsx
@@ -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 (
-
+
App
-
-
+
Version
-
-
+
+
CPU
-
-
+
+
Memory
-
-
+
Containers
-
-
+
+
Ports
-
-
+
+
Images
-
-
+
+
Updates
-
+
>
}
body={
<>
-
+
{(resource) => {
const app = () => appMeta(resource);
const name = () =>
diff --git a/frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx b/frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx
index 8a185eb9f..b819b8ba0 100644
--- a/frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx
@@ -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 (
-
+
Share
-
-
+
+
Protocol
-
-
+
Path
-
-
+
+
Access
-
-
+
+
Clients
-
-
+
+
State
-
+
>
}
body={
<>
-
+
{(resource) => {
const share = () => shareMeta(resource);
const name = () => shareName(resource, share());
diff --git a/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx b/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx
index 54f853d7e..f8f5d0a90 100644
--- a/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx
@@ -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 (
-
+
Dataset
-
-
+
+
Artifact
-
-
+
Target
-
-
+
Completed
-
-
+
Size
-
-
+
+
Outcome
-
+
>
}
body={
<>
-
+
{(point) => {
const outcome = () => normalizeRecoveryOutcome(point.outcome);
const artifact = () => artifactLabel(point);
diff --git a/frontend-modern/src/features/truenas/TrueNASServicesTable.tsx b/frontend-modern/src/features/truenas/TrueNASServicesTable.tsx
index fa406d190..f8cbe6f7e 100644
--- a/frontend-modern/src/features/truenas/TrueNASServicesTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASServicesTable.tsx
@@ -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 (
-
+
Service
-
-
+
+
State
-
-
+
+
Boot
-
-
+
PIDs
-
-
+
+
System
-
+
>
}
body={
<>
-
+
{(row) => {
const status = () => mapTrueNASServiceStatus(row);
const pids = createMemo(() => formatPIDs(row.service.pids));
diff --git a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx
index 94d3252a5..6d8c3ca99 100644
--- a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx
@@ -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) => (
);
+// 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,
+): readonly TrueNASStorageTopologyRow[] => {
+ if (!sort.sortKey()) return rows;
+ const present = new Set(rows.map((row) => row.id));
+ const roots: TrueNASStorageTopologyRow[] = [];
+ const childrenByParent = new Map();
+ 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 (
-
+
Resource
-
-
+
+
Kind
-
-
+
+
Usage / Size
-
-
+
Disks
-
-
+
Temp
-
-
+
+
Health
-
+
>
}
body={
<>
-
+
{(row) => {
const resource = () => row.resource;
const detailRowId = () => drawer.detailRowId(resource());
diff --git a/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx b/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx
index f5ac399d2..0f860aab4 100644
--- a/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx
@@ -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 (
-
+
System
-
-
+
+
CPU
-
-
+
+
Memory
-
-
+
+
Capacity
-
-
+
Temp
-
-
+
+
Inventory
-
-
+
Shares
-
-
+
+
VMs / Apps
-
-
+
Services
-
+
>
}
body={
<>
-
+
{(system) => {
const name = () => asTrimmedString(system.name) || system.id;
const version = () => asTrimmedString(system.agent?.osVersion) || '—';
diff --git a/frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx b/frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx
index bc2cca9f3..85d5b7e9d 100644
--- a/frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx
@@ -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 (
-
+
VM
-
-
+
+
State
-
-
+
CPU
-
-
+
Memory
-
-
+
Boot
-
-
+
+
Devices
-
-
+
+
Flags
-
+
>
}
body={
<>
-
+
{(resource) => {
const vm = () => vmMeta(resource);
const name = () =>
diff --git a/frontend-modern/src/features/truenas/__tests__/TrueNASStorageTopologyTable.sort.test.tsx b/frontend-modern/src/features/truenas/__tests__/TrueNASStorageTopologyTable.sort.test.tsx
new file mode 100644
index 000000000..db9881a65
--- /dev/null
+++ b/frontend-modern/src/features/truenas/__tests__/TrueNASStorageTopologyTable.sort.test.tsx
@@ -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 }) => (
+
+ ),
+}));
+
+const makeStorageResource = (overrides: Partial & Pick): 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(() => (
+ }
+ 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');
+ });
+});
diff --git a/frontend-modern/src/features/vmware/VsphereActivityTable.tsx b/frontend-modern/src/features/vmware/VsphereActivityTable.tsx
index 40ec817b0..3a5322d4d 100644
--- a/frontend-modern/src/features/vmware/VsphereActivityTable.tsx
+++ b/frontend-modern/src/features/vmware/VsphereActivityTable.tsx
@@ -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 (
-
+
Resource
-
-
+
+
Type
-
-
+
+
Activity
-
-
+
+
State
-
-
+
Actor
-
-
+
vCenter
-
-
+
When
-
+
>
}
body={
<>
-
+
{(activity) => {
const meta = () => activity.resource.vmware;
const detailRowId = () => drawer.detailRowId(activity);
diff --git a/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx b/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx
index faa118295..b8c33d127 100644
--- a/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx
+++ b/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx
@@ -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 (
-
- Datastore
-
-
- Type
-
-
- Capacity
-
-
+ Datastore
+
+
+ Type
+
+
+ Capacity
+
+
Hosts
-
-
+
VMs
-
-
+
+
Consumers
-
-
+
Datacenter
-
+
>
}
body={
<>
-
+
{(datastore) => {
const hosts = createMemo(() => hostSummary(datastore));
const consumers = createMemo(() => consumerSummary(datastore));
diff --git a/frontend-modern/src/features/vmware/VsphereHostsTable.tsx b/frontend-modern/src/features/vmware/VsphereHostsTable.tsx
index bc2fe4ed6..9885f27f7 100644
--- a/frontend-modern/src/features/vmware/VsphereHostsTable.tsx
+++ b/frontend-modern/src/features/vmware/VsphereHostsTable.tsx
@@ -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,
+ 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 (
0}
@@ -132,58 +204,99 @@ export const VsphereHostsTable: Component<{
integer-count columns to what their content actually
needs. Mobile widths are unchanged.
*/}
-
+
Host
-
-
+
Version
-
-
+
Datacenter
-
-
+
Cluster
-
-
+
Power
-
-
+
+
CPU
-
-
+
+
Memory
-
-
+
Datastores
-
-
+
+
VMs
-
-
+
Uptime
-
-
+
vCenter
-
+
>
}
body={
<>
-
+
{(host) => {
const meta = () => host.vmware;
const name = () => asTrimmedString(host.name) || host.id;
diff --git a/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx b/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx
index e9cc609f4..2253d4a4d 100644
--- a/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx
+++ b/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx
@@ -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 (
-
- Network
-
-
- Type
-
-
+ Network
+
+
+ Type
+
+
Hosts
-
-
+
VMs
-
-
+
+
Connected VMs
-
-
+
Datacenter
-
+
>
}
body={
<>
-
+
{(network) => {
const hosts = createMemo(() => hostSummary(network));
const vms = createMemo(() => vmSummary(network));