mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-17 20:02:24 +00:00
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.
This commit is contained in:
parent
d5437a9353
commit
20cccc1a91
14 changed files with 1424 additions and 210 deletions
|
|
@ -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<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
|
|
@ -24,6 +55,12 @@ export const DockerConfigsTable: Component<DockerNativeTableProps> = (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 (
|
||||
<Show
|
||||
|
|
@ -66,30 +103,50 @@ export const DockerConfigsTable: Component<DockerNativeTableProps> = (props) =>
|
|||
tableClass="min-w-full table-fixed text-xs md:min-w-[1040px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[28%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="config"
|
||||
class="md:w-[28%]"
|
||||
>
|
||||
Config
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[16%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="template"
|
||||
class="md:w-[16%]"
|
||||
>
|
||||
Template
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[16%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="created"
|
||||
class="hidden md:table-cell md:w-[16%]"
|
||||
>
|
||||
Created
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[24%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
class="hidden md:table-cell md:w-[24%]"
|
||||
>
|
||||
Labels
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[16%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="host"
|
||||
class="md:w-[16%]"
|
||||
>
|
||||
Host
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(resource) => (
|
||||
<TableRow class="text-[11px] sm:text-xs" data-docker-config-row={resource.id}>
|
||||
<DockerResourceNameCell resource={resource} />
|
||||
|
|
|
|||
|
|
@ -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<DockerContainersTableProps> = (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<DockerContainersTableProps> = (pro
|
|||
<>
|
||||
<For each={visibleColumns()}>
|
||||
{(column) => (
|
||||
<TableHead class={getPlatformTableHeadClassForKind(column.kind)}>
|
||||
<PlatformSortableTableHead
|
||||
kind={column.kind}
|
||||
sort={sort}
|
||||
sortKey={getDockerContainerSortKey(column.id)}
|
||||
>
|
||||
<Show when={column.id === 'memory'} fallback={<>{column.label}</>}>
|
||||
<span class="md:hidden">Mem</span>
|
||||
<span class="hidden md:inline">Memory</span>
|
||||
</Show>
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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<Resource['docker']> & {
|
||||
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<string | null>(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.
|
||||
*/}
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} w-[40%] md:w-[13%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="host"
|
||||
class="w-[40%] md:w-[13%]"
|
||||
>
|
||||
Host
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[7%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="system"
|
||||
class="hidden md:table-cell md:w-[7%]"
|
||||
>
|
||||
System
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[7%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="version"
|
||||
class="hidden md:table-cell md:w-[7%]"
|
||||
>
|
||||
Version
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[9%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="containers"
|
||||
class="hidden md:table-cell md:w-[9%]"
|
||||
>
|
||||
Containers
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('metric-bar')} w-[20%] md:w-[14%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="cpu"
|
||||
class="w-[20%] md:w-[14%]"
|
||||
>
|
||||
CPU
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('metric-bar')} w-[20%] md:w-[14%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="memory"
|
||||
class="w-[20%] md:w-[14%]"
|
||||
>
|
||||
<span class="md:hidden">Mem</span>
|
||||
<span class="hidden md:inline">Memory</span>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('metric-bar')} w-[20%] md:w-[14%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="disk"
|
||||
class="w-[20%] md:w-[14%]"
|
||||
>
|
||||
Disk
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[6%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="uptime"
|
||||
class="hidden md:table-cell md:w-[6%]"
|
||||
>
|
||||
Uptime
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[6%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="temp"
|
||||
class="hidden md:table-cell md:w-[6%]"
|
||||
>
|
||||
Temp
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
<Show when={showSwarmColumn()}>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="swarm"
|
||||
class="hidden md:table-cell md:w-[10%]"
|
||||
>
|
||||
Swarm role
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedHosts()}>
|
||||
{(host) => {
|
||||
const docker = () =>
|
||||
host.docker as
|
||||
| (NonNullable<Resource['docker']> & {
|
||||
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) || '—';
|
||||
|
|
|
|||
|
|
@ -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<DockerImageUpdateTone, string> = {
|
|||
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 (
|
||||
<Show
|
||||
|
|
@ -89,28 +133,51 @@ export const DockerImagesTable: Component<
|
|||
tableClass="min-w-full table-fixed text-xs md:min-w-[880px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[30%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="image"
|
||||
class="md:w-[30%]"
|
||||
>
|
||||
Image
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[18%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="host"
|
||||
class="md:w-[18%]"
|
||||
>
|
||||
Host
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[24%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="usedBy"
|
||||
class="md:w-[24%]"
|
||||
>
|
||||
Used by
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[12%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="size"
|
||||
class="md:w-[12%]"
|
||||
>
|
||||
Size
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[16%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="update"
|
||||
class="md:w-[16%]"
|
||||
>
|
||||
Update check
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(resource) => {
|
||||
const operational = () =>
|
||||
getDockerImageOperationalPresentation(
|
||||
|
|
|
|||
|
|
@ -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<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
|
|
@ -24,6 +57,12 @@ export const DockerSecretsTable: Component<DockerNativeTableProps> = (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 (
|
||||
<Show
|
||||
|
|
@ -66,35 +105,58 @@ export const DockerSecretsTable: Component<DockerNativeTableProps> = (props) =>
|
|||
tableClass="min-w-full table-fixed text-xs md:min-w-[1080px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[24%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="secret"
|
||||
class="md:w-[24%]"
|
||||
>
|
||||
Secret
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[14%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="driver"
|
||||
class="md:w-[14%]"
|
||||
>
|
||||
Driver
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="template"
|
||||
class="hidden md:table-cell md:w-[14%]"
|
||||
>
|
||||
Template
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="created"
|
||||
class="hidden md:table-cell md:w-[14%]"
|
||||
>
|
||||
Created
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
class="hidden md:table-cell md:w-[18%]"
|
||||
>
|
||||
Labels
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[16%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="host"
|
||||
class="md:w-[16%]"
|
||||
>
|
||||
Host
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(resource) => (
|
||||
<TableRow class="text-[11px] sm:text-xs" data-docker-secret-row={resource.id}>
|
||||
<DockerResourceNameCell resource={resource} />
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/}
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[16%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="service"
|
||||
class="md:w-[16%]"
|
||||
>
|
||||
Service
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[9%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="stack"
|
||||
class="hidden md:table-cell md:w-[9%]"
|
||||
>
|
||||
Stack
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[19%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="image"
|
||||
class="hidden md:table-cell md:w-[19%]"
|
||||
>
|
||||
Image
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[8%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="mode"
|
||||
class="md:w-[8%]"
|
||||
>
|
||||
Mode
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="desired"
|
||||
class="hidden md:table-cell md:w-[8%]"
|
||||
>
|
||||
Desired
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="running"
|
||||
class="md:w-[8%]"
|
||||
>
|
||||
Running
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[12%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="update"
|
||||
class="hidden md:table-cell md:w-[12%]"
|
||||
>
|
||||
Update
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
class="hidden md:table-cell md:w-[14%]"
|
||||
>
|
||||
Ports
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="host"
|
||||
class="hidden md:table-cell md:w-[10%]"
|
||||
>
|
||||
Host
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
|
|
|
|||
|
|
@ -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<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
|
|
@ -29,7 +86,17 @@ export const DockerSwarmNodesTable: Component<DockerNativeTableProps> = (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 (
|
||||
<Show
|
||||
|
|
@ -72,40 +139,70 @@ export const DockerSwarmNodesTable: Component<DockerNativeTableProps> = (props)
|
|||
tableClass="min-w-full table-fixed text-xs md:min-w-[1120px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[20%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="node"
|
||||
class="md:w-[20%]"
|
||||
>
|
||||
Node
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[10%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="role"
|
||||
class="md:w-[10%]"
|
||||
>
|
||||
Role
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="availability"
|
||||
class="md:w-[12%]"
|
||||
>
|
||||
Availability
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="reachability"
|
||||
class="hidden md:table-cell md:w-[14%]"
|
||||
>
|
||||
Reachability
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[16%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="engine"
|
||||
class="hidden md:table-cell md:w-[16%]"
|
||||
>
|
||||
Engine
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="cpus"
|
||||
class="hidden md:table-cell md:w-[8%]"
|
||||
>
|
||||
CPUs
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="memory"
|
||||
class="md:w-[10%]"
|
||||
>
|
||||
Memory
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="address"
|
||||
class="hidden md:table-cell md:w-[10%]"
|
||||
>
|
||||
Address
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
|
|
|
|||
|
|
@ -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<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
|
|
@ -28,7 +71,14 @@ export const DockerTasksTable: Component<DockerNativeTableProps> = (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 (
|
||||
<Show
|
||||
|
|
@ -71,35 +121,62 @@ export const DockerTasksTable: Component<DockerNativeTableProps> = (props) => {
|
|||
tableClass="min-w-full table-fixed text-xs md:min-w-[1160px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[18%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="task"
|
||||
class="md:w-[18%]"
|
||||
>
|
||||
Task
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="service"
|
||||
class="hidden md:table-cell md:w-[18%]"
|
||||
>
|
||||
Service
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="slot"
|
||||
class="hidden md:table-cell md:w-[8%]"
|
||||
>
|
||||
Slot
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="desired"
|
||||
class="md:w-[12%]"
|
||||
>
|
||||
Desired
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[16%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="current"
|
||||
class="md:w-[16%]"
|
||||
>
|
||||
Current
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[16%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="node"
|
||||
class="hidden md:table-cell md:w-[16%]"
|
||||
>
|
||||
Node
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[12%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="started"
|
||||
class="hidden md:table-cell md:w-[12%]"
|
||||
>
|
||||
Started
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
|
|
|
|||
|
|
@ -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<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
|
|
@ -38,6 +82,12 @@ export const DockerVolumesTable: Component<DockerNativeTableProps> = (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 (
|
||||
<Show
|
||||
|
|
@ -82,42 +132,67 @@ export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) =>
|
|||
tableClass="min-w-full table-fixed text-xs md:min-w-[1120px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="volume"
|
||||
class="md:w-[22%]"
|
||||
>
|
||||
Volume
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="driver"
|
||||
class="md:w-[12%]"
|
||||
>
|
||||
Driver
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="scope"
|
||||
class="hidden md:table-cell md:w-[10%]"
|
||||
>
|
||||
Scope
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="size"
|
||||
class="md:w-[10%]"
|
||||
>
|
||||
Size
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="refs"
|
||||
class="hidden md:table-cell md:w-[8%]"
|
||||
>
|
||||
Refs
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="created"
|
||||
class="hidden md:table-cell md:w-[14%]"
|
||||
>
|
||||
Created
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[24%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="mountpoint"
|
||||
class="hidden md:table-cell md:w-[24%]"
|
||||
>
|
||||
Mountpoint
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(resource) => {
|
||||
const detailRowId = () => drawer.detailRowId(resource);
|
||||
const isExpanded = () => drawer.isExpanded(resource);
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<div data-testid={`responsive-${props.type}-metric`} data-resource-id={props.resourceId ?? ''} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Workloads/StackedMemoryBar', () => ({
|
||||
StackedMemoryBar: () => <div data-testid="stacked-memory-bar" />,
|
||||
}));
|
||||
|
||||
const makeContainer = ({
|
||||
id,
|
||||
host,
|
||||
...overrides
|
||||
}: Partial<Resource> & { 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(() => (
|
||||
<Router>
|
||||
<Route
|
||||
path="/"
|
||||
component={(() => (
|
||||
<DockerContainersTable
|
||||
resources={FIXTURE}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No containers"
|
||||
emptyDescription="No containers"
|
||||
showToolbar={false}
|
||||
/>
|
||||
)) as () => JSX.Element}
|
||||
/>
|
||||
</Router>
|
||||
));
|
||||
|
||||
// Row order including group boundaries: group header rows render as
|
||||
// `group:<host>`, 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<WorkloadTableLayoutMode, number> = {
|
||||
mobile: 0,
|
||||
tablet: 1,
|
||||
|
|
|
|||
|
|
@ -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('<kind>')) 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('<kind>') on a raw TableHead or the
|
||||
// kind="<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/,
|
||||
|
|
|
|||
|
|
@ -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 = <T,>(
|
||||
fn: (sort: ReturnType<typeof createPlatformTableSortState<SortKey>>) => 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 extends string> = {
|
||||
sortKey: () => SortKey | null;
|
||||
sortDirection: () => PlatformTableSortDirection;
|
||||
handleSort: (key: SortKey) => void;
|
||||
getAriaSort: (key: SortKey) => 'ascending' | 'descending' | undefined;
|
||||
sortRows: <Row>(
|
||||
rows: readonly Row[],
|
||||
getSortValue: (row: Row, key: SortKey) => PlatformTableSortValue,
|
||||
) => readonly Row[];
|
||||
};
|
||||
|
||||
export function createPlatformTableSortState<SortKey extends string>(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<SortKey> {
|
||||
const isSortKey = (value: string): value is SortKey =>
|
||||
(options.sortKeys as readonly string[]).includes(value);
|
||||
const [sortKey, setSortKey] = usePersistentSignal<SortKey | null>(
|
||||
`${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<PlatformTableSortDirection>(
|
||||
`${options.storageKey}SortDirection`,
|
||||
'asc',
|
||||
{ deserialize: (raw) => (raw === 'desc' ? 'desc' : 'asc') },
|
||||
);
|
||||
const descendingFirst = new Set<SortKey>(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 = <Row,>(
|
||||
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<SortKey extends string>(props: {
|
||||
kind: PlatformTableColumnKind;
|
||||
sort: PlatformTableSortState<SortKey>;
|
||||
// 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 (
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind(props.kind)} ${
|
||||
props.sortKey !== undefined ? 'cursor-pointer select-none hover:bg-surface-hover' : ''
|
||||
} ${props.class ?? ''}`
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()}
|
||||
aria-sort={props.sortKey !== undefined ? props.sort.getAriaSort(props.sortKey) : undefined}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{props.children}
|
||||
{isSorted() && (props.sort.sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
export const formatPlatformTableTextValue = (value: unknown, emptyText = '—'): string =>
|
||||
asTrimmedString(value) || emptyText;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue