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.
This commit is contained in:
rcourtman 2026-07-11 00:11:04 +01:00
parent 20cccc1a91
commit f66c4b46b3
12 changed files with 1113 additions and 255 deletions

View file

@ -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 (
<Show
@ -116,39 +161,60 @@ export const KubernetesAutoscalingTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[1080px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[20%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="autoscaler"
class="md:w-[20%]"
>
Autoscaler
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[16%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="scope"
class="hidden md:table-cell md:w-[16%]"
>
Scope
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[18%]`}>
Scale target
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[10%]`}>
Bounds
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[9%]`}>
Current
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[9%]`}>
Desired
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[11%]`}>
Metrics
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[7%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="target"
class="md:w-[18%]"
>
Scale target
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="md:w-[10%]">
Bounds
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="current"
class="md:w-[9%]"
>
Current
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="desired"
class="md:w-[9%]"
>
Desired
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="md:w-[11%]">
Metrics
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[7%]">
Labels
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => autoscalerName(resource);

View file

@ -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 (
<Show
when={props.clusters.length > 0}
@ -109,43 +168,75 @@ export const KubernetesClustersTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[1040px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[17%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="cluster"
class="md:w-[17%]"
>
Cluster
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[15%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="context"
class="hidden md:table-cell md:w-[15%]"
>
Context
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="version"
class="hidden md:table-cell md:w-[10%]"
>
Version
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="nodes"
class="md:w-[8%]"
>
Nodes
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="pods"
class="hidden md:table-cell md:w-[8%]"
>
Pods
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[12%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="deployments"
class="hidden md:table-cell md:w-[12%]"
>
Deployments
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[15%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="cpu"
class="md:w-[15%]"
>
CPU
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[15%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="memory"
class="md:w-[15%]"
>
Memory
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(cluster) => {
const name = () =>
asTrimmedString(cluster.kubernetes?.clusterName) ||

View file

@ -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 (
<Show
@ -216,38 +253,47 @@ export const KubernetesConfigTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[1160px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[18%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="resource"
class="md:w-[18%]"
>
Resource
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[13%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} sortKey="kind" class="md:w-[13%]">
Kind
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[16%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="scope"
class="hidden md:table-cell md:w-[16%]"
>
Scope
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[16%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="lifecycle"
class="md:w-[16%]"
>
Lifecycle / trust
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[14%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="md:w-[14%]">
Data shape
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[13%]">
Token refs
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[10%]">
Labels
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => configName(resource);

View file

@ -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={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[18%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="controller"
class="md:w-[18%]"
>
Controller
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[9%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} sortKey="kind" class="md:w-[9%]">
Kind
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="scope"
class="hidden md:table-cell md:w-[14%]"
>
Scope
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="target"
class="md:w-[12%]"
>
Target
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="current"
class="md:w-[8%]"
>
Current
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="ready"
class="md:w-[10%]"
>
Ready/Done
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[9%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="available"
class="hidden md:table-cell md:w-[9%]"
>
Available
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[11%]`}>
Exceptions
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[9%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="exceptions"
class="md:w-[11%]"
>
Exceptions
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[9%]">
Detail
</TableHead>
</PlatformSortableTableHead>
</>
}
body={

View file

@ -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.
*/}
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[25%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="deployment"
class="md:w-[25%]"
>
Deployment
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[20%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="namespace"
class="hidden md:table-cell md:w-[20%]"
>
Namespace
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[17%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="cluster"
class="hidden md:table-cell md:w-[17%]"
>
Cluster
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="desired"
class="hidden md:table-cell md:w-[8%]"
>
Desired
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="updated"
class="hidden md:table-cell md:w-[8%]"
>
Updated
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[7%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="ready"
class="md:w-[7%]"
>
Ready
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[9%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="available"
class="md:w-[9%]"
>
Available
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[6%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="age"
class="md:w-[6%]"
>
Age
</TableHead>
</PlatformSortableTableHead>
</>
}
body={

View file

@ -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 (
<Show
@ -161,38 +197,47 @@ export const KubernetesNetworkingTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[1180px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[19%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="resource"
class="md:w-[19%]"
>
Resource
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} sortKey="kind" class="md:w-[12%]">
Kind
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="scope"
class="hidden md:table-cell md:w-[14%]"
>
Scope
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="typeClass"
class="md:w-[12%]"
>
Type / class
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[18%]">
Address / hosts
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="md:w-[12%]">
Ports
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[13%]">
Targets
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => resourceName(resource);

View file

@ -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 (
<Show
@ -126,45 +196,73 @@ export const KubernetesNodesTable: Component<{
desktop gets extra room without forcing normal desktop
viewports to hide Capacity behind horizontal scroll.
*/}
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[15%]`}>
<PlatformSortableTableHead kind="name" sort={sort} sortKey="node" class="md:w-[15%]">
Node
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="cluster"
class="hidden md:table-cell md:w-[10%]"
>
Cluster
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="roles"
class="hidden md:table-cell md:w-[10%]"
>
Roles
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[8%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="kubelet"
class="hidden md:table-cell md:w-[8%]"
>
Kubelet
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[15%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="runtime"
class="hidden md:table-cell md:w-[15%]"
>
Runtime
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[11%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="cpu"
class="md:w-[11%]"
>
CPU
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[11%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="metric-bar"
sort={sort}
sortKey="memory"
class="md:w-[11%]"
>
Memory
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[6%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="uptime"
class="hidden md:table-cell md:w-[6%]"
>
Uptime
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[14%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="capacity"
class="md:w-[14%]"
>
Capacity
</TableHead>
</PlatformSortableTableHead>
</>
}
body={

View file

@ -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={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[20%]`}>
<PlatformSortableTableHead kind="name" sort={sort} sortKey="pod" class="md:w-[20%]">
Pod
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="scope"
class="hidden md:table-cell md:w-[13%]"
>
Scope
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="node"
class="hidden md:table-cell md:w-[13%]"
>
Node
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[8%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} sortKey="status" class="md:w-[8%]">
Status
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[7%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="ready"
class="md:w-[7%]"
>
Ready
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="restarts"
class="md:w-[8%]"
>
Restarts
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="owner"
class="hidden md:table-cell md:w-[13%]"
>
Owner
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="image"
class="hidden md:table-cell md:w-[14%]"
>
Image
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[4%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="age"
class="hidden md:table-cell md:w-[4%]"
>
Age
</TableHead>
</PlatformSortableTableHead>
</>
}
body={

View file

@ -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 (
<Show
@ -120,38 +154,47 @@ export const KubernetesServicesTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[1120px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[19%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="service"
class="md:w-[19%]"
>
Service
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[15%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="scope"
class="hidden md:table-cell md:w-[15%]"
>
Scope
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[11%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} sortKey="type" class="md:w-[11%]">
Type
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[13%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="clusterIp"
class="md:w-[13%]"
>
Cluster IP
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[13%]">
External IPs
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[16%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="md:w-[16%]">
Ports
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[13%]">
Selector
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => serviceName(resource);

View file

@ -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 (
<Show
@ -200,43 +267,65 @@ export const KubernetesStorageTable: Component<{
tableClass="min-w-full table-fixed text-xs md:min-w-[1120px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[19%]`}>
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="resource"
class="md:w-[19%]"
>
Resource
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[10%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} sortKey="kind" class="md:w-[10%]">
Kind
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="scope"
class="hidden md:table-cell md:w-[13%]"
>
Scope
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[14%]`}>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="phase"
class="md:w-[14%]"
>
Binding / phase
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="class"
class="hidden md:table-cell md:w-[10%]"
>
Class
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}>
Size
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[12%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="numeric-value"
sort={sort}
sortKey="size"
class="md:w-[8%]"
>
Size
</PlatformSortableTableHead>
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[12%]">
Access / policy
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="provider"
class="hidden md:table-cell md:w-[14%]"
>
Provider / binding
</TableHead>
</PlatformSortableTableHead>
</>
}
body={
<>
<For each={tableState.filtered()}>
<For each={sortedRows()}>
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => storageName(resource);

View file

@ -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<Resource> & Pick<Resource, 'id'>): 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(() => (
<KubernetesPodsTable
resources={FIXTURE}
emptyIcon={<span />}
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']);
});
});

View file

@ -410,18 +410,12 @@ describe('platform overview layout guardrails', () => {
'<span class="md:hidden">{compactCapacityLabel()}</span>',
);
expect(truenasSystemsTableSource).toMatch(
/getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?System/,
);
expect(truenasSystemsTableSource).toMatch(/kind="name"[\s\S]{0,200}?System/);
expect(truenasSystemsTableSource).toMatch(
/<span class="md:hidden">\s*<PlatformTablePercentValue value={storagePercent\(\)} \/>\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