Complete threshold-aware temperature displays

Refs #1534
This commit is contained in:
rcourtman 2026-07-07 10:00:33 +01:00
parent 1c8a9346ef
commit ffd50a0052
21 changed files with 274 additions and 53 deletions

View file

@ -113,6 +113,27 @@ describe('NodeDrawer', () => {
expect(screen.getByText('Temp monitor')).toBeInTheDocument();
});
it('colors overview thermal rows from configured thresholds', () => {
render(() => (
<NodeDrawer
node={makeNode({
temperature: {
cpuPackage: 76,
cpuMax: 76,
cpuMin: 50,
cpuMaxRecord: 76,
available: true,
hasCPU: true,
lastUpdate: new Date().toISOString(),
},
})}
temperatureThresholds={{ warning: 80, critical: 85 }}
/>
));
expect(screen.getAllByText('76°C')[0]).toHaveClass('text-green-600');
});
it('renders node-only thermal history without requiring a table temperature column', async () => {
render(() => <NodeDrawer node={makeNode()} />);

View file

@ -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<NodeDrawerProps> = (props) => {
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
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 (
<section class="space-y-3" aria-labelledby={headingId()} data-testid="node-drawer">
@ -74,7 +88,11 @@ export const NodeDrawer: Component<NodeDrawerProps> = (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. */}
<div class={activeTab() === 'overview' ? '' : 'hidden'} style={{ 'overflow-anchor': 'none' }}>
<NodeDrawerOverview node={props.node} disks={props.disks} />
<NodeDrawerOverview
node={props.node}
disks={props.disks}
temperatureThresholds={temperatureThresholds()}
/>
</div>
<div class={activeTab() === 'history' ? '' : 'hidden'} style={{ 'overflow-anchor': 'none' }}>

View file

@ -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) {
<DrawerDiskListCard disks={perDiskItems()} testId="node-drawer-disks" />
</Show>
<DetailCard title="Telemetry" rows={telemetryRows()} />
<DetailCard title="Thermals" rows={getThermalRows(props.node.temperature)} />
<DetailCard
title="Thermals"
rows={getThermalRows(props.node.temperature, props.temperatureThresholds)}
/>
</div>
);
}

View file

@ -41,6 +41,7 @@ type WorkloadPanelProps = Pick<
| 'bottomSpacerHeight'
| 'compactGroupHeaders'
| 'getGroupLabel'
| 'getNodeTemperatureThresholds'
| 'groupedGuests'
| 'groupedWindowing'
| 'groupLabelBadges'
@ -181,7 +182,10 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
<span title="Proxmox VE version">PVE {version}</span>
</Show>
<Show when={temperature !== null}>
<span class={getTemperatureTextClass(temperature)} title="CPU temperature">
<span
class={getTemperatureTextClass(temperature, props.getNodeTemperatureThresholds(node))}
title="CPU temperature"
>
{formatTemperature(temperature)}
</span>
</Show>
@ -461,6 +465,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
>
<NodeGroupHeader
node={node()!}
temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}
renderAs="tr"
colspan={props.totalColumns()}
columns={
@ -502,7 +507,10 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
colspan={props.totalColumns()}
data-inline-node-detail-for={groupKey()}
>
<NodeDrawer node={node()!} />
<NodeDrawer
node={node()!}
temperatureThresholds={props.getNodeTemperatureThresholds(node()!)}
/>
</InlineDetailTableRow>
</Show>
</Show>

View file

@ -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}

View file

@ -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}

View file

@ -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,

View file

@ -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<HTMLTableRowElement> &
Partial<Record<`data-${string}`, string | undefined>>;
@ -89,7 +91,10 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
<span title="Proxmox VE version">PVE {pveVersion()}</span>
</Show>
<Show when={cpuTemperature() !== null}>
<span class={getTemperatureTextClass(cpuTemperature())} title="CPU temperature">
<span
class={getTemperatureTextClass(cpuTemperature(), props.temperatureThresholds)}
title="CPU temperature"
>
{formatTemperature(cpuTemperature())}
</span>
</Show>

View file

@ -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<TemperatureGaugeProps> = (props) => {
});
const textColorClass = createMemo(() =>
getTemperatureTextClass(props.value, explicitThresholds()),
getTemperatureTextClass(props.value, explicitThresholds(), props.metric),
);
return (

View file

@ -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(() => (
<NodeGroupHeader
node={makeNode({
temperature: {
available: true,
cpuPackage: 76,
lastUpdate: '2026-01-01T00:00:00Z',
},
})}
temperatureThresholds={{ warning: 80, critical: 85 }}
/>
));
expect(screen.getByText('76°C')).toHaveClass('text-green-600');
});
});

View file

@ -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>): 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({

View file

@ -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<Node, 'id' | 'name' | 'instance' | 'host' | 'linkedAgentId'>,
): 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);

View file

@ -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 (

View file

@ -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<string, never> }),
}));
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(() => (
<DockerHostsTable
resources={[makeDockerHost({ temperature: 76 })]}
emptyIcon={<span />}
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(() => (
<DockerHostsTable

View file

@ -588,6 +588,7 @@ export const ProxmoxNodesTable: Component<{
<NodeDrawer
node={selectedNode()}
disks={normalizeDiskArray(node.agent?.disks)}
temperatureThresholds={temperatureThresholds()}
discoveryTarget={(() => {
const config = toDiscoveryConfig(node);
return config

View file

@ -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 <div data-testid="node-drawer">{props.node.name}</div>;
},
}));
@ -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!);

View file

@ -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<{
</Show>
}
>
{(value) => <TemperatureGauge value={value()} />}
{(value) => (
<TemperatureGauge value={value()} metric={props.metric} thresholds={props.thresholds} />
)}
</Show>
}
>
@ -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<{
>
<AgentMachineTemperatureCell
celsius={temperature()}
metric={temperatureMetric()}
sections={temperatureSections()}
thresholds={temperatureThresholds()}
title={temperatureTitle()}
thermalPressure={thermalPressure()}
/>

View file

@ -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', () => {

View file

@ -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[] => {

View file

@ -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('<TemperatureGauge value={value()} />');
});
});

View file

@ -104,7 +104,10 @@ const TEMPERATURE_TEXT_CLASSES = {
normal: 'text-green-600 dark:text-green-400',
} as const;
type TemperatureDisplayMetric = Extract<DisplayMetricType, 'temperature' | 'diskTemperature'>;
export type TemperatureDisplayMetric = Extract<
DisplayMetricType,
'temperature' | 'diskTemperature'
>;
export const getTemperatureTextClass = (
celsius: number | null | undefined,