From 274f36daa8612c36a21a80750eeb3522cad541f8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 12 Oct 2025 10:33:51 +0000 Subject: [PATCH] Improve dashboard responsiveness and temperature handling --- .../src/components/Dashboard/Dashboard.tsx | 26 +++---- .../src/components/Dashboard/GuestRow.tsx | 22 +++--- .../src/components/Dashboard/NodeCard.tsx | 45 ++++++++++-- .../components/shared/NodeSummaryTable.tsx | 58 +++++++++++----- frontend-modern/src/utils/temperature.ts | 53 +++++++++++++++ internal/monitoring/temperature.go | 11 ++- internal/monitoring/temperature_test.go | 68 +++++++++++++++++++ 7 files changed, 237 insertions(+), 46 deletions(-) create mode 100644 frontend-modern/src/utils/temperature.ts create mode 100644 internal/monitoring/temperature_test.go diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 69e77ac..4c2dfc9 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -698,12 +698,12 @@ export function Dashboard(props: DashboardProps) { 0}> - - + +
{/* Uptime */} {/* CPU */} - - {/* Network I/O */} - - diff --git a/frontend-modern/src/utils/temperature.ts b/frontend-modern/src/utils/temperature.ts new file mode 100644 index 0000000..ce3a61b --- /dev/null +++ b/frontend-modern/src/utils/temperature.ts @@ -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; +}; diff --git a/internal/monitoring/temperature.go b/internal/monitoring/temperature.go index 26a761f..82c4357 100644 --- a/internal/monitoring/temperature.go +++ b/internal/monitoring/temperature.go @@ -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 } diff --git a/internal/monitoring/temperature_test.go b/internal/monitoring/temperature_test.go new file mode 100644 index 0000000..49cde0c --- /dev/null +++ b/internal/monitoring/temperature_test.go @@ -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) + } +}
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' ? '▲' : '▼')} handleSort('type')} > Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('vmid')} > VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('uptime')} > Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('cpu')} > CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('memory')} > Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('disk')} > Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('diskRead')} > Disk Read{' '} {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('diskWrite')} > Disk Write{' '} {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('networkIn')} > Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('networkOut')} > Net Out{' '} diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 5bffe21..7c3d1e5 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -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) { {/* Type */} - +
{/* VMID */} -
+ {props.guest.vmid} @@ -317,7 +317,7 @@ export function GuestRow(props: GuestRowProps) { + -}> {/* Memory */} - +
-}> {/* Disk – surface usage even if guest is currently stopped so users can see last reported values */} -
+ {/* Disk I/O */} - +
+
+
+
diff --git a/frontend-modern/src/components/Dashboard/NodeCard.tsx b/frontend-modern/src/components/Dashboard/NodeCard.tsx index 22e2512..3796869 100644 --- a/frontend-modern/src/components/Dashboard/NodeCard.tsx +++ b/frontend-modern/src/components/Dashboard/NodeCard.tsx @@ -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 = (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 = (props) => { ↑{formatUptime(props.node.uptime)} ⚡{normalizedLoad()}} > 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 + + + {primaryTemperatureLabel()} + + diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index 477f454..9de5e38 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -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 = (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) => { switch (key) { case 'name': @@ -194,9 +204,7 @@ export const NodeSummaryTable: Component = (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 = (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 = (props) => {
-} > - = 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 - +
+ {(() => { + 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 ( + <> + + {value}°C + + + + {temperatureReading?.device ?? 'NVMe'} + + + + ); + })()} +