/** * Agent REST route handlers (v1 + v2 + v3). * * Local-agent style GET endpoints for charts/data/contexts/nodes/info/allmetrics. */ import os from 'os' import { APP_NAME, APP_VERSION, PROTOCOL, PROTOCOL_VERSION, Roles, } from '../../shared/protocol.js' import { getAllChartDefs, getContextIds, CHART_BY_ID, chartSummary, } from '../../shared/metrics.js' import { getStore } from '../services/store.js' import { getCollector } from '../services/collector.js' import { getAnomalyEngine } from '../services/anomaly.js' import { computeWeights } from '../services/weights.js' import { queryLogs } from '../services/logs.js' import { listProcesses } from '../services/processes.js' import { listAlerts, getAlert } from '../services/alerts.js' import { getServerPublicKeyHex } from '../core/auth-keys.js' import { formatAllMetrics } from './formatters.js' import { peers } from '../core/peer-registry.js' import { getDb, isHyperDbEnabled } from '../db/index.js' import { isSwarmEnabled } from '../db/replicate.js' import { listRemoteDbs } from '../db/remote.js' import { getParentCollector, isParentEnabled } from '../services/collectors/parent.js' import { getRestTunnelInfo } from '../services/rest-tunnel.js' /** * @param {string} pathname * @param {URLSearchParams} query * @returns {{ status: number, contentType: string, body: any }} */ export async function handleRest(pathname, query) { const path = pathname.replace(/\/+$/, '') || '/' // ── info / versions ────────────────────────────────────── if (path === '/api/v1/info' || path === '/api/v2/info' || path === '/api/v3/info') { return json(infoPayload()) } if (path === '/api/v3/versions') { return json({ agent: APP_VERSION, protocol: PROTOCOL, protocolVersion: PROTOCOL_VERSION, api: ['v1', 'v2', 'v3'], node: process.version, }) } if (path === '/api/v3/me') { return json({ authenticated: false, role: 'anonymous-rest', note: 'REST is local by default; P2P uses pubkey/invite roles', }) } // ── nodes ──────────────────────────────────────────────── if (path === '/api/v2/nodes' || path === '/api/v3/nodes') { const nodes = allNodePayloads() return json({ nodes, count: nodes.length }) } if (path === '/api/v3/node_instances') { return json({ nodes: allNodePayloads() }) } if (path === '/api/v3/fleet') { if (!isParentEnabled()) { return json({ enabled: false, local: nodePayload(), children: [] }) } const parent = getParentCollector() return json({ enabled: true, local: nodePayload(), children: parent.listChildren(), summary: parent.fleetSummary(), }) } // ── contexts ───────────────────────────────────────────── if (path === '/api/v2/contexts' || path === '/api/v3/contexts') { return json({ contexts: Object.fromEntries( getContextIds().map((id) => { const charts = getAllChartDefs().filter((c) => c.context === id) return [ id, { family: charts[0]?.family, title: charts[0]?.title, units: charts[0]?.units, charts: charts.map((c) => c.id), }, ] }) ), }) } if (path === '/api/v3/context' || path === '/api/v2/context') { const id = query.get('context') || query.get('id') || '' const charts = getAllChartDefs().filter((c) => c.context === id || c.id === id) if (!charts.length) return err(404, 'unknown context') return json({ id, charts: charts.map((c) => getStore().getMeta(c.id) || chartSummary(c)), }) } // ── charts (v1 legacy + still useful) ──────────────────── if (path === '/api/v1/charts') { return json({ hostname: os.hostname(), version: APP_VERSION, os: `${os.platform()} ${os.release()}`, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, history: Number(process.env.PEARDATA_TIER0_POINTS) || 3600, update_every: 1, charts: getStore().listChartSummaries(), }) } if (path === '/api/v1/chart') { const id = query.get('chart') || '' const meta = getStore().getMeta(id) || (CHART_BY_ID.has(id) ? chartSummary(CHART_BY_ID.get(id)) : null) if (!meta) return err(404, 'unknown chart') return json(meta) } // ── data queries ───────────────────────────────────────── if ( path === '/api/v1/data' || path === '/api/v2/data' || path === '/api/v3/data' ) { const chart = query.get('chart') || query.get('context') || query.get('scopes') || '' if (!chart) return err(400, 'chart or context required') // scopes may be comma-separated contexts — take first for MVP const chartId = chart.split(',')[0].trim() const resolved = CHART_BY_ID.has(chartId) ? chartId : getAllChartDefs().find((c) => c.context === chartId)?.id || chartId const result = await getStore().query({ chart: resolved, after: num(query.get('after'), -60), before: num(query.get('before'), 0), points: num(query.get('points'), 60), group: query.get('group') || query.get('time_group') || 'average', tier: num(query.get('tier'), 0), }) if (result.error) return err(404, result.error) const format = query.get('format') || 'json' if (format === 'csv') { const lines = [result.labels.join(',')] for (const row of result.data) lines.push(row.join(',')) return { status: 200, contentType: 'text/csv', body: lines.join('\n') + '\n' } } if (format === 'array') { return json(result.data) } return json(result) } // ── weights / metric correlations scoring ──────────────── if (path === '/api/v3/weights' || path === '/api/v2/weights' || path === '/api/v1/weights') { const result = await computeWeights({ method: query.get('method') || undefined, chart: query.get('chart') || query.get('context') || undefined, charts: query.get('charts') || undefined, contexts: query.get('contexts') || undefined, dimensions: query.get('dimensions') || undefined, after: query.get('after') ?? query.get('highlight_after') ?? undefined, before: query.get('before') ?? query.get('highlight_before') ?? undefined, baseline_after: query.get('baseline_after') || undefined, baseline_before: query.get('baseline_before') || undefined, points: query.get('points') || undefined, limit: query.get('limit') || undefined, timeout: query.get('timeout') || undefined, time_group: query.get('time_group') || query.get('group') || undefined, }) if (result.error) { return { status: 400, contentType: 'application/json', body: JSON.stringify(result) } } return json(result) } // ── processes (live /proc table) ───────────────────────── if ( path === '/api/v3/processes' || path === '/api/v2/processes' || path === '/api/v1/processes' ) { const result = listProcesses({ q: query.get('q') || query.get('query') || '', sort: query.get('sort') || undefined, order: query.get('order') || undefined, filter: query.get('filter') || undefined, limit: query.get('limit') || undefined, offset: query.get('offset') || undefined, pid: query.get('pid') || undefined, }) return json(result) } // ── logs (anomaly / audit / journal) ───────────────────── if (path === '/api/v3/logs' || path === '/api/v2/logs' || path === '/api/v1/logs') { const result = await queryLogs({ source: query.get('source') || 'anomaly', q: query.get('q') || query.get('query') || '', since: query.get('since') || undefined, until: query.get('until') || undefined, priority: query.get('priority') || undefined, unit: query.get('unit') || undefined, limit: query.get('limit') || undefined, cursor: query.get('cursor') || undefined, // REST is localhost-open; treat as admin for audit/journal role: Roles.admin, }) if (!result.ok) { const status = result.code === 'PERMISSION_DENIED' ? 403 : 400 return { status, contentType: 'application/json', body: JSON.stringify(result) } } return json(result) } if (path === '/api/v3/q' || path === '/api/v2/q') { const q = (query.get('q') || query.get('query') || '').toLowerCase() const hits = getAllChartDefs() .filter( (c) => !q || c.id.includes(q) || c.title.toLowerCase().includes(q) || c.context.includes(q) || c.family.includes(q) ) .map((c) => ({ type: 'chart', id: c.id, title: c.title, context: c.context })) return json({ results: hits, q }) } // ── alerts ─────────────────────────────────────────────── if ( path === '/api/v1/alarms' || path === '/api/v2/alerts' || path === '/api/v3/alerts' ) { return json({ alerts: listAlerts(), alarms: listAlerts() }) } if (path === '/api/v1/alarm_variables' || path === '/api/v3/variable') { const id = query.get('alarm') || query.get('alert') || query.get('name') || '' const a = id ? getAlert(id) : null return json(a || { alerts: listAlerts() }) } if (path === '/api/v3/alert_transitions') { return json({ transitions: getAnomalyEngine().listRecent(100) }) } if (path === '/api/v3/alert_config') { return json({ alerts: getAnomalyEngine().listConfigs() }) } // ── allmetrics export ──────────────────────────────────── if ( path === '/api/v1/allmetrics' || path === '/api/v2/allmetrics' || path === '/api/v3/allmetrics' ) { const format = (query.get('format') || 'json').toLowerCase() const out = formatAllMetrics(format) return { status: 200, contentType: out.contentType, body: typeof out.body === 'string' ? out.body : out.body, } } // ── badge ──────────────────────────────────────────────── if (path === '/api/v1/badge.svg' || path === '/api/v3/badge.svg') { const chart = query.get('chart') || 'system.cpu' const dim = query.get('dimensions') || query.get('dimension') || 'user' const latest = getStore().latestValues()[chart] const val = latest?.values?.[dim] const label = query.get('label') || `${chart}.${dim}` const text = val == null ? 'n/a' : String(Math.round(val * 100) / 100) const svg = ` ${escapeXml(label)} ${escapeXml(text)} ` return { status: 200, contentType: 'image/svg+xml', body: svg } } // ── functions / settings stubs ─────────────────────────── if (path === '/api/v3/functions' || path === '/api/v2/functions') { return json({ functions: [ { name: 'collectOnce' }, { name: 'snapshot' }, { name: 'exportSnapshot' }, { name: 'prometheusPush' }, { name: 'retrainAnomaly' }, { name: 'gcBuffers' }, ], }) } if (path === '/api/v3/tunnel') { return json(getRestTunnelInfo()) } if (path === '/api/v3/export' || path === '/api/v2/export') { const { buildSnapshot, writeSnapshotFile, toPrometheusText } = await import( '../services/export.js' ) const format = (query.get('format') || 'json').toLowerCase() const snapshot = buildSnapshot() if (query.get('write') === '1') writeSnapshotFile(snapshot) if (format === 'prometheus') { return { status: 200, contentType: 'text/plain; version=0.0.4', body: toPrometheusText(snapshot.latest) } } return json(snapshot) } if (path === '/api/v3/settings' || path === '/api/v3/config') { let retention = null let storage = null try { const { getRetentionConfig, getStorageInfo } = await import('../services/retention.js') retention = getRetentionConfig() storage = await getStorageInfo() } catch { // ignore } return json({ sample_ms: Number(process.env.PEARDATA_SAMPLE_MS) || 1000, rest_host: process.env.PEARDATA_REST_HOST || '127.0.0.1', rest_port: Number(process.env.PEARDATA_REST_PORT) || 18888, tier0_points: retention?.tier0Points ?? (Number(process.env.PEARDATA_TIER0_POINTS) || 3600), tier1_points: retention?.tier1Points ?? (Number(process.env.PEARDATA_TIER1_POINTS) || 1440), tier1_every: retention?.tier1Every ?? (Number(process.env.PEARDATA_TIER1_EVERY) || 60), retention, storage: storage ? { dataDir: storage.dataDir, dataDirBytes: storage.usage?.dataDirBytes, corestoreBytes: storage.usage?.corestoreBytes, memory: storage.memory, warm: storage.warm, } : null, }) } if (path === '/api/v3/stream_path') { const pathNodes = [ { node: getServerPublicKeyHex(), hostname: os.hostname(), hops: 0, role: isParentEnabled() ? 'parent' : 'agent', }, ] if (isParentEnabled()) { for (const child of getParentCollector().listChildren()) { pathNodes.push({ node: child.publicKeyHex, hostname: child.hostname || child.shortId, hops: 1, role: 'child', connected: child.connected, health: child.health, }) } } return json({ path: pathNodes }) } // ── health / root ──────────────────────────────────────── if (path === '/api/v1/health' || path === '/health' || path === '/api/v3/health') { return json(getAnomalyEngine().getHealth()) } if (path === '/api/v3/db' || path === '/api/v2/db') { const db = getDb() if (!db) return json({ enabled: false, hyperdb: isHyperDbEnabled() }) return json({ enabled: true, publicKeyHex: db.publicKeyHex, discoveryKeyHex: db.discoveryKeyHex, swarm: isSwarmEnabled(), remotes: listRemoteDbs(), collections: [ '@peardata/node', '@peardata/peer-link', '@peardata/alert-config', '@peardata/alert-event', '@peardata/metric-point', '@peardata/job', ], }) } if (path === '/' || path === '/api') { return json({ name: APP_NAME, version: APP_VERSION, apis: ['/api/v1', '/api/v2', '/api/v3'], docs: 'See docs/REST-API.md', p2p: { protocol: PROTOCOL, publicKeyHex: getServerPublicKeyHex() }, hyperdb: getDb() ? { publicKeyHex: getDb().publicKeyHex, discoveryKeyHex: getDb().discoveryKeyHex } : null, }) } return err(404, `not found: ${path}`) } function infoPayload() { const c = getCollector() return { version: APP_VERSION, uid: getServerPublicKeyHex(), mirrored_hosts: [os.hostname()], mirrored_hosts_status: [{ hostname: os.hostname(), reachable: true }], alarms: { normal: 0, warning: 0, critical: 0 }, os_name: os.platform(), os_id: os.release(), cores_total: os.cpus().length, total_ram: os.totalmem(), hostname: os.hostname(), collected: c.sampleCount, update_every: 1, peers_connected: peers.size(), peardata: { protocol: PROTOCOL, protocolVersion: PROTOCOL_VERSION, publicKeyHex: getServerPublicKeyHex(), anomalyMode: process.env.PEARDATA_ANOMALY_MODE || 'threshold', hyperdb: getDb() ? { publicKeyHex: getDb().publicKeyHex, discoveryKeyHex: getDb().discoveryKeyHex, swarm: isSwarmEnabled(), } : null, restTunnel: getRestTunnelInfo(), }, } } function nodePayload() { const pk = getServerPublicKeyHex() return { nm: os.hostname(), nd: pk?.slice(0, 16), guid: pk, hw: { cpu_cores: os.cpus().length, ram_total: os.totalmem(), }, os: { id: os.platform(), nm: os.release(), }, st: 'online', role: isParentEnabled() ? 'parent' : 'agent', } } function allNodePayloads() { const nodes = [nodePayload()] if (isParentEnabled()) { for (const child of getParentCollector().listChildren()) { nodes.push({ nm: child.hostname || child.shortId, nd: child.publicKeyHex.slice(0, 16), guid: child.publicKeyHex, st: child.connected ? 'online' : 'offline', role: 'child', health: child.health, cpu: child.cpu, ram: child.ram, lastSeen: child.lastSeen, }) } } return nodes } function json(body) { return { status: 200, contentType: 'application/json', body } } function err(status, message) { return { status, contentType: 'application/json', body: { error: message, status } } } function num(v, fallback) { if (v == null || v === '') return fallback const n = Number(v) return Number.isFinite(n) ? n : fallback } function escapeXml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') }