Files
peardata/server/rest/routes.js
T
Raven Scott f604d210c3
CI / test (push) Successful in 1m1s
Release rolling / release (push) Successful in 7m12s
Add process view
2026-07-19 15:41:35 -04:00

510 lines
18 KiB
JavaScript

/**
* 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="20">
<rect width="160" height="20" fill="#555"/>
<rect x="90" width="70" height="20" fill="#4c1"/>
<text x="45" y="14" fill="#fff" font-family="sans-serif" font-size="11" text-anchor="middle">${escapeXml(label)}</text>
<text x="125" y="14" fill="#fff" font-family="sans-serif" font-size="11" text-anchor="middle">${escapeXml(text)}</text>
</svg>`
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}