Read a container's state under its name on narrow phones (#2944)

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.
This commit is contained in:
rcourtman
2026-10-14 16:59:13 +01:00
committed by GitHub
9 changed files with 288 additions and 77 deletions
@@ -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
@@ -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
@@ -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));
}
},
);
@@ -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"
]
}
@@ -84,6 +84,7 @@ import {
getDockerContainerTableMinWidthClass,
getDockerContainerVisibleColumnsForLayout,
isDockerContainerColumnInLayout,
isDockerContainerStateUnderName,
type DockerContainerSortKey,
type DockerContainerTableColumn,
} from './dockerContainerTableModel';
@@ -378,6 +379,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (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<DockerContainersTableProps> = (pro
const groupingMode = createMemo<GroupedTableMode>(() =>
isGroupable() ? groupingModePreference() : 'flat',
);
const hostResourceByName = createMemo(() => {
const map = new Map<string, Resource>();
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<DockerContainerHostGroup[]>(() => {
const groups = new Map<string, Resource[]>();
// 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<DockerContainerRenderItem[]>(() => {
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<DockerContainersTableProps> = (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<string, Resource>();
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<DockerContainerHostGroup[]>(() => {
const groups = new Map<string, Resource[]>();
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<DockerContainerRenderItem[]>(() => {
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<DockerContainersTableProps> = (pro
const indicator = mapDockerContainerStatus(resource);
const image = () => dockerTextValue(resource.docker?.image);
const state = () => stateFor(resource);
const stateWords = () => (
<span
class={`block max-w-full truncate ${DOCKER_CONTAINER_STATE_TONE_CLASS[state().tone ?? 'none']}`}
title={state().title}
data-docker-container-state={state().tone ?? 'ok'}
>
{state().label}
</span>
);
const runtime = () => runtimeSummary(resource);
const host = () => dockerHostName(resource);
const running = () => isContainerRunning(resource);
@@ -535,6 +550,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
<DockerResourceNameCell
resource={resource}
indicator={indicator}
detail={stateUnderName() ? stateWords() : undefined}
detailToggle={
<PlatformResourceDetailToggleButton
expanded={isExpanded()}
@@ -570,13 +586,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
case 'state':
return (
<TableCell class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}>
<span
class={`block max-w-full truncate ${DOCKER_CONTAINER_STATE_TONE_CLASS[state().tone ?? 'none']}`}
title={state().title}
data-docker-container-state={state().tone ?? 'ok'}
>
{state().label}
</span>
{stateWords()}
</TableCell>
);
case 'cpu':
@@ -905,7 +915,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
<Show
when={groupingMode() === 'grouped'}
fallback={
<PlatformWindowedRows items={sortedRows} estimatedRowHeight={32}>
<PlatformWindowedRows items={displayRows} estimatedRowHeight={32}>
{(resource) => renderContainerRow(resource)}
</PlatformWindowedRows>
}
@@ -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 = () => (
<ResourceNameWithWebInterfaceLink
name={name()}
url={props.resource.customUrl}
class="min-w-0 max-[359px]:[&>a]:hidden"
nameClass={`${props.imageReference ? 'flex min-w-0' : 'truncate'} font-semibold text-base-content`}
>
{props.imageReference ? <DockerImageReferenceText reference={name()} /> : undefined}
</ResourceNameWithWebInterfaceLink>
);
return (
<TableCell class={getPlatformTableCellClassForKind('name')}>
@@ -88,14 +100,14 @@ export const DockerResourceNameCell: Component<{
title={resolvedIndicator().label}
ariaHidden
/>
<ResourceNameWithWebInterfaceLink
name={name()}
url={props.resource.customUrl}
class="min-w-0 max-[359px]:[&>a]:hidden"
nameClass={`${props.imageReference ? 'flex min-w-0' : 'truncate'} font-semibold text-base-content`}
>
{props.imageReference ? <DockerImageReferenceText reference={name()} /> : undefined}
</ResourceNameWithWebInterfaceLink>
<Show when={props.detail} fallback={nameLink()}>
{(detail) => (
<div class="flex min-w-0 flex-col">
{nameLink()}
{detail()}
</div>
)}
</Show>
</div>
</TableCell>
);
@@ -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(() => (
<DockerContainersTable
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' },
}),
]}
emptyIcon={<span />}
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(() => (
<DockerContainersTable
resources={resources}
emptyIcon={<span />}
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);
@@ -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', () => {
@@ -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<WorkloadTableLayoutMode, 'wide'>,
Partial<Record<DockerContainerTableColumnId, number>>
> = {
// 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