Truncate platform summary cells inside their own column (#2514)

Follow-up to #2509, which fixed the Kubernetes tables. The Docker,
Proxmox and vSphere tables still put summary values in a capped
`inline-block` span (`max-w-48 truncate`, `max-w-[18rem] truncate`, or
`truncate inline-block max-w-32`). These tables are `table-fixed` and
their `td` already truncates, so the cap ignored the column width:

- **Spill with no ellipsis.** Where the cap was wider than the cell, a
long value ran past the cell edge and was cut mid-glyph. At 700 wide: a
swarm service image by 225px, secret labels by 184px, image "Used by" by
168px, Mail Gateway domains by 71px. At 1440: volume mountpoints by
51px.
- **Off the row line.** An `inline-block` with hidden overflow aligns
its bottom edge to the baseline, so every one of these values sat
2–2.5px above the rest of the row.

A `block truncate` span fills the cell, ends in an ellipsis inside it,
and sits on the row line.

**Changed cells (19 spans in 11 files)**
- Docker: Configs (Created, Labels), Secrets (Created, Labels), Tasks
(Current), Volumes (Created, Mountpoint), Images (Used by), Networks
(Subnets), Services (Stack, Image, Update)
- Proxmox: backup coverage Node, restore-evidence Location and Details,
recoverable Location and Details, Mail Gateway drawer Top domains
- vSphere: Hosts vCenter

The Docker service **Update** cell still wraps on phones. From md it now
truncates inside its column instead of at a fixed 10rem cap.

The guardrail from #2509 (`keeps platform summary cells truncating
inside their own column`) now covers every table source that carried the
pattern. `ProxmoxCoverageTable.test.tsx` checks that a long node name
renders as the cell's only child, in a `block truncate` span, inside a
`table-fixed` table. Both tests fail against main's sources.

**Verification** (private mock stack, Playwright)
- **Live DOM, before switching anything.** At 700 and 1440, every one of
the 1,000 measured spans is the only child of its cell. Every table
computes `table-layout: fixed`, including the Mail Gateway drawer's Top
domains table.
- **After.** No span runs past its cell, all 134 truncated values end in
an ellipsis inside their own column, and row text sits on one line (0px
offset).
- **Clip sweep.** All eight affected routes (/docker/images,
/docker/networks, /docker/storage, /docker/swarm, /proxmox/backups/date,
/proxmox/backups/coverage, /proxmox/mail, /vmware/overview), 8 widths
from 390 to 1440. Clipped cells go from 931 to 928 out of 19,209
measured, and no column is worse at any width. The count barely moves
because a spilled value was already clipped by its cell. What changes is
a hard cut becoming an ellipsis.
- **Mock gap.** The mock Mail Gateway reports no domain stats, so the
drawer table was mounted by adding eight domains to the real
`/api/pmg/instances` response through a route intercept.
- **Tests.** Docker, Proxmox, vSphere, platformPage and app-level
suites: 79 files, 1,549 tests pass.
- **Receipt.** The browser receipt is included.
This commit is contained in:
rcourtman
2026-10-05 14:38:32 +01:00
committed by GitHub
17 changed files with 173 additions and 30 deletions
@@ -2340,6 +2340,13 @@ not a replacement status card, CTA band, or page-local nested card.
disclosure; a non-expandable identity cell must otherwise expose its
complete value without requiring hover. Default data rows stay single-line
so scan density and row rhythm are not traded for automatic text wrapping.
A summary value that must truncate sits in a `block truncate` span as the
cell's only child, so it fills the fixed-layout column and ends in an
ellipsis inside it on the row's text line. A capped `inline-block max-w-*
truncate` span ignores the column width: it spills past a narrower cell
with no ellipsis, wastes room in a wider one, and sits about 2 pixels above
the row baseline. `platformOverviewLayout.guardrails.test.ts` holds the
Docker, Kubernetes, Proxmox and vSphere table sources to that rule.
When a platform table row itself owns that keyboard- and touch-operable
disclosure, `SummaryRowActionButton` removes the redundant visible chevron
below the shared mobile breakpoint while preserving the button for screen
@@ -3094,6 +3094,17 @@ presentation and sort change only; it adds no backup, restore, or retention
authority. Pinned by `proxmoxBackupRecoveryModel.test.ts` and
`ProxmoxCoverageTable.test.tsx`.
### Backup table values truncate inside their own column
The coverage Node cell, the restore-evidence Location and Details cells, and
the recoverable table's Location and Details cells render their value in a
block span that fills the fixed-layout cell, so a long node name, datastore path
or detail ends in an ellipsis inside its column on the row's text line, with the
full value on the title. A capped inline-block span let a long detail run past
its cell with no ellipsis and sat above the row. This is presentation only; it
adds no backup, restore, or retention authority. Pinned by
`ProxmoxCoverageTable.test.tsx` and `platformOverviewLayout.guardrails.test.ts`.
### Retained Patrol objectives do not create recovery authority
The shared `internal/api` retained-objective endpoints may scope an outcome to
@@ -1189,7 +1189,12 @@ RBAC names first, then the namespace scope, then the short Lifecycle and Data
shape values, then Kind. Kubernetes summary cells truncate through a block span
that fills the cell, so a long value ends in an ellipsis inside its own column
on the row's text line; a fixed-width inline-block cap spilled past narrow
cells without an ellipsis and sat above the row baseline.
cells without an ellipsis and sat above the row baseline. Docker image, network,
volume, service, task, secret and config cells, Proxmox backup coverage,
restore-evidence and recoverable cells, the Mail Gateway drawer's top domains,
and vSphere host vCenter cells follow the same rule. The Docker service Update
cell still wraps on phones and from md truncates inside its column instead of
at a 10rem cap.
Duration and interval cells keep the same split: unified-resource or
source-specific consumers own which elapsed duration, human fallback, or poll
interval field is meaningful, while dense platform table rendering must use
@@ -0,0 +1,90 @@
{
"version": 1,
"base_sha": "914fabfc1fef6d728bf145cf3097557ef82eafe7",
"verified_at": "2026-10-05T13:10:50Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx",
"frontend-modern/src/features/docker/DockerSecretsTable.tsx",
"frontend-modern/src/features/docker/DockerServicesTable.tsx",
"frontend-modern/src/features/docker/DockerTasksTable.tsx",
"frontend-modern/src/features/docker/DockerVolumesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx",
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx"
],
"content_sha256": {
"frontend-modern/src/features/docker/DockerConfigsTable.tsx": "5e5aa4ec47bff613fa1e61fffc6e5f78fbb52a11e74fd692f632f7d9f39c68ed",
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "dad1a0a668160d0590d376d35da94f0e2e500965852631fff40e85a09ece62c1",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx": "a4fbc6cfe58dabb7e453e0e65b6bc6aeb72bcc05945fb68a6571e503f44722c9",
"frontend-modern/src/features/docker/DockerSecretsTable.tsx": "3a68466cc647a84f4bc7cf9d9b6184ebf0476b9f21d05b9329ae400cfd9f7460",
"frontend-modern/src/features/docker/DockerServicesTable.tsx": "aa3cf92e9aeb611027cec10dc3596e81907732e20930c06ba5d9275451f42c1e",
"frontend-modern/src/features/docker/DockerTasksTable.tsx": "02435077ae71767e6f6db6bc0f3441a585661013dd62c35892e48229cc53e7b6",
"frontend-modern/src/features/docker/DockerVolumesTable.tsx": "020345e4bc6c9a4389410cbfbe566aa34dae8ea7c702f65246bdfb75ce9e3e96",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "b16eaab7cc00f13a858b290f453e46ee1886c1e50a9a542999f4568a0d42aead",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx": "18c6dbea9038cc6561f5f914f61e67954abc4a3a93f83560b76398ae2915b7ba",
"frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "f8f3695bbd200fa8a8f7091ec6431faa1dada5fbdbb98b80b9fc8c5f7ede304b",
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx": "e6caa80836b45e9509d450e6816645062adc1cf6ce29041810e5daf70491ac6f"
},
"routes": [
"/docker/images",
"/docker/networks",
"/docker/storage",
"/docker/swarm",
"/proxmox/backups/date",
"/proxmox/backups/coverage",
"/proxmox/mail",
"/vmware/overview"
],
"viewports": [
{
"width": 390,
"height": 1000
},
{
"width": 600,
"height": 1000
},
{
"width": 700,
"height": 1000
},
{
"width": 768,
"height": 1000
},
{
"width": 900,
"height": 1000
},
{
"width": 1100,
"height": 1000
},
{
"width": 1280,
"height": 1000
},
{
"width": 1440,
"height": 1000
}
],
"states": [
"Live DOM probe on main at 700 and 1440 over every changed span (Docker images, networks, volumes, services, tasks, secrets, configs; Proxmox recoverable, coverage Node and restore-evidence Location/Details, Mail Gateway drawer Top domains; vSphere hosts vCenter): all 1000 measured spans are the only child of their cell and every table, including the drawer's Top domains table, computes table-layout fixed",
"Main: 66 spans ran past their cell with no ellipsis (Service Image 225px and Secret Labels 184px at 700, Mountpoint 51px and restore-evidence Details 22px at 1440, PMG domains 71px at 700), and every inline-block span sat 2 to 2.5px above the row's text line",
"Patched: no span runs past its cell, all 134 truncated values end in an ellipsis inside their own column, and each value sits on the row's text line (0px; the 11px restore-evidence rows read 0.5px)",
"Docker service Update cell still wraps Rollback started onto two lines below md (700) and truncates inside its column from md (1440) instead of at a 10rem cap",
"Clip sweep (scrollWidth over clientWidth on every cell, screen-reader text excluded) across all eight routes at 390 to 1440: clipped cells 931 on main and 928 patched over 19209 measured cells, no column worse at any width (Secret Labels, Mountpoint and Used by each one better)",
"Before and after element screenshots of all eleven affected tables at 700 and 1440 show hard cuts such as registry.exa and mail-relay.customer-opera on main becoming registry... and mail-relay.customer-op... inside the cell, with Created and Mountpoint back on the row line",
"Mock PMG reports no domain stats, so the drawer's Top domains table was mounted by adding eight domains (4 to 54 characters) to the real /api/pmg/instances response through a Playwright route intercept"
],
"interactions": [
"Expanded the first three Proxmox coverage rows to mount the restore-evidence table",
"Opened the first Mail Gateway row's drawer to mount the Top domains table",
"Loaded each Docker, Proxmox backups (by date and coverage) and vSphere overview tab fresh at every viewport and waited for the row count to settle before measuring"
]
}
@@ -194,7 +194,7 @@ export const DockerConfigsTable: Component<DockerNativeTableProps> = (props) =>
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-56 truncate"
class="block truncate"
title={dockerTextValue(resource.docker?.objectCreatedAt)}
>
<PlatformTableRelativeTimeValue
@@ -206,7 +206,7 @@ export const DockerConfigsTable: Component<DockerNativeTableProps> = (props) =>
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
>
<span
class="inline-block max-w-88 truncate"
class="block truncate"
title={dockerLabelsSummary(resource.docker?.labels)}
>
{dockerLabelsSummary(resource.docker?.labels)}
@@ -221,10 +221,7 @@ export const DockerImagesTable: Component<
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content platform-table-phone-hidden`}
>
<span
class="inline-block max-w-[20rem] truncate"
title={operational().consumerSummary}
>
<span class="block truncate" title={operational().consumerSummary}>
{operational().consumerSummary}
</span>
</TableCell>
@@ -569,7 +569,7 @@ export const DockerNetworksTable: Component<DockerNetworksTableProps> = (props)
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
>
<span class="inline-block max-w-[18rem] truncate" title={subnets()}>
<span class="block truncate" title={subnets()}>
{subnets()}
</span>
</TableCell>
@@ -209,7 +209,7 @@ export const DockerSecretsTable: Component<DockerNativeTableProps> = (props) =>
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-48 truncate"
class="block truncate"
title={dockerTextValue(resource.docker?.objectCreatedAt)}
>
<PlatformTableRelativeTimeValue
@@ -221,7 +221,7 @@ export const DockerSecretsTable: Component<DockerNativeTableProps> = (props) =>
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
>
<span
class="inline-block max-w-[16rem] truncate"
class="block truncate"
title={dockerLabelsSummary(resource.docker?.labels)}
>
{dockerLabelsSummary(resource.docker?.labels)}
@@ -331,14 +331,14 @@ export const DockerServicesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-narrow-hidden hidden text-base-content md:table-cell`}
>
<span class="truncate inline-block max-w-32" title={stack()}>
<span class="block truncate" title={stack()}>
{stack()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content md:table-cell`}
>
<span class="truncate inline-block max-w-[18rem]" title={image()}>
<span class="block truncate" title={image()}>
{image()}
</span>
</TableCell>
@@ -361,7 +361,7 @@ export const DockerServicesTable: Component<{
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-full whitespace-normal wrap-break-word leading-3 md:max-w-40 md:truncate md:leading-normal"
class="block whitespace-normal wrap-break-word leading-3 md:truncate md:leading-normal"
title={update().title}
>
{update().label}
@@ -238,7 +238,7 @@ export const DockerTasksTable: Component<DockerNativeTableProps> = (props) => {
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-56 truncate"
class="block truncate"
title={dockerTextValue(
resource.docker?.error ||
resource.docker?.message ||
@@ -246,7 +246,7 @@ export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) =>
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-48 truncate"
class="block truncate"
title={formatPlatformTableDateTimeValue(resource.docker?.createdAt)}
>
<PlatformTableRelativeTimeValue
@@ -259,7 +259,7 @@ export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) =>
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span
class="inline-block max-w-88 truncate"
class="block truncate"
title={dockerTextValue(resource.docker?.mountpoint)}
>
{dockerTextValue(resource.docker?.mountpoint)}
@@ -395,11 +395,22 @@ describe('platform overview layout guardrails', () => {
);
});
it('keeps Kubernetes summary cells truncating inside their own column', () => {
it('keeps platform summary cells truncating inside their own column', () => {
// A capped inline-block (max-w-52 truncate) ignored the column width, so a
// long value spilled past a narrow cell without an ellipsis and sat above
// the row's text line; a block span fills the cell and ellipsizes there.
for (const source of [
dockerConfigsTableSource,
dockerImagesTableSource,
dockerNetworksTableSource,
dockerSecretsTableSource,
dockerServicesTableSource,
dockerTasksTableSource,
dockerVolumesTableSource,
proxmoxCoverageTableSource,
proxmoxMailGatewayDrawerSource,
proxmoxRecoverableTableSource,
vsphereHostsTableSource,
kubernetesAutoscalingTableSource,
kubernetesConfigTableSource,
kubernetesControllersTableSource,
@@ -350,7 +350,7 @@ export function ProxmoxCoverageTable(props: {
fallback={<span class="text-muted">—</span>}
>
{(node) => (
<span class="inline-block max-w-full truncate" title={node()}>
<span class="block truncate" title={node()}>
{node()}
</span>
)}
@@ -586,10 +586,7 @@ export function ProxmoxCoverageTable(props: {
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-[18rem] truncate"
title={artifact.location}
>
<span class="block truncate" title={artifact.location}>
{artifact.location}
</span>
</TableCell>
@@ -634,7 +631,7 @@ export function ProxmoxCoverageTable(props: {
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-[24rem] truncate"
class="block truncate"
title={artifact.detailTitle ?? artifact.detail}
>
{artifact.detail || '—'}
@@ -649,7 +649,7 @@ export const ProxmoxMailGatewayDrawer: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('name')} font-mono text-[11px] text-base-content`}
>
<span class="inline-block max-w-56 truncate" title={domain.domain}>
<span class="block truncate" title={domain.domain}>
{domain.domain || '—'}
</span>
</TableCell>
@@ -160,7 +160,7 @@ export function ProxmoxRecoverableTable(props: {
</TableCell>
<Show when={columnVisible('location')}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
<span class="inline-block max-w-[16rem] truncate" title={artifact.location}>
<span class="block truncate" title={artifact.location}>
{artifact.location}
</span>
</TableCell>
@@ -192,10 +192,7 @@ export function ProxmoxRecoverableTable(props: {
</TableCell>
<Show when={columnVisible('details')}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
<span
class="inline-block max-w-[20rem] truncate"
title={artifact.detailTitle ?? artifact.detail}
>
<span class="block truncate" title={artifact.detailTitle ?? artifact.detail}>
{artifact.detail || '—'}
</span>
</TableCell>
@@ -258,4 +258,32 @@ describe('ProxmoxCoverageTable column visibility', () => {
);
expect(titles.join(' ')).not.toMatch(/canonical|provider evidence|independent recovery/);
});
it('keeps a long node name truncating inside its own column', () => {
// A capped inline-block sat above the row's text line; a block span fills
// the fixed-layout cell and ends in an ellipsis there.
const node = 'pve-production-rack-04-node-17.example.internal';
render(() => (
<ProxmoxCoverageTable
rows={[{ ...row, workload: { ...row.workload, node } }] as WorkloadCoverageRow[]}
hasAnyRows
emptyIcon={<span />}
emptyTitle=""
emptyDescription=""
sortKey={(() => 'posture') as Accessor<CoverageSortKey>}
sortDirection={() => 'asc'}
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showTaskColumn={false}
layoutWidth={() => 1_200}
/>
));
const span = document.querySelector(`td span[title="${node}"]`);
expect(span).toHaveTextContent(node);
expect(span?.parentElement?.children).toHaveLength(1);
expect(span?.getAttribute('class')).toBe('block truncate');
expect(span?.closest('table')).toHaveClass('table-fixed');
});
});
@@ -503,7 +503,7 @@ export const VsphereHostsTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden font-mono text-[11px] text-base-content md:table-cell`}
>
<span class="inline-block max-w-48 truncate" title={vcenter()}>
<span class="block truncate" title={vcenter()}>
{vcenter()}
</span>
</TableCell>