Stop the Restarts and Lifecycle headers clipping at tablet widths

Docker containers clipped its Restarts header to "RES..." between 600
and 800px, and widening the column clipped Memory instead. Below a 720px
table the count now waits in the row drawer, as it already did on
phones, and a running container that restarted more than five times
says so in its State cell there ("7 restarts"), so the crash-loop
warning stays visible on narrow screens; State sorts by that same text.

Kubernetes Configuration clipped "Lifecycle / trust" to "LIFECYCLE /
..." through the tablet band. The shared responsive header label gains
compactInBand, which keeps the compact label it already shows on phones
("Status") through that band; other headers are unchanged.
This commit is contained in:
rcourtman
2026-10-05 22:28:43 +01:00
parent ce3262f423
commit 5b335fdfed
15 changed files with 329 additions and 21 deletions
@@ -2331,6 +2331,10 @@ not a replacement status card, CTA band, or page-local nested card.
`platformTableBandWidthStyle` or the sortable header's `bandWidth`, which
sets `--platform-table-band-width`; the shell applies it only inside the band,
after the name anchor, so md widths and phone widths are unchanged.
A header whose full label outgrows its band column (Kubernetes
Configuration's `Lifecycle / trust`) passes `compactInBand` to
`PlatformResponsiveTableLabel`, which keeps the compact label it already
shows on phones through the band; other headers switch at 34rem as before.
A column whose content must read in full (a status badge) is sized for its
widest label at the narrowest table in each range: a 34rem container for the
band, and the table a 768px viewport leaves (about 695px) for md widths. The
@@ -6034,6 +6034,10 @@ than the unified online/offline status, a running container's health check
gets its own row (Failing, Starting, Passing), and Uptime is the current run,
so a stopped container shows neither a stale health check nor leftover
uptime. Both surfaces read `dockerContainerStatePresentation.ts`.
Below a 720px table the containers table has no Restarts column (its header
cannot fit beside State, the metric bars and the Update control), so a running
container that restarted more than five times says `N restarts` in its State
cell there instead; wider tables keep the count in its own column.
ResourceDetailDrawer headings wrap long display names rather than ellipsising
them. Overview rows may still truncate identity; keyboard expansion exposes the
@@ -0,0 +1,87 @@
{
"version": 1,
"base_sha": "ce3262f4230aedc10acbd38dfb0482e6f0bbb298",
"verified_at": "2026-10-06T08:14:26Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/dockerContainerStatePresentation.ts",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/index.css"
],
"content_sha256": {
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "575efffccfc2285e11d497eaae3de3a7dd0dbbb1ba4c02a6d4adbc296b02ab2e",
"frontend-modern/src/features/docker/dockerContainerStatePresentation.ts": "7316bb3852c75bb135025ba245e7a55d3014b9b5581d7fe0410b4fb9be6cdea7",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts": "0fac403071fbf75093e0d32425863c599471c7424ecb89f6d7ca91f3ea8930ae",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx": "6f59b8f00077029fd2a19298bb72ec7654c06b0600533ed91d84592d63278b18",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "717decd0b40532f47653d17cd13fe119acf4559aeb831a0b7515773259829807",
"frontend-modern/src/index.css": "4884f5bed3239c2e06ddd2415a0b0c63eedd1ada8bee84d9e1d3ff534c65fe74"
},
"routes": [
"/docker/containers",
"/kubernetes/configuration"
],
"viewports": [
{
"width": 390,
"height": 1000
},
{
"width": 430,
"height": 1000
},
{
"width": 600,
"height": 1000
},
{
"width": 650,
"height": 1000
},
{
"width": 700,
"height": 1000
},
{
"width": 767,
"height": 1000
},
{
"width": 800,
"height": 1000
},
{
"width": 1000,
"height": 1000
},
{
"width": 1280,
"height": 1000
},
{
"width": 1440,
"height": 1000
},
{
"width": 1600,
"height": 1000
},
{
"width": 1920,
"height": 1000
}
],
"states": [
"Docker containers header sweep 390-1920 against main: main clipped the Restarts header at 600 (47px, RES...) and 800 (70px); the change has no clipped header at any width and no column's cell clips grew",
"Below a 720px table Restarts moves to the row drawer as it already did on phones; at 800 it returns with 78px and its header whole",
"A running, healthy container with 7 restarts (API response edited): at 600 its State cell reads 7 restarts in amber (95px, not clipped); at 1440 State reads Healthy and the Restarts column shows the count",
"Kubernetes Configuration at 600/650/700/767: main clipped the Lifecycle / trust header to LIFECYCLE / ...; the change shows its compact Status label through the tablet band, with no header clip, and the full label from md widths up",
"Re-checked after rebasing onto main with the Docker drawer health change (#2534): no clipped headers at 600/800/1440, the 7 restarts State warning at 600, the Kubernetes Lifecycle header at 700, and the drawer Running / Health check Failing rows unchanged"
],
"interactions": [
"Measured header and cell clips on main (reverse-applied patch) and on the change",
"Served an edited /api/resources response with the websocket held"
]
}
@@ -302,6 +302,18 @@ describe('App architecture', () => {
expect(band).toMatch(
/width: 30%;\s*\}[\s\S]*?th\[style\*='--platform-table-band-width'\] \{\s*width: var\(--platform-table-band-width\);/,
);
// A header whose full label outgrows its band column keeps its compact
// label there; these rules sit after the 34rem label switch so they win.
expect(band).toMatch(
/--platform-table-band-width\);\s*\}[\s\S]*?\.platform-table-label-compact\.platform-table-label-band-compact \{\s*display: inline;\s*\}\s*\.platform-table-label-full\.platform-table-label-band-compact \{\s*display: none;/,
);
const labelSwitch = appStylesSource.indexOf(
'@container (min-width: 34rem) {\n .platform-table-label-compact',
);
expect(labelSwitch).toBeGreaterThan(0);
expect(appStylesSource.indexOf('.platform-table-label-band-compact')).toBeGreaterThan(
labelSwitch,
);
});
it('draws the shared select arrow that appearance-none removes', () => {
@@ -82,6 +82,7 @@ import {
getDockerContainerSortKey,
getDockerContainerTableMinWidthClass,
getDockerContainerVisibleColumnsForLayout,
isDockerContainerColumnInLayout,
type DockerContainerSortKey,
type DockerContainerTableColumn,
} from './dockerContainerTableModel';
@@ -91,6 +92,7 @@ import { DockerContainerLifecycleControls } from './DockerContainerLifecycleCont
import { CONTAINER_CPU_CAPACITY_DESCRIPTION } from './dockerCpuPresentation';
import { DockerImageReferenceText } from './DockerImageReferenceText';
import {
DOCKER_RESTART_ATTENTION_THRESHOLD,
getDockerContainerStatePresentation,
getDockerContainerUptimeSeconds,
type DockerContainerStateTone,
@@ -188,10 +190,6 @@ const DOCKER_CONTAINER_STATE_TONE_CLASS: Record<DockerContainerStateTone | 'none
none: '',
};
// v5 flagged crash-loopers in the restarts column; a container that restarted
// more than this many times needs an operator's eye even while "running".
const RESTART_ATTENTION_THRESHOLD = 5;
const updateStatusLabel = (resource: Resource): string => {
const update = resource.docker?.updateStatus;
if (!update) return '—';
@@ -424,6 +422,13 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
// User-controlled sorting layered over the attention-first default: rows
// are pre-sorted by the status compare, so a user sort keeps that order for
// ties and the table falls straight back to it when the sort is cleared.
// Where the layout has no Restarts column, a crash-looping container says
// so in its State cell instead.
const flagRestartsInState = createMemo(
() => !isDockerContainerColumnInLayout('restarts', layoutMode()),
);
const stateFor = (resource: Resource) =>
getDockerContainerStatePresentation(resource, { flagRestarts: flagRestartsInState() });
// Sorting reorders rows only; grouped mode re-buckets the sorted rows, so
// the sort applies within each host group and stays orthogonal to grouping.
const sort = createPlatformTableSortState({
@@ -432,7 +437,10 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
descendingFirst: ['cpu', 'memory', 'restarts', 'uptime'],
});
const sortedRows = createMemo(() =>
sort.sortRows([...scopedRows()].sort(compareDockerContainers), getDockerContainerSortValue),
sort.sortRows([...scopedRows()].sort(compareDockerContainers), (resource, key) =>
// State sorts by the words the row shows, restart warning included.
key === 'state' ? stateFor(resource).label : getDockerContainerSortValue(resource, key),
),
);
// Grouped-by-host view, mirroring the workloads table: preference persists
// locally (not in the URL) and only applies once the fleet spans more than
@@ -510,7 +518,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
// A running container with a failing or starting health check needs the
// State column as much as a stopped one does.
const showStateColumn = createMemo(() =>
scopedRows().some((resource) => getDockerContainerStatePresentation(resource).tone !== null),
scopedRows().some((resource) => stateFor(resource).tone !== null),
);
const showUptimeColumn = createMemo(() =>
scopedRows().some((resource) => getDockerContainerUptimeSeconds(resource) !== undefined),
@@ -536,7 +544,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
const renderContainerRow = (resource: Resource): JSX.Element => {
const indicator = mapDockerContainerStatus(resource);
const image = () => dockerTextValue(resource.docker?.image);
const state = () => getDockerContainerStatePresentation(resource);
const state = () => stateFor(resource);
const runtime = () => runtimeSummary(resource);
const host = () => dockerHostName(resource);
const running = () => isContainerRunning(resource);
@@ -659,7 +667,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
>
<span
class={`tabular-nums ${
restartCount() > RESTART_ATTENTION_THRESHOLD
restartCount() > DOCKER_RESTART_ATTENTION_THRESHOLD
? 'font-medium text-red-600 dark:text-red-400'
: ''
}`}
@@ -965,6 +965,72 @@ describe('Docker native tables', () => {
expect(screen.getByText('Exited (1)')).toHaveAttribute('data-docker-container-state', 'danger');
});
it('flags a crash-looping container in State where the Restarts column is hidden', () => {
const rows = [
makeResource({
id: 'container-1',
type: 'app-container',
name: 'edge-web',
status: 'running',
docker: { hostname: 'edge-01', containerState: 'running', restartCount: 7 },
}),
makeResource({
id: 'container-2',
type: 'app-container',
name: 'edge-api',
status: 'running',
docker: { hostname: 'edge-01', containerState: 'running', restartCount: 1 },
}),
];
// 600px is the mobile layout, which has no Restarts column.
setViewportWidth(600);
const { unmount } = renderInRouter(() => (
<DockerContainersTable
resources={rows}
emptyIcon={<span />}
emptyTitle="No containers"
emptyDescription="No containers"
showToolbar={false}
/>
));
expect(screen.queryByText('Restarts')).not.toBeInTheDocument();
expect(screen.getByText('7 restarts')).toHaveAttribute(
'data-docker-container-state',
'warning',
);
// State sorts by the words the row shows: "7 restarts" ranks apart from
// "Running" both ways (with equal labels both orders would be the same).
const rowOrder = () =>
[...document.querySelectorAll('[data-docker-container-row]')].map((row) =>
row.getAttribute('data-docker-container-row'),
);
const clickState = () => {
const header = screen
.getAllByRole('columnheader')
.find((th) => th.textContent?.trim().startsWith('State'));
fireEvent.click(header!.querySelector('button') ?? header!);
};
clickState();
expect(rowOrder()).toEqual(['container-1', 'container-2']);
clickState();
expect(rowOrder()).toEqual(['container-2', 'container-1']);
unmount();
// A wide table shows the count in its own column instead.
setViewportWidth(WORKLOAD_TABLE_WIDE_LAYOUT_WIDTH);
renderInRouter(() => (
<DockerContainersTable
resources={rows}
emptyIcon={<span />}
emptyTitle="No containers"
emptyDescription="No containers"
showToolbar={false}
/>
));
expect(screen.getByText('Restarts')).toBeInTheDocument();
expect(screen.queryByText('7 restarts')).not.toBeInTheDocument();
});
it('leaves out the Uptime column when no container reports one', () => {
setViewportWidth(WORKLOAD_TABLE_WIDE_LAYOUT_WIDTH);
@@ -158,3 +158,30 @@ describe('drawer runtime rows', () => {
expect(getDockerContainerHealthCheckPresentation(crashed)).toBeUndefined();
});
});
describe('restart warning where the Restarts column is hidden', () => {
it('names a crash-looping running container in State only when asked', () => {
const looping = container({ containerState: 'running', health: 'healthy', restartCount: 7 });
expect(getDockerContainerStatePresentation(looping, { flagRestarts: true })).toEqual({
label: '7 restarts',
title: 'Running, but Docker has restarted it 7 times',
tone: 'warning',
});
expect(getDockerContainerStatePresentation(looping)).toMatchObject({ label: 'Healthy' });
expect(
getDockerContainerStatePresentation(
container({ containerState: 'running', restartCount: 5 }),
{ flagRestarts: true },
),
).toMatchObject({ label: 'Running', tone: null });
});
it('keeps a worse state ahead of the restart count', () => {
expect(
getDockerContainerStatePresentation(
container({ containerState: 'running', health: 'unhealthy', restartCount: 9 }),
{ flagRestarts: true },
),
).toMatchObject({ label: 'Unhealthy', tone: 'danger' });
});
});
@@ -57,7 +57,8 @@ describe('dockerContainerTableModel', () => {
const columns = getDockerContainerVisibleColumnsForLayout('mobile', true, true, true);
const ids = columns.map((column) => column.id);
expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'restarts', 'updates', 'actions']);
// Restarts waits for the tablet layout (its header clips below 720px).
expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates', 'actions']);
expect(getDockerContainerTableMinWidthClass()).toBe('min-w-full');
expect(getDockerContainerColumnWidthStyle('container', 'mobile', ids)).toEqual({
width: '30%',
@@ -65,10 +66,10 @@ describe('dockerContainerTableModel', () => {
// State spells out a problem ("Exited (139)", about 77px with padding), so
// it takes the room the memory bar did not need.
expect(getDockerContainerColumnWidthStyle('state', 'mobile', ids)).toEqual({
width: '14.5%',
width: '16.371%',
});
expect(getDockerContainerColumnWidthStyle('memory', 'mobile', ids)).toEqual({
width: '13.5%',
width: '15.2419%',
});
});
@@ -120,6 +121,23 @@ describe('dockerContainerTableModel', () => {
}
});
it('gives the Restarts header room on tablets when one host or groups hide Host', () => {
// "Restarts" is about 61px at 11px uppercase with 6px of padding a side.
for (const hostOption of [{ groupedByHost: true }, { singleHost: true }]) {
const ids = getDockerContainerVisibleColumnsForLayout(
'tablet',
false,
true,
true,
hostOption,
).map((column) => column.id);
const width = Number.parseFloat(
String(getDockerContainerColumnWidthStyle('restarts', 'tablet', ids).width),
);
expect((width / 100) * 720).toBeGreaterThanOrEqual(73);
}
});
it('drops uptime when no container in view reports one', () => {
const ids = getDockerContainerVisibleColumnsForLayout('compact', true, true, true, {
includeUptime: false,
@@ -48,11 +48,31 @@ const runStateTitle = (resource: Resource): string | undefined => {
return undefined;
};
// v5 flagged crash-loopers in the restarts column; a container that restarted
// more than this many times needs an operator's eye even while "running".
export const DOCKER_RESTART_ATTENTION_THRESHOLD = 5;
// flagRestarts: the row shows no Restarts column (narrow layouts), so a
// running container past the restart threshold says so in its State cell.
export const getDockerContainerStatePresentation = (
resource: Resource,
options: { flagRestarts?: boolean } = {},
): DockerContainerStatePresentation => {
const status = mapDockerContainerStatus(resource);
const tone = presentationTone(status.variant);
const restarts = resource.docker?.restartCount;
if (
options.flagRestarts &&
tone === null &&
typeof restarts === 'number' &&
restarts > DOCKER_RESTART_ATTENTION_THRESHOLD
) {
return {
label: `${restarts} restarts`,
title: `Running, but Docker has restarted it ${restarts} times`,
tone: 'warning',
};
}
return { label: status.label, title: containerStateTitle(resource, status.label), tone };
};
@@ -72,9 +72,10 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record<
state: 'narrow',
cpu: 'narrow',
memory: 'narrow',
// A phone row cannot fit the Restarts header beside the Update control;
// the count stays in the row expansion there and returns at mobile width.
restarts: 'mobile',
// Below a 720px table the Restarts header cannot fit beside State, the
// metric bars and the Update control without clipping one of them; the
// count stays in the row expansion there and returns at tablet width.
restarts: 'tablet',
// Uptime is the current run, so a short one shows a container that just
// restarted. Its header needs about 57px, which a tablet row cannot spare
// beside State, Restarts and the Update control; the drawer keeps it there.
@@ -160,7 +161,6 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
state: 14.5,
cpu: 10,
memory: 13.5,
restarts: 8,
updates: 16.25,
actions: 7.75,
},
@@ -169,8 +169,8 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
host: 15,
state: 14,
cpu: 9.5,
memory: 13,
restarts: 9,
memory: 12,
restarts: 10,
updates: 14.5,
actions: 6,
},
@@ -193,6 +193,15 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
},
};
// Whether a layout has room for a column at all (before row-set gates such
// as "only when some container restarted").
export const isDockerContainerColumnInLayout = (
columnId: DockerContainerTableColumnId,
layoutMode: WorkloadTableLayoutMode,
): boolean =>
DOCKER_CONTAINER_TABLE_LAYOUT_ORDER[DOCKER_CONTAINER_COLUMN_MIN_LAYOUT[columnId]] <=
DOCKER_CONTAINER_TABLE_LAYOUT_ORDER[layoutMode];
export const getDockerContainerVisibleColumnsForLayout = (
layoutMode: WorkloadTableLayoutMode,
includeRuntime: boolean,
@@ -297,7 +297,11 @@ export const KubernetesConfigTable: Component<{
class="platform-table-phone-hidden md:w-[17%]"
bandWidth={16}
>
<PlatformResponsiveTableLabel compact="Status" full="Lifecycle / trust" />
<PlatformResponsiveTableLabel
compact="Status"
full="Lifecycle / trust"
compactInBand
/>
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
@@ -21,6 +21,7 @@ import kubernetesAlertsTableSource from '@/features/kubernetes/KubernetesAlertsT
import kubernetesAutoscalingTableSource from '@/features/kubernetes/KubernetesAutoscalingTable.tsx?raw';
import kubernetesClustersTableSource from '@/features/kubernetes/KubernetesClustersTable.tsx?raw';
import kubernetesConfigTableSource from '@/features/kubernetes/KubernetesConfigTable.tsx?raw';
import dockerContainerTableModelSource from '@/features/docker/dockerContainerTableModel.ts?raw';
import kubernetesControllersTableSource from '@/features/kubernetes/KubernetesControllersTable.tsx?raw';
import kubernetesDeploymentsTableSource from '@/features/kubernetes/KubernetesDeploymentsTable.tsx?raw';
import kubernetesEventsTableSource from '@/features/kubernetes/KubernetesEventsTable.tsx?raw';
@@ -453,6 +454,17 @@ describe('platform overview layout guardrails', () => {
}
});
it('keeps long tablet headers from clipping', () => {
// "Lifecycle / trust" outgrows its tablet-band column, so it keeps the
// compact label it shows on phones through that band.
expect(kubernetesConfigTableSource).toMatch(
/compact="Status"\s+full="Lifecycle \/ trust"\s+compactInBand/,
);
// The Docker Restarts header cannot fit below a 720px table; the count
// waits in the drawer there and a crash-looper says so in State.
expect(dockerContainerTableModelSource).toMatch(/restarts: 'tablet',/);
});
it('keeps phone-priority visibility container-led and symmetric across table rows', () => {
expect(indexCssSource).toContain(':is(th, td).platform-table-phone-hidden');
expect(indexCssSource).toContain(':is(th, td).platform-table-phone-only');
@@ -38,6 +38,7 @@ import {
withPlatformStatusCounts,
type PlatformResourceStatusFilter,
platformTableBandWidthStyle,
PlatformResponsiveTableLabel,
} from '../sharedPlatformPage';
afterEach(() => {
@@ -924,4 +925,23 @@ describe('PlatformTableMetricFallback', () => {
expect(platformTableBandWidthStyle(9.5)).toEqual({ '--platform-table-band-width': '9.5%' });
expect(platformTableBandWidthStyle(undefined)).toBeUndefined();
});
it('lets a long header keep its compact label through the tablet band', () => {
render(() =>
createComponent(PlatformResponsiveTableLabel, {
compact: 'Status',
full: 'Lifecycle / trust',
compactInBand: true,
}),
);
expect(screen.getByText('Status').className).toBe(
'platform-table-label-compact platform-table-label-band-compact',
);
expect(screen.getByText('Lifecycle / trust').className).toBe(
'platform-table-label-full platform-table-label-band-compact',
);
cleanup();
render(() => createComponent(PlatformResponsiveTableLabel, { compact: 'Rdy', full: 'Ready' }));
expect(screen.getByText('Ready').className).toBe('platform-table-label-full');
});
});
@@ -462,11 +462,18 @@ export const getPlatformTableCellClassForKind = (kind: PlatformTableColumnKind):
// can expand without weakening the overview-table contract.
export const getPlatformTableRowClass = (): string => PLATFORM_TABLE_SUMMARY_ROW_CLASS;
export function PlatformResponsiveTableLabel(props: { compact: string; full: string }) {
// compactInBand keeps the compact label through the tablet band (a 34rem
// container below a 768px viewport) for a full label longer than its column.
export function PlatformResponsiveTableLabel(props: {
compact: string;
full: string;
compactInBand?: boolean;
}) {
const band = () => (props.compactInBand ? ' platform-table-label-band-compact' : '');
return (
<>
<span class="platform-table-label-compact">{props.compact}</span>
<span class="platform-table-label-full">{props.full}</span>
<span class={`platform-table-label-compact${band()}`}>{props.compact}</span>
<span class={`platform-table-label-full${band()}`}>{props.full}</span>
</>
);
}
+10
View File
@@ -1489,6 +1489,16 @@
.table-scroll-shell > .table-fixed.platform-table th[style*='--platform-table-band-width'] {
width: var(--platform-table-band-width);
}
/* A header whose full label is longer than its band column keeps the
compact label it already shows on phones (compactInBand). */
.platform-table-label-compact.platform-table-label-band-compact {
display: inline;
}
.platform-table-label-full.platform-table-label-band-compact {
display: none;
}
}
}