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;