From f66c4b46b3aedf782626377a0fb1807cf2bb75e9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 11 Jul 2026 00:11:04 +0100 Subject: [PATCH] Restore user column sorting on Kubernetes platform tables v5 parity follow-up to 20cccc1a9 (v5 KubernetesClusters.tsx persisted k8s-sort-key/k8s-sort-dir sorting; v6 shipped fixed ordering). Adopts the shared platform-table sort fabric (createPlatformTableSortState + PlatformSortableTableHead) across the clusters, nodes, pods, deployments, controllers, services, storage, config, autoscaling, and networking tables: per-table storage keys, descendingFirst on metric/count columns (pods Ready stays ascending-first so the least-ready pods surface), the status-first compare preserved as the base order until the user sorts, and canonical kind-based alignment via the sortable head's kind prop. Composite summary columns (ports, selectors, labels, access/policy, bounds, detail) stay non-sortable. TrueNAS/vSphere adoption is landing separately. --- .../kubernetes/KubernetesAutoscalingTable.tsx | 120 ++++++++++---- .../kubernetes/KubernetesClustersTable.tsx | 137 +++++++++++++--- .../kubernetes/KubernetesConfigTable.tsx | 92 ++++++++--- .../kubernetes/KubernetesControllersTable.tsx | 139 +++++++++++++--- .../kubernetes/KubernetesDeploymentsTable.tsx | 134 ++++++++++++--- .../kubernetes/KubernetesNetworkingTable.tsx | 91 ++++++++--- .../kubernetes/KubernetesNodesTable.tsx | 152 ++++++++++++++---- .../kubernetes/KubernetesPodsTable.tsx | 145 ++++++++++++++--- .../kubernetes/KubernetesServicesTable.tsx | 89 +++++++--- .../kubernetes/KubernetesStorageTable.tsx | 139 +++++++++++++--- .../KubernetesPodsTable.sort.test.tsx | 118 ++++++++++++++ .../platformOverviewLayout.guardrails.test.ts | 12 +- 12 files changed, 1113 insertions(+), 255 deletions(-) create mode 100644 frontend-modern/src/features/kubernetes/__tests__/KubernetesPodsTable.sort.test.tsx diff --git a/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx index 31a0199e3..5c7d0357e 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx @@ -1,19 +1,21 @@ -import { For, Show, type Component, type JSX } from 'solid-js'; +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 { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableNumberValue, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableTextValue, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, summarizePlatformTableValues, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -55,6 +57,41 @@ const bounds = (resource: Resource): string => { return `${min ?? '—'}-${max ?? '—'}`; }; +// Bounds ("1-10"), Metrics, and Labels are composite summaries with no single +// scalar to order on, so they stay non-sortable. +const KUBERNETES_AUTOSCALING_SORT_KEYS = [ + 'autoscaler', + 'scope', + 'target', + 'current', + 'desired', +] as const; + +type KubernetesAutoscalingSortKey = (typeof KUBERNETES_AUTOSCALING_SORT_KEYS)[number]; + +const getKubernetesAutoscalingSortValue = ( + resource: Resource, + key: KubernetesAutoscalingSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'autoscaler': + return autoscalerName(resource); + case 'scope': + return kubernetesScopeLabel(resource); + case 'target': { + const target = targetRef(resource); + return target === '—' ? null : target; + } + case 'current': + return resource.kubernetes?.currentReplicas ?? null; + case 'desired': + return resource.kubernetes?.desiredReplicas ?? null; + default: + key satisfies never; + return null; + } +}; + export const KubernetesAutoscalingTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; @@ -74,6 +111,14 @@ export const KubernetesAutoscalingTable: Component<{ }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-autoscaling-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); + const sort = createPlatformTableSortState({ + storageKey: 'kubernetesAutoscaling', + sortKeys: KUBERNETES_AUTOSCALING_SORT_KEYS, + descendingFirst: ['current', 'desired'], + }); + const sortedRows = createMemo(() => + sort.sortRows(tableState.filtered(), getKubernetesAutoscalingSortValue), + ); return ( - + Autoscaler - - - - Scale target - - - Bounds - - - Current - - - Desired - - - Metrics - - + } body={ <> - + {(resource) => { const indicator = () => getSimpleStatusIndicator(resource.status); const name = () => autoscalerName(resource); diff --git a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx index 8718f927e..1d72f24a1 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx @@ -2,22 +2,24 @@ 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 { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { getSimpleStatusIndicator } from '@/utils/status'; import { asTrimmedString } from '@/utils/stringUtils'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableCountRatioValue, PlatformTableMetricFallback, PlatformTableToolbar, PlatformTableEmptyState, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableTextValue, getPlatformTableFiniteMetric, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -44,6 +46,19 @@ import { // CPU/Memory utilisation. It reuses the same shared primitives every // other platform-page table uses. +const KUBERNETES_CLUSTER_SORT_KEYS = [ + 'cluster', + 'context', + 'version', + 'nodes', + 'pods', + 'deployments', + 'cpu', + 'memory', +] as const; + +type KubernetesClusterSortKey = (typeof KUBERNETES_CLUSTER_SORT_KEYS)[number]; + export const KubernetesClustersTable: Component<{ clusters: Resource[]; // All Kubernetes-tagged resources from the same query, so the table can @@ -68,6 +83,50 @@ export const KubernetesClustersTable: Component<{ buildKubernetesClusterChildCounts(props.scope, props.clusters), ); + const sort = createPlatformTableSortState({ + storageKey: 'kubernetesClusters', + sortKeys: KUBERNETES_CLUSTER_SORT_KEYS, + descendingFirst: ['nodes', 'pods', 'deployments', 'cpu', 'memory'], + }); + // Closure rather than a module-level accessor: the count columns derive + // from the countsByCluster memo over props.scope. + const getClusterSortValue = ( + cluster: Resource, + key: KubernetesClusterSortKey, + ): PlatformTableSortValue => { + switch (key) { + case 'cluster': + return ( + asTrimmedString(cluster.kubernetes?.clusterName) || + asTrimmedString(cluster.name) || + cluster.id + ); + case 'context': + return asTrimmedString(cluster.kubernetes?.context) || null; + case 'version': + return asTrimmedString(cluster.kubernetes?.version) || null; + case 'nodes': + return countsByCluster().get(cluster.id)?.nodes.total ?? null; + case 'pods': + return countsByCluster().get(cluster.id)?.pods.total ?? null; + case 'deployments': + return countsByCluster().get(cluster.id)?.deployments.total ?? null; + case 'cpu': + return getPlatformTableFiniteMetric(cluster.cpu?.current) ?? null; + case 'memory': { + const total = getPlatformTableFiniteMetric(cluster.memory?.total) ?? 0; + if (total > 0) { + return ((getPlatformTableFiniteMetric(cluster.memory?.used) ?? 0) / total) * 100; + } + return getPlatformTableFiniteMetric(cluster.memory?.current) ?? null; + } + default: + key satisfies never; + return null; + } + }; + const sortedRows = createMemo(() => sort.sortRows(tableState.filtered(), getClusterSortValue)); + return ( 0} @@ -109,43 +168,75 @@ export const KubernetesClustersTable: Component<{ tableClass="min-w-full table-fixed text-xs md:min-w-[1040px]" header={ <> - + Cluster - - - - + + Nodes - - - - + + CPU - - + + Memory - + } body={ <> - + {(cluster) => { const name = () => asTrimmedString(cluster.kubernetes?.clusterName) || diff --git a/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx index 5878a4732..407d6c8b9 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx @@ -1,18 +1,20 @@ -import { For, Show, type Component, type JSX } from 'solid-js'; +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 { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableTextValue, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, summarizePlatformTableValues, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -155,6 +157,34 @@ const serviceAccountRefs = (resource: Resource): { label: string; title: string const labelSummary = (resource: Resource): { label: string; title: string } => summarizePlatformTableValues(resource.tags); +// Data shape, Token refs, and Labels are composite summaries with no single +// scalar to order on, so they stay non-sortable. Lifecycle / trust is a +// single categorical string per row, which makes sorting group like rows. +const KUBERNETES_CONFIG_SORT_KEYS = ['resource', 'kind', 'scope', 'lifecycle'] as const; + +type KubernetesConfigSortKey = (typeof KUBERNETES_CONFIG_SORT_KEYS)[number]; + +const getKubernetesConfigSortValue = ( + resource: Resource, + key: KubernetesConfigSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'resource': + return configName(resource); + case 'kind': + return configKind(resource); + case 'scope': + return kubernetesScopeLabel(resource); + case 'lifecycle': { + const state = lifecycleOrTrust(resource); + return state === '—' ? null : state; + } + default: + key satisfies never; + return null; + } +}; + export const KubernetesConfigTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; @@ -174,6 +204,13 @@ export const KubernetesConfigTable: Component<{ }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-config-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); + const sort = createPlatformTableSortState({ + storageKey: 'kubernetesConfig', + sortKeys: KUBERNETES_CONFIG_SORT_KEYS, + }); + const sortedRows = createMemo(() => + sort.sortRows(tableState.filtered(), getKubernetesConfigSortValue), + ); return ( - + Resource - - + + Kind - - - + + Lifecycle / trust - - + + Data shape - - - + } body={ <> - + {(resource) => { const indicator = () => getSimpleStatusIndicator(resource.status); const name = () => configName(resource); diff --git a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx index 12b4623d3..e1cf0e6b7 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx @@ -1,18 +1,20 @@ 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 { getResourceTypeLabel } from '@/utils/resourceTypePresentation'; import { asTrimmedString } from '@/utils/stringUtils'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableNumberValue, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableTextValue, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -148,6 +150,53 @@ const apiDetail = (resource: Resource): string => { } }; +// The Detail column is a per-kind grab-bag (service names, timestamps, +// generation counters) with no single scalar meaning across rows, so it +// stays non-sortable. +const KUBERNETES_CONTROLLER_SORT_KEYS = [ + 'controller', + 'kind', + 'scope', + 'target', + 'current', + 'ready', + 'available', + 'exceptions', +] as const; + +type KubernetesControllerSortKey = (typeof KUBERNETES_CONTROLLER_SORT_KEYS)[number]; + +const getKubernetesControllerSortValue = ( + resource: Resource, + key: KubernetesControllerSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'controller': + return controllerName(resource); + case 'kind': + return controllerKind(resource); + case 'scope': + return kubernetesScopeLabel(resource); + case 'target': { + const target = targetValue(resource); + return target === '—' ? null : target; + } + case 'current': + return currentValue(resource) ?? null; + case 'ready': + return readyOrDoneValue(resource) ?? null; + case 'available': + return availableValue(resource) ?? null; + case 'exceptions': { + const exceptions = exceptionSummary(resource); + return exceptions === '—' ? null : exceptions; + } + default: + key satisfies never; + return null; + } +}; + export const KubernetesControllersTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; @@ -165,8 +214,19 @@ export const KubernetesControllersTable: Component<{ externalSearch: props.externalSearch, externalStatus: props.externalStatus, }); + // User-controlled sorting layered over the attention-first default: rows + // are pre-sorted by the status compare, so a user sort keeps that order + // for ties and the table falls straight back to it when the sort clears. + const sort = createPlatformTableSortState({ + storageKey: 'kubernetesControllers', + sortKeys: KUBERNETES_CONTROLLER_SORT_KEYS, + descendingFirst: ['current', 'ready', 'available'], + }); const sortedRows = createMemo(() => - [...tableState.filtered()].sort(compareKubernetesControllers), + sort.sortRows( + [...tableState.filtered()].sort(compareKubernetesControllers), + getKubernetesControllerSortValue, + ), ); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-controller-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); @@ -212,41 +272,68 @@ export const KubernetesControllersTable: Component<{ tableClass="min-w-full table-fixed text-xs md:min-w-[1120px]" header={ <> - + Controller - - + + Kind - - - + + Target - - + + Current - - + Ready/Done - - - - Exceptions - - + } body={ diff --git a/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx index e733c97a4..61b2949ab 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx @@ -1,18 +1,21 @@ 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 { asTrimmedString } from '@/utils/stringUtils'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableNumberValue, PlatformTableRelativeTimeValue, PlatformTableToolbar, PlatformTableEmptyState, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableTextValue, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, + getPlatformTableDateTimeSortValue, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -40,6 +43,48 @@ import { // observedGeneration is deliberately not a column: without the spec // generation beside it the raw number is unactionable. +const KUBERNETES_DEPLOYMENT_SORT_KEYS = [ + 'deployment', + 'namespace', + 'cluster', + 'desired', + 'updated', + 'ready', + 'available', + 'age', +] as const; + +type KubernetesDeploymentSortKey = (typeof KUBERNETES_DEPLOYMENT_SORT_KEYS)[number]; + +// Replica counts sort on the same `?? 0` fallback the cells render, so a +// deployment displaying 0 orders as 0 instead of sinking as missing. +const getKubernetesDeploymentSortValue = ( + deployment: Resource, + key: KubernetesDeploymentSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'deployment': + return asTrimmedString(deployment.name) || deployment.id; + case 'namespace': + return asTrimmedString(deployment.kubernetes?.namespace) || null; + case 'cluster': + return kubernetesClusterLabel(deployment) || null; + case 'desired': + return deployment.kubernetes?.desiredReplicas ?? 0; + case 'updated': + return deployment.kubernetes?.updatedReplicas ?? 0; + case 'ready': + return deployment.kubernetes?.readyReplicas ?? 0; + case 'available': + return deployment.kubernetes?.availableReplicas ?? 0; + case 'age': + return getPlatformTableDateTimeSortValue(deployment.kubernetes?.createdAt); + default: + key satisfies never; + return null; + } +}; + export const KubernetesDeploymentsTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; @@ -57,8 +102,19 @@ export const KubernetesDeploymentsTable: Component<{ externalSearch: props.externalSearch, externalStatus: props.externalStatus, }); + // User-controlled sorting layered over the attention-first default: rows + // are pre-sorted by the status compare, so a user sort keeps that order + // for ties and the table falls straight back to it when the sort clears. + const sort = createPlatformTableSortState({ + storageKey: 'kubernetesDeployments', + sortKeys: KUBERNETES_DEPLOYMENT_SORT_KEYS, + descendingFirst: ['desired', 'updated', 'ready', 'available', 'age'], + }); const sortedRows = createMemo(() => - [...tableState.filtered()].sort(compareKubernetesDeployments), + sort.sortRows( + [...tableState.filtered()].sort(compareKubernetesDeployments), + getKubernetesDeploymentSortValue, + ), ); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-deployment-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); @@ -111,38 +167,70 @@ export const KubernetesDeploymentsTable: Component<{ Available) trim to what their headers plus 1-2 digit values need. Mobile widths are unchanged. */} - + Deployment - - - - - - + + Ready - - + + Available - - + + Age - + } body={ diff --git a/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx index 68624f3cc..20e0f504e 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx @@ -1,19 +1,21 @@ -import { For, Show, type Component, type JSX } from 'solid-js'; +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 { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableCountRatioValue, formatPlatformTableTextValue, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, summarizePlatformTableValues, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -100,6 +102,33 @@ const targetSummary = (resource: Resource): { label: string; title: string } => }; }; +// Address / hosts, Ports, and Targets are composite summaries with no single +// scalar to order on, so they stay non-sortable. +const KUBERNETES_NETWORKING_SORT_KEYS = ['resource', 'kind', 'scope', 'typeClass'] as const; + +type KubernetesNetworkingSortKey = (typeof KUBERNETES_NETWORKING_SORT_KEYS)[number]; + +const getKubernetesNetworkingSortValue = ( + resource: Resource, + key: KubernetesNetworkingSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'resource': + return resourceName(resource); + case 'kind': + return networkKind(resource); + case 'scope': + return kubernetesScopeLabel(resource); + case 'typeClass': { + const typeClass = typeOrClass(resource); + return typeClass === '—' ? null : typeClass; + } + default: + key satisfies never; + return null; + } +}; + export const KubernetesNetworkingTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; @@ -119,6 +148,13 @@ export const KubernetesNetworkingTable: Component<{ }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-networking-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); + const sort = createPlatformTableSortState({ + storageKey: 'kubernetesNetworking', + sortKeys: KUBERNETES_NETWORKING_SORT_KEYS, + }); + const sortedRows = createMemo(() => + sort.sortRows(tableState.filtered(), getKubernetesNetworkingSortValue), + ); return ( - + Resource - - + + Kind - - - + + Type / class - - - + + Ports - - + } body={ <> - + {(resource) => { const indicator = () => getSimpleStatusIndicator(resource.status); const name = () => resourceName(resource); diff --git a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx index b11467088..ab4fd8d5f 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.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 { asTrimmedString } from '@/utils/stringUtils'; import { getAlertStyles } from '@/utils/alerts'; import { useWebSocket } from '@/contexts/appRuntime'; @@ -10,17 +10,19 @@ import { useAlertsActivation } from '@/stores/alertsActivation'; import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableMetricFallback, PlatformTableEmptyState, PlatformTableShell, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableBytesValue, formatPlatformTableTextValue, formatPlatformTableUptimeValue, getPlatformTableFiniteMetric, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -55,6 +57,61 @@ const formatRoles = (roles: string[] | undefined): string => { return roles.map((role) => role.replace('node-role.kubernetes.io/', '')).join(', '); }; +const KUBERNETES_NODE_SORT_KEYS = [ + 'node', + 'cluster', + 'roles', + 'kubelet', + 'runtime', + 'cpu', + 'memory', + 'uptime', + 'capacity', +] as const; + +type KubernetesNodeSortKey = (typeof KUBERNETES_NODE_SORT_KEYS)[number]; + +// Scalar per column that user-controlled sorting orders on. Capacity is a +// composite label (cores / bytes / pods); CPU core count is its dominant +// scalar, so that is what the column sorts by. +const getKubernetesNodeSortValue = ( + node: Resource, + key: KubernetesNodeSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'node': + return asTrimmedString(node.name) || node.id; + case 'cluster': + return kubernetesClusterLabel(node) || null; + case 'roles': { + const roles = formatRoles(node.kubernetes?.roles); + return roles === '—' ? null : roles; + } + case 'kubelet': + return asTrimmedString(node.kubernetes?.kubeletVersion) || null; + case 'runtime': + return asTrimmedString(node.kubernetes?.containerRuntimeVersion) || null; + case 'cpu': + return getPlatformTableFiniteMetric(node.cpu?.current) ?? null; + case 'memory': { + const total = getPlatformTableFiniteMetric(node.memory?.total) ?? 0; + if (total > 0) { + return ((getPlatformTableFiniteMetric(node.memory?.used) ?? 0) / total) * 100; + } + return getPlatformTableFiniteMetric(node.memory?.current) ?? null; + } + case 'uptime': + return getPlatformTableFiniteMetric(node.uptime) ?? null; + case 'capacity': { + const cores = node.kubernetes?.capacityCpuCores; + return typeof cores === 'number' && cores > 0 ? cores : null; + } + default: + key satisfies never; + return null; + } +}; + export const KubernetesNodesTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; @@ -73,7 +130,20 @@ export const KubernetesNodesTable: Component<{ }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-node-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); - const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareKubernetesNodes)); + // User-controlled sorting layered over the attention-first default: rows + // are pre-sorted by the status compare, so a user sort keeps that order + // for ties and the table falls straight back to it when the sort clears. + const sort = createPlatformTableSortState({ + storageKey: 'kubernetesNodes', + sortKeys: KUBERNETES_NODE_SORT_KEYS, + descendingFirst: ['cpu', 'memory', 'uptime', 'capacity'], + }); + const sortedRows = createMemo(() => + sort.sortRows( + [...tableState.filtered()].sort(compareKubernetesNodes), + getKubernetesNodeSortValue, + ), + ); return ( + Node - - - - - - + + CPU - - + + Memory - - - + Capacity - + } body={ diff --git a/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx index 5168fb9e8..ca8190162 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPodsTable.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 { asTrimmedString } from '@/utils/stringUtils'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableNumberValue, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableTextValue, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -71,6 +73,62 @@ const ageValue = (resource: Resource): string => { return `${Math.floor(seconds / 86_400)}d`; }; +const KUBERNETES_POD_SORT_KEYS = [ + 'pod', + 'scope', + 'node', + 'status', + 'ready', + 'restarts', + 'owner', + 'image', + 'age', +] as const; + +type KubernetesPodSortKey = (typeof KUBERNETES_POD_SORT_KEYS)[number]; + +// Scalar per column that user-controlled sorting orders on. Ready sorts by +// the ready fraction (ascending-first, so the least-ready pods surface on +// the first click); '—' style empty renders map to null so rows without +// the datum sink to the bottom. +const getKubernetesPodSortValue = ( + resource: Resource, + key: KubernetesPodSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'pod': + return podName(resource); + case 'scope': + return kubernetesScopeLabel(resource); + case 'node': + return asTrimmedString(resource.kubernetes?.nodeName) || null; + case 'status': + return mapKubernetesPodStatus(resource).label; + case 'ready': { + const containers = podContainers(resource); + if (containers.length === 0) return null; + return containers.filter((container) => container.ready).length / containers.length; + } + case 'restarts': + return restartCount(resource) ?? null; + case 'owner': { + const owner = ownerValue(resource); + return owner === '—' ? null : owner; + } + case 'image': { + const image = imageValue(resource); + return image === '—' ? null : image; + } + case 'age': { + const seconds = resource.kubernetes?.uptimeSeconds ?? resource.uptime; + return typeof seconds === 'number' && seconds >= 0 ? seconds : null; + } + default: + key satisfies never; + return null; + } +}; + export const KubernetesPodsTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; @@ -88,7 +146,17 @@ export const KubernetesPodsTable: Component<{ externalSearch: props.externalSearch, externalStatus: props.externalStatus, }); - const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareKubernetesPods)); + // User-controlled sorting layered over the attention-first default: rows + // are pre-sorted by the status compare, so a user sort keeps that order + // for ties and the table falls straight back to it when the sort clears. + const sort = createPlatformTableSortState({ + storageKey: 'kubernetesPods', + sortKeys: KUBERNETES_POD_SORT_KEYS, + descendingFirst: ['restarts', 'age'], + }); + const sortedRows = createMemo(() => + sort.sortRows([...tableState.filtered()].sort(compareKubernetesPods), getKubernetesPodSortValue), + ); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-pod-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); @@ -133,43 +201,68 @@ export const KubernetesPodsTable: Component<{ tableClass="min-w-full table-fixed text-xs md:min-w-[1240px]" header={ <> - + Pod - - - - + + Status - - + + Ready - - + + Restarts - - - - + } body={ diff --git a/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx index 4090e590f..61d9b414f 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx @@ -1,18 +1,20 @@ -import { For, Show, type Component, type JSX } from 'solid-js'; +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 { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableTextValue, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, summarizePlatformTableValues, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -59,6 +61,31 @@ const selectorSummary = (resource: Resource): { label: string; title: string } = const externalIpSummary = (resource: Resource): { label: string; title: string } => summarizePlatformTableValues(resource.kubernetes?.externalIps); +// The multi-value summary columns (External IPs, Ports, Selector) carry no +// single scalar to order on, so they stay non-sortable. +const KUBERNETES_SERVICE_SORT_KEYS = ['service', 'scope', 'type', 'clusterIp'] as const; + +type KubernetesServiceSortKey = (typeof KUBERNETES_SERVICE_SORT_KEYS)[number]; + +const getKubernetesServiceSortValue = ( + resource: Resource, + key: KubernetesServiceSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'service': + return serviceName(resource); + case 'scope': + return kubernetesScopeLabel(resource); + case 'type': + return asTrimmedString(resource.kubernetes?.serviceType) || null; + case 'clusterIp': + return asTrimmedString(resource.kubernetes?.clusterIp) || null; + default: + key satisfies never; + return null; + } +}; + export const KubernetesServicesTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; @@ -78,6 +105,13 @@ export const KubernetesServicesTable: Component<{ }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-service-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); + const sort = createPlatformTableSortState({ + storageKey: 'kubernetesServices', + sortKeys: KUBERNETES_SERVICE_SORT_KEYS, + }); + const sortedRows = createMemo(() => + sort.sortRows(tableState.filtered(), getKubernetesServiceSortValue), + ); return ( - + Service - - - + + Type - - + + Cluster IP - - - + + Ports - - + } body={ <> - + {(resource) => { const indicator = () => getSimpleStatusIndicator(resource.status); const name = () => serviceName(resource); diff --git a/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx index a1247297c..f542d4143 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx @@ -1,18 +1,20 @@ -import { For, Show, type Component, type JSX } from 'solid-js'; +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 { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableBytesValue, formatPlatformTableTextValue, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -143,6 +145,63 @@ const bindingTarget = (resource: Resource): { label: string; title: string } => return { label: volumeName || 'Unbound', title: volumeName || 'Unbound' }; }; +// Access / policy is a composite label (access modes + reclaim policy + +// expansion), so it stays non-sortable. Size sorts on the same bytes the +// capacity label renders (capacity, falling back to the PVC request). +const KUBERNETES_STORAGE_SORT_KEYS = [ + 'resource', + 'kind', + 'scope', + 'phase', + 'class', + 'size', + 'provider', +] as const; + +type KubernetesStorageSortKey = (typeof KUBERNETES_STORAGE_SORT_KEYS)[number]; + +const getKubernetesStorageSortValue = ( + resource: Resource, + key: KubernetesStorageSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'resource': + return storageName(resource); + case 'kind': + return storageKind(resource); + case 'scope': + return kubernetesScopeLabel(resource); + case 'phase': { + const phase = bindingOrPhase(resource); + return phase === '—' ? null : phase; + } + case 'class': { + const klass = storageClass(resource); + return klass === '—' ? null : klass; + } + case 'size': { + const capacity = resource.kubernetes?.capacityBytes; + if (typeof capacity === 'number' && capacity > 0) return capacity; + const requested = resource.kubernetes?.requestedBytes; + if ( + resource.type === 'k8s-persistent-volume-claim' && + typeof requested === 'number' && + requested > 0 + ) { + return requested; + } + return null; + } + case 'provider': { + const target = bindingTarget(resource).label; + return target === '—' ? null : target; + } + default: + key satisfies never; + return null; + } +}; + export const KubernetesStorageTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; @@ -158,6 +217,14 @@ export const KubernetesStorageTable: Component<{ }); const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-storage-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); + const sort = createPlatformTableSortState({ + storageKey: 'kubernetesStorage', + sortKeys: KUBERNETES_STORAGE_SORT_KEYS, + descendingFirst: ['size'], + }); + const sortedRows = createMemo(() => + sort.sortRows(tableState.filtered(), getKubernetesStorageSortValue), + ); return ( - + Resource - - + + Kind - - - + + Binding / phase - - - - Size - - - + } body={ <> - + {(resource) => { const indicator = () => getSimpleStatusIndicator(resource.status); const name = () => storageName(resource); diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPodsTable.sort.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPodsTable.sort.test.tsx new file mode 100644 index 000000000..6e9f41edb --- /dev/null +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPodsTable.sort.test.tsx @@ -0,0 +1,118 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { Resource } from '@/types/resource'; +import { KubernetesPodsTable } from '../KubernetesPodsTable'; + +const makePod = ({ id, ...overrides }: Partial & Pick): Resource => ({ + id, + name: id, + displayName: id, + platformId: 'cluster-1', + platformType: 'kubernetes', + sourceType: 'agent', + sources: ['kubernetes'], + status: 'online', + type: 'pod', + lastSeen: 1_700_000_000_000, + ...overrides, +}); + +// Mixed statuses so both the attention-first default order and the user +// sort are observable: zulu is the only danger row and floats by default. +const FIXTURE: Resource[] = [ + makePod({ + id: 'alpha', + kubernetes: { + podPhase: 'Running', + podContainers: [{ ready: true, state: 'running', restartCount: 9 }], + }, + }), + makePod({ + id: 'zulu', + kubernetes: { + podPhase: 'Running', + podContainers: [{ ready: false, state: 'waiting', reason: 'CrashLoopBackOff', restartCount: 3 }], + }, + }), + makePod({ + id: 'mike', + kubernetes: { + podPhase: 'Running', + }, + }), +]; + +const renderTable = () => + render(() => ( + } + emptyTitle="No pods" + emptyDescription="No pods" + showToolbar={false} + /> + )); + +const visibleRowOrder = (): string[] => + Array.from(document.querySelectorAll('tr[data-kubernetes-pod-row]')).map( + (row) => row.getAttribute('data-kubernetes-pod-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('KubernetesPodsTable user sorting', () => { + it('cycles a name sort and falls back to the attention-first order', () => { + renderTable(); + + // Built-in order: attention first (zulu is CrashLoopBackOff), then names. + expect(visibleRowOrder()).toEqual(['zulu', 'alpha', 'mike']); + + fireEvent.click(headerFor('Pod')); + expect(headerFor('Pod')).toHaveAttribute('aria-sort', 'ascending'); + expect(visibleRowOrder()).toEqual(['alpha', 'mike', 'zulu']); + + fireEvent.click(headerFor('Pod')); + expect(headerFor('Pod')).toHaveAttribute('aria-sort', 'descending'); + expect(visibleRowOrder()).toEqual(['zulu', 'mike', 'alpha']); + + // Third click clears back to the built-in attention-first order. + fireEvent.click(headerFor('Pod')); + expect(headerFor('Pod')).not.toHaveAttribute('aria-sort'); + expect(visibleRowOrder()).toEqual(['zulu', 'alpha', 'mike']); + }); + + it('sorts restarts descending first with missing values last', () => { + renderTable(); + + fireEvent.click(headerFor('Restarts')); + expect(headerFor('Restarts')).toHaveAttribute('aria-sort', 'descending'); + // mike reports no containers and no restart count, so it sinks. + expect(visibleRowOrder()).toEqual(['alpha', 'zulu', 'mike']); + }); + + it('persists the chosen sort across a remount', () => { + renderTable(); + fireEvent.click(headerFor('Pod')); + expect(visibleRowOrder()).toEqual(['alpha', 'mike', 'zulu']); + expect(window.localStorage.getItem('kubernetesPodsSortKey')).toBe('pod'); + expect(window.localStorage.getItem('kubernetesPodsSortDirection')).toBe('asc'); + + cleanup(); + + renderTable(); + expect(headerFor('Pod')).toHaveAttribute('aria-sort', 'ascending'); + expect(visibleRowOrder()).toEqual(['alpha', 'mike', 'zulu']); + }); +}); 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 54640361c..a1d1915ab 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -410,18 +410,12 @@ describe('platform overview layout guardrails', () => { '{compactCapacityLabel()}', ); - expect(truenasSystemsTableSource).toMatch( - /getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?System/, - ); + expect(truenasSystemsTableSource).toMatch(/kind="name"[\s\S]{0,200}?System/); expect(truenasSystemsTableSource).toMatch( /\s*\s*<\/span>/, ); - expect(vsphereHostsTableSource).toMatch( - /getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?Host/, - ); - expect(vsphereHostsTableSource).toMatch( - /getPlatformTableHeadClassForKind\('numeric-value'\)[\s\S]{0,200}?VMs/, - ); + expect(vsphereHostsTableSource).toMatch(/kind="name"[\s\S]{0,200}?Host/); + expect(vsphereHostsTableSource).toMatch(/kind="numeric-value"[\s\S]{0,200}?VMs/); expect(vsphereHostsTableSource).toContain('hidden md:table-cell'); // AgentsMachinesTable uses a column-config pattern: kind helpers are // applied dynamically in the table render, with labels living in the