Files
peardata/server/services/collectors/parent.js
T
Raven Scott 1d46b7b3ad
CI / test (push) Failing after 6s
Release rolling / release (push) Has been cancelled
updates
2026-07-18 19:11:32 -04:00

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
}