diff --git a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx index eaa4122d9b..e3f9eb3dfc 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx @@ -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 = () => ( ); +// 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) => ( + <> + 0 ? 'text-amber-700 dark:text-amber-300' : ''}> + {count.total - count.attention} + + /{count.total} + +); + 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<{ - {counts().nodes} + {childCountCell(counts().nodes)} { ]; 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', () => { diff --git a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts index a28e578b22..3145271c9d 100644 --- a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts +++ b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts @@ -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;