From 516097990a41366c5594876d23dca5fb3d5f06c5 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 20 Nov 2025 17:58:19 +0000 Subject: [PATCH] Add sorting controls to Docker resources table Related to #730 --- .../components/Docker/DockerUnifiedTable.tsx | 524 ++++++++++++++++-- 1 file changed, 466 insertions(+), 58 deletions(-) diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 49f8662..f618694 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -21,6 +21,7 @@ import { getDockerHostStatusIndicator, getDockerServiceStatusIndicator, } from '@/utils/status'; +import { usePersistentSignal } from '@/hooks/usePersistentSignal'; const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => { switch (type) { @@ -67,6 +68,46 @@ interface DockerUnifiedTableProps { onCustomUrlUpdate?: (resourceId: string, url: string) => void; } +type SortKey = + | 'host' + | 'resource' + | 'type' + | 'image' + | 'status' + | 'cpu' + | 'memory' + | 'disk' + | 'tasks' + | 'updated'; + +type SortDirection = 'asc' | 'desc'; + +const SORT_KEYS: SortKey[] = [ + 'host', + 'resource', + 'type', + 'image', + 'status', + 'cpu', + 'memory', + 'disk', + 'tasks', + 'updated', +]; + +const SORT_DEFAULT_DIRECTION: Record = { + host: 'asc', + resource: 'asc', + type: 'asc', + image: 'asc', + status: 'desc', + cpu: 'desc', + memory: 'desc', + disk: 'desc', + tasks: 'desc', + updated: 'desc', +}; + // Global state for currently expanded drawer (only one drawer open at a time) const [currentlyExpandedRowId, setCurrentlyExpandedRowId] = createSignal(null); @@ -101,6 +142,132 @@ const parseSearchTerm = (term?: string): SearchToken[] => { }); }; +const getHostDisplayName = (host: DockerHost): string => + host.customDisplayName || host.displayName || host.hostname || host.id || ''; + +const compareStrings = (a: string, b: string) => + a.localeCompare(b, undefined, { sensitivity: 'base' }); + +const STATUS_SEVERITY: Record = { + error: 3, + critical: 3, + danger: 3, + warning: 2, + degraded: 2, + offline: 2, + alert: 2, + info: 1, + success: 1, + ok: 1, + default: 0, +}; + +const getResourceName = (row: DockerRow) => + row.kind === 'container' + ? row.container.name || row.container.id || '' + : row.service.name || row.service.id || ''; + +const getImageKey = (row: DockerRow) => + row.kind === 'container' + ? row.container.image || '' + : row.service.image || row.service.stack || ''; + +const getTypeSortValue = (row: DockerRow) => (row.kind === 'container' ? 0 : 1); + +const getStatusSortValue = (row: DockerRow) => { + const indicator = + row.kind === 'container' + ? getDockerContainerStatusIndicator(row.container) + : getDockerServiceStatusIndicator(row.service); + return STATUS_SEVERITY[toLower(indicator.variant)] ?? 0; +}; + +const getContainerCpuSortValue = (container: DockerContainer) => { + const running = toLower(container.state) === 'running'; + const value = Number.isFinite(container.cpuPercent) ? container.cpuPercent : Number.NEGATIVE_INFINITY; + if (!running || value <= 0) return Number.NEGATIVE_INFINITY; + return value; +}; + +const getContainerMemorySortValue = (container: DockerContainer) => { + const running = toLower(container.state) === 'running'; + const value = Number.isFinite(container.memoryPercent) + ? container.memoryPercent + : Number.NEGATIVE_INFINITY; + if (!running || !container.memoryUsageBytes) return Number.NEGATIVE_INFINITY; + return value; +}; + +const getContainerDiskSortValue = (container: DockerContainer) => { + const total = container.rootFilesystemBytes ?? 0; + const used = container.writableLayerBytes ?? 0; + if (total <= 0 || used <= 0) return Number.NEGATIVE_INFINITY; + return Math.min(100, (used / total) * 100); +}; + +const getCpuSortValue = (row: DockerRow) => + row.kind === 'container' ? getContainerCpuSortValue(row.container) : Number.NEGATIVE_INFINITY; + +const getMemorySortValue = (row: DockerRow) => + row.kind === 'container' ? getContainerMemorySortValue(row.container) : Number.NEGATIVE_INFINITY; + +const getDiskSortValue = (row: DockerRow) => + row.kind === 'container' ? getContainerDiskSortValue(row.container) : Number.NEGATIVE_INFINITY; + +const getTasksSortValue = (row: DockerRow) => { + if (row.kind === 'container') { + const restarts = Number.isFinite(row.container.restartCount) + ? row.container.restartCount + : 0; + return -restarts; + } + + const desired = row.service.desiredTasks ?? 0; + const running = row.service.runningTasks ?? 0; + if (desired > 0) { + return running / desired; + } + if (running > 0) return 1; + return 0; +}; + +const getUpdatedSortValue = (row: DockerRow) => { + if (row.kind === 'container') { + const uptime = row.container.uptimeSeconds; + if (!Number.isFinite(uptime)) return Number.NEGATIVE_INFINITY; + return Date.now() - uptime * 1000; + } + const timestamp = ensureMs(row.service.updatedAt ?? row.service.createdAt); + return timestamp ?? Number.NEGATIVE_INFINITY; +}; + +const compareRowsByKey = (a: DockerRow, b: DockerRow, key: SortKey) => { + switch (key) { + case 'host': + return compareStrings(toLower(getHostDisplayName(a.host)), toLower(getHostDisplayName(b.host))); + case 'resource': + return compareStrings(toLower(getResourceName(a)), toLower(getResourceName(b))); + case 'type': + return getTypeSortValue(a) - getTypeSortValue(b); + case 'image': + return compareStrings(toLower(getImageKey(a)), toLower(getImageKey(b))); + case 'status': + return getStatusSortValue(a) - getStatusSortValue(b); + case 'cpu': + return getCpuSortValue(a) - getCpuSortValue(b); + case 'memory': + return getMemorySortValue(a) - getMemorySortValue(b); + case 'disk': + return getDiskSortValue(a) - getDiskSortValue(b); + case 'tasks': + return getTasksSortValue(a) - getTasksSortValue(b); + case 'updated': + return getUpdatedSortValue(a) - getUpdatedSortValue(b); + default: + return compareStrings(toLower(getResourceName(a)), toLower(getResourceName(b))); + } +}; + interface PodmanMetadataItem { label: string; value?: string; @@ -534,9 +701,10 @@ const buildRowId = (host: DockerHost, row: DockerRow) => { }; const GROUPED_RESOURCE_INDENT = 'pl-5 sm:pl-6 lg:pl-8'; +const UNGROUPED_RESOURCE_INDENT = 'pl-4 sm:pl-5 lg:pl-6'; const DockerHostGroupHeader: Component<{ host: DockerHost; colspan: number }> = (props) => { - const displayName = props.host.customDisplayName || props.host.displayName || props.host.hostname || props.host.id; + const displayName = getHostDisplayName(props.host); const hostStatus = () => getDockerHostStatusIndicator(props.host); const isOnline = () => hostStatus().variant === 'success'; return ( @@ -569,13 +737,18 @@ const DockerContainerRow: Component<{ columns: number; customUrl?: string; onCustomUrlUpdate?: (resourceId: string, url: string) => void; + showHostContext?: boolean; + resourceIndentClass?: string; }> = (props) => { const { host, container } = props.row; const runtimeInfo = resolveHostRuntime(host); const runtimeVersion = () => host.runtimeVersion || host.dockerVersion || null; + const hostStatus = createMemo(() => getDockerHostStatusIndicator(host)); + const hostDisplayName = () => getHostDisplayName(host); const rowId = buildRowId(host, props.row); const resourceId = () => `${host.id}:container:${container.id || container.name}`; const isEditingUrl = createMemo(() => currentlyEditingDockerResourceId() === resourceId()); + const resourceIndent = () => props.resourceIndentClass ?? GROUPED_RESOURCE_INDENT; const [customUrl, setCustomUrl] = createSignal(props.customUrl); const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false); @@ -882,7 +1055,7 @@ const DockerContainerRow: Component<{ onClick={toggle} aria-expanded={expanded()} > - +
+ + + + {hostDisplayName()} + +
} > @@ -1423,11 +1610,16 @@ const DockerServiceRow: Component<{ columns: number; customUrl?: string; onCustomUrlUpdate?: (resourceId: string, url: string) => void; + showHostContext?: boolean; + resourceIndentClass?: string; }> = (props) => { const { host, service, tasks } = props.row; const rowId = buildRowId(host, props.row); const resourceId = () => `${host.id}:service:${service.id || service.name}`; const isEditingUrl = createMemo(() => currentlyEditingDockerResourceId() === resourceId()); + const hostStatus = createMemo(() => getDockerHostStatusIndicator(host)); + const hostDisplayName = () => getHostDisplayName(host); + const resourceIndent = () => props.resourceIndentClass ?? GROUPED_RESOURCE_INDENT; const [customUrl, setCustomUrl] = createSignal(props.customUrl); const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false); @@ -1637,7 +1829,7 @@ const DockerServiceRow: Component<{ onClick={toggle} aria-expanded={expanded()} > - +
+ + + + {hostDisplayName()} + +
} > @@ -1910,12 +2116,51 @@ const DockerServiceRow: Component<{ const DockerUnifiedTable: Component = (props) => { const tokens = createMemo(() => parseSearchTerm(props.searchTerm)); + const [sortKey, setSortKey] = usePersistentSignal('dockerUnifiedSortKey', 'host', { + deserialize: (value) => (SORT_KEYS.includes(value as SortKey) ? (value as SortKey) : 'host'), + }); + const [sortDirection, setSortDirection] = usePersistentSignal( + 'dockerUnifiedSortDirection', + 'asc', + { + deserialize: (value) => (value === 'asc' || value === 'desc' ? value : 'asc'), + }, + ); + + const isGroupedView = createMemo(() => sortKey() === 'host'); + + const handleSort = (key: SortKey) => { + if (sortKey() === key) { + setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); + return; + } + setSortKey(key); + setSortDirection(SORT_DEFAULT_DIRECTION[key]); + }; + + const renderSortIndicator = (key: SortKey) => { + if (sortKey() !== key) return null; + return sortDirection() === 'asc' ? '▲' : '▼'; + }; + + const resetHostGrouping = () => { + setSortKey('host'); + setSortDirection(SORT_DEFAULT_DIRECTION.host); + }; + + const ariaSort = (key: SortKey) => { + if (sortKey() !== key) { + if (sortKey() === 'host' && key === 'resource') return 'other'; + return 'none'; + } + return sortDirection() === 'asc' ? 'ascending' : 'descending'; + }; const sortedHosts = createMemo(() => { const hosts = props.hosts || []; return [...hosts].sort((a, b) => { - const aName = a.customDisplayName || a.displayName || a.hostname || a.id; - const bName = b.customDisplayName || b.displayName || b.hostname || b.id; + const aName = getHostDisplayName(a); + const bName = getHostDisplayName(b); return aName.localeCompare(bName); }); }); @@ -2042,9 +2287,45 @@ const DockerUnifiedTable: Component = (props) => { return groups; }); - const totalRows = createMemo(() => - groupedRows().reduce((acc, group) => acc + group.rows.length, 0), - ); + const flatRows = createMemo(() => groupedRows().flatMap((group) => group.rows)); + + const orderedGroups = createMemo(() => { + if (sortKey() !== 'host') { + return groupedRows(); + } + if (sortDirection() === 'asc') return groupedRows(); + const reversed = [...groupedRows()]; + reversed.reverse(); + return reversed; + }); + + const sortedRows = createMemo(() => { + if (sortKey() === 'host') { + return flatRows(); + } + + const rows = [...flatRows()]; + const key = sortKey(); + const dir = sortDirection(); + + rows.sort((a, b) => { + const primary = compareRowsByKey(a, b, key); + if (primary !== 0) { + return dir === 'asc' ? primary : -primary; + } + + const byResource = compareRowsByKey(a, b, 'resource'); + if (byResource !== 0) { + return byResource; + } + + return compareRowsByKey(a, b, 'host'); + }); + + return rows; + }); + + const totalRows = createMemo(() => flatRows().length); const totalContainers = createMemo(() => (props.hosts || []).reduce((acc, host) => acc + (host.containers?.length ?? 0), 0), @@ -2075,6 +2356,34 @@ const DockerUnifiedTable: Component = (props) => { }, 0), ); + const renderRow = (row: DockerRow, grouped: boolean) => { + const resourceId = + row.kind === 'container' + ? `${row.host.id}:container:${row.container.id || row.container.name}` + : `${row.host.id}:service:${row.service.id || row.service.name}`; + const metadata = props.dockerMetadata?.[resourceId]; + + return row.kind === 'container' ? ( + + ) : ( + + ); + }; + return (
= (props) => { - - - - - - - - - - - {(group) => ( - <> - - - {(row) => { - // Build resource ID for metadata lookup - const resourceId = row.kind === 'container' - ? `${row.host.id}:container:${row.container.id || row.container.name}` - : `${row.host.id}:service:${row.service.id || row.service.name}`; - const metadata = props.dockerMetadata?.[resourceId]; - - return row.kind === 'container' ? ( - - ) : ( - - ); - }} - - - )} - + + {(row) => renderRow(row, false)} + + } + > + + {(group) => ( + <> + + {(row) => renderRow(row, true)} + + )} + +
- Resource + handleSort('resource')} + onKeyDown={(e) => e.key === 'Enter' && handleSort('resource')} + tabIndex={0} + role="button" + aria-label={`Sort by resource ${sortKey() === 'resource' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={ariaSort('resource')} + > +
+ Resource + {renderSortIndicator('resource')} + + Grouped by host + + + + +
- Type + handleSort('type')} + onKeyDown={(e) => e.key === 'Enter' && handleSort('type')} + tabIndex={0} + role="button" + aria-label={`Sort by type ${sortKey() === 'type' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={ariaSort('type')} + > +
+ Type + {renderSortIndicator('type')} +
- Image / Stack + handleSort('image')} + onKeyDown={(e) => e.key === 'Enter' && handleSort('image')} + tabIndex={0} + role="button" + aria-label={`Sort by image or stack ${sortKey() === 'image' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={ariaSort('image')} + > +
+ Image / Stack + {renderSortIndicator('image')} +
- Status + handleSort('status')} + onKeyDown={(e) => e.key === 'Enter' && handleSort('status')} + tabIndex={0} + role="button" + aria-label={`Sort by status ${sortKey() === 'status' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={ariaSort('status')} + > +
+ Status + {renderSortIndicator('status')} +
- CPU + handleSort('cpu')} + onKeyDown={(e) => e.key === 'Enter' && handleSort('cpu')} + tabIndex={0} + role="button" + aria-label={`Sort by CPU ${sortKey() === 'cpu' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={ariaSort('cpu')} + > +
+ CPU + {renderSortIndicator('cpu')} +
- Memory + handleSort('memory')} + onKeyDown={(e) => e.key === 'Enter' && handleSort('memory')} + tabIndex={0} + role="button" + aria-label={`Sort by memory ${sortKey() === 'memory' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={ariaSort('memory')} + > +
+ Memory + {renderSortIndicator('memory')} +
- Disk + handleSort('disk')} + onKeyDown={(e) => e.key === 'Enter' && handleSort('disk')} + tabIndex={0} + role="button" + aria-label={`Sort by disk ${sortKey() === 'disk' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={ariaSort('disk')} + > +
+ Disk + {renderSortIndicator('disk')} +
- Tasks / Restarts + handleSort('tasks')} + onKeyDown={(e) => e.key === 'Enter' && handleSort('tasks')} + tabIndex={0} + role="button" + aria-label={`Sort by tasks or restarts ${sortKey() === 'tasks' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={ariaSort('tasks')} + > +
+ Tasks / Restarts + {renderSortIndicator('tasks')} +
- Updated / Uptime + handleSort('updated')} + onKeyDown={(e) => e.key === 'Enter' && handleSort('updated')} + tabIndex={0} + role="button" + aria-label={`Sort by updated or uptime ${sortKey() === 'updated' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={ariaSort('updated')} + > +
+ Updated / Uptime + {renderSortIndicator('updated')} +