Files
peardata/server/services/collectors/smart.js
T
Raven Scott 52a1823469
CI / test (push) Successful in 1m4s
Release rolling / release (push) Successful in 7m14s
Updates
2026-07-18 20:01:47 -04:00

186 lines
5.1 KiB
JavaScript

/**
* SMART disk health collector via smartctl.
*
* Enable: PEARDATA_SMART=1
*
* Charts: smart.temp.{dev}, smart.reallocated.{dev}
*/
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import { execFile } from '../../utils/exec.js'
import logger from '../../utils/logger.js'
const log = logger.child('smart')
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<string[]|null>}
*/
export async function scanSmartDevices(timeoutMs = DEVICE_TIMEOUT_MS) {
try {
const { stdout } = await execFile('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 execFile('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 execFile('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<string, number|null> }>} */
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
}