From cf0f027b64c463610575000b77324892912e1d2e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 11 Jul 2026 00:15:42 +0100 Subject: [PATCH] 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. --- .../platformOverviewLayout.guardrails.test.ts | 12 +- .../features/proxmox/ProxmoxNodesTable.tsx | 95 +++------ .../__tests__/ProxmoxNodesTable.test.tsx | 3 + .../src/features/truenas/TrueNASAppsTable.tsx | 125 +++++++++--- .../truenas/TrueNASNetworkSharesTable.tsx | 89 +++++++-- .../truenas/TrueNASProtectionTable.tsx | 114 +++++++++-- .../features/truenas/TrueNASServicesTable.tsx | 91 +++++++-- .../truenas/TrueNASStorageTopologyTable.tsx | 143 +++++++++++-- .../features/truenas/TrueNASSystemsTable.tsx | 144 ++++++++++--- .../truenas/TrueNASVirtualMachinesTable.tsx | 116 +++++++++-- .../TrueNASStorageTopologyTable.sort.test.tsx | 189 ++++++++++++++++++ .../features/vmware/VsphereActivityTable.tsx | 125 ++++++++++-- .../vmware/VsphereDatastoresTable.tsx | 107 +++++++--- .../src/features/vmware/VsphereHostsTable.tsx | 177 +++++++++++++--- .../features/vmware/VsphereNetworksTable.tsx | 93 +++++++-- 15 files changed, 1308 insertions(+), 315 deletions(-) create mode 100644 frontend-modern/src/features/truenas/__tests__/TrueNASStorageTopologyTable.sort.test.tsx 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 - - - + + CPU - - + + Memory - - - - - + + 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 - - - + + Access - - - + + 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 - - - + Completed - - - + + 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 - - - + + 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 - - - - + + 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 - - - - - - + } 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 - - - - - - + + 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 - - - - + } 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 - - - - - + } 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 - - - - - - + + CPU - - + + Memory - - - + + VMs - - - + } 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 - - - - - + } body={ <> - + {(network) => { const hosts = createMemo(() => hostSummary(network)); const vms = createMemo(() => vmSummary(network));