From ffd50a0052f85f44c52d6efce71629b0ce449e94 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 7 Jul 2026 10:00:33 +0100 Subject: [PATCH] Complete threshold-aware temperature displays Refs #1534 --- .../components/Workloads/NodeDrawer.test.tsx | 21 ++++++++ .../src/components/Workloads/NodeDrawer.tsx | 20 +++++++- .../Workloads/NodeDrawerOverview.tsx | 23 ++++++--- .../components/Workloads/WorkloadPanel.tsx | 12 ++++- .../components/Workloads/WorkloadsSurface.tsx | 15 +++--- .../components/Workloads/WorkloadsTable.tsx | 2 + .../components/Workloads/useWorkloadsState.ts | 5 ++ .../src/components/shared/NodeGroupHeader.tsx | 7 ++- .../components/shared/TemperatureGauge.tsx | 9 +++- .../shared/__tests__/NodeGroupHeader.test.tsx | 17 +++++++ .../__tests__/alertOverridesModel.test.ts | 20 ++++++++ .../features/alerts/alertOverridesModel.ts | 14 +++++- .../docker/DockerHostDrawerOverview.tsx | 12 ++++- .../__tests__/DockerHostsTable.test.tsx | 24 +++++++++- .../features/proxmox/ProxmoxNodesTable.tsx | 1 + .../__tests__/ProxmoxNodesTable.test.tsx | 11 +++-- .../standalone/AgentsMachinesTable.tsx | 18 ++++++- .../__tests__/agentMachineTableModel.test.ts | 48 +++++++++---------- .../standalone/agentMachineTableModel.ts | 15 ++++++ .../temperatureDisplayGuardrails.test.ts | 28 +++++++++++ frontend-modern/src/utils/temperature.ts | 5 +- 21 files changed, 274 insertions(+), 53 deletions(-) create mode 100644 frontend-modern/src/utils/__tests__/temperatureDisplayGuardrails.test.ts diff --git a/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx b/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx index fb317f84c..6830f1139 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx @@ -113,6 +113,27 @@ describe('NodeDrawer', () => { expect(screen.getByText('Temp monitor')).toBeInTheDocument(); }); + it('colors overview thermal rows from configured thresholds', () => { + render(() => ( + + )); + + expect(screen.getAllByText('76°C')[0]).toHaveClass('text-green-600'); + }); + it('renders node-only thermal history without requiring a table temperature column', async () => { render(() => ); diff --git a/frontend-modern/src/components/Workloads/NodeDrawer.tsx b/frontend-modern/src/components/Workloads/NodeDrawer.tsx index 2cdde508c..e2e27f2e2 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawer.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawer.tsx @@ -5,7 +5,10 @@ import { DiscoveryTab } from '@/components/Discovery/DiscoveryTab'; import { DiscoveryLoadingFallback } from '@/components/shared/DiscoveryLoadingFallback'; import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading'; import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs'; +import { nodeOverrideIdCandidates } from '@/features/alerts/alertOverridesModel'; +import { useAlertsActivation } from '@/stores/alertsActivation'; import type { Disk, Node } from '@/types/api'; +import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; import { getNodeDisplayName } from '@/utils/nodes'; import { getSimpleStatusIndicator } from '@/utils/status'; @@ -27,6 +30,7 @@ interface NodeDrawerProps { node: Node; disks?: Disk[]; discoveryTarget?: NodeDrawerDiscoveryTarget; + temperatureThresholds?: MetricDisplayThresholds | null; } type NodeDrawerTab = 'overview' | 'history' | 'discovery'; @@ -36,12 +40,22 @@ export const NodeDrawer: Component = (props) => { const [historyRange, setHistoryRange] = createSignal( GUEST_DRAWER_HISTORY_DEFAULT_RANGE, ); + const alertsActivation = useAlertsActivation(); const headingId = () => `node-drawer-heading-${props.node.id}`; const displayName = createMemo(() => getNodeDisplayName(props.node)); const historyTarget = createMemo(() => getNodeDrawerHistoryTarget(props.node)); const fallbackMetrics = createMemo(() => getNodeDrawerHistoryFallbackMetrics(props.node)); const headerIndicator = createMemo(() => getSimpleStatusIndicator(props.node.status)); + const temperatureThresholds = createMemo(() => + props.temperatureThresholds !== undefined + ? props.temperatureThresholds + : alertsActivation.getMetricThresholds( + 'node', + 'temperature', + nodeOverrideIdCandidates(props.node), + ), + ); return (
@@ -74,7 +88,11 @@ export const NodeDrawer: Component = (props) => { {/* Use CSS hidden instead of Show to avoid mount/unmount which causes scroll jumps. overflow-anchor: none prevents browser scroll anchoring from jumping when display toggles. */}
- +
diff --git a/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx b/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx index 8208946f7..dd0e6edda 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx @@ -9,6 +9,7 @@ import { formatUptime, normalizeDiskArray, } from '@/utils/format'; +import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; import { getNodeDisplayName } from '@/utils/nodes'; import { formatTemperature, getCpuTemperature, getTemperatureTextClass } from '@/utils/temperature'; @@ -17,6 +18,7 @@ import { DrawerDiskListCard, buildDrawerDiskListItems } from './DrawerDiskListCa interface NodeDrawerOverviewProps { node: Node; disks?: Disk[]; + temperatureThresholds?: MetricDisplayThresholds | null; } interface NodeOverviewRow { @@ -89,25 +91,29 @@ const pushTemperature = ( rows: NodeOverviewRow[], label: string, value: number | null | undefined, + thresholds: MetricDisplayThresholds | null | undefined, ): void => { if (typeof value !== 'number' || !Number.isFinite(value)) return; rows.push({ label, value: formatTemperature(value), - valueClass: getTemperatureTextClass(value), + valueClass: getTemperatureTextClass(value, thresholds), }); }; -const getThermalRows = (temperature: Temperature | undefined): NodeOverviewRow[] => { +const getThermalRows = ( + temperature: Temperature | undefined, + thresholds: MetricDisplayThresholds | null | undefined, +): NodeOverviewRow[] => { if (!temperature?.available) return []; const rows: NodeOverviewRow[] = []; const primary = getCpuTemperature(temperature); - pushTemperature(rows, 'CPU current', primary); - pushTemperature(rows, 'CPU package', temperature.cpuPackage); - pushTemperature(rows, 'CPU low', temperature.cpuMin); - pushTemperature(rows, 'CPU record', temperature.cpuMaxRecord); + pushTemperature(rows, 'CPU current', primary, thresholds); + pushTemperature(rows, 'CPU package', temperature.cpuPackage, thresholds); + pushTemperature(rows, 'CPU low', temperature.cpuMin, thresholds); + pushTemperature(rows, 'CPU record', temperature.cpuMaxRecord, thresholds); if (temperature.nvme?.length) { rows.push({ @@ -325,7 +331,10 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) { - +
); } diff --git a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx index 691bc89be..d7202b6e7 100644 --- a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx @@ -41,6 +41,7 @@ type WorkloadPanelProps = Pick< | 'bottomSpacerHeight' | 'compactGroupHeaders' | 'getGroupLabel' + | 'getNodeTemperatureThresholds' | 'groupedGuests' | 'groupedWindowing' | 'groupLabelBadges' @@ -181,7 +182,10 @@ export function WorkloadPanel(props: WorkloadPanelProps) { PVE {version} - + {formatTemperature(temperature)} @@ -461,6 +465,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) { > - + diff --git a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx index 4113a18be..8e38de700 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx @@ -91,12 +91,14 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) { } if (visibleInventoryIssues().length > 0) { - return state.workloadsNoInventoryState?.() ?? { - title: 'No workload inventory available', - description: - 'Pulse has infrastructure sources, but no VM, container, or pod inventory is available right now.', - actionLabel: 'Review infrastructure sources', - }; + return ( + state.workloadsNoInventoryState?.() ?? { + title: 'No workload inventory available', + description: + 'Pulse has infrastructure sources, but no VM, container, or pod inventory is available right now.', + actionLabel: 'Review infrastructure sources', + } + ); } return { @@ -169,6 +171,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) { bottomSpacerHeight={state.bottomSpacerHeight} compactGroupHeaders={state.compactGroupHeaders} getGroupLabel={state.getGroupLabel} + getNodeTemperatureThresholds={state.getNodeTemperatureThresholds} groupedGuests={state.groupedGuests} groupedWindowing={state.groupedWindowing} groupLabelBadges={state.groupLabelBadges} diff --git a/frontend-modern/src/components/Workloads/WorkloadsTable.tsx b/frontend-modern/src/components/Workloads/WorkloadsTable.tsx index b86ae63da..4b2e8f1ed 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsTable.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsTable.tsx @@ -16,6 +16,7 @@ type WorkloadsTableProps = Pick< | 'bottomSpacerHeight' | 'compactGroupHeaders' | 'getGroupLabel' + | 'getNodeTemperatureThresholds' | 'groupedGuests' | 'groupedWindowing' | 'groupLabelBadges' @@ -102,6 +103,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) { bottomSpacerHeight={props.bottomSpacerHeight} compactGroupHeaders={props.compactGroupHeaders} getGroupLabel={props.getGroupLabel} + getNodeTemperatureThresholds={props.getNodeTemperatureThresholds} groupedGuests={props.groupedGuests} groupedWindowing={props.groupedWindowing} groupLabelBadges={props.groupLabelBadges} diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index e2e1b576e..99d579f56 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -1,6 +1,7 @@ import { createEffect, createMemo, onCleanup, type Accessor } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import { ConnectionsAPI, type ConnectionsListResponse } from '@/api/connections'; +import { nodeOverrideIdCandidates } from '@/features/alerts/alertOverridesModel'; import type { VM, Container, Node } from '@/types/api'; import type { Resource } from '@/types/resource'; import type { ViewMode, WorkloadGuest, WorkloadType } from '@/types/workloads'; @@ -350,6 +351,9 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { reconnect(); }; + const getNodeTemperatureThresholds = (node: Node) => + alertsActivation.getMetricThresholds('node', 'temperature', nodeOverrideIdCandidates(node)); + createEffect(() => { if (!workloadsEnabled()) return; const handle = window.setInterval(() => { @@ -481,6 +485,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { focusedSummaryWorkloadGroupScope, focusedSummaryWorkloadGroupId, getGroupLabel, + getNodeTemperatureThresholds, groupedGuests, groupedWindowing, guestMetadata, diff --git a/frontend-modern/src/components/shared/NodeGroupHeader.tsx b/frontend-modern/src/components/shared/NodeGroupHeader.tsx index 702b12a47..137826ee9 100644 --- a/frontend-modern/src/components/shared/NodeGroupHeader.tsx +++ b/frontend-modern/src/components/shared/NodeGroupHeader.tsx @@ -1,5 +1,6 @@ import { Component, For, Show, type JSX } from 'solid-js'; import type { Node } from '@/types/api'; +import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; import { getNodeDisplayName, getNodeExternalUrl, hasAlternateDisplayName } from '@/utils/nodes'; import { StatusDot } from '@/components/shared/StatusDot'; import { getNodeStatusIndicator } from '@/utils/status'; @@ -22,6 +23,7 @@ interface NodeGroupHeaderProps { renderColumnCell?: (columnId: string, node: Node) => JSX.Element; renderAs?: 'tr' | 'div'; showFactsInName?: boolean; + temperatureThresholds?: MetricDisplayThresholds | null; trClass?: string; trProps?: JSX.HTMLAttributes & Partial>; @@ -89,7 +91,10 @@ export const NodeGroupHeader: Component = (props) => { PVE {pveVersion()} - + {formatTemperature(cpuTemperature())} diff --git a/frontend-modern/src/components/shared/TemperatureGauge.tsx b/frontend-modern/src/components/shared/TemperatureGauge.tsx index 98875b81e..01e0c179b 100644 --- a/frontend-modern/src/components/shared/TemperatureGauge.tsx +++ b/frontend-modern/src/components/shared/TemperatureGauge.tsx @@ -1,5 +1,9 @@ import { Component, createMemo } from 'solid-js'; -import { formatTemperature, getTemperatureTextClass } from '@/utils/temperature'; +import { + formatTemperature, + getTemperatureTextClass, + type TemperatureDisplayMetric, +} from '@/utils/temperature'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; interface TemperatureGaugeProps { @@ -9,6 +13,7 @@ interface TemperatureGaugeProps { critical?: number; warning?: number; thresholds?: MetricDisplayThresholds | null; + metric?: TemperatureDisplayMetric; class?: string; } @@ -25,7 +30,7 @@ export const TemperatureGauge: Component = (props) => { }); const textColorClass = createMemo(() => - getTemperatureTextClass(props.value, explicitThresholds()), + getTemperatureTextClass(props.value, explicitThresholds(), props.metric), ); return ( diff --git a/frontend-modern/src/components/shared/__tests__/NodeGroupHeader.test.tsx b/frontend-modern/src/components/shared/__tests__/NodeGroupHeader.test.tsx index d9c992ced..88f23a060 100644 --- a/frontend-modern/src/components/shared/__tests__/NodeGroupHeader.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/NodeGroupHeader.test.tsx @@ -52,4 +52,21 @@ describe('NodeGroupHeader', () => { expect(link).toHaveAttribute('target', '_blank'); expect(link).toHaveAttribute('rel', 'noopener noreferrer'); }); + + it('colors grouped header temperatures from configured thresholds', () => { + render(() => ( + + )); + + expect(screen.getByText('76°C')).toHaveClass('text-green-600'); + }); }); diff --git a/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.test.ts b/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.test.ts index 19f1143a4..8124069ec 100644 --- a/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.test.ts +++ b/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.test.ts @@ -5,6 +5,7 @@ import type { Resource } from '@/types/resource'; import { buildContainerRuntimeResources, buildProjectedOverrides, + nodeOverrideIdCandidates, normalizeRawOverridesConfig, } from '../alertOverridesModel'; @@ -17,6 +18,25 @@ const makeResource = (overrides: Partial): Resource => }) as Resource; describe('alertOverridesModel', () => { + it('builds node temperature override candidates from stable node and agent identities', () => { + expect( + nodeOverrideIdCandidates({ + id: 'agent:pve-node-1', + name: 'pve-node-1', + instance: 'homelab', + host: 'https://pve-node-1:8006', + linkedAgentId: 'agent-linked', + }), + ).toEqual([ + 'agent-linked', + 'agent:pve-node-1', + 'homelab-pve-node-1', + 'homelab:pve-node-1', + 'pve-node-1', + 'https://pve-node-1:8006', + ]); + }); + it('normalizes disk override ids into canonical agent disk keys', () => { expect( normalizeRawOverridesConfig({ diff --git a/frontend-modern/src/features/alerts/alertOverridesModel.ts b/frontend-modern/src/features/alerts/alertOverridesModel.ts index 2ddd535fa..b88b161ed 100644 --- a/frontend-modern/src/features/alerts/alertOverridesModel.ts +++ b/frontend-modern/src/features/alerts/alertOverridesModel.ts @@ -1,4 +1,4 @@ -import type { PBSInstance } from '@/types/api'; +import type { Node, PBSInstance } from '@/types/api'; import type { RawOverrideConfig } from '@/types/alerts'; import type { Resource } from '@/types/resource'; import { getActionableAgentIdFromResource, isTrueNASSystemResource } from '@/utils/agentResources'; @@ -154,6 +154,18 @@ export const hostOverrideIdCandidates = (resource: Resource): string[] => { ); }; +export const nodeOverrideIdCandidates = ( + node: Pick, +): string[] => + uniqueIds( + node.linkedAgentId, + node.id, + node.instance && node.name ? `${node.instance}-${node.name}` : undefined, + node.instance && node.name ? `${node.instance}:${node.name}` : undefined, + node.name, + node.host, + ); + export const dockerHostOverrideIdCandidates = (resource: Resource): string[] => { const data = platformData(resource); const docker = asRecord(data?.docker); diff --git a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx index da696c102..d0cbb4b79 100644 --- a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx +++ b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx @@ -7,7 +7,9 @@ import { } from '@/components/Workloads/DrawerDiskListCard'; import { InfoCardFrame } from '@/components/shared/InfoCardFrame'; import { useResourceDetailDrawerDockerActionsState } from '@/components/Infrastructure/useResourceDetailDrawerDockerActionsState'; +import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel'; import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings'; +import { useAlertsActivation } from '@/stores/alertsActivation'; import type { Resource } from '@/types/resource'; import { formatBytes, @@ -103,9 +105,17 @@ const DetailCard = (props: { title: string; rows: DockerOverviewRow[] }) => ( ); export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) { + const alertsActivation = useAlertsActivation(); const docker = () => props.host.docker; const agent = () => props.host.agent; const linkedAgentId = () => cleanText(agent()?.agentId); + const temperatureThresholds = createMemo(() => + alertsActivation.getMetricThresholds( + 'node', + 'temperature', + hostOverrideIdCandidates(props.host), + ), + ); const runtimeLabel = (): string => { const runtime = cleanText(docker()?.runtime) || 'Docker'; @@ -302,7 +312,7 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) { rows.push({ label: 'CPU temp', value: formatTemperature(temperature), - valueClass: getTemperatureTextClass(temperature), + valueClass: getTemperatureTextClass(temperature, temperatureThresholds()), }); } if ( diff --git a/frontend-modern/src/features/docker/__tests__/DockerHostsTable.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerHostsTable.test.tsx index 0c863ceb4..2d5fa2624 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerHostsTable.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerHostsTable.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Disk } from '@/types/api'; @@ -19,7 +19,10 @@ vi.mock('@/contexts/appRuntime', () => ({ useWebSocket: () => ({ activeAlerts: {} as Record }), })); vi.mock('@/stores/alertsActivation', () => ({ - useAlertsActivation: () => ({ activationState: () => 'active' }), + useAlertsActivation: () => ({ + activationState: () => 'active', + getMetricThresholds: () => ({ warning: 80, critical: 85 }), + }), })); vi.mock('@/components/Workloads/StackedMemoryBar', () => ({ @@ -132,6 +135,23 @@ describe('DockerHostsTable', () => { expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute('type', 'button'); }); + it('colors drawer host temperatures from configured thresholds', () => { + render(() => ( + } + emptyTitle="No Docker hosts" + emptyDescription="No hosts" + showToolbar={false} + /> + )); + + fireEvent.click(screen.getByText('docker-01').closest('tr')!); + + const drawer = screen.getByTestId('docker-host-drawer'); + expect(within(drawer).getByText('76°C')).toHaveClass('text-green-600'); + }); + it('surfaces container update actions in the host drawer', () => { render(() => ( { const config = toDiscoveryConfig(node); return config diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx index 50b132126..ca802f005 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx @@ -58,8 +58,8 @@ vi.mock('@/components/Workloads/useWorkloadTableMetricHistory', () => ({ })); vi.mock('@/components/Workloads/NodeDrawer', () => ({ - NodeDrawer: (props: { node: { name: string } }) => { - nodeDrawerMock(props.node); + NodeDrawer: (props: { node: { name: string }; temperatureThresholds?: unknown }) => { + nodeDrawerMock(props); return
{props.node.name}
; }, })); @@ -304,7 +304,12 @@ describe('ProxmoxNodesTable', () => { expect(row).toHaveAttribute('aria-expanded', 'true'); expect(screen.getByTestId('node-drawer')).toHaveTextContent('pve-node-1'); - expect(nodeDrawerMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'pve-node-1' })); + expect(nodeDrawerMock).toHaveBeenCalledWith( + expect.objectContaining({ + node: expect.objectContaining({ name: 'pve-node-1' }), + temperatureThresholds: { warning: 80, critical: 85 }, + }), + ); await fireEvent.click(row!); diff --git a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx index 31f62df26..a8defb099 100644 --- a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx +++ b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx @@ -56,6 +56,7 @@ import { import { useColumnVisibility } from '@/hooks/useColumnVisibility'; import type { Disk } from '@/types/api'; import type { Resource, ResourceAvailabilityMeta } from '@/types/resource'; +import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; import { getActionableAgentIdFromResource } from '@/utils/agentResources'; import { formatBytes, formatSpeed, normalizeDiskArray } from '@/utils/format'; import { STORAGE_KEYS } from '@/utils/localStorage'; @@ -88,6 +89,7 @@ import { getAgentMachineRaidSummary, getAgentMachineTemperatureCelsius, getAgentMachineTemperatureDetailSections, + getAgentMachineTemperatureMetric, getAgentMachineTemperatureTitle, getAgentMachineThermalPressurePresentation, getNextAgentMachineSortState, @@ -99,6 +101,7 @@ import { type AgentMachineRaidArrayDetail, type AgentMachineSortKey, type AgentMachineTemperatureDetailSection, + type AgentMachineTemperatureMetric, } from './agentMachineTableModel'; type MetricFallbackReason = { @@ -171,7 +174,9 @@ const AgentMachineMetricTooltip: Component<{ const AgentMachineTemperatureCell: Component<{ celsius: number | undefined; + metric: AgentMachineTemperatureMetric; sections: AgentMachineTemperatureDetailSection[]; + thresholds?: MetricDisplayThresholds | null; title: string; thermalPressure?: { label: string; @@ -207,7 +212,9 @@ const AgentMachineTemperatureCell: Component<{ } > - {(value) => } + {(value) => ( + + )} } > @@ -1479,6 +1486,13 @@ export const AgentsMachinesTable: Component<{ const raidArrays = () => getAgentMachineRaidArrayDetails(machine); const raidSummary = () => getAgentMachineRaidSummary(machine); const temperature = () => getAgentMachineTemperatureCelsius(machine); + const temperatureMetric = () => getAgentMachineTemperatureMetric(machine); + const temperatureThresholds = () => + alertsActivation.getMetricThresholds( + temperatureMetric() === 'diskTemperature' ? 'agent' : 'node', + temperatureMetric(), + alertResourceIds(), + ); const temperatureSections = () => getAgentMachineTemperatureDetailSections(machine); const temperatureTitle = () => getAgentMachineTemperatureTitle(machine); @@ -1672,7 +1686,9 @@ export const AgentsMachinesTable: Component<{ > diff --git a/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.test.ts b/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.test.ts index 33630dc2c..c4b393e52 100644 --- a/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.test.ts +++ b/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.test.ts @@ -6,6 +6,7 @@ import { getAgentMachineNetworkInterfaceDetails, getAgentMachineRaidArrayDetails, getAgentMachineTemperatureCelsius, + getAgentMachineTemperatureMetric, getAgentMachineTemperatureTitle, matchesAgentMachineSearch, sortAgentMachines, @@ -182,35 +183,32 @@ describe('agentMachineTableModel', () => { }); expect(getAgentMachineTemperatureCelsius(machine)).toBe(44); + expect(getAgentMachineTemperatureMetric(machine)).toBe('diskTemperature'); }); it('prefers direct and sensor temperatures over SMART fallback temperatures', () => { - expect( - getAgentMachineTemperatureCelsius( - resource({ - temperature: 55, - agent: { - sensors: { - temperatureCelsius: { 'cpu.package': 61 }, - smart: [{ device: '/dev/nvme0', temperature: 72 }], - }, - }, - }), - ), - ).toBe(55); + const direct = resource({ + temperature: 55, + agent: { + sensors: { + temperatureCelsius: { 'cpu.package': 61 }, + smart: [{ device: '/dev/nvme0', temperature: 72 }], + }, + }, + }); + expect(getAgentMachineTemperatureCelsius(direct)).toBe(55); + expect(getAgentMachineTemperatureMetric(direct)).toBe('temperature'); - expect( - getAgentMachineTemperatureCelsius( - resource({ - agent: { - sensors: { - temperatureCelsius: { 'cpu.package': 61, 'cpu.core0': 64 }, - smart: [{ device: '/dev/nvme0', temperature: 72 }], - }, - }, - }), - ), - ).toBe(64); + const sensor = resource({ + agent: { + sensors: { + temperatureCelsius: { 'cpu.package': 61, 'cpu.core0': 64 }, + smart: [{ device: '/dev/nvme0', temperature: 72 }], + }, + }, + }); + expect(getAgentMachineTemperatureCelsius(sensor)).toBe(64); + expect(getAgentMachineTemperatureMetric(sensor)).toBe('temperature'); }); it('includes agent sensor, SMART, fan, and additional readings in the temperature title', () => { diff --git a/frontend-modern/src/features/standalone/agentMachineTableModel.ts b/frontend-modern/src/features/standalone/agentMachineTableModel.ts index dcaca1651..944219095 100644 --- a/frontend-modern/src/features/standalone/agentMachineTableModel.ts +++ b/frontend-modern/src/features/standalone/agentMachineTableModel.ts @@ -141,6 +141,8 @@ export type AgentMachineThermalPressurePresentation = { className: string; }; +export type AgentMachineTemperatureMetric = 'temperature' | 'diskTemperature'; + export type AgentMachineNetworkInterfaceDetail = { name: string; mac?: string; @@ -458,6 +460,19 @@ export const getAgentMachineTemperatureCelsius = (machine: Resource): number | u ); }; +export const getAgentMachineTemperatureMetric = ( + machine: Resource, +): AgentMachineTemperatureMetric => { + if (positiveTemperature(machine.temperature) !== undefined) return 'temperature'; + if (maxTemperatureReading(getSensorTemperatureReadings(machine)) !== undefined) { + return 'temperature'; + } + if (maxTemperatureReading(getActiveSmartTemperatureReadings(machine)) !== undefined) { + return 'diskTemperature'; + } + return 'temperature'; +}; + export const getAgentMachineTemperatureDetailSections = ( machine: Resource, ): AgentMachineTemperatureDetailSection[] => { diff --git a/frontend-modern/src/utils/__tests__/temperatureDisplayGuardrails.test.ts b/frontend-modern/src/utils/__tests__/temperatureDisplayGuardrails.test.ts new file mode 100644 index 000000000..3f8e8898e --- /dev/null +++ b/frontend-modern/src/utils/__tests__/temperatureDisplayGuardrails.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import nodeDrawerOverviewSource from '@/components/Workloads/NodeDrawerOverview.tsx?raw'; +import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw'; +import nodeGroupHeaderSource from '@/components/shared/NodeGroupHeader.tsx?raw'; +import temperatureGaugeSource from '@/components/shared/TemperatureGauge.tsx?raw'; +import dockerHostDrawerOverviewSource from '@/features/docker/DockerHostDrawerOverview.tsx?raw'; +import agentsMachinesTableSource from '@/features/standalone/AgentsMachinesTable.tsx?raw'; + +describe('temperature display guardrails', () => { + it('keeps colored temperature displays threshold-aware', () => { + expect(nodeGroupHeaderSource).toContain('temperatureThresholds'); + expect(workloadPanelSource).toContain('getNodeTemperatureThresholds(node)'); + expect(nodeDrawerOverviewSource).toContain('getTemperatureTextClass(value, thresholds)'); + expect(dockerHostDrawerOverviewSource).toContain('temperatureThresholds()'); + expect(agentsMachinesTableSource).toContain('getAgentMachineTemperatureMetric'); + expect(agentsMachinesTableSource).toContain('thresholds={temperatureThresholds()}'); + expect(temperatureGaugeSource).toContain('props.metric'); + }); + + it('does not reintroduce the previous local temperature severity calls', () => { + expect(nodeGroupHeaderSource).not.toContain('getTemperatureTextClass(cpuTemperature())'); + expect(workloadPanelSource).not.toContain('getTemperatureTextClass(temperature)}'); + expect(nodeDrawerOverviewSource).not.toContain('getTemperatureTextClass(value)'); + expect(dockerHostDrawerOverviewSource).not.toContain('getTemperatureTextClass(temperature)'); + expect(agentsMachinesTableSource).not.toContain(''); + }); +}); diff --git a/frontend-modern/src/utils/temperature.ts b/frontend-modern/src/utils/temperature.ts index cf015e31c..f8b822504 100644 --- a/frontend-modern/src/utils/temperature.ts +++ b/frontend-modern/src/utils/temperature.ts @@ -104,7 +104,10 @@ const TEMPERATURE_TEXT_CLASSES = { normal: 'text-green-600 dark:text-green-400', } as const; -type TemperatureDisplayMetric = Extract; +export type TemperatureDisplayMetric = Extract< + DisplayMetricType, + 'temperature' | 'diskTemperature' +>; export const getTemperatureTextClass = ( celsius: number | null | undefined,