510 lines
18 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|