Improve dashboard responsiveness and temperature handling

This commit is contained in:
rcourtman 2025-10-12 10:33:51 +00:00
parent a74baed121
commit 274f36daa8
7 changed files with 237 additions and 46 deletions

View file

@ -698,12 +698,12 @@ export function Dashboard(props: DashboardProps) {
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
<ComponentErrorBoundary name="Guest Table">
<Card padding="none" class="mb-4 overflow-hidden">
<ScrollableTable minWidth="900px">
<table class="w-full min-w-[900px] table-fixed border-collapse">
<ScrollableTable minWidth="760px">
<table class="w-full min-w-[760px] md:min-w-[900px] table-fixed border-collapse">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[170px] lg:w-[190px] xl:w-[210px] 2xl:w-[260px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[140px] sm:w-[160px] lg:w-[190px] xl:w-[210px] 2xl:w-[260px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => handleSort('name')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabindex="0"
@ -713,63 +713,63 @@ export function Dashboard(props: DashboardProps) {
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] lg:w-[60px] xl:w-[64px] 2xl:w-[72px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[48px] sm:w-[56px] lg:w-[60px] xl:w-[64px] 2xl:w-[72px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('type')}
>
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[64px] lg:w-[72px] xl:w-[80px] 2xl:w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[56px] sm:w-[64px] lg:w-[72px] xl:w-[80px] 2xl:w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('vmid')}
>
VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[90px] lg:w-[100px] 2xl:w-[120px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[80px] sm:w-[90px] lg:w-[100px] 2xl:w-[120px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('uptime')}
>
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[120px] lg:w-[140px] xl:w-[150px] 2xl:w-[190px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[110px] sm:w-[120px] lg:w-[140px] xl:w-[160px] 2xl:w-[190px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[120px] lg:w-[140px] xl:w-[150px] 2xl:w-[190px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[110px] sm:w-[120px] lg:w-[140px] xl:w-[160px] 2xl:w-[190px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('memory')}
>
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[120px] lg:w-[140px] xl:w-[150px] 2xl:w-[190px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[110px] sm:w-[120px] lg:w-[140px] xl:w-[160px] 2xl:w-[190px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[68px] lg:w-[76px] xl:w-[82px] 2xl:w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[60px] sm:w-[66px] lg:w-[74px] xl:w-[82px] 2xl:w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('diskRead')}
>
Disk Read{' '}
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[68px] lg:w-[76px] xl:w-[82px] 2xl:w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[60px] sm:w-[66px] lg:w-[74px] xl:w-[82px] 2xl:w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('diskWrite')}
>
Disk Write{' '}
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[68px] lg:w-[76px] xl:w-[82px] 2xl:w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[60px] sm:w-[66px] lg:w-[74px] xl:w-[82px] 2xl:w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('networkIn')}
>
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[68px] lg:w-[76px] xl:w-[82px] 2xl:w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[60px] sm:w-[66px] lg:w-[74px] xl:w-[82px] 2xl:w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('networkOut')}
>
Net Out{' '}

View file

@ -219,7 +219,7 @@ export function GuestRow(props: GuestRowProps) {
// Get first cell styling
const firstCellClass = createMemo(() => {
const base =
'py-0.5 pr-2 whitespace-nowrap relative w-[170px] lg:w-[190px] xl:w-[210px] 2xl:w-[260px]';
'py-0.5 pr-2 whitespace-nowrap relative w-[140px] sm:w-[160px] lg:w-[190px] xl:w-[210px] 2xl:w-[260px]';
const indent = 'pl-6';
return `${base} ${indent}`;
});
@ -286,7 +286,7 @@ export function GuestRow(props: GuestRowProps) {
</td>
{/* Type */}
<td class="py-0.5 px-2 whitespace-nowrap w-[56px] lg:w-[60px] xl:w-[64px] 2xl:w-[72px]">
<td class="py-0.5 px-2 whitespace-nowrap w-[48px] sm:w-[56px] lg:w-[60px] xl:w-[64px] 2xl:w-[72px]">
<div class="flex h-[24px] items-center">
<span
class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${
@ -301,13 +301,13 @@ export function GuestRow(props: GuestRowProps) {
</td>
{/* VMID */}
<td class="py-0.5 px-2 whitespace-nowrap w-[64px] lg:w-[72px] xl:w-[80px] 2xl:w-[90px] text-sm text-gray-600 dark:text-gray-400 align-middle">
<td class="py-0.5 px-2 whitespace-nowrap w-[56px] sm:w-[64px] lg:w-[72px] xl:w-[80px] 2xl:w-[90px] text-sm text-gray-600 dark:text-gray-400 align-middle">
{props.guest.vmid}
</td>
{/* Uptime */}
<td
class={`py-0.5 px-2 w-[90px] lg:w-[100px] 2xl:w-[120px] text-sm whitespace-nowrap align-middle ${
class={`py-0.5 px-2 w-[80px] sm:w-[90px] lg:w-[100px] 2xl:w-[120px] text-sm whitespace-nowrap align-middle ${
props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'
}`}
>
@ -317,7 +317,7 @@ export function GuestRow(props: GuestRowProps) {
</td>
{/* CPU */}
<td class="py-0.5 px-2 w-[120px] lg:w-[140px] xl:w-[150px] 2xl:w-[190px]">
<td class="py-0.5 px-2 w-[110px] sm:w-[120px] lg:w-[140px] xl:w-[160px] 2xl:w-[190px]">
<Show when={showGuestMetrics()} fallback={<span class="text-sm text-gray-400">-</span>}>
<MetricBar
value={cpuPercent()}
@ -333,7 +333,7 @@ export function GuestRow(props: GuestRowProps) {
</td>
{/* Memory */}
<td class="py-0.5 px-2 w-[120px] lg:w-[140px] xl:w-[150px] 2xl:w-[190px]">
<td class="py-0.5 px-2 w-[110px] sm:w-[120px] lg:w-[140px] xl:w-[160px] 2xl:w-[190px]">
<div title={memoryTooltip() ?? undefined}>
<Show when={showGuestMetrics()} fallback={<span class="text-sm text-gray-400">-</span>}>
<MetricBar
@ -347,7 +347,7 @@ export function GuestRow(props: GuestRowProps) {
</td>
{/* Disk surface usage even if guest is currently stopped so users can see last reported values */}
<td class="py-0.5 px-2 w-[120px] lg:w-[140px] xl:w-[150px] 2xl:w-[190px]">
<td class="py-0.5 px-2 w-[110px] sm:w-[120px] lg:w-[140px] xl:w-[160px] 2xl:w-[190px]">
<Show
when={hasDiskUsage()}
fallback={
@ -370,24 +370,24 @@ export function GuestRow(props: GuestRowProps) {
</td>
{/* Disk I/O */}
<td class="py-0.5 px-2 w-[68px] lg:w-[76px] xl:w-[82px] 2xl:w-[100px]">
<td class="py-0.5 px-2 w-[60px] sm:w-[66px] lg:w-[74px] xl:w-[82px] 2xl:w-[100px]">
<div class="flex h-[24px] items-center">
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
</div>
</td>
<td class="py-0.5 px-2 w-[68px] lg:w-[76px] xl:w-[82px] 2xl:w-[100px]">
<td class="py-0.5 px-2 w-[60px] sm:w-[66px] lg:w-[74px] xl:w-[82px] 2xl:w-[100px]">
<div class="flex h-[24px] items-center">
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
</div>
</td>
{/* Network I/O */}
<td class="py-0.5 px-2 w-[68px] lg:w-[76px] xl:w-[82px] 2xl:w-[100px]">
<td class="py-0.5 px-2 w-[60px] sm:w-[66px] lg:w-[74px] xl:w-[82px] 2xl:w-[100px]">
<div class="flex h-[24px] items-center">
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
</div>
</td>
<td class="py-0.5 px-2 w-[68px] lg:w-[76px] xl:w-[82px] 2xl:w-[100px]">
<td class="py-0.5 px-2 w-[60px] sm:w-[66px] lg:w-[74px] xl:w-[82px] 2xl:w-[100px]">
<div class="flex h-[24px] items-center">
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
</div>

View file

@ -6,6 +6,7 @@ import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndica
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
import { getPrimaryTemperature } from '@/utils/temperature';
interface NodeCardProps {
node: Node;
@ -118,6 +119,35 @@ const NodeCard: Component<NodeCardProps> = (props) => {
);
const unacknowledgedNodeAlerts = createMemo(() => nodeAlerts().filter((alert) => !alert.acknowledged));
const primaryTemperature = createMemo(() => getPrimaryTemperature(props.node.temperature));
const primaryTemperatureValue = createMemo(() => {
const reading = primaryTemperature();
return reading ? Math.round(reading.value) : null;
});
const primaryTemperatureLabel = createMemo(() => {
const reading = primaryTemperature();
if (!reading) return null;
if (reading.source === 'nvme') {
return reading.device ?? 'NVMe';
}
return 'CPU';
});
const temperatureTooltip = createMemo(() => {
const temp = props.node.temperature;
const rounded = primaryTemperatureValue();
if (!temp?.available || rounded === null) {
return '';
}
const label = primaryTemperatureLabel();
const primaryLabel =
label && label !== 'CPU' ? `${label}: ${rounded}°C` : `CPU: ${rounded}°C`;
const nvmeDetails =
temp.nvme && temp.nvme.length > 0
? ` | NVMe: ${temp.nvme.map((n) => `${n.device}: ${Math.round(n.temp)}°C`).join(', ')}`
: '';
return `${primaryLabel}${nvmeDetails}`;
});
// Determine border/ring style based on status and alerts
const getBorderClass = () => {
// Selected nodes get blue ring
@ -219,20 +249,25 @@ const NodeCard: Component<NodeCardProps> = (props) => {
{formatUptime(props.node.uptime)}
</span>
<Show
when={props.node.temperature?.available}
when={props.node.temperature?.available && primaryTemperatureValue() !== null}
fallback={<span title={`Load: ${normalizedLoad()}`}>{normalizedLoad()}</span>}
>
<span
class={`font-medium ${
(props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0) > 80
(primaryTemperatureValue() ?? 0) > 80
? 'text-red-500'
: (props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0) > 60
: (primaryTemperatureValue() ?? 0) > 60
? 'text-yellow-500'
: 'text-green-500'
}`}
title={`CPU: ${Math.round(props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0)}°C${props.node.temperature!.nvme && props.node.temperature!.nvme.length > 0 ? ` | NVMe: ${props.node.temperature!.nvme.map((n) => `${n.device}: ${Math.round(n.temp)}°C`).join(', ')}` : ''}`}
title={temperatureTooltip() || undefined}
>
🌡{Math.round(props.node.temperature!.cpuPackage || props.node.temperature!.cpuMax || 0)}°C
🌡{primaryTemperatureValue()}°C
<Show when={primaryTemperatureLabel() && primaryTemperatureLabel() !== 'CPU'}>
<span class="ml-1 text-[9px] uppercase text-gray-500 dark:text-gray-400">
{primaryTemperatureLabel()}
</span>
</Show>
</span>
</Show>
</div>

View file

@ -6,6 +6,7 @@ import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { Card } from '@/components/shared/Card';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
import { getPrimaryTemperature, type PrimaryTemperatureReading } from '@/utils/temperature';
interface NodeSummaryTableProps {
nodes: Node[];
@ -106,6 +107,15 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return counts;
});
const roundTemperature = (reading: PrimaryTemperatureReading | null) =>
reading ? Math.round(reading.value) : null;
const getTemperatureReading = (item: SortableItem): PrimaryTemperatureReading | null => {
if (item.type !== 'pve') return null;
const node = item.data as Node;
return getPrimaryTemperature(node.temperature);
};
const getDefaultSortDirection = (key: Exclude<SortKey, 'default'>) => {
switch (key) {
case 'name':
@ -194,9 +204,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const getTemperatureValue = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
if (!node.temperature?.available) return null;
return Math.round(node.temperature.cpuPackage ?? node.temperature.cpuMax ?? 0);
return roundTemperature(getTemperatureReading(item));
}
return null;
};
@ -406,7 +414,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const memoryPercentValue = getMemoryPercent(item);
const diskPercentValue = getDiskPercent(item);
const diskSublabel = getDiskSublabel(item);
const temperatureValue = getTemperatureValue(item);
const temperatureReading = getTemperatureReading(item);
const temperatureValue = roundTemperature(temperatureReading);
const uptimeValue = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0;
const displayName = () => {
if (isPVE) return getNodeDisplayName(node as Node);
@ -597,20 +606,37 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<Show when={hasAnyTemperatureData()}>
<td class="px-2 py-0.5 whitespace-nowrap text-center">
<Show
when={online && isPVE && node!.temperature?.available}
when={
online &&
isPVE &&
node!.temperature?.available &&
temperatureValue !== null
}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">-</span>}
>
<span
class={`text-xs font-medium ${
(temperatureValue ?? 0) >= 80
? 'text-red-600 dark:text-red-400'
: (temperatureValue ?? 0) >= 70
? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400'
}`}
>
{temperatureValue ?? 0}°C
</span>
<div class="flex items-center justify-center gap-1">
{(() => {
const value = temperatureValue as number;
const severityClass =
value >= 80
? 'text-red-600 dark:text-red-400'
: value >= 70
? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
return (
<>
<span class={`text-xs font-medium ${severityClass}`}>
{value}°C
</span>
<Show when={temperatureReading?.source === 'nvme'}>
<span class="text-[9px] uppercase text-gray-500 dark:text-gray-400">
{temperatureReading?.device ?? 'NVMe'}
</span>
</Show>
</>
);
})()}
</div>
</Show>
</td>
</Show>

View file

@ -0,0 +1,53 @@
import type { Temperature } from '@/types/api';
export type PrimaryTemperatureReading = {
value: number;
source: 'cpu' | 'nvme';
device?: string;
};
const isValidTemperature = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
export const getPrimaryTemperature = (
temperature?: Temperature | null,
): PrimaryTemperatureReading | null => {
if (!temperature?.available) return null;
const cpuCandidates: number[] = [];
if (isValidTemperature(temperature.cpuPackage)) {
cpuCandidates.push(temperature.cpuPackage);
}
if (isValidTemperature(temperature.cpuMax)) {
cpuCandidates.push(temperature.cpuMax);
}
if (cpuCandidates.length > 0) {
return {
value: Math.max(...cpuCandidates),
source: 'cpu',
};
}
const nvmeCandidates = (temperature.nvme ?? [])
.filter((nvme) => isValidTemperature(nvme.temp))
.map((nvme) => ({
device: nvme.device,
temp: nvme.temp,
}));
if (nvmeCandidates.length > 0) {
const hottest = nvmeCandidates.reduce((max, current) =>
current.temp > max.temp ? current : max,
);
return {
value: hottest.temp,
source: 'nvme',
device: hottest.device,
};
}
return null;
};

View file

@ -53,7 +53,10 @@ func (tc *TemperatureCollector) CollectTemperature(ctx context.Context, nodeHost
return &models.Temperature{Available: false}, nil
}
temp.Available = true
if !temp.Available {
return temp, nil
}
temp.LastUpdate = time.Now()
return temp, nil
@ -145,6 +148,12 @@ func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temper
}
}
if temp.CPUPackage > 0 || temp.CPUMax > 0 || len(temp.Cores) > 0 || len(temp.NVMe) > 0 {
temp.Available = true
} else {
temp.Available = false
}
return temp, nil
}

View file

@ -0,0 +1,68 @@
package monitoring
import "testing"
func TestParseSensorsJSON_NoTemperatureData(t *testing.T) {
collector := &TemperatureCollector{}
jsonStr := `{
"acpitz-acpi-0": {
"Adapter": "ACPI interface",
"temp1": {
"temp1_label": "temp1"
}
}
}`
temp, err := collector.parseSensorsJSON(jsonStr)
if err != nil {
t.Fatalf("unexpected error parsing sensors output: %v", err)
}
if temp == nil {
t.Fatalf("expected temperature struct, got nil")
}
if temp.Available {
t.Fatalf("expected temperature to be unavailable when no readings are present")
}
}
func TestParseSensorsJSON_WithCpuAndNvmeData(t *testing.T) {
collector := &TemperatureCollector{}
jsonStr := `{
"coretemp-isa-0000": {
"Package id 0": {"temp1_input": 45.5},
"Core 0": {"temp2_input": 43.0},
"Core 1": {"temp3_input": 44.2}
},
"nvme-pci-0400": {
"Composite": {"temp1_input": 38.75}
}
}`
temp, err := collector.parseSensorsJSON(jsonStr)
if err != nil {
t.Fatalf("unexpected error parsing sensors output: %v", err)
}
if temp == nil {
t.Fatalf("expected temperature struct, got nil")
}
if !temp.Available {
t.Fatalf("expected temperature to be available when readings are present")
}
if temp.CPUPackage != 45.5 {
t.Fatalf("expected cpu package temperature 45.5, got %.2f", temp.CPUPackage)
}
if temp.CPUMax <= 0 {
t.Fatalf("expected cpu max temperature to be greater than zero, got %.2f", temp.CPUMax)
}
if len(temp.Cores) != 2 {
t.Fatalf("expected two core temperatures, got %d", len(temp.Cores))
}
if len(temp.NVMe) != 1 {
t.Fatalf("expected one NVMe temperature, got %d", len(temp.NVMe))
}
if temp.NVMe[0].Temp != 38.75 {
t.Fatalf("expected NVMe temperature 38.75, got %.2f", temp.NVMe[0].Temp)
}
}