Add host temperature telemetry and history

This commit is contained in:
2026-10-13 23:39:04 -04:00
parent 09396ab584
commit 171f657876
28 changed files with 1466 additions and 32 deletions
+8 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Host } from '$lib/hosts.js';
import { cpuTemperature, formatTemperature } from '$lib/host-temperatures.js';
import { contextHref } from '$lib/navigation-context.js';
import {
hostListStatus,
@@ -68,7 +69,13 @@
? '—'
: `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td
>
<td class:stale>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
<td class:stale
>{hostCpuUsage(host.inventory.cpuUsagePercent)}<span class="secondary"
>CPU temp: {formatTemperature(
cpuTemperature(host.inventory.temperatures)?.celsius
)}</span
></td
>
<td class:stale
>{hostUsedSpace(
host.inventory.memoryTotal,
@@ -0,0 +1,79 @@
<script lang="ts">
import type { Host } from '$lib/hosts.js';
import { cpuTemperature, formatTemperature, temperatureLabel } from '$lib/host-temperatures.js';
import { hostReadingsStale } from '$ui/host-overview.js';
import { onMount } from 'svelte';
let { host }: { host: Host } = $props();
let now = $state(Date.now());
const stale = $derived(hostReadingsStale(host, now));
const sensors = $derived(host.inventory.temperatures ?? []);
const cpu = $derived(cpuTemperature(sensors));
onMount(() => {
const clock = setInterval(() => (now = Date.now()), 15_000);
return () => clearInterval(clock);
});
</script>
<div class:stale>
{formatTemperature(cpu?.celsius)}{#if stale && cpu}
(last reported){/if}
{#if cpu}<span class="source">{temperatureLabel(cpu)}</span>{/if}
{#if sensors.length}
<details>
<summary>Temperature sensors</summary>
<ul>
{#each sensors as sensor (sensor.id)}
<li>
<span
>{temperatureLabel(sensor)}<small
>{sensor.device} · {sensor.label} · {sensor.id.slice(-6)}</small
></span
><span>{formatTemperature(sensor.celsius)}</span>
</li>
{/each}
</ul>
{#if stale}<p>Last reported readings; waiting for a fresh host heartbeat.</p>{/if}
</details>
{:else}<span class="source">No supported temperature sensors reported.</span>{/if}
</div>
<style>
.source,
small {
display: block;
color: var(--text-secondary);
font-size: var(--text-xs);
margin-top: var(--space-1);
}
.stale {
color: var(--text-secondary);
}
details {
margin-top: var(--space-2);
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
summary:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
ul {
padding: 0;
list-style: none;
margin: var(--space-3) 0;
display: grid;
gap: var(--space-3);
}
li {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-2);
font-variant-numeric: tabular-nums;
}
p {
font-size: var(--text-xs);
}
</style>
@@ -0,0 +1,456 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { CapacityHistory, TemperatureHistorySeries } from '$lib/capacity.js';
import { formatTemperature } from '$lib/host-temperatures.js';
import { timeFormat } from '$ui/stores/time-zone.js';
let {
history,
scope,
controls
}: { history: CapacityHistory; scope: string; controls?: Snippet } = $props();
let visibility = $state<Record<string, boolean>>({});
let selectedAt = $state<number | null>(null);
const series = $derived(history.temperatures);
const visible = $derived(series.filter((s) => visibility[s.id] ?? s.kind === 'cpu'));
const values = $derived(
series.flatMap((s) => s.points.flatMap((p) => (p.celsius === null ? [] : [p.celsius])))
);
const minimum = $derived(
values.length
? Math.floor((values.reduce((a, b) => Math.min(a, b), Infinity) - 5) / 10) * 10
: 0
);
const maximum = $derived(
values.length
? Math.ceil((values.reduce((a, b) => Math.max(a, b), -Infinity) + 5) / 10) * 10
: 100
);
const times = $derived(history.points.map((p) => p.at));
const measured = $derived(visible.some((s) => s.points.some((p) => p.celsius !== null)));
const selectedIndex = $derived.by(() => {
if (selectedAt !== null)
return times.reduce(
(best, at, i) =>
Math.abs(at - selectedAt!) < Math.abs(times[best] - selectedAt!) ? i : best,
0
);
for (let i = times.length - 1; i >= 0; i--)
if (visible.some((s) => s.points[i]?.celsius != null)) return i;
return Math.max(0, times.length - 1);
});
const selectedTime = $derived(times[selectedIndex]);
const readout = $derived(
visible
.map((s) => `${s.label}: ${formatTemperature(s.points[selectedIndex]?.celsius)}`)
.join('. ')
);
const ticks = [0, 0.25, 0.5, 0.75, 1];
function color(s: TemperatureHistorySeries) {
return `var(--chart-series-${(series.indexOf(s) % 6) + 1})`;
}
function dash(s: TemperatureHistorySeries) {
return ['', '7 4', '2 4'][Math.floor(series.indexOf(s) / 6) % 3];
}
function x(at: number) {
return (
8 +
984 *
Math.max(
0,
Math.min(
1,
(at - history.startAt) / Math.max(1, history.endAt - history.startAt)
)
)
);
}
function y(celsius: number) {
return 230 - (220 * (celsius - minimum)) / (maximum - minimum);
}
function segments(s: TemperatureHistorySeries) {
const result: { x: number; y: number }[][] = [];
let segment: { x: number; y: number }[] = [];
for (const point of s.points) {
if (point.celsius === null) {
if (segment.length) result.push(segment);
segment = [];
} else segment.push({ x: x(point.at), y: y(point.celsius) });
}
if (segment.length) result.push(segment);
return result;
}
function inspect(event: PointerEvent) {
if (!times.length || (event.type === 'pointermove' && event.pointerType === 'touch'))
return;
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
selectedAt =
history.startAt +
Math.max(
0,
Math.min(1, (((event.clientX - rect.left) / rect.width) * 1000 - 8) / 984)
) *
(history.endAt - history.startAt);
}
function move(event: KeyboardEvent) {
if (!times.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key))
return;
event.preventDefault();
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? times.length - 1
: Math.max(
0,
Math.min(
times.length - 1,
selectedIndex + (event.key === 'ArrowLeft' ? -1 : 1)
)
);
selectedAt = times[next];
}
</script>
<section class="panel" id="temperatures" aria-labelledby="temperature-history-title">
<div class="heading">
<div>
<h2 id="temperature-history-title">Temperature history</h2>
<p>{scope} · Peak temperature per period</p>
</div>
{@render controls?.()}
</div>
<div class="legend" role="group" aria-label="Temperature series">
{#each series as sensor (sensor.id)}
<button
type="button"
aria-pressed={visibility[sensor.id] ?? sensor.kind === 'cpu'}
onclick={() =>
(visibility[sensor.id] = !(visibility[sensor.id] ?? sensor.kind === 'cpu'))}
>
<svg viewBox="0 0 24 8" aria-hidden="true"
><line
x1="0"
x2="24"
y1="4"
y2="4"
stroke={color(sensor)}
stroke-dasharray={dash(sensor)}
/></svg
>
{sensor.label}
</button>
{/each}
</div>
<div class="axis-title">Temperature (°C)</div>
<div class="chart-grid">
<div class="y-axis" aria-hidden="true">
{#each ticks as tick}<span style:top={`${(1 - tick) * 100}%`}
>{minimum + (maximum - minimum) * tick}</span
>{/each}
</div>
<div
class="plot"
role="slider"
tabindex="0"
aria-label="Temperature history. Use left and right arrows to inspect a period."
aria-valuemin="0"
aria-valuemax={Math.max(0, times.length - 1)}
aria-valuenow={selectedIndex}
aria-valuetext={selectedTime === undefined
? 'No temperature history'
: `${$timeFormat.period(selectedTime)}. ${readout || 'No sensors selected'}`}
onpointermove={inspect}
onpointerdown={inspect}
onkeydown={move}
>
<svg viewBox="0 0 1000 240" preserveAspectRatio="none" aria-hidden="true">
{#each ticks as tick}<line
class="grid-line"
x1="0"
x2="1000"
y1={y(minimum + (maximum - minimum) * tick)}
y2={y(minimum + (maximum - minimum) * tick)}
/>{/each}
{#each visible as sensor (sensor.id)}
<g data-series={sensor.id}>
{#each segments(sensor) as segment}
<path
class="trend"
stroke={color(sensor)}
stroke-dasharray={dash(sensor)}
d={segment
.map((point, i) => `${i ? 'L' : 'M'}${point.x},${point.y}`)
.join(' ')}
/>
{#if segment.length === 1}<circle
cx={segment[0].x}
cy={segment[0].y}
r="3"
fill={color(sensor)}
/>{/if}
{/each}
{#if sensor.points[selectedIndex]?.celsius != null}<circle
class="selected-point"
cx={x(selectedTime)}
cy={y(sensor.points[selectedIndex].celsius!)}
r="4"
fill={color(sensor)}
/>{/if}
</g>
{/each}
{#if selectedTime !== undefined && measured}<line
class="cursor"
x1={x(selectedTime)}
x2={x(selectedTime)}
y1="5"
y2="235"
/>{/if}
</svg>
{#if !measured}<div class="empty-chart">
<strong
>{visible.length || !series.length
? 'No temperature readings in this period'
: 'Select a sensor to show history'}</strong
>
{#if visible.length || !series.length}<span
>Readings appear when an agent reports supported temperature sensors.</span
>{/if}
</div>{/if}
</div>
<div class="x-axis" aria-hidden="true">
<span>{$timeFormat.period(history.startAt)}</span><span
>{$timeFormat.period(history.endAt)}</span
>
</div>
</div>
{#if selectedTime !== undefined && visible.length}
<div class="readout" aria-label="Selected temperature period">
<span>{$timeFormat.period(selectedTime)}</span>
<dl>
{#each visible as sensor (sensor.id)}<div>
<dt>{sensor.label}</dt>
<dd>{formatTemperature(sensor.points[selectedIndex]?.celsius)}</dd>
</div>{/each}
</dl>
</div>
{/if}
<details>
<summary>About this chart</summary>
<p>
Each CPU line represents one host: its hottest reported package or physical die, falling
back to its hottest core. Select a host to compare its CPU, NVMe drives and chipset.
Per-minute peaks are retained; longer periods show their highest reading. Gaps mean no
measurement was received in that period. Temperatures include all workloads and are
unaffected by the game filter. Colors identify series; they do not indicate safe or
unsafe temperatures.
</p>
</details>
</section>
<style>
.panel {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--space-5);
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
.heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.heading p {
color: var(--text-secondary);
font-size: var(--text-sm);
margin: var(--space-2) 0 0;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-5);
margin: var(--space-4) 0;
}
.legend button {
display: flex;
align-items: center;
gap: var(--space-2);
background: none;
border: 0;
padding: var(--space-2) 0;
color: var(--text-primary);
font: inherit;
font-size: var(--text-sm);
cursor: pointer;
overflow-wrap: anywhere;
max-width: 100%;
}
.legend button[aria-pressed='false'] {
color: var(--text-secondary);
}
.legend button[aria-pressed='false'] svg {
opacity: 0.35;
}
.legend svg {
width: 24px;
height: 8px;
flex-shrink: 0;
}
.legend line {
stroke-width: 3;
}
button:focus-visible,
.plot:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.axis-title,
.x-axis,
.y-axis {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.chart-grid {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
margin-top: 14px;
}
.y-axis {
position: relative;
height: 220px;
margin-top: 10px;
}
.y-axis span {
position: absolute;
right: 0;
transform: translateY(-50%);
}
.plot {
position: relative;
height: 240px;
cursor: crosshair;
touch-action: pan-y;
}
.plot > svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
.plot path,
.plot line {
vector-effect: non-scaling-stroke;
}
.grid-line {
stroke: var(--border-subtle);
stroke-width: 1;
}
.trend {
fill: none;
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.selected-point {
stroke: var(--surface);
stroke-width: 2;
}
.cursor {
stroke: var(--text-tertiary);
stroke-width: 1;
stroke-dasharray: 4 5;
}
.x-axis {
grid-column: 2;
display: flex;
justify-content: space-between;
gap: var(--space-4);
}
.x-axis span:last-child {
text-align: right;
}
.empty-chart {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
text-align: center;
pointer-events: none;
}
.empty-chart strong {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
}
.empty-chart span {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.readout {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
font-size: var(--text-xs);
margin: var(--space-4) 0;
}
.readout > span,
dt {
color: var(--text-secondary);
}
dl {
display: flex;
flex-wrap: wrap;
gap: var(--space-3) var(--space-5);
margin: 0;
}
dl > div {
display: flex;
flex-wrap: wrap;
min-width: 0;
gap: var(--space-2);
}
dt {
overflow-wrap: anywhere;
}
dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
summary {
cursor: pointer;
font-size: var(--text-xs);
color: var(--text-secondary);
padding: var(--space-2) 0;
}
details p {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.65;
}
@media (max-width: 640px) {
.panel {
padding: var(--space-4);
}
.chart-grid {
grid-template-columns: 32px minmax(0, 1fr);
gap: 8px;
}
}
@media (pointer: coarse) {
.legend button {
min-height: 44px;
}
}
</style>
+6
View File
@@ -78,6 +78,12 @@
/* Metrics: unknown uses a pattern as well as color. */
--chart-active: var(--accent-primary);
--chart-series-1: var(--accent-primary);
--chart-series-2: #b99af7;
--chart-series-3: #69c6ba;
--chart-series-4: #dfb76c;
--chart-series-5: #df91b6;
--chart-series-6: #bbc7db;
--chart-idle: #2b3442;
--chart-unknown-base: #605b64;
--chart-unknown-stripe: #a7a0ad;