From 3c9d36172af91e7a448d813fca1b6d1f6a4e21ab Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:03:01 +0100 Subject: [PATCH] Read a container's state under its name on narrow phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below a 360px table (most phones at 360-393px) the shared shell gives the name column a 40% floor, and the State column beside it, both metric pills and the Update control cut "Unhealthy" to "Unhe…" and "Exited (139)" to "Exite…". Narrowing the name from the colgroup does not help: the browser takes the larger of the header and column widths and rescales everything else. The narrow layout drops the State column and shows the same words, tone and title on a second line under the container name, on every row so rows keep one height. CPU, memory and Update share the rest, which keeps a 100% pill and the Update label whole at 320px too. The phone layout and up keep their State column. A saved sort now applies only while its column's header shows, so a State sort stops reordering phone rows with no visible indicator; this replaces the grouped view's Host/Engine special case. The name cell only adds its column wrapper when a table passes a second line. Reviewed by Sol from before/after screenshots at 320, 390 and 430px. --- .../subsystems/frontend-primitives.md | 16 ++- .../internal/subsystems/unified-resources.md | 6 +- .../browser-tests/proxmox-docker-rows.cjs | 14 ++- .../64bc0d9ae9b93e92.json | 53 +++++++++ .../features/docker/DockerContainersTable.tsx | 112 ++++++++++-------- .../docker/DockerNativeTableShared.tsx | 30 +++-- .../__tests__/DockerNativeTables.test.tsx | 88 ++++++++++++++ .../dockerContainerTableModel.test.ts | 19 ++- .../docker/dockerContainerTableModel.ts | 27 +++-- 9 files changed, 288 insertions(+), 77 deletions(-) create mode 100644 frontend-modern/browser-verification/64bc0d9ae9b93e92.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 250bf4e082..26f5c2612e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2768,7 +2768,14 @@ not a replacement status card, CTA band, or page-local nested card. Below a 360-pixel content width, the shared narrow layout promotes identity to 40 percent and may remove one additional lowest-priority context column via `platform-table-narrow-hidden` rather than shrinking names and metric values - into illegible fragments. The shared container rule then applies that stage + into illegible fragments. That 40 percent is a floor on the name header, + and a browser lays a fixed table out by the larger of a header's width and + its column's, so a table cannot shrink identity below it from its colgroup + (doing so only rescales every other column). A field that needs more room + than the remainder gives it moves into the identity cell instead: Docker's + container State words read on a second line under the name there, on every + row so the rows keep one height, and CPU, memory and the Update control + share the rest. The shared container rule then applies that stage across provider tables, direct Storage tables, and nested provider detail tables without a global reveal or page-local media-query exception. `PlatformTableShell` marks every @@ -3811,7 +3818,12 @@ default` instead of fusing provider and badge text such as table's default order remains its page-model status-first compare until the user selects a column, and grouped modes (for example Docker's grouped-by-host containers view) sort within groups while grouping itself - stays orthogonal to sort state. + stays orthogonal to sort state. A saved sort applies only while its + column's header shows: where a layout hides that column (Docker's Host + and Engine while grouped, State under a phone row's name, Uptime on a + phone), the rows keep the default order instead of reordering with no + visible indicator, and the preference stays saved for the layouts that + show it. 18. Keep estate orientation inside the canonical controls operators already scan. Large-estate workload totals must flow from the unfiltered shared diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 3279b34d43..cf789f9410 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -7035,7 +7035,11 @@ health check, Exited (139) for a crash, Restarting, and muted Exited or Paused for a container stopped on purpose, with the full sentence on hover. A health status only counts for a running container, because Docker keeps the last one on a stopped container (#1724). The State column appears when any row in view -has one of those states, including an unhealthy running container. From the +has one of those states, including an unhealthy running container. A narrow +table (under 360 pixels, most phones) has no State column: beside the shared +40 percent name it cut "Unhealthy" to "Unhe…", so the same words read under +the container name on every row, and a saved State sort waits for a layout +that shows its header. From the compact layout up an Uptime column shows the current run (none for a stopped container, whose leftover uptime is not time spent running), which marks a container that just restarted; narrower rows keep it in the drawer. The Ports diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.cjs b/frontend-modern/browser-tests/proxmox-docker-rows.cjs index b4a23f462e..99250188ad 100644 --- a/frontend-modern/browser-tests/proxmox-docker-rows.cjs +++ b/frontend-modern/browser-tests/proxmox-docker-rows.cjs @@ -372,7 +372,19 @@ const observations = (mode) => [ const headers = await table.locator('th:visible').allTextContents(); if (width === 390) { assert.ok(!headers.some((h) => h.includes('Restarts'))); - assert.ok(headers.some((h) => h.includes('State'))); + // A phone row reads the state words under the name, whole: as a + // column beside the name they were cut to "Unhe…". + assert.ok(!headers.some((h) => h.includes('State'))); + const words = table + .locator('[data-docker-container-row="edge-web"]') + .locator('[data-docker-container-state]'); + assert.equal(await words.innerText(), 'Unhealthy'); + const fit = await words.evaluate((e) => ({ + scroll: e.scrollWidth, + client: e.clientWidth, + name: e.closest('td') === e.closest('tr').querySelector('td'), + })); + assert.ok(fit.name && fit.scroll <= fit.client, JSON.stringify(fit)); } }, ); diff --git a/frontend-modern/browser-verification/64bc0d9ae9b93e92.json b/frontend-modern/browser-verification/64bc0d9ae9b93e92.json new file mode 100644 index 0000000000..4eaf978b7f --- /dev/null +++ b/frontend-modern/browser-verification/64bc0d9ae9b93e92.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "base_sha": "db80bddd77aafb3b5a54cd06259cab3035f6cf21", + "verified_at": "2026-10-14T15:02:51Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/docker/DockerContainersTable.tsx", + "frontend-modern/src/features/docker/DockerNativeTableShared.tsx", + "frontend-modern/src/features/docker/dockerContainerTableModel.ts" + ], + "content_sha256": { + "frontend-modern/src/features/docker/DockerContainersTable.tsx": "d47e314de5289bf9c8442f1e27eef27de5af973b4cbed20739f0eeb2f2319ead", + "frontend-modern/src/features/docker/DockerNativeTableShared.tsx": "1de22fb2d6b0d07b8d6f03fd3b8b87e94a6e40728ddd382feae581bffea3f4c0", + "frontend-modern/src/features/docker/dockerContainerTableModel.ts": "ca72cad76c73637c39e084276f75c3ece87eb0fec859819fbc716148a4725f6d" + }, + "routes": [ + "browser-tests/proxmox-docker-rows.html (production DockerContainersTable and ResourceDetailDrawer, Chromium and WebKit via Playwright 1.56.1 on pulse-dev)" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 430, + "height": 844 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 360, + "height": 844 + }, + { + "width": 320, + "height": 844 + } + ], + "states": [ + "narrow phone table (320/360/390): no State column; Exited (139), Unhealthy and 999 restarts read whole under the container name, nothing clipped (old: Unhe..., Exite..., 999 r...)", + "narrow phone table: 100% CPU and memory pills and the Update control whole at 320 (old: 10..., Upda te)", + "phone layout (430): State column unchanged and whole", + "dark-mode WebKit 390 proof: state line tone colours readable", + "desktop 1440: State column unchanged", + "reviewed by Sol from before/after screenshots at 320/390/430; its shared-name-cell wrapper and hidden saved-sort points are fixed here" + ], + "interactions": [ + "row expand by pointer (desktop) and tap (phone)", + "Containers fixture view selection at each width" + ] +} diff --git a/frontend-modern/src/features/docker/DockerContainersTable.tsx b/frontend-modern/src/features/docker/DockerContainersTable.tsx index 0e8ac69090..0b9effbfcd 100644 --- a/frontend-modern/src/features/docker/DockerContainersTable.tsx +++ b/frontend-modern/src/features/docker/DockerContainersTable.tsx @@ -84,6 +84,7 @@ import { getDockerContainerTableMinWidthClass, getDockerContainerVisibleColumnsForLayout, isDockerContainerColumnInLayout, + isDockerContainerStateUnderName, type DockerContainerSortKey, type DockerContainerTableColumn, } from './dockerContainerTableModel'; @@ -378,6 +379,7 @@ export const DockerContainersTable: Component = (pro const flagRestartsInState = createMemo( () => !isDockerContainerColumnInLayout('restarts', layoutMode()), ); + const stateUnderName = createMemo(() => isDockerContainerStateUnderName(layoutMode())); const stateFor = (resource: Resource) => getDockerContainerStatePresentation(resource, { flagRestarts: flagRestartsInState() }); // Sorting reorders rows only; grouped mode re-buckets the sorted rows, so @@ -407,49 +409,6 @@ export const DockerContainersTable: Component = (pro const groupingMode = createMemo(() => isGroupable() ? groupingModePreference() : 'flat', ); - const hostResourceByName = createMemo(() => { - const map = new Map(); - for (const hostResource of props.hosts ?? []) { - const keys = [ - dockerHostName(hostResource), - asTrimmedString(hostResource.name) ?? '', - asTrimmedString(hostResource.displayName) ?? '', - ]; - for (const key of keys) { - if (key && key !== '—' && !map.has(key)) map.set(key, hostResource); - } - } - return map; - }); - const groupedRows = createMemo(() => { - const groups = new Map(); - // Host and engine columns are hidden while grouped, so a sort saved on - // either would reorder rows with no visible indicator; grouped mode falls - // back to the attention-first order instead and flat mode keeps the sort. - const hiddenSortKey = sort.sortKey() === 'host' || sort.sortKey() === 'runtime'; - const sourceRows = hiddenSortKey - ? [...scopedRows()].sort(compareDockerContainers) - : sortedRows(); - for (const resource of sourceRows) { - const host = dockerHostName(resource); - const rows = groups.get(host); - if (rows) rows.push(resource); - else groups.set(host, [resource]); - } - return [...groups.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([host, rows]) => ({ host, hostResource: hostResourceByName().get(host), rows })); - }); - const groupedRenderItems = createMemo(() => { - const items: DockerContainerRenderItem[] = []; - for (const group of groupedRows()) { - items.push({ kind: 'group', key: `group:${group.host}`, group }); - for (const resource of group.rows) { - items.push({ kind: 'resource', key: `resource:${resource.id}`, resource }); - } - } - return items; - }); // Runtime is host-level data repeated on every container row; it only // informs when the fleet actually mixes runtimes (docker vs podman). const showRuntimeColumn = createMemo(() => { @@ -488,6 +447,53 @@ export const DockerContainersTable: Component = (pro ), ); const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id)); + // A sort applies only while its column shows. A saved sort on a column this + // layout hides (Host or Engine while grouped, State under a phone row's + // name, Uptime on a phone) would reorder rows with no visible indicator, so + // those rows fall back to the attention-first order; the preference stays + // saved for the layouts that show its column. + const displayRows = createMemo(() => { + const sortKey = sort.sortKey(); + return sortKey && !visibleColumnIds().includes(sortKey) + ? [...scopedRows()].sort(compareDockerContainers) + : sortedRows(); + }); + const hostResourceByName = createMemo(() => { + const map = new Map(); + for (const hostResource of props.hosts ?? []) { + const keys = [ + dockerHostName(hostResource), + asTrimmedString(hostResource.name) ?? '', + asTrimmedString(hostResource.displayName) ?? '', + ]; + for (const key of keys) { + if (key && key !== '—' && !map.has(key)) map.set(key, hostResource); + } + } + return map; + }); + const groupedRows = createMemo(() => { + const groups = new Map(); + for (const resource of displayRows()) { + const host = dockerHostName(resource); + const rows = groups.get(host); + if (rows) rows.push(resource); + else groups.set(host, [resource]); + } + return [...groups.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([host, rows]) => ({ host, hostResource: hostResourceByName().get(host), rows })); + }); + const groupedRenderItems = createMemo(() => { + const items: DockerContainerRenderItem[] = []; + for (const group of groupedRows()) { + items.push({ kind: 'group', key: `group:${group.host}`, group }); + for (const resource of group.rows) { + items.push({ kind: 'resource', key: `resource:${resource.id}`, resource }); + } + } + return items; + }); const drawerColSpan = createMemo(() => visibleColumns().length); const drawer = createPlatformResourceDetailState({ idPrefix: 'docker-container-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); @@ -496,6 +502,15 @@ export const DockerContainersTable: Component = (pro const indicator = mapDockerContainerStatus(resource); const image = () => dockerTextValue(resource.docker?.image); const state = () => stateFor(resource); + const stateWords = () => ( + + {state().label} + + ); const runtime = () => runtimeSummary(resource); const host = () => dockerHostName(resource); const running = () => isContainerRunning(resource); @@ -535,6 +550,7 @@ export const DockerContainersTable: Component = (pro = (pro case 'state': return ( - - {state().label} - + {stateWords()} ); case 'cpu': @@ -905,7 +915,7 @@ export const DockerContainersTable: Component = (pro + {(resource) => renderContainerRow(resource)} } diff --git a/frontend-modern/src/features/docker/DockerNativeTableShared.tsx b/frontend-modern/src/features/docker/DockerNativeTableShared.tsx index d1e54d304b..8ff38b23e7 100644 --- a/frontend-modern/src/features/docker/DockerNativeTableShared.tsx +++ b/frontend-modern/src/features/docker/DockerNativeTableShared.tsx @@ -1,4 +1,4 @@ -import { For, type Component, type JSX } from 'solid-js'; +import { For, Show, type Component, type JSX } from 'solid-js'; import { StatusDot } from '@/components/shared/StatusDot'; import { TableCell } from '@/components/shared/Table'; import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink'; @@ -73,10 +73,22 @@ export const DockerResourceNameCell: Component<{ // Image rows name the resource by its reference, so a narrow cell keeps the // repository and tag visible instead of the shared registry prefix. imageReference?: boolean; + // A second line under the name, for a field the layout has no column for. + detail?: JSX.Element; }> = (props) => { const resolvedIndicator = (): StatusIndicator => props.indicator ?? getSimpleStatusIndicator(props.resource.status); const name = () => dockerResourceName(props.resource); + const nameLink = () => ( + + {props.imageReference ? : undefined} + + ); return ( @@ -88,14 +100,14 @@ export const DockerResourceNameCell: Component<{ title={resolvedIndicator().label} ariaHidden /> - - {props.imageReference ? : undefined} - + + {(detail) => ( +
+ {nameLink()} + {detail()} +
+ )} +
); diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index 65feaf6f9e..78c8fc14ec 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -930,6 +930,94 @@ describe('Docker native tables', () => { expect(screen.getByText('Healthy')).toHaveAttribute('data-docker-container-state', 'ok'); }); + it('puts the state words under the name where a phone row has no State column', () => { + // A narrow table cannot give "Unhealthy" its own column beside the 40% + // name, both metric pills and the Update control without cutting it. + setViewportWidth(320); + + renderInRouter(() => ( + } + emptyTitle="No containers" + emptyDescription="No containers" + showToolbar={false} + /> + )); + + expect(screen.queryByRole('columnheader', { name: /State/ })).not.toBeInTheDocument(); + const unhealthy = screen.getByText('Unhealthy'); + expect(unhealthy).toHaveAttribute('data-docker-container-state', 'danger'); + expect(unhealthy.closest('td')).toBe(screen.getByText('edge-api').closest('td')); + // Every row carries the line, so the rows keep one height. + expect(screen.getByText('Running').closest('td')).toBe( + screen.getByText('edge-web').closest('td'), + ); + }); + + it('keeps a saved State sort only where the State column shows', () => { + window.localStorage.setItem('dockerContainersSortKey', 'state'); + window.localStorage.setItem('dockerContainersSortDirection', 'asc'); + const resources = [ + makeResource({ + id: 'container-1', + type: 'app-container', + name: 'edge-web', + status: 'running', + docker: { hostname: 'edge-01', containerState: 'running' }, + }), + makeResource({ + id: 'container-2', + type: 'app-container', + name: 'edge-api', + status: 'running', + docker: { hostname: 'edge-01', containerState: 'running', health: 'unhealthy' }, + }), + ]; + const rowOrder = () => + [...document.querySelectorAll('[data-docker-container-row]')].map((row) => + row.getAttribute('data-docker-container-row'), + ); + const renderTable = () => + renderInRouter(() => ( + } + emptyTitle="No containers" + emptyDescription="No containers" + showToolbar={false} + /> + )); + + setViewportWidth(WORKLOAD_TABLE_WIDE_LAYOUT_WIDTH); + renderTable(); + // "Running" sorts before "Unhealthy" while the header shows the sort. + expect(rowOrder()).toEqual(['container-1', 'container-2']); + cleanup(); + + // A phone row has no State header to show the sort, so the rows keep the + // attention-first order and the failing container leads. + setViewportWidth(320); + renderTable(); + expect(rowOrder()).toEqual(['container-2', 'container-1']); + expect(window.localStorage.getItem('dockerContainersSortKey')).toBe('state'); + }); + it("shows each running container's uptime and none for a stopped one", () => { setViewportWidth(WORKLOAD_TABLE_WIDE_LAYOUT_WIDTH); diff --git a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts index bd0b3c44da..6f9dcb5016 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts @@ -5,6 +5,7 @@ import { getDockerContainerColumnWidthStyle, getDockerContainerTableMinWidthClass, getDockerContainerVisibleColumnsForLayout, + isDockerContainerStateUnderName, } from '../dockerContainerTableModel'; describe('dockerContainerTableModel', () => { @@ -26,19 +27,29 @@ describe('dockerContainerTableModel', () => { const columns = getDockerContainerVisibleColumnsForLayout('narrow', true, true, true); const ids = columns.map((column) => column.id); - expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']); + // State reads under the name there: beside the 40% name it could not + // hold "Unhealthy" without cutting the metric pills or the Update control. + expect(ids).toEqual(['container', 'cpu', 'memory', 'updates']); + expect(isDockerContainerStateUnderName('narrow')).toBe(true); + expect(isDockerContainerStateUnderName('phone')).toBe(false); expect(getDockerContainerColumnWidthStyle('container', 'narrow', ids)).toEqual({ width: '40%', }); - expect(getDockerContainerColumnWidthStyle('cpu', 'narrow', ids)).toEqual({ width: '15%' }); + // At the 286px table of a 320px phone that is 57px a column: a "100%" pill + // needs about 53px and the Update control about 46px. + for (const columnId of ['cpu', 'memory', 'updates'] as const) { + expect(getDockerContainerColumnWidthStyle(columnId, 'narrow', ids)).toEqual({ + width: '20%', + }); + } }); - it('keeps the five-field narrow projection stable when every container is running', () => { + it('keeps the narrow projection stable when every container is running', () => { const ids = getDockerContainerVisibleColumnsForLayout('narrow', true, false, false).map( (column) => column.id, ); - expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']); + expect(ids).toEqual(['container', 'cpu', 'memory', 'updates']); }); it('keeps phone rows focused on container identity and live health signals', () => { diff --git a/frontend-modern/src/features/docker/dockerContainerTableModel.ts b/frontend-modern/src/features/docker/dockerContainerTableModel.ts index e47de843e7..be10cd8bf4 100644 --- a/frontend-modern/src/features/docker/dockerContainerTableModel.ts +++ b/frontend-modern/src/features/docker/dockerContainerTableModel.ts @@ -69,7 +69,11 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record< WorkloadTableLayoutMode > = { container: 'narrow', - state: 'narrow', + // A narrow table (most phones) cannot give State's words their own column + // beside the 40% name, both metric pills and the Update control without + // cutting "Unhealthy" to "Unhe…"; the name cell shows them on a second line + // there instead (isDockerContainerStateUnderName). + state: 'phone', cpu: 'narrow', memory: 'narrow', // Below a 720px table the Restarts header cannot fit beside State, the @@ -142,12 +146,13 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record< Exclude, Partial> > = { + // With State under the name, the metric pills ("100%" is about 53px) and + // the Update control (about 46px) share what the 40% name leaves. narrow: { container: 40, - state: 15, - cpu: 15, - memory: 17, - updates: 13, + cpu: 20, + memory: 20, + updates: 20, }, phone: { container: 32, @@ -202,6 +207,11 @@ export const isDockerContainerColumnInLayout = ( DOCKER_CONTAINER_TABLE_LAYOUT_ORDER[DOCKER_CONTAINER_COLUMN_MIN_LAYOUT[columnId]] <= DOCKER_CONTAINER_TABLE_LAYOUT_ORDER[layoutMode]; +// Where the layout has no State column, the name cell carries the state words +// on its own second line, for every row, so the rows keep one height. +export const isDockerContainerStateUnderName = (layoutMode: WorkloadTableLayoutMode): boolean => + !isDockerContainerColumnInLayout('state', layoutMode); + export const getDockerContainerVisibleColumnsForLayout = ( layoutMode: WorkloadTableLayoutMode, includeRuntime: boolean, @@ -219,10 +229,9 @@ export const getDockerContainerVisibleColumnsForLayout = ( if (column.id === 'runtime' && !includeRuntime) return false; if (column.id === 'restarts' && !includeRestarts) return false; if (column.id === 'uptime' && options.includeUptime === false) return false; - // Ultra-narrow rows still need five stable scan fields. Keep the explicit - // state label there even when every current row is running; wider layouts - // may continue to remove the otherwise repetitive column. - if (column.id === 'state' && !includeState && layoutMode !== 'narrow') return false; + // Wider layouts remove the otherwise repetitive column while every + // current row is running. + if (column.id === 'state' && !includeState) return false; return ( DOCKER_CONTAINER_TABLE_LAYOUT_ORDER[DOCKER_CONTAINER_COLUMN_MIN_LAYOUT[column.id]] <= layoutRank