Stop Kubernetes Config and Deployments tables clipping names (#2509)

Follow-up to #2506. The Kubernetes Configuration and Deployments tables
still clipped names and values between phone and desktop widths.

- **Tablet band widths.** Between the phone projection and the md column
widths (a 34rem container below a 768px viewport) these tables split
their width evenly, which cut names to a few letters. Headers can now
declare a band percent through `bandWidth` /
`platformTableBandWidthStyle`, and both tables give names the most room.
- **Scope shows the namespace.** With one cluster in view, every scope
cell repeated the cluster name ("Production EU/ser…"). The cell now
shows the namespace (`Cluster` for cluster-scoped objects) and keeps the
full scope on the title and for screen readers. Several clusters keep
the combined label.
- **Deployments.** Desired appears only on wide tables, because Ready
already reads `2/2`. Ready keeps its denominator until Desired is shown.
On a phone, Age takes Ready's spare width so `18h ago` fits.
- **Summary cells.** Values sat in a capped `inline-block` span that
ignored the column width. Long values spilled past narrow cells with no
ellipsis and sat about 4px above the row. A `block truncate` span fills
the cell, ends in an ellipsis inside it, and lines up with the rest of
the row.

Verification:
- Clip sweep of every Kubernetes tab at 600–1440px, compared with main:
clipped cells drop from 566 to 356, and no column is worse at any width.
- Deployment, Resource and Namespace clips on Workloads and
Configuration drop from 25 to 0, and Scope clips from 136 to 7.
- Row text alignment is 0px spread on every tab.
- The browser receipt is included.
This commit is contained in:
rcourtman
2026-10-05 12:18:10 +01:00
committed by GitHub
25 changed files with 525 additions and 99 deletions
@@ -2309,6 +2309,11 @@ not a replacement status card, CTA band, or page-local nested card.
keeps the 30 percent `platform-table-name-column` anchor in that band. Tables
sized by a weighted colgroup keep their own widths there, since column widths
win over header widths.
A table whose columns need unequal room in that band (a long name beside
short counts) declares each header's percent through
`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.
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
@@ -1177,6 +1177,19 @@ part that tells siblings apart (`checkout-api-6d…-x7k2p`, `prod…-03`), with
full name on the title. Pod names, the Pods table's Node column and controller
names use it. Deployment names are chosen by people, so they keep plain end
truncation.
With one cluster in view, every Kubernetes scope cell repeated the cluster
name and crowded out the namespace that differs between rows, so the tables
show the namespace alone (`Cluster` for cluster-scoped objects) through
`kubernetesScopeDisplayLabel` and keep the full cluster/namespace scope on the
title; several clusters keep the combined label. The Deployments table keeps
Desired for wide tables only, because Ready already shows ready over desired,
and sizes the tablet band for a long name beside short counts. The
Configuration tables size their band and md widths for ConfigMap, Secret and
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.
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,122 @@
{
"version": 1,
"base_sha": "3af37a88625701f6dad057fc8889d09a61dffd24",
"verified_at": "2026-10-05T11:01:05Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesScopeText.tsx",
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx",
"frontend-modern/src/features/kubernetes/kubernetesPageModel.ts",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/index.css"
],
"content_sha256": {
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx": "b66c8c5c705b9478d98ba2bfd51ff199c07619ecf40516d7f276a13c5409a825",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx": "bc3e01de166d4467e7b547a72958091c465768ef552ae31118969d6b4d6c36fd",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "b1b184557d03331d8d2ef19d9ab8af9d083013ac3b839fde719bd5d5e4d12d9e",
"frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx": "95cca80263abaf11c0025f1c0bf7a2ccc4421acb9a0d2c611096602353ea75d5",
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx": "814ce1c8358cdde955da9707498d491bc3081dded52e86405edd9517e3f71f65",
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx": "6a0c1e57cddf59acaf0f0a21690fde5db75f08a9fddd4ac957086b5d7ac307c9",
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx": "487541b013886c3f371762a585443ae790d6ec7c72531ac7991ec1a5c76b73a5",
"frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx": "5ed2a52f1a7e5c27a96d2df832d4b80ef210873c84051eb396d4f9cf31f42f51",
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx": "91293a32d6cb195a07d39f472c9a7e204bd96de709c65d82728e30d74aac77a9",
"frontend-modern/src/features/kubernetes/KubernetesScopeText.tsx": "50a0468caf73eb7658bbc90e26cd5b48702af7a441e384ab70fde5a6a0daf557",
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx": "48ccb976014015513b1aa52391da969604c541d2f27a20d8efac098d24890f15",
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx": "b0c87e0386b0d4f7440a7859a106d73daf611867bfd88418e1c6ed7cf8456ae9",
"frontend-modern/src/features/kubernetes/kubernetesPageModel.ts": "9cf09fca38e4c64616e1cd4ee578c6c03bddb810498abdd974e0450a68aa81b1",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "843a1efe473f4092a1ee508bd1634bc51fdcce395e2fe7dd41998ba1c7aebf8b",
"frontend-modern/src/index.css": "ce8f3f0d5a8da290fad1545eedca6a46d4211b4e98239fb1a8e4773cf063e4b2"
},
"routes": [
"/kubernetes/overview",
"/kubernetes/nodes",
"/kubernetes/workloads",
"/kubernetes/services",
"/kubernetes/storage",
"/kubernetes/configuration",
"/kubernetes/events"
],
"viewports": [
{
"width": 360,
"height": 900
},
{
"width": 390,
"height": 1100
},
{
"width": 430,
"height": 900
},
{
"width": 520,
"height": 900
},
{
"width": 600,
"height": 1000
},
{
"width": 650,
"height": 1000
},
{
"width": 700,
"height": 1000
},
{
"width": 767,
"height": 1000
},
{
"width": 768,
"height": 1000
},
{
"width": 820,
"height": 1000
},
{
"width": 900,
"height": 1000
},
{
"width": 1100,
"height": 1000
},
{
"width": 1300,
"height": 1000
},
{
"width": 1440,
"height": 1000
}
],
"states": [
"Clip sweep (scrollWidth over clientWidth on every cell, every Kubernetes tab, 600 to 1440 wide, screen-reader text excluded): clipped cells fall from 566 to 356 against main with no column worse at any width; Deployment, Resource and Namespace clips on Workloads and Configuration go from 25 to 0, Scope from 136 to 7, Age from 22 to 1",
"Workloads at 700: every deployment and namespace reads in full (main showed ingress-nginx-cont... and monitori...); columns are Deployment, Namespace, Updated, Ready, Available, Age with Ready reading 2/2; at 1300 and 1440 Cluster and Desired return and Ready reads 2",
"Configuration at 700: ConfigMap, Secret and RBAC names read in full (main cut them to checkout-api-runti... and payments-worker-...), Scope shows the namespace (apps, monitoring, services, Cluster) instead of Production EU/ser, and long Lifecycle and Data shape values end in an ellipsis inside their own cell where main spilled 1 subject - Service past the card edge",
"Row text alignment on every Kubernetes tab at 700 and 1440: each row's cell text sits on one line (0px spread); main's Scope and Data shape spans sat about 4px above the row",
"All 205 block truncate summary spans are the only content of their cell and sit in fixed-layout tables",
"Phone at 390: Deployments Age reads 18h ago (main showed 18h a...), Ready keeps 2/2; Configuration shows the namespace scope and ellipsizes Data inside the cell; at 360 Age stays hidden as before",
"Scope cell exposes the full cluster/namespace scope to assistive technology through a screen-reader span and keeps it on the title"
],
"interactions": [
"Ran the clip sweep on main (reverse-applied patch) and on the change across seven widths",
"Tuned tablet-band widths live by injecting --platform-table-band-width at 600, 650, 700 and 767",
"Measured row text alignment and block-span containment in the live DOM",
"Captured before/after table screenshots at 700 and 390 for Workloads and Configuration"
]
}
@@ -274,6 +274,20 @@ describe('App architecture', () => {
expect(appStylesSource).not.toContain('white-space: normal;\n -webkit-box-orient');
});
it('keeps the desired replica count on screen in the Deployments table', () => {
// Desired returns with the 72rem tier, so Ready's own /desired total hides
// only there, after its base rule so the container rule wins.
const base = appStylesSource.indexOf('.k8s-deployment-ready-total {\n display: inline;');
const hide = appStylesSource.indexOf(
'@container (min-width: 72rem) {\n .k8s-deployment-ready-total {\n display: none;',
);
expect(base).toBeGreaterThan(-1);
expect(hide).toBeGreaterThan(base);
expect(appStylesSource.match(/\.k8s-deployment-ready-total \{\s*display: none;/g)).toHaveLength(
1,
);
});
it('keeps the identity anchor between the phone projection and the md column widths', () => {
// Column visibility is container-led but md:w-[...] widths are viewport
// media queries, so a 544-767px band showed every column with no widths and
@@ -283,6 +297,11 @@ describe('App architecture', () => {
expect(band).toMatch(
/^@media \(width < 768px\) \{\s*@container \(min-width: 34rem\) \{\s*\.table-scroll-shell > \.table-fixed\.platform-table th\.platform-table-name-column \{\s*width: 30%;/,
);
// A table that needs unequal room in the band declares each header's
// width, and the shell applies it only there (after the name anchor).
expect(band).toMatch(
/width: 30%;\s*\}[\s\S]*?th\[style\*='--platform-table-band-width'\] \{\s*width: var\(--platform-table-band-width\);/,
);
});
it('keeps compact phone settings spacing scoped to the settings content shell', () => {
@@ -32,7 +32,9 @@ import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
isSingleKubernetesCluster,
} from './kubernetesPageModel';
import { KubernetesScopeText } from './KubernetesScopeText';
const autoscalerName = (resource: Resource): string =>
asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id;
@@ -105,6 +107,7 @@ export const KubernetesAutoscalingTable: Component<{
externalSearch?: () => string;
externalStatus?: () => KubernetesResourceStatusFilter;
}> = (props) => {
const singleCluster = createMemo(() => isSingleKubernetesCluster(props.resources));
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as KubernetesResourceStatusFilter,
@@ -237,7 +240,6 @@ export const KubernetesAutoscalingTable: Component<{
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => autoscalerName(resource);
const scope = () => kubernetesScopeLabel(resource);
const target = () => targetRef(resource);
const metrics = () => metricSources(resource);
const labels = () => labelSummary(resource);
@@ -273,14 +275,16 @@ export const KubernetesAutoscalingTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
>
<span class="inline-block max-w-48 truncate" title={scope()}>
{scope()}
</span>
<KubernetesScopeText
resource={resource}
singleCluster={singleCluster()}
class="block truncate"
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-56 truncate" title={target()}>
<span class="block truncate" title={target()}>
{target()}
</span>
</TableCell>
@@ -306,14 +310,14 @@ export const KubernetesAutoscalingTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content sm:table-cell`}
>
<span class="inline-block max-w-40 truncate" title={metrics().title}>
<span class="block truncate" title={metrics().title}>
{metrics().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
>
<span class="inline-block max-w-32 truncate" title={labels().title}>
<span class="block truncate" title={labels().title}>
{labels().label}
</span>
</TableCell>
@@ -31,7 +31,9 @@ import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
isSingleKubernetesCluster,
} from './kubernetesPageModel';
import { KubernetesScopeText } from './KubernetesScopeText';
// ConfigMaps and Secrets are intentionally rendered as API metadata. Pulse may
// know that keys exist, but metadata-only collection must never imply that
@@ -198,6 +200,7 @@ export const KubernetesConfigTable: Component<{
externalSearch?: () => string;
externalStatus?: () => KubernetesResourceStatusFilter;
}> = (props) => {
const singleCluster = createMemo(() => isSingleKubernetesCluster(props.resources));
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as KubernetesResourceStatusFilter,
@@ -264,7 +267,8 @@ export const KubernetesConfigTable: Component<{
kind="name"
sort={sort}
sortKey="resource"
class="platform-table-mobile-w-30 md:w-[22%]"
class="platform-table-mobile-w-30 md:w-[27%]"
bandWidth={35}
>
Resource
</PlatformSortableTableHead>
@@ -272,7 +276,8 @@ export const KubernetesConfigTable: Component<{
kind="text"
sort={sort}
sortKey="kind"
class="platform-table-mobile-w-15 md:w-[13%]"
class="platform-table-mobile-w-15 md:w-[15%]"
bandWidth={16}
>
Kind
</PlatformSortableTableHead>
@@ -280,7 +285,8 @@ export const KubernetesConfigTable: Component<{
kind="text"
sort={sort}
sortKey="scope"
class="platform-table-mobile-w-20 md:w-[16%]"
class="platform-table-mobile-w-20 md:w-[10%]"
bandWidth={14}
>
Scope
</PlatformSortableTableHead>
@@ -288,7 +294,8 @@ export const KubernetesConfigTable: Component<{
kind="text"
sort={sort}
sortKey="lifecycle"
class="platform-table-phone-hidden md:w-[16%]"
class="platform-table-phone-hidden md:w-[17%]"
bandWidth={16}
>
<PlatformResponsiveTableLabel compact="Status" full="Lifecycle / trust" />
</PlatformSortableTableHead>
@@ -296,6 +303,7 @@ export const KubernetesConfigTable: Component<{
kind="text"
sort={sort}
class="platform-table-mobile-w-15 md:w-[14%]"
bandWidth={19}
>
<PlatformResponsiveTableLabel compact="Data" full="Data shape" />
</PlatformSortableTableHead>
@@ -322,7 +330,6 @@ export const KubernetesConfigTable: Component<{
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => configName(resource);
const kind = () => configKind(resource);
const scope = () => kubernetesScopeLabel(resource);
const state = () => lifecycleOrTrust(resource);
const data = () => dataShape(resource);
const refs = () => serviceAccountRefs(resource);
@@ -364,9 +371,11 @@ export const KubernetesConfigTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-52 truncate" title={scope()}>
{scope()}
</span>
<KubernetesScopeText
resource={resource}
singleCluster={singleCluster()}
class="block truncate"
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
@@ -376,21 +385,21 @@ export const KubernetesConfigTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-52 truncate" title={data().title}>
<span class="block truncate" title={data().title}>
{data().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="inline-block max-w-52 truncate" title={refs().title}>
<span class="block truncate" title={refs().title}>
{refs().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="inline-block max-w-36 truncate" title={labels().title}>
<span class="block truncate" title={labels().title}>
{labels().label}
</span>
</TableCell>
@@ -36,7 +36,9 @@ import {
kubernetesScopeLabel,
mapKubernetesControllerStatus,
type KubernetesResourceStatusFilter,
isSingleKubernetesCluster,
} from './kubernetesPageModel';
import { KubernetesScopeText } from './KubernetesScopeText';
import { KubernetesNameText } from './KubernetesNameText';
const controllerName = (resource: Resource): string =>
@@ -243,6 +245,7 @@ export const KubernetesControllersTable: Component<{
externalSearch?: () => string;
externalStatus?: () => KubernetesResourceStatusFilter;
}> = (props) => {
const singleCluster = createMemo(() => isSingleKubernetesCluster(props.resources));
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as KubernetesResourceStatusFilter,
@@ -397,7 +400,6 @@ export const KubernetesControllersTable: Component<{
const indicator = () => mapKubernetesControllerStatus(resource);
const name = () => controllerName(resource);
const kind = () => controllerKind(resource);
const scope = () => kubernetesScopeLabel(resource);
const target = () => targetValue(resource);
const exceptions = () => exceptionSummary(resource);
const detail = () => apiDetail(resource);
@@ -439,14 +441,16 @@ export const KubernetesControllersTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
>
<span class="inline-block max-w-48 truncate" title={scope()}>
{scope()}
</span>
<KubernetesScopeText
resource={resource}
singleCluster={singleCluster()}
class="block truncate"
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
>
<span class="inline-block max-w-48 truncate" title={target()}>
<span class="block truncate" title={target()}>
{target()}
</span>
</TableCell>
@@ -468,7 +472,7 @@ export const KubernetesControllersTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-52 truncate" title={exceptions()}>
<span class="block truncate" title={exceptions()}>
{exceptions()}
</span>
</TableCell>
@@ -174,13 +174,18 @@ export const KubernetesDeploymentsTable: Component<{
The integer-count columns (Desired / Updated / Ready /
Available) trim to what their headers plus 1-2 digit
values need. The basic tier restores namespace context
without crowding the phone tier.
without crowding the phone tier. Desired is the
denominator Ready already shows (2/2), so it waits for a
wide table. bandWidth sizes the tablet band below the
md widths, where a long deployment name needs about a
third of the row and the counts need little.
*/}
<PlatformSortableTableHead
kind="name"
sort={sort}
sortKey="deployment"
class="platform-table-mobile-w-30 md:w-[25%]"
bandWidth={36}
>
Deployment
</PlatformSortableTableHead>
@@ -188,7 +193,8 @@ export const KubernetesDeploymentsTable: Component<{
kind="text"
sort={sort}
sortKey="namespace"
class="platform-table-mobile-w-15 md:w-[20%]"
class="platform-table-mobile-w-15 md:w-[18%]"
bandWidth={16}
>
Namespace
</PlatformSortableTableHead>
@@ -204,7 +210,7 @@ export const KubernetesDeploymentsTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="desired"
class="platform-table-phone-hidden md:w-[8%]"
class="hidden md:table-cell md:w-[8%]"
>
<PlatformResponsiveTableLabel compact="Des" full="Desired" />
</PlatformSortableTableHead>
@@ -213,6 +219,7 @@ export const KubernetesDeploymentsTable: Component<{
sort={sort}
sortKey="updated"
class="platform-table-phone-hidden md:w-[8%]"
bandWidth={12}
>
<PlatformResponsiveTableLabel compact="Upd" full="Updated" />
</PlatformSortableTableHead>
@@ -220,7 +227,8 @@ export const KubernetesDeploymentsTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="ready"
class="platform-table-mobile-w-15 md:w-[7%]"
class="platform-table-mobile-w-10 md:w-[7%]"
bandWidth={9.5}
>
<PlatformResponsiveTableLabel compact="Rdy" full="Ready" />
</PlatformSortableTableHead>
@@ -229,6 +237,7 @@ export const KubernetesDeploymentsTable: Component<{
sort={sort}
sortKey="available"
class="platform-table-phone-hidden md:w-[9%]"
bandWidth={14}
>
<PlatformResponsiveTableLabel compact="Avail" full="Available" />
</PlatformSortableTableHead>
@@ -236,7 +245,8 @@ export const KubernetesDeploymentsTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="age"
class="platform-table-mobile-w-10 md:w-[6%] platform-table-narrow-hidden"
class="platform-table-mobile-w-15 md:w-[8%] platform-table-narrow-hidden"
bandWidth={12.5}
>
Age
</PlatformSortableTableHead>
@@ -292,7 +302,7 @@ export const KubernetesDeploymentsTable: Component<{
{cluster()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} platform-table-phone-hidden text-base-content`}
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content md:table-cell`}
>
<PlatformTableNumberValue
value={deployment.kubernetes?.desiredReplicas ?? 0}
@@ -28,10 +28,11 @@ import type { Resource } from '@/types/resource';
import {
compareKubernetesEvents,
filterKubernetesResources,
kubernetesScopeLabel,
mapKubernetesEventSeverity,
type KubernetesResourceStatusFilter,
isSingleKubernetesCluster,
} from './kubernetesPageModel';
import { KubernetesScopeText } from './KubernetesScopeText';
const eventName = (resource: Resource): string =>
asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id;
@@ -59,6 +60,7 @@ export const KubernetesEventsTable: Component<{
showToolbar?: boolean;
}> = (props) => {
const sortedEvents = createMemo(() => [...props.resources].sort(compareKubernetesEvents));
const singleCluster = createMemo(() => isSingleKubernetesCluster(props.resources));
const tableState = createPlatformTableFilterState({
resources: sortedEvents,
initialStatus: 'all' as KubernetesResourceStatusFilter,
@@ -161,7 +163,6 @@ export const KubernetesEventsTable: Component<{
const indicator = () =>
mapKubernetesEventSeverity(resource.kubernetes?.eventType);
const name = () => eventName(resource);
const scope = () => kubernetesScopeLabel(resource);
const observed = () => observedTimestamp(resource);
const message = () =>
formatPlatformTableTextValue(resource.kubernetes?.message);
@@ -197,9 +198,11 @@ export const KubernetesEventsTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
>
<span class="inline-block max-w-48 truncate" title={scope()}>
{scope()}
</span>
<KubernetesScopeText
resource={resource}
singleCluster={singleCluster()}
class="block truncate"
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
@@ -210,7 +213,7 @@ export const KubernetesEventsTable: Component<{
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-48 truncate"
class="block truncate"
title={formatPlatformTableTextValue(resource.kubernetes?.reason)}
>
{formatPlatformTableTextValue(resource.kubernetes?.reason)}
@@ -219,10 +222,7 @@ export const KubernetesEventsTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-52 truncate"
title={involvedObject(resource)}
>
<span class="block truncate" title={involvedObject(resource)}>
{involvedObject(resource)}
</span>
</TableCell>
@@ -234,14 +234,14 @@ export const KubernetesEventsTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<span class="inline-block max-w-48 truncate" title={observed() || '—'}>
<span class="block truncate" title={observed() || '—'}>
<PlatformTableRelativeTimeValue value={observed()} />
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content sm:table-cell`}
>
<span class="inline-block max-w-[16rem] truncate" title={message()}>
<span class="block truncate" title={message()}>
{message()}
</span>
</TableCell>
@@ -32,7 +32,9 @@ import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
isSingleKubernetesCluster,
} from './kubernetesPageModel';
import { KubernetesScopeText } from './KubernetesScopeText';
const resourceName = (resource: Resource): string =>
asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id;
@@ -142,6 +144,7 @@ export const KubernetesNetworkingTable: Component<{
externalSearch?: () => string;
externalStatus?: () => KubernetesResourceStatusFilter;
}> = (props) => {
const singleCluster = createMemo(() => isSingleKubernetesCluster(props.resources));
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as KubernetesResourceStatusFilter,
@@ -265,7 +268,6 @@ export const KubernetesNetworkingTable: Component<{
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => resourceName(resource);
const scope = () => kubernetesScopeLabel(resource);
const address = () => addressOrHosts(resource);
const ports = () => portLabel(resource);
const targets = () => targetSummary(resource);
@@ -309,9 +311,11 @@ export const KubernetesNetworkingTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-48 truncate" title={scope()}>
{scope()}
</span>
<KubernetesScopeText
resource={resource}
singleCluster={singleCluster()}
class="block truncate"
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
@@ -321,24 +325,21 @@ export const KubernetesNetworkingTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span
class="inline-block max-w-[16rem] truncate"
title={address().title}
>
<span class="block truncate" title={address().title}>
{address().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-48 truncate" title={ports().title}>
<span class="block truncate" title={ports().title}>
{ports().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="inline-block max-w-52 truncate" title={targets().title}>
<span class="block truncate" title={targets().title}>
{targets().label}
</span>
</TableCell>
@@ -427,7 +427,7 @@ export const KubernetesNodesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden font-mono text-[11px] text-base-content md:table-cell`}
>
<span class="truncate inline-block max-w-40" title={runtime()}>
<span class="block truncate" title={runtime()}>
{runtime()}
</span>
</TableCell>
@@ -32,7 +32,9 @@ import {
kubernetesScopeLabel,
mapKubernetesPodStatus,
type KubernetesResourceStatusFilter,
isSingleKubernetesCluster,
} from './kubernetesPageModel';
import { KubernetesScopeText } from './KubernetesScopeText';
import { KubernetesNameText } from './KubernetesNameText';
const podName = (resource: Resource): string =>
@@ -143,6 +145,7 @@ export const KubernetesPodsTable: Component<{
externalSearch?: () => string;
externalStatus?: () => KubernetesResourceStatusFilter;
}> = (props) => {
const singleCluster = createMemo(() => isSingleKubernetesCluster(props.resources));
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as KubernetesResourceStatusFilter,
@@ -292,7 +295,6 @@ export const KubernetesPodsTable: Component<{
{(resource) => {
const indicator = () => mapKubernetesPodStatus(resource);
const name = () => podName(resource);
const scope = () => kubernetesScopeLabel(resource);
const node = () => formatPlatformTableTextValue(resource.kubernetes?.nodeName);
const owner = () => ownerValue(resource);
const image = () => imageValue(resource);
@@ -334,9 +336,11 @@ export const KubernetesPodsTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="block max-w-full truncate" title={scope()}>
{scope()}
</span>
<KubernetesScopeText
resource={resource}
singleCluster={singleCluster()}
class="block max-w-full truncate"
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
@@ -1,4 +1,4 @@
import { Show, type Component, type JSX } from 'solid-js';
import { createMemo, Show, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
@@ -16,6 +16,7 @@ import {
summarizePlatformTableValues,
PlatformTableShell,
withPlatformStatusCounts,
platformTableBandWidthStyle,
} from '@/features/platformPage/sharedPlatformPage';
import {
PlatformResourceDetailToggleButton,
@@ -27,9 +28,10 @@ import {
import type { Resource } from '@/types/resource';
import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
isSingleKubernetesCluster,
} from './kubernetesPageModel';
import { KubernetesScopeText } from './KubernetesScopeText';
// Kubernetes policy resources carry very different API fields. Keep this table
// on the documented NetworkPolicy, PDB, ResourceQuota, and LimitRange shapes
@@ -187,6 +189,7 @@ export const KubernetesPolicyTable: Component<{
externalSearch?: () => string;
externalStatus?: () => KubernetesResourceStatusFilter;
}> = (props) => {
const singleCluster = createMemo(() => isSingleKubernetesCluster(props.resources));
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as KubernetesResourceStatusFilter,
@@ -247,26 +250,31 @@ export const KubernetesPolicyTable: Component<{
<>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[22%]`}
style={platformTableBandWidthStyle(30)}
>
Resource
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[14%]`}
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[17%]`}
style={platformTableBandWidthStyle(23)}
>
Kind
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[15%]`}
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[12%]`}
style={platformTableBandWidthStyle(13)}
>
Scope
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-20 md:w-[15%]`}
style={platformTableBandWidthStyle(18)}
>
<PlatformResponsiveTableLabel compact="Policy" full="Policy shape" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-20 md:w-[16%]`}
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-20 md:w-[15%]`}
style={platformTableBandWidthStyle(16)}
>
<PlatformResponsiveTableLabel compact="Limits" full="Spec / limits" />
</TableHead>
@@ -288,7 +296,6 @@ export const KubernetesPolicyTable: Component<{
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => policyName(resource);
const scope = () => kubernetesScopeLabel(resource);
const shape = () => policyShape(resource);
const spec = () => policySpec(resource);
const state = () => policyState(resource);
@@ -330,35 +337,37 @@ export const KubernetesPolicyTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-52 truncate" title={scope()}>
{scope()}
</span>
<KubernetesScopeText
resource={resource}
singleCluster={singleCluster()}
class="block truncate"
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-52 truncate" title={shape().title}>
<span class="block truncate" title={shape().title}>
{shape().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-[16rem] truncate" title={spec().title}>
<span class="block truncate" title={spec().title}>
{spec().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="inline-block max-w-56 truncate" title={state().title}>
<span class="block truncate" title={state().title}>
{state().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="inline-block max-w-32 truncate" title={labels().title}>
<span class="block truncate" title={labels().title}>
{labels().label}
</span>
</TableCell>
@@ -0,0 +1,28 @@
import { Show, type Component } from 'solid-js';
import type { Resource } from '@/types/resource';
import { kubernetesScopeDisplayLabel, kubernetesScopeLabel } from './kubernetesPageModel';
/**
* A Kubernetes scope cell. With one cluster in view it shows the namespace
* alone (the cluster name would repeat on every row), keeps the full
* cluster/namespace scope on the title, and gives assistive technology the full
* scope in place of the shortened text.
*/
export const KubernetesScopeText: Component<{
resource: Resource;
singleCluster: boolean;
class?: string;
}> = (props) => {
const full = () => kubernetesScopeLabel(props.resource);
const shown = () => kubernetesScopeDisplayLabel(props.resource, props.singleCluster);
return (
<span class={props.class} title={full()} data-kubernetes-scope>
<span aria-hidden={shown() !== full() ? 'true' : undefined}>{shown()}</span>
<Show when={shown() !== full()}>
<span class="sr-only">{full()}</span>
</Show>
</span>
);
};
export default KubernetesScopeText;
@@ -32,7 +32,9 @@ import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
isSingleKubernetesCluster,
} from './kubernetesPageModel';
import { KubernetesScopeText } from './KubernetesScopeText';
// Services have a distinct API contract from Ingress and EndpointSlice:
// operators need the Service type, virtual IP, exposed ports, node ports, and
@@ -100,6 +102,7 @@ export const KubernetesServicesTable: Component<{
externalSearch?: () => string;
externalStatus?: () => KubernetesResourceStatusFilter;
}> = (props) => {
const singleCluster = createMemo(() => isSingleKubernetesCluster(props.resources));
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as KubernetesResourceStatusFilter,
@@ -223,7 +226,6 @@ export const KubernetesServicesTable: Component<{
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => serviceName(resource);
const scope = () => kubernetesScopeLabel(resource);
const serviceType = () =>
formatPlatformTableTextValue(resource.kubernetes?.serviceType);
const clusterIp = () =>
@@ -266,9 +268,11 @@ export const KubernetesServicesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-48 truncate" title={scope()}>
{scope()}
</span>
<KubernetesScopeText
resource={resource}
singleCluster={singleCluster()}
class="block truncate"
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
@@ -283,24 +287,21 @@ export const KubernetesServicesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
>
<span
class="inline-block max-w-48 truncate"
title={externalIps().title}
>
<span class="block truncate" title={externalIps().title}>
{externalIps().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-60 truncate" title={ports().title}>
<span class="block truncate" title={ports().title}>
{ports().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="inline-block max-w-52 truncate" title={selector().title}>
<span class="block truncate" title={selector().title}>
{selector().label}
</span>
</TableCell>
@@ -31,7 +31,9 @@ import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
isSingleKubernetesCluster,
} from './kubernetesPageModel';
import { KubernetesScopeText } from './KubernetesScopeText';
// Kubernetes storage is intentionally not rendered by the generic
// inventory table. StorageClass, PersistentVolume, and
@@ -213,6 +215,7 @@ export const KubernetesStorageTable: Component<{
title?: string;
showToolbar?: boolean;
}> = (props) => {
const singleCluster = createMemo(() => isSingleKubernetesCluster(props.resources));
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as KubernetesResourceStatusFilter,
@@ -346,7 +349,6 @@ export const KubernetesStorageTable: Component<{
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => storageName(resource);
const kind = () => storageKind(resource);
const scope = () => kubernetesScopeLabel(resource);
const policy = () => policyLabel(resource);
const target = () => bindingTarget(resource);
const detailRowId = () => drawer.detailRowId(resource);
@@ -386,9 +388,11 @@ export const KubernetesStorageTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content sm:table-cell`}
>
<span class="inline-block max-w-48 truncate" title={scope()}>
{scope()}
</span>
<KubernetesScopeText
resource={resource}
singleCluster={singleCluster()}
class="block truncate"
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
@@ -408,17 +412,14 @@ export const KubernetesStorageTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="inline-block max-w-48 truncate" title={policy().title}>
<span class="block truncate" title={policy().title}>
{policy().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span
class="inline-block max-w-[18rem] truncate"
title={target().title}
>
<span class="block truncate" title={target().title}>
{target().label}
</span>
</TableCell>
@@ -61,7 +61,10 @@ describe('KubernetesAutoscalingTable', () => {
expect(screen.getByText('Bounds')).toBeInTheDocument();
expect(screen.getByText('Metrics')).toBeInTheDocument();
expect(screen.getByText('checkout-api-hpa')).toBeInTheDocument();
expect(screen.getByText('prod/apps')).toBeInTheDocument();
expect(screen.getByText('apps').closest('[data-kubernetes-scope]')).toHaveAttribute(
'title',
'prod/apps',
);
expect(screen.getByText('Deployment/checkout-api')).toBeInTheDocument();
expect(screen.getByText('2-10')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
@@ -124,4 +124,39 @@ describe('KubernetesDeploymentsTable', () => {
expect(screen.getByTitle('1 / 3 ready')).toHaveClass('bg-amber-500');
expect(screen.getByTitle('Ready')).toHaveClass('bg-emerald-500');
});
it('sizes the tablet band for a long name and keeps Desired for wide tables', () => {
const { container } = render(() => (
<KubernetesDeploymentsTable
resources={[
makeResource({
id: 'ingress-nginx-controller',
kubernetes: {
namespace: 'ingress-nginx',
resourceKind: 'Deployment',
desiredReplicas: 2,
readyReplicas: 2,
},
}),
]}
emptyIcon={<span />}
emptyTitle="No deployments"
emptyDescription="No deployments"
showToolbar={false}
/>
));
const headers = [...container.querySelectorAll('thead th')];
const byLabel = (label: string) =>
headers.find((th) => th.textContent?.includes(label)) as HTMLElement | undefined;
// The band gives the name about a third of the row and the counts little.
expect(byLabel('Deployment')?.style.getPropertyValue('--platform-table-band-width')).toBe(
'36%',
);
expect(byLabel('Ready')?.style.getPropertyValue('--platform-table-band-width')).toBe('9.5%');
// Ready already shows the desired count as its denominator (2/2).
const desired = byLabel('Desired');
expect(desired).toHaveClass('hidden', 'md:table-cell');
expect(desired?.style.getPropertyValue('--platform-table-band-width')).toBe('');
});
});
@@ -112,7 +112,13 @@ describe('KubernetesPodsTable', () => {
expect(screen.getByText('Age')).toBeInTheDocument();
expect(screen.getByText(kubernetesName('checkout-api-6c746d5bcf-c7z2p'))).toBeInTheDocument();
expect(screen.getByText('prod-euw1/services')).toBeInTheDocument();
// One cluster in view: the scope cell shows the namespace and keeps the
// full cluster/namespace scope on hover.
const scopeCell = screen.getByText('services').closest('[data-kubernetes-scope]');
expect(scopeCell).toHaveAttribute('title', 'prod-euw1/services');
// Assistive technology gets the full scope in place of the shortened text.
expect(screen.getByText('services')).toHaveAttribute('aria-hidden', 'true');
expect(scopeCell?.querySelector('.sr-only')).toHaveTextContent('prod-euw1/services');
expect(screen.getByText(kubernetesName('prod-euw1-k8s-02'))).toBeInTheDocument();
// Narrow columns truncate the head and keep the generated tail, so
// replicas and nodes stay distinguishable. The title keeps the full name.
@@ -199,4 +205,28 @@ describe('KubernetesPodsTable', () => {
screen.getAllByTitle('Running').some((el) => el.classList.contains('bg-emerald-500')),
).toBe(true);
});
it('keeps the cluster in the scope cell when several clusters are in view', () => {
render(() => (
<KubernetesPodsTable
resources={[
makeResource({
id: 'eu-pod',
kubernetes: { clusterName: 'prod-eu', namespace: 'services', podName: 'eu-pod' },
}),
makeResource({
id: 'us-pod',
kubernetes: { clusterName: 'prod-us', namespace: 'services', podName: 'us-pod' },
}),
]}
emptyIcon={<span />}
emptyTitle="No pods"
emptyDescription="No pods"
showToolbar={false}
/>
));
expect(screen.getByText('prod-eu/services')).toBeInTheDocument();
expect(screen.getByText('prod-us/services')).toBeInTheDocument();
});
});
@@ -3,6 +3,8 @@ import type { Resource } from '@/types/resource';
import {
getKubernetesNodesNeedingAttention,
splitKubernetesNameTail,
isSingleKubernetesCluster,
kubernetesScopeDisplayLabel,
} from '../kubernetesPageModel';
import {
KUBERNETES_TAB_SPECS,
@@ -47,6 +49,52 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
});
describe('kubernetesPageModel', () => {
it('shows the namespace alone when one cluster is in view', () => {
const inCluster = (id: string, clusterName: string, namespace?: string) =>
({
id,
type: 'pod',
name: id,
kubernetes: { clusterName, ...(namespace ? { namespace } : {}) },
}) as unknown as Resource;
const services = inCluster('a', 'Production EU', 'services');
const clusterRole = inCluster('b', 'Production EU');
const otherCluster = inCluster('c', 'Staging', 'services');
expect(isSingleKubernetesCluster([services, clusterRole])).toBe(true);
expect(isSingleKubernetesCluster([services, otherCluster])).toBe(false);
expect(isSingleKubernetesCluster([])).toBe(true);
// Two clusters can share a display name, and a row with no cluster at all
// counts as its own, so neither case loses the cluster from the cell.
const sameNameOtherId = {
id: 'd',
type: 'pod',
name: 'd',
kubernetes: { clusterId: 'eu-2', clusterName: 'Production EU', namespace: 'services' },
} as unknown as Resource;
const euOne = {
id: 'e',
type: 'pod',
name: 'e',
kubernetes: { clusterId: 'eu-1', clusterName: 'Production EU', namespace: 'apps' },
} as unknown as Resource;
const noCluster = {
id: 'f',
type: 'pod',
name: 'f',
kubernetes: { namespace: 'apps' },
} as unknown as Resource;
expect(isSingleKubernetesCluster([euOne, sameNameOtherId])).toBe(false);
expect(isSingleKubernetesCluster([euOne, noCluster])).toBe(false);
expect(isSingleKubernetesCluster([noCluster])).toBe(true);
expect(kubernetesScopeDisplayLabel(services, true)).toBe('services');
expect(kubernetesScopeDisplayLabel(clusterRole, true)).toBe('Cluster');
// Several clusters keep the cluster in the cell so rows stay unambiguous.
expect(kubernetesScopeDisplayLabel(services, false)).toBe('Production EU/services');
expect(kubernetesScopeDisplayLabel(clusterRole, false)).toBe('Production EU');
});
it('splits off the generated tail that tells sibling names apart', () => {
expect(splitKubernetesNameTail('checkout-api-6d8f9c7b5-x7k2p')).toEqual({
head: 'checkout-api-6d8f9c7b5',
@@ -137,6 +137,25 @@ const podHasFatalContainer = (containers: ResourceKubernetesPodContainerStatus[]
const podAllContainersReady = (containers: ResourceKubernetesPodContainerStatus[]): boolean =>
containers.length > 0 && containers.every((container) => container.ready === true);
// With one cluster in view, every row's scope repeats the cluster name and
// crowds out the namespace, the part that differs between rows. Tables then
// show the namespace alone (Cluster-wide for cluster-scoped objects) and keep
// the full cluster/namespace scope on hover. Several clusters keep both.
// Clusters are told apart by ID (two can share a display name), and a row with
// no cluster at all counts as its own, so a mix keeps the full scope.
const kubernetesClusterKey = (resource: Resource): string =>
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName) ||
'';
export const isSingleKubernetesCluster = (resources: readonly Resource[]): boolean =>
new Set(resources.map(kubernetesClusterKey)).size <= 1;
export const kubernetesScopeDisplayLabel = (resource: Resource, singleCluster: boolean): string =>
singleCluster
? asTrimmedString(resource.kubernetes?.namespace) || 'Cluster'
: kubernetesScopeLabel(resource);
export function mapKubernetesPodStatus(resource: Resource): StatusIndicator {
const phase = normalizeKubernetesToken(
resource.kubernetes?.podPhase || resource.kubernetes?.phase,
@@ -370,7 +370,7 @@ describe('platform overview layout guardrails', () => {
/sortKey="capacity"[\s\S]{0,120}?class="platform-table-mobile-w-10 md:w-\[16%\]"/,
);
expect(kubernetesDeploymentsTableSource).toMatch(
/sortKey="namespace"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[20%\]"/,
/sortKey="namespace"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[18%\]"/,
);
expect(kubernetesPodsTableSource).toMatch(
/sortKey="scope"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[13%\]"/,
@@ -388,13 +388,34 @@ describe('platform overview layout guardrails', () => {
/sortKey="class"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[10%\]"/,
);
expect(kubernetesConfigTableSource).toMatch(
/sortKey="scope"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[16%\]"/,
/sortKey="scope"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[10%\]"/,
);
expect(kubernetesPolicyTableSource).toContain(
"getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[15%]",
"getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[12%]",
);
});
it('keeps Kubernetes 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 [
kubernetesAutoscalingTableSource,
kubernetesConfigTableSource,
kubernetesControllersTableSource,
kubernetesEventsTableSource,
kubernetesNetworkingTableSource,
kubernetesNodesTableSource,
kubernetesPolicyTableSource,
kubernetesServicesTableSource,
kubernetesStorageTableSource,
]) {
expect(source).not.toMatch(/class="[^"]*\binline-block\b[^"]*\btruncate\b/);
expect(source).not.toMatch(/class="[^"]*\btruncate\b[^"]*\binline-block\b/);
expect(source).toContain('class="block truncate"');
}
});
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');
@@ -417,8 +438,17 @@ describe('platform overview layout guardrails', () => {
expect(dockerVolumesTableSource).toMatch(
/getPlatformTableCellClassForKind\('text'\)[\s\S]{0,100}?platform-table-phone-hidden[\s\S]{0,140}?dockerTextValue\(resource\.docker\?\.scope\)/,
);
// Desired is the denominator Ready already shows (2/2), so it waits for a
// wide table rather than returning with the tablet tier.
expect(kubernetesDeploymentsTableSource).toMatch(
/sortKey="desired"[\s\S]{0,120}?platform-table-phone-hidden/,
/sortKey="desired"[\s\S]{0,120}?class="hidden md:table-cell md:w-\[8%\]"/,
);
// On a phone Ready only shows 2/2 while Age needs room for "18h ago".
expect(kubernetesDeploymentsTableSource).toMatch(
/sortKey="ready"[\s\S]{0,120}?class="platform-table-mobile-w-10 md:w-\[7%\]"/,
);
expect(kubernetesDeploymentsTableSource).toMatch(
/sortKey="age"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[8%\] platform-table-narrow-hidden"/,
);
expect(truenasVirtualMachinesTableSource).toMatch(
/sortKey="state"[\s\S]{0,120}?platform-table-phone-hidden/,
@@ -37,6 +37,7 @@ import {
summarizePlatformTableValues,
withPlatformStatusCounts,
type PlatformResourceStatusFilter,
platformTableBandWidthStyle,
} from '../sharedPlatformPage';
afterEach(() => {
@@ -917,4 +918,10 @@ describe('PlatformTableMetricFallback', () => {
expect(marker.getAttribute('title')).toBe('Update this agent for full machine telemetry.');
expect(marker.getAttribute('aria-hidden')).toBeNull();
});
it('declares tablet band widths as a custom property the shell reads', () => {
expect(platformTableBandWidthStyle(36)).toEqual({ '--platform-table-band-width': '36%' });
expect(platformTableBandWidthStyle(9.5)).toEqual({ '--platform-table-band-width': '9.5%' });
expect(platformTableBandWidthStyle(undefined)).toBeUndefined();
});
});
@@ -590,12 +590,23 @@ export function createPlatformTableSortState<SortKey extends string>(options: {
return { sortKey, sortDirection, handleSort, getAriaSort, sortRows };
}
// The tablet band between the phone projection and the md column widths (a
// 34rem container below a 768px viewport) applies no md:w-[...] width, so a
// table whose columns need unequal room there declares each header's percent
// width through this style, which the shared shell reads only in that band.
export const platformTableBandWidthStyle = (
percent: number | undefined,
): JSX.CSSProperties | undefined =>
percent === undefined ? undefined : { '--platform-table-band-width': `${percent}%` };
export function PlatformSortableTableHead<SortKey extends string>(props: {
kind: PlatformTableColumnKind;
sort: PlatformTableSortState<SortKey>;
// Omit to render a non-sortable header with the same canonical alignment.
sortKey?: SortKey;
class?: string;
// Percent width for the tablet band (see platformTableBandWidthStyle).
bandWidth?: number;
children: JSX.Element;
}) {
const isSorted = () => props.sortKey !== undefined && props.sort.sortKey() === props.sortKey;
@@ -611,6 +622,7 @@ export function PlatformSortableTableHead<SortKey extends string>(props: {
.replace(/\s+/g, ' ')
.trim()}
aria-sort={props.sortKey !== undefined ? props.sort.getAriaSort(props.sortKey) : undefined}
style={platformTableBandWidthStyle(props.bandWidth)}
onClick={handleClick}
>
{props.children}
+16 -4
View File
@@ -529,6 +529,15 @@
display: inline;
}
/* The Deployments Desired column returns with the 72rem tier (hidden
md:table-cell), so Ready drops its own /desired total there and never
before: one of the two always carries the desired count. */
@container (min-width: 72rem) {
.k8s-deployment-ready-total {
display: none;
}
}
@container (min-width: 50rem) {
.table-scroll-shell col.alert-history-context-track {
visibility: visible;
@@ -568,10 +577,6 @@
.table-scroll-shell :is(th, td).storage-dataset-referenced-column {
display: table-cell;
}
.k8s-deployment-ready-total {
display: none;
}
}
@container (min-width: 64rem) {
@@ -1446,6 +1451,13 @@
.table-scroll-shell > .table-fixed.platform-table th.platform-table-name-column {
width: 30%;
}
/* A table whose columns need unequal room in this band (short counts
beside a long name) declares each width through the header's
bandWidth, which the shell applies only here. */
.table-scroll-shell > .table-fixed.platform-table th[style*='--platform-table-band-width'] {
width: var(--platform-table-band-width);
}
}
}