Carry Kubernetes job times into the controller row expansion

The Workload Controllers table hides its Detail column below a 76rem
container, and that column was the only place a Job's completion time and
a CronJob's last success or last run were shown. The row expansion rendered
only the generic Observed state, Last seen, Identity and Aliases, so a phone
or a half-width pane had no way to read when a job finished. At 390px the
15 percent Kind track also clipped "DaemonSet", "StatefulSet" and
"CronJob", and the Target track clipped "1 completions".

The drawer's Kubernetes model now builds a per-kind controller section
(Job, CronJob, DaemonSet, StatefulSet, ReplicaSet) that the always-visible
summary tables render in the same slot as the Docker container section, so
the facts are one tap away in every width and on the Infrastructure drawer
too. Job rows lead with Started, Completed and Duration; CronJob rows with
Schedule, Last run and Last success; each timestamp is the absolute time
with its age in parentheses and wraps in a phone-width cell. Target, the
counts, the StatefulSet service name, Namespace and Cluster follow, which
also shows the service name the Detail column truncates at 1440px.

The phone projection keeps Controller, Kind, Ready and Issues and demotes
Target with platform-table-phone-hidden, widening Kind to 25 percent so
every kind label fits whole. The Target count now pluralizes through
getPlatformResourceCountNoun, so a single completion reads "1 completion"
in the row as it does in the expansion.

Verified with a Playwright clip sweep at 1440, 1280, 900 and 390px against
a private mock-mode stack; the receipt records the measurements.

Contract-Neutral: Kubernetes controller facts move into the existing drawer summary slot and the phone row uses the existing platform-table-phone-hidden marker; owning unified-resources clause updated; no frontend-primitives clause, primitive, or API change
This commit is contained in:
rcourtman
2026-10-03 10:40:53 +01:00
parent 74fd189fd0
commit 0ff8132acb
7 changed files with 529 additions and 78 deletions
@@ -797,6 +797,19 @@ last success and last schedule) are relative-age values under that rule:
`formatPlatformTableDateTimeValue` time as hover title, never as the raw RFC
3339 string the agent reports, and the Detail column carries enough weight
for those labelled ages to fit whole on a full-width desktop table.
The controller row expansion carries those timestamps in full: the
always-visible summary tables of the resource drawer render a Kubernetes
workload-controller section (`buildKubernetesControllerSection`, in the same
slot as the Docker container section) with the Job start and completion time,
the CronJob schedule and last run and last success time, each as the absolute
`formatPlatformTableDateTimeValue` time followed by its age in parentheses and
allowed to wrap in a phone-width detail cell, plus the target,
count, service, and scope fields the narrower layouts drop, so a phone or a
half-width pane reaches them in one tap rather than through a collapsed
platform-details disclosure. The `KubernetesControllersTable` phone projection
keeps controller, kind, ready, and issues and demotes Target with
`platform-table-phone-hidden`, so kind labels such as `DaemonSet` and
`StatefulSet` fit whole instead of clipping in a 15 percent track.
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
+19 -69
View File
@@ -1,56 +1,20 @@
{
"version": 1,
"base_sha": "0e90236d3bb901dc84d4d07e70d4d2a09d5e6705",
"verified_at": "2026-10-02T23:55:48Z",
"base_sha": "74fd189fd03098b7ed94d806b15f570722048670",
"verified_at": "2026-10-03T09:40:30Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/Table.tsx"
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx"
],
"content_sha256": {
"frontend-modern/src/components/shared/Table.tsx": "8e5029d5ee9dddc86a4733fa1e7249793cdbedb135f3634e75112f8fb3b327c9"
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "5356c4527e5bccaf86d1dbcaf808cdbeacfce05c8bd58879ab8f90287db1cca8",
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts": "79a9a4414958bbd3034bc145ea5f5b5a1f58a52af4ff8de8b46f18888f5aed51",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "f6f1b090580e4bea0d6496b908f72049c886c4faeee7aca3a3b11e9417c67df7"
},
"routes": [
"/proxmox/overview",
"/proxmox/storage",
"/proxmox/replication",
"/proxmox/ceph",
"/proxmox/mail",
"/docker/overview",
"/docker/images",
"/docker/storage",
"/docker/networks",
"/docker/swarm",
"/kubernetes/overview",
"/kubernetes/nodes",
"/kubernetes/workloads",
"/kubernetes/services",
"/kubernetes/storage",
"/kubernetes/configuration",
"/kubernetes/events",
"/truenas/overview",
"/truenas/storage",
"/truenas/services",
"/truenas/apps",
"/truenas/vms",
"/truenas/shares",
"/truenas/protection",
"/vmware/overview",
"/vmware/storage",
"/vmware/networks",
"/vmware/health",
"/vmware/activity",
"/standalone/machines",
"/standalone/availability",
"/alerts/overview",
"/alerts/history",
"/alerts/thresholds",
"/actions",
"/settings/infrastructure",
"/settings/monitoring",
"/settings/pulse-intelligence",
"/settings/organization",
"/settings/security-users",
"/settings/security-webhooks"
"/kubernetes"
],
"viewports": [
{
@@ -65,36 +29,22 @@
"width": 900,
"height": 900
},
{
"width": 700,
"height": 900
},
{
"width": 560,
"height": 900
},
{
"width": 390,
"height": 900
}
],
"states": [
"Mock stack on origin/main plus this change; Playwright sweep of every table on the routes above at all six viewports, before and after, recording computed th/td padding per cell and leaf elements with scrollWidth > clientWidth under hidden overflow (455 table states compared)",
"Default TableCell/TableHead with no caller padding keep 8px/12px horizontal and 2px/6px vertical padding (Settings infrastructure source and agent tables unchanged at 12px/12px/6px/6px)",
"Platform tables (Proxmox nodes, Docker hosts and containers, Kubernetes clusters/nodes/deployments/pods, TrueNAS systems, vSphere, Machines): cells 12px -> 8px horizontal at 700px and up and 8px -> 6px at 560/390px; header cells keep the stylesheet 6px horizontal and drop to 2px vertical, header rows 29px -> 21px; row height 32px unchanged; clipped-cell count improved in 89 table states, unchanged in 362; the 4 states counted worse are live mock-value drift (relative-time and usage labels) with identical or wider content boxes",
"Physical Disks table (Proxmox > Storage > Physical Disks): cells 8px -> 4px horizontal at 390px and 12px -> 6px at 700px, matching the composed px-1 sm:px-1.5 lg:px-2; clipped cells at 390px 93 -> 18; detail row 12px -> 16px horizontal at 700px and 1440px",
"Storage pools table unchanged at 6px/8px cell padding across 390, 560 and 700px",
"Inline detail rows (Proxmox node, Kubernetes controller, TrueNAS system expansions): detail cell padding 12px/12px/2px/2px -> 0, detail panel inset now only its own px-2 sm:px-4; incident timeline panel in Alert History gets its p-3",
"Alerts overview stats table: dot column right padding 12px -> 0 and value column 12px -> 6px at 700px and up as its classes name; Alert History header cells 12px -> 8px horizontal; AI cost table (Settings > Pulse Intelligence) Est. USD column 12px -> 4px as its px-1 names",
"Workloads table header cells now 6px/8px to match their raw td data cells; Proxmox replication compact-mode !px-1 override still applies at 390px",
"Reactive case: switching a cell class between px-1 and a non-padding class re-resolves the base padding (unit test)"
"Mock estate (1 Kubernetes cluster, 2 nodes, 6 pods, 2 deployments) with one CronJob carrying lastScheduleTime and lastSuccessfulTime, one completed Job with startTime and completionTime, one DaemonSet and one StatefulSet in the Workload Controllers table, Chromium; re-run on 2026-10-03 against this tree after main gained the shared Table cell-padding change (#2427)",
"Workload Controllers table at the full desktop layout where Scope and Detail show (1440px), at container widths where they are hidden (1280 and 900px), and at the phone projection (390px) where the row keeps Controller, Kind, Ready and Issues and the Target column is demoted",
"Each CronJob, Job, DaemonSet and StatefulSet row expanded to its inline detail at all four widths; the summary tables show Runtime context, the new per-kind controller section and Identity"
],
"interactions": [
"Load each route at 1440, 1280, 900, 700, 560 and 390px and run the padding and clip sweep over all tables",
"Click Physical Disks on Proxmox > Storage at 390, 560, 700 and 1440px and expand the first disk row",
"Expand the first node row on Proxmox > Overview, the first deployment row on Kubernetes > Workloads, the first host row on Docker > Overview and the first system row on TrueNAS > Overview at 390, 560, 700 and 1440px",
"Expand the first alert row incident timeline on Alerts > History at 1440px",
"Compare before and after JSON sweeps per route, tab, viewport and table; re-measure the four states counted worse three times each",
"Revert and restore the change on the running dev server to A/B the Kubernetes clusters memory bar label at 560px"
]
"Measured every controllers table header and cell for clipped text (scrollWidth over clientWidth) at each viewport: at 390px the Kind track is 90px and \"DaemonSet\", \"StatefulSet\" and \"CronJob\" fit whole where the 54px track had clipped them, no Target cell remains to clip \"1 completions\", and only the four long controller names truncate with an ellipsis; no viewport scrolls horizontally",
"Expanded each controller row by tapping it and read the new section: the CronJob shows Schedule \"0 2 * * *\", Last run and Last success as \"Oct 2, 2026, 10:25 PM (7m ago)\" style absolute times, Active, Namespace and Cluster; the Job shows Started, Completed, Duration 5m, Target \"1 completion\", Succeeded, Namespace and Cluster; the DaemonSet and StatefulSet show Target, Current, Ready, Available, Updated and the StatefulSet Service name whole",
"Measured the expansion for clipped text at each width: the wrapped timestamp values no longer clip at 390px (an earlier single-line \"absolute \u00b7 age\" value needed 169px in a 142px cell), and the only remaining clipped values are the pre-existing Identity IDs and alias badges",
"Read the Job row Target at 1440px as \"1 completion\" after the pluralization fix, matching the expansion",
"Compared before and after screenshots at 1440 and 390px: the desktop drawer gains a Job or CronJob card between Runtime context and Identity, and the phone row reads Controller, Kind, Ready, Issues with every kind label whole"
],
"command": "playwright chromium against a private mock-mode stack built from this commit (backend :7697, vite :5217), sweep script in the session scratchpad"
}
@@ -13,6 +13,7 @@ import { TagBadges } from '@/components/shared/TagBadges';
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference';
import { formatInteger } from './resourceDetailMappers';
import { buildKubernetesControllerSection } from './resourceDetailDrawerKubernetesModel';
import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState';
interface ResourceSummaryPresentationProps {
@@ -285,6 +286,7 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
}
: null,
props.content !== 'overview' && docker ? dockerSection(docker) : null,
props.content !== 'overview' ? buildKubernetesControllerSection(props.resource) : null,
props.content !== 'overview'
? {
label: 'Identity',
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
import {
buildKubernetesControllerSection,
buildKubernetesDetailSections,
buildKubernetesDetailsSummary,
hasKubernetesDetailSections,
@@ -177,3 +179,203 @@ describe('buildKubernetesDetailsSummary', () => {
).toBe('https://prod.k8s.local:6443');
});
});
describe('buildKubernetesControllerSection', () => {
afterEach(() => {
vi.useRealTimers();
});
const absolute = (timestamp: string): string =>
formatPlatformTableDateTimeValue(timestamp, { dateTimeFormat: { year: 'numeric' } });
it('returns nothing for resources that are not workload controllers', () => {
expect(
buildKubernetesControllerSection(
makeResource({ id: 'pod-1', type: 'pod', kubernetes: { namespace: 'apps' } }),
),
).toBeNull();
expect(buildKubernetesControllerSection(makeResource({ id: 'vm-1', type: 'vm' }))).toBeNull();
expect(
buildKubernetesControllerSection(makeResource({ id: 'job-1', type: 'k8s-job' })),
).toBeNull();
});
it('leads a Job with its absolute start and completion times, then the counts', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
const section = buildKubernetesControllerSection(
makeResource({
id: 'nightly-import',
type: 'k8s-job',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
desiredReplicas: 10,
active: 1,
succeeded: 8,
failed: 2,
startTime: '2026-05-24T12:55:00Z',
completionTime: '2026-05-24T13:00:00Z',
},
}),
);
expect(section?.label).toBe('Job');
expect(section?.testId).toBe('resource-kubernetes-controller-section');
expect(section?.rows).toEqual([
{ label: 'Started', value: `${absolute('2026-05-24T12:55:00Z')} (36m ago)`, wrap: true },
{ label: 'Completed', value: `${absolute('2026-05-24T13:00:00Z')} (31m ago)`, wrap: true },
{ label: 'Duration', value: '5m' },
{ label: 'Target', value: '10 completions' },
{ label: 'Active', value: '1' },
{ label: 'Succeeded', value: '8' },
{ label: 'Failed', value: '2', tone: 'danger' },
{ label: 'Namespace', value: 'batch' },
{ label: 'Cluster', value: 'prod' },
]);
});
it('keeps a running Job on its start time without a completion or duration row', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
const section = buildKubernetesControllerSection(
makeResource({
id: 'nightly-import',
type: 'k8s-job',
kubernetes: {
namespace: 'batch',
desiredReplicas: 1,
active: 1,
failed: 0,
startTime: '2026-05-24T13:29:30Z',
},
}),
);
expect(section?.rows.map((row) => row.label)).toEqual([
'Started',
'Target',
'Active',
'Failed',
'Namespace',
]);
expect(section?.rows[0]).toEqual({
label: 'Started',
value: `${absolute('2026-05-24T13:29:30Z')} (1m ago)`,
wrap: true,
});
expect(section?.rows[3]).toEqual({ label: 'Failed', value: '0', tone: 'default' });
});
it('leads a CronJob with its schedule and the last run and last success times', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
const section = buildKubernetesControllerSection(
makeResource({
id: 'billing-rollup',
type: 'k8s-cronjob',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
schedule: '0 2 * * *',
suspend: true,
active: 0,
lastScheduleTime: '2026-05-24T02:00:00Z',
lastSuccessfulTime: '2026-05-23T02:00:00Z',
},
}),
);
expect(section?.label).toBe('CronJob');
expect(section?.rows).toEqual([
{ label: 'Schedule', value: '0 2 * * *', valueClass: 'font-mono' },
{ label: 'Last run', value: `${absolute('2026-05-24T02:00:00Z')} (11h ago)`, wrap: true },
{ label: 'Last success', value: `${absolute('2026-05-23T02:00:00Z')} (1d ago)`, wrap: true },
{ label: 'Suspended', value: 'Yes', tone: 'warning' },
{ label: 'Active', value: '0' },
{ label: 'Namespace', value: 'batch' },
{ label: 'Cluster', value: 'prod' },
]);
});
it('carries the service name and counts for StatefulSets and DaemonSets', () => {
const statefulSet = buildKubernetesControllerSection(
makeResource({
id: 'checkout-api-stateful',
type: 'k8s-statefulset',
kubernetes: {
namespace: 'apps',
desiredReplicas: 3,
currentReplicas: 3,
readyReplicas: 2,
availableReplicas: 2,
updatedReplicas: 2,
serviceName: 'checkout-headless',
},
}),
);
expect(statefulSet?.label).toBe('StatefulSet');
expect(statefulSet?.rows).toEqual([
{ label: 'Service', value: 'checkout-headless' },
{ label: 'Target', value: '3 pods' },
{ label: 'Current', value: '3' },
{ label: 'Ready', value: '2' },
{ label: 'Available', value: '2' },
{ label: 'Updated', value: '2' },
{ label: 'Namespace', value: 'apps' },
]);
const daemonSet = buildKubernetesControllerSection(
makeResource({
id: 'node-exporter',
type: 'k8s-daemonset',
kubernetes: {
namespace: 'observability',
desiredNumberScheduled: 6,
currentNumberScheduled: 6,
numberReady: 5,
numberAvailable: 5,
numberUnavailable: 1,
numberMisscheduled: 0,
updatedReplicas: 5,
},
}),
);
expect(daemonSet?.label).toBe('DaemonSet');
expect(daemonSet?.rows).toEqual([
{ label: 'Target', value: '6 nodes' },
{ label: 'Current', value: '6' },
{ label: 'Ready', value: '5' },
{ label: 'Available', value: '5' },
{ label: 'Updated', value: '5' },
{ label: 'Unavailable', value: '1', tone: 'warning' },
{ label: 'Misscheduled', value: '0', tone: 'default' },
{ label: 'Namespace', value: 'observability' },
]);
});
it('shows the ReplicaSet labelling and generation fields the Detail column carries', () => {
const section = buildKubernetesControllerSection(
makeResource({
id: 'checkout-api-replicaset',
type: 'k8s-replicaset',
kubernetes: {
desiredReplicas: 4,
readyReplicas: 3,
fullyLabeledReplicas: 4,
observedGeneration: 7,
},
}),
);
expect(section?.label).toBe('ReplicaSet');
expect(section?.rows).toEqual([
{ label: 'Target', value: '4 pods' },
{ label: 'Ready', value: '3' },
{ label: 'Fully labeled', value: '4' },
{ label: 'Observed generation', value: '7' },
]);
});
});
@@ -3,9 +3,18 @@ import {
compactDetailRows as compactRows,
compactDetailSections as compactSections,
formatDetailBytesValue,
formatDetailCountValue,
formatDetailIntegerValue,
makeDetailRow as makeRow,
type DetailRow,
type DetailSection,
type DetailValueTone,
} from '@/components/shared/detailSectionModel';
import {
formatPlatformTableDateTimeValue,
formatPlatformTableDurationValue,
formatPlatformTableRelativeTimeValue,
} from '@/features/platformPage/sharedPlatformPage';
export type ResourceDetailDrawerKubernetesSection = DetailSection;
@@ -120,6 +129,146 @@ export const buildKubernetesDetailSections = (
return compactSections(sections);
};
const countRow = (
label: string,
value: number | undefined,
options: { noun?: string; tone?: (count: number) => DetailValueTone } = {},
): DetailRow | null => {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
const text = options.noun
? formatDetailCountValue(value, options.noun)
: formatDetailIntegerValue(value);
return makeRow(label, text, options.tone ? { tone: options.tone(value) } : {});
};
const warnWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'warning' : 'default');
const dangerWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'danger' : 'default');
// Kubernetes reports controller timestamps as RFC 3339 strings. The table row
// only has room for an age, so the expansion carries the absolute time first
// and the age after it: "Oct 2, 2026, 09:13 PM (1h ago)". The value wraps
// because a phone-width detail cell is too narrow for both on one line.
const timestampRow = (label: string, timestamp?: string | null): DetailRow | null => {
const raw = asString(timestamp);
if (!raw) return null;
const absolute = formatPlatformTableDateTimeValue(raw, {
emptyText: '',
dateTimeFormat: { year: 'numeric' },
});
if (!absolute) return makeRow(label, raw, { wrap: true });
const relative = formatPlatformTableRelativeTimeValue(raw, { emptyText: '' });
return makeRow(label, relative ? `${absolute} (${relative})` : absolute, { wrap: true });
};
const elapsedSeconds = (start?: string | null, end?: string | null): number | undefined => {
const startedAt = Date.parse(asString(start) ?? '');
const endedAt = Date.parse(asString(end) ?? '');
if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt) || endedAt < startedAt) {
return undefined;
}
return (endedAt - startedAt) / 1000;
};
type ControllerRows = { label: string; rows: Array<DetailRow | null> };
// Each kind leads with what its narrow table row drops: the Detail column
// (service name, timestamps) below the large layout and Target on a phone.
const controllerRows = (resource: Resource): ControllerRows | null => {
const k = resource.kubernetes;
if (!k) return null;
switch (resource.type) {
case 'k8s-replicaset':
return {
label: 'ReplicaSet',
rows: [
countRow('Target', k.desiredReplicas, { noun: 'pod' }),
countRow('Current', k.currentReplicas),
countRow('Ready', k.readyReplicas),
countRow('Available', k.availableReplicas),
countRow('Fully labeled', k.fullyLabeledReplicas),
countRow('Observed generation', k.observedGeneration),
],
};
case 'k8s-statefulset':
return {
label: 'StatefulSet',
rows: [
makeRow('Service', k.serviceName),
countRow('Target', k.desiredReplicas, { noun: 'pod' }),
countRow('Current', k.currentReplicas),
countRow('Ready', k.readyReplicas),
countRow('Available', k.availableReplicas),
countRow('Updated', k.updatedReplicas),
],
};
case 'k8s-daemonset':
return {
label: 'DaemonSet',
rows: [
countRow('Target', k.desiredNumberScheduled, { noun: 'node' }),
countRow('Current', k.currentNumberScheduled),
countRow('Ready', k.numberReady),
countRow('Available', k.numberAvailable),
countRow('Updated', k.updatedReplicas),
countRow('Unavailable', k.numberUnavailable, { tone: warnWhenPositive }),
countRow('Misscheduled', k.numberMisscheduled, { tone: warnWhenPositive }),
],
};
case 'k8s-job':
return {
label: 'Job',
rows: [
timestampRow('Started', k.startTime),
timestampRow('Completed', k.completionTime),
makeRow(
'Duration',
formatPlatformTableDurationValue(elapsedSeconds(k.startTime, k.completionTime), {
emptyText: '',
}),
),
countRow('Target', k.desiredReplicas, { noun: 'completion' }),
countRow('Active', k.active),
countRow('Succeeded', k.succeeded),
countRow('Failed', k.failed, { tone: dangerWhenPositive }),
],
};
case 'k8s-cronjob':
return {
label: 'CronJob',
rows: [
makeRow('Schedule', k.schedule, { valueClass: 'font-mono' }),
timestampRow('Last run', k.lastScheduleTime),
timestampRow('Last success', k.lastSuccessfulTime),
makeRow('Suspended', k.suspend === true ? 'Yes' : null, { tone: 'warning' }),
countRow('Active', k.active),
],
};
default:
return null;
}
};
// Workload-controller facts for the always-visible summary of the resource
// drawer, the same slot Docker containers use for their timestamps. The
// controllers table hides Scope and Detail below its large layout and Target
// on a phone, so without this a Job's completion time or a CronJob's last
// success is unreachable from a narrow row.
export const buildKubernetesControllerSection = (resource: Resource): DetailSection | null => {
const controller = controllerRows(resource);
if (!controller) return null;
const rows = compactRows([
...controller.rows,
makeRow('Namespace', resource.kubernetes?.namespace),
makeRow('Cluster', resource.kubernetes?.clusterName),
]);
if (rows.length === 0) return null;
return {
label: controller.label,
rows,
testId: 'resource-kubernetes-controller-section',
};
};
export const buildKubernetesDetailsSummary = (resource: Resource): string | null => {
const k = resource.kubernetes;
if (!k) return null;
@@ -16,6 +16,7 @@ import {
formatPlatformTableDateTimeValue,
formatPlatformTableRelativeTimeValue,
formatPlatformTableTextValue,
getPlatformResourceCountNoun,
getPlatformTableCellClassForKind,
PlatformTableShell,
type PlatformTableSortValue,
@@ -43,15 +44,20 @@ const controllerName = (resource: Resource): string =>
const controllerKind = (resource: Resource): string =>
resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type;
const countedTarget = (count: number | undefined, noun: string): string => {
const value = count ?? 0;
return `${value} ${getPlatformResourceCountNoun(noun, value)}`;
};
const targetValue = (resource: Resource): string => {
switch (resource.type) {
case 'k8s-replicaset':
case 'k8s-statefulset':
return `${resource.kubernetes?.desiredReplicas ?? 0} pods`;
return countedTarget(resource.kubernetes?.desiredReplicas, 'pods');
case 'k8s-daemonset':
return `${resource.kubernetes?.desiredNumberScheduled ?? 0} nodes`;
return countedTarget(resource.kubernetes?.desiredNumberScheduled, 'nodes');
case 'k8s-job':
return `${resource.kubernetes?.desiredReplicas ?? 0} completions`;
return countedTarget(resource.kubernetes?.desiredReplicas, 'completions');
case 'k8s-cronjob':
return formatPlatformTableTextValue(resource.kubernetes?.schedule);
default:
@@ -317,7 +323,7 @@ export const KubernetesControllersTable: Component<{
kind="text"
sort={sort}
sortKey="kind"
class="platform-table-mobile-w-15 md:w-[8%]"
class="platform-table-mobile-w-25 md:w-[8%]"
>
Kind
</PlatformSortableTableHead>
@@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{
>
Scope
</PlatformSortableTableHead>
{/* The phone row keeps controller, kind, ready and issues. A
15% kind track clipped "DaemonSet" and "StatefulSet" and the
target track clipped "1 completions", so target moves to the
row expansion, which also carries the schedule and job
timestamps the narrower layouts drop. */}
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="target"
class="platform-table-mobile-w-20 md:w-[12%]"
class="platform-table-phone-hidden md:w-[12%]"
>
Target
</PlatformSortableTableHead>
@@ -365,7 +376,7 @@ export const KubernetesControllersTable: Component<{
kind="text"
sort={sort}
sortKey="exceptions"
class="platform-table-mobile-w-20 md:w-[11%]"
class="platform-table-mobile-w-30 md:w-[11%]"
>
<PlatformResponsiveTableLabel compact="Issues" full="Exceptions" />
</PlatformSortableTableHead>
@@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
>
<span class="inline-block max-w-[12rem] truncate" title={target()}>
{target()}
@@ -1,6 +1,16 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/resources', () => ({
ResourceAPI: {
getFacetBundle: vi.fn().mockResolvedValue({
capabilities: [],
relationships: [],
recentChanges: [],
}),
},
}));
import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
import { KubernetesControllersTable } from '../KubernetesControllersTable';
@@ -160,6 +170,117 @@ describe('KubernetesControllersTable', () => {
expect(screen.queryByText(/2026-05-24T1[23]/)).not.toBeInTheDocument();
});
it('keeps controller, kind, ready and issues on the phone row and moves target to the expansion', () => {
render(() => (
<KubernetesControllersTable
resources={[
makeResource({
id: 'node-exporter',
type: 'k8s-daemonset',
kubernetes: {
clusterName: 'prod',
namespace: 'observability',
resourceKind: 'DaemonSet',
desiredNumberScheduled: 6,
numberReady: 6,
},
}),
]}
emptyIcon={<span />}
emptyTitle="No controllers"
emptyDescription="No controllers"
showToolbar={false}
/>
));
// "DaemonSet" and "StatefulSet" clipped in a 15% kind track at 390px, and
// "1 completions" clipped in the target track, so the phone row widens kind
// and issues and demotes target to the row expansion.
expect(screen.getByText('Kind').closest('th')).toHaveClass('platform-table-mobile-w-25');
expect(screen.getByText('Target').closest('th')).toHaveClass('platform-table-phone-hidden');
expect(screen.getByText('6 nodes').closest('td')).toHaveClass('platform-table-phone-hidden');
expect(screen.getByText('Exceptions').closest('th')).toHaveClass('platform-table-mobile-w-30');
expect(screen.getByText('Controller').closest('th')).toHaveClass('platform-table-mobile-w-30');
expect(screen.getByText('Ready/Done').closest('th')).toHaveClass('platform-table-mobile-w-15');
expect(screen.getByText('Kind').closest('th')).not.toHaveClass('platform-table-phone-hidden');
});
it('shows the absolute Job and CronJob times in the row expansion', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
render(() => (
<KubernetesControllersTable
resources={[
makeResource({
id: 'nightly-import',
type: 'k8s-job',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
resourceKind: 'Job',
desiredReplicas: 10,
active: 1,
succeeded: 8,
failed: 2,
startTime: '2026-05-24T12:55:00Z',
completionTime: '2026-05-24T13:00:00Z',
},
}),
makeResource({
id: 'billing-rollup',
type: 'k8s-cronjob',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
resourceKind: 'CronJob',
schedule: '*/5 * * * *',
lastScheduleTime: '2026-05-24T13:25:00Z',
lastSuccessfulTime: '2026-05-24T12:55:00Z',
},
}),
]}
emptyIcon={<span />}
emptyTitle="No controllers"
emptyDescription="No controllers"
showToolbar={false}
/>
));
// Tapping the row opens the expansion; the Detail column is hidden below a
// large container, so this is where a phone or half-width pane reads the
// completion time.
fireEvent.click(screen.getByText('nightly-import'));
const job = screen.getByTestId('resource-kubernetes-controller-section');
expect(within(job).getByText('Started')).toBeInTheDocument();
expect(
within(job).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`),
).toBeInTheDocument();
expect(within(job).getByText('Completed')).toBeInTheDocument();
expect(
within(job).getByText(`${absoluteTime('2026-05-24T13:00:00Z')} (31m ago)`),
).toBeInTheDocument();
expect(within(job).getByText('Duration')).toBeInTheDocument();
expect(within(job).getByText('5m')).toBeInTheDocument();
expect(within(job).getByText('Target')).toBeInTheDocument();
expect(within(job).getByText('10 completions')).toBeInTheDocument();
expect(within(job).getByText('Failed').closest('tr')).toHaveTextContent('2');
fireEvent.click(screen.getByText('billing-rollup'));
const cron = screen.getByTestId('resource-kubernetes-controller-section');
expect(within(cron).getByText('Schedule')).toBeInTheDocument();
expect(within(cron).getByText('*/5 * * * *')).toBeInTheDocument();
expect(within(cron).getByText('Last run')).toBeInTheDocument();
expect(
within(cron).getByText(`${absoluteTime('2026-05-24T13:25:00Z')} (6m ago)`),
).toBeInTheDocument();
expect(within(cron).getByText('Last success')).toBeInTheDocument();
expect(
within(cron).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`),
).toBeInTheDocument();
expect(within(cron).getByText('Namespace').closest('tr')).toHaveTextContent('batch');
});
it('falls back to start and last-run times when a Job or CronJob has not completed', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
@@ -198,6 +319,9 @@ describe('KubernetesControllersTable', () => {
/>
));
// A single desired completion reads as "1 completion", matching the
// expansion's Target row.
expect(screen.getByText('1 completion')).toBeInTheDocument();
expect(screen.getByText('Started 1m ago')).toHaveAttribute(
'title',
`Started: ${absoluteTime('2026-05-24T13:29:30Z')}`,