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
|
type StorageStats
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { formatBytes as fmtBytes } from '$lib/upload-validation';
|
import { formatBytes as fmtBytes } from '$lib/upload-validation';
|
||||||
|
import { pickCpuTemp } from '$lib/admin/temps';
|
||||||
|
|
||||||
let stats: SystemStats | null = $state(null);
|
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 error: string | null = $state(null);
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
let storageTimer: ReturnType<typeof setInterval> | null = null;
|
let storageTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -168,36 +176,19 @@
|
|||||||
<h2>Temperatures</h2>
|
<h2>Temperatures</h2>
|
||||||
{#if stats.temperatures.length === 0}
|
{#if stats.temperatures.length === 0}
|
||||||
<p class="muted">Sensor data unavailable (not exposed in this environment).</p>
|
<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}
|
{:else}
|
||||||
{#each stats.temperatures as t (t.label)}
|
{#each stats.temperatures as t (t.label)}
|
||||||
{@const limit = t.critical_celsius ?? 100}
|
{@render Temp(t)}
|
||||||
{@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>
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
@@ -299,6 +290,37 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 })}
|
{#snippet Bar({ percent }: { percent: number })}
|
||||||
<div
|
<div
|
||||||
class="bar"
|
class="bar"
|
||||||
@@ -400,6 +422,17 @@
|
|||||||
.temp + .temp {
|
.temp + .temp {
|
||||||
margin-top: var(--space-3);
|
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 {
|
.temp-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
Reference in New Issue
Block a user