Files
peardata/server/services/collectors/ipmi.js
T
Raven Scott 2e1a3e9b06
CI / test (push) Successful in 1m24s
Release rolling / release (push) Has been cancelled
Updates
2026-07-18 19:44:32 -04:00

144 lines
3.8 KiB
JavaScript

/**
* 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<string, number|null> }>} */
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
}