/** * SMART disk health collector via smartctl. * * Enable: PEARDATA_SMART=1 * * Charts: smart.temp.{dev}, smart.reallocated.{dev} */ 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('smart') const execFileAsync = promisify(execFile) const DEVICE_TIMEOUT_MS = 2000 export function isSmartEnabled() { const v = process.env.PEARDATA_SMART return v === '1' || v === 'on' || v === 'true' } /** * @param {string} dev */ function sanitizeDev(dev) { return String(dev) .replace(/[^a-zA-Z0-9_.-]/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 64) } /** * @param {string} dev * @param {'temp'|'reallocated'} kind */ function makeSmartChart(dev, kind) { const safe = sanitizeDev(dev) const id = kind === 'temp' ? `smart.temp.${safe}` : `smart.reallocated.${safe}` return { id, name: id, context: kind === 'temp' ? 'smart.temperature' : 'smart.reallocated', title: kind === 'temp' ? `SMART temperature ${dev}` : `SMART reallocated ${dev}`, units: kind === 'temp' ? 'Celsius' : 'sectors', family: safe, chartType: 'line', priority: kind === 'temp' ? 7100 : 7110, plugin: 'smart', dimensions: [{ id: 'value', name: 'value', algorithm: 'absolute' }], } } /** * @param {number} timeoutMs * @returns {Promise} */ export async function scanSmartDevices(timeoutMs = DEVICE_TIMEOUT_MS) { try { const { stdout } = await execFileAsync('smartctl', ['--scan'], { timeout: timeoutMs, encoding: 'utf8', }) /** @type {string[]} */ const devs = [] for (const line of String(stdout || '').split('\n')) { const parts = line.trim().split(/\s+/) if (parts[0] && !parts[0].startsWith('#')) devs.push(parts[0]) } return devs.length ? devs : null } catch { return null } } /** * @param {string} dev * @returns {Promise<{ temp: number|null, reallocated: number|null }|null>} */ export async function readSmartAttributes(dev) { try { const { stdout } = await execFileAsync('smartctl', ['-A', '-j', dev], { timeout: DEVICE_TIMEOUT_MS, encoding: 'utf8', }) const json = JSON.parse(stdout) const attrs = json?.ata_smart_attributes?.table || json?.table || [] let temp = null let reallocated = null for (const a of attrs) { const id = a.id ?? a.attr_id const val = a.raw?.value ?? a.raw_value ?? a.value if (id === 194 || a.name === 'Temperature_Celsius') temp = Number(val) if (id === 5 || a.name === 'Reallocated_Sector_Ct') reallocated = Number(val) } if (temp == null && json?.temperature?.current != null) temp = Number(json.temperature.current) return { temp: Number.isFinite(temp) ? temp : null, reallocated: Number.isFinite(reallocated) ? reallocated : null } } catch { try { const { stdout } = await execFileAsync('smartctl', ['-A', dev], { timeout: DEVICE_TIMEOUT_MS, encoding: 'utf8', }) let temp = null let reallocated = null for (const line of String(stdout || '').split('\n')) { const m = line.match(/^\s*(\d+)\s+(\S+)\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(\d+)/) if (!m) continue const id = Number(m[1]) const val = Number(m[3]) if (id === 194) temp = val if (id === 5) reallocated = val } return { temp: Number.isFinite(temp) ? temp : null, reallocated: Number.isFinite(reallocated) ? reallocated : null, } } catch { return null } } } export class SmartCollector extends CollectorPlugin { constructor(opts = {}) { super({ name: 'smart', intervalMs: opts.intervalMs }) /** @type {string[]|null} */ this.devices = null } isEnabled() { return isSmartEnabled() } start() { if (!this.isEnabled()) return log.info('SMART collector started') super.start() } async collect() { if (this.devices === null) { this.devices = (await scanSmartDevices()) || [] if (!this.devices.length) { log.info('smartctl unavailable or no devices — collector idle') return [] } } const ts = Date.now() /** @type {Array<{ chart: string, context: string, ts: number, values: Record }>} */ const batch = [] for (const dev of this.devices) { const attrs = await readSmartAttributes(dev) if (!attrs) continue if (attrs.temp != null) { const def = makeSmartChart(dev, 'temp') registerChart(def) batch.push({ chart: def.id, context: def.context, ts, values: { value: attrs.temp }, }) } if (attrs.reallocated != null) { const def = makeSmartChart(dev, 'reallocated') registerChart(def) batch.push({ chart: def.id, context: def.context, ts, values: { value: attrs.reallocated }, }) } } return batch } } /** @type {SmartCollector|null} */ let singleton = null export function getSmartCollector() { if (!singleton) singleton = new SmartCollector() return singleton }