From a61b44f46c1936567d967da7954bb4116f3d6cbf Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Jun 2026 18:40:07 +0100 Subject: [PATCH] kubernetes(status): make degraded k8s states visible and filterable The canonical Kubernetes adapter reports every unhealthy-but-alive state (cordoned node, not-ready container, pending pod, partially available controller) as `warning`, but the platform page's filter triad only accepted degraded/paused, so those rows matched none of the Healthy / Degraded / Offline chips and were findable only under All. Flagged by the v5-parity audit: v5's Unhealthy filter classified all of these. - Accept `warning` into the page's degraded filter bucket. - Declare `unschedulable` on ResourceKubernetesMeta (the adapter has projected it all along) and map ready-but-cordoned nodes to an amber Unschedulable indicator instead of green Ready. NotReady still wins. - Render the mapped status label in the pods table status column. The raw phase reads Running while a container is in CrashLoopBackOff; the reason was previously visible only as the dot's hover title. - Add `warning` to the frontend ResourceStatus union to match what the backend emits. --- .../kubernetes/KubernetesPodsTable.tsx | 13 ++--- .../__tests__/KubernetesPodsTable.test.tsx | 21 +++++++-- .../__tests__/kubernetesPageModel.test.ts | 47 +++++++++++++++++++ .../kubernetes/kubernetesPageModel.ts | 11 ++++- .../src/hooks/__tests__/useResources.test.ts | 1 + frontend-modern/src/hooks/useResources.ts | 1 + frontend-modern/src/types/resource.ts | 6 ++- 7 files changed, 87 insertions(+), 13 deletions(-) diff --git a/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx index 9b6776772b..213196196d 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx @@ -53,9 +53,6 @@ const podScope = (resource: Resource): string => { return cluster || 'Cluster'; }; -const podPhase = (resource: Resource): string => - textValue(resource.kubernetes?.podPhase || resource.kubernetes?.phase); - const podContainers = (resource: Resource): ResourceKubernetesPodContainerStatus[] => resource.kubernetes?.podContainers ?? []; @@ -171,7 +168,7 @@ export const KubernetesPodsTable: Component<{ Node - Phase + Status podName(resource); const scope = () => podScope(resource); const node = () => textValue(resource.kubernetes?.nodeName); - const phase = () => podPhase(resource); const owner = () => ownerValue(resource); const image = () => imageValue(resource); const age = () => ageValue(resource); @@ -255,7 +251,12 @@ export const KubernetesPodsTable: Component<{ - {phase()} + {/* The mapped label carries the container failure reason + (CrashLoopBackOff, ImagePullBackOff, ...) where the raw + phase would still read "Running". */} + + {indicator().label} + { expect(screen.getByText('Pod')).toBeInTheDocument(); expect(screen.getByText('Scope')).toBeInTheDocument(); expect(screen.getByText('Node')).toBeInTheDocument(); - expect(screen.getByText('Phase')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); expect(screen.getByText('Ready')).toBeInTheDocument(); expect(screen.getByText('Restarts')).toBeInTheDocument(); expect(screen.getByText('Owner')).toBeInTheDocument(); @@ -83,7 +83,9 @@ describe('KubernetesPodsTable', () => { expect(screen.getByText('checkout-api-6c746d5bcf-c7z2p')).toBeInTheDocument(); expect(screen.getByText('prod-euw1/services')).toBeInTheDocument(); expect(screen.getByText('prod-euw1-k8s-02')).toBeInTheDocument(); - expect(screen.getByText('Running')).toBeInTheDocument(); + // Raw phase is Running, but the metrics-sidecar container is not ready; + // the status column shows the mapped label, not the phase. + expect(screen.getByText('Not ready')).toBeInTheDocument(); expect(screen.getByText('1/2')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); expect(screen.getByText('Deployment/checkout-api')).toBeInTheDocument(); @@ -136,8 +138,17 @@ describe('KubernetesPodsTable', () => { (row) => row.getAttribute('data-kubernetes-pod-row'), ); expect(rows).toEqual(['crashing-pod', 'not-ready-pod', 'happy-pod']); - expect(screen.getByTitle('CrashLoopBackOff')).toHaveClass('bg-red-500'); - expect(screen.getByTitle('Not ready')).toHaveClass('bg-amber-500'); - expect(screen.getByTitle('Running')).toHaveClass('bg-emerald-500'); + // The failure reason is visible cell text, not just a dot tooltip. + expect(screen.getByText('CrashLoopBackOff')).toBeInTheDocument(); + expect(screen.getByText('Not ready')).toBeInTheDocument(); + expect( + screen.getAllByTitle('CrashLoopBackOff').some((el) => el.classList.contains('bg-red-500')), + ).toBe(true); + expect( + screen.getAllByTitle('Not ready').some((el) => el.classList.contains('bg-amber-500')), + ).toBe(true); + expect( + screen.getAllByTitle('Running').some((el) => el.classList.contains('bg-emerald-500')), + ).toBe(true); }); }); diff --git a/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts b/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts index dc508f675d..ddced6bb87 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts +++ b/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts @@ -365,6 +365,32 @@ describe('kubernetesPageModel', () => { ).toEqual({ variant: 'success', label: 'Ready' }); }); + it('returns warning for a cordoned node even though it reports ready', () => { + expect( + mapKubernetesNodeStatus( + makeResource({ + id: 'cordoned', + type: 'k8s-node', + status: 'warning', + kubernetes: { ready: true, unschedulable: true }, + }), + ), + ).toEqual({ variant: 'warning', label: 'Unschedulable' }); + }); + + it('keeps NotReady as the dominant state when a cordoned node is also down', () => { + expect( + mapKubernetesNodeStatus( + makeResource({ + id: 'cordoned-down', + type: 'k8s-node', + status: 'offline', + kubernetes: { ready: false, unschedulable: true }, + }), + ), + ).toEqual({ variant: 'danger', label: 'NotReady' }); + }); + it('falls back to resource.status when ready is undefined', () => { expect( mapKubernetesNodeStatus(makeResource({ id: 'fallback-ok', type: 'k8s-node', status: 'online' })), @@ -758,6 +784,27 @@ describe('kubernetesPageModel', () => { ]); expect(filterKubernetesResources(rows, 'v1.30', 'online')).toEqual([]); }); + + it('buckets the backend `warning` status into the degraded filter', () => { + // The Kubernetes adapter emits `warning` (never `degraded`) for + // unhealthy-but-alive rows; the Degraded chip must match them. + const warningRows: Resource[] = [ + makeResource({ + id: 'pod-crashloop', + type: 'pod', + status: 'warning', + kubernetes: { namespace: 'payments', podName: 'checkout-api-abc' }, + }), + makeResource({ id: 'pod-healthy', type: 'pod', status: 'online' }), + ]; + expect(filterKubernetesResources(warningRows, '', 'degraded').map((r) => r.id)).toEqual([ + 'pod-crashloop', + ]); + expect(filterKubernetesResources(warningRows, '', 'online').map((r) => r.id)).toEqual([ + 'pod-healthy', + ]); + expect(filterKubernetesResources(warningRows, '', 'offline')).toEqual([]); + }); }); describe('mapKubernetesIncidentSeverity', () => { diff --git a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts index e259521912..c76f27dbf2 100644 --- a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts +++ b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts @@ -132,6 +132,11 @@ export function mapKubernetesPodStatus(resource: Resource): StatusIndicator { export function mapKubernetesNodeStatus(resource: Resource): StatusIndicator { const ready = resource.kubernetes?.ready; if (ready === false) return { variant: 'danger', label: 'NotReady' }; + // A cordoned node still reports ready=true; check unschedulable before + // declaring the row healthy or the cordon is invisible on the page. + if (resource.kubernetes?.unschedulable === true) { + return { variant: 'warning', label: 'Unschedulable' }; + } if (ready === true) return { variant: 'success', label: 'Ready' }; const status = normalizeKubernetesToken(resource.status); @@ -438,7 +443,11 @@ export function filterKubernetesIncidents( export type KubernetesResourceStatusFilter = 'all' | 'online' | 'degraded' | 'offline'; const ONLINE_RESOURCE_STATUSES = new Set(['online', 'running']); -const DEGRADED_RESOURCE_STATUSES = new Set(['degraded', 'paused']); +// The canonical Kubernetes adapter reports every unhealthy-but-alive state +// (cordoned node, not-ready container, pending pod, partially available +// controller) as `warning`, so the degraded bucket must accept it or those +// rows match none of the Healthy / Degraded / Offline filter chips. +const DEGRADED_RESOURCE_STATUSES = new Set(['degraded', 'paused', 'warning']); const OFFLINE_RESOURCE_STATUSES = new Set(['offline', 'stopped']); const mapResourceStatusToTriad = ( diff --git a/frontend-modern/src/hooks/__tests__/useResources.test.ts b/frontend-modern/src/hooks/__tests__/useResources.test.ts index c91386007e..bbf84ea332 100644 --- a/frontend-modern/src/hooks/__tests__/useResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useResources.test.ts @@ -135,6 +135,7 @@ describe('useResources - Resource Filtering Logic', () => { running: 0, stopped: 0, degraded: 0, + warning: 0, paused: 0, unknown: 0, }; diff --git a/frontend-modern/src/hooks/useResources.ts b/frontend-modern/src/hooks/useResources.ts index 922a3875af..6e3bfb7219 100644 --- a/frontend-modern/src/hooks/useResources.ts +++ b/frontend-modern/src/hooks/useResources.ts @@ -143,6 +143,7 @@ export function useResources(storeOverride?: ResourceStoreLike): UseResourcesRet running: 0, stopped: 0, degraded: 0, + warning: 0, paused: 0, unknown: 0, }; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index c340d2e799..bcc091082d 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -95,13 +95,16 @@ export type SourceType = | 'agent' // Data pushed from agent | 'hybrid'; // Both sources, agent preferred -// Resource status - operational state +// Resource status - operational state. `warning` is what the canonical +// Kubernetes adapter emits for unhealthy-but-alive rows (cordoned nodes, +// not-ready containers, pending pods, partially available controllers). export type ResourceStatus = | 'online' | 'offline' | 'running' | 'stopped' | 'degraded' + | 'warning' | 'paused' | 'unknown'; @@ -1108,6 +1111,7 @@ export interface ResourceKubernetesMeta { kernelVersion?: string; roles?: string[]; ready?: boolean; + unschedulable?: boolean; capacityCpuCores?: number; allocatableCpuCores?: number; capacityMemoryBytes?: number;