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.
This commit is contained in:
rcourtman
2026-06-11 18:40:07 +01:00
parent 8ce0f4a872
commit a61b44f46c
7 changed files with 87 additions and 13 deletions
@@ -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
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[8%]`}>
Phase
Status
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[7%]`}
@@ -207,7 +204,6 @@ export const KubernetesPodsTable: Component<{
const name = () => 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<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{phase()}
{/* The mapped label carries the container failure reason
(CrashLoopBackOff, ImagePullBackOff, ...) where the raw
phase would still read "Running". */}
<span class="block max-w-full truncate" title={indicator().label}>
{indicator().label}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
@@ -73,7 +73,7 @@ describe('KubernetesPodsTable', () => {
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);
});
});
@@ -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', () => {
@@ -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<string>(['online', 'running']);
const DEGRADED_RESOURCE_STATUSES = new Set<string>(['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<string>(['degraded', 'paused', 'warning']);
const OFFLINE_RESOURCE_STATUSES = new Set<string>(['offline', 'stopped']);
const mapResourceStatusToTriad = (
@@ -135,6 +135,7 @@ describe('useResources - Resource Filtering Logic', () => {
running: 0,
stopped: 0,
degraded: 0,
warning: 0,
paused: 0,
unknown: 0,
};
@@ -143,6 +143,7 @@ export function useResources(storeOverride?: ResourceStoreLike): UseResourcesRet
running: 0,
stopped: 0,
degraded: 0,
warning: 0,
paused: 0,
unknown: 0,
};
+5 -1
View File
@@ -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;