Improve dashboard responsiveness and temperature handling
This commit is contained in:
parent
a74baed121
commit
274f36daa8
7 changed files with 237 additions and 46 deletions
|
|
@ -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{' '}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
53
frontend-modern/src/utils/temperature.ts
Normal file
53
frontend-modern/src/utils/temperature.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
68
internal/monitoring/temperature_test.go
Normal file
68
internal/monitoring/temperature_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue