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)}
- {counts().pods}
+ {childCountCell(counts().pods)}
- {counts().deployments}
+ {childCountCell(counts().deployments)}
{
];
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;