From 20cccc1a91158d5520df8443f4035fc616901916 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 10 Jul 2026 23:43:45 +0100 Subject: [PATCH] Restore user column sorting on Docker platform tables v5 parity: v6 platform tables shipped with fixed status-first ordering and no sortable headers. Adds opt-in sortable-header support to the shared platform table fabric (createPlatformTableSortState + PlatformSortableTableHead in sharedPlatformPage.tsx): click cycles first-direction -> flipped -> built-in order, state persists per table via usePersistentSignal, missing values sink to the bottom, aria-sort and arrow indicator match the workloads table, and alignment stays on the canonical kind helpers. Adopted across the Docker tables (containers, hosts, images, services, volumes, tasks, swarm nodes, secrets, configs). Container sorting applies within host groups and stays orthogonal to grouping, unlike v5's sort-by-host-equals-grouping design. Kubernetes and TrueNAS/vSphere adoption are follow-ups. --- .../features/docker/DockerConfigsTable.tsx | 89 +++++-- .../features/docker/DockerContainersTable.tsx | 77 +++++- .../src/features/docker/DockerHostsTable.tsx | 183 ++++++++++---- .../src/features/docker/DockerImagesTable.tsx | 97 ++++++-- .../features/docker/DockerSecretsTable.tsx | 100 ++++++-- .../features/docker/DockerServicesTable.tsx | 136 +++++++++-- .../features/docker/DockerSwarmNodesTable.tsx | 145 +++++++++-- .../src/features/docker/DockerTasksTable.tsx | 119 +++++++-- .../features/docker/DockerVolumesTable.tsx | 121 +++++++-- .../DockerContainersTable.sort.test.tsx | 230 ++++++++++++++++++ .../docker/dockerContainerTableModel.ts | 23 ++ .../platformOverviewLayout.guardrails.test.ts | 33 +-- .../__tests__/platformTableSortState.test.ts | 130 ++++++++++ .../platformPage/sharedPlatformPage.tsx | 151 +++++++++++- 14 files changed, 1424 insertions(+), 210 deletions(-) create mode 100644 frontend-modern/src/features/docker/__tests__/DockerContainersTable.sort.test.tsx create mode 100644 frontend-modern/src/features/platformPage/__tests__/platformTableSortState.test.ts diff --git a/frontend-modern/src/features/docker/DockerConfigsTable.tsx b/frontend-modern/src/features/docker/DockerConfigsTable.tsx index fc88f227b..de0a7c96c 100644 --- a/frontend-modern/src/features/docker/DockerConfigsTable.tsx +++ b/frontend-modern/src/features/docker/DockerConfigsTable.tsx @@ -1,22 +1,53 @@ -import { For, Show, type Component } from 'solid-js'; -import { TableCell, TableHead, TableRow } from '@/components/shared/Table'; +import { For, Show, createMemo, type Component } from 'solid-js'; +import { TableCell, TableRow } from '@/components/shared/Table'; +import { asTrimmedString } from '@/utils/stringUtils'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, + getPlatformTableDateTimeSortValue, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, dockerHostName, dockerLabelsSummary, + dockerResourceName, dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel'; +import type { Resource } from '@/types/resource'; + +const DOCKER_CONFIG_SORT_KEYS = ['config', 'template', 'created', 'host'] as const; + +type DockerConfigSortKey = (typeof DOCKER_CONFIG_SORT_KEYS)[number]; + +const getDockerConfigSortValue = ( + resource: Resource, + key: DockerConfigSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'config': + return dockerResourceName(resource); + case 'template': + return asTrimmedString(resource.docker?.templatingDriver) || null; + case 'created': + return getPlatformTableDateTimeSortValue(resource.docker?.objectCreatedAt); + case 'host': { + const host = dockerHostName(resource); + return host === '—' ? null : host; + } + default: + key satisfies never; + return null; + } +}; export const DockerConfigsTable: Component = (props) => { const tableState = createPlatformTableFilterState({ @@ -24,6 +55,12 @@ export const DockerConfigsTable: Component = (props) => initialStatus: 'all' as DockerResourceStatusFilter, filter: filterDockerResources, }); + const sort = createPlatformTableSortState({ + storageKey: 'dockerConfigs', + sortKeys: DOCKER_CONFIG_SORT_KEYS, + descendingFirst: ['created'], + }); + const sortedRows = createMemo(() => sort.sortRows(tableState.filtered(), getDockerConfigSortValue)); return ( = (props) => tableClass="min-w-full table-fixed text-xs md:min-w-[1040px]" header={ <> - + Config - - + + Template - - - - + + Host - + } body={ <> - + {(resource) => ( diff --git a/frontend-modern/src/features/docker/DockerContainersTable.tsx b/frontend-modern/src/features/docker/DockerContainersTable.tsx index 6e1ca4ddf..3a845b7b7 100644 --- a/frontend-modern/src/features/docker/DockerContainersTable.tsx +++ b/frontend-modern/src/features/docker/DockerContainersTable.tsx @@ -19,20 +19,22 @@ import { getSimpleStatusIndicator } from '@/utils/status'; import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar'; import { getWorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel'; import { useBreakpoint } from '@/hooks/useBreakpoint'; -import { TableCell, TableHead, TableRow } from '@/components/shared/Table'; +import { TableCell, TableRow } from '@/components/shared/Table'; import { asTrimmedString } from '@/utils/stringUtils'; import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableMetricFallback, PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, getPlatformTableFiniteMetric, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -57,9 +59,12 @@ import { type DockerResourceStatusFilter, } from './dockerPageModel'; import { + DOCKER_CONTAINER_SORTABLE_COLUMN_IDS, getDockerContainerColumnWidthStyle, + getDockerContainerSortKey, getDockerContainerTableMinWidthClass, getDockerContainerVisibleColumnsForLayout, + type DockerContainerSortKey, type DockerContainerTableColumn, } from './dockerContainerTableModel'; import type { DockerContainerUpdateStatus } from '@/types/api'; @@ -199,6 +204,52 @@ const containerUpdateAction = ( }; }; +// Scalar per column that user-controlled sorting orders on. '—' style empty +// renders map to null so rows without the datum sink to the bottom. +const getDockerContainerSortValue = ( + resource: Resource, + key: DockerContainerSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'container': + return dockerResourceName(resource); + case 'host': { + const host = dockerHostName(resource); + return host === '—' ? null : host; + } + case 'runtime': { + const runtime = runtimeSummary(resource); + return runtime === '—' ? null : runtime; + } + case 'image': + return asTrimmedString(resource.docker?.image) || null; + case 'state': { + const state = containerState(resource); + return state === '—' ? null : state; + } + case 'cpu': + return getPlatformTableFiniteMetric(resource.cpu?.current) ?? null; + case 'memory': { + const total = getPlatformTableFiniteMetric(resource.memory?.total) ?? 0; + if (total > 0) { + return ((getPlatformTableFiniteMetric(resource.memory?.used) ?? 0) / total) * 100; + } + return getPlatformTableFiniteMetric(resource.memory?.current) ?? null; + } + case 'restarts': + return typeof resource.docker?.restartCount === 'number' + ? resource.docker.restartCount + : null; + case 'updates': { + const label = updateStatusLabel(resource); + return label === '—' ? null : label; + } + default: + key satisfies never; + return null; + } +}; + const DOCKER_CONTAINER_SEARCH_TIPS = { popoverId: 'docker-containers-search-help', intro: 'Filter containers by name, image, compose stack, or label.', @@ -299,7 +350,19 @@ export const DockerContainersTable: Component = (pro }, { replace: true }, ); - const sortedRows = createMemo(() => [...scopedRows()].sort(compareDockerContainers)); + // 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 is cleared. + // Sorting reorders rows only; grouped mode re-buckets the sorted rows, so + // the sort applies within each host group and stays orthogonal to grouping. + const sort = createPlatformTableSortState({ + storageKey: 'dockerContainers', + sortKeys: DOCKER_CONTAINER_SORTABLE_COLUMN_IDS, + descendingFirst: ['cpu', 'memory', 'restarts'], + }); + const sortedRows = createMemo(() => + sort.sortRows([...scopedRows()].sort(compareDockerContainers), getDockerContainerSortValue), + ); // Grouped-by-host view, mirroring the workloads table: preference persists // locally (not in the URL) and only applies once the fleet spans more than // one host; a single-host list gains nothing from a group header. @@ -708,12 +771,16 @@ export const DockerContainersTable: Component = (pro <> {(column) => ( - + {column.label}}> Mem - + )} diff --git a/frontend-modern/src/features/docker/DockerHostsTable.tsx b/frontend-modern/src/features/docker/DockerHostsTable.tsx index 464575e0d..5686e94c0 100644 --- a/frontend-modern/src/features/docker/DockerHostsTable.tsx +++ b/frontend-modern/src/features/docker/DockerHostsTable.tsx @@ -5,7 +5,7 @@ import { DockerHostDrawer } from './DockerHostDrawer'; import { ResponsiveMetricCell } from '@/components/shared/responsive'; import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar'; import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar'; -import { TableCell, TableHead, TableRow } from '@/components/shared/Table'; +import { TableCell, TableRow } from '@/components/shared/Table'; import { getSimpleStatusIndicator } from '@/utils/status'; import { getAlertStyles } from '@/utils/alerts'; import { useWebSocket } from '@/contexts/appRuntime'; @@ -15,16 +15,18 @@ import { normalizeDiskArray } from '@/utils/format'; import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableMetricFallback, PlatformTableEmptyState, PlatformTableTemperatureValue, PlatformTableShell, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableUptimeValue, getPlatformTableFiniteMetric, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow'; import type { Disk } from '@/types/api'; @@ -66,6 +68,20 @@ const memoryPercentOnlyFor = (host: Resource): number | undefined => { ); }; +// Host telemetry the Docker agent reports beyond the typed Resource docker +// block. One cast site shared by the row renderer and the sort accessor. +type DockerHostDockerMeta = NonNullable & { + runtime?: string; + runtimeVersion?: string; + containerCount?: number; + uptimeSeconds?: number; + temperature?: number; + swarm?: { nodeRole?: string }; +}; + +const dockerHostMeta = (host: Resource): DockerHostDockerMeta | undefined => + host.docker as DockerHostDockerMeta | undefined; + const aggregateDiskFor = (host: Resource): Disk | undefined => { if (!host.disk) return undefined; const total = getPlatformTableFiniteMetric(host.disk.total) ?? 0; @@ -80,6 +96,59 @@ const aggregateDiskFor = (host: Resource): Disk | undefined => { return { total, used, free, usage }; }; +const DOCKER_HOST_SORT_KEYS = [ + 'host', + 'system', + 'version', + 'containers', + 'cpu', + 'memory', + 'disk', + 'uptime', + 'temp', + 'swarm', +] as const; + +type DockerHostSortKey = (typeof DOCKER_HOST_SORT_KEYS)[number]; + +const getDockerHostSortValue = ( + host: Resource, + key: DockerHostSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'host': + return asTrimmedString(host.name) || host.id; + case 'system': + return getDockerHostSystemBadge(host)?.label ?? null; + case 'version': + return asTrimmedString(dockerHostMeta(host)?.runtimeVersion) || null; + case 'containers': + return dockerHostMeta(host)?.containerCount ?? null; + case 'cpu': + return percentFromMetric(host.cpu) ?? null; + case 'memory': { + const total = memoryTotalFor(host); + if (total > 0) return (memoryUsedFor(host) / total) * 100; + return memoryPercentOnlyFor(host) ?? null; + } + case 'disk': + return aggregateDiskFor(host)?.usage ?? null; + case 'uptime': + return host.uptime ?? dockerHostMeta(host)?.uptimeSeconds ?? null; + case 'temp': { + const temp = host.temperature ?? dockerHostMeta(host)?.temperature; + return typeof temp === 'number' && Number.isFinite(temp) && temp > 0 ? temp : null; + } + case 'swarm': { + if (!hasDockerSwarmEvidence(host)) return null; + return asTrimmedString(dockerHostMeta(host)?.swarm?.nodeRole) || null; + } + default: + key satisfies never; + return null; + } +}; + export const DockerHostsTable: Component<{ resources: Resource[]; sourceCount?: number; @@ -100,6 +169,12 @@ export const DockerHostsTable: Component<{ const showSwarmColumn = createMemo(() => props.resources.some(hasDockerSwarmEvidence)); const [selectedHostId, setSelectedHostId] = createSignal(null); const drawerColspan = createMemo(() => (showSwarmColumn() ? 10 : 9)); + const sort = createPlatformTableSortState({ + storageKey: 'dockerHosts', + sortKeys: DOCKER_HOST_SORT_KEYS, + descendingFirst: ['containers', 'cpu', 'memory', 'disk', 'uptime', 'temp'], + }); + const sortedHosts = createMemo(() => sort.sortRows(tableState.filtered(), getDockerHostSortValue)); const hasFilteredSourceRows = () => (props.sourceCount ?? props.resources.length) > 0; @@ -154,74 +229,96 @@ export const DockerHostsTable: Component<{ bars aren't squeezed by table-fixed's equal split. Mobile widths (w-[40%], w-[20%]) are unchanged. */} - + Host - - - - - + CPU - - + Mem - - + Disk - - - + - + } body={ <> - + {(host) => { - const docker = () => - host.docker as - | (NonNullable & { - runtime?: string; - runtimeVersion?: string; - containerCount?: number; - uptimeSeconds?: number; - temperature?: number; - swarm?: { nodeRole?: string }; - }) - | undefined; + const docker = () => dockerHostMeta(host); const name = () => asTrimmedString(host.name) || host.id; const systemBadge = () => getDockerHostSystemBadge(host); const version = () => asTrimmedString(docker()?.runtimeVersion) || '—'; diff --git a/frontend-modern/src/features/docker/DockerImagesTable.tsx b/frontend-modern/src/features/docker/DockerImagesTable.tsx index 4a623c1b4..80bf48595 100644 --- a/frontend-modern/src/features/docker/DockerImagesTable.tsx +++ b/frontend-modern/src/features/docker/DockerImagesTable.tsx @@ -1,13 +1,15 @@ -import { For, Show, type Component } from 'solid-js'; -import { TableCell, TableHead, TableRow } from '@/components/shared/Table'; +import { For, Show, createMemo, type Component } from 'solid-js'; +import { TableCell, TableRow } from '@/components/shared/Table'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -37,6 +39,10 @@ const updateToneClass: Record = { muted: 'bg-surface-hover text-muted', }; +const DOCKER_IMAGE_SORT_KEYS = ['image', 'host', 'usedBy', 'size', 'update'] as const; + +type DockerImageSortKey = (typeof DOCKER_IMAGE_SORT_KEYS)[number]; + export const DockerImagesTable: Component< DockerNativeTableProps & { relatedContainers?: Resource[] } > = (props) => { @@ -47,6 +53,44 @@ export const DockerImagesTable: Component< }); const drawer = createPlatformResourceDetailState({ idPrefix: 'docker-image-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); + const sort = createPlatformTableSortState({ + storageKey: 'dockerImages', + sortKeys: DOCKER_IMAGE_SORT_KEYS, + descendingFirst: ['size'], + }); + // Closure rather than a module-level accessor: the operational columns + // (Used by / Update check) derive from props.relatedContainers. + const getImageSortValue = ( + resource: Resource, + key: DockerImageSortKey, + ): PlatformTableSortValue => { + switch (key) { + case 'image': + return dockerResourceName(resource); + case 'host': { + const host = dockerHostName(resource); + return host === '—' ? null : host; + } + case 'usedBy': { + const summary = getDockerImageOperationalPresentation( + resource, + props.relatedContainers ?? [], + ).consumerSummary; + return summary && summary !== '—' ? summary : null; + } + case 'size': + return typeof resource.docker?.sizeBytes === 'number' && resource.docker.sizeBytes > 0 + ? resource.docker.sizeBytes + : null; + case 'update': + return getDockerImageOperationalPresentation(resource, props.relatedContainers ?? []) + .updateLabel; + default: + key satisfies never; + return null; + } + }; + const sortedRows = createMemo(() => sort.sortRows(tableState.filtered(), getImageSortValue)); return ( - + Image - - + + Host - - + + Used by - - + Size - - + + Update check - + } body={ <> - + {(resource) => { const operational = () => getDockerImageOperationalPresentation( diff --git a/frontend-modern/src/features/docker/DockerSecretsTable.tsx b/frontend-modern/src/features/docker/DockerSecretsTable.tsx index 7d83f2f0b..8782ddc24 100644 --- a/frontend-modern/src/features/docker/DockerSecretsTable.tsx +++ b/frontend-modern/src/features/docker/DockerSecretsTable.tsx @@ -1,22 +1,55 @@ -import { For, Show, type Component } from 'solid-js'; -import { TableCell, TableHead, TableRow } from '@/components/shared/Table'; +import { For, Show, createMemo, type Component } from 'solid-js'; +import { TableCell, TableRow } from '@/components/shared/Table'; +import { asTrimmedString } from '@/utils/stringUtils'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, + getPlatformTableDateTimeSortValue, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, dockerHostName, dockerLabelsSummary, + dockerResourceName, dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel'; +import type { Resource } from '@/types/resource'; + +const DOCKER_SECRET_SORT_KEYS = ['secret', 'driver', 'template', 'created', 'host'] as const; + +type DockerSecretSortKey = (typeof DOCKER_SECRET_SORT_KEYS)[number]; + +const getDockerSecretSortValue = ( + resource: Resource, + key: DockerSecretSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'secret': + return dockerResourceName(resource); + case 'driver': + return asTrimmedString(resource.docker?.driver) || null; + case 'template': + return asTrimmedString(resource.docker?.templatingDriver) || null; + case 'created': + return getPlatformTableDateTimeSortValue(resource.docker?.objectCreatedAt); + case 'host': { + const host = dockerHostName(resource); + return host === '—' ? null : host; + } + default: + key satisfies never; + return null; + } +}; export const DockerSecretsTable: Component = (props) => { const tableState = createPlatformTableFilterState({ @@ -24,6 +57,12 @@ export const DockerSecretsTable: Component = (props) => initialStatus: 'all' as DockerResourceStatusFilter, filter: filterDockerResources, }); + const sort = createPlatformTableSortState({ + storageKey: 'dockerSecrets', + sortKeys: DOCKER_SECRET_SORT_KEYS, + descendingFirst: ['created'], + }); + const sortedRows = createMemo(() => sort.sortRows(tableState.filtered(), getDockerSecretSortValue)); return ( = (props) => tableClass="min-w-full table-fixed text-xs md:min-w-[1080px]" header={ <> - + Secret - - + + Driver - - - - - + + Host - + } body={ <> - + {(resource) => ( diff --git a/frontend-modern/src/features/docker/DockerServicesTable.tsx b/frontend-modern/src/features/docker/DockerServicesTable.tsx index 438783498..8f3e464b2 100644 --- a/frontend-modern/src/features/docker/DockerServicesTable.tsx +++ b/frontend-modern/src/features/docker/DockerServicesTable.tsx @@ -1,16 +1,18 @@ 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, PlatformTableToolbar, PlatformTableEmptyState, createPlatformTableFilterState, + createPlatformTableSortState, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; import { @@ -68,6 +70,47 @@ const formatServiceUpdate = ( return { label, title }; }; +const DOCKER_SERVICE_SORT_KEYS = [ + 'service', + 'stack', + 'image', + 'mode', + 'desired', + 'running', + 'update', + 'host', +] as const; + +type DockerServiceSortKey = (typeof DOCKER_SERVICE_SORT_KEYS)[number]; + +const getDockerServiceSortValue = ( + service: Resource, + key: DockerServiceSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'service': + return asTrimmedString(service.name) || service.id; + case 'stack': + return dockerServiceStack(service) || null; + case 'image': + return asTrimmedString(service.docker?.image) || null; + case 'mode': + return asTrimmedString(service.docker?.mode) || null; + case 'desired': + // Rendered as 0 when unreported, so sort on the same number. + return service.docker?.desiredTasks ?? 0; + case 'running': + return service.docker?.runningTasks ?? 0; + case 'update': + return formatServiceUpdate(service.docker?.serviceUpdate).label; + case 'host': + return asTrimmedString(service.docker?.hostname) || null; + default: + key satisfies never; + return null; + } +}; + export const DockerServicesTable: Component<{ resources: Resource[]; sourceCount?: number; @@ -82,7 +125,14 @@ export const DockerServicesTable: Component<{ initialStatus: 'all' as DockerResourceStatusFilter, filter: filterDockerResources, }); - const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareDockerServices)); + const sort = createPlatformTableSortState({ + storageKey: 'dockerServices', + sortKeys: DOCKER_SERVICE_SORT_KEYS, + descendingFirst: ['desired', 'running'], + }); + const sortedRows = createMemo(() => + sort.sortRows([...tableState.filtered()].sort(compareDockerServices), getDockerServiceSortValue), + ); const hasFilteredSourceRows = () => (props.sourceCount ?? props.resources.length) > 0; @@ -141,45 +191,77 @@ export const DockerServicesTable: Component<{ Host get middle slices for rollout state, port lists, and hostnames. Mobile widths are unchanged. */} - + Service - - - - + + Mode - - - + + Running - - - - + } body={ diff --git a/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx b/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx index 31074543a..dfdd959de 100644 --- a/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx +++ b/frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx @@ -1,18 +1,22 @@ import { For, Show, createMemo, type Component } from 'solid-js'; -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, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, dockerByteValue, dockerCpuValue, + dockerResourceName, dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; @@ -22,6 +26,59 @@ import { mapDockerSwarmNodeStatus, type DockerResourceStatusFilter, } from './dockerPageModel'; +import type { Resource } from '@/types/resource'; + +const DOCKER_SWARM_NODE_SORT_KEYS = [ + 'node', + 'role', + 'availability', + 'reachability', + 'engine', + 'cpus', + 'memory', + 'address', +] as const; + +type DockerSwarmNodeSortKey = (typeof DOCKER_SWARM_NODE_SORT_KEYS)[number]; + +const getDockerSwarmNodeSortValue = ( + resource: Resource, + key: DockerSwarmNodeSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'node': + return dockerResourceName(resource); + case 'role': + return asTrimmedString(resource.docker?.nodeRole) || null; + case 'availability': + return asTrimmedString(resource.docker?.availability) || null; + case 'reachability': + return ( + asTrimmedString( + resource.docker?.leader ? 'leader' : resource.docker?.managerReachability, + ) || null + ); + case 'engine': + return ( + asTrimmedString(resource.docker?.engineVersion || resource.docker?.runtimeVersion) || null + ); + case 'cpus': + return typeof resource.docker?.nanoCpus === 'number' && resource.docker.nanoCpus > 0 + ? resource.docker.nanoCpus + : null; + case 'memory': + return typeof resource.docker?.memoryBytes === 'number' && resource.docker.memoryBytes > 0 + ? resource.docker.memoryBytes + : null; + case 'address': + return ( + asTrimmedString(resource.docker?.address || resource.docker?.managerAddress) || null + ); + default: + key satisfies never; + return null; + } +}; export const DockerSwarmNodesTable: Component = (props) => { const tableState = createPlatformTableFilterState({ @@ -29,7 +86,17 @@ export const DockerSwarmNodesTable: Component = (props) initialStatus: 'all' as DockerResourceStatusFilter, filter: filterDockerResources, }); - const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareDockerSwarmNodes)); + const sort = createPlatformTableSortState({ + storageKey: 'dockerSwarmNodes', + sortKeys: DOCKER_SWARM_NODE_SORT_KEYS, + descendingFirst: ['cpus', 'memory'], + }); + const sortedRows = createMemo(() => + sort.sortRows( + [...tableState.filtered()].sort(compareDockerSwarmNodes), + getDockerSwarmNodeSortValue, + ), + ); return ( = (props) tableClass="min-w-full table-fixed text-xs md:min-w-[1120px]" header={ <> - + Node - - + + Role - - + + Availability - - - - - + Memory - - + } body={ diff --git a/frontend-modern/src/features/docker/DockerTasksTable.tsx b/frontend-modern/src/features/docker/DockerTasksTable.tsx index 782a1d496..debc28bef 100644 --- a/frontend-modern/src/features/docker/DockerTasksTable.tsx +++ b/frontend-modern/src/features/docker/DockerTasksTable.tsx @@ -1,17 +1,22 @@ import { For, Show, createMemo, type Component } from 'solid-js'; -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, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, + getPlatformTableDateTimeSortValue, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { DockerResourceNameCell, dockerNumberValue, + dockerResourceName, dockerTextValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; @@ -21,6 +26,44 @@ import { mapDockerTaskStatus, type DockerResourceStatusFilter, } from './dockerPageModel'; +import type { Resource } from '@/types/resource'; + +const DOCKER_TASK_SORT_KEYS = [ + 'task', + 'service', + 'slot', + 'desired', + 'current', + 'node', + 'started', +] as const; + +type DockerTaskSortKey = (typeof DOCKER_TASK_SORT_KEYS)[number]; + +const getDockerTaskSortValue = ( + resource: Resource, + key: DockerTaskSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'task': + return dockerResourceName(resource); + case 'service': + return asTrimmedString(resource.docker?.serviceName) || null; + case 'slot': + return typeof resource.docker?.slot === 'number' ? resource.docker.slot : null; + case 'desired': + return asTrimmedString(resource.docker?.desiredState) || null; + case 'current': + return asTrimmedString(resource.docker?.currentState) || null; + case 'node': + return asTrimmedString(resource.docker?.nodeName || resource.docker?.nodeId) || null; + case 'started': + return getPlatformTableDateTimeSortValue(resource.docker?.startedAt); + default: + key satisfies never; + return null; + } +}; export const DockerTasksTable: Component = (props) => { const tableState = createPlatformTableFilterState({ @@ -28,7 +71,14 @@ export const DockerTasksTable: Component = (props) => { initialStatus: 'all' as DockerResourceStatusFilter, filter: filterDockerResources, }); - const sortedRows = createMemo(() => [...tableState.filtered()].sort(compareDockerTasks)); + const sort = createPlatformTableSortState({ + storageKey: 'dockerTasks', + sortKeys: DOCKER_TASK_SORT_KEYS, + descendingFirst: ['started'], + }); + const sortedRows = createMemo(() => + sort.sortRows([...tableState.filtered()].sort(compareDockerTasks), getDockerTaskSortValue), + ); return ( = (props) => { tableClass="min-w-full table-fixed text-xs md:min-w-[1160px]" header={ <> - + Task - - - - + + Desired - - + + Current - - - + } body={ diff --git a/frontend-modern/src/features/docker/DockerVolumesTable.tsx b/frontend-modern/src/features/docker/DockerVolumesTable.tsx index c4e7b68f3..ec87f002d 100644 --- a/frontend-modern/src/features/docker/DockerVolumesTable.tsx +++ b/frontend-modern/src/features/docker/DockerVolumesTable.tsx @@ -1,15 +1,19 @@ -import { For, Show, type Component } from 'solid-js'; -import { TableCell, TableHead, TableRow } from '@/components/shared/Table'; +import { For, Show, createMemo, type Component } from 'solid-js'; +import { TableCell, TableRow } from '@/components/shared/Table'; +import { asTrimmedString } from '@/utils/stringUtils'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableRelativeTimeValue, PlatformTableToolbar, createPlatformTableFilterState, + createPlatformTableSortState, formatPlatformTableDateTimeValue, getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, + getPlatformTableDateTimeSortValue, PlatformTableShell, + type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton, @@ -27,6 +31,46 @@ import { type DockerNativeTableProps, } from './DockerNativeTableShared'; import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel'; +import type { Resource } from '@/types/resource'; + +const DOCKER_VOLUME_SORT_KEYS = [ + 'volume', + 'driver', + 'scope', + 'size', + 'refs', + 'created', + 'mountpoint', +] as const; + +type DockerVolumeSortKey = (typeof DOCKER_VOLUME_SORT_KEYS)[number]; + +const getDockerVolumeSortValue = ( + resource: Resource, + key: DockerVolumeSortKey, +): PlatformTableSortValue => { + switch (key) { + case 'volume': + return dockerResourceName(resource); + case 'driver': + return asTrimmedString(resource.docker?.driver) || null; + case 'scope': + return asTrimmedString(resource.docker?.scope) || null; + case 'size': + return typeof resource.docker?.sizeBytes === 'number' && resource.docker.sizeBytes > 0 + ? resource.docker.sizeBytes + : null; + case 'refs': + return typeof resource.docker?.refCount === 'number' ? resource.docker.refCount : null; + case 'created': + return getPlatformTableDateTimeSortValue(resource.docker?.createdAt); + case 'mountpoint': + return asTrimmedString(resource.docker?.mountpoint) || null; + default: + key satisfies never; + return null; + } +}; export const DockerVolumesTable: Component = (props) => { const tableState = createPlatformTableFilterState({ @@ -38,6 +82,12 @@ export const DockerVolumesTable: Component = (props) => }); const drawer = createPlatformResourceDetailState({ idPrefix: 'docker-volume-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); + const sort = createPlatformTableSortState({ + storageKey: 'dockerVolumes', + sortKeys: DOCKER_VOLUME_SORT_KEYS, + descendingFirst: ['size', 'refs', 'created'], + }); + const sortedRows = createMemo(() => sort.sortRows(tableState.filtered(), getDockerVolumeSortValue)); return ( = (props) => tableClass="min-w-full table-fixed text-xs md:min-w-[1120px]" header={ <> - + Volume - - + + Driver - - - + Size - - - - + } body={ <> - + {(resource) => { const detailRowId = () => drawer.detailRowId(resource); const isExpanded = () => drawer.isExpanded(resource); diff --git a/frontend-modern/src/features/docker/__tests__/DockerContainersTable.sort.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerContainersTable.sort.test.tsx new file mode 100644 index 000000000..c6f291035 --- /dev/null +++ b/frontend-modern/src/features/docker/__tests__/DockerContainersTable.sort.test.tsx @@ -0,0 +1,230 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { Route, Router } from '@solidjs/router'; +import type { JSX } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Resource } from '@/types/resource'; +import { DockerContainersTable } from '../DockerContainersTable'; + +vi.mock('@/api/monitoring', () => ({ + MonitoringAPI: { + updateDockerContainer: vi.fn().mockResolvedValue({ success: true }), + }, +})); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@/stores/containerUpdates', () => ({ + clearContainerUpdateState: vi.fn(), + getContainerUpdateState: vi.fn(() => undefined), + markContainerQueued: vi.fn(), + markContainerUpdateError: vi.fn(), + markContainerUpdateSuccess: vi.fn(), + updateStates: vi.fn(() => ({})), +})); + +vi.mock('@/stores/systemSettings', () => ({ + areSystemSettingsLoaded: () => true, + shouldHideDockerUpdateActions: () => false, +})); + +vi.mock('@/components/shared/responsive', () => ({ + ResponsiveMetricCell: (props: { type: string; resourceId?: string }) => ( +
+ ), +})); + +vi.mock('@/components/Workloads/StackedMemoryBar', () => ({ + StackedMemoryBar: () =>
, +})); + +const makeContainer = ({ + id, + host, + ...overrides +}: Partial & { id: string; host: string }): Resource => ({ + id, + name: id, + displayName: id, + platformId: 'docker-1', + platformType: 'docker', + sourceType: 'agent', + sources: ['docker'], + status: 'running', + type: 'app-container', + lastSeen: 1_700_000_000_000, + ...overrides, + docker: { + agentId: `agent-${host}`, + hostname: host, + containerState: 'running', + image: 'nginx:latest', + ...(overrides.docker ?? {}), + }, +}); + +// Two hosts with mixed statuses so both the attention-first default order and +// the within-group user sort are observable. +const FIXTURE: Resource[] = [ + makeContainer({ id: 'alpha', host: 'host-a', cpu: { current: 20 } }), + makeContainer({ + id: 'zulu', + host: 'host-a', + status: 'stopped', + cpu: undefined, + docker: { agentId: 'agent-host-a', hostname: 'host-a', containerState: 'exited', exitCode: 1 }, + }), + makeContainer({ id: 'mike', host: 'host-a', cpu: { current: 80 } }), + makeContainer({ id: 'bravo', host: 'host-b', cpu: { current: 10 } }), + makeContainer({ id: 'yankee', host: 'host-b', cpu: { current: 55 } }), +]; + +const renderTable = () => + render(() => ( + + ( + } + emptyTitle="No containers" + emptyDescription="No containers" + showToolbar={false} + /> + )) as () => JSX.Element} + /> + + )); + +// Row order including group boundaries: group header rows render as +// `group:`, container rows as their id. +const visibleRowOrder = (container: HTMLElement): string[] => + Array.from( + container.querySelectorAll('tr[data-docker-container-row], tr[data-docker-host-group]'), + ).map((row) => + row.hasAttribute('data-docker-host-group') + ? `group:${row.getAttribute('data-docker-host-group')}` + : (row.getAttribute('data-docker-container-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.history.pushState({}, '', '/'); + window.localStorage.clear(); + cleanup(); + vi.clearAllMocks(); +}); + +describe('DockerContainersTable user sorting', () => { + it('sorts within host groups without changing the group order', () => { + const { container } = renderTable(); + + // Built-in order: attention first (zulu exited non-zero), then names. + expect(visibleRowOrder(container)).toEqual([ + 'group:host-a', + 'zulu', + 'alpha', + 'mike', + 'group:host-b', + 'bravo', + 'yankee', + ]); + + fireEvent.click(headerFor('Container')); + expect(headerFor('Container')).toHaveAttribute('aria-sort', 'ascending'); + expect(visibleRowOrder(container)).toEqual([ + 'group:host-a', + 'alpha', + 'mike', + 'zulu', + 'group:host-b', + 'bravo', + 'yankee', + ]); + + fireEvent.click(headerFor('Container')); + expect(headerFor('Container')).toHaveAttribute('aria-sort', 'descending'); + expect(visibleRowOrder(container)).toEqual([ + 'group:host-a', + 'zulu', + 'mike', + 'alpha', + 'group:host-b', + 'yankee', + 'bravo', + ]); + + // Third click clears back to the built-in attention-first order. + fireEvent.click(headerFor('Container')); + expect(headerFor('Container')).not.toHaveAttribute('aria-sort'); + expect(visibleRowOrder(container)).toEqual([ + 'group:host-a', + 'zulu', + 'alpha', + 'mike', + 'group:host-b', + 'bravo', + 'yankee', + ]); + }); + + it('sorts metric columns descending first with missing values last', () => { + const { container } = renderTable(); + + fireEvent.click(headerFor('CPU')); + expect(headerFor('CPU')).toHaveAttribute('aria-sort', 'descending'); + // zulu reports no CPU metric, so it sinks to the bottom of its group. + expect(visibleRowOrder(container)).toEqual([ + 'group:host-a', + 'mike', + 'alpha', + 'zulu', + 'group:host-b', + 'yankee', + 'bravo', + ]); + }); + + it('persists the chosen sort across a remount', () => { + const first = renderTable(); + fireEvent.click(headerFor('Container')); + expect(visibleRowOrder(first.container)).toEqual([ + 'group:host-a', + 'alpha', + 'mike', + 'zulu', + 'group:host-b', + 'bravo', + 'yankee', + ]); + expect(window.localStorage.getItem('dockerContainersSortKey')).toBe('container'); + expect(window.localStorage.getItem('dockerContainersSortDirection')).toBe('asc'); + + cleanup(); + + const second = renderTable(); + expect(headerFor('Container')).toHaveAttribute('aria-sort', 'ascending'); + expect(visibleRowOrder(second.container)).toEqual([ + 'group:host-a', + 'alpha', + 'mike', + 'zulu', + 'group:host-b', + 'bravo', + 'yankee', + ]); + }); +}); diff --git a/frontend-modern/src/features/docker/dockerContainerTableModel.ts b/frontend-modern/src/features/docker/dockerContainerTableModel.ts index d4e07a2e6..42ec45851 100644 --- a/frontend-modern/src/features/docker/dockerContainerTableModel.ts +++ b/frontend-modern/src/features/docker/dockerContainerTableModel.ts @@ -25,6 +25,29 @@ export type DockerContainerTableColumn = { kind: PlatformTableColumnKind; }; +// Columns a user can sort by. The multi-value summary columns (ports, +// networks, mounts) and the actions column carry no scalar to order on. +export const DOCKER_CONTAINER_SORTABLE_COLUMN_IDS = [ + 'container', + 'host', + 'runtime', + 'image', + 'state', + 'cpu', + 'memory', + 'restarts', + 'updates', +] as const satisfies readonly DockerContainerTableColumnId[]; + +export type DockerContainerSortKey = (typeof DOCKER_CONTAINER_SORTABLE_COLUMN_IDS)[number]; + +export const getDockerContainerSortKey = ( + columnId: DockerContainerTableColumnId, +): DockerContainerSortKey | undefined => + (DOCKER_CONTAINER_SORTABLE_COLUMN_IDS as readonly string[]).includes(columnId) + ? (columnId as DockerContainerSortKey) + : undefined; + const DOCKER_CONTAINER_TABLE_LAYOUT_ORDER: Record = { mobile: 0, tablet: 1, 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 db2c714b1..54640361c 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -187,7 +187,14 @@ describe('platform overview layout guardrails', () => { for (const source of platformShellTableSources) { expect(source).toContain('PlatformTableShell'); - expect(source).toContain('getPlatformTableHeadClass'); + // Headers resolve their canonical kind-based class either directly + // (getPlatformTableHeadClassForKind) or through the shared sortable + // header component, which applies the same helper internally. + expect( + source.includes('getPlatformTableHeadClass') || + source.includes('PlatformSortableTableHead'), + 'table headers must use getPlatformTableHeadClassForKind or PlatformSortableTableHead', + ).toBe(true); expect(source).toContain('getPlatformTableCellClass'); expect(source).not.toContain('TableCard class={PLATFORM_TABLE_CARD_CLASS}'); expect(source).not.toContain('TableCardHeader'); @@ -380,21 +387,15 @@ describe('platform overview layout guardrails', () => { }); it('keeps mobile host tables focused on useful operational columns', () => { - // Assertions use the canonical kind-based helpers - // (getPlatformTableHeadClassForKind('')) so the platform overview - // tables keep aligned metric and numeric columns across providers. - expect(dockerHostsTableSource).toMatch( - /getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?Host/, - ); - expect(dockerHostsTableSource).toMatch( - /getPlatformTableHeadClassForKind\('metric-bar'\)[\s\S]{0,200}?CPU/, - ); - expect(dockerHostsTableSource).toMatch( - /getPlatformTableHeadClassForKind\('metric-bar'\)[\s\S]{0,200}?Memory/, - ); - expect(dockerHostsTableSource).toMatch( - /getPlatformTableHeadClassForKind\('metric-bar'\)[\s\S]{0,200}?Disk/, - ); + // Assertions use the canonical kind-based helpers — either + // getPlatformTableHeadClassForKind('') on a raw TableHead or the + // kind="" prop on the shared PlatformSortableTableHead — so the + // platform overview tables keep aligned metric and numeric columns + // across providers. + expect(dockerHostsTableSource).toMatch(/kind="name"[\s\S]{0,200}?Host/); + expect(dockerHostsTableSource).toMatch(/kind="metric-bar"[\s\S]{0,200}?CPU/); + expect(dockerHostsTableSource).toMatch(/kind="metric-bar"[\s\S]{0,300}?Memory/); + expect(dockerHostsTableSource).toMatch(/kind="metric-bar"[\s\S]{0,200}?Disk/); expect(kubernetesClustersTableSource).toMatch( /getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?Cluster/, diff --git a/frontend-modern/src/features/platformPage/__tests__/platformTableSortState.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformTableSortState.test.ts new file mode 100644 index 000000000..e807c3677 --- /dev/null +++ b/frontend-modern/src/features/platformPage/__tests__/platformTableSortState.test.ts @@ -0,0 +1,130 @@ +import { createRoot } from 'solid-js'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + createPlatformTableSortState, + getPlatformTableDateTimeSortValue, + type PlatformTableSortValue, +} from '../sharedPlatformPage'; + +const SORT_KEYS = ['name', 'cpu'] as const; +type SortKey = (typeof SORT_KEYS)[number]; + +type Row = { name: string; cpu: number | null }; + +const getSortValue = (row: Row, key: SortKey): PlatformTableSortValue => + key === 'name' ? row.name : row.cpu; + +const ROWS: readonly Row[] = [ + { name: 'bravo', cpu: 40 }, + { name: 'alpha', cpu: null }, + { name: 'charlie', cpu: 90 }, +]; + +const withSortState = ( + fn: (sort: ReturnType>) => T, +): T => + createRoot((dispose) => { + const sort = createPlatformTableSortState({ + storageKey: 'testTable', + sortKeys: SORT_KEYS, + descendingFirst: ['cpu'], + }); + const result = fn(sort); + dispose(); + return result; + }); + +afterEach(() => { + window.localStorage.clear(); +}); + +describe('createPlatformTableSortState', () => { + it('keeps the built-in row order until a header is clicked', () => { + withSortState((sort) => { + expect(sort.sortKey()).toBeNull(); + expect(sort.sortRows(ROWS, getSortValue)).toEqual(ROWS); + expect(sort.getAriaSort('name')).toBeUndefined(); + }); + }); + + it('cycles a text column asc → desc → built-in order', () => { + withSortState((sort) => { + sort.handleSort('name'); + expect(sort.getAriaSort('name')).toBe('ascending'); + expect(sort.sortRows(ROWS, getSortValue).map((row) => row.name)).toEqual([ + 'alpha', + 'bravo', + 'charlie', + ]); + + sort.handleSort('name'); + expect(sort.getAriaSort('name')).toBe('descending'); + expect(sort.sortRows(ROWS, getSortValue).map((row) => row.name)).toEqual([ + 'charlie', + 'bravo', + 'alpha', + ]); + + sort.handleSort('name'); + expect(sort.sortKey()).toBeNull(); + expect(sort.sortRows(ROWS, getSortValue)).toEqual(ROWS); + }); + }); + + it('starts metric columns descending and keeps missing values last both ways', () => { + withSortState((sort) => { + sort.handleSort('cpu'); + expect(sort.getAriaSort('cpu')).toBe('descending'); + expect(sort.sortRows(ROWS, getSortValue).map((row) => row.name)).toEqual([ + 'charlie', + 'bravo', + 'alpha', + ]); + + sort.handleSort('cpu'); + expect(sort.getAriaSort('cpu')).toBe('ascending'); + // alpha has no CPU value, so it stays at the bottom in either direction. + expect(sort.sortRows(ROWS, getSortValue).map((row) => row.name)).toEqual([ + 'bravo', + 'charlie', + 'alpha', + ]); + }); + }); + + it('switching columns applies the new column first-click direction', () => { + withSortState((sort) => { + sort.handleSort('name'); + sort.handleSort('cpu'); + expect(sort.sortKey()).toBe('cpu'); + expect(sort.getAriaSort('cpu')).toBe('descending'); + expect(sort.getAriaSort('name')).toBeUndefined(); + }); + }); + + it('restores a persisted sort and ignores stale persisted keys', () => { + window.localStorage.setItem('testTableSortKey', 'name'); + window.localStorage.setItem('testTableSortDirection', 'desc'); + withSortState((sort) => { + expect(sort.sortKey()).toBe('name'); + expect(sort.sortDirection()).toBe('desc'); + }); + + window.localStorage.setItem('testTableSortKey', 'removed-column'); + withSortState((sort) => { + expect(sort.sortKey()).toBeNull(); + }); + }); +}); + +describe('getPlatformTableDateTimeSortValue', () => { + it('maps timestamps to epoch millis and unparsable input to null', () => { + expect(getPlatformTableDateTimeSortValue('2026-01-02T03:04:05Z')).toBe( + Date.parse('2026-01-02T03:04:05Z'), + ); + expect(getPlatformTableDateTimeSortValue('not a date')).toBeNull(); + expect(getPlatformTableDateTimeSortValue(undefined)).toBeNull(); + expect(getPlatformTableDateTimeSortValue('')).toBeNull(); + }); +}); diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index 3c6d0174a..95ea49f0a 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -14,10 +14,11 @@ import { EmptyState } from '@/components/shared/EmptyState'; import { type FilterOption as PlatformTableFilterOption } from '@/components/shared/FilterButtonGroup'; import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar'; import { type SearchInputProps } from '@/components/shared/SearchInput'; -import { Table, TableBody, TableHeader, TableRow } from '@/components/shared/Table'; +import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/shared/Table'; import { TableCard } from '@/components/shared/TableCard'; import { TableCardHeader } from '@/components/shared/TableCardHeader'; import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable'; import type { Resource } from '@/types/resource'; import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; @@ -185,6 +186,154 @@ export const getPlatformTableHeadClassForKind = (kind: PlatformTableColumnKind): export const getPlatformTableCellClassForKind = (kind: PlatformTableColumnKind): string => getPlatformTableCellClass(getPlatformColumnAlign(kind)); +// --- User-controlled column sorting ---------------------------------------- +// +// Platform tables keep their built-in attention-first ordering until the user +// clicks a sortable header. Clicking cycles per column: first-click direction +// → flipped → back to the built-in order. The chosen column and direction +// persist per table via usePersistentSignal, so the preference survives +// reloads. Sorting only reorders rows, so it stays orthogonal to grouping: a +// grouped table re-buckets the sorted rows, which sorts rows within each +// group without changing the group order. + +export type PlatformTableSortDirection = 'asc' | 'desc'; + +// null means "no value for this column" — those rows sink to the bottom in +// either direction so real values stay scannable. +export type PlatformTableSortValue = string | number | null; + +const isEmptyPlatformTableSortValue = (value: PlatformTableSortValue): boolean => + value === null || (typeof value === 'number' && Number.isNaN(value)); + +const comparePlatformTableSortValues = ( + a: PlatformTableSortValue, + b: PlatformTableSortValue, +): number => { + const aEmpty = isEmptyPlatformTableSortValue(a); + const bEmpty = isEmptyPlatformTableSortValue(b); + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + if (typeof a === 'number' && typeof b === 'number') return a === b ? 0 : a < b ? -1 : 1; + return String(a).localeCompare(String(b), undefined, { sensitivity: 'base' }); +}; + +// Timestamp columns (Created / Started) sort numerically via this helper so +// string timestamps compare chronologically instead of lexically. +export const getPlatformTableDateTimeSortValue = ( + value: PlatformTableDateTimeValueInput, +): number | null => { + const parsed = resolvePlatformTableDateTime(value); + return parsed ? parsed.getTime() : null; +}; + +export type PlatformTableSortState = { + sortKey: () => SortKey | null; + sortDirection: () => PlatformTableSortDirection; + handleSort: (key: SortKey) => void; + getAriaSort: (key: SortKey) => 'ascending' | 'descending' | undefined; + sortRows: ( + rows: readonly Row[], + getSortValue: (row: Row, key: SortKey) => PlatformTableSortValue, + ) => readonly Row[]; +}; + +export function createPlatformTableSortState(options: { + // Storage namespace, e.g. 'dockerContainers' → dockerContainersSortKey / + // dockerContainersSortDirection in localStorage. + storageKey: string; + sortKeys: readonly SortKey[]; + // Columns whose first click sorts descending — metrics and counts read + // "biggest on top". Everything else starts ascending. + descendingFirst?: readonly SortKey[]; +}): PlatformTableSortState { + const isSortKey = (value: string): value is SortKey => + (options.sortKeys as readonly string[]).includes(value); + const [sortKey, setSortKey] = usePersistentSignal( + `${options.storageKey}SortKey`, + null, + // A persisted key for a renamed or removed column falls back to the + // table's built-in order instead of throwing the sort into limbo. + { deserialize: (raw) => (isSortKey(raw) ? raw : null) }, + ); + const [sortDirection, setSortDirection] = usePersistentSignal( + `${options.storageKey}SortDirection`, + 'asc', + { deserialize: (raw) => (raw === 'desc' ? 'desc' : 'asc') }, + ); + const descendingFirst = new Set(options.descendingFirst ?? []); + const firstClickDirection = (key: SortKey): PlatformTableSortDirection => + descendingFirst.has(key) ? 'desc' : 'asc'; + + const handleSort = (key: SortKey) => { + if (sortKey() === key) { + if (sortDirection() === firstClickDirection(key)) { + setSortDirection(firstClickDirection(key) === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(null); + setSortDirection('asc'); + } + return; + } + setSortKey(() => key); + setSortDirection(firstClickDirection(key)); + }; + + const getAriaSort = (key: SortKey): 'ascending' | 'descending' | undefined => { + if (sortKey() !== key) return undefined; + return sortDirection() === 'asc' ? 'ascending' : 'descending'; + }; + + const sortRows = ( + rows: readonly Row[], + getSortValue: (row: Row, key: SortKey) => PlatformTableSortValue, + ): readonly Row[] => { + const key = sortKey(); + if (!key) return rows; + const dir = sortDirection() === 'asc' ? 1 : -1; + const decorated = rows.map((row) => [getSortValue(row, key), row] as const); + decorated.sort(([a], [b]) => { + // Missing values stay last in either direction. + if (isEmptyPlatformTableSortValue(a) || isEmptyPlatformTableSortValue(b)) { + return comparePlatformTableSortValues(a, b); + } + return comparePlatformTableSortValues(a, b) * dir; + }); + return decorated.map(([, row]) => row); + }; + + return { sortKey, sortDirection, handleSort, getAriaSort, sortRows }; +} + +export function PlatformSortableTableHead(props: { + kind: PlatformTableColumnKind; + sort: PlatformTableSortState; + // Omit to render a non-sortable header with the same canonical alignment. + sortKey?: SortKey; + class?: string; + children: JSX.Element; +}) { + const isSorted = () => props.sortKey !== undefined && props.sort.sortKey() === props.sortKey; + const handleClick = () => { + const key = props.sortKey; + if (key !== undefined) props.sort.handleSort(key); + }; + return ( + + {props.children} + {isSorted() && (props.sort.sortDirection() === 'asc' ? ' ▲' : ' ▼')} + + ); +} + export const formatPlatformTableTextValue = (value: unknown, emptyText = '—'): string => asTrimmedString(value) || emptyText;