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
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:
@@ -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
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "e6540da45da4de4bcb954463373d8ea9f196eb56",
|
||||
"verified_at": "2026-10-02T21:04:53Z",
|
||||
"base_sha": "78796b411c1f37f57b303cae9fab5ddaa13e1121",
|
||||
"verified_at": "2026-10-02T21:38:37Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"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/features/kubernetes/KubernetesControllersTable.tsx": "f2d8a72d62d06a89d3a70bd82d63d658ece3ac8acf3a0f688fb751a250d6f057"
|
||||
"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": [
|
||||
"/kubernetes"
|
||||
@@ -31,15 +35,16 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"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"
|
||||
"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",
|
||||
"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": [
|
||||
"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"
|
||||
"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 :7693, vite :5213)"
|
||||
"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',
|
||||
|
||||
+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-[12rem] 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