mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Carry Kubernetes job times into the controller row expansion (#2434)
## Why 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". ## What - The drawer's Kubernetes model 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 at 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, counts, the StatefulSet service name, Namespace and Cluster follow. - 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 pluralizes through `getPlatformResourceCountNoun`, so a single completion reads "1 completion" in the row as it does in the expansion. ## Verification Playwright clip sweep (scrollWidth over clientWidth on every th/td and their children) at 1440, 1280, 900 and 390px against a private mock-mode stack, with each CronJob, Job, DaemonSet and StatefulSet row expanded at every width: - 390px: the Kind track is 90px and no kind label or target value clips; only the four long controller names truncate with an ellipsis. The wrapped timestamp values in the expansion no longer clip (a single-line value had needed 169px in a 142px cell). - 1440px: no new clipped cell; the StatefulSet service name, which the Detail column truncates, now reads whole in the expansion. - No viewport scrolls horizontally. Unit tests cover the controller section per kind and the table's phone classes and expansion content. `frontend-modern/browser-verification.json` carries the receipt. Builds on #2411. Supersedes #2417, which conflicted with main on the shared browser-verification receipt.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+203
-1
@@ -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-48 truncate" title={target()}>
|
||||
{target()}
|
||||
|
||||
+125
-1
@@ -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')}`,
|
||||
|
||||
Reference in New Issue
Block a user