refactor(admin): focus System tab temperatures on the CPU die
The Temperatures box listed every component sysinfo exposes (per-core, chipset, NVMe…), dominating the System tab. Surface only the CPU die/package reading and collapse the rest behind a disclosure. Add a tested pure helper, pickCpuTemp, that selects the best CPU sensor (Intel package → AMD Tctl/Tdie → hottest core → any cpu/coretemp → null), and render it via a shared Temp snippet with the remaining sensors in a default-closed "N other sensors" details element. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
43
frontend/src/lib/admin/temps.test.ts
Normal file
43
frontend/src/lib/admin/temps.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
25
frontend/src/lib/admin/temps.ts
Normal file
25
frontend/src/lib/admin/temps.ts
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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<typeof setInterval> | null = null;
|
||||
let storageTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -168,36 +176,19 @@
|
||||
<h2>Temperatures</h2>
|
||||
{#if stats.temperatures.length === 0}
|
||||
<p class="muted">Sensor data unavailable (not exposed in this environment).</p>
|
||||
{:else if cpuTemp}
|
||||
{@render Temp(cpuTemp)}
|
||||
{#if otherTemps.length > 0}
|
||||
<details class="more-temps">
|
||||
<summary>{otherTemps.length} other sensor{otherTemps.length === 1 ? '' : 's'}</summary>
|
||||
{#each otherTemps as t (t.label)}
|
||||
{@render Temp(t)}
|
||||
{/each}
|
||||
</details>
|
||||
{/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))}
|
||||
<div class="temp">
|
||||
<div class="temp-head">
|
||||
<span class="temp-label">{t.label}</span>
|
||||
<span class="temp-val">{t.celsius.toFixed(0)}°C</span>
|
||||
</div>
|
||||
<div
|
||||
class="bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={t.celsius}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={limit}
|
||||
aria-label="{t.label} {t.celsius.toFixed(0)}°C"
|
||||
>
|
||||
<div
|
||||
class="fill"
|
||||
class:high={pct >= 90}
|
||||
class:mid={pct >= 70 && pct < 90}
|
||||
style:width="{pct}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="sub">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
{@render Temp(t)}
|
||||
{/each}
|
||||
{/if}
|
||||
</article>
|
||||
@@ -299,6 +290,37 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#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))}
|
||||
<div class="temp">
|
||||
<div class="temp-head">
|
||||
<span class="temp-label">{t.label}</span>
|
||||
<span class="temp-val">{t.celsius.toFixed(0)}°C</span>
|
||||
</div>
|
||||
<div
|
||||
class="bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={t.celsius}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={limit}
|
||||
aria-label="{t.label} {t.celsius.toFixed(0)}°C"
|
||||
>
|
||||
<div
|
||||
class="fill"
|
||||
class:high={pct >= 90}
|
||||
class:mid={pct >= 70 && pct < 90}
|
||||
style:width="{pct}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="sub">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet Bar({ percent }: { percent: number })}
|
||||
<div
|
||||
class="bar"
|
||||
@@ -400,6 +422,17 @@
|
||||
.temp + .temp {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.more-temps {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.more-temps summary {
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.more-temps .temp:first-of-type {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.temp-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user