kubernetes(tables): shared scope labels, humanized event times, drop Observed

Noise findings from the v5-parity audit:

- Eight tables carried copy-pasted scope helpers that preferred the
  internal cluster id over the display name, so the pods table read
  "k8s-production-1/services" while the deployments table said
  "Production EU" for the same cluster. Replace every local helper
  with shared kubernetesScopeLabel / kubernetesClusterLabel (name
  first, id fallback) in the page model.
- The events Observed column rendered the raw ISO timestamp with
  microseconds; humanize it like every other age on the page and keep
  the raw timestamp on the hover title.
- Drop the Deployments Observed column: raw observedGeneration with no
  spec generation beside it fails the what-does-the-user-do test, the
  same shape as the Source badge removed from the disks table in
  #1471. The value still ships in the resource payload.
This commit is contained in:
rcourtman
2026-06-11 19:08:45 +01:00
parent 5dfaf96cae
commit f0c5b38a3f
15 changed files with 109 additions and 126 deletions
@@ -32,6 +32,7 @@ import {
import type { Resource } from '@/types/resource';
import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -40,15 +41,6 @@ const textValue = (value: string | undefined): string => asTrimmedString(value)
const autoscalerName = (resource: Resource): string =>
asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id;
const autoscalerScope = (resource: Resource): string => {
const cluster =
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) return cluster ? `${cluster}/${namespace}` : namespace;
return cluster || 'Cluster';
};
const targetRef = (resource: Resource): string =>
textValue(
[resource.kubernetes?.targetKind, resource.kubernetes?.targetName].filter(Boolean).join('/'),
@@ -184,7 +176,7 @@ export const KubernetesAutoscalingTable: Component<{
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => autoscalerName(resource);
const scope = () => autoscalerScope(resource);
const scope = () => kubernetesScopeLabel(resource);
const target = () => targetRef(resource);
const metrics = () => metricSources(resource);
const labels = () => labelSummary(resource);
@@ -32,6 +32,7 @@ import {
import type { Resource } from '@/types/resource';
import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -56,15 +57,6 @@ const configKind = (resource: Resource): string => {
return resource.kubernetes?.resourceKind || resource.type;
};
const configScope = (resource: Resource): string => {
const cluster =
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) return cluster ? `${cluster}/${namespace}` : namespace;
return cluster || 'Cluster';
};
const plural = (count: number, singular: string, pluralLabel = `${singular}s`): string =>
`${count} ${count === 1 ? singular : pluralLabel}`;
@@ -284,7 +276,7 @@ export const KubernetesConfigTable: Component<{
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => configName(resource);
const kind = () => configKind(resource);
const scope = () => configScope(resource);
const scope = () => kubernetesScopeLabel(resource);
const state = () => lifecycleOrTrust(resource);
const data = () => dataShape(resource);
const refs = () => serviceAccountRefs(resource);
@@ -33,6 +33,7 @@ import type { Resource } from '@/types/resource';
import {
compareKubernetesControllers,
filterKubernetesResources,
kubernetesScopeLabel,
mapKubernetesControllerStatus,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -48,15 +49,6 @@ const controllerName = (resource: Resource): string =>
const controllerKind = (resource: Resource): string =>
resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type;
const controllerScope = (resource: Resource): string => {
const cluster =
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) return cluster ? `${cluster}/${namespace}` : namespace;
return cluster || 'Cluster';
};
const targetValue = (resource: Resource): string => {
switch (resource.type) {
case 'k8s-replicaset':
@@ -278,7 +270,7 @@ export const KubernetesControllersTable: Component<{
const indicator = () => mapKubernetesControllerStatus(resource);
const name = () => controllerName(resource);
const kind = () => controllerKind(resource);
const scope = () => controllerScope(resource);
const scope = () => kubernetesScopeLabel(resource);
const target = () => targetValue(resource);
const exceptions = () => exceptionSummary(resource);
const detail = () => apiDetail(resource);
@@ -33,6 +33,7 @@ import type { Resource } from '@/types/resource';
import {
compareKubernetesDeployments,
filterKubernetesResources,
kubernetesClusterLabel,
mapKubernetesDeploymentStatus,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -43,8 +44,9 @@ import {
// render as dashes. This deployment-native table reuses canonical shared
// primitives (Card, Table, SearchInput, FilterButtonGroup, StatusDot) but
// surfaces deployment-meaningful columns only: namespace, cluster,
// desired / updated / ready / available replicas, observed generation, and
// metadata age.
// desired / updated / ready / available replicas, and metadata age.
// observedGeneration is deliberately not a column: without the spec
// generation beside it the raw number is unactionable.
const replicaCount = (value: number | undefined): JSX.Element => (
<span class="tabular-nums">{value ?? 0}</span>
@@ -121,19 +123,19 @@ export const KubernetesDeploymentsTable: Component<{
Desktop widths: Deployment, Namespace, and Cluster take
the biggest shares because their content can be long.
The integer-count columns (Desired / Updated / Ready /
Available / Observed) trim to what their headers plus
1-2 digit values need. Mobile widths are unchanged.
Available) trim to what their headers plus 1-2 digit
values need. Mobile widths are unchanged.
*/}
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[25%]`}>
Deployment
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[17%]`}
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[20%]`}
>
Namespace
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[17%]`}
>
Cluster
</TableHead>
@@ -157,11 +159,6 @@ export const KubernetesDeploymentsTable: Component<{
>
Available
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[9%]`}
>
Observed
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[6%]`}
>
@@ -174,10 +171,7 @@ export const KubernetesDeploymentsTable: Component<{
{(deployment) => {
const name = () => asTrimmedString(deployment.name) || deployment.id;
const ns = () => asTrimmedString(deployment.kubernetes?.namespace) || '—';
const cluster = () =>
asTrimmedString(deployment.kubernetes?.clusterName) ||
asTrimmedString(deployment.kubernetes?.clusterId) ||
'—';
const cluster = () => kubernetesClusterLabel(deployment) || '—';
const indicator = () => mapKubernetesDeploymentStatus(deployment);
const age = () => formatAge(deployment.kubernetes?.createdAt);
const detailRowId = () => drawer.detailRowId(deployment);
@@ -236,11 +230,6 @@ export const KubernetesDeploymentsTable: Component<{
>
{replicaCount(deployment.kubernetes?.availableReplicas)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content md:table-cell`}
>
{replicaCount(deployment.kubernetes?.observedGeneration)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
@@ -253,7 +242,7 @@ export const KubernetesDeploymentsTable: Component<{
resource={deployment}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={9}
colSpan={8}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(deployment)}
/>
@@ -11,6 +11,7 @@ import {
TableRow,
} from '@/components/shared/Table';
import { asTrimmedString } from '@/utils/stringUtils';
import { formatRelativeTime } from '@/utils/format';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PLATFORM_TABLE_BODY_CLASS,
@@ -32,6 +33,7 @@ import type { Resource } from '@/types/resource';
import {
compareKubernetesEvents,
filterKubernetesResources,
kubernetesScopeLabel,
mapKubernetesEventSeverity,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -41,15 +43,6 @@ const textValue = (value: string | undefined): string => asTrimmedString(value)
const eventName = (resource: Resource): string =>
asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id;
const eventScope = (resource: Resource): string => {
const cluster =
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) return cluster ? `${cluster}/${namespace}` : namespace;
return cluster || 'Cluster';
};
const involvedObject = (resource: Resource): string =>
textValue(
[resource.kubernetes?.involvedKind, resource.kubernetes?.involvedName]
@@ -57,12 +50,16 @@ const involvedObject = (resource: Resource): string =>
.join('/'),
);
const observedTime = (resource: Resource): string =>
textValue(
const observedTimestamp = (resource: Resource): string =>
asTrimmedString(
resource.kubernetes?.eventTime ||
resource.kubernetes?.firstSeen ||
resource.kubernetes?.createdAt,
);
) || '';
// Humanized for the cell ("2h ago"); the raw timestamp stays on the title.
const observedTime = (resource: Resource): string =>
formatRelativeTime(observedTimestamp(resource), { compact: true, emptyText: '—' }) || '—';
const numberValue = (value: number | undefined): JSX.Element =>
typeof value === 'number' ? <span class="tabular-nums">{value}</span> : <span>—</span>;
@@ -163,7 +160,7 @@ export const KubernetesEventsTable: Component<{
const indicator = () =>
mapKubernetesEventSeverity(resource.kubernetes?.eventType);
const name = () => eventName(resource);
const scope = () => eventScope(resource);
const scope = () => kubernetesScopeLabel(resource);
const observed = () => observedTime(resource);
const message = () => textValue(resource.kubernetes?.message);
const detailRowId = () => drawer.detailRowId(resource);
@@ -233,7 +230,10 @@ export const KubernetesEventsTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="inline-block max-w-[12rem] truncate" title={observed()}>
<span
class="inline-block max-w-[12rem] truncate"
title={observedTimestamp(resource) || observed()}
>
{observed()}
</span>
</TableCell>
@@ -32,6 +32,7 @@ import {
import type { Resource } from '@/types/resource';
import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -46,15 +47,6 @@ const networkKind = (resource: Resource): string => {
return resource.kubernetes?.resourceKind || resource.type;
};
const scopeLabel = (resource: Resource): string => {
const cluster =
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) return cluster ? `${cluster}/${namespace}` : namespace;
return cluster || 'Cluster';
};
const summarizeValues = (
values: readonly (string | undefined)[] | undefined,
visible = 2,
@@ -226,7 +218,7 @@ export const KubernetesNetworkingTable: Component<{
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => resourceName(resource);
const scope = () => scopeLabel(resource);
const scope = () => kubernetesScopeLabel(resource);
const address = () => addressOrHosts(resource);
const ports = () => portLabel(resource);
const targets = () => targetSummary(resource);
@@ -35,6 +35,7 @@ import type { Resource } from '@/types/resource';
import {
compareKubernetesNodes,
filterKubernetesResources,
kubernetesClusterLabel,
mapKubernetesNodeStatus,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -203,10 +204,7 @@ export const KubernetesNodesTable: Component<{
{(node) => {
const meta = () => node.kubernetes;
const name = () => asTrimmedString(node.name) || node.id;
const cluster = () =>
asTrimmedString(meta()?.clusterName) ||
asTrimmedString(meta()?.clusterId) ||
'—';
const cluster = () => kubernetesClusterLabel(node) || '—';
const kubelet = () => asTrimmedString(meta()?.kubeletVersion) || '—';
const runtime = () => asTrimmedString(meta()?.containerRuntimeVersion) || '—';
const capacityLabel = () => {
@@ -32,6 +32,7 @@ import type { Resource, ResourceKubernetesPodContainerStatus } from '@/types/res
import {
compareKubernetesPods,
filterKubernetesResources,
kubernetesScopeLabel,
mapKubernetesPodStatus,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -44,15 +45,6 @@ const podName = (resource: Resource): string =>
asTrimmedString(resource.name) ||
resource.id;
const podScope = (resource: Resource): string => {
const cluster =
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) return cluster ? `${cluster}/${namespace}` : namespace;
return cluster || 'Cluster';
};
const podContainers = (resource: Resource): ResourceKubernetesPodContainerStatus[] =>
resource.kubernetes?.podContainers ?? [];
@@ -202,7 +194,7 @@ export const KubernetesPodsTable: Component<{
{(resource) => {
const indicator = () => mapKubernetesPodStatus(resource);
const name = () => podName(resource);
const scope = () => podScope(resource);
const scope = () => kubernetesScopeLabel(resource);
const node = () => textValue(resource.kubernetes?.nodeName);
const owner = () => ownerValue(resource);
const image = () => imageValue(resource);
@@ -32,6 +32,7 @@ import {
import type { Resource } from '@/types/resource';
import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -52,15 +53,6 @@ const policyKind = (resource: Resource): string => {
return resource.kubernetes?.resourceKind || resource.type;
};
const policyScope = (resource: Resource): string => {
const cluster =
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) return cluster ? `${cluster}/${namespace}` : namespace;
return cluster || 'Cluster';
};
const summarizeValues = (
values: readonly (string | undefined)[] | undefined,
visible = 2,
@@ -307,7 +299,7 @@ export const KubernetesPolicyTable: Component<{
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => policyName(resource);
const scope = () => policyScope(resource);
const scope = () => kubernetesScopeLabel(resource);
const shape = () => policyShape(resource);
const spec = () => policySpec(resource);
const state = () => policyState(resource);
@@ -32,6 +32,7 @@ import {
import type { Resource } from '@/types/resource';
import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -44,15 +45,6 @@ const textValue = (value: string | undefined): string => asTrimmedString(value)
const serviceName = (resource: Resource): string =>
asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id;
const serviceScope = (resource: Resource): string => {
const cluster =
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) return cluster ? `${cluster}/${namespace}` : namespace;
return cluster || 'Cluster';
};
const summarizeValues = (
values: readonly (string | undefined)[] | undefined,
visible = 2,
@@ -185,7 +177,7 @@ export const KubernetesServicesTable: Component<{
{(resource) => {
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => serviceName(resource);
const scope = () => serviceScope(resource);
const scope = () => kubernetesScopeLabel(resource);
const serviceType = () => textValue(resource.kubernetes?.serviceType);
const clusterIp = () => textValue(resource.kubernetes?.clusterIp);
const externalIps = () => externalIpSummary(resource);
@@ -33,6 +33,7 @@ import {
import type { Resource } from '@/types/resource';
import {
filterKubernetesResources,
kubernetesScopeLabel,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -62,17 +63,6 @@ const storageKind = (resource: Resource): string => {
return resource.kubernetes?.resourceKind || resource.type;
};
const storageScope = (resource: Resource): string => {
const clusterScope =
asTrimmedString(resource.kubernetes?.clusterId) ||
asTrimmedString(resource.kubernetes?.clusterName);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) {
return clusterScope ? `${clusterScope}/${namespace}` : namespace;
}
return clusterScope || 'Cluster';
};
const bindingOrPhase = (resource: Resource): string => {
if (resource.type === 'k8s-storage-class') {
return textValue(resource.kubernetes?.volumeBindingMode || 'Immediate');
@@ -259,7 +249,7 @@ export const KubernetesStorageTable: Component<{
const indicator = () => getSimpleStatusIndicator(resource.status);
const name = () => storageName(resource);
const kind = () => storageKind(resource);
const scope = () => storageScope(resource);
const scope = () => kubernetesScopeLabel(resource);
const policy = () => policyLabel(resource);
const target = () => bindingTarget(resource);
const detailRowId = () => drawer.detailRowId(resource);
@@ -66,7 +66,9 @@ describe('KubernetesDeploymentsTable', () => {
expect(screen.getByText('Updated')).toBeInTheDocument();
expect(screen.getByText('Ready')).toBeInTheDocument();
expect(screen.getByText('Available')).toBeInTheDocument();
expect(screen.getByText('Observed')).toBeInTheDocument();
// observedGeneration is deliberately not a column: the raw number is
// unactionable without the spec generation beside it.
expect(screen.queryByText('Observed')).toBeNull();
expect(screen.getByText('Age')).toBeInTheDocument();
expect(screen.getByText('checkout-api')).toBeInTheDocument();
@@ -75,7 +77,7 @@ describe('KubernetesDeploymentsTable', () => {
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getAllByText('2')).toHaveLength(2);
expect(screen.getByText('12')).toBeInTheDocument();
expect(screen.queryByText('12')).toBeNull();
expect(screen.getByText('2h ago')).toBeInTheDocument();
expect(
document.querySelector('[data-kubernetes-deployment-row="checkout-api"]'),
@@ -1,5 +1,5 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { KubernetesEventsTable } from '../KubernetesEventsTable';
@@ -24,10 +24,14 @@ const makeResource = ({
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe('KubernetesEventsTable', () => {
it('renders Kubernetes Event fields with severity, newest-first ordering, and message context', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T15:00:00Z'));
render(() => (
<KubernetesEventsTable
resources={[
@@ -84,7 +88,10 @@ describe('KubernetesEventsTable', () => {
expect(screen.getByText('FailedScheduling')).toBeInTheDocument();
expect(screen.getByText('Pod/checkout-api-123')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('2026-05-24T13:00:00Z')).toBeInTheDocument();
// The cell humanizes the observed time; the raw timestamp stays on the
// hover title.
expect(screen.getByText('2h ago')).toBeInTheDocument();
expect(screen.getByTitle('2026-05-24T13:00:00Z')).toBeInTheDocument();
expect(screen.getByText('0/3 nodes are available')).toBeInTheDocument();
expect(
@@ -13,6 +13,8 @@ import {
compareKubernetesNodes,
compareKubernetesPods,
getKubernetesPageTabSpecs,
kubernetesClusterLabel,
kubernetesScopeLabel,
mapKubernetesControllerStatus,
mapKubernetesCronJobStatus,
mapKubernetesDaemonSetStatus,
@@ -807,6 +809,42 @@ describe('kubernetesPageModel', () => {
});
});
describe('kubernetesScopeLabel', () => {
it('prefers the human cluster name over the internal cluster id', () => {
expect(
kubernetesScopeLabel(
makeResource({
id: 'pod-1',
type: 'pod',
kubernetes: {
clusterId: 'k8s-production-1',
clusterName: 'Production EU',
namespace: 'services',
},
}),
),
).toBe('Production EU/services');
expect(
kubernetesClusterLabel(
makeResource({
id: 'pod-2',
type: 'pod',
kubernetes: { clusterId: 'k8s-production-1' },
}),
),
).toBe('k8s-production-1');
});
it('falls back through namespace-only and empty scopes', () => {
expect(
kubernetesScopeLabel(
makeResource({ id: 'ns-only', type: 'pod', kubernetes: { namespace: 'apps' } }),
),
).toBe('apps');
expect(kubernetesScopeLabel(makeResource({ id: 'bare', type: 'pod' }))).toBe('Cluster');
});
});
describe('mapKubernetesIncidentSeverity', () => {
it('buckets severity strings into critical / warning / info', () => {
expect(mapKubernetesIncidentSeverity('critical')).toBe('critical');
@@ -94,6 +94,21 @@ const displayName = (resource: Resource): string =>
asTrimmedString(resource.kubernetes?.podName) ||
resource.id;
// Scope labels prefer the human-facing cluster name; the internal cluster id
// is only a fallback for rows whose adapter carries no name. Every Kubernetes
// page table shares this so the same cluster never reads as "Production EU"
// in one column and "k8s-production-1" in another.
export const kubernetesClusterLabel = (resource: Resource): string =>
asTrimmedString(resource.kubernetes?.clusterName) ||
asTrimmedString(resource.kubernetes?.clusterId);
export const kubernetesScopeLabel = (resource: Resource): string => {
const cluster = kubernetesClusterLabel(resource);
const namespace = asTrimmedString(resource.kubernetes?.namespace);
if (namespace) return cluster ? `${cluster}/${namespace}` : namespace;
return cluster || 'Cluster';
};
const containerHasFatalReason = (container: ResourceKubernetesPodContainerStatus): boolean => {
const reason = normalizeKubernetesToken(container.reason);
if (reason && POD_CONTAINER_FATAL_REASONS.has(reason)) return true;