335 lines
9.4 KiB
JavaScript
335 lines
9.4 KiB
JavaScript
/**
|
|
* Parent peer fleet aggregator (Phase 4 / M6 spike).
|
|
*
|
|
* Enable: PEARDATA_PARENT=1
|
|
* Children: PEARDATA_PARENT_PEERS=hex,hex (64-char agent public keys)
|
|
* Optional: PEARDATA_PARENT_SEED (admin proof), PEARDATA_PARENT_POLL_MS (default 5000)
|
|
*
|
|
* Dials child agents over HyperDHT, polls getHealth + getAllMetrics,
|
|
* and emits namespaced fleet charts into the local pipeline:
|
|
* fleet.cpu — per-child CPU %
|
|
* fleet.ram — per-child RAM used MiB
|
|
* fleet.children — connected / configured counts
|
|
*/
|
|
import fs from 'fs'
|
|
import { EventEmitter } from 'events'
|
|
import { PearDataConnection } from '../../../client/connection.js'
|
|
import { Methods } from '../../../shared/protocol.js'
|
|
import { registerChart } from '../../../shared/metrics.js'
|
|
import logger from '../../utils/logger.js'
|
|
|
|
const log = logger.child('parent')
|
|
|
|
export function isParentEnabled() {
|
|
const v = process.env.PEARDATA_PARENT
|
|
return v === '1' || v === 'on' || v === 'true'
|
|
}
|
|
|
|
/**
|
|
* @returns {string[]}
|
|
*/
|
|
export function parseParentPeers() {
|
|
const raw = process.env.PEARDATA_PARENT_PEERS || ''
|
|
const fromEnv = raw
|
|
.split(/[,\s]+/)
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter((s) => /^[0-9a-f]{64}$/.test(s))
|
|
|
|
const file = process.env.PEARDATA_PARENT_PEERS_FILE
|
|
if (!file) return [...new Set(fromEnv)]
|
|
try {
|
|
const text = fs.readFileSync(file, 'utf8')
|
|
const fromFile = text
|
|
.split(/\r?\n/)
|
|
.map((l) => l.replace(/#.*$/, '').trim().toLowerCase())
|
|
.filter((s) => /^[0-9a-f]{64}$/.test(s))
|
|
return [...new Set([...fromEnv, ...fromFile])]
|
|
} catch {
|
|
return [...new Set(fromEnv)]
|
|
}
|
|
}
|
|
|
|
function shortId(pk) {
|
|
return String(pk).slice(0, 12)
|
|
}
|
|
|
|
/**
|
|
* @param {string[]} childIds
|
|
* @param {'cpu'|'ram'} kind
|
|
*/
|
|
function registerFleetChart(childIds, kind) {
|
|
const dims = childIds.map((id) => ({
|
|
id,
|
|
name: id,
|
|
algorithm: 'absolute',
|
|
}))
|
|
if (!dims.length) dims.push({ id: '_none', name: '_none', algorithm: 'absolute' })
|
|
const def =
|
|
kind === 'cpu'
|
|
? {
|
|
id: 'fleet.cpu',
|
|
name: 'fleet.cpu',
|
|
context: 'fleet.cpu',
|
|
title: 'Fleet CPU (children)',
|
|
units: 'percentage',
|
|
family: 'fleet',
|
|
chartType: 'line',
|
|
priority: 7000,
|
|
plugin: 'parent',
|
|
dimensions: dims,
|
|
}
|
|
: {
|
|
id: 'fleet.ram',
|
|
name: 'fleet.ram',
|
|
context: 'fleet.ram',
|
|
title: 'Fleet RAM used (children)',
|
|
units: 'MiB',
|
|
family: 'fleet',
|
|
chartType: 'line',
|
|
priority: 7010,
|
|
plugin: 'parent',
|
|
dimensions: dims,
|
|
}
|
|
registerChart(def)
|
|
return def
|
|
}
|
|
|
|
function registerChildrenChart() {
|
|
const def = {
|
|
id: 'fleet.children',
|
|
name: 'fleet.children',
|
|
context: 'fleet.children',
|
|
title: 'Fleet child agents',
|
|
units: 'agents',
|
|
family: 'fleet',
|
|
chartType: 'line',
|
|
priority: 7020,
|
|
plugin: 'parent',
|
|
dimensions: [
|
|
{ id: 'connected', name: 'connected', algorithm: 'absolute' },
|
|
{ id: 'configured', name: 'configured', algorithm: 'absolute' },
|
|
],
|
|
}
|
|
registerChart(def)
|
|
return def
|
|
}
|
|
|
|
/**
|
|
* Extract CPU used % and RAM MiB from getAllMetrics / latest-style payload.
|
|
* @param {any} metrics
|
|
*/
|
|
export function extractChildStats(metrics) {
|
|
/** @type {{ cpu: number|null, ram: number|null }} */
|
|
const out = { cpu: null, ram: null }
|
|
const root = metrics?.body || metrics
|
|
const charts = root?.charts || root?.latest || root || {}
|
|
const cpu = charts['system.cpu']
|
|
const ram = charts['system.ram']
|
|
const cpuVals = cpu?.dimensions || cpu?.values || cpu
|
|
const ramVals = ram?.dimensions || ram?.values || ram
|
|
if (cpuVals && typeof cpuVals === 'object') {
|
|
const idle = Number(cpuVals.idle?.value ?? cpuVals.idle)
|
|
if (Number.isFinite(idle)) out.cpu = Math.max(0, 100 - idle)
|
|
}
|
|
if (ramVals && typeof ramVals === 'object') {
|
|
const used = Number(ramVals.used?.value ?? ramVals.used)
|
|
if (Number.isFinite(used)) out.ram = used
|
|
}
|
|
return out
|
|
}
|
|
|
|
export class ParentCollector extends EventEmitter {
|
|
constructor(opts = {}) {
|
|
super()
|
|
this.pollMs = opts.pollMs || Number(process.env.PEARDATA_PARENT_POLL_MS) || 5000
|
|
this.adminSeed = opts.adminSeed || process.env.PEARDATA_PARENT_SEED || null
|
|
this.peerKeys = opts.peers || parseParentPeers()
|
|
/** @type {Map<string, { conn: PearDataConnection|null, connected: boolean, hostname: string|null, health: string|null, cpu: number|null, ram: number|null, lastError: string|null, lastSeen: number|null }>} */
|
|
this.children = new Map()
|
|
this._timer = null
|
|
this._dialing = false
|
|
}
|
|
|
|
start() {
|
|
if (this._timer) return
|
|
for (const pk of this.peerKeys) {
|
|
this.children.set(pk, {
|
|
conn: null,
|
|
connected: false,
|
|
hostname: null,
|
|
health: null,
|
|
cpu: null,
|
|
ram: null,
|
|
lastError: null,
|
|
lastSeen: null,
|
|
})
|
|
}
|
|
registerChildrenChart()
|
|
registerFleetChart(this.peerKeys.map(shortId), 'cpu')
|
|
registerFleetChart(this.peerKeys.map(shortId), 'ram')
|
|
log.info('Parent collector started', { children: this.peerKeys.length, pollMs: this.pollMs })
|
|
this._tick()
|
|
this._timer = setInterval(() => this._tick(), this.pollMs)
|
|
if (typeof this._timer.unref === 'function') this._timer.unref()
|
|
}
|
|
|
|
stop() {
|
|
if (this._timer) {
|
|
clearInterval(this._timer)
|
|
this._timer = null
|
|
}
|
|
for (const [pk, st] of this.children) {
|
|
if (st.conn) {
|
|
st.conn.destroy().catch(() => {})
|
|
st.conn = null
|
|
}
|
|
st.connected = false
|
|
this.children.set(pk, st)
|
|
}
|
|
}
|
|
|
|
listChildren() {
|
|
return [...this.children.entries()].map(([publicKeyHex, st]) => ({
|
|
publicKeyHex,
|
|
shortId: shortId(publicKeyHex),
|
|
connected: st.connected,
|
|
hostname: st.hostname,
|
|
health: st.health,
|
|
cpu: st.cpu,
|
|
ram: st.ram,
|
|
lastError: st.lastError,
|
|
lastSeen: st.lastSeen,
|
|
}))
|
|
}
|
|
|
|
fleetSummary() {
|
|
const kids = this.listChildren()
|
|
const connected = kids.filter((k) => k.connected).length
|
|
const cpus = kids.map((k) => k.cpu).filter((n) => n != null)
|
|
const avgCpu = cpus.length ? cpus.reduce((a, b) => a + b, 0) / cpus.length : null
|
|
return {
|
|
configured: kids.length,
|
|
connected,
|
|
avgCpu,
|
|
status: connected === 0 ? 'offline' : connected < kids.length ? 'degraded' : 'ok',
|
|
}
|
|
}
|
|
|
|
async _ensureDial(pk) {
|
|
const st = this.children.get(pk)
|
|
if (!st) return null
|
|
if (st.conn?.connected) return st.conn
|
|
try {
|
|
if (st.conn) await st.conn.destroy().catch(() => {})
|
|
const conn = new PearDataConnection(pk, {
|
|
adminSeed: this.adminSeed,
|
|
timeoutMs: 20_000,
|
|
})
|
|
await conn.connect()
|
|
st.conn = conn
|
|
st.connected = true
|
|
st.lastError = null
|
|
conn.on('disconnected', () => {
|
|
st.connected = false
|
|
st.conn = null
|
|
})
|
|
this.children.set(pk, st)
|
|
log.info('Dialed child', { peer: shortId(pk) })
|
|
return conn
|
|
} catch (err) {
|
|
st.connected = false
|
|
st.conn = null
|
|
st.lastError = err.message
|
|
this.children.set(pk, st)
|
|
return null
|
|
}
|
|
}
|
|
|
|
async _tick() {
|
|
if (this._dialing) return
|
|
this._dialing = true
|
|
try {
|
|
const ts = Date.now()
|
|
/** @type {Record<string, number|null>} */
|
|
const cpuValues = {}
|
|
/** @type {Record<string, number|null>} */
|
|
const ramValues = {}
|
|
let connected = 0
|
|
|
|
for (const pk of this.peerKeys) {
|
|
const sid = shortId(pk)
|
|
cpuValues[sid] = null
|
|
ramValues[sid] = null
|
|
const conn = await this._ensureDial(pk)
|
|
const st = this.children.get(pk)
|
|
if (!conn || !st) continue
|
|
try {
|
|
const [health, node, metrics] = await Promise.all([
|
|
conn.request(Methods.getHealth, {}),
|
|
conn.request(Methods.getNodeInfo, {}),
|
|
conn.request(Methods.getAllMetrics, { format: 'json' }),
|
|
])
|
|
const stats = extractChildStats(metrics)
|
|
st.connected = true
|
|
st.health = health?.status || 'ok'
|
|
st.hostname = node?.hostname || null
|
|
st.cpu = stats.cpu
|
|
st.ram = stats.ram
|
|
st.lastSeen = ts
|
|
st.lastError = null
|
|
cpuValues[sid] = stats.cpu
|
|
ramValues[sid] = stats.ram
|
|
connected++
|
|
this.children.set(pk, st)
|
|
} catch (err) {
|
|
st.connected = false
|
|
st.lastError = err.message
|
|
this.children.set(pk, st)
|
|
try {
|
|
await conn.destroy()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
st.conn = null
|
|
}
|
|
}
|
|
|
|
const ids = this.peerKeys.map(shortId)
|
|
registerFleetChart(ids, 'cpu')
|
|
registerFleetChart(ids, 'ram')
|
|
registerChildrenChart()
|
|
|
|
this.emit('samples', [
|
|
{
|
|
chart: 'fleet.cpu',
|
|
context: 'fleet.cpu',
|
|
ts,
|
|
values: cpuValues,
|
|
},
|
|
{
|
|
chart: 'fleet.ram',
|
|
context: 'fleet.ram',
|
|
ts,
|
|
values: ramValues,
|
|
},
|
|
{
|
|
chart: 'fleet.children',
|
|
context: 'fleet.children',
|
|
ts,
|
|
values: { connected, configured: this.peerKeys.length },
|
|
},
|
|
])
|
|
} finally {
|
|
this._dialing = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @type {ParentCollector|null} */
|
|
let singleton = null
|
|
|
|
export function getParentCollector() {
|
|
if (!singleton) singleton = new ParentCollector()
|
|
return singleton
|
|
}
|