/** * IPMI sensor collector via ipmitool. * * Enable: PEARDATA_IPMI=1 * * Charts: sensors.ipmi.temp.*, sensors.ipmi.fan.* */ import { execFile } from 'child_process' import { promisify } from 'util' import { CollectorPlugin } from './plugin.js' import { registerChart } from '../../../shared/metrics.js' import logger from '../../utils/logger.js' const log = logger.child('ipmi') const execFileAsync = promisify(execFile) export function isIpmiEnabled() { const v = process.env.PEARDATA_IPMI return v === '1' || v === 'on' || v === 'true' } /** * @param {string} name */ function sanitizeSensor(name) { return String(name) .replace(/[^a-zA-Z0-9_.-]/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 64) } /** * @param {'temp'|'fan'} kind * @param {string} name */ function makeIpmiChart(kind, name) { const safe = sanitizeSensor(name) const id = kind === 'temp' ? `sensors.ipmi.temp.${safe}` : `sensors.ipmi.fan.${safe}` return { id, name: id, context: kind === 'temp' ? 'sensors.ipmi.temperature' : 'sensors.ipmi.fan', title: kind === 'temp' ? `IPMI temperature ${name}` : `IPMI fan ${name}`, units: kind === 'temp' ? 'Celsius' : 'RPM', family: 'ipmi', chartType: 'line', priority: kind === 'temp' ? 7020 : 7030, plugin: 'ipmi', dimensions: [{ id: 'value', name: 'value', algorithm: 'absolute' }], } } /** * Parse `ipmitool sensor` output lines. * @param {string} stdout * @returns {Array<{ name: string, kind: 'temp'|'fan', value: number }>} */ export function parseIpmitoolSensor(stdout) { /** @type {Array<{ name: string, kind: 'temp'|'fan', value: number }>} */ const out = [] for (const line of String(stdout || '').split('\n')) { const t = line.trim() if (!t) continue const parts = t.split('|').map((s) => s.trim()) if (parts.length < 4) continue const name = parts[0] const reading = parts[1] const type = parts[2]?.toLowerCase() || '' const lowerName = name.toLowerCase() const isTemp = type.includes('degrees') || type.includes('temperature') || /temp|degrees c/i.test(lowerName) const isFan = type.includes('rpm') || /fan|rpm/i.test(lowerName) if (!isTemp && !isFan) continue const val = Number(String(reading).replace(/[^\d.-]/g, '')) if (!Number.isFinite(val)) continue out.push({ name, kind: isFan ? 'fan' : 'temp', value: val }) } return out } export async function readIpmitoolSensors(timeoutMs = 5000) { const { stdout } = await execFileAsync('ipmitool', ['sensor'], { timeout: timeoutMs, encoding: 'utf8', }) return parseIpmitoolSensor(stdout) } export class IpmiCollector extends CollectorPlugin { constructor(opts = {}) { super({ name: 'ipmi', intervalMs: opts.intervalMs }) /** @type {boolean|null} */ this._available = null } isEnabled() { return isIpmiEnabled() } start() { if (!this.isEnabled()) return log.info('IPMI collector started') super.start() } async collect() { let sensors try { sensors = await readIpmitoolSensors() this._available = true } catch (err) { if (this._available !== false) { log.info('ipmitool unavailable — collector idle', { error: err.message }) this._available = false } return [] } const ts = Date.now() /** @type {Array<{ chart: string, context: string, ts: number, values: Record }>} */ const batch = [] for (const s of sensors) { const def = makeIpmiChart(s.kind, s.name) registerChart(def) batch.push({ chart: def.id, context: def.context, ts, values: { value: s.value }, }) } return batch } } /** @type {IpmiCollector|null} */ let singleton = null export function getIpmiCollector() { if (!singleton) singleton = new IpmiCollector() return singleton }