Updates
CI / test (push) Successful in 58s
Release rolling / release (push) Successful in 7m10s

This commit is contained in:
Raven Scott
2026-07-18 19:16:58 -04:00
parent 1d46b7b3ad
commit 462cce4b5b
24 changed files with 1398 additions and 51 deletions
+149 -11
View File
@@ -1,9 +1,14 @@
/**
* Threshold-based anomaly detection (MVP).
* Later: scoring, k-means consensus, ML jobs.
* Threshold-based anomaly detection (+ optional z-score hybrid).
*
* PEARDATA_ANOMALY_MODE:
* threshold (default) — fixed warn/crit
* zscore — fire when |z| exceeds warnZ/critZ
* hybrid — threshold OR z-score
*/
import { EventEmitter } from 'events'
import { normalizeAnomaly } from '../../shared/data-model.js'
import { stats, zScore, thresholdsFromStats, seriesFromStore } from './zscore.js'
/** @typedef {import('../../shared/data-model.js').AlertConfig} AlertConfig */
/** @typedef {import('../../shared/data-model.js').AnomalyEvent} AnomalyEvent */
@@ -78,6 +83,38 @@ function compare(op, value, threshold) {
}
}
/**
* Continuous score in [0, 1] from how far value sits past warn→crit.
* @param {number} value
* @param {number|null} warn
* @param {number|null} crit
* @param {string} op
* @param {'warning'|'critical'} severity
*/
export function scoreSeverity(value, warn, crit, op, severity) {
if (severity === 'critical') {
if (warn == null || crit == null || warn === crit) return 1
const span = Math.abs(crit - warn) || 1
if (op === '<' || op === '<=') {
// lower is worse: crit < warn
const over = Math.max(0, warn - value)
return Math.min(1, 0.6 + 0.4 * Math.min(1, over / span))
}
const over = Math.max(0, value - warn)
return Math.min(1, 0.6 + 0.4 * Math.min(1, over / span))
}
// warning band
if (warn == null) return 0.6
if (crit == null) return 0.6
const span = Math.abs(crit - warn) || 1
if (op === '<' || op === '<=') {
const into = Math.max(0, warn - value)
return Math.min(0.99, 0.35 + 0.25 * Math.min(1, into / span))
}
const into = Math.max(0, value - warn)
return Math.min(0.99, 0.35 + 0.25 * Math.min(1, into / span))
}
export class AnomalyEngine extends EventEmitter {
/**
* @param {{ cpuCount?: number }} [opts]
@@ -85,13 +122,84 @@ export class AnomalyEngine extends EventEmitter {
constructor(opts = {}) {
super()
this.cpuCount = opts.cpuCount || 1
/** @type {Map<string, AlertConfig & { _dynamicLoad?: boolean }>} */
/** @type {Map<string, AlertConfig & { _dynamicLoad?: boolean, _mean?: number, _stdev?: number }>} */
this.configs = new Map(DEFAULT_THRESHOLDS.map((c) => [c.id, { ...c }]))
/** @type {Map<string, string>} status CLEAR|WARNING|CRITICAL */
this.status = new Map()
/** @type {AnomalyEvent[]} */
this.recent = []
this.recentMax = 500
/** @type {Map<string, number[]>} rolling values for z-score */
this._windows = new Map()
this.windowMax = Number(process.env.PEARDATA_ANOMALY_WINDOW) || 120
this.warnZ = Number(process.env.PEARDATA_ANOMALY_WARN_Z) || 2
this.critZ = Number(process.env.PEARDATA_ANOMALY_CRIT_Z) || 3
}
anomalyMode() {
const m = String(process.env.PEARDATA_ANOMALY_MODE || 'threshold').toLowerCase()
if (m === 'zscore' || m === 'hybrid') return m
return 'threshold'
}
/**
* Push a sample into the rolling window for a config.
* @param {string} cfgId
* @param {number} value
*/
_pushWindow(cfgId, value) {
let arr = this._windows.get(cfgId)
if (!arr) {
arr = []
this._windows.set(cfgId, arr)
}
arr.push(value)
while (arr.length > this.windowMax) arr.shift()
}
/**
* Retrain warn/crit from MetricStore history (or rolling windows).
* @param {import('./store.js').MetricStore} [store]
* @param {{ warnZ?: number, critZ?: number, minPoints?: number }} [opts]
*/
retrain(store = null, opts = {}) {
const warnZ = opts.warnZ ?? this.warnZ
const critZ = opts.critZ ?? this.critZ
const minPoints = opts.minPoints ?? 30
/** @type {Array<{ id: string, n: number, mean: number, stdev: number, warn: number, crit: number }>} */
const updated = []
for (const cfg of this.configs.values()) {
if (cfg._dynamicLoad) continue
let values = []
if (store) {
values = seriesFromStore(store, cfg.chart, cfg.dimension, this.windowMax)
}
if (values.length < minPoints) {
values = this._windows.get(cfg.id) || values
}
if (values.length < minPoints) continue
const s = stats(values)
const thr = thresholdsFromStats(s, cfg.comparator || '>', { warnZ, critZ })
this.setConfig({
...cfg,
warn: thr.warn,
crit: thr.crit,
_mean: thr.mean,
_stdev: thr.stdev,
info: cfg.info,
})
updated.push({
id: cfg.id,
n: s.n,
mean: thr.mean,
stdev: thr.stdev,
warn: thr.warn,
crit: thr.crit,
})
}
return { ok: true, mode: this.anomalyMode(), updated, warnZ, critZ }
}
/**
@@ -136,6 +244,8 @@ export class AnomalyEngine extends EventEmitter {
const value = sample.values[cfg.dimension]
if (value == null) continue
this._pushWindow(cfg.id, value)
let warn = cfg.warn
let crit = cfg.crit
if (cfg._dynamicLoad) {
@@ -144,14 +254,38 @@ export class AnomalyEngine extends EventEmitter {
}
const op = cfg.comparator || '>'
const mode = this.anomalyMode()
let severity = null
let threshold = null
if (compare(op, value, crit)) {
severity = 'critical'
threshold = crit
} else if (compare(op, value, warn)) {
severity = 'warning'
threshold = warn
let z = null
if (mode === 'threshold' || mode === 'hybrid') {
if (compare(op, value, crit)) {
severity = 'critical'
threshold = crit
} else if (compare(op, value, warn)) {
severity = 'warning'
threshold = warn
}
}
if (mode === 'zscore' || mode === 'hybrid') {
const win = this._windows.get(cfg.id) || []
if (win.length >= 15) {
const s = stats(win.slice(0, -1)) // exclude current for baseline
z = zScore(value, s.mean, s.stdev)
const absZ = Math.abs(z)
let zSev = null
if (absZ >= this.critZ) zSev = 'critical'
else if (absZ >= this.warnZ) zSev = 'warning'
if (zSev) {
const rank = { warning: 1, critical: 2 }
if (!severity || rank[zSev] > rank[severity]) {
severity = zSev
threshold = zSev === 'critical' ? this.critZ : this.warnZ
}
}
}
}
const prev = this.status.get(cfg.id) || 'CLEAR'
@@ -177,7 +311,11 @@ export class AnomalyEngine extends EventEmitter {
this._push(cleared)
fired.push(cleared)
} else if (severity) {
const score = severity === 'critical' ? 1 : 0.6
const score =
z != null
? Math.min(1, Math.abs(z) / Math.max(this.critZ, 1))
: scoreSeverity(value, warn, crit, op, severity)
const zPart = z != null ? ` z=${round2(z)}` : ''
const ev = normalizeAnomaly({
id: `${cfg.id}:${sample.ts}`,
chart: cfg.chart,
@@ -188,7 +326,7 @@ export class AnomalyEngine extends EventEmitter {
value,
threshold,
comparator: op,
message: `${cfg.info || cfg.id}: ${cfg.dimension}=${round2(value)} ${op} ${threshold}`,
message: `${cfg.info || cfg.id}: ${cfg.dimension}=${round2(value)} ${op} ${threshold}${zPart} (score ${score.toFixed(2)})`,
ts: sample.ts,
})
this._push(ev)
+239
View File
@@ -0,0 +1,239 @@
/**
* PearDock bridge collector (Phase 3 spike).
*
* Dials dock / agent peers and remaps their docker/container charts into
* peardock.* — no PearDock source copied (AGPL boundary).
*
* Enable: PEARDATA_PEARDOCK=1
* Peers: PEARDATA_PEARDOCK_PEERS=hex,hex (or PEARDATA_PEARDOCK_PEERS_FILE)
* Optional: PEARDATA_PEARDOCK_SEED, PEARDATA_PEARDOCK_POLL_MS
*
* Charts: peardock.containers, peardock.cpu.<id>, peardock.mem.<id>
* (sourced from remote docker.* charts when present)
*/
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('peardock')
export function isPearDockEnabled() {
const v = process.env.PEARDATA_PEARDOCK
return v === '1' || v === 'on' || v === 'true'
}
/**
* @returns {string[]}
*/
export function parsePearDockPeers() {
const raw = process.env.PEARDATA_PEARDOCK_PEERS || ''
const fromEnv = raw
.split(/[,\s]+/)
.map((s) => s.trim().toLowerCase())
.filter((s) => /^[0-9a-f]{64}$/.test(s))
const file = process.env.PEARDATA_PEARDOCK_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)]
}
}
/**
* Map remote docker/container chart id → peardock chart id.
* @param {string} chartId
* @returns {string|null}
*/
export function remapDockChart(chartId) {
const id = String(chartId || '')
if (id.startsWith('docker.')) return `peardock.${id.slice('docker.'.length)}`
if (id.startsWith('container.')) return `peardock.${id.slice('container.'.length)}`
if (id.startsWith('cgroup.docker.')) return `peardock.${id.slice('cgroup.docker.'.length)}`
return null
}
/**
* @param {any} metrics getAllMetrics-style payload
* @returns {Array<{ chart: string, context: string, values: Record<string, number|null>, sourceChart: string }>}
*/
export function extractDockCharts(metrics) {
const root = metrics?.body || metrics
const charts = root?.charts || {}
/** @type {Array<{ chart: string, context: string, values: Record<string, number|null>, sourceChart: string }>} */
const out = []
for (const [chartId, point] of Object.entries(charts)) {
const mapped = remapDockChart(chartId)
if (!mapped) continue
const values = point?.dimensions || point?.values || {}
/** @type {Record<string, number|null>} */
const nums = {}
for (const [k, v] of Object.entries(values)) {
const n = typeof v === 'object' && v != null ? Number(v.value) : Number(v)
nums[k] = Number.isFinite(n) ? n : null
}
const parts = mapped.split('.')
const context = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : mapped
out.push({
chart: mapped,
context,
values: nums,
sourceChart: chartId,
})
}
return out
}
function registerMappedChart(mappedId, context, values) {
const dims = Object.keys(values).map((id) => ({
id,
name: id,
algorithm: 'absolute',
}))
if (!dims.length) dims.push({ id: 'value', name: 'value', algorithm: 'absolute' })
registerChart({
id: mappedId,
name: mappedId,
context,
title: mappedId,
units: context.includes('mem') ? 'MiB' : context.includes('cpu') ? 'percentage' : 'count',
family: 'peardock',
chartType: 'line',
priority: 8500,
plugin: 'peardock',
dimensions: dims,
})
}
export class PearDockCollector extends EventEmitter {
constructor(opts = {}) {
super()
this.pollMs = opts.pollMs || Number(process.env.PEARDATA_PEARDOCK_POLL_MS) || 5000
this.adminSeed = opts.adminSeed || process.env.PEARDATA_PEARDOCK_SEED || null
this.peerKeys = opts.peers || parsePearDockPeers()
/** @type {Map<string, PearDataConnection|null>} */
this.conns = new Map()
this._timer = null
this._busy = false
}
start() {
if (this._timer) return
registerChart({
id: 'peardock.containers',
name: 'peardock.containers',
context: 'peardock.containers',
title: 'PearDock bridged containers',
units: 'containers',
family: 'peardock',
chartType: 'line',
priority: 8490,
plugin: 'peardock',
dimensions: [
{ id: 'charts', name: 'charts', algorithm: 'absolute' },
{ id: 'peers', name: 'peers', algorithm: 'absolute' },
],
})
log.info('PearDock bridge started', { peers: 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, conn] of this.conns) {
if (conn) conn.destroy().catch(() => {})
this.conns.set(pk, null)
}
}
async _ensure(pk) {
let conn = this.conns.get(pk)
if (conn?.connected) return conn
try {
if (conn) await conn.destroy().catch(() => {})
conn = new PearDataConnection(pk, {
adminSeed: this.adminSeed,
timeoutMs: 20_000,
})
await conn.connect()
this.conns.set(pk, conn)
conn.on('disconnected', () => this.conns.set(pk, null))
return conn
} catch (err) {
this.conns.set(pk, null)
log.warn('PearDock dial failed', { peer: pk.slice(0, 12), error: err.message })
return null
}
}
async _tick() {
if (this._busy) return
this._busy = true
try {
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
let peersUp = 0
let chartCount = 0
for (const pk of this.peerKeys) {
const conn = await this._ensure(pk)
if (!conn) continue
try {
const metrics = await conn.request(Methods.getAllMetrics, { format: 'json' })
const mapped = extractDockCharts(metrics)
peersUp++
for (const row of mapped) {
registerMappedChart(row.chart, row.context, row.values)
batch.push({
chart: row.chart,
context: row.context,
ts,
values: row.values,
})
chartCount++
}
} catch (err) {
log.warn('PearDock poll failed', { peer: pk.slice(0, 12), error: err.message })
try {
await conn.destroy()
} catch {
// ignore
}
this.conns.set(pk, null)
}
}
batch.unshift({
chart: 'peardock.containers',
context: 'peardock.containers',
ts,
values: { charts: chartCount, peers: peersUp },
})
this.emit('samples', batch)
} finally {
this._busy = false
}
}
}
/** @type {PearDockCollector|null} */
let singleton = null
export function getPearDockCollector() {
if (!singleton) singleton = new PearDockCollector()
return singleton
}
+9
View File
@@ -7,6 +7,7 @@ import { Pushes } from '../../shared/protocol.js'
import { peers } from '../core/peer-registry.js'
import { getCollector } from './collector.js'
import { getStore } from './store.js'
import { getAnomalyEngine } from './anomaly.js'
import { jobExportSnapshot, jobPrometheusPush } from './export.js'
const JOB_HANDLERS = {
@@ -20,6 +21,14 @@ const JOB_HANDLERS = {
},
exportSnapshot: jobExportSnapshot,
prometheusPush: jobPrometheusPush,
retrainAnomaly: async (args = {}) => {
const eng = getAnomalyEngine()
return eng.retrain(getStore(), {
warnZ: args.warnZ != null ? Number(args.warnZ) : undefined,
critZ: args.critZ != null ? Number(args.critZ) : undefined,
minPoints: args.minPoints != null ? Number(args.minPoints) : undefined,
})
},
gcBuffers: async () => {
// ring buffers self-trim; placeholder for future disk GC
return { ok: true }
+180
View File
@@ -0,0 +1,180 @@
/**
* Outbound anomaly notifications (webhook).
*
* Env:
* PEARDATA_WEBHOOK_URL — POST JSON AnomalyEvent
* PEARDATA_WEBHOOK_SECRET — HMAC key (+ optional legacy shared secret header)
* PEARDATA_WEBHOOK_SIGN — default on when secret set; `0` disables HMAC
* PEARDATA_WEBHOOK_LEGACY_SECRET — `1` also send X-PearData-Secret plaintext
* PEARDATA_NOTIFY_CLEARS — 1 = also notify on clear
* PEARDATA_NOTIFY_MIN_SEVERITY — warning | critical (default warning)
*
* Signed request headers:
* X-PearData-Timestamp — unix ms
* X-PearData-Signature — sha256=<hex HMAC-SHA256(secret, `${ts}.${body}`)>
*/
import http from 'http'
import https from 'https'
import crypto from 'crypto'
import b4a from 'b4a'
import logger from '../utils/logger.js'
const log = logger.child('notify')
export function isWebhookEnabled() {
return Boolean(process.env.PEARDATA_WEBHOOK_URL)
}
export function isWebhookSigningEnabled() {
if (!process.env.PEARDATA_WEBHOOK_SECRET) return false
const v = process.env.PEARDATA_WEBHOOK_SIGN
if (v === '0' || v === 'off' || v === 'false') return false
return true
}
/**
* @param {'warning'|'critical'} severity
*/
export function meetsMinSeverity(severity) {
const min = String(process.env.PEARDATA_NOTIFY_MIN_SEVERITY || 'warning').toLowerCase()
if (min === 'critical') return severity === 'critical'
return severity === 'warning' || severity === 'critical'
}
/**
* @param {import('../../shared/data-model.js').AnomalyEvent} ev
*/
export function shouldNotify(ev) {
if (!isWebhookEnabled()) return false
if (ev.cleared) {
const v = process.env.PEARDATA_NOTIFY_CLEARS
return v === '1' || v === 'on' || v === 'true'
}
return meetsMinSeverity(ev.severity)
}
/**
* @param {string} secret
* @param {string} timestamp
* @param {string} body
* @returns {string} sha256=<hex>
*/
export function signWebhookPayload(secret, timestamp, body) {
const mac = crypto
.createHmac('sha256', String(secret))
.update(`${timestamp}.${body}`)
.digest('hex')
return `sha256=${mac}`
}
/**
* @param {string} secret
* @param {string} timestamp
* @param {string} body
* @param {string} signatureHeader
* @param {{ maxSkewMs?: number, now?: number }} [opts]
*/
export function verifyWebhookSignature(secret, timestamp, body, signatureHeader, opts = {}) {
const now = opts.now ?? Date.now()
const maxSkew = opts.maxSkewMs ?? 5 * 60_000
const ts = Number(timestamp)
if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkew) return false
const expected = signWebhookPayload(secret, String(timestamp), body)
const got = String(signatureHeader || '')
try {
const a = b4a.from(expected)
const b = b4a.from(got)
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
} catch {
return expected === got
}
}
/**
* @param {string} url
* @param {object} body
* @param {number} [timeoutMs]
*/
export function postWebhook(url, body, timeoutMs = 8000) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body)
const u = new URL(url)
const mod = u.protocol === 'https:' ? https : http
const ts = String(Date.now())
/** @type {Record<string, string>} */
const headers = {
'Content-Type': 'application/json',
'Content-Length': String(b4a.byteLength(payload)),
'User-Agent': 'peardata-notify/0.1',
'X-PearData-Timestamp': ts,
}
const secret = process.env.PEARDATA_WEBHOOK_SECRET
if (secret && isWebhookSigningEnabled()) {
headers['X-PearData-Signature'] = signWebhookPayload(secret, ts, payload)
}
if (secret && (process.env.PEARDATA_WEBHOOK_LEGACY_SECRET === '1' || !isWebhookSigningEnabled())) {
headers['X-PearData-Secret'] = secret
}
const req = mod.request(
{
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + u.search,
method: 'POST',
headers,
timeout: timeoutMs,
},
(res) => {
let data = ''
res.on('data', (c) => {
data += c
})
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`webhook HTTP ${res.statusCode}: ${data.slice(0, 160)}`))
return
}
resolve({
status: res.statusCode || 200,
signed: Boolean(headers['X-PearData-Signature']),
})
})
}
)
req.on('timeout', () => {
req.destroy()
reject(new Error('webhook timeout'))
})
req.on('error', reject)
req.write(payload)
req.end()
})
}
/**
* Fire-and-forget notify for one anomaly event.
* @param {import('../../shared/data-model.js').AnomalyEvent} ev
*/
export async function notifyAnomaly(ev) {
if (!shouldNotify(ev)) return { skipped: true }
const url = process.env.PEARDATA_WEBHOOK_URL
try {
const res = await postWebhook(url, {
type: 'anomaly',
...ev,
notifiedAt: Date.now(),
})
log.info('Webhook delivered', {
chart: ev.chart,
severity: ev.severity,
status: res.status,
signed: res.signed,
})
return { ok: true, ...res }
} catch (err) {
log.warn('Webhook failed', { error: err.message, chart: ev.chart })
return { ok: false, error: err.message }
}
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Holesail-style HyperDHT TCP tunnel for the local REST API.
*
* Enable: PEARDATA_REST_TUNNEL=1
* Optional: PEARDATA_REST_TUNNEL_SEED=<64 hex> for a stable public key
*
* Peers connect with HyperDHT to the tunnel public key; each connection is
* proxied to PEARDATA_REST_HOST:PEARDATA_REST_PORT (default 127.0.0.1:19999).
*
* Uses existing `hyperdht` — no holesail package dependency (Bare-friendly).
*/
import net from 'net'
import DHT from 'hyperdht'
import b4a from 'b4a'
import logger from '../utils/logger.js'
const log = logger.child('rest-tunnel')
/** @type {import('hyperdht')|null} */
let dht = null
/** @type {any} */
let server = null
/** @type {string|null} */
let publicKeyHex = null
export function isRestTunnelEnabled() {
const v = process.env.PEARDATA_REST_TUNNEL
return v === '1' || v === 'on' || v === 'true'
}
function tunnelKeyPair() {
const seedHex = process.env.PEARDATA_REST_TUNNEL_SEED
if (seedHex && /^[0-9a-fA-F]{64}$/.test(seedHex)) {
return DHT.keyPair(b4a.from(seedHex, 'hex'))
}
return DHT.keyPair()
}
/**
* @returns {Promise<{ publicKeyHex: string, target: string }|null>}
*/
export async function startRestTunnel() {
if (!isRestTunnelEnabled()) {
log.info('REST tunnel disabled (set PEARDATA_REST_TUNNEL=1)')
return null
}
if (server) {
return {
publicKeyHex,
target: `${process.env.PEARDATA_REST_HOST || '127.0.0.1'}:${Number(process.env.PEARDATA_REST_PORT) || 19999}`,
}
}
const host = process.env.PEARDATA_REST_HOST || '127.0.0.1'
const port = Number(process.env.PEARDATA_REST_PORT) || 19999
const keyPair = tunnelKeyPair()
publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
dht = new DHT()
server = dht.createServer()
server.on('connection', (socket) => {
const remote = socket.remotePublicKey
? b4a.toString(socket.remotePublicKey, 'hex').slice(0, 12)
: '?'
log.info('Tunnel peer connected', { remote })
const local = net.connect({ host, port })
const cleanup = () => {
try {
socket.destroy()
} catch {
// ignore
}
try {
local.destroy()
} catch {
// ignore
}
}
socket.on('error', cleanup)
local.on('error', cleanup)
socket.on('close', cleanup)
local.on('close', cleanup)
socket.pipe(local).pipe(socket)
})
await server.listen(keyPair)
log.info('REST tunnel listening on HyperDHT', {
publicKeyHex,
target: `${host}:${port}`,
tip: `Dial HyperDHT ${publicKeyHex} → localhost REST`,
})
return { publicKeyHex, target: `${host}:${port}` }
}
export function getRestTunnelInfo() {
if (!publicKeyHex) return { enabled: isRestTunnelEnabled(), active: false }
return {
enabled: true,
active: Boolean(server),
publicKeyHex,
target: `${process.env.PEARDATA_REST_HOST || '127.0.0.1'}:${Number(process.env.PEARDATA_REST_PORT) || 19999}`,
seedConfigured: Boolean(process.env.PEARDATA_REST_TUNNEL_SEED),
}
}
export async function stopRestTunnel() {
if (server) {
try {
await server.close()
} catch {
// ignore
}
server = null
}
if (dht) {
try {
await dht.destroy()
} catch {
// ignore
}
dht = null
}
publicKeyHex = null
log.info('REST tunnel stopped')
}
/** Exported for tests — stable key from seed */
export function publicKeyFromTunnelSeed(seedHex) {
const kp = DHT.keyPair(b4a.from(seedHex, 'hex'))
return b4a.toString(kp.publicKey, 'hex')
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Rolling z-score helpers for anomaly retrain / hybrid detection.
*/
/**
* @param {number[]} values
* @returns {{ mean: number, stdev: number, n: number }}
*/
export function stats(values) {
const xs = values.filter((v) => v != null && Number.isFinite(v))
const n = xs.length
if (!n) return { mean: 0, stdev: 0, n: 0 }
const mean = xs.reduce((a, b) => a + b, 0) / n
if (n < 2) return { mean, stdev: 0, n }
let sumSq = 0
for (const v of xs) sumSq += (v - mean) ** 2
const stdev = Math.sqrt(sumSq / (n - 1))
return { mean, stdev, n }
}
/**
* @param {number} value
* @param {number} mean
* @param {number} stdev
*/
export function zScore(value, mean, stdev) {
if (!Number.isFinite(value) || !Number.isFinite(mean)) return 0
if (!stdev || stdev < 1e-9) return value === mean ? 0 : value > mean ? 3 : -3
return (value - mean) / stdev
}
/**
* Derive warn/crit absolute thresholds from baseline + z multipliers.
* For `>` comparator: warn = mean + warnZ*stdev, crit = mean + critZ*stdev
* For `<` comparator: warn = mean - warnZ*stdev, crit = mean - critZ*stdev
*
* @param {{ mean: number, stdev: number }} s
* @param {string} comparator
* @param {{ warnZ?: number, critZ?: number, minStdev?: number }} [opts]
*/
export function thresholdsFromStats(s, comparator, opts = {}) {
const warnZ = opts.warnZ ?? 2
const critZ = opts.critZ ?? 3
const minStdev = opts.minStdev ?? 0.01
const stdev = Math.max(s.stdev, minStdev)
const op = comparator || '>'
if (op === '<' || op === '<=') {
return {
warn: s.mean - warnZ * stdev,
crit: s.mean - critZ * stdev,
mean: s.mean,
stdev,
}
}
return {
warn: s.mean + warnZ * stdev,
crit: s.mean + critZ * stdev,
mean: s.mean,
stdev,
}
}
/**
* Extract numeric dimension series from MetricStore points.
* @param {import('./store.js').MetricStore} store
* @param {string} chart
* @param {string} dimension
* @param {number} [maxPoints]
* @returns {number[]}
*/
export function seriesFromStore(store, chart, dimension, maxPoints = 300) {
const entry = store.series?.get?.(chart)
const points = entry?.points || []
const slice = points.slice(-maxPoints)
/** @type {number[]} */
const out = []
for (const p of slice) {
const v = p.values?.[dimension]
if (v != null && Number.isFinite(v)) out.push(v)
}
return out
}