diff --git a/frontend/src/lib/admin/temps.test.ts b/frontend/src/lib/admin/temps.test.ts new file mode 100644 index 0000000..2c1a57e --- /dev/null +++ b/frontend/src/lib/admin/temps.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { pickCpuTemp } from './temps'; +import type { TempStat } from '$lib/api/admin'; + +function t(label: string, celsius: number): TempStat { + return { label, celsius, max_celsius: celsius, critical_celsius: 100 }; +} + +describe('pickCpuTemp', () => { + it('returns null when there are no sensors', () => { + expect(pickCpuTemp([])).toBeNull(); + }); + + it('prefers the Intel package sensor over individual cores', () => { + const temps = [ + t('coretemp Core 0', 50), + t('coretemp Core 1', 49), + t('coretemp Package id 0', 51), + t('acpitz temp1', 28) + ]; + expect(pickCpuTemp(temps)?.label).toBe('coretemp Package id 0'); + }); + + it('falls back to the AMD Tctl/Tdie sensor when there is no package', () => { + const temps = [t('k10temp Tctl', 55), t('nvme Composite', 41)]; + expect(pickCpuTemp(temps)?.label).toBe('k10temp Tctl'); + }); + + it('falls back to the hottest core when only per-core sensors exist', () => { + const temps = [t('coretemp Core 0', 50), t('coretemp Core 3', 58), t('coretemp Core 1', 49)]; + expect(pickCpuTemp(temps)?.label).toBe('coretemp Core 3'); + }); + + it('falls back to any cpu/coretemp-labelled sensor as a last resort', () => { + const temps = [t('nvme Composite', 41), t('cpu_thermal', 47)]; + expect(pickCpuTemp(temps)?.label).toBe('cpu_thermal'); + }); + + it('returns null when nothing looks like a CPU sensor', () => { + const temps = [t('nvme Composite', 41), t('acpitz temp1', 28)]; + expect(pickCpuTemp(temps)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/admin/temps.ts b/frontend/src/lib/admin/temps.ts new file mode 100644 index 0000000..1d33dbc --- /dev/null +++ b/frontend/src/lib/admin/temps.ts @@ -0,0 +1,25 @@ +import type { TempStat } from '$lib/api/admin'; + +/** Pick the sensor that best represents the CPU die/package temperature + * from a heterogeneous component list. Intel exposes + * `coretemp Package id 0`; AMD exposes `k10temp Tctl`/`Tdie`. When neither + * is present we fall back to the hottest individual core, then to any + * CPU-ish sensor, and finally `null` (nothing CPU-related — e.g. only NVMe + * / chipset sensors are exposed). Used to keep the System tab focused on + * the one reading operators care about, with the rest tucked away. */ +export function pickCpuTemp(temps: TempStat[]): TempStat | null { + const byLabel = (re: RegExp) => temps.find((t) => re.test(t.label)) ?? null; + return ( + byLabel(/package/i) ?? + byLabel(/tctl|tdie|k10temp/i) ?? + hottestCore(temps) ?? + byLabel(/cpu|coretemp/i) ?? + null + ); +} + +function hottestCore(temps: TempStat[]): TempStat | null { + const cores = temps.filter((t) => /core\s*\d+/i.test(t.label)); + if (cores.length === 0) return null; + return cores.reduce((a, b) => (b.celsius > a.celsius ? b : a)); +} diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index 5b15d67..8d28f52 100644 --- a/frontend/src/routes/admin/system/+page.svelte +++ b/frontend/src/routes/admin/system/+page.svelte @@ -8,8 +8,16 @@ type StorageStats } from '$lib/api/admin'; import { formatBytes as fmtBytes } from '$lib/upload-validation'; + import { pickCpuTemp } from '$lib/admin/temps'; let stats: SystemStats | null = $state(null); + + // The component list is noisy (every core, chipset, NVMe…). Surface the + // CPU die/package reading and tuck the rest behind a disclosure. + const cpuTemp = $derived.by(() => (stats ? pickCpuTemp(stats.temperatures) : null)); + const otherTemps = $derived.by(() => + stats ? stats.temperatures.filter((t) => t !== cpuTemp) : [] + ); let error: string | null = $state(null); let timer: ReturnType | null = null; let storageTimer: ReturnType | null = null; @@ -168,36 +176,19 @@

Temperatures

{#if stats.temperatures.length === 0}

Sensor data unavailable (not exposed in this environment).

+ {:else if cpuTemp} + {@render Temp(cpuTemp)} + {#if otherTemps.length > 0} +
+ {otherTemps.length} other sensor{otherTemps.length === 1 ? '' : 's'} + {#each otherTemps as t (t.label)} + {@render Temp(t)} + {/each} +
+ {/if} {:else} {#each stats.temperatures as t (t.label)} - {@const limit = t.critical_celsius ?? 100} - {@const pct = Math.min(100, Math.max(0, (t.celsius / limit) * 100))} -
-
- {t.label} - {t.celsius.toFixed(0)}°C -
-
-
= 90} - class:mid={pct >= 70 && pct < 90} - style:width="{pct}%" - >
-
-

- {#if t.max_celsius !== null}max {t.max_celsius.toFixed(0)}°C{/if} - {#if t.critical_celsius !== null} - · crit {t.critical_celsius.toFixed(0)}°C{/if} -

-
+ {@render Temp(t)} {/each} {/if} @@ -299,6 +290,37 @@ {/if} +{#snippet Temp(t: { label: string; celsius: number; max_celsius: number | null; critical_celsius: number | null })} + {@const limit = t.critical_celsius ?? 100} + {@const pct = Math.min(100, Math.max(0, (t.celsius / limit) * 100))} +
+
+ {t.label} + {t.celsius.toFixed(0)}°C +
+
+
= 90} + class:mid={pct >= 70 && pct < 90} + style:width="{pct}%" + >
+
+

+ {#if t.max_celsius !== null}max {t.max_celsius.toFixed(0)}°C{/if} + {#if t.critical_celsius !== null} + · crit {t.critical_celsius.toFixed(0)}°C{/if} +

+
+{/snippet} + {#snippet Bar({ percent }: { percent: number })}