kubernetes(overview): restore v5 healthy/total fractions on cluster counts

The v5 clusters table read "2/3 ready" the moment a node went down;
the v6 rewrite flattened Nodes / Pods / Deployments to raw totals, so a
cluster with a NotReady node still showed a plain "3". Roll attention
counts (danger/warning indicator variants) into the per-cluster child
rollup and render healthy/total with the amber tone on shortfall.
Muted states (Unknown, deployments scaled to zero) deliberately do not
count as attention.
This commit is contained in:
rcourtman
2026-06-11 19:14:07 +01:00
parent a24e90f7c6
commit 955c750019
3 changed files with 129 additions and 28 deletions
@@ -35,7 +35,9 @@ import {
import type { Resource } from '@/types/resource';
import {
buildKubernetesClusterChildCounts,
emptyKubernetesClusterChildCounts,
filterKubernetesResources,
type KubernetesClusterChildCount,
type KubernetesResourceStatusFilter,
} from './kubernetesPageModel';
@@ -60,6 +62,18 @@ const metricFallback = () => (
</div>
);
// v5-style healthy/total fraction: the healthy share turns amber as soon as
// any child needs attention, so a cluster with a NotReady node reads "2/3"
// instead of a flat "3".
const childCountCell = (count: KubernetesClusterChildCount) => (
<>
<span class={count.attention > 0 ? 'text-amber-700 dark:text-amber-300' : ''}>
{count.total - count.attention}
</span>
<span class="text-muted">/{count.total}</span>
</>
);
export const KubernetesClustersTable: Component<{
clusters: Resource[];
// All Kubernetes-tagged resources from the same query, so the table can
@@ -169,11 +183,7 @@ export const KubernetesClustersTable: Component<{
const hasMemoryMetric = () =>
memoryTotal() > 0 || memoryPercentOnly() !== undefined;
const counts = () =>
countsByCluster().get(cluster.id) ?? {
nodes: 0,
pods: 0,
deployments: 0,
};
countsByCluster().get(cluster.id) ?? emptyKubernetesClusterChildCounts();
const indicator = () => getSimpleStatusIndicator(cluster.status);
const canRenderMetrics = () => indicator().variant !== 'muted';
const detailRowId = () => drawer.detailRowId(cluster);
@@ -222,17 +232,17 @@ export const KubernetesClustersTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{counts().nodes}
{childCountCell(counts().nodes)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content tabular-nums md:table-cell`}
>
{counts().pods}
{childCountCell(counts().pods)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content tabular-nums md:table-cell`}
>
{counts().deployments}
{childCountCell(counts().deployments)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} w-[20%] md:w-auto`}
@@ -674,8 +674,71 @@ describe('kubernetesPageModel', () => {
];
const counts = buildKubernetesClusterChildCounts(resources, [prod, stg]);
expect(counts.get('cluster-prod')).toEqual({ nodes: 2, pods: 3, deployments: 1 });
expect(counts.get('cluster-stg')).toEqual({ nodes: 1, pods: 1, deployments: 0 });
expect(counts.get('cluster-prod')).toEqual({
nodes: { total: 2, attention: 0 },
pods: { total: 3, attention: 0 },
deployments: { total: 1, attention: 0 },
});
expect(counts.get('cluster-stg')).toEqual({
nodes: { total: 1, attention: 0 },
pods: { total: 1, attention: 0 },
deployments: { total: 0, attention: 0 },
});
});
it('tallies attention per bucket from the status mappers', () => {
const prod = cluster('cluster-prod', { clusterId: 'prod' });
const counts = buildKubernetesClusterChildCounts(
[
prod,
makeResource({
id: 'n-ready',
type: 'k8s-node',
kubernetes: { clusterId: 'prod', ready: true },
}),
makeResource({
id: 'n-cordoned',
type: 'k8s-node',
kubernetes: { clusterId: 'prod', ready: true, unschedulable: true },
}),
makeResource({
id: 'p-running',
type: 'pod',
kubernetes: {
clusterId: 'prod',
podPhase: 'Running',
podContainers: [{ ready: true, state: 'running' }],
},
}),
makeResource({
id: 'p-crash',
type: 'pod',
kubernetes: {
clusterId: 'prod',
podPhase: 'Running',
podContainers: [{ ready: false, state: 'waiting', reason: 'CrashLoopBackOff' }],
},
}),
makeResource({
id: 'd-scaled-to-zero',
type: 'k8s-deployment',
kubernetes: { clusterId: 'prod', desiredReplicas: 0 },
}),
makeResource({
id: 'd-partial',
type: 'k8s-deployment',
kubernetes: { clusterId: 'prod', desiredReplicas: 3, readyReplicas: 1 },
}),
],
[prod],
);
expect(counts.get('cluster-prod')).toEqual({
nodes: { total: 2, attention: 1 },
pods: { total: 2, attention: 1 },
// Scaled-to-zero is muted, not degraded; only the partial rollout
// counts as attention.
deployments: { total: 2, attention: 1 },
});
});
it('counts agent rows that report a kubernetes source as cluster nodes', () => {
@@ -698,7 +761,11 @@ describe('kubernetesPageModel', () => {
],
[prod],
);
expect(counts.get('cluster-prod')).toEqual({ nodes: 1, pods: 0, deployments: 0 });
expect(counts.get('cluster-prod')).toEqual({
nodes: { total: 1, attention: 0 },
pods: { total: 0, attention: 0 },
deployments: { total: 0, attention: 0 },
});
});
it('ignores resources whose kubernetes.clusterId / clusterName matches no cluster row', () => {
@@ -711,7 +778,11 @@ describe('kubernetesPageModel', () => {
],
[prod],
);
expect(counts.get('cluster-prod')).toEqual({ nodes: 0, pods: 0, deployments: 0 });
expect(counts.get('cluster-prod')).toEqual({
nodes: { total: 0, attention: 0 },
pods: { total: 0, attention: 0 },
deployments: { total: 0, attention: 0 },
});
});
it('returns an empty map when there are no clusters', () => {
@@ -704,18 +704,36 @@ export type KubernetesPageModel = {
incidents: KubernetesIncidentRow[];
};
export type KubernetesClusterChildCounts = {
nodes: number;
pods: number;
deployments: number;
export type KubernetesClusterChildCount = {
total: number;
// Rows whose status indicator is danger or warning. Healthy-for-display is
// total - attention, so muted states (Unknown, Scaled to 0) never read as
// degraded.
attention: number;
};
const emptyKubernetesClusterChildCounts = (): KubernetesClusterChildCounts => ({
nodes: 0,
pods: 0,
deployments: 0,
export type KubernetesClusterChildCounts = {
nodes: KubernetesClusterChildCount;
pods: KubernetesClusterChildCount;
deployments: KubernetesClusterChildCount;
};
export const emptyKubernetesClusterChildCounts = (): KubernetesClusterChildCounts => ({
nodes: { total: 0, attention: 0 },
pods: { total: 0, attention: 0 },
deployments: { total: 0, attention: 0 },
});
const tallyKubernetesChild = (
count: KubernetesClusterChildCount,
indicator: StatusIndicator,
): void => {
count.total += 1;
if (indicator.variant === 'danger' || indicator.variant === 'warning') {
count.attention += 1;
}
};
const matchClusterFor = (
resource: Resource,
clusters: readonly Resource[],
@@ -738,10 +756,12 @@ const matchClusterFor = (
};
// Walks the resource snapshot once and rolls per-cluster Nodes / Pods /
// Deployments into a Map keyed by cluster row id. The Overview clusters
// table calls this instead of computing counts inline, so the same
// rollup is testable and reusable by other consumers (incident rollups,
// future Overview "active issues" cards).
// Deployments into a Map keyed by cluster row id, carrying both the total
// and how many rows need attention so the Overview can render the v5-style
// healthy/total fractions. The Overview clusters table calls this instead
// of computing counts inline, so the same rollup is testable and reusable
// by other consumers (incident rollups, future Overview "active issues"
// cards).
export function buildKubernetesClusterChildCounts(
resources: readonly Resource[],
clusters: readonly Resource[],
@@ -758,13 +778,13 @@ export function buildKubernetesClusterChildCounts(
const bucket = counts.get(cluster.id);
if (!bucket) continue;
if (resource.type === 'k8s-node') {
bucket.nodes += 1;
tallyKubernetesChild(bucket.nodes, mapKubernetesNodeStatus(resource));
} else if (resource.type === 'agent' && resource.sources?.includes('kubernetes')) {
bucket.nodes += 1;
tallyKubernetesChild(bucket.nodes, mapKubernetesNodeStatus(resource));
} else if (resource.type === 'pod') {
bucket.pods += 1;
tallyKubernetesChild(bucket.pods, mapKubernetesPodStatus(resource));
} else if (resource.type === 'k8s-deployment') {
bucket.deployments += 1;
tallyKubernetesChild(bucket.deployments, mapKubernetesDeploymentStatus(resource));
}
}
return counts;