186 lines
5.1 KiB
JavaScript
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
|
|
}
|