/** * 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} */ 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} */ const cpuValues = {} /** @type {Record} */ 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 }