diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index db7c72aded..a98a3b15eb 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 9a954da020..b459d393da 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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:` 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; diff --git a/frontend-modern/browser-verification/83848f2d5ef2a326.json b/frontend-modern/browser-verification/83848f2d5ef2a326.json new file mode 100644 index 0000000000..edffd9048a --- /dev/null +++ b/frontend-modern/browser-verification/83848f2d5ef2a326.json @@ -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" + ] +} diff --git a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx index 0d939ae8c9..dae497764b 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx @@ -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 ( 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 ( <> drawer.toggle(node)} > diff --git a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx index 311aa3f67b..be03791131 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx @@ -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 (
{/* 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. */} 0}>
@@ -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} />
diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesNodesTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesNodesTable.test.tsx index bdc81eba19..59791206c1 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesNodesTable.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesNodesTable.test.tsx @@ -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: () =>
, })); +// The websocket's activeAlerts is a Solid store, so rows must follow it live. +const activeAlertsRef = vi.hoisted(() => ({ current: {} as Record })); + vi.mock('@/contexts/appRuntime', () => ({ - useWebSocket: () => ({ activeAlerts: [] }), + useWebSocket: () => ({ activeAlerts: activeAlertsRef.current }), })); vi.mock('@/stores/alertsActivation', () => ({ @@ -51,8 +56,12 @@ const makeNodeResource = (overrides: Partial = {}): Resource => ({ ...overrides, }); +const [activeAlerts, setActiveAlerts] = createStore>({}); +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 => ({ + 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:" 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); + setActiveAlerts({ + memory: agentAlert('memory', 'agent:host-k8s-node-2'), + }); + + const { container } = render(() => ( + } + 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'); + }); }); diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx index 44bb874f08..8badbd48f1 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx @@ -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 })); const makeResource = (resource: Partial & Pick): 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', () => ({
resource.id).join(',')} data-title={props.title} data-sort-key={props.sortStorageKey} /> @@ -159,6 +171,9 @@ vi.mock('../KubernetesStorageTable', () => ({ ), })); +const [activeAlerts, setActiveAlerts] = createStore>({}); +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 & Pick), + 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' }), diff --git a/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts b/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts index 8511c16e62..0079bef0d3 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts +++ b/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts @@ -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:" and labelled with its hostname. + const agentNode = (id: string, agentId: string, kubernetes: Record) => + ({ + id, + name: id, + type: 'agent', + status: 'online', + agent: { agentId }, + kubernetes, + }) as never; + const alert = (id: string, resourceId: string, overrides: Partial = {}): 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); + }); }); diff --git a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts index 14cb794a50..d205da31b7 100644 --- a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts +++ b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts @@ -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, + 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 = {}, + 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( diff --git a/frontend-modern/src/features/truenas/__tests__/TrueNASSystemsTable.test.tsx b/frontend-modern/src/features/truenas/__tests__/TrueNASSystemsTable.test.tsx index 401b95b28e..26249d44c4 100644 --- a/frontend-modern/src/features/truenas/__tests__/TrueNASSystemsTable.test.tsx +++ b/frontend-modern/src/features/truenas/__tests__/TrueNASSystemsTable.test.tsx @@ -9,8 +9,10 @@ vi.mock('@/components/shared/responsive', () => ({ ResponsiveMetricCell: () =>
, })); +const activeAlertsRef = vi.hoisted(() => ({ current: {} as Record })); + vi.mock('@/contexts/appRuntime', () => ({ - useWebSocket: () => ({ activeAlerts: {} as Record }), + useWebSocket: () => ({ activeAlerts: activeAlertsRef.current }), })); vi.mock('@/stores/alertsActivation', () => ({ useAlertsActivation: () => ({ @@ -43,6 +45,7 @@ const makeSystem = (overrides: Partial & Pick): 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(() => ( + } + 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'); + }); });