From 2b19127c0ce96c41b981f13fd0e6e674b888653d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 26 Nov 2025 10:58:12 +0000 Subject: [PATCH] feat: add responsive column hiding to Docker table Convert Docker table from HTML table to CSS Grid with dynamic column visibility, matching the responsive behavior of the Proxmox overview. Changes: - Add DOCKER_COLUMNS with priority-based visibility breakpoints - Use useGridTemplate hook for dynamic grid-template-columns - Convert DockerContainerRow and DockerServiceRow to grid layout - Use ResponsiveMetricCell for CPU/Memory/Disk columns - Columns show/hide automatically based on viewport width: - essential (always): Resource, Status - primary (sm): Type, Updated - secondary (md): CPU, Memory - supplementary (lg): Image, Tasks - detailed (xl): Disk --- .../components/Docker/DockerUnifiedTable.tsx | 1027 ++++++++--------- 1 file changed, 459 insertions(+), 568 deletions(-) diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index c846e25..04eb97f 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -1,7 +1,6 @@ -import { Component, For, Show, createMemo, createSignal, createEffect } from 'solid-js'; +import { Component, For, Show, createMemo, createSignal, createEffect, Accessor } from 'solid-js'; import type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/types/api'; import { Card } from '@/components/shared/Card'; -import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { EmptyState } from '@/components/shared/EmptyState'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { formatBytes, formatPercent, formatUptime, formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; @@ -22,6 +21,8 @@ import { getDockerServiceStatusIndicator, } from '@/utils/status'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; +import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive'; +import type { ColumnConfig } from '@/types/responsive'; const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => { switch (type) { @@ -108,6 +109,30 @@ const SORT_DEFAULT_DIRECTION: Record = { updated: 'desc', }; +// Column configuration using the priority system (matching Proxmox overview pattern) +// Extends ColumnConfig for type compatibility with useGridTemplate +interface DockerColumnDef extends ColumnConfig { + shortLabel?: string; // Short label for narrow viewports +} + +// Column definitions with responsive priorities: +// - essential: Always visible (xs and up) +// - primary: Visible on small screens and up (sm: 640px+) +// - secondary: Visible on medium screens and up (md: 768px+) +// - supplementary: Visible on large screens and up (lg: 1024px+) +// - detailed: Visible on extra large screens and up (xl: 1280px+) +export const DOCKER_COLUMNS: DockerColumnDef[] = [ + { id: 'resource', label: 'Resource', priority: 'essential', minWidth: '150px', flex: 2, sortKey: 'resource' }, + { id: 'type', label: 'Type', priority: 'primary', minWidth: '60px', maxWidth: '80px', sortKey: 'type' }, + { id: 'image', label: 'Image / Stack', shortLabel: 'Image', priority: 'supplementary', minWidth: '100px', flex: 1, sortKey: 'image' }, + { id: 'status', label: 'Status', priority: 'essential', minWidth: '80px', maxWidth: '120px', sortKey: 'status' }, + { id: 'cpu', label: 'CPU', priority: 'secondary', minWidth: '80px', flex: 1, sortKey: 'cpu' }, + { id: 'memory', label: 'Memory', shortLabel: 'Mem', priority: 'secondary', minWidth: '90px', flex: 1, sortKey: 'memory' }, + { id: 'disk', label: 'Disk', priority: 'detailed', minWidth: '90px', flex: 1, sortKey: 'disk' }, + { id: 'tasks', label: 'Tasks / Restarts', shortLabel: 'Tasks', priority: 'supplementary', minWidth: '70px', maxWidth: '100px', sortKey: 'tasks' }, + { id: 'updated', label: 'Updated / Uptime', shortLabel: 'Updated', priority: 'primary', minWidth: '60px', maxWidth: '90px', sortKey: 'updated' }, +]; + // Global state for currently expanded drawer (only one drawer open at a time) const [currentlyExpandedRowId, setCurrentlyExpandedRowId] = createSignal(null); @@ -703,38 +728,42 @@ 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 DockerHostGroupHeader: Component<{ + host: DockerHost; + gridTemplate: Accessor; + visibleColumns: Accessor; +}> = (props) => { const displayName = getHostDisplayName(props.host); const hostStatus = () => getDockerHostStatusIndicator(props.host); const isOnline = () => hostStatus().variant === 'success'; return ( - - -
+
+ - - {displayName} - - - ({props.host.hostname}) - - -
- - + ariaLabel={hostStatus().label} + size="xs" + /> + {displayName} + + + ({props.host.hostname}) + + +
+ ); }; const DockerContainerRow: Component<{ row: Extract; - columns: number; + visibleColumns: Accessor; + gridTemplate: Accessor; + isMobile: Accessor; customUrl?: string; onCustomUrlUpdate?: (resourceId: string, url: string) => void; showHostContext?: boolean; @@ -1046,223 +1075,195 @@ const DockerContainerRow: Component<{ return identifier ? `${primary} \u2014 ${identifier}` : primary; }; + // Render cell content based on column type + const renderCell = (column: ColumnConfig) => { + switch (column.id) { + case 'resource': + return ( +
+
+ +
+ + + {container.name || container.id} + + + {(name) => ( + + Pod: {name()} + + + infra + + + + )} + + + event.stopPropagation()} + > + + + + + + + + + {hostDisplayName()} + + +
+ } + > +
+ { dockerEditingValues.set(resourceId(), e.currentTarget.value); setDockerEditingValuesVersion(v => v + 1); }} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); saveUrl(); } else if (e.key === 'Escape') { e.preventDefault(); cancelEditingUrl(); } }} + onClick={(e) => e.stopPropagation()} + placeholder="https://example.com:8080" + class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+ +
+
+ + ); + case 'type': + return ( +
+ + {runtimeInfo.label} + +
+ ); + case 'image': + return ( +
+ {container.image || '—'} +
+ ); + case 'status': + return ( +
+ {statusLabel()} +
+ ); + case 'cpu': + return ( +
+ 0} + showMobile={props.isMobile()} + class="w-full" + /> +
+ ); + case 'memory': + return ( +
+ 0} + showMobile={props.isMobile()} + class="w-full" + /> +
+ ); + case 'disk': + return ( +
+ —}> + {diskUsageLabel()}}> + + + +
+ ); + case 'tasks': + return ( +
+ —}> + {restarts()} + restarts + +
+ ); + case 'updated': + return ( +
+ —}> + + {formatUptime(container.uptimeSeconds || 0, true)} + + +
+ ); + default: + return null; + } + }; + return ( <> - - -
- -
- {/* Name - show input when editing, otherwise show name with optional link */} - - - {container.name || container.id} - - - {(name) => ( - - Pod: {name()} - - - infra - - - - )} - - - event.stopPropagation()} - > - - - - - - - - - {hostDisplayName()} - - -
- } - > -
- { - dockerEditingValues.set(resourceId(), e.currentTarget.value); - setDockerEditingValuesVersion(v => v + 1); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - saveUrl(); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancelEditingUrl(); - } - }} - onClick={(e) => e.stopPropagation()} - placeholder="https://example.com:8080" - class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - - -
- -
- - - - - {runtimeInfo.label} - - - - - {container.image || '—'} - - - - - {statusLabel()} - - - - 0} - fallback={} - > - - - - - 0} - fallback={} - > - - - - - —}> - {diskUsageLabel()}} - > - - - - - - —}> - {restarts()} - restarts - - - - —}> - {uptime()} - - - + + {(column) => renderCell(column)} + + - - +
@@ -1596,8 +1597,7 @@ const DockerContainerRow: Component<{
- - +
); @@ -1605,7 +1605,9 @@ const DockerContainerRow: Component<{ const DockerServiceRow: Component<{ row: Extract; - columns: number; + visibleColumns: Accessor; + gridTemplate: Accessor; + isMobile: Accessor; customUrl?: string; onCustomUrlUpdate?: (resourceId: string, url: string) => void; showHostContext?: boolean; @@ -1814,176 +1816,151 @@ const DockerServiceRow: Component<{ return identifier ? `${primary} \u2014 ${identifier}` : primary; }; + // Render cell content based on column type + const renderCell = (column: ColumnConfig) => { + switch (column.id) { + case 'resource': + return ( +
+
+ +
+ + + {service.name || service.id || 'Service'} + + + event.stopPropagation()} + > + + + + + + + + Stack: {service.stack} + + + + + + {hostDisplayName()} + + +
+ } + > +
+ { dockerEditingValues.set(resourceId(), e.currentTarget.value); setDockerEditingValuesVersion(v => v + 1); }} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); saveUrl(); } else if (e.key === 'Escape') { e.preventDefault(); cancelEditingUrl(); } }} + onClick={(e) => e.stopPropagation()} + placeholder="https://example.com:8080" + class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+ +
+
+
+ ); + case 'type': + return ( +
+ + Service + +
+ ); + case 'image': + return ( +
+ {service.image || '—'} +
+ ); + case 'status': + return ( +
+ {badge.label} +
+ ); + case 'cpu': + return
; + case 'memory': + return
; + case 'disk': + return
; + case 'tasks': + return ( +
+ + {(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0} + + tasks +
+ ); + case 'updated': + return ( +
+ + {(timestamp) => ( + + {formatRelativeTime(timestamp())} + + )} + +
+ ); + default: + return null; + } + }; + return ( <> - - -
- -
- {/* Name - show input when editing, otherwise show name with optional link */} - - - {service.name || service.id || 'Service'} - - - event.stopPropagation()} - > - - - - - - - - Stack: {service.stack} - - - - - - {hostDisplayName()} - - -
- } - > -
- { - dockerEditingValues.set(resourceId(), e.currentTarget.value); - setDockerEditingValuesVersion(v => v + 1); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - saveUrl(); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancelEditingUrl(); - } - }} - onClick={(e) => e.stopPropagation()} - placeholder="https://example.com:8080" - class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - - -
- -
- - - - - Service - - - - - {service.image || '—'} - - - - - {badge.label} - - - — - — - — - - - {(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0} - - tasks - - - - {(timestamp) => ( - - {formatRelativeTime(timestamp())} - - )} - - - + + {(column) => renderCell(column)} + + - - +
@@ -2103,14 +2080,16 @@ const DockerServiceRow: Component<{
- - +
); }; const DockerUnifiedTable: Component = (props) => { + // Use the responsive grid template hook for dynamic column visibility + const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: DOCKER_COLUMNS }); + const tokens = createMemo(() => parseSearchTerm(props.searchTerm)); const [sortKey, setSortKey] = usePersistentSignal('dockerUnifiedSortKey', 'host', { deserialize: (value) => (SORT_KEYS.includes(value as SortKey) ? (value as SortKey) : 'host'), @@ -2387,7 +2366,9 @@ const DockerUnifiedTable: Component = (props) => { return row.kind === 'container' ? ( = (props) => { ) : ( = (props) => { } > - - - - - - - - - - - - - - - - - - {(row) => renderRow(row, false)} - - } - > - - {(group) => ( - <> - - {(row) => renderRow(row, true)} - - )} + ); + }} + + + + {/* Rows */} +
+ + {(row) => renderRow(row, false)} - -
-
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 +
+ {/* Header Row */} +
+ + {(column) => { + const col = column as DockerColumnDef; + const colSortKey = col.sortKey as SortKey | undefined; + const isResource = col.id === 'resource'; + return ( +
colSortKey && handleSort(colSortKey)} + onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)} + tabIndex={0} + role="button" + aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'} + > + +
+ {col.label} + {colSortKey && renderSortIndicator(colSortKey)} + + Grouped by host + + + + +
- - + +
+ + {col.shortLabel || col.label} + {colSortKey && renderSortIndicator(colSortKey)} +
-
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')} -
-
-
+ } + > + + {(group) => ( + <> + + {(row) => renderRow(row, true)} + + )} + +
+ +