Show Kubernetes job times as an age, not a raw timestamp

The Workload Controllers table printed Job and CronJob timestamps
exactly as the agent reports them, so at 1440px the Detail cell read
"Last success: 2026-10-02T19:59:14.523639+01:00" in a 119px column
and was cut off before the time. A user could not tell when the job
last ran.

The cell now says "Last success 31m ago" or "Completed 2h ago" through
the shared platform relative-time formatter, with the absolute local
time on hover, matching how the Events table shows its observed time.
The Detail column takes 12% instead of 9% so every label the column
can produce fits whole, taken from the Kind, Current and Available
columns which hold short values. The Detail span truncates to the cell
with an ellipsis instead of being cut at the cell edge.

Contract-Neutral: presentation of existing Kubernetes controller time fields only; owning unified-resources clause updated; no frontend-primitives clause, primitive, or API change
This commit is contained in:
rcourtman
2026-10-02 22:05:16 +01:00
parent e6540da45d
commit 4fd5e7a82d
4 changed files with 138 additions and 63 deletions
@@ -790,6 +790,13 @@ dense platform table rendering must use `PlatformTableRelativeTimeValue` /
`formatPlatformTableRelativeTimeValue` for relative labels, compact defaults,
invalid/empty markers, and tabular styling instead of importing
`formatRelativeTime` or declaring local timestamp-age helpers in table files.
Kubernetes controller Detail timestamps (Job completion and start, CronJob
last success and last schedule) are relative-age values under that rule:
`KubernetesControllersTable` renders them as a labelled age through
`formatPlatformTableRelativeTimeValue` with the absolute
`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.
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
+14 -39
View File
@@ -1,31 +1,16 @@
{
"version": 1,
"base_sha": "e5d0843851a3837a90d71afaced3a009c0606bbf",
"verified_at": "2026-10-02T20:33:38Z",
"base_sha": "e6540da45da4de4bcb954463373d8ea9f196eb56",
"verified_at": "2026-10-02T21:04:53Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts",
"frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx"
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx": "9e828c5dde7d4cb461d7c936edb8f14f1c667eaa488a81802d14014aaf0ead6e",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx": "ea2b08b041148b12e0aee0afc652a43ab2f4bb937820ec513edfea36a043305d",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "bd5d7b7838852b23c5561c3e722777eb24d4e3324de863eb380c080a5796f2c8",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "0d0ed89d9d2dd164638dadd9f233d308d1cc52616fa2c5be4402c31659403a1d",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx": "e9fe8c62cdad1f7759f5215044b30c086a3e37ebd50b83cb3f6c64a5c5d58feb",
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "39b479e996c89716c095ea8cae7ecccc397989af3e3a0a83ec5c4c228a75e8b5",
"frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "f4768c045d61983799c04245ba8bb2df7fc8da4af5a6f7d88f2968d7d1624c95"
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "f2d8a72d62d06a89d3a70bd82d63d658ece3ac8acf3a0f688fb751a250d6f057"
},
"routes": [
"/proxmox/replication",
"/proxmox/backups",
"/proxmox/ceph",
"/proxmox/mail"
"/kubernetes"
],
"viewports": [
{
@@ -36,35 +21,25 @@
"width": 1280,
"height": 900
},
{
"width": 1100,
"height": 900
},
{
"width": 900,
"height": 900
},
{
"width": 700,
"height": 900
},
{
"width": 390,
"height": 844
"height": 900
}
],
"states": [
"Mock estate (5 PVE nodes, 8 replication jobs with 2 failed, 3 PBS datastores, 69 backup targets, 1 Ceph cluster, 2 Mail Gateways), dark theme, Chromium",
"Replication: failed and healthy rows collapsed and expanded at full, expanded, operational, basic and compact container layouts",
"Backups: Coverage and By date views, protected, attention, unprotected and unknown rows, empty evidence cells",
"Ceph: single auto-opened cluster with pools and services, pool table in the half-width card and the phone card",
"Mail Gateway: healthy and degraded instances at every container layout"
"Mock estate (1 Kubernetes cluster, 2 nodes, 6 pods, 2 deployments) with one CronJob carrying lastSuccessfulTime, one completed Job, one DaemonSet and one StatefulSet in the Workload Controllers table, Chromium",
"Workload Controllers table at the full desktop layout where the Detail column is shown (1440px) and at container widths where it is hidden (1280, 900, 390px)",
"Controller row expanded to its detail disclosure at 390px"
],
"interactions": [
"Expanded a failed replication job by its disclosure button at 1440px and by row tap at 390px; the detail shows the whole error and the full job record",
"Switched Backups between Coverage and By date; sortable headers render uppercase beside the plain ones",
"Measured every table cell and header for clipped text (scrollWidth over clientWidth) at each viewport; at 1440px all four tabs report none, and no viewport scrolls horizontally",
"Compared before and after screenshots: Mail Gateway Version/Spam/Virus/Quarantine and Ceph Services no longer truncate, Ceph phone pool usage reads 50.1% instead of a clipped 0.1%"
"Measured every controllers table header and cell for clipped text (scrollWidth over clientWidth) at each viewport; before the change the Detail cells needed 258px in a 119px column, after it the CronJob row reads \"Last success 1h ago\" and the Job row \"Completed 41m ago\" with no clipped element in the column and no horizontal page scroll",
"Read the Detail span title attributes: they carry the absolute local time (\"Last success: Oct 2, 2026, 08:51 PM\") instead of the raw RFC 3339 string",
"Probed the rendered width of every label the column can produce (Started, Completed, Last success, Last run with ages up to 365d) against the 135px Detail cell at 1440px; all fit whole",
"Compared before and after screenshots of the table at 1440px; the StatefulSet service name now truncates with an ellipsis inside the cell instead of being cut by the cell edge"
],
"command": "playwright chromium against a private mock-mode stack built from this commit (backend :7685, vite :5203)"
"command": "playwright chromium against a private mock-mode stack built from this commit (backend :7693, vite :5213)"
}
@@ -13,6 +13,8 @@ import {
PlatformTableToolbar,
createPlatformTableFilterState,
createPlatformTableSortState,
formatPlatformTableDateTimeValue,
formatPlatformTableRelativeTimeValue,
formatPlatformTableTextValue,
getPlatformTableCellClassForKind,
PlatformTableShell,
@@ -120,36 +122,60 @@ const exceptionSummary = (resource: Resource): string => {
}
};
const apiDetail = (resource: Resource): string => {
type ControllerDetail = { text: string; title: string };
const plainDetail = (text: string): ControllerDetail => ({ text, title: text });
// Kubernetes reports job timestamps as RFC 3339 strings with microseconds,
// which never fit the Detail column. The cell says how long ago the event
// happened and keeps the absolute time on hover.
const timeDetail = (label: string, timestamp: string): ControllerDetail => {
const relative = formatPlatformTableRelativeTimeValue(timestamp, { emptyText: '' });
if (!relative) return plainDetail(`${label}: ${timestamp}`);
const absolute = formatPlatformTableDateTimeValue(timestamp, {
dateTimeFormat: { year: 'numeric' },
});
return { text: `${label} ${relative}`, title: `${label}: ${absolute}` };
};
const apiDetail = (resource: Resource): ControllerDetail => {
switch (resource.type) {
case 'k8s-replicaset': {
if (typeof resource.kubernetes?.fullyLabeledReplicas === 'number') {
return `Fully labeled: ${resource.kubernetes.fullyLabeledReplicas}`;
return plainDetail(`Fully labeled: ${resource.kubernetes.fullyLabeledReplicas}`);
}
return typeof resource.kubernetes?.observedGeneration === 'number'
? `Observed: ${resource.kubernetes.observedGeneration}`
: '—';
return plainDetail(
typeof resource.kubernetes?.observedGeneration === 'number'
? `Observed: ${resource.kubernetes.observedGeneration}`
: '—',
);
}
case 'k8s-statefulset':
return resource.kubernetes?.serviceName ? `Service: ${resource.kubernetes.serviceName}` : '—';
return plainDetail(
resource.kubernetes?.serviceName ? `Service: ${resource.kubernetes.serviceName}` : '—',
);
case 'k8s-daemonset':
return typeof resource.kubernetes?.updatedReplicas === 'number'
? `Updated: ${resource.kubernetes.updatedReplicas}`
: '—';
return plainDetail(
typeof resource.kubernetes?.updatedReplicas === 'number'
? `Updated: ${resource.kubernetes.updatedReplicas}`
: '—',
);
case 'k8s-job':
if (resource.kubernetes?.completionTime) {
return `Completed: ${resource.kubernetes.completionTime}`;
return timeDetail('Completed', resource.kubernetes.completionTime);
}
return resource.kubernetes?.startTime ? `Started: ${resource.kubernetes.startTime}` : '—';
return resource.kubernetes?.startTime
? timeDetail('Started', resource.kubernetes.startTime)
: plainDetail('—');
case 'k8s-cronjob':
if (resource.kubernetes?.lastSuccessfulTime) {
return `Last success: ${resource.kubernetes.lastSuccessfulTime}`;
return timeDetail('Last success', resource.kubernetes.lastSuccessfulTime);
}
return resource.kubernetes?.lastScheduleTime
? `Last schedule: ${resource.kubernetes.lastScheduleTime}`
: '—';
? timeDetail('Last run', resource.kubernetes.lastScheduleTime)
: plainDetail('—');
default:
return '—';
return plainDetail('—');
}
};
@@ -291,7 +317,7 @@ export const KubernetesControllersTable: Component<{
kind="text"
sort={sort}
sortKey="kind"
class="platform-table-mobile-w-15 md:w-[9%]"
class="platform-table-mobile-w-15 md:w-[8%]"
>
Kind
</PlatformSortableTableHead>
@@ -315,7 +341,7 @@ export const KubernetesControllersTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="current"
class="hidden sm:table-cell md:w-[8%]"
class="hidden sm:table-cell md:w-[7%]"
>
Current
</PlatformSortableTableHead>
@@ -331,7 +357,7 @@ export const KubernetesControllersTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="available"
class="hidden md:table-cell md:w-[9%]"
class="hidden md:table-cell md:w-[8%]"
>
Available
</PlatformSortableTableHead>
@@ -346,7 +372,7 @@ export const KubernetesControllersTable: Component<{
<PlatformSortableTableHead
kind="text"
sort={sort}
class="hidden lg:table-cell md:w-[9%]"
class="hidden lg:table-cell md:w-[12%]"
>
Detail
</PlatformSortableTableHead>
@@ -436,8 +462,8 @@ export const KubernetesControllersTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
>
<span class="inline-block max-w-[14rem] truncate" title={detail()}>
{detail()}
<span class="block truncate" title={detail().title}>
{detail().text}
</span>
</TableCell>
</TableRow>
@@ -1,6 +1,7 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, 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 { KubernetesControllersTable } from '../KubernetesControllersTable';
@@ -24,10 +25,17 @@ const makeResource = ({
afterEach(() => {
cleanup();
vi.useRealTimers();
});
const absoluteTime = (timestamp: string): string =>
formatPlatformTableDateTimeValue(timestamp, { dateTimeFormat: { year: 'numeric' } });
describe('KubernetesControllersTable', () => {
it('renders native controller fields for ReplicaSet, StatefulSet, DaemonSet, Job, and CronJob rows', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
render(() => (
<KubernetesControllersTable
resources={[
@@ -133,11 +141,70 @@ describe('KubernetesControllersTable', () => {
expect(screen.getByText('Job')).toBeInTheDocument();
expect(screen.getByText('10 completions')).toBeInTheDocument();
expect(screen.getByText('Failed: 2')).toBeInTheDocument();
expect(screen.getByText('Completed: 2026-05-24T13:00:00Z')).toBeInTheDocument();
// Job and CronJob timestamps arrive as RFC 3339 strings; the cell shows
// how long ago and keeps the absolute time on hover.
const completed = screen.getByText('Completed 31m ago');
expect(completed).toHaveAttribute(
'title',
`Completed: ${absoluteTime('2026-05-24T13:00:00Z')}`,
);
expect(screen.getByText('CronJob')).toBeInTheDocument();
expect(screen.getByText('*/5 * * * *')).toBeInTheDocument();
expect(screen.getByText('Suspended')).toBeInTheDocument();
expect(screen.getByText('Last success: 2026-05-24T12:55:00Z')).toBeInTheDocument();
const lastSuccess = screen.getByText('Last success 36m ago');
expect(lastSuccess).toHaveAttribute(
'title',
`Last success: ${absoluteTime('2026-05-24T12:55:00Z')}`,
);
expect(screen.queryByText(/2026-05-24T1[23]/)).not.toBeInTheDocument();
});
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'));
render(() => (
<KubernetesControllersTable
resources={[
makeResource({
id: 'nightly-import',
type: 'k8s-job',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
resourceKind: 'Job',
desiredReplicas: 1,
active: 1,
startTime: '2026-05-24T13:29:30Z',
},
}),
makeResource({
id: 'billing-rollup',
type: 'k8s-cronjob',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
resourceKind: 'CronJob',
schedule: '0 2 * * *',
lastScheduleTime: '2026-05-22T02:00:00Z',
},
}),
]}
emptyIcon={<span />}
emptyTitle="No controllers"
emptyDescription="No controllers"
showToolbar={false}
/>
));
expect(screen.getByText('Started 1m ago')).toHaveAttribute(
'title',
`Started: ${absoluteTime('2026-05-24T13:29:30Z')}`,
);
expect(screen.getByText('Last run 2d ago')).toHaveAttribute(
'title',
`Last run: ${absoluteTime('2026-05-22T02:00:00Z')}`,
);
});
});