diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ae311147f4..2ebaa48331 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification/f1bc00520f68ff00.json b/frontend-modern/browser-verification/f1bc00520f68ff00.json new file mode 100644 index 0000000000..d75329d97d --- /dev/null +++ b/frontend-modern/browser-verification/f1bc00520f68ff00.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "ebda81d2be79d2ee7e7ff4f665b41e1d330d87a6", + "verified_at": "2026-10-03T20:21:04Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "ddad24a010a2a194f37977f3c3aa3fa647b431bd65d1d75e9f5aea6d42f72692", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "5d7362905632cf4e69c4a878f972ac40544bda2959db2822a431bcc74ba35089" + }, + "routes": [ + "/kubernetes" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 1280, + "height": 900 + }, + { + "width": 900, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Merge of origin/main 1f140a5d38 into #2434: both files carry the PR's controller-expansion changes plus main's Tailwind 4 renames (max-w-48/max-w-52 truncation spans, rounded-sm badges, dark:bg-blue-900/25 identity IP badge), checked on a private mock stack built from the merged tree", + "Workload Controllers table with DaemonSet, CronJob, Job and StatefulSet rows across three mock clusters: 1440 shows Scope and Detail, 1280 and 900 hide them, 390 keeps Controller, Kind, Ready and Issues; table width equals its container at every width, 1440 clips only one long StatefulSet Service detail, 1280 and 900 clip nothing, 390 clips only long controller names", + "CronJob, Job and StatefulSet rows expanded at every width in light (and dark at 1440 and 390): the CronJob section shows Schedule, Last run and Last success as absolute time with age, Active, Namespace and Cluster; the Job section shows Started, Completed, Duration 5m, Target 1 completion and Succeeded; the StatefulSet shows Service, Target, Current, Ready, Available and Updated; no value in those sections clips, and only the pre-existing Identity IDs clip at 390" + ], + "interactions": [ + "Loaded /kubernetes per viewport and colour scheme, clicked the cron-nightly-backfill, nightly-backfill-28918234 and platform-observability rows to open and close their inline drawers", + "Ran a scrollWidth-over-clientWidth sweep over every controllers th/td leaf and every leaf inside each open drawer", + "Ran vitest for src/features/kubernetes, src/components/Infrastructure and src/features/platformPage on the merged tree: 90 files, 1504 tests passed" + ] +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index 50e454e147..2cdbf67ff4 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -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 { ).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' }, + ]); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts index 5d3d5e9275..2d44225f6b 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts @@ -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 }; + +// 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; diff --git a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx index 189041e54d..47f82717ce 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx @@ -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 @@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{ > Scope + {/* 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. */} Target @@ -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%]" > @@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{ {target()} diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx index cf201f66f5..1823b37427 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx @@ -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(() => ( + } + 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(() => ( + } + 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')}`,