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
+151
View File
@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import {
cpuTemperature,
historyTemperatures,
validateTemperatures,
type HostTemperatureSensor
} from '../../src/lib/host-temperatures.js';
const sensor = (changes: Partial<HostTemperatureSensor> = {}): HostTemperatureSensor => ({
id: 'package',
device: 'coretemp',
label: 'Package id 0',
kind: 'cpu-package',
celsius: 65,
...changes
});
describe('temperature readings', () => {
it('keeps valid sensors while dropping malformed, duplicate and out-of-range entries', () => {
const good = sensor();
const readings = [
good,
good,
null,
'bad',
...[NaN, Infinity, -Infinity, null, '65', true, -101, 201].map((celsius) => ({
...good,
id: 'invalid',
celsius
})),
{ ...good, id: 'x'.repeat(101) },
{ ...good, label: '' },
{ ...good, kind: 'cpu-control' },
sensor({ id: 'zero', celsius: 0 })
];
expect(validateTemperatures(readings)).toEqual([good, sensor({ id: 'zero', celsius: 0 })]);
expect(validateTemperatures({})).toEqual([]);
expect(validateTemperatures(undefined)).toEqual([]);
});
it('chooses the hottest package and uses die/core readings only as fallbacks', () => {
const packages = [sensor(), sensor({ id: 'socket2', label: 'Package id 1', celsius: 72 })];
const core = sensor({ id: 'core', label: 'Core 0', kind: 'cpu-core', celsius: 80 });
const die = sensor({ id: 'die', label: 'Tdie', kind: 'cpu-die', celsius: 64 });
expect(cpuTemperature([...packages, core])?.celsius).toBe(72);
expect(cpuTemperature([die, core])?.celsius).toBe(64);
expect(cpuTemperature([core])?.celsius).toBe(80);
expect(cpuTemperature()).toBeNull();
const drive = sensor({
id: 'drive',
kind: 'nvme',
device: 'nvme',
label: 'Composite',
celsius: 50.9
});
expect(
historyTemperatures([
...packages,
core,
drive,
{ ...drive, id: 'internal', label: 'Sensor 1' }
])
).toEqual([{ ...packages[1], id: 'cpu', label: 'CPU temperature' }, drive]);
});
});
describe('temperature persistence', () => {
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => {
vi.restoreAllMocks();
await fixture.cleanup();
});
it('accepts older agents and faulty temperature payloads without losing host metrics', async () => {
const { createHost, heartbeat, validateInventory } =
await import('../../src/lib/server/hosts/index.js');
const host = createHost({ name: 'ec-2' });
expect(validateInventory({ cpuUsagePercent: 25 }).temperatures).toBeUndefined();
expect(
heartbeat(host.id, {
cpuUsagePercent: 25,
temperatures: [sensor(), { celsius: 'bad' }]
}).inventory
).toMatchObject({ cpuUsagePercent: 25, temperatures: [sensor()] });
expect(
heartbeat(host.id, { cpuUsagePercent: 30, temperatures: [] }).inventory.temperatures
).toEqual([]);
expect(
heartbeat(host.id, { cpuUsagePercent: 35, temperatures: 'bad' }).inventory.temperatures
).toEqual([]);
const { db } = await import('../../src/lib/server/db/index.js');
expect(
db()
.prepare('SELECT celsius FROM ec_host_temperature_samples WHERE host_id=?')
.all(host.id)
).toEqual([{ celsius: 65 }]);
});
it('retains one peak per minute and applies host history retention, including offline hosts', async () => {
const { createHost } = await import('../../src/lib/server/hosts/index.js');
const { recordTemperatures } = await import('../../src/lib/server/hosts/temperatures.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { fleetSettings, saveFleetSettings } =
await import('../../src/lib/server/settings.js');
const { pruneSampleHistory } = await import('../../src/lib/server/retention.js');
const host = createHost({ name: 'ec-2' });
const minute = Math.floor((Date.now() - 3 * 86400_000) / 60_000) * 60_000;
for (const [i, celsius] of [65, 78, 67].entries())
recordTemperatures(
host.id,
{ temperatures: [sensor({ celsius })] },
new Date(minute + i * 15_000).toISOString()
);
expect(db().prepare('SELECT celsius FROM ec_host_temperature_samples').all()).toEqual([
{ celsius: 78 }
]);
saveFleetSettings({ ...fleetSettings(), hostResourceKeepDays: 0 });
pruneSampleHistory();
expect(db().prepare('SELECT COUNT(*) AS n FROM ec_host_temperature_samples').get()).toEqual(
{ n: 1 }
);
recordTemperatures(host.id, { temperatures: [sensor()] }, new Date().toISOString());
saveFleetSettings({ ...fleetSettings(), hostResourceKeepDays: 1 });
pruneSampleHistory();
expect(db().prepare('SELECT celsius FROM ec_host_temperature_samples').all()).toEqual([
{ celsius: 65 }
]);
});
it('upgrades an existing database without rewriting its resource samples', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { createHost } = await import('../../src/lib/server/hosts/index.js');
const host = createHost({ name: 'old' });
db()
.prepare(
'INSERT INTO ec_host_resource_samples(host_id,sampled_at,cpu_usage_percent) VALUES(?,?,?)'
)
.run(host.id, '2026-10-13T12:00:00Z', 25);
db().exec('DROP TABLE ec_host_temperature_samples; PRAGMA user_version=22;');
closeDb();
expect(db().prepare('SELECT * FROM ec_host_temperature_samples').all()).toEqual([]);
expect(
db().prepare('SELECT cpu_usage_percent FROM ec_host_resource_samples').all()
).toEqual([{ cpu_usage_percent: 25 }]);
expect(db().pragma('foreign_key_check')).toEqual([]);
});
});