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(); 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; }