Add host temperature telemetry and history
This commit is contained in:
@@ -30,6 +30,14 @@ export interface CapacityHistory {
|
||||
endAt: number;
|
||||
bucketMs: number;
|
||||
points: CapacityHistoryPoint[];
|
||||
temperatures: TemperatureHistorySeries[];
|
||||
}
|
||||
export interface TemperatureHistorySeries {
|
||||
id: string;
|
||||
hostId: string;
|
||||
label: string;
|
||||
kind: 'cpu' | 'nvme' | 'chipset';
|
||||
points: { at: number; celsius: number | null }[];
|
||||
}
|
||||
export interface CapacityHost {
|
||||
id: string;
|
||||
|
||||
@@ -305,7 +305,7 @@ export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
|
||||
'Finished non-update maintenance. Update attempts remain durable to prevent duplicate automatic retries.',
|
||||
recoveryKeepDays:
|
||||
'Resolved watchdog incidents with no unfinished recovery. Dismissed incidents remain as suppression evidence.',
|
||||
hostResourceKeepDays: 'Host CPU and memory history.',
|
||||
hostResourceKeepDays: 'Host CPU, memory and temperature history.',
|
||||
instanceResourceKeepDays:
|
||||
'Minute resource samples. Capacity learning continues to use its independent 24-hour window.',
|
||||
metricsKeepDays: 'Historical fleet occupancy and utilization samples.',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
export const temperatureKinds = ['cpu-package', 'cpu-die', 'cpu-core', 'nvme', 'chipset'] as const;
|
||||
export type TemperatureKind = (typeof temperatureKinds)[number];
|
||||
export interface HostTemperatureSensor {
|
||||
id: string;
|
||||
device: string;
|
||||
label: string;
|
||||
kind: TemperatureKind;
|
||||
celsius: number;
|
||||
}
|
||||
|
||||
/** Optional telemetry must never make an otherwise valid heartbeat fail. */
|
||||
export function validateTemperatures(value: unknown): HostTemperatureSensor[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
return value.slice(0, 128).flatMap((sensor) => {
|
||||
if (
|
||||
!sensor ||
|
||||
typeof sensor !== 'object' ||
|
||||
Array.isArray(sensor) ||
|
||||
typeof sensor.id !== 'string' ||
|
||||
!/^[a-zA-Z0-9_-]{1,100}$/.test(sensor.id) ||
|
||||
seen.has(sensor.id) ||
|
||||
!['device', 'label'].every(
|
||||
(key) =>
|
||||
typeof sensor[key] === 'string' &&
|
||||
sensor[key].length > 0 &&
|
||||
sensor[key].length <= 120
|
||||
) ||
|
||||
!temperatureKinds.includes(sensor.kind) ||
|
||||
typeof sensor.celsius !== 'number' ||
|
||||
!Number.isFinite(sensor.celsius) ||
|
||||
sensor.celsius < -100 ||
|
||||
sensor.celsius > 200
|
||||
)
|
||||
return [];
|
||||
seen.add(sensor.id);
|
||||
return [
|
||||
{
|
||||
id: sensor.id,
|
||||
device: sensor.device,
|
||||
label: sensor.label,
|
||||
kind: sensor.kind,
|
||||
celsius: sensor.celsius
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function cpuTemperature(
|
||||
sensors: HostTemperatureSensor[] = []
|
||||
): HostTemperatureSensor | null {
|
||||
// Prefer physical package/die readings. Cores are a fallback, never mixed
|
||||
// into an average that might hide a hot package on a multi-socket host.
|
||||
for (const kind of ['cpu-package', 'cpu-die', 'cpu-core'] as const) {
|
||||
const candidates = sensors.filter((sensor) => sensor.kind === kind);
|
||||
if (candidates.length)
|
||||
return candidates.reduce((hot, sensor) =>
|
||||
sensor.celsius > hot.celsius ? sensor : hot
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function temperatureLabel(sensor: HostTemperatureSensor): string {
|
||||
if (sensor.kind === 'cpu-package')
|
||||
return `CPU package ${sensor.label.replace(/^Package id /, '')}`;
|
||||
if (sensor.kind === 'cpu-core') return `CPU ${sensor.label.toLowerCase()}`;
|
||||
if (sensor.kind === 'cpu-die') return `CPU die · ${sensor.label}`;
|
||||
if (sensor.kind === 'chipset') return 'Chipset';
|
||||
return `NVMe · ${sensor.label}`;
|
||||
}
|
||||
|
||||
export function formatTemperature(celsius: number | null | undefined): string {
|
||||
return typeof celsius === 'number' && Number.isFinite(celsius)
|
||||
? `${Number(celsius.toFixed(1))} °C`
|
||||
: 'Unavailable';
|
||||
}
|
||||
|
||||
/** One CPU series per host, plus bounded device series for the host drill-down. */
|
||||
export function historyTemperatures(
|
||||
sensors: HostTemperatureSensor[] = []
|
||||
): HostTemperatureSensor[] {
|
||||
const cpu = cpuTemperature(sensors);
|
||||
const devices = sensors
|
||||
.filter(
|
||||
(sensor) =>
|
||||
sensor.kind === 'chipset' ||
|
||||
(sensor.kind === 'nvme' && sensor.label.toLowerCase() === 'composite')
|
||||
)
|
||||
.slice(0, 16);
|
||||
return [...(cpu ? [{ ...cpu, id: 'cpu', label: 'CPU temperature' }] : []), ...devices];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HostLibraryCache } from './game-files.js';
|
||||
import type { HostTemperatureSensor } from './host-temperatures.js';
|
||||
|
||||
export type HostMode = 'active' | 'draining' | 'disabled';
|
||||
export type HostReadiness =
|
||||
@@ -149,6 +150,7 @@ export interface HostInventory {
|
||||
cpu?: string;
|
||||
cpuCount?: number;
|
||||
cpuUsagePercent?: number;
|
||||
temperatures?: HostTemperatureSensor[];
|
||||
memoryTotal?: number;
|
||||
memoryAvailable?: number;
|
||||
storageTotal?: number;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
||||
import { migratePlayerScoreTotal } from './player-score-total.js';
|
||||
import { migrateBank } from './bank.js';
|
||||
import { migrateBankCredentials } from './bank-credentials.js';
|
||||
import { migrateTemperatures } from '../hosts/temperatures.js';
|
||||
import type { Migration } from './migration-runner.js';
|
||||
|
||||
// Append only: never renumber entries or change an applied migration to add an
|
||||
@@ -118,7 +119,8 @@ export const migrations: readonly Migration[] = [
|
||||
},
|
||||
{ version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank },
|
||||
{ version: 21, name: 'instance-owned bank credentials', run: migrateBankCredentials },
|
||||
{ version: 22, name: 'remembered browser sessions', run: migrateRememberedSessions }
|
||||
{ version: 22, name: 'remembered browser sessions', run: migrateRememberedSessions },
|
||||
{ version: 23, name: 'host temperature history', run: migrateTemperatures }
|
||||
];
|
||||
|
||||
export const databaseVersion = migrations.length;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from '../db/index.js';
|
||||
import type { CapacityHistory, CapacityQuery, CapacityRange } from '../../capacity.js';
|
||||
import { readTemperatureHistory } from './temperatures.js';
|
||||
|
||||
const windows: Record<CapacityRange, { duration: number; bucketMs: number }> = {
|
||||
'1h': { duration: 3_600_000, bucketMs: 60_000 },
|
||||
@@ -15,10 +16,14 @@ export function readCapacityHistory(query: CapacityQuery, now = Date.now()): Cap
|
||||
const count = duration / bucketMs;
|
||||
const hosts = handle
|
||||
.prepare(
|
||||
`SELECT id,created_at FROM ec_hosts
|
||||
`SELECT id,name,created_at FROM ec_hosts
|
||||
WHERE deleted_at IS NULL ${query.hostId ? 'AND id=?' : ''} ORDER BY id`
|
||||
)
|
||||
.all(...(query.hostId ? [query.hostId] : [])) as { id: string; created_at: string }[];
|
||||
.all(...(query.hostId ? [query.hostId] : [])) as {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}[];
|
||||
// The primary key bounds each host scan to the selected interval. Aggregate
|
||||
// before crossing the worker boundary; do not return raw heartbeat rows.
|
||||
const samples = handle.prepare(`SELECT
|
||||
@@ -69,5 +74,12 @@ export function readCapacityHistory(query: CapacityQuery, now = Date.now()): Cap
|
||||
point.memoryBytesAvailable = null;
|
||||
}
|
||||
}
|
||||
return { range: query.range, startAt, endAt: now, bucketMs, points };
|
||||
return {
|
||||
range: query.range,
|
||||
startAt,
|
||||
endAt: now,
|
||||
bucketMs,
|
||||
points,
|
||||
temperatures: readTemperatureHistory(query, hosts, startAt, now, bucketMs)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { logResultMetadata } from './log-results.js';
|
||||
import { parseHostInventory } from './request-context.js';
|
||||
import { fleetSettings } from '../settings.js';
|
||||
import { recordInstanceResources, validateInstanceResources } from './calibration.js';
|
||||
import { validateTemperatures } from '../../host-temperatures.js';
|
||||
import { recordTemperatures } from './temperatures.js';
|
||||
import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js';
|
||||
import { hostAgentLaneLabel, hostAgentLaneState } from '../../hosts.js';
|
||||
import type {
|
||||
@@ -622,6 +624,8 @@ export function validateInventory(value: unknown): HostInventory {
|
||||
throw new Error('Host inventory must be an object.');
|
||||
const inventory = value as HostInventory;
|
||||
if (JSON.stringify(inventory).length > 256_000) throw new Error('Host inventory is too large.');
|
||||
if (inventory.temperatures !== undefined)
|
||||
inventory.temperatures = validateTemperatures(inventory.temperatures);
|
||||
for (const key of ['hostname', 'agentVersion', 'os', 'architecture', 'cpu', 'dockerVersion']) {
|
||||
if (
|
||||
inventory[key] !== undefined &&
|
||||
@@ -924,6 +928,7 @@ export function heartbeat(hostId: string, value: unknown): Host {
|
||||
|
||||
function recordResources(hostId: string, inventory: HostInventory, timestamp: string): void {
|
||||
recordInstanceResources(hostId, inventory, timestamp);
|
||||
recordTemperatures(hostId, inventory, timestamp);
|
||||
database()
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO ec_host_resource_samples
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { db } from '../db/index.js';
|
||||
import { historyTemperatures } from '../../host-temperatures.js';
|
||||
import type { HostInventory } from '../../hosts.js';
|
||||
import type { CapacityQuery, TemperatureHistorySeries } from '../../capacity.js';
|
||||
|
||||
export function migrateTemperatures(handle: Database.Database): void {
|
||||
handle.exec(`CREATE TABLE IF NOT EXISTS ec_host_temperature_samples (
|
||||
host_id TEXT NOT NULL REFERENCES ec_hosts(id), bucket TEXT NOT NULL,
|
||||
sensor_id TEXT NOT NULL, kind TEXT NOT NULL, label TEXT NOT NULL,
|
||||
celsius REAL NOT NULL CHECK(celsius BETWEEN -100 AND 200),
|
||||
PRIMARY KEY(host_id,bucket,sensor_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ec_host_temperatures_retention ON ec_host_temperature_samples(bucket);`);
|
||||
}
|
||||
|
||||
export function recordTemperatures(
|
||||
hostId: string,
|
||||
inventory: HostInventory,
|
||||
timestamp: string
|
||||
): void {
|
||||
const sensors = historyTemperatures(inventory.temperatures);
|
||||
if (!sensors.length) return;
|
||||
const bucket = new Date(Math.floor(Date.parse(timestamp) / 60_000) * 60_000).toISOString();
|
||||
const insert = db().prepare(`INSERT INTO ec_host_temperature_samples
|
||||
(host_id,bucket,sensor_id,kind,label,celsius) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(host_id,bucket,sensor_id) DO UPDATE SET
|
||||
celsius=MAX(celsius,excluded.celsius),kind=excluded.kind,label=excluded.label`);
|
||||
for (const sensor of sensors)
|
||||
insert.run(hostId, bucket, sensor.id, sensor.kind, sensor.label, sensor.celsius);
|
||||
}
|
||||
|
||||
/** Runs on the capacity report worker, returning at most 168 points per series. */
|
||||
export function readTemperatureHistory(
|
||||
query: CapacityQuery,
|
||||
hosts: { id: string; name: string; created_at: string }[],
|
||||
startAt: number,
|
||||
endAt: number,
|
||||
bucketMs: number
|
||||
): TemperatureHistorySeries[] {
|
||||
const count = Math.round((endAt - startAt) / bucketMs);
|
||||
const statement = db().prepare(`SELECT sensor_id,kind,label,
|
||||
MIN(?,MAX(0,CAST((CAST(strftime('%s',bucket) AS INTEGER)*1000-?) / ? AS INTEGER))) AS period,
|
||||
MAX(celsius) AS celsius
|
||||
FROM ec_host_temperature_samples WHERE host_id=? AND bucket>=? AND bucket<=?
|
||||
${query.hostId ? '' : "AND sensor_id='cpu'"}
|
||||
GROUP BY sensor_id,period ORDER BY sensor_id,period`);
|
||||
const result: TemperatureHistorySeries[] = [];
|
||||
for (const host of hosts) {
|
||||
const bySensor = new Map<string, TemperatureHistorySeries>();
|
||||
const add = (id: string, kind: TemperatureHistorySeries['kind'], label: string) => {
|
||||
const series = {
|
||||
id: `${host.id}:${id}`,
|
||||
hostId: host.id,
|
||||
kind,
|
||||
label,
|
||||
points: Array.from({ length: count }, (_, i) => ({
|
||||
at: startAt + i * bucketMs,
|
||||
celsius: null as number | null
|
||||
}))
|
||||
};
|
||||
bySensor.set(id, series);
|
||||
result.push(series);
|
||||
return series;
|
||||
};
|
||||
add('cpu', 'cpu', query.hostId ? 'CPU temperature' : host.name);
|
||||
const rows = statement.all(
|
||||
count - 1,
|
||||
startAt,
|
||||
bucketMs,
|
||||
host.id,
|
||||
new Date(
|
||||
Math.max(
|
||||
Math.floor(Date.parse(host.created_at) / 60_000) * 60_000,
|
||||
Math.floor(startAt / 60_000) * 60_000
|
||||
)
|
||||
).toISOString(),
|
||||
new Date(endAt).toISOString()
|
||||
) as { sensor_id: string; kind: string; label: string; period: number; celsius: number }[];
|
||||
for (const row of rows) {
|
||||
let series = bySensor.get(row.sensor_id);
|
||||
if (!series) {
|
||||
if (!['nvme', 'chipset'].includes(row.kind) || bySensor.size >= 17) continue;
|
||||
const kind = row.kind as 'nvme' | 'chipset';
|
||||
const number = [...bySensor.values()].filter((s) => s.kind === kind).length + 1;
|
||||
series = add(
|
||||
row.sensor_id,
|
||||
kind,
|
||||
`${kind === 'nvme' ? 'NVMe drive' : 'Chipset'} ${number}`
|
||||
);
|
||||
}
|
||||
if (series.points[row.period]) series.points[row.period].celsius = row.celsius;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -556,6 +556,14 @@ function samplePolicies(s = fleetSettings()) {
|
||||
value: iso(s.hostResourceKeepDays),
|
||||
enabled: s.hostResourceKeepDays > 0
|
||||
},
|
||||
{
|
||||
category: 'host-temperatures',
|
||||
label: 'Host temperature samples',
|
||||
table: 'ec_host_temperature_samples',
|
||||
condition: 'bucket < ?',
|
||||
value: iso(s.hostResourceKeepDays),
|
||||
enabled: s.hostResourceKeepDays > 0
|
||||
},
|
||||
{
|
||||
category: 'instance-resources',
|
||||
label: 'Instance resource samples',
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import CapacityHosts from '$ui/components/capacity/CapacityHosts.svelte';
|
||||
import CapacityChart from '$ui/components/capacity/CapacityChart.svelte';
|
||||
import TemperatureChart from '$ui/components/capacity/TemperatureChart.svelte';
|
||||
import RebalancePanel from '$ui/components/capacity/RebalancePanel.svelte';
|
||||
import Freshness from '$ui/components/Freshness.svelte';
|
||||
import { plural } from '$ui/plural.js';
|
||||
@@ -319,16 +320,20 @@
|
||||
: 'Waiting for host reports.'} Refreshes every 30 seconds while visible.
|
||||
</p>
|
||||
</details>
|
||||
<CapacityChart history={report.history} scope={scopeName}>
|
||||
{#snippet controls()}<nav class="ranges" aria-label="History range">
|
||||
{#each ranges as range}<a
|
||||
href={scopeUrl({ range: range.value })}
|
||||
aria-current={report.query.range === range.value ? 'page' : undefined}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll>{range.label}</a
|
||||
>{/each}
|
||||
</nav>{/snippet}
|
||||
</CapacityChart>
|
||||
{#snippet historyRanges()}<nav class="ranges" aria-label="History range">
|
||||
{#each ranges as range}<a
|
||||
href={scopeUrl({ range: range.value })}
|
||||
aria-current={report.query.range === range.value ? 'page' : undefined}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll>{range.label}</a
|
||||
>{/each}
|
||||
</nav>{/snippet}
|
||||
<CapacityChart history={report.history} scope={scopeName} controls={historyRanges} />
|
||||
{#key report.query.hostId}<TemperatureChart
|
||||
history={report.history}
|
||||
scope={scopeName}
|
||||
controls={historyRanges}
|
||||
/>{/key}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
||||
import HostPreparation from '$ui/components/HostPreparation.svelte';
|
||||
import CapacitySamples from '$ui/components/CapacitySamples.svelte';
|
||||
import HostTemperatures from '$ui/components/HostTemperatures.svelte';
|
||||
import ServerCard from '$ui/components/ServerCard.svelte';
|
||||
import type { ServerObservation } from '$lib/server-observation.js';
|
||||
import { syncServerTime } from '$ui/stores/servers.js';
|
||||
@@ -300,6 +301,8 @@
|
||||
: `${host.inventory.cpuUsagePercent.toFixed(1)}%`}{#if !host.connected && host.inventory.cpuUsagePercent !== undefined}
|
||||
(last reported){/if}
|
||||
</dd>
|
||||
<dt>CPU temperature</dt>
|
||||
<dd><HostTemperatures {host} /></dd>
|
||||
<dt>Memory available / total</dt>
|
||||
<dd>
|
||||
{bytes(host.inventory.memoryAvailable)} / {bytes(
|
||||
|
||||
Reference in New Issue
Block a user