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