Tint Kubernetes node rows for their drawer's alerts

A Kubernetes node that runs a Pulse agent is an agent row, and the
agent's alerts are keyed "agent:<id>" and labelled with the hostname.
The nodes table matched only the row id and display name, so a node at
100% memory with a critical alert stayed untinted while its drawer
listed the alert. The rows now use getUnifiedResourceAlertStyles, the
set the drawer reads, and drop the tint while expanded like the Docker
host and Machines rows.

The overview's "Nodes needing attention" looked only at readiness and
cordons, so that node was missing from the list too. It now ranks each
node by the stronger of its state and its unacknowledged open alerts,
NotReady and critical alerts first, and the nodes table's default
order uses the same rank so a critical node no longer sorts below a
degraded one. An acknowledged alert drops out of both, as it does from
the tint.

vSphere host and TrueNAS system rows keep the id-plus-host-name match.
Their VMs', pools' and apps' alerts carry the host name as their node,
the TrueNAS drawer states the same pool problems, and no producer keys
alerts on those rows' "agent:" aliases (a Pulse agent on a TrueNAS box
stays a separate row). A test now pins the TrueNAS pool rollup.
This commit is contained in:
rcourtman
2026-10-06 16:43:14 +01:00
parent 3b290640a5
commit a649dd9b45
10 changed files with 426 additions and 28 deletions
@@ -4995,7 +4995,8 @@ name, so it keeps the shared single-line rhythm. Drawers for unified resources
websocket `activeAlerts` map through `getAlertsForUnifiedResource`
(`frontend-modern/src/utils/alerts.ts`), most severe first, never from a
resource-embedded list, and show each alert's resource name and metric beside
its text like the other drawers. Their table rows take the platform host rows'
its text like the other drawers. Their table rows (Docker hosts, Machines,
Kubernetes nodes) take the platform host rows'
red (critical) or yellow tint from the same set through
`getUnifiedResourceAlertStyles`, only for unacknowledged alerts and never on
an expanded row, so a row and its drawer agree. The remaining Overview rows are additive
@@ -2230,7 +2230,17 @@ may repeat beside it. The Docker hosts (`DockerHostsTable.tsx`) and Machines
`getUnifiedResourceAlertStyles`, so a collapsed row never looks healthy while
its drawer lists an unacknowledged alert (an expanded row drops the tint while
its drawer shows the alerts); acknowledged alerts stay in the drawer without
tinting the row.
tinting the row. Kubernetes node rows (`KubernetesNodesTable.tsx`) tint the
same way: a node that runs a Pulse agent is an agent row whose alerts are keyed
`agent:<id>` and carry the hostname, so the earlier id-and-display-name match
left it untinted while its drawer listed a critical alert. The Kubernetes
overview's "Nodes needing attention" and the nodes table's default order rank
a node by the stronger of its readiness state and those unacknowledged alerts
(`getKubernetesNodeAttentionRank`), so a Ready node at 100% memory is named
beside the NotReady ones. vSphere host and TrueNAS system rows keep
`getAlertStyles` with the host name: their VMs', pools' and apps' alerts carry
that name as their node, and no producer keys alerts on those rows' `agent:`
aliases (a Pulse agent on a TrueNAS box stays a separate resource).
Machine and host overview cards that render compact system, hardware, disk,
and temperature facts must also compose the frontend-primitives
`InfoCardKeyValueRow`. Mobile rows retain their condensed endpoint layout;
@@ -0,0 +1,51 @@
{
"version": 1,
"base_sha": "3b290640a51ef0f488f9cd06b4aa644adbdcc6b3",
"verified_at": "2026-10-06T15:43:08Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx",
"frontend-modern/src/features/kubernetes/kubernetesPageModel.ts"
],
"content_sha256": {
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx": "8861f6f170dd9ebab4eebe949e3a311af46916aa10d8d2d2768a33e3d64b2d1a",
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx": "9a2096f3ff967cec93024466d15c1b5117f08d89985c1955965d336a38d03bc0",
"frontend-modern/src/features/kubernetes/kubernetesPageModel.ts": "0c2f0be19aa05f8e07f1167273b26b17b0643c73855df8246fcf5f591d3ae5b8"
},
"routes": [
"/kubernetes/overview",
"/kubernetes/nodes"
],
"viewports": [
{
"width": 1440,
"height": 1000
},
{
"width": 1280,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack (backend built from base 3b290640a5 on its own port, Vite serving this tree); mock mode skips CheckHost, so the private binary added that call through a go build overlay of internal/monitoring/monitor_alerts.go that is not part of this commit",
"Kubernetes nodes: Prod Euw1 K8s 02 (agent row agent-c8db98b4db98ed5c) has a backend-raised critical alert keyed agent:host-k8s-k8s-production-1-k8s-production-1-node-2-955a86fe (node prod-euw1-k8s-02, Agent memory at 100.0%); before the change its row was untinted while its drawer listed the alert, now the row is red-50 and its drawer lists the same alert",
"Kubernetes nodes default order: K8s 02 (Ready, critical alert) first, then K8s 03 (Degraded), then K8s 01 (Ready); K8s 01 and K8s 03 untinted with no drawer alerts",
"Kubernetes overview: Nodes needing attention lists K8s 02 (red) then K8s 03 (Degraded); before the change it listed only K8s 03",
"After acknowledging the K8s 02 alert through the private stack API: its row is untinted, it drops out of Nodes needing attention, the nodes table falls back to K8s 03, K8s 01, K8s 02, and its drawer still lists the alert as Acknowledged; unacknowledged again afterwards",
"Expanded K8s 02 row drops the tint (surface hover background) while its drawer shows the alert",
"Light theme at 1440px and 390px and dark theme at 1280px: red-50 / red-950/25 on K8s 02 only, no horizontal overflow at 390px",
"TrueNAS and vSphere tables unchanged: truenas-main stays amber for its pool alerts (node truenas-main) and its drawer lists the pool problems; esxi-07 amber for its own alert",
"No page errors or console errors"
],
"interactions": [
"Open Kubernetes overview and Nodes, read each node row's tint and order",
"Expand and collapse the K8s 02 row drawer and read its Needs attention list",
"Acknowledge then unacknowledge the K8s 02 memory alert and re-read the overview list, row tint, order and drawer",
"Repeat the overview and nodes checks at the 390px phone viewport (row tap to expand) and in dark mode"
]
}
@@ -6,7 +6,7 @@ import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { TableCell, TableRow } from '@/components/shared/Table';
import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink';
import { asTrimmedString } from '@/utils/stringUtils';
import { getAlertStyles } from '@/utils/alerts';
import { getUnifiedResourceAlertStyles } from '@/utils/alerts';
import { useWebSocket } from '@/contexts/appRuntime';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { unifiedPlatformOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
@@ -41,6 +41,7 @@ import type { Resource } from '@/types/resource';
import {
compareKubernetesNodes,
filterKubernetesResources,
getKubernetesNodeAttentionRank,
kubernetesClusterLabel,
mapKubernetesNodeStatus,
type KubernetesResourceStatusFilter,
@@ -145,19 +146,27 @@ export const KubernetesNodesTable: Component<{
const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-node-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources);
// User-controlled sorting layered over the attention-first default: rows
// are pre-sorted by the status compare, so a user sort keeps that order
// for ties and the table falls straight back to it when the sort clears.
// are pre-sorted by attention (status and open alerts), then the status
// compare, so a user sort keeps that order for ties and the table falls
// straight back to it when the sort clears.
const sort = createPlatformTableSortState({
storageKey: props.sortStorageKey ?? 'kubernetesNodes',
sortKeys: KUBERNETES_NODE_SORT_KEYS,
descendingFirst: ['cpu', 'memory', 'uptime', 'capacity'],
});
const sortedRows = createMemo(() =>
sort.sortRows(
[...tableState.filtered()].sort(compareKubernetesNodes),
const sortedRows = createMemo(() => {
const rows = tableState.filtered();
const enabled = alertsEnabled();
const ranks = new Map(
rows.map((node) => [node.id, getKubernetesNodeAttentionRank(node, activeAlerts, enabled)]),
);
return sort.sortRows(
[...rows].sort(
(a, b) => (ranks.get(a.id) ?? 2) - (ranks.get(b.id) ?? 2) || compareKubernetesNodes(a, b),
),
getKubernetesNodeSortValue,
),
);
);
});
return (
<Show
@@ -367,8 +376,12 @@ export const KubernetesNodesTable: Component<{
const canRenderMetrics = () => indicator().variant !== 'muted';
const detailRowId = () => drawer.detailRowId(node);
const isExpanded = () => drawer.isExpanded(node);
// The same open alerts the node's drawer lists: a node that
// runs a Pulse agent has its alerts keyed on the agent and
// labelled with its hostname, so matching the row's id or
// display name finds none of them.
const nodeAlertStyles = createMemo(() =>
getAlertStyles(node.id, activeAlerts, alertsEnabled(), name()),
getUnifiedResourceAlertStyles(node, activeAlerts, alertsEnabled()),
);
const nodeAlertBg = () => {
const s = nodeAlertStyles();
@@ -380,7 +393,9 @@ export const KubernetesNodesTable: Component<{
return (
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs ${nodeAlertBg()}`}
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs ${
isExpanded() ? '' : nodeAlertBg()
}`}
data-kubernetes-node-row={node.id}
onClick={() => drawer.toggle(node)}
>
@@ -9,8 +9,10 @@ import {
collectOutdatedAgentHosts,
formatAgentVersionDisplay,
} from '@/features/platformPage/agentVersion';
import { useWebSocket } from '@/contexts/appRuntime';
import { useUnifiedResources, type UnifiedResourceFacets } from '@/hooks/useUnifiedResources';
import { buildKubernetesPath, KUBERNETES_QUERY_PARAMS } from '@/routing/resourceLinks';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { updateStore } from '@/stores/updates';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
@@ -710,14 +712,22 @@ function KubernetesOverview(props: KubernetesOverviewProps) {
return posture.podAttention + posture.deploymentAttention;
});
const nodesNeedingAttention = createMemo(() => getKubernetesNodesNeedingAttention(props.model()));
const { activeAlerts } = useWebSocket();
const alertsActivation = useAlertsActivation();
const nodesNeedingAttention = createMemo(() =>
getKubernetesNodesNeedingAttention(
props.model(),
activeAlerts,
alertsActivation.detectionEnabled(),
),
);
return (
<div class="space-y-4">
{/*
Problems lead, inventory follows: active health signals, then the
nodes that are not Ready, before the cluster summary and workloads.
Filtering stays on the Nodes and Events tabs.
nodes that are not Ready or have an open alert, before the cluster
summary and workloads. Filtering stays on the Nodes and Events tabs.
*/}
<Show when={props.model().incidents.length > 0}>
<div id="kubernetes-health-signals">
@@ -738,7 +748,7 @@ function KubernetesOverview(props: KubernetesOverviewProps) {
sortStorageKey="kubernetesNodesNeedingAttention"
emptyIcon={k8sIcon()}
emptyTitle="All nodes are Ready"
emptyDescription="Nodes appear here when they are not Ready, cordoned or degraded."
emptyDescription="Nodes appear here when they are not Ready, cordoned, degraded or alerting."
showToolbar={false}
/>
</div>
@@ -1,6 +1,8 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { createStore, reconcile } from 'solid-js/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Alert } from '@/types/api';
import type { Resource } from '@/types/resource';
import { KubernetesNodesTable } from '../KubernetesNodesTable';
@@ -12,8 +14,11 @@ vi.mock('@/components/Workloads/StackedMemoryBar', () => ({
StackedMemoryBar: () => <div data-testid="stacked-memory-bar" />,
}));
// The websocket's activeAlerts is a Solid store, so rows must follow it live.
const activeAlertsRef = vi.hoisted(() => ({ current: {} as Record<string, Alert> }));
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({ activeAlerts: [] }),
useWebSocket: () => ({ activeAlerts: activeAlertsRef.current }),
}));
vi.mock('@/stores/alertsActivation', () => ({
@@ -51,8 +56,12 @@ const makeNodeResource = (overrides: Partial<Resource> = {}): Resource => ({
...overrides,
});
const [activeAlerts, setActiveAlerts] = createStore<Record<string, Alert>>({});
activeAlertsRef.current = activeAlerts;
afterEach(() => {
cleanup();
setActiveAlerts(reconcile({}));
vi.clearAllMocks();
});
@@ -146,4 +155,79 @@ describe('KubernetesNodesTable', () => {
'false',
);
});
it('tints a node row for the open alerts its drawer lists and leads with it', () => {
const agentAlert = (id: string, resourceId: string, overrides: Partial<Alert> = {}): Alert => ({
id,
type: 'memory',
level: 'critical',
resourceId,
resourceName: 'Prod Euw1 K8s 02',
node: 'prod-euw1-k8s-02',
instance: '',
message: 'Agent memory at 100.0%',
value: 100,
threshold: 90,
startTime: '2026-10-06T10:00:00Z',
acknowledged: false,
...overrides,
});
// A node that runs a Pulse agent is an agent row; its alerts are keyed
// "agent:<agentId>" and carry the hostname, never the row id or name.
const agentNode = (id: string, name: string, agentId: string, ready = true) =>
makeNodeResource({
id,
name,
displayName: name,
type: 'agent',
agent: { agentId },
kubernetes: { ...makeNodeResource().kubernetes, nodeName: id, ready },
} as Partial<Resource>);
setActiveAlerts({
memory: agentAlert('memory', 'agent:host-k8s-node-2'),
});
const { container } = render(() => (
<KubernetesNodesTable
resources={[
agentNode('node-1', 'Prod Euw1 K8s 01', 'host-k8s-node-1'),
agentNode('node-2', 'Prod Euw1 K8s 02', 'host-k8s-node-2'),
makeNodeResource({
id: 'node-3',
name: 'Prod Euw1 K8s 03',
kubernetes: { ...makeNodeResource().kubernetes, unschedulable: true },
}),
]}
emptyIcon={<span />}
emptyTitle="No nodes"
emptyDescription="No nodes"
showToolbar={false}
/>
));
const row = (id: string) =>
container.querySelector(`[data-kubernetes-node-row="${id}"]`) as HTMLElement;
const order = () =>
[...container.querySelectorAll('[data-kubernetes-node-row]')].map((element) =>
element.getAttribute('data-kubernetes-node-row'),
);
expect(row('node-2')).toHaveClass('bg-red-50');
expect(row('node-1')).not.toHaveClass('bg-red-50');
// A Ready node with a critical alert leads, ahead of a cordoned node.
expect(order()).toEqual(['node-2', 'node-3', 'node-1']);
// The tint and the order follow the live alert store: acknowledging
// clears both, and a new warning tints the row amber.
setActiveAlerts('memory', 'acknowledged', true);
expect(row('node-2')).not.toHaveClass('bg-red-50');
expect(order()).toEqual(['node-3', 'node-1', 'node-2']);
setActiveAlerts('memory', { acknowledged: false, level: 'warning' });
expect(row('node-2')).toHaveClass('bg-yellow-50');
// An expanded row drops the tint while its drawer lists the alert.
fireEvent.click(row('node-2'));
expect(row('node-2')).not.toHaveClass('bg-yellow-50');
fireEvent.click(row('node-2'));
expect(row('node-2')).toHaveClass('bg-yellow-50');
setActiveAlerts(reconcile({}));
expect(row('node-2')).not.toHaveClass('bg-yellow-50');
});
});
@@ -1,12 +1,15 @@
import { Route, Router } from '@solidjs/router';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { createStore, reconcile } from 'solid-js/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Alert } from '@/types/api';
import type { Resource } from '@/types/resource';
import { KubernetesPageSurface } from '../KubernetesPageSurface';
const mockUseUnifiedResources = vi.fn();
const mockPathname = vi.hoisted(() => vi.fn(() => '/'));
const mockVersionInfo = vi.hoisted(() => vi.fn());
const activeAlertsRef = vi.hoisted(() => ({ current: {} as Record<string, Alert> }));
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource => ({
name: resource.id,
@@ -40,6 +43,14 @@ vi.mock('@/hooks/useUnifiedResources', () => ({
useUnifiedResources: (...args: unknown[]) => mockUseUnifiedResources(...args),
}));
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({ activeAlerts: activeAlertsRef.current }),
}));
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({ detectionEnabled: () => true }),
}));
vi.mock('@/stores/updates', () => ({
updateStore: {
versionInfo: mockVersionInfo,
@@ -129,6 +140,7 @@ vi.mock('../KubernetesNodesTable', () => ({
<div
data-testid="nodes-table"
data-rows={props.resources.length}
data-ids={props.resources.map((resource) => resource.id).join(',')}
data-title={props.title}
data-sort-key={props.sortStorageKey}
/>
@@ -159,6 +171,9 @@ vi.mock('../KubernetesStorageTable', () => ({
),
}));
const [activeAlerts, setActiveAlerts] = createStore<Record<string, Alert>>({});
activeAlertsRef.current = activeAlerts;
describe('KubernetesPageSurface contract', () => {
beforeEach(() => {
mockPathname.mockReturnValue('/');
@@ -167,6 +182,7 @@ describe('KubernetesPageSurface contract', () => {
afterEach(() => {
cleanup();
setActiveAlerts(reconcile({}));
vi.clearAllMocks();
});
@@ -207,6 +223,58 @@ describe('KubernetesPageSurface contract', () => {
).toBeTruthy();
});
it('names a Ready node with an open alert among the nodes needing attention', () => {
setResources([
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
makeResource({
id: 'node-memory',
type: 'agent',
sources: ['agent', 'kubernetes'],
agent: { agentId: 'host-k8s-02' },
kubernetes: { ready: true } as Resource['kubernetes'],
} as Partial<Resource> & Pick<Resource, 'id' | 'type'>),
makeResource({
id: 'node-ok',
type: 'k8s-node',
kubernetes: { ready: true } as Resource['kubernetes'],
}),
]);
renderSurface();
// Every node is Ready, so nothing needs attention until an alert opens.
expect(screen.queryByTestId('nodes-table')).toBeNull();
// The node's agent raises it: keyed on the agent, labelled with the hostname.
setActiveAlerts({
memory: {
id: 'memory',
type: 'memory',
level: 'critical',
resourceId: 'agent:host-k8s-02',
resourceName: 'Prod Euw1 K8s 02',
node: 'prod-euw1-k8s-02',
instance: '',
message: 'Agent memory at 100.0%',
value: 100,
threshold: 90,
startTime: '2026-10-06T10:00:00Z',
acknowledged: false,
},
});
const attention = screen.getByTestId('nodes-table');
expect(attention).toHaveAttribute('data-title', 'Nodes needing attention');
expect(attention).toHaveAttribute('data-ids', 'node-memory');
// Acknowledged: someone owns it, and the row is no longer tinted either.
setActiveAlerts('memory', 'acknowledged', true);
expect(screen.queryByTestId('nodes-table')).toBeNull();
setActiveAlerts('memory', 'acknowledged', false);
expect(screen.getByTestId('nodes-table')).toHaveAttribute('data-ids', 'node-memory');
// Resolved: the alert leaves the store and the node leaves the list.
setActiveAlerts(reconcile({}));
expect(screen.queryByTestId('nodes-table')).toBeNull();
});
it('shows no attention table when every node is Ready', () => {
setResources([
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import type { Alert } from '@/types/api';
import type { Resource } from '@/types/resource';
import {
getKubernetesNodeAttentionRank,
getKubernetesNodesNeedingAttention,
splitKubernetesNameTail,
isSingleKubernetesCluster,
@@ -1178,4 +1180,91 @@ describe('getKubernetesNodesNeedingAttention', () => {
expect(result.map((entry: { id: string }) => entry.id)).toEqual(['a-down', 'c-cordoned']);
});
// A node running a Pulse agent is an agent row whose alerts are keyed
// "agent:<agentId>" and labelled with its hostname.
const agentNode = (id: string, agentId: string, kubernetes: Record<string, unknown>) =>
({
id,
name: id,
type: 'agent',
status: 'online',
agent: { agentId },
kubernetes,
}) as never;
const alert = (id: string, resourceId: string, overrides: Partial<Alert> = {}): Alert => ({
id,
type: 'memory',
level: 'critical',
resourceId,
resourceName: id,
node: id,
instance: '',
message: id,
value: 100,
threshold: 90,
startTime: '2026-10-06T10:00:00Z',
acknowledged: false,
...overrides,
});
it('names Ready nodes with an unacknowledged open alert, critical ones with NotReady', () => {
const nodes = [
agentNode('a-ready', 'host-a', { ready: true }),
agentNode('b-memory', 'host-b', { ready: true }),
agentNode('c-cordoned', 'host-c', { ready: true, unschedulable: true }),
agentNode('d-down', 'host-d', { ready: false }),
agentNode('e-disk', 'host-e', { ready: true }),
agentNode('f-acked', 'host-f', { ready: true }),
];
const activeAlerts = {
memory: alert('memory', 'agent:host-b'),
// A component alert the drawer lists counts for its node too.
disk: alert('disk', 'agent:host-e/disk:root', { type: 'disk', level: 'warning' }),
acked: alert('acked', 'agent:host-f', { acknowledged: true }),
};
const result = getKubernetesNodesNeedingAttention({ nodes } as never, activeAlerts, true);
// Within a rank, node state orders: NotReady ahead of a Ready node.
expect(result.map((entry: { id: string }) => entry.id)).toEqual([
'd-down',
'b-memory',
'c-cordoned',
'e-disk',
]);
// With alert detection off the list falls back to node state alone.
expect(
getKubernetesNodesNeedingAttention({ nodes } as never, activeAlerts, false).map(
(entry: { id: string }) => entry.id,
),
).toEqual(['d-down', 'c-cordoned']);
});
it('counts alerts keyed on a node without an agent by its own id', () => {
// Unified Kubernetes metric and incident alerts carry the resource id and
// the cluster as their node.
const nodes = [node('k8s-node-a', { ready: true }), node('k8s-node-b', { ready: true })];
const activeAlerts = {
cpu: alert('cpu', 'k8s-node-b', { type: 'cpu', level: 'warning', node: 'prod-west' }),
};
expect(
getKubernetesNodesNeedingAttention({ nodes } as never, activeAlerts, true).map(
(entry: { id: string }) => entry.id,
),
).toEqual(['k8s-node-b']);
});
it('ranks a node by the stronger of its state and its open alerts', () => {
const ready = agentNode('ready', 'host-ready', { ready: true });
const cordoned = agentNode('cordoned', 'host-cordoned', { ready: true, unschedulable: true });
const warning = { w: alert('w', 'agent:host-ready', { level: 'warning' }) };
const critical = { c: alert('c', 'agent:host-cordoned') };
expect(getKubernetesNodeAttentionRank(ready, {}, true)).toBe(2);
expect(getKubernetesNodeAttentionRank(ready, warning, true)).toBe(1);
expect(getKubernetesNodeAttentionRank(cordoned, {}, true)).toBe(1);
expect(getKubernetesNodeAttentionRank(cordoned, critical, true)).toBe(0);
});
});
@@ -4,7 +4,9 @@ import type {
ResourceKubernetesPodContainerStatus,
ResourceType,
} from '@/types/resource';
import type { Alert } from '@/types/api';
import type { StatusIndicator, StatusIndicatorVariant } from '@/utils/status';
import { getUnifiedResourceAlertStyles } from '@/utils/alerts';
import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
import { matchesSearchTermSplit, splitSearchExclusions } from '@/utils/searchQuery';
@@ -801,17 +803,44 @@ const countKubernetesDanger = (
mapper: (resource: Resource) => StatusIndicator,
): number => resources.filter((resource) => mapper(resource).variant === 'danger').length;
// Nodes the overview should name before any inventory: NotReady first, then
// cordoned or degraded ones. "2/3 nodes" on the cluster row says something is
// wrong but not which node; this list answers that.
export function getKubernetesNodesNeedingAttention(model: KubernetesPageModel): Resource[] {
const rank = (node: Resource) => {
const variant = mapKubernetesNodeStatus(node).variant;
return variant === 'danger' ? 0 : variant === 'warning' ? 1 : 2;
};
// Attention order shared by the overview's "Nodes needing attention" list and
// the nodes table's default sort: 0 for a NotReady node or one with an
// unacknowledged critical alert, 1 for a cordoned or degraded node or one with
// another unacknowledged alert, 2 otherwise. The alerts are the ones the
// node's row tint and drawer use, so a Ready node at 100% memory ranks with
// the NotReady ones instead of below every degraded node.
export function getKubernetesNodeAttentionRank(
node: Resource,
activeAlerts: Record<string, Alert>,
alertsEnabled: boolean,
): 0 | 1 | 2 {
const variant = mapKubernetesNodeStatus(node).variant;
const statusRank = variant === 'danger' ? 0 : variant === 'warning' ? 1 : 2;
const alertStyles = getUnifiedResourceAlertStyles(node, activeAlerts, alertsEnabled);
const alertRank = !alertStyles.hasUnacknowledgedAlert
? 2
: alertStyles.severity === 'critical'
? 0
: 1;
return Math.min(statusRank, alertRank) as 0 | 1 | 2;
}
// Nodes the overview should name before any inventory, in attention order.
// "2/3 nodes" on the cluster row says something is wrong but not which node;
// this list answers that.
export function getKubernetesNodesNeedingAttention(
model: KubernetesPageModel,
activeAlerts: Record<string, Alert> = {},
alertsEnabled = false,
): Resource[] {
return model.nodes
.filter((node) => rank(node) < 2)
.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
.map((node) => ({
node,
rank: getKubernetesNodeAttentionRank(node, activeAlerts, alertsEnabled),
}))
.filter((entry) => entry.rank < 2)
.sort((a, b) => a.rank - b.rank || compareKubernetesNodes(a.node, b.node))
.map((entry) => entry.node);
}
export function buildKubernetesOverviewPosture(
@@ -9,8 +9,10 @@ vi.mock('@/components/shared/responsive', () => ({
ResponsiveMetricCell: () => <div data-testid="responsive-metric-cell" />,
}));
const activeAlertsRef = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({ activeAlerts: {} as Record<string, never> }),
useWebSocket: () => ({ activeAlerts: activeAlertsRef.current }),
}));
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({
@@ -43,6 +45,7 @@ const makeSystem = (overrides: Partial<Resource> & Pick<Resource, 'id'>): Resour
afterEach(() => {
cleanup();
activeAlertsRef.current = {};
});
describe('TrueNASSystemsTable', () => {
@@ -107,4 +110,42 @@ describe('TrueNASSystemsTable', () => {
expect(container.querySelector('[data-truenas-system-row="truenas-healthy"]')).toBeNull();
expect(screen.getByText('1 of 2 systems')).toBeInTheDocument();
});
it("tints a system row for its pools' alerts, which carry the system hostname", () => {
// TrueNAS pool, dataset, disk and app alerts are keyed on the child
// resource and name the system in "node". The system's drawer states the
// same pool problem through its health issue, so the row matches by
// hostname as well as id; no producer keys a TrueNAS system's alerts on
// its "agent:" alias.
activeAlertsRef.current = {
pool: {
id: 'pool',
type: 'truenas-pool-health',
level: 'warning',
resourceId: 'storage-archive',
resourceName: 'archive',
node: 'truenas-main',
instance: 'TrueNAS',
message: 'Pool archive is DEGRADED',
value: 0,
threshold: 0,
startTime: '2026-10-06T10:00:00Z',
acknowledged: false,
},
};
const { container } = render(() => (
<TrueNASSystemsTable
systems={[makeSystem({ id: 'truenas-main' }), makeSystem({ id: 'truenas-backup' })]}
scope={[makeSystem({ id: 'truenas-main' }), makeSystem({ id: 'truenas-backup' })]}
emptyIcon={<span />}
emptyTitle="No systems"
emptyDescription="No systems"
showToolbar={false}
/>
));
const row = (id: string) => container.querySelector(`[data-truenas-system-row="${id}"]`);
expect(row('truenas-main')).toHaveClass('bg-yellow-50');
expect(row('truenas-backup')).not.toHaveClass('bg-yellow-50');
});
});