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
This commit is contained in:
rcourtman 2025-11-26 10:58:12 +00:00
parent d12d4b3530
commit 2b19127c0c

View file

@ -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 type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/types/api';
import { Card } from '@/components/shared/Card'; import { Card } from '@/components/shared/Card';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { EmptyState } from '@/components/shared/EmptyState'; import { EmptyState } from '@/components/shared/EmptyState';
import { MetricBar } from '@/components/Dashboard/MetricBar'; import { MetricBar } from '@/components/Dashboard/MetricBar';
import { formatBytes, formatPercent, formatUptime, formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; import { formatBytes, formatPercent, formatUptime, formatRelativeTime, formatAbsoluteTime } from '@/utils/format';
@ -22,6 +21,8 @@ import {
getDockerServiceStatusIndicator, getDockerServiceStatusIndicator,
} from '@/utils/status'; } from '@/utils/status';
import { usePersistentSignal } from '@/hooks/usePersistentSignal'; 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') => { const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => {
switch (type) { switch (type) {
@ -108,6 +109,30 @@ const SORT_DEFAULT_DIRECTION: Record<SortKey, SortDirection> = {
updated: 'desc', 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) // Global state for currently expanded drawer (only one drawer open at a time)
const [currentlyExpandedRowId, setCurrentlyExpandedRowId] = createSignal<string | null>(null); const [currentlyExpandedRowId, setCurrentlyExpandedRowId] = createSignal<string | null>(null);
@ -703,13 +728,16 @@ const buildRowId = (host: DockerHost, row: DockerRow) => {
const GROUPED_RESOURCE_INDENT = 'pl-5 sm:pl-6 lg:pl-8'; 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 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<string>;
visibleColumns: Accessor<ColumnConfig[]>;
}> = (props) => {
const displayName = getHostDisplayName(props.host); const displayName = getHostDisplayName(props.host);
const hostStatus = () => getDockerHostStatusIndicator(props.host); const hostStatus = () => getDockerHostStatusIndicator(props.host);
const isOnline = () => hostStatus().variant === 'success'; const isOnline = () => hostStatus().variant === 'success';
return ( return (
<tr class="bg-gray-50 dark:bg-gray-900/40"> <div class="bg-gray-50 dark:bg-gray-900/40 py-0.5 pr-2 pl-4">
<td colSpan={props.colspan} class="py-0.5 pr-2 pl-4">
<div <div
class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`} class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`}
title={hostStatus().label} title={hostStatus().label}
@ -727,14 +755,15 @@ const DockerHostGroupHeader: Component<{ host: DockerHost; colspan: number }> =
</span> </span>
</Show> </Show>
</div> </div>
</td> </div>
</tr>
); );
}; };
const DockerContainerRow: Component<{ const DockerContainerRow: Component<{
row: Extract<DockerRow, { kind: 'container' }>; row: Extract<DockerRow, { kind: 'container' }>;
columns: number; visibleColumns: Accessor<ColumnConfig[]>;
gridTemplate: Accessor<string>;
isMobile: Accessor<boolean>;
customUrl?: string; customUrl?: string;
onCustomUrlUpdate?: (resourceId: string, url: string) => void; onCustomUrlUpdate?: (resourceId: string, url: string) => void;
showHostContext?: boolean; showHostContext?: boolean;
@ -1046,15 +1075,12 @@ const DockerContainerRow: Component<{
return identifier ? `${primary} \u2014 ${identifier}` : primary; return identifier ? `${primary} \u2014 ${identifier}` : primary;
}; };
// Render cell content based on column type
const renderCell = (column: ColumnConfig) => {
switch (column.id) {
case 'resource':
return ( return (
<> <div class={`${resourceIndent()} pr-2 py-0.5`}>
<tr
class={`border-b border-gray-200 dark:border-gray-700 transition-all duration-200 ${hasDrawerContent() ? 'cursor-pointer' : ''
} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isRunning() ? 'opacity-60' : ''}`}
onClick={toggle}
aria-expanded={expanded()}
>
<td class={`${resourceIndent()} pr-2 py-0.5`}>
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
<StatusDot <StatusDot
variant={containerStatusIndicator().variant} variant={containerStatusIndicator().variant}
@ -1063,7 +1089,6 @@ const DockerContainerRow: Component<{
size="xs" size="xs"
/> />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
{/* Name - show input when editing, otherwise show name with optional link */}
<Show <Show
when={isEditingUrl()} when={isEditingUrl()}
fallback={ fallback={
@ -1098,18 +1123,8 @@ const DockerContainerRow: Component<{
title="Open in new tab" title="Open in new tab"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<svg <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="w-3.5 h-3.5" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg> </svg>
</a> </a>
</Show> </Show>
@ -1118,12 +1133,7 @@ const DockerContainerRow: Component<{
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300" class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300"
title={`Host: ${hostDisplayName()}`} title={`Host: ${hostDisplayName()}`}
> >
<StatusDot <StatusDot variant={hostStatus().variant} title={hostStatus().label} ariaLabel={hostStatus().label} size="xs" />
variant={hostStatus().variant}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<span class="max-w-[160px] truncate">{hostDisplayName()}</span> <span class="max-w-[160px] truncate">{hostDisplayName()}</span>
</span> </span>
</Show> </Show>
@ -1136,133 +1146,124 @@ const DockerContainerRow: Component<{
type="text" type="text"
value={editingUrlValue()} value={editingUrlValue()}
data-resource-id={resourceId()} data-resource-id={resourceId()}
onInput={(e) => { onInput={(e) => { dockerEditingValues.set(resourceId(), e.currentTarget.value); setDockerEditingValuesVersion(v => v + 1); }}
dockerEditingValues.set(resourceId(), e.currentTarget.value); onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); saveUrl(); } else if (e.key === 'Escape') { e.preventDefault(); cancelEditingUrl(); } }}
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()} onClick={(e) => e.stopPropagation()}
placeholder="https://example.com:8080" 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" 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"
/> />
<button <button type="button" data-url-editor-button onClick={(e) => { e.stopPropagation(); saveUrl(); }} class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors" title="Save (or press Enter)"></button>
type="button" <button type="button" data-url-editor-button onClick={(e) => { e.stopPropagation(); deleteUrl(); }} class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors" title="Delete URL"></button>
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
>
</button>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
>
</button>
</div> </div>
</Show> </Show>
</div> </div>
</div> </div>
</td> </div>
<td class="hidden sm:table-cell px-2 py-0.5"> );
<span case 'type':
class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`} return (
title={ <div class="px-2 py-0.5 flex items-center">
runtimeVersion() <span class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`} title={runtimeVersion() ? `${runtimeInfo.label} ${runtimeVersion()}` : runtimeInfo.raw || runtimeInfo.label}>
? `${runtimeInfo.label} ${runtimeVersion()}`
: runtimeInfo.raw || runtimeInfo.label
}
>
{runtimeInfo.label} {runtimeInfo.label}
</span> </span>
</td> </div>
<td class="hidden lg:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300"> );
<span title={container.image}> case 'image':
{container.image || '—'} return (
</span> <div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate">
</td> <span title={container.image}>{container.image || '—'}</span>
<td class="px-2 py-0.5 text-xs"> </div>
<span class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${statusBadgeClass()}`}> );
{statusLabel()} case 'status':
</span> return (
</td> <div class="px-2 py-0.5 text-xs">
<td class="hidden md:table-cell px-2 py-0.5"> <span class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${statusBadgeClass()}`}>{statusLabel()}</span>
<Show </div>
when={isRunning() && container.cpuPercent && container.cpuPercent > 0} );
fallback={<span class="text-xs text-gray-400"></span>} case 'cpu':
> return (
<MetricBar <div class="px-2 py-0.5 flex items-center">
<ResponsiveMetricCell
value={cpuPercent()} value={cpuPercent()}
label={formatPercent(cpuPercent())}
type="cpu" type="cpu"
resourceId={metricsKey} resourceId={metricsKey}
isRunning={isRunning() && (container.cpuPercent ?? 0) > 0}
showMobile={props.isMobile()}
class="w-full"
/> />
</Show> </div>
</td> );
<td class="hidden md:table-cell px-2 py-0.5"> case 'memory':
<Show return (
when={isRunning() && container.memoryUsageBytes && container.memoryUsageBytes > 0} <div class="px-2 py-0.5 flex items-center">
fallback={<span class="text-xs text-gray-400"></span>} <ResponsiveMetricCell
>
<MetricBar
value={memPercent()} value={memPercent()}
label={formatPercent(memPercent())}
type="memory" type="memory"
resourceId={metricsKey}
sublabel={memUsageLabel()} sublabel={memUsageLabel()}
resourceId={metricsKey} isRunning={isRunning() && (container.memoryUsageBytes ?? 0) > 0}
showMobile={props.isMobile()}
class="w-full"
/> />
</Show> </div>
</td> );
<td class="hidden xl:table-cell px-2 py-0.5"> case 'disk':
return (
<div class="px-2 py-0.5 flex items-center">
<Show when={hasDiskStats()} fallback={<span class="text-xs text-gray-400"></span>}> <Show when={hasDiskStats()} fallback={<span class="text-xs text-gray-400"></span>}>
<Show <Show when={diskPercent() !== null} fallback={<span class="text-xs text-gray-700 dark:text-gray-300">{diskUsageLabel()}</span>}>
when={diskPercent() !== null} <ResponsiveMetricCell
fallback={<span class="text-xs text-gray-700 dark:text-gray-300">{diskUsageLabel()}</span>}
>
<MetricBar
value={diskPercent() ?? 0} value={diskPercent() ?? 0}
label={formatPercent(diskPercent() ?? 0)}
type="disk" type="disk"
sublabel={diskSublabel() ?? diskUsageLabel()}
resourceId={metricsKey} resourceId={metricsKey}
sublabel={diskSublabel() ?? diskUsageLabel()}
isRunning={true}
showMobile={props.isMobile()}
class="w-full"
/> />
</Show> </Show>
</Show> </Show>
</td> </div>
<td class="hidden lg:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300"> );
case 'tasks':
return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
<Show when={isRunning()} fallback={<span class="text-gray-400"></span>}> <Show when={isRunning()} fallback={<span class="text-gray-400"></span>}>
{restarts()} {restarts()}
<span class="text-[10px] text-gray-500 dark:text-gray-400 ml-1">restarts</span> <span class="text-[10px] text-gray-500 dark:text-gray-400 ml-1">restarts</span>
</Show> </Show>
</td> </div>
<td class="hidden sm:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300"> );
case 'updated':
return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
<Show when={isRunning()} fallback={<span class="text-gray-400"></span>}> <Show when={isRunning()} fallback={<span class="text-gray-400"></span>}>
{uptime()} <Show when={props.isMobile()} fallback={uptime()}>
{formatUptime(container.uptimeSeconds || 0, true)}
</Show> </Show>
</td> </Show>
</tr> </div>
);
default:
return null;
}
};
return (
<>
<div
class={`grid items-center transition-all duration-200 ${hasDrawerContent() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isRunning() ? 'opacity-60' : ''}`}
style={{ 'grid-template-columns': props.gridTemplate() }}
onClick={toggle}
aria-expanded={expanded()}
>
<For each={props.visibleColumns()}>
{(column) => renderCell(column)}
</For>
</div>
<Show when={expanded() && hasDrawerContent()}> <Show when={expanded() && hasDrawerContent()}>
<tr class="bg-gray-50 dark:bg-gray-900/50"> <div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3">
<td colSpan={props.columns} class="px-4 py-3">
<div class="flex flex-wrap justify-start gap-3"> <div class="flex flex-wrap justify-start gap-3">
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30"> <div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200"> <div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
@ -1596,8 +1597,7 @@ const DockerContainerRow: Component<{
</div> </div>
</Show> </Show>
</div> </div>
</td> </div>
</tr>
</Show> </Show>
</> </>
); );
@ -1605,7 +1605,9 @@ const DockerContainerRow: Component<{
const DockerServiceRow: Component<{ const DockerServiceRow: Component<{
row: Extract<DockerRow, { kind: 'service' }>; row: Extract<DockerRow, { kind: 'service' }>;
columns: number; visibleColumns: Accessor<ColumnConfig[]>;
gridTemplate: Accessor<string>;
isMobile: Accessor<boolean>;
customUrl?: string; customUrl?: string;
onCustomUrlUpdate?: (resourceId: string, url: string) => void; onCustomUrlUpdate?: (resourceId: string, url: string) => void;
showHostContext?: boolean; showHostContext?: boolean;
@ -1814,18 +1816,12 @@ const DockerServiceRow: Component<{
return identifier ? `${primary} \u2014 ${identifier}` : primary; return identifier ? `${primary} \u2014 ${identifier}` : primary;
}; };
// Render cell content based on column type
const renderCell = (column: ColumnConfig) => {
switch (column.id) {
case 'resource':
return ( return (
<> <div class={`${resourceIndent()} pr-2 py-0.5`}>
<tr
class={`border-b border-gray-200 dark:border-gray-700 transition-all duration-200 ${hasTasks() ? 'cursor-pointer' : ''
} ${expanded()
? 'bg-gray-50 dark:bg-gray-800/40'
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
} ${!isHealthy() ? 'opacity-60' : ''}`}
onClick={toggle}
aria-expanded={expanded()}
>
<td class={`${resourceIndent()} pr-2 py-0.5`}>
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
<StatusDot <StatusDot
variant={serviceStatusIndicator().variant} variant={serviceStatusIndicator().variant}
@ -1834,7 +1830,6 @@ const DockerServiceRow: Component<{
size="xs" size="xs"
/> />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
{/* Name - show input when editing, otherwise show name with optional link */}
<Show <Show
when={isEditingUrl()} when={isEditingUrl()}
fallback={ fallback={
@ -1857,18 +1852,8 @@ const DockerServiceRow: Component<{
title="Open in new tab" title="Open in new tab"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<svg <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="w-3.5 h-3.5" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg> </svg>
</a> </a>
</Show> </Show>
@ -1882,12 +1867,7 @@ const DockerServiceRow: Component<{
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300" class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300"
title={`Host: ${hostDisplayName()}`} title={`Host: ${hostDisplayName()}`}
> >
<StatusDot <StatusDot variant={hostStatus().variant} title={hostStatus().label} ariaLabel={hostStatus().label} size="xs" />
variant={hostStatus().variant}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<span class="max-w-[160px] truncate">{hostDisplayName()}</span> <span class="max-w-[160px] truncate">{hostDisplayName()}</span>
</span> </span>
</Show> </Show>
@ -1900,77 +1880,58 @@ const DockerServiceRow: Component<{
type="text" type="text"
value={editingUrlValue()} value={editingUrlValue()}
data-resource-id={resourceId()} data-resource-id={resourceId()}
onInput={(e) => { onInput={(e) => { dockerEditingValues.set(resourceId(), e.currentTarget.value); setDockerEditingValuesVersion(v => v + 1); }}
dockerEditingValues.set(resourceId(), e.currentTarget.value); onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); saveUrl(); } else if (e.key === 'Escape') { e.preventDefault(); cancelEditingUrl(); } }}
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()} onClick={(e) => e.stopPropagation()}
placeholder="https://example.com:8080" 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" 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"
/> />
<button <button type="button" data-url-editor-button onClick={(e) => { e.stopPropagation(); saveUrl(); }} class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors" title="Save (or press Enter)"></button>
type="button" <button type="button" data-url-editor-button onClick={(e) => { e.stopPropagation(); deleteUrl(); }} class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors" title="Delete URL"></button>
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
>
</button>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
>
</button>
</div> </div>
</Show> </Show>
</div> </div>
</div> </div>
</td> </div>
<td class="hidden sm:table-cell px-2 py-0.5"> );
case 'type':
return (
<div class="px-2 py-0.5 flex items-center">
<span class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${typeBadgeClass('service')}`}> <span class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${typeBadgeClass('service')}`}>
Service Service
</span> </span>
</td> </div>
<td class="hidden lg:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300"> );
<span title={service.image}> case 'image':
{service.image || '—'} return (
</span> <div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate">
</td> <span title={service.image}>{service.image || '—'}</span>
<td class="px-2 py-0.5 text-xs"> </div>
<span class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${badge.class}`}> );
{badge.label} case 'status':
</span> return (
</td> <div class="px-2 py-0.5 text-xs">
<td class="hidden md:table-cell px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500"></td> <span class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${badge.class}`}>{badge.label}</span>
<td class="hidden md:table-cell px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500"></td> </div>
<td class="hidden xl:table-cell px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500"></td> );
<td class="hidden lg:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap"> case 'cpu':
return <div class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500"></div>;
case 'memory':
return <div class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500"></div>;
case 'disk':
return <div class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500"></div>;
case 'tasks':
return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<span class="font-semibold text-gray-900 dark:text-gray-100"> <span class="font-semibold text-gray-900 dark:text-gray-100">
{(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0} {(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0}
</span> </span>
<span class="ml-1 text-gray-500 dark:text-gray-400">tasks</span> <span class="ml-1 text-gray-500 dark:text-gray-400">tasks</span>
</td> </div>
<td class="hidden sm:table-cell px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap"> );
case 'updated':
return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<Show when={updatedAt} fallback="—"> <Show when={updatedAt} fallback="—">
{(timestamp) => ( {(timestamp) => (
<span title={new Date(timestamp()).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}> <span title={new Date(timestamp()).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}>
@ -1978,12 +1939,28 @@ const DockerServiceRow: Component<{
</span> </span>
)} )}
</Show> </Show>
</td> </div>
</tr> );
default:
return null;
}
};
return (
<>
<div
class={`grid items-center transition-all duration-200 ${hasTasks() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isHealthy() ? 'opacity-60' : ''}`}
style={{ 'grid-template-columns': props.gridTemplate() }}
onClick={toggle}
aria-expanded={expanded()}
>
<For each={props.visibleColumns()}>
{(column) => renderCell(column)}
</For>
</div>
<Show when={expanded() && hasTasks()}> <Show when={expanded() && hasTasks()}>
<tr class="bg-gray-50 dark:bg-gray-900/60"> <div class="bg-gray-50 dark:bg-gray-900/60 px-4 py-3">
<td colSpan={props.columns} class="px-4 py-3">
<div class="flex flex-wrap justify-start gap-3"> <div class="flex flex-wrap justify-start gap-3">
<div class="min-w-[320px] flex-1 rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30"> <div class="min-w-[320px] flex-1 rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="flex items-center justify-between text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200"> <div class="flex items-center justify-between text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
@ -2103,14 +2080,16 @@ const DockerServiceRow: Component<{
</div> </div>
</div> </div>
</div> </div>
</td> </div>
</tr>
</Show> </Show>
</> </>
); );
}; };
const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => { const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (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 tokens = createMemo(() => parseSearchTerm(props.searchTerm));
const [sortKey, setSortKey] = usePersistentSignal<SortKey>('dockerUnifiedSortKey', 'host', { const [sortKey, setSortKey] = usePersistentSignal<SortKey>('dockerUnifiedSortKey', 'host', {
deserialize: (value) => (SORT_KEYS.includes(value as SortKey) ? (value as SortKey) : 'host'), deserialize: (value) => (SORT_KEYS.includes(value as SortKey) ? (value as SortKey) : 'host'),
@ -2387,7 +2366,9 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
return row.kind === 'container' ? ( return row.kind === 'container' ? (
<DockerContainerRow <DockerContainerRow
row={row} row={row}
columns={9} visibleColumns={visibleColumns}
gridTemplate={gridTemplate}
isMobile={isMobile}
customUrl={metadata?.customUrl} customUrl={metadata?.customUrl}
onCustomUrlUpdate={props.onCustomUrlUpdate} onCustomUrlUpdate={props.onCustomUrlUpdate}
showHostContext={!grouped} showHostContext={!grouped}
@ -2396,7 +2377,9 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
) : ( ) : (
<DockerServiceRow <DockerServiceRow
row={row} row={row}
columns={9} visibleColumns={visibleColumns}
gridTemplate={gridTemplate}
isMobile={isMobile}
customUrl={metadata?.customUrl} customUrl={metadata?.customUrl}
onCustomUrlUpdate={props.onCustomUrlUpdate} onCustomUrlUpdate={props.onCustomUrlUpdate}
showHostContext={!grouped} showHostContext={!grouped}
@ -2425,22 +2408,31 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
} }
> >
<Card padding="none" class="overflow-hidden"> <Card padding="none" class="overflow-hidden">
<ScrollableTable> <div class="overflow-x-auto">
<table class="w-full table-fixed border-collapse whitespace-nowrap"> {/* Header Row */}
<thead> <div
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600"> class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[400px] md:min-w-0"
<th style={{ 'grid-template-columns': gridTemplate() }}
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-auto cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500" >
onClick={() => handleSort('resource')} <For each={visibleColumns()}>
onKeyDown={(e) => e.key === 'Enter' && handleSort('resource')} {(column) => {
const col = column as DockerColumnDef;
const colSortKey = col.sortKey as SortKey | undefined;
const isResource = col.id === 'resource';
return (
<div
class={`${isResource ? 'pl-4 pr-2' : 'px-2'} py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center whitespace-nowrap`}
onClick={() => colSortKey && handleSort(colSortKey)}
onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)}
tabIndex={0} tabIndex={0}
role="button" role="button"
aria-label={`Sort by resource ${sortKey() === 'resource' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
aria-sort={ariaSort('resource')} aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'}
> >
<Show when={isResource}>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span>Resource</span> <span>{col.label}</span>
{renderSortIndicator('resource')} {colSortKey && renderSortIndicator(colSortKey)}
<Show when={sortKey() === 'host'}> <Show when={sortKey() === 'host'}>
<span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">Grouped by host</span> <span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">Grouped by host</span>
</Show> </Show>
@ -2457,122 +2449,22 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
</button> </button>
</Show> </Show>
</div> </div>
</th> </Show>
<th <Show when={!isResource}>
class="hidden sm:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => 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')}
>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span>Type</span> <span class="hidden xl:inline">{col.label}</span>
{renderSortIndicator('type')} <span class="xl:hidden">{col.shortLabel || col.label}</span>
{colSortKey && renderSortIndicator(colSortKey)}
</div> </div>
</th> </Show>
<th
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => 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')}
>
<div class="flex items-center gap-1">
<span>Image / Stack</span>
{renderSortIndicator('image')}
</div> </div>
</th> );
<th }}
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500" </For>
onClick={() => 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')}
>
<div class="flex items-center gap-1">
<span>Status</span>
{renderSortIndicator('status')}
</div> </div>
</th>
<th {/* Rows */}
class="hidden md:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500" <div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[400px] md:min-w-0">
onClick={() => 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')}
>
<div class="flex items-center gap-1">
<span>CPU</span>
{renderSortIndicator('cpu')}
</div>
</th>
<th
class="hidden md:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => 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')}
>
<div class="flex items-center gap-1">
<span>Memory</span>
{renderSortIndicator('memory')}
</div>
</th>
<th
class="hidden xl:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => 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')}
>
<div class="flex items-center gap-1">
<span>Disk</span>
{renderSortIndicator('disk')}
</div>
</th>
<th
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[9%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => 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')}
>
<div class="flex items-center gap-1">
<span>Tasks / Restarts</span>
{renderSortIndicator('tasks')}
</div>
</th>
<th
class="hidden sm:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%] cursor-pointer select-none hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => 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')}
>
<div class="flex items-center gap-1">
<span>Updated / Uptime</span>
{renderSortIndicator('updated')}
</div>
</th>
</tr>
</thead>
<tbody>
<Show <Show
when={isGroupedView()} when={isGroupedView()}
fallback={ fallback={
@ -2584,15 +2476,14 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
<For each={orderedGroups()}> <For each={orderedGroups()}>
{(group) => ( {(group) => (
<> <>
<DockerHostGroupHeader host={group.host} colspan={9} /> <DockerHostGroupHeader host={group.host} gridTemplate={gridTemplate} visibleColumns={visibleColumns} />
<For each={group.rows}>{(row) => renderRow(row, true)}</For> <For each={group.rows}>{(row) => renderRow(row, true)}</For>
</> </>
)} )}
</For> </For>
</Show> </Show>
</tbody> </div>
</table> </div>
</ScrollableTable>
</Card> </Card>
<div class="flex items-center gap-2 rounded border border-gray-200 bg-gray-50 p-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-300"> <div class="flex items-center gap-2 rounded border border-gray-200 bg-gray-50 p-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-300">