188 lines
4.8 KiB
JavaScript
188 lines
4.8 KiB
JavaScript
/**
|
|
* Linux MD RAID /proc/mdstat helper + collector.
|
|
*
|
|
* Always-on when /proc/mdstat has active arrays.
|
|
*
|
|
* Charts: md.health, md.array.{name}
|
|
*/
|
|
import fs from 'fs'
|
|
import os from 'os'
|
|
import { EventEmitter } from 'events'
|
|
import { SAMPLE_INTERVAL_MS, registerChart } from '../../../shared/metrics.js'
|
|
import logger from '../../utils/logger.js'
|
|
|
|
const log = logger.child('mdstat')
|
|
|
|
const CHART_HEALTH = {
|
|
id: 'md.health',
|
|
name: 'md.health',
|
|
context: 'md.health',
|
|
title: 'MD RAID arrays',
|
|
units: 'arrays',
|
|
family: 'md',
|
|
chartType: 'line',
|
|
priority: 4500,
|
|
plugin: 'mdstat',
|
|
dimensions: [
|
|
{ id: 'active', name: 'active', algorithm: 'absolute' },
|
|
{ id: 'degraded', name: 'degraded', algorithm: 'absolute' },
|
|
{ id: 'recovering', name: 'recovering', algorithm: 'absolute' },
|
|
],
|
|
}
|
|
|
|
function readFile(p) {
|
|
try {
|
|
return fs.readFileSync(p, 'utf8')
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {boolean}
|
|
*/
|
|
export function isMdstatUseful() {
|
|
if (os.platform() !== 'linux') return false
|
|
const raw = readFile('/proc/mdstat')
|
|
if (!raw) return false
|
|
return /^md\d+\s*:/m.test(raw)
|
|
}
|
|
|
|
export function isMdstatEnabled() {
|
|
return isMdstatUseful()
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
*/
|
|
function makeMdArrayChart(name) {
|
|
const safe = String(name).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
|
return {
|
|
id: `md.array.${safe}`,
|
|
name: `md.array.${safe}`,
|
|
context: 'md.array',
|
|
title: `MD array ${name}`,
|
|
units: 'disks',
|
|
family: name,
|
|
chartType: 'line',
|
|
priority: 4510,
|
|
plugin: 'mdstat',
|
|
dimensions: [
|
|
{ id: 'active', name: 'active', algorithm: 'absolute' },
|
|
{ id: 'total', name: 'total', algorithm: 'absolute' },
|
|
],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} raw
|
|
* @returns {{ health: { active: number, degraded: number, recovering: number }, arrays: Array<{ name: string, active: number, total: number, degraded: boolean, recovering: boolean }> }}
|
|
*/
|
|
export function parseMdstat(raw) {
|
|
const text = String(raw || '')
|
|
/** @type {Array<{ name: string, active: number, total: number, degraded: boolean, recovering: boolean }>} */
|
|
const arrays = []
|
|
const lines = text.split('\n')
|
|
let i = 0
|
|
while (i < lines.length) {
|
|
const hdr = lines[i].match(/^(md\d+)\s*:\s*(.*)$/)
|
|
if (!hdr) {
|
|
i++
|
|
continue
|
|
}
|
|
const name = hdr[1]
|
|
const hdrRest = hdr[2] || ''
|
|
const detail = lines[i + 1] || ''
|
|
const block = `${hdrRest} ${detail}`.toLowerCase()
|
|
const bracket = detail.match(/\[(\d+)\/(\d+)\]/)
|
|
const active = bracket ? Number(bracket[1]) : 0
|
|
const total = bracket ? Number(bracket[2]) : 0
|
|
const diskState = detail.match(/\[([U_.]+)\]/)
|
|
const degraded =
|
|
/degraded|faulty|F/i.test(block) ||
|
|
(diskState ? /[_F]/.test(diskState[1]) : active < total && total > 0)
|
|
const recovering = /recovery|resync|reshape|check|repair/i.test(block)
|
|
arrays.push({ name, active, total, degraded, recovering })
|
|
i += 2
|
|
}
|
|
|
|
return {
|
|
health: {
|
|
active: arrays.length,
|
|
degraded: arrays.filter((a) => a.degraded).length,
|
|
recovering: arrays.filter((a) => a.recovering).length,
|
|
},
|
|
arrays,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append mdstat samples to an existing batch.
|
|
* @param {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} batch
|
|
* @param {number} ts
|
|
*/
|
|
export function collectMdstat(batch, ts) {
|
|
if (!isMdstatUseful()) return
|
|
const raw = readFile('/proc/mdstat')
|
|
if (!raw) return
|
|
const parsed = parseMdstat(raw)
|
|
registerChart(CHART_HEALTH)
|
|
batch.push({
|
|
chart: 'md.health',
|
|
context: 'md.health',
|
|
ts,
|
|
values: parsed.health,
|
|
})
|
|
for (const arr of parsed.arrays) {
|
|
const def = makeMdArrayChart(arr.name)
|
|
registerChart(def)
|
|
batch.push({
|
|
chart: def.id,
|
|
context: def.context,
|
|
ts,
|
|
values: { active: arr.active, total: arr.total },
|
|
})
|
|
}
|
|
}
|
|
|
|
export class MdstatCollector extends EventEmitter {
|
|
constructor(opts = {}) {
|
|
super()
|
|
this.intervalMs =
|
|
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
|
|
this.timer = null
|
|
this.running = false
|
|
}
|
|
|
|
start() {
|
|
if (this.running || !isMdstatUseful()) return
|
|
this.running = true
|
|
this._tick()
|
|
this.timer = setInterval(() => this._tick(), this.intervalMs)
|
|
if (this.timer.unref) this.timer.unref()
|
|
log.info('Mdstat collector started')
|
|
}
|
|
|
|
stop() {
|
|
this.running = false
|
|
if (this.timer) clearInterval(this.timer)
|
|
this.timer = null
|
|
}
|
|
|
|
_tick() {
|
|
const ts = Date.now()
|
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
|
const batch = []
|
|
collectMdstat(batch, ts)
|
|
if (batch.length) this.emit('samples', batch)
|
|
}
|
|
}
|
|
|
|
/** @type {MdstatCollector|null} */
|
|
let singleton = null
|
|
|
|
export function getMdstatCollector() {
|
|
if (!singleton) singleton = new MdstatCollector()
|
|
return singleton
|
|
}
|