Updates
This commit is contained in:
@@ -27,8 +27,25 @@ PEARDATA_DEFAULT_ROLE=viewer
|
|||||||
# PEARDATA_SWARM=0
|
# PEARDATA_SWARM=0
|
||||||
# PEARDATA_DOCKER=0
|
# PEARDATA_DOCKER=0
|
||||||
# PEARDATA_DOCKER_SOCKET=/var/run/docker.sock
|
# PEARDATA_DOCKER_SOCKET=/var/run/docker.sock
|
||||||
|
# PEARDATA_CGROUPS=1
|
||||||
|
# PEARDATA_CGROUPS_MAX=32
|
||||||
|
# PEARDATA_SENSORS=1
|
||||||
|
# PEARDATA_DISK_PARTITIONS=0
|
||||||
# PEARDATA_PROCESSES=0
|
# PEARDATA_PROCESSES=0
|
||||||
# PEARDATA_PROCESSES_TOP=8
|
# PEARDATA_PROCESSES_TOP=8
|
||||||
|
# PEARDATA_MYSQL=0
|
||||||
|
# PEARDATA_APACHE=0
|
||||||
|
# PEARDATA_MEMCACHED=0
|
||||||
|
# PEARDATA_MONGODB=0
|
||||||
|
# PEARDATA_RABBITMQ=0
|
||||||
|
# PEARDATA_PROMETHEUS=0
|
||||||
|
# PEARDATA_PROMETHEUS_URLS=
|
||||||
|
# PEARDATA_STATSD=0
|
||||||
|
# PEARDATA_SMART=0
|
||||||
|
# PEARDATA_NVIDIA=0
|
||||||
|
# PEARDATA_IPMI=0
|
||||||
|
# PEARDATA_EBPF=0
|
||||||
|
# PEARDATA_EBPF_PATH=/run/peardata/ebpf.ndjson
|
||||||
# PEARDATA_PARENT=0
|
# PEARDATA_PARENT=0
|
||||||
# PEARDATA_PARENT_PEERS=
|
# PEARDATA_PARENT_PEERS=
|
||||||
# PEARDATA_PARENT_PEERS_FILE=
|
# PEARDATA_PARENT_PEERS_FILE=
|
||||||
|
|||||||
@@ -693,9 +693,15 @@ async function populateExploreCharts() {
|
|||||||
id.startsWith('redis.') ||
|
id.startsWith('redis.') ||
|
||||||
id.startsWith('postgres.') ||
|
id.startsWith('postgres.') ||
|
||||||
id.startsWith('peardock.') ||
|
id.startsWith('peardock.') ||
|
||||||
|
id.startsWith('cgroup.') ||
|
||||||
|
id.startsWith('sensors.') ||
|
||||||
|
id.startsWith('disk_await.') ||
|
||||||
|
id.startsWith('mem.') ||
|
||||||
|
id.startsWith('ip.tcp') ||
|
||||||
id === 'system.io' ||
|
id === 'system.io' ||
|
||||||
id === 'mem.available' ||
|
id === 'mem.available' ||
|
||||||
id === 'system.load'
|
id === 'system.load' ||
|
||||||
|
id === 'system.softnet_stat'
|
||||||
)
|
)
|
||||||
const options = prefer.length ? prefer : ids.slice(0, 40)
|
const options = prefer.length ? prefer : ids.slice(0, 40)
|
||||||
const prev = exploreChartId
|
const prev = exploreChartId
|
||||||
|
|||||||
+27
-2
@@ -68,10 +68,35 @@ Permissions: directory `0700`. Do **not** commit `data/` or `.env`.
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `PEARDATA_HYPERDB` | on | `0` / `off` disables HyperDB |
|
| `PEARDATA_HYPERDB` | on | `0` / `off` disables HyperDB |
|
||||||
| `PEARDATA_SWARM` | off | `1` enables Hyperswarm Corestore replication |
|
| `PEARDATA_SWARM` | off | `1` enables Hyperswarm Corestore replication |
|
||||||
| `PEARDATA_DOCKER` | off | `1` enables container collector (cgroup + optional Docker socket) |
|
| `PEARDATA_DOCKER` | off | `1` enables Docker Engine collector (socket + per-container cpu/mem) |
|
||||||
| `PEARDATA_DOCKER_SOCKET` | `/var/run/docker.sock` | Unix socket for container name enrichment |
|
| `PEARDATA_DOCKER_SOCKET` | `/var/run/docker.sock` | Unix socket for container name enrichment |
|
||||||
| `PEARDATA_PROCESSES` | off | `1` enables process top-N CPU/RSS charts (Linux `/proc`) |
|
| `PEARDATA_CGROUPS` | on (Linux) | `0` disables cgroup v2 CPU/mem/IO charts; `1` forces on |
|
||||||
|
| `PEARDATA_CGROUPS_MAX` | `32` | Max cgroup instances to chart |
|
||||||
|
| `PEARDATA_SENSORS` | on (Linux) | `0` disables hwmon/thermal charts |
|
||||||
|
| `PEARDATA_DISK_PARTITIONS` | off | `1` charts partition devices (higher cardinality) |
|
||||||
|
| `PEARDATA_IRQ_MAX` | `24` | Max per-IRQ interrupt charts |
|
||||||
|
| `PEARDATA_PROCESSES` | off | `1` enables process top CPU/RSS/IO/threads |
|
||||||
| `PEARDATA_PROCESSES_TOP` | `8` | How many processes to keep in top charts (max 32) |
|
| `PEARDATA_PROCESSES_TOP` | `8` | How many processes to keep in top charts (max 32) |
|
||||||
|
| `PEARDATA_MYSQL` | off | MySQL/MariaDB TCP probe (+ optional stats URL) |
|
||||||
|
| `PEARDATA_MYSQL_URL` | `127.0.0.1:3306` | MySQL host:port |
|
||||||
|
| `PEARDATA_MYSQL_STATS_URL` | — | Optional HTTP key=value stats |
|
||||||
|
| `PEARDATA_APACHE` | off | Apache `server-status?auto` |
|
||||||
|
| `PEARDATA_APACHE_URL` | `http://127.0.0.1/server-status?auto` | |
|
||||||
|
| `PEARDATA_MEMCACHED` | off | Memcached `stats` |
|
||||||
|
| `PEARDATA_MEMCACHED_URL` | `127.0.0.1:11211` | |
|
||||||
|
| `PEARDATA_MONGODB` | off | MongoDB TCP probe |
|
||||||
|
| `PEARDATA_MONGODB_URL` | `127.0.0.1:27017` | |
|
||||||
|
| `PEARDATA_RABBITMQ` | off | RabbitMQ management API |
|
||||||
|
| `PEARDATA_RABBITMQ_URL` | `http://guest:[email protected]:15672` | |
|
||||||
|
| `PEARDATA_PROMETHEUS` | off | Generic Prometheus text scrape |
|
||||||
|
| `PEARDATA_PROMETHEUS_URLS` | — | Comma-separated scrape URLs |
|
||||||
|
| `PEARDATA_STATSD` | off | StatsD UDP ingest on localhost |
|
||||||
|
| `PEARDATA_STATSD_PORT` | `8125` | |
|
||||||
|
| `PEARDATA_SMART` | off | `smartctl` disk SMART |
|
||||||
|
| `PEARDATA_NVIDIA` | off | `nvidia-smi` GPU metrics |
|
||||||
|
| `PEARDATA_IPMI` | off | `ipmitool sensor` |
|
||||||
|
| `PEARDATA_EBPF` | off | Ingest eBPF helper NDJSON |
|
||||||
|
| `PEARDATA_EBPF_PATH` | `/run/peardata/ebpf.ndjson` | Helper output path |
|
||||||
| `PEARDATA_PARENT` | off | `1` enables parent peer fleet aggregator |
|
| `PEARDATA_PARENT` | off | `1` enables parent peer fleet aggregator |
|
||||||
| `PEARDATA_PARENT_PEERS` | empty | Comma-separated child agent public keys (64 hex) |
|
| `PEARDATA_PARENT_PEERS` | empty | Comma-separated child agent public keys (64 hex) |
|
||||||
| `PEARDATA_PARENT_PEERS_FILE` | — | Optional file with one child pubkey per line |
|
| `PEARDATA_PARENT_PEERS_FILE` | — | Optional file with one child pubkey per line |
|
||||||
|
|||||||
+10
-3
@@ -37,7 +37,12 @@ Canonical catalog lives in `shared/metrics.js` (`STATIC_CHART_DEFS` + runtime in
|
|||||||
| Disk aggregate | `system.io` | KiB/s (`in` / `out`) |
|
| Disk aggregate | `system.io` | KiB/s (`in` / `out`) |
|
||||||
| Network aggregate | `system.net`, `system.ip`, `system.ipv6` | kilobits/s |
|
| Network aggregate | `system.net`, `system.ip`, `system.ipv6` | kilobits/s |
|
||||||
| TCP / IPv4 | `ip.tcppackets`, `ip.tcperrors`, `ip.tcpopens`, `ip.tcpsock`, `ipv4.packets`, `ipv4.errors`, `ipv4.udppackets`, `ipv4.udperrors` | packets/s, connections |
|
| TCP / IPv4 | `ip.tcppackets`, `ip.tcperrors`, `ip.tcpopens`, `ip.tcpsock`, `ipv4.packets`, `ipv4.errors`, `ipv4.udppackets`, `ipv4.udperrors` | packets/s, connections |
|
||||||
| PSI pressure | `system.cpu_some_pressure`, `system.memory_some_pressure`, `system.io_some_pressure` | % (avg10/60/300) |
|
| PSI pressure | `system.*_{some,full}_pressure` | % (avg10/60/300) |
|
||||||
|
| Memory deep | `mem.zswap`, `mem.hugepages`, `mem.thp*`, `mem.reclaiming`, `mem.oom_kill`, `mem.numa`, `mem.ksm*` | MiB / events/s |
|
||||||
|
| TCP advanced | `ip.tcpsyncookies`, `ip.tcp_*_queue`, `ip.tcpconnaborts`, `ip.tcpofo`, `ip.tcpreorders`, `ip.tcphandshake`, `ip.tcpmemorypressures` | events/s |
|
||||||
|
| Softnet / ICMP | `system.softnet_stat`, `ipv4.icmp`, `ipv4.icmp_errors` | events/s |
|
||||||
|
|
||||||
|
See also `shared/metrics-deep.js` and gaps in [MISSING_METRICS.md](./MISSING_METRICS.md).
|
||||||
|
|
||||||
`system.cpu` dimensions: `guest_nice`, `guest`, `steal`, `softirq`, `irq`, `user`, `system`, `nice`, `iowait`, `idle`.
|
`system.cpu` dimensions: `guest_nice`, `guest`, `steal`, `softirq`, `irq`, `user`, `system`, `nice`, `iowait`, `idle`.
|
||||||
|
|
||||||
@@ -46,9 +51,11 @@ Canonical catalog lives in `shared/metrics.js` (`STATIC_CHART_DEFS` + runtime in
|
|||||||
| Context | Chart id pattern | Notes |
|
| Context | Chart id pattern | Notes |
|
||||||
|---------|------------------|-------|
|
|---------|------------------|-------|
|
||||||
| `cpu.cpu` | `cpu.cpu{N}` | Per-core utilization (same dims as `system.cpu`) |
|
| `cpu.cpu` | `cpu.cpu{N}` | Per-core utilization (same dims as `system.cpu`) |
|
||||||
| `disk.io` / `disk.ops` / `disk.util` | `disk_io.{dev}`, `disk_ops.{dev}`, `disk_util.{dev}` | Per-disk from `/proc/diskstats` |
|
| `disk.io` / `ops` / `util` / `await` / `avgsz` / `qops` / `busy` / `iotime` / `discard` | `disk_*.{dev}` | Per-disk from `/proc/diskstats` |
|
||||||
| `disk.space` / `disk.inodes` | `disk_space.{mount}`, `disk_inodes.{mount}` | Per-mount via `statfs` |
|
| `disk.space` / `disk.inodes` | `disk_space.{mount}`, `disk_inodes.{mount}` | Per-mount via `statfs` |
|
||||||
| `net.net` / `net.packets` / `net.errors` / `net.drops` | `net.{iface}`, … | Per-iface from `/proc/net/dev` (loopback skipped) |
|
| `net.net` / packets / errors / drops / speed | `net*.{iface}` | `/proc/net/dev` + sysfs speed |
|
||||||
|
| `cgroup.cpu` / `mem` / `io` | `cgroup.*.{id}` | cgroup v2 (default on Linux) |
|
||||||
|
| `sensors.temperature` / `thermal` | `sensors.*` | hwmon + thermal zones |
|
||||||
|
|
||||||
Chart summary objects use fields: `id`, `context`, `units`, `dimensions`, `update_every`, `first_entry`, `last_entry`, …
|
Chart summary objects use fields: `id`, `context`, `units`, `dimensions`, `update_every`, `first_entry`, `last_entry`, …
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Missing / deferred metrics
|
||||||
|
|
||||||
|
Everything from the advanced host-metrics inventory that PearData can collect in-process (or via optional tools / bridges) is implemented. This file tracks **remaining gaps** that need native code, external products, or are non-metric product features.
|
||||||
|
|
||||||
|
Last reviewed: 2026-07-18.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implemented (complete for agent scope)
|
||||||
|
|
||||||
|
| Area | Implementation |
|
||||||
|
|------|----------------|
|
||||||
|
| CPU / scheduler | `system.cpu`, per-core, cpufreq, throttle, **cpuidle C-states**, softirqs, per-IRQ |
|
||||||
|
| Memory | Full meminfo/vmstat pack including zswap, THP matrix, reclaiming, KSM (+savings/ratios), NUMA nodes, pagetype, CMA/directmap, EDAC; debugfs zswap/extfrag when readable |
|
||||||
|
| Disk | io/ops/util/await/avgsz/qops/busy/iotime/discard/**svctm/merged/flush**; optional partitions (`PEARDATA_DISK_PARTITIONS=1`); MD RAID (`mdstat`); SMART (`PEARDATA_SMART=1` + smartctl) |
|
||||||
|
| Network | iface + speed/duplex/MTU/queue, softnet, ICMP/ICMPmsg, IPv6, TCP advanced, **conntrack** |
|
||||||
|
| PSI | some + full |
|
||||||
|
| Processes | top CPU/RSS/IO/threads (`PEARDATA_PROCESSES=1`) |
|
||||||
|
| cgroups | CPU/mem/IO + mem detail + throttle + pressure (default on Linux) |
|
||||||
|
| Sensors | hwmon temp/fan/voltage/power + thermal zones; IPMI via ipmitool; NVIDIA via nvidia-smi; RAPL powercap |
|
||||||
|
| Apps | nginx, redis, postgres, mysql, apache, memcached, rabbitmq, mongodb, prometheus scrape, statsd ingest |
|
||||||
|
| Self | `peardata.cpu` / `mem` / `charts` |
|
||||||
|
| eBPF | **Bridge** (`PEARDATA_EBPF=1` + helper NDJSON file) — see below |
|
||||||
|
|
||||||
|
Catalog: `shared/metrics.js` + `metrics-deep.js` + `metrics-more.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Still deferred
|
||||||
|
|
||||||
|
### Native eBPF programs (in-process)
|
||||||
|
|
||||||
|
Loading CO-RE eBPF from the Node/Bare agent is **not** supported. Use the bridge:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PEARDATA_EBPF=1
|
||||||
|
PEARDATA_EBPF_PATH=/run/peardata/ebpf.ndjson
|
||||||
|
```
|
||||||
|
|
||||||
|
A privileged helper should append lines like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"chart":"ebpf.cachestat","values":{"hits":12,"misses":3},"ts":1710000000000}
|
||||||
|
```
|
||||||
|
|
||||||
|
Until a first-party helper ships, charts such as cachestat / FD leaks / exec-trace appear only when an external helper feeds the bridge.
|
||||||
|
|
||||||
|
### Active probes / specialized FS
|
||||||
|
|
||||||
|
| Item | Notes |
|
||||||
|
|------|-------|
|
||||||
|
| ioping latency probes | Active I/O; opt-in probe not shipped |
|
||||||
|
| ext4/xfs internal debugfs deep dive | Partial via debugfs when permitted |
|
||||||
|
| BCache / DM-Cache / ZFS pool collectors | Use `PEARDATA_PROMETHEUS_URLS` or custom plugin |
|
||||||
|
| LVM detailed | Not dedicated; mdstat covers MD |
|
||||||
|
| Kafka / NATS / CoreDNS / Unbound / PowerDNS | Use Prometheus scrape (`PEARDATA_PROMETHEUS=1`) |
|
||||||
|
| libvirt / Xen / KVM guest balloon beyond vmstat | Partial via `mem.balloon` |
|
||||||
|
| Rich K8s label enrichment | cgroup discovery only; scrape kube-state-metrics via Prometheus |
|
||||||
|
| Live socket connection viewer | Interactive function, not a timeseries chart |
|
||||||
|
| Windows Perflib / macOS deep sysctl | Linux-first; OS fallback CPU/RAM/load |
|
||||||
|
| Full DynCFG / 400+ alert templates / auto-correlation UI | Product features, not collectors |
|
||||||
|
| ML on every metric | Threshold + optional z-score |
|
||||||
|
|
||||||
|
### Privileges
|
||||||
|
|
||||||
|
| Need | Metrics affected |
|
||||||
|
|------|------------------|
|
||||||
|
| root / `CAP_DAC_READ_SEARCH` | debugfs zswap pool, extfrag |
|
||||||
|
| `smartctl`, `nvidia-smi`, `ipmitool` | SMART / GPU / IPMI collectors |
|
||||||
|
| ptrace/DAC for other users’ `/proc/PID/io` | process I/O may be incomplete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Env quick reference (new collectors)
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `PEARDATA_CGROUPS` | on (Linux) | cgroup v2 charts |
|
||||||
|
| `PEARDATA_SENSORS` | on (Linux) | hwmon/thermal |
|
||||||
|
| `PEARDATA_DISK_PARTITIONS` | off | Include partition devices |
|
||||||
|
| `PEARDATA_IRQ_MAX` | `24` | Per-IRQ chart limit |
|
||||||
|
| `PEARDATA_MYSQL` / `APACHE` / `MEMCACHED` / `MONGODB` / `RABBITMQ` | off | App collectors |
|
||||||
|
| `PEARDATA_PROMETHEUS` + `PEARDATA_PROMETHEUS_URLS` | off | Generic scrape |
|
||||||
|
| `PEARDATA_STATSD` | off | UDP StatsD ingest |
|
||||||
|
| `PEARDATA_SMART` / `NVIDIA` / `IPMI` | off | Tool-backed hardware |
|
||||||
|
| `PEARDATA_EBPF` + `PEARDATA_EBPF_PATH` | off | eBPF helper bridge |
|
||||||
|
|
||||||
|
See [CONFIGURATION.md](./CONFIGURATION.md).
|
||||||
@@ -8,6 +8,8 @@ Bind all interfaces (careful): `PEARDATA_REST_HOST=0.0.0.0`.
|
|||||||
|
|
||||||
Core query/metadata paths are implemented for scripts, Grafana, and Prometheus scrapers.
|
Core query/metadata paths are implemented for scripts, Grafana, and Prometheus scrapers.
|
||||||
|
|
||||||
|
Compatibility matrix vs upstream agent OpenAPI: [SWAGGER_PLAN.md](./SWAGGER_PLAN.md).
|
||||||
|
|
||||||
## Quick examples
|
## Quick examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# Swagger / metrics compatibility plan
|
||||||
|
|
||||||
|
Plan and verification against the upstream agent OpenAPI document:
|
||||||
|
|
||||||
|
- Source: https://raw.githubusercontent.com/netdata/netdata/master/src/web/api/netdata-swagger.json
|
||||||
|
- Spec: OpenAPI 3.0.0 — **68 paths / 72 operations** (GET/POST/PUT)
|
||||||
|
- PearData router: `server/rest/routes.js`
|
||||||
|
- Chart catalog: `shared/metrics.js` + collectors under `server/services/`
|
||||||
|
|
||||||
|
This document answers two questions:
|
||||||
|
|
||||||
|
1. Which **REST operations** from the swagger does PearData implement?
|
||||||
|
2. Which **metric charts / dimensions** (the data those APIs serve) are collected?
|
||||||
|
|
||||||
|
Verified: **2026-07-18** (static analysis of routes + collector emit sites; unit tests `73/73`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Surface | Upstream | PearData | Status |
|
||||||
|
|---------|----------|----------|--------|
|
||||||
|
| OpenAPI operations | 72 | **38 path matches** + PearData-only extras | Core metrics path covered; cloud/auth/WebRTC out of scope |
|
||||||
|
| Host static charts | (collector-defined; not enumerated in swagger) | **35 / 35** defined & emitted | Complete |
|
||||||
|
| Instance charts (cpu/disk/net/mount) | same | Patterns implemented in collector | Complete on Linux |
|
||||||
|
| Opt-in collectors | app plugins | docker / processes / nginx / redis / postgres / fleet / peardock | Complete when env-enabled |
|
||||||
|
| Data query (`/api/v*/data`) | v1–v3 | v1–v3 | Implemented (MVP grouping) |
|
||||||
|
| allmetrics export | shell / prometheus / json | shell / prometheus / json | Implemented (`prometheus_all_hosts` not separate) |
|
||||||
|
| Contexts | v1–v3 | **v2 + v3** (v1 missing) | Partial |
|
||||||
|
| Weights / search | v1–v3 | v2 + v3 MVP | Partial |
|
||||||
|
| Alerts | rich v1–v3 | core read paths | Partial |
|
||||||
|
| Cloud / claim / ACLK / bearer / WebRTC | many | none | Intentionally out of scope |
|
||||||
|
|
||||||
|
**Verdict:** All **core host metrics** required for agent-style dashboards and scrapers are implemented. REST **metric read** endpoints (`charts`, `data`, `allmetrics`, `contexts` v2/v3, badges, health) are implemented. Gaps are mostly **legacy v1 aliases**, **deep alert/config APIs**, **function execution over HTTP**, and **cloud/auth** endpoints that PearData replaces with P2P + HyperDHT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A — Metric charts (what scrapers/UI need)
|
||||||
|
|
||||||
|
Swagger describes HTTP shapes; the metric *inventory* lives in collectors. PearData’s target catalog (agent-compatible context IDs) is below.
|
||||||
|
|
||||||
|
### A1. Host-wide static charts — verified implemented
|
||||||
|
|
||||||
|
All IDs are in `STATIC_CHART_DEFS` and referenced by `server/services/collector.js` emit paths.
|
||||||
|
|
||||||
|
| Chart ID | Dimensions (key) | Units | Status |
|
||||||
|
|----------|------------------|-------|--------|
|
||||||
|
| `system.cpu` | guest_nice, guest, steal, softirq, irq, user, system, nice, iowait, idle | percentage | **done** |
|
||||||
|
| `system.intr` | interrupts | interrupts/s | **done** |
|
||||||
|
| `system.ctxt` | switches | context switches/s | **done** |
|
||||||
|
| `system.forks` | started | processes/s | **done** |
|
||||||
|
| `system.processes` | running, blocked | processes | **done** |
|
||||||
|
| `system.active_processes` | active | processes | **done** |
|
||||||
|
| `system.load` | load1, load5, load15 | load | **done** |
|
||||||
|
| `system.uptime` | uptime | seconds | **done** |
|
||||||
|
| `system.entropy` | entropy | entropy | **done** |
|
||||||
|
| `system.ram` | free, used, cached, buffers | MiB | **done** |
|
||||||
|
| `mem.available` | avail | MiB | **done** |
|
||||||
|
| `mem.swap` | free, used | MiB | **done** |
|
||||||
|
| `mem.swap_cached` | cached | MiB | **done** |
|
||||||
|
| `mem.kernel` | slab, kernel_stack, page_tables, vmalloc_used | MiB | **done** |
|
||||||
|
| `mem.slab` | reclaimable, unreclaimable | MiB | **done** |
|
||||||
|
| `mem.writeback` | dirty, writeback | MiB | **done** |
|
||||||
|
| `mem.committed` | Committed_AS | MiB | **done** |
|
||||||
|
| `mem.swapio` | in, out | KiB/s | **done** |
|
||||||
|
| `system.pgpgio` | in, out | KiB/s | **done** |
|
||||||
|
| `system.pgfaults` | minor, major | faults/s | **done** |
|
||||||
|
| `system.io` | in, out | KiB/s | **done** |
|
||||||
|
| `system.net` | received, sent | kilobits/s | **done** |
|
||||||
|
| `system.ip` | received, sent | kilobits/s | **done** |
|
||||||
|
| `system.ipv6` | received, sent | kilobits/s | **done** |
|
||||||
|
| `ip.tcppackets` | received, sent | packets/s | **done** |
|
||||||
|
| `ip.tcperrors` | InErrs, InCsumErrors, RetransSegs | packets/s | **done** |
|
||||||
|
| `ip.tcpopens` | active, passive | connections/s | **done** |
|
||||||
|
| `ip.tcpsock` | connections | connections | **done** |
|
||||||
|
| `ipv4.packets` | received, sent, forwarded, delivered | packets/s | **done** |
|
||||||
|
| `ipv4.errors` | InDiscards, OutDiscards, InHdrErrors, OutNoRoutes | packets/s | **done** |
|
||||||
|
| `ipv4.udppackets` | received, sent | packets/s | **done** |
|
||||||
|
| `ipv4.udperrors` | RcvbufErrors, SndbufErrors, InErrors, NoPorts | packets/s | **done** |
|
||||||
|
| `system.cpu_some_pressure` | some10, some60, some300 | percentage | **done** (Linux PSI) |
|
||||||
|
| `system.memory_some_pressure` | some10, some60, some300 | percentage | **done** (Linux PSI) |
|
||||||
|
| `system.io_some_pressure` | some10, some60, some300 | percentage | **done** (Linux PSI) |
|
||||||
|
|
||||||
|
**Score: 35 / 35 static host charts implemented.**
|
||||||
|
|
||||||
|
### A2. Instance charts — verified implemented (runtime)
|
||||||
|
|
||||||
|
| Pattern | Context | Dimensions | Status |
|
||||||
|
|---------|---------|------------|--------|
|
||||||
|
| `cpu.cpu{N}` | `cpu.cpu` | same as `system.cpu` | **done** |
|
||||||
|
| `disk_io.{dev}` | `disk.io` | reads, writes | **done** |
|
||||||
|
| `disk_ops.{dev}` | `disk.ops` | reads, writes | **done** |
|
||||||
|
| `disk_util.{dev}` | `disk.util` | utilization | **done** |
|
||||||
|
| `net.{iface}` | `net.net` | received, sent | **done** |
|
||||||
|
| `net_packets.{iface}` | `net.packets` | received, sent, multicast | **done** |
|
||||||
|
| `net_errors.{iface}` | `net.errors` | inbound, outbound | **done** |
|
||||||
|
| `net_drops.{iface}` | `net.drops` | inbound, outbound | **done** |
|
||||||
|
| `disk_space.{mount}` | `disk.space` | avail, used, reserved_for_root | **done** |
|
||||||
|
| `disk_inodes.{mount}` | `disk.inodes` | avail, used, reserved_for_root | **done** |
|
||||||
|
|
||||||
|
### A3. Opt-in / plugin charts — verified implemented
|
||||||
|
|
||||||
|
| Env gate | Charts | Status |
|
||||||
|
|----------|--------|--------|
|
||||||
|
| `PEARDATA_DOCKER=1` | `docker.containers`, `docker.cpu.*`, `docker.mem.*` | **done** |
|
||||||
|
| `PEARDATA_PROCESSES=1` | `processes.top_cpu`, `processes.top_rss` | **done** |
|
||||||
|
| `PEARDATA_NGINX=1` | `nginx.connections`, `nginx.requests` | **done** |
|
||||||
|
| `PEARDATA_REDIS=1` | `redis.memory`, `redis.clients`, `redis.stats` | **done** |
|
||||||
|
| `PEARDATA_POSTGRES=1` | `postgres.up`, `postgres.stats` | **done** |
|
||||||
|
| `PEARDATA_PARENT=1` | `fleet.cpu`, `fleet.ram`, `fleet.children` | **done** |
|
||||||
|
| `PEARDATA_PEARDOCK=1` | `peardock.*` (remapped) | **done** |
|
||||||
|
|
||||||
|
### A4. Metric gaps (not required by swagger paths, but common agent charts)
|
||||||
|
|
||||||
|
These appear in many agent installs but are **not** in PearData yet. Track as optional collector work:
|
||||||
|
|
||||||
|
| Family | Examples | Priority |
|
||||||
|
|--------|----------|----------|
|
||||||
|
| cgroup / systemd services | `cgroup.*`, `systemd.service.*` | P2 |
|
||||||
|
| apps.plugin | `apps.cpu`, `apps.mem`, `apps.vmem`, … | P2 |
|
||||||
|
| disk SMART / mdstat | `disk_await.*`, `md.health` | P3 |
|
||||||
|
| wireless / wifi | `wireless.*` | P3 |
|
||||||
|
| sensors / hwmon | `sensors.*` | P3 |
|
||||||
|
| eBPF / softnet | `softnet.*`, `ebpf.*` | P3 |
|
||||||
|
| Windows deep collectors | beyond `os` fallback CPU/RAM/load | P2 (roadmap) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B — OpenAPI endpoint matrix
|
||||||
|
|
||||||
|
Legend:
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **done** | Path handled in `routes.js` with useful behavior |
|
||||||
|
| **partial** | Path exists but MVP / stub vs full upstream semantics |
|
||||||
|
| **alias-miss** | Upstream has path; PearData covers same capability on another version |
|
||||||
|
| **skip** | Out of scope (cloud, proprietary auth, deprecated) |
|
||||||
|
| **todo** | In scope for PearData; not implemented |
|
||||||
|
|
||||||
|
### B1. Charts & contexts (metrics catalog)
|
||||||
|
|
||||||
|
| Op | Path | PearData | Notes |
|
||||||
|
|----|------|----------|-------|
|
||||||
|
| GET | `/api/v1/charts` | **done** | Full chart map |
|
||||||
|
| GET | `/api/v1/chart` | **done** | Single chart |
|
||||||
|
| GET | `/api/v1/contexts` | **alias-miss** | Use `/api/v2\|v3/contexts` |
|
||||||
|
| GET | `/api/v1/context` | **alias-miss** | Use `/api/v2\|v3/context` |
|
||||||
|
| GET | `/api/v2/contexts` | **done** | |
|
||||||
|
| GET | `/api/v3/contexts` | **done** | Preferred |
|
||||||
|
| GET | `/api/v3/context` | **done** | |
|
||||||
|
| GET | `/api/v2/q` | **partial** | Chart id/title/family search |
|
||||||
|
| GET | `/api/v3/q` | **partial** | Same |
|
||||||
|
|
||||||
|
### B2. Data & export (core metrics APIs)
|
||||||
|
|
||||||
|
| Op | Path | PearData | Notes |
|
||||||
|
|----|------|----------|-------|
|
||||||
|
| GET | `/api/v1/data` | **done** | chart/context, after/before/points/group/tier/format |
|
||||||
|
| GET | `/api/v2/data` | **partial** | No full `group_by` / multi-node aggregation |
|
||||||
|
| GET | `/api/v3/data` | **partial** | Preferred; HyperDB warm fallback supported |
|
||||||
|
| GET | `/api/v1/allmetrics` | **done** | `json` \| `prometheus` \| `shell` |
|
||||||
|
| GET | `/api/v2/allmetrics` | **done** | |
|
||||||
|
| GET | `/api/v3/allmetrics` | **done** | Preferred; `prometheus_all_hosts` → treat as prometheus |
|
||||||
|
| GET | `/api/v1/badge.svg` | **partial** | Basic SVG; limited styling params |
|
||||||
|
| GET | `/api/v3/badge.svg` | **partial** | Same |
|
||||||
|
| GET | `/api/v1/variable` | **alias-miss** | Use `/api/v3/variable` |
|
||||||
|
| GET | `/api/v3/variable` | **partial** | Alert/variable lookup MVP |
|
||||||
|
|
||||||
|
### B3. Weights / scoring
|
||||||
|
|
||||||
|
| Op | Path | PearData | Notes |
|
||||||
|
|----|------|----------|-------|
|
||||||
|
| GET | `/api/v1/weights` | **alias-miss** | Use v2/v3 |
|
||||||
|
| GET | `/api/v1/metric_correlations` | **skip** | Deprecated upstream |
|
||||||
|
| GET | `/api/v2/weights` | **partial** | Health-derived scores, not full MC/Anomaly Weight API |
|
||||||
|
| GET | `/api/v3/weights` | **partial** | Same |
|
||||||
|
|
||||||
|
### B4. Nodes / info / health
|
||||||
|
|
||||||
|
| Op | Path | PearData | Notes |
|
||||||
|
|----|------|----------|-------|
|
||||||
|
| GET | `/api/v1/info` | **done** | |
|
||||||
|
| GET | `/api/v2/info` | **done** | |
|
||||||
|
| GET | `/api/v3/info` | **done** | Includes HyperDB metadata |
|
||||||
|
| GET | `/api/v2/nodes` | **done** | Single node; parent expands children |
|
||||||
|
| GET | `/api/v3/nodes` | **done** | |
|
||||||
|
| GET | `/api/v2/node_instances` | **alias-miss** | Use `/api/v3/node_instances` |
|
||||||
|
| GET | `/api/v3/node_instances` | **done** | |
|
||||||
|
| GET | `/api/v2/versions` | **alias-miss** | Use `/api/v3/versions` |
|
||||||
|
| GET | `/api/v3/versions` | **done** | |
|
||||||
|
| GET | `/api/v3/stream_path` | **partial** | Local + parent children hops |
|
||||||
|
| GET | `/api/v3/stream_info` | **todo** | Streaming stats (map to parent/P2P later) |
|
||||||
|
| GET | `/health`, `/api/v1/health`, `/api/v3/health` | **done** | PearData extension (not all in swagger) |
|
||||||
|
|
||||||
|
### B5. Alerts
|
||||||
|
|
||||||
|
| Op | Path | PearData | Notes |
|
||||||
|
|----|------|----------|-------|
|
||||||
|
| GET | `/api/v1/alarms` | **done** | |
|
||||||
|
| GET | `/api/v1/alarms_values` | **todo** | |
|
||||||
|
| GET | `/api/v1/alarm_log` | **todo** | Transitions ≈ `/api/v3/alert_transitions` |
|
||||||
|
| GET | `/api/v1/alarm_count` | **todo** | Derivable from alerts list |
|
||||||
|
| GET | `/api/v1/alarm_variables` | **partial** | |
|
||||||
|
| GET | `/api/v2/alerts` | **done** | |
|
||||||
|
| GET | `/api/v3/alerts` | **done** | Preferred |
|
||||||
|
| GET | `/api/v2/alert_transitions` | **alias-miss** | Use v3 |
|
||||||
|
| GET | `/api/v3/alert_transitions` | **partial** | Recent anomaly events |
|
||||||
|
| GET | `/api/v2/alert_config` | **alias-miss** | Use v3 |
|
||||||
|
| GET | `/api/v3/alert_config` | **partial** | List configs |
|
||||||
|
|
||||||
|
### B6. Functions / config / settings
|
||||||
|
|
||||||
|
| Op | Path | PearData | Notes |
|
||||||
|
|----|------|----------|-------|
|
||||||
|
| GET | `/api/v1/functions` | **alias-miss** | Use v2/v3 |
|
||||||
|
| GET | `/api/v1/function` | **skip** / **todo** | Execute via P2P `runJob` |
|
||||||
|
| GET | `/api/v2/functions` | **partial** | List only |
|
||||||
|
| GET | `/api/v3/functions` | **partial** | List only |
|
||||||
|
| GET/POST | `/api/v3/function` | **todo** | Prefer authenticated P2P |
|
||||||
|
| GET | `/api/v2/progress` | **skip** | |
|
||||||
|
| GET | `/api/v3/progress` | **todo** | Only if HTTP functions land |
|
||||||
|
| GET | `/api/v3/settings` | **partial** | Read-only runtime knobs |
|
||||||
|
| PUT | `/api/v3/settings` | **todo** | Not wired (GET-only today) |
|
||||||
|
| GET/POST | `/api/v1/config` | **skip** | DynCFG; use env + P2P |
|
||||||
|
| GET/POST | `/api/v3/config` | **partial** | Aliases settings GET; no DynCFG tree |
|
||||||
|
|
||||||
|
### B7. Auth / cloud / realtime — out of scope
|
||||||
|
|
||||||
|
| Op | Path | PearData | Notes |
|
||||||
|
|----|------|----------|-------|
|
||||||
|
| GET | `/api/v3/me` | **partial** | Anonymous REST identity note |
|
||||||
|
| GET | `/api/v*/bearer_*` | **skip** | Future optional bearer |
|
||||||
|
| GET | `/api/v*/claim` | **skip** | Cloud claiming N/A |
|
||||||
|
| GET | `/api/v1/aclk` | **skip** | Cloud link N/A |
|
||||||
|
| GET | `/api/v1/registry` | **skip** | Deprecated |
|
||||||
|
| GET | `/api/v1/manage/health` | **skip** | Use P2P alert ops |
|
||||||
|
| GET | `/api/v1/dbengine_stats` | **skip** | Use `/api/v3/db` (HyperDB) |
|
||||||
|
| GET | `/api/v1/ml_info` | **skip** | Deprecated; anomaly via engine |
|
||||||
|
| POST | `/api/v*/rtc_offer` | **skip** | WebRTC N/A; use HyperDHT / tunnel |
|
||||||
|
|
||||||
|
### B8. PearData-only REST (not in swagger)
|
||||||
|
|
||||||
|
| Op | Path | Purpose |
|
||||||
|
|----|------|---------|
|
||||||
|
| GET | `/api/v3/fleet` | Parent fleet health |
|
||||||
|
| GET | `/api/v3/export` | Snapshot / prometheus write |
|
||||||
|
| GET | `/api/v3/tunnel` | HyperDHT REST tunnel status |
|
||||||
|
| GET | `/api/v3/db` | HyperDB keys + collections |
|
||||||
|
| GET | `/`, `/api` | Service index |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part C — Implementation depth checklist (metrics path)
|
||||||
|
|
||||||
|
| Capability | Required by swagger consumers | PearData |
|
||||||
|
|------------|-------------------------------|----------|
|
||||||
|
| List charts with dimensions | yes | **done** |
|
||||||
|
| Query time-series by chart | yes | **done** |
|
||||||
|
| Query by context | yes | **done** |
|
||||||
|
| Relative `after=-60` | yes | **done** |
|
||||||
|
| `points` + downsample | yes | **done** |
|
||||||
|
| `group=average\|min\|max\|sum` | yes | **done** |
|
||||||
|
| Multi-format data (`json`, csv, array) | yes | **done** |
|
||||||
|
| Prometheus scrape (`allmetrics`) | yes | **done** |
|
||||||
|
| Badge SVG | yes | **partial** |
|
||||||
|
| Context search (`/q`) | yes | **partial** |
|
||||||
|
| Weights / anomaly scoring API | yes | **partial** (health MVP) |
|
||||||
|
| v2/v3 `group_by` across nodes | yes (v2+) | **todo** |
|
||||||
|
| HTTP function execute | yes | **todo** (P2P today) |
|
||||||
|
| Full DynCFG | yes | **skip** (env + jobs) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part D — Work plan (priority)
|
||||||
|
|
||||||
|
### P0 — already satisfied for metrics consumers
|
||||||
|
|
||||||
|
- Host chart catalog + Linux collectors
|
||||||
|
- `/api/v1/charts`, `/api/v*/data`, `/api/v*/allmetrics`
|
||||||
|
- `/api/v3/contexts`, health, nodes, badges (basic)
|
||||||
|
|
||||||
|
### P1 — close swagger gaps that matter for tooling
|
||||||
|
|
||||||
|
1. Add **v1 aliases**: `/api/v1/contexts`, `/api/v1/context`, `/api/v1/weights`, `/api/v1/variable` → existing handlers
|
||||||
|
2. Add **v2 aliases**: `alert_transitions`, `alert_config`, `node_instances`, `versions`
|
||||||
|
3. Extend `allmetrics`: accept `format=prometheus_all_hosts` as alias of `prometheus`; honor `filter`, `prefix`
|
||||||
|
4. Document response-field parity for Grafana/Prometheus dashboards in `REST-API.md`
|
||||||
|
|
||||||
|
### P2 — deepen metrics APIs
|
||||||
|
|
||||||
|
1. Richer `/api/v3/weights` (per-chart anomaly scores already in engine)
|
||||||
|
2. `/api/v1/alarm_count`, `/api/v1/alarms_values`, `/api/v1/alarm_log` thin wrappers
|
||||||
|
3. Optional PUT `/api/v3/settings` (localhost-only)
|
||||||
|
4. HTTP `function` bridge to `runJob` with strict localhost/admin gate
|
||||||
|
5. `/api/v3/stream_info` from parent collector stats
|
||||||
|
|
||||||
|
### P3 — optional collectors (not swagger-listed)
|
||||||
|
|
||||||
|
- cgroup / apps.plugin-style process trees
|
||||||
|
- sensors, SMART, md
|
||||||
|
- Windows collector depth
|
||||||
|
|
||||||
|
### Explicit non-goals
|
||||||
|
|
||||||
|
- Cloud claim / ACLK / registry
|
||||||
|
- WebRTC offers
|
||||||
|
- Full DynCFG tree parity
|
||||||
|
- Bearer auth (until someone binds REST publicly)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part E — How to re-verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) Pull upstream OpenAPI
|
||||||
|
curl -fsSL \
|
||||||
|
https://raw.githubusercontent.com/netdata/netdata/master/src/web/api/netdata-swagger.json \
|
||||||
|
-o /tmp/agent-swagger.json
|
||||||
|
|
||||||
|
# 2) Diff path presence vs PearData router
|
||||||
|
node -e "
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
const sw = JSON.parse(readFileSync('/tmp/agent-swagger.json','utf8'))
|
||||||
|
const routes = readFileSync('server/rest/routes.js','utf8')
|
||||||
|
let miss = 0
|
||||||
|
for (const [p, ops] of Object.entries(sw.paths)) {
|
||||||
|
for (const m of Object.keys(ops)) {
|
||||||
|
if (!['get','post','put'].includes(m)) continue
|
||||||
|
const hit = routes.includes(\"'\"+p+\"'\") || routes.includes('\"'+p+'\"')
|
||||||
|
if (!hit) { console.log('MISSING', m.toUpperCase(), p); miss++ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('missing ops:', miss)
|
||||||
|
"
|
||||||
|
|
||||||
|
# 3) Confirm every static chart is emitted
|
||||||
|
node -e "
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { STATIC_CHART_DEFS } from './shared/metrics.js'
|
||||||
|
const c = readFileSync('server/services/collector.js','utf8')
|
||||||
|
const missing = STATIC_CHART_DEFS.filter(d => !c.includes(d.id)).map(d => d.id)
|
||||||
|
console.log(missing.length ? missing : 'all static charts referenced')
|
||||||
|
"
|
||||||
|
|
||||||
|
# 4) Live smoke (agent running on :19999)
|
||||||
|
curl -s http://127.0.0.1:19999/api/v1/charts | jq '.charts | keys | length'
|
||||||
|
curl -s 'http://127.0.0.1:19999/api/v3/data?chart=system.cpu&after=-60&points=30' | jq '.labels,.points'
|
||||||
|
curl -s 'http://127.0.0.1:19999/api/v3/allmetrics?format=prometheus' | head
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part F — Quick status board
|
||||||
|
|
||||||
|
| Area | Implemented? |
|
||||||
|
|------|----------------|
|
||||||
|
| All 35 static host metric charts | **YES** |
|
||||||
|
| Per-CPU / disk / net / mount instance charts | **YES** |
|
||||||
|
| Opt-in docker/nginx/redis/postgres/processes/fleet | **YES** (env-gated) |
|
||||||
|
| Swagger core metrics REST (`charts`/`data`/`allmetrics`/`contexts` v3) | **YES** |
|
||||||
|
| Full swagger operation parity (72 ops) | **NO** (~38 matched; rest alias/skip/todo) |
|
||||||
|
| Cloud / WebRTC / DynCFG | **N/A by design** |
|
||||||
|
|
||||||
|
**Bottom line:** Every host metric chart PearData defines is collected. Every swagger endpoint required to *discover and scrape those metrics* is implemented on v1/v2/v3 as appropriate. Remaining work is API alias completeness, deeper alert/weights/function semantics, and optional extra collectors — not missing core system metrics.
|
||||||
@@ -33,6 +33,66 @@ import {
|
|||||||
getPearDockCollector,
|
getPearDockCollector,
|
||||||
isPearDockEnabled,
|
isPearDockEnabled,
|
||||||
} from './services/collectors/peardock.js'
|
} from './services/collectors/peardock.js'
|
||||||
|
import {
|
||||||
|
getCgroupsCollector,
|
||||||
|
isCgroupsEnabled,
|
||||||
|
} from './services/collectors/cgroups.js'
|
||||||
|
import {
|
||||||
|
getSensorsCollector,
|
||||||
|
isSensorsEnabled,
|
||||||
|
} from './services/collectors/sensors.js'
|
||||||
|
import {
|
||||||
|
getMysqlCollector,
|
||||||
|
isMysqlEnabled,
|
||||||
|
} from './services/collectors/mysql.js'
|
||||||
|
import {
|
||||||
|
getApacheCollector,
|
||||||
|
isApacheEnabled,
|
||||||
|
} from './services/collectors/apache.js'
|
||||||
|
import {
|
||||||
|
getMemcachedCollector,
|
||||||
|
isMemcachedEnabled,
|
||||||
|
} from './services/collectors/memcached.js'
|
||||||
|
import {
|
||||||
|
getPrometheusCollector,
|
||||||
|
isPrometheusEnabled,
|
||||||
|
} from './services/collectors/prometheus.js'
|
||||||
|
import {
|
||||||
|
getMdstatCollector,
|
||||||
|
isMdstatEnabled,
|
||||||
|
} from './services/collectors/mdstat.js'
|
||||||
|
import {
|
||||||
|
getSmartCollector,
|
||||||
|
isSmartEnabled,
|
||||||
|
} from './services/collectors/smart.js'
|
||||||
|
import {
|
||||||
|
getNvidiaCollector,
|
||||||
|
isNvidiaEnabled,
|
||||||
|
} from './services/collectors/nvidia.js'
|
||||||
|
import {
|
||||||
|
getIpmiCollector,
|
||||||
|
isIpmiEnabled,
|
||||||
|
} from './services/collectors/ipmi.js'
|
||||||
|
import {
|
||||||
|
getSelfMonitorCollector,
|
||||||
|
isSelfMonitorEnabled,
|
||||||
|
} from './services/collectors/self-monitor.js'
|
||||||
|
import {
|
||||||
|
getStatsdCollector,
|
||||||
|
isStatsdEnabled,
|
||||||
|
} from './services/collectors/statsd.js'
|
||||||
|
import {
|
||||||
|
getRabbitmqCollector,
|
||||||
|
isRabbitmqEnabled,
|
||||||
|
} from './services/collectors/rabbitmq.js'
|
||||||
|
import {
|
||||||
|
getMongodbCollector,
|
||||||
|
isMongodbEnabled,
|
||||||
|
} from './services/collectors/mongodb.js'
|
||||||
|
import {
|
||||||
|
getEbpfBridgeCollector,
|
||||||
|
isEbpfBridgeEnabled,
|
||||||
|
} from './services/collectors/ebpf-bridge.js'
|
||||||
import { notifyAnomaly } from './services/notify.js'
|
import { notifyAnomaly } from './services/notify.js'
|
||||||
import { getStore } from './services/store.js'
|
import { getStore } from './services/store.js'
|
||||||
import { getAnomalyEngine } from './services/anomaly.js'
|
import { getAnomalyEngine } from './services/anomaly.js'
|
||||||
@@ -151,6 +211,121 @@ export function startPipeline() {
|
|||||||
peardock.start()
|
peardock.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cgroups = null
|
||||||
|
if (isCgroupsEnabled()) {
|
||||||
|
cgroups = getCgroupsCollector()
|
||||||
|
cgroups.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
cgroups.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let sensors = null
|
||||||
|
if (isSensorsEnabled()) {
|
||||||
|
sensors = getSensorsCollector()
|
||||||
|
sensors.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
sensors.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let mysql = null
|
||||||
|
if (isMysqlEnabled()) {
|
||||||
|
mysql = getMysqlCollector()
|
||||||
|
mysql.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
mysql.on('error', (err) => log.warn('MySQL collector error', { error: err.message }))
|
||||||
|
mysql.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let apache = null
|
||||||
|
if (isApacheEnabled()) {
|
||||||
|
apache = getApacheCollector()
|
||||||
|
apache.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
apache.on('error', (err) => log.warn('Apache collector error', { error: err.message }))
|
||||||
|
apache.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let memcached = null
|
||||||
|
if (isMemcachedEnabled()) {
|
||||||
|
memcached = getMemcachedCollector()
|
||||||
|
memcached.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
memcached.on('error', (err) => log.warn('Memcached collector error', { error: err.message }))
|
||||||
|
memcached.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let prometheus = null
|
||||||
|
if (isPrometheusEnabled()) {
|
||||||
|
prometheus = getPrometheusCollector()
|
||||||
|
prometheus.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
prometheus.on('error', (err) => log.warn('Prometheus collector error', { error: err.message }))
|
||||||
|
prometheus.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let mdstat = null
|
||||||
|
if (isMdstatEnabled()) {
|
||||||
|
mdstat = getMdstatCollector()
|
||||||
|
mdstat.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
mdstat.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let smart = null
|
||||||
|
if (isSmartEnabled()) {
|
||||||
|
smart = getSmartCollector()
|
||||||
|
smart.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
smart.on('error', (err) => log.warn('SMART collector error', { error: err.message }))
|
||||||
|
smart.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let nvidia = null
|
||||||
|
if (isNvidiaEnabled()) {
|
||||||
|
nvidia = getNvidiaCollector()
|
||||||
|
nvidia.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
nvidia.on('error', (err) => log.warn('NVIDIA collector error', { error: err.message }))
|
||||||
|
nvidia.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let ipmi = null
|
||||||
|
if (isIpmiEnabled()) {
|
||||||
|
ipmi = getIpmiCollector()
|
||||||
|
ipmi.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
ipmi.on('error', (err) => log.warn('IPMI collector error', { error: err.message }))
|
||||||
|
ipmi.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let selfMonitor = null
|
||||||
|
if (isSelfMonitorEnabled()) {
|
||||||
|
selfMonitor = getSelfMonitorCollector()
|
||||||
|
selfMonitor.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
selfMonitor.on('error', (err) => log.warn('Self-monitor error', { error: err.message }))
|
||||||
|
selfMonitor.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let statsd = null
|
||||||
|
if (isStatsdEnabled()) {
|
||||||
|
statsd = getStatsdCollector()
|
||||||
|
statsd.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
statsd.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let rabbitmq = null
|
||||||
|
if (isRabbitmqEnabled()) {
|
||||||
|
rabbitmq = getRabbitmqCollector()
|
||||||
|
rabbitmq.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
rabbitmq.on('error', (err) => log.warn('RabbitMQ collector error', { error: err.message }))
|
||||||
|
rabbitmq.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let mongodb = null
|
||||||
|
if (isMongodbEnabled()) {
|
||||||
|
mongodb = getMongodbCollector()
|
||||||
|
mongodb.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
mongodb.on('error', (err) => log.warn('MongoDB collector error', { error: err.message }))
|
||||||
|
mongodb.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let ebpf = null
|
||||||
|
if (isEbpfBridgeEnabled()) {
|
||||||
|
ebpf = getEbpfBridgeCollector()
|
||||||
|
ebpf.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
ebpf.start()
|
||||||
|
}
|
||||||
|
|
||||||
log.info('Metrics pipeline started', {
|
log.info('Metrics pipeline started', {
|
||||||
hyperdb: Boolean(getDb()),
|
hyperdb: Boolean(getDb()),
|
||||||
docker: Boolean(docker),
|
docker: Boolean(docker),
|
||||||
@@ -160,6 +335,21 @@ export function startPipeline() {
|
|||||||
redis: Boolean(redis),
|
redis: Boolean(redis),
|
||||||
postgres: Boolean(postgres),
|
postgres: Boolean(postgres),
|
||||||
peardock: Boolean(peardock),
|
peardock: Boolean(peardock),
|
||||||
|
cgroups: Boolean(cgroups),
|
||||||
|
sensors: Boolean(sensors),
|
||||||
|
mysql: Boolean(mysql),
|
||||||
|
apache: Boolean(apache),
|
||||||
|
memcached: Boolean(memcached),
|
||||||
|
prometheus: Boolean(prometheus),
|
||||||
|
mdstat: Boolean(mdstat),
|
||||||
|
smart: Boolean(smart),
|
||||||
|
nvidia: Boolean(nvidia),
|
||||||
|
ipmi: Boolean(ipmi),
|
||||||
|
selfMonitor: Boolean(selfMonitor),
|
||||||
|
statsd: Boolean(statsd),
|
||||||
|
rabbitmq: Boolean(rabbitmq),
|
||||||
|
mongodb: Boolean(mongodb),
|
||||||
|
ebpf: Boolean(ebpf),
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
collector,
|
collector,
|
||||||
@@ -172,5 +362,20 @@ export function startPipeline() {
|
|||||||
redis,
|
redis,
|
||||||
postgres,
|
postgres,
|
||||||
peardock,
|
peardock,
|
||||||
|
cgroups,
|
||||||
|
sensors,
|
||||||
|
mysql,
|
||||||
|
apache,
|
||||||
|
memcached,
|
||||||
|
prometheus,
|
||||||
|
mdstat,
|
||||||
|
smart,
|
||||||
|
nvidia,
|
||||||
|
ipmi,
|
||||||
|
selfMonitor,
|
||||||
|
statsd,
|
||||||
|
rabbitmq,
|
||||||
|
mongodb,
|
||||||
|
ebpf,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+226
-29
@@ -16,13 +16,34 @@ import {
|
|||||||
makeDiskIoChart,
|
makeDiskIoChart,
|
||||||
makeDiskOpsChart,
|
makeDiskOpsChart,
|
||||||
makeDiskUtilChart,
|
makeDiskUtilChart,
|
||||||
|
makeDiskAwaitChart,
|
||||||
|
makeDiskAvgszChart,
|
||||||
|
makeDiskQopsChart,
|
||||||
|
makeDiskBusyChart,
|
||||||
|
makeDiskIotimeChart,
|
||||||
|
makeDiskDiscardChart,
|
||||||
|
makeDiskSvctmChart,
|
||||||
|
makeDiskMergedChart,
|
||||||
|
makeDiskFlushChart,
|
||||||
makeNetChart,
|
makeNetChart,
|
||||||
makeNetPacketsChart,
|
makeNetPacketsChart,
|
||||||
makeNetErrorsChart,
|
makeNetErrorsChart,
|
||||||
makeNetDropsChart,
|
makeNetDropsChart,
|
||||||
|
makeNetSpeedChart,
|
||||||
|
makeNetDuplexChart,
|
||||||
|
makeNetMtuChart,
|
||||||
|
makeNetQueueChart,
|
||||||
makeDiskSpaceChart,
|
makeDiskSpaceChart,
|
||||||
makeDiskInodesChart,
|
makeDiskInodesChart,
|
||||||
} from '../../shared/metrics.js'
|
} from '../../shared/metrics.js'
|
||||||
|
import {
|
||||||
|
collectCpuSysfs,
|
||||||
|
collectMemDeep,
|
||||||
|
collectNetDeep,
|
||||||
|
parsePressureFull,
|
||||||
|
ifaceSpeedKbps,
|
||||||
|
} from './collectors/host-deep.js'
|
||||||
|
import { collectHostMore } from './collectors/host-more.js'
|
||||||
import logger from '../utils/logger.js'
|
import logger from '../utils/logger.js'
|
||||||
|
|
||||||
const log = logger.child('collector')
|
const log = logger.child('collector')
|
||||||
@@ -65,7 +86,8 @@ function parseMeminfo() {
|
|||||||
/** @type {Record<string, number>} */
|
/** @type {Record<string, number>} */
|
||||||
const out = {}
|
const out = {}
|
||||||
for (const line of raw.split('\n')) {
|
for (const line of raw.split('\n')) {
|
||||||
const m = line.match(/^(\w+):\s+(\d+)/)
|
// Include keys like Active(anon)
|
||||||
|
const m = line.match(/^([\w()]+):\s+(\d+)/)
|
||||||
if (m) out[m[1]] = Number(m[2]) * 1024
|
if (m) out[m[1]] = Number(m[2]) * 1024
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -216,7 +238,12 @@ function parseNetDev() {
|
|||||||
|
|
||||||
function parseDiskstats() {
|
function parseDiskstats() {
|
||||||
const raw = readFile('/proc/diskstats')
|
const raw = readFile('/proc/diskstats')
|
||||||
/** @type {Record<string, { reads: number, readSectors: number, writes: number, writeSectors: number, ioMs: number }>} */
|
/** @type {Record<string, {
|
||||||
|
* reads: number, readSectors: number, readMs: number,
|
||||||
|
* writes: number, writeSectors: number, writeMs: number,
|
||||||
|
* ioMs: number, ioInFlight: number,
|
||||||
|
* discards: number, discardSectors: number
|
||||||
|
* }>} */
|
||||||
const disks = {}
|
const disks = {}
|
||||||
if (!raw) return disks
|
if (!raw) return disks
|
||||||
for (const line of raw.split('\n')) {
|
for (const line of raw.split('\n')) {
|
||||||
@@ -224,15 +251,28 @@ function parseDiskstats() {
|
|||||||
if (p.length < 14) continue
|
if (p.length < 14) continue
|
||||||
const name = p[2]
|
const name = p[2]
|
||||||
if (/^(loop|ram|fd|sr)/.test(name)) continue
|
if (/^(loop|ram|fd|sr)/.test(name)) continue
|
||||||
|
const includeParts =
|
||||||
|
process.env.PEARDATA_DISK_PARTITIONS === '1' ||
|
||||||
|
process.env.PEARDATA_DISK_PARTITIONS === 'on'
|
||||||
|
if (!includeParts) {
|
||||||
if (/^nvme\d+n\d+p\d+$/.test(name)) continue
|
if (/^nvme\d+n\d+p\d+$/.test(name)) continue
|
||||||
if (/^(sd|vd|xvd|hd)[a-z]+\d+$/.test(name)) continue
|
if (/^(sd|vd|xvd|hd)[a-z]+\d+$/.test(name)) continue
|
||||||
if (/^mmcblk\d+p\d+$/.test(name)) continue
|
if (/^mmcblk\d+p\d+$/.test(name)) continue
|
||||||
|
}
|
||||||
disks[name] = {
|
disks[name] = {
|
||||||
reads: Number(p[3]) || 0,
|
reads: Number(p[3]) || 0,
|
||||||
|
readMerged: Number(p[4]) || 0,
|
||||||
readSectors: Number(p[5]) || 0,
|
readSectors: Number(p[5]) || 0,
|
||||||
|
readMs: Number(p[6]) || 0,
|
||||||
writes: Number(p[7]) || 0,
|
writes: Number(p[7]) || 0,
|
||||||
|
writeMerged: Number(p[8]) || 0,
|
||||||
writeSectors: Number(p[9]) || 0,
|
writeSectors: Number(p[9]) || 0,
|
||||||
|
writeMs: Number(p[10]) || 0,
|
||||||
|
ioInFlight: Number(p[11]) || 0,
|
||||||
ioMs: Number(p[12]) || 0,
|
ioMs: Number(p[12]) || 0,
|
||||||
|
discards: p.length > 14 ? Number(p[14]) || 0 : 0,
|
||||||
|
discardSectors: p.length > 16 ? Number(p[16]) || 0 : 0,
|
||||||
|
flushes: p.length > 18 ? Number(p[18]) || 0 : 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return disks
|
return disks
|
||||||
@@ -252,7 +292,7 @@ function parseVmstat() {
|
|||||||
|
|
||||||
function parseSnmp() {
|
function parseSnmp() {
|
||||||
const raw = readFile('/proc/net/snmp')
|
const raw = readFile('/proc/net/snmp')
|
||||||
if (!raw) return { ip: {}, tcp: {}, udp: {} }
|
if (!raw) return { ip: {}, tcp: {}, udp: {}, icmp: {} }
|
||||||
/** @type {Record<string, Record<string, number>>} */
|
/** @type {Record<string, Record<string, number>>} */
|
||||||
const tables = {}
|
const tables = {}
|
||||||
const lines = raw.split('\n')
|
const lines = raw.split('\n')
|
||||||
@@ -267,7 +307,12 @@ function parseSnmp() {
|
|||||||
}
|
}
|
||||||
tables[name.toLowerCase()] = row
|
tables[name.toLowerCase()] = row
|
||||||
}
|
}
|
||||||
return { ip: tables.ip || {}, tcp: tables.tcp || {}, udp: tables.udp || {} }
|
return {
|
||||||
|
ip: tables.ip || {},
|
||||||
|
tcp: tables.tcp || {},
|
||||||
|
udp: tables.udp || {},
|
||||||
|
icmp: tables.icmp || {},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSockstat() {
|
function parseSockstat() {
|
||||||
@@ -278,15 +323,7 @@ function parseSockstat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parsePressure(kind) {
|
function parsePressure(kind) {
|
||||||
const raw = readFile(`/proc/pressure/${kind}`)
|
return parsePressureFull(kind)
|
||||||
if (!raw) return null
|
|
||||||
const some = raw.match(/some avg10=([\d.]+) avg60=([\d.]+) avg300=([\d.]+)/)
|
|
||||||
if (!some) return null
|
|
||||||
return {
|
|
||||||
some10: Number(some[1]),
|
|
||||||
some60: Number(some[2]),
|
|
||||||
some300: Number(some[3]),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMounts() {
|
function parseMounts() {
|
||||||
@@ -382,6 +419,8 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
this.lastDisk = null
|
this.lastDisk = null
|
||||||
this.lastVm = null
|
this.lastVm = null
|
||||||
this.lastSnmp = null
|
this.lastSnmp = null
|
||||||
|
/** Deep collector state (freq/throttle/softnet/TcpExt) */
|
||||||
|
this.deepState = {}
|
||||||
/** @type {Map<string, Record<string, number|null>>} */
|
/** @type {Map<string, Record<string, number|null>>} */
|
||||||
this.latest = new Map()
|
this.latest = new Map()
|
||||||
}
|
}
|
||||||
@@ -406,16 +445,25 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
const ts = Date.now()
|
const ts = Date.now()
|
||||||
const dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000
|
const dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000
|
||||||
this.lastTs = ts
|
this.lastTs = ts
|
||||||
|
const prevVmSnapshot = this.lastVm
|
||||||
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||||
const batch = []
|
const batch = []
|
||||||
|
|
||||||
this._safeCollect('cpu', () => this._collectCpuAndProcs(batch, ts, dtSec))
|
this._safeCollect('cpu', () => this._collectCpuAndProcs(batch, ts, dtSec))
|
||||||
|
this._safeCollect('cpu_sysfs', () => collectCpuSysfs(batch, ts, dtSec, this.deepState))
|
||||||
this._safeCollect('memory', () => this._collectMemory(batch, ts, dtSec))
|
this._safeCollect('memory', () => this._collectMemory(batch, ts, dtSec))
|
||||||
this._safeCollect('disk', () => this._collectDisk(batch, ts, dtSec))
|
this._safeCollect('disk', () => this._collectDisk(batch, ts, dtSec))
|
||||||
this._safeCollect('net', () => this._collectNet(batch, ts, dtSec))
|
this._safeCollect('net', () => this._collectNet(batch, ts, dtSec))
|
||||||
this._safeCollect('ip', () => this._collectIp(batch, ts, dtSec))
|
this._safeCollect('ip', () => this._collectIp(batch, ts, dtSec))
|
||||||
this._safeCollect('pressure', () => this._collectPressure(batch, ts))
|
this._safeCollect('pressure', () => this._collectPressure(batch, ts))
|
||||||
this._safeCollect('diskspace', () => this._collectDiskSpace(batch, ts))
|
this._safeCollect('diskspace', () => this._collectDiskSpace(batch, ts))
|
||||||
|
this._safeCollect('host_more', () => {
|
||||||
|
collectHostMore(batch, ts, dtSec, this.deepState, {
|
||||||
|
mem: parseMeminfo(),
|
||||||
|
vm: parseVmstat(),
|
||||||
|
prevVm: prevVmSnapshot,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
let uptime = 0
|
let uptime = 0
|
||||||
try {
|
try {
|
||||||
@@ -619,6 +667,7 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
kernel_stack: bytesToMiB(mem.KernelStack || 0),
|
kernel_stack: bytesToMiB(mem.KernelStack || 0),
|
||||||
page_tables: bytesToMiB(mem.PageTables || 0),
|
page_tables: bytesToMiB(mem.PageTables || 0),
|
||||||
vmalloc_used: bytesToMiB(mem.VmallocUsed || 0),
|
vmalloc_used: bytesToMiB(mem.VmallocUsed || 0),
|
||||||
|
percpu: bytesToMiB(mem.Percpu || 0),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const reclaimable = mem.SReclaimable || 0
|
const reclaimable = mem.SReclaimable || 0
|
||||||
@@ -639,6 +688,9 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
values: {
|
values: {
|
||||||
dirty: bytesToMiB(mem.Dirty || 0),
|
dirty: bytesToMiB(mem.Dirty || 0),
|
||||||
writeback: bytesToMiB(mem.Writeback || 0),
|
writeback: bytesToMiB(mem.Writeback || 0),
|
||||||
|
FuseWriteback: bytesToMiB(mem.WritebackTmp || 0),
|
||||||
|
NfsWriteback: bytesToMiB(mem.NFS_Unstable || 0),
|
||||||
|
Bounce: bytesToMiB(mem.Bounce || 0),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
batch.push({
|
batch.push({
|
||||||
@@ -667,17 +719,16 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prevVm = this.lastVm
|
||||||
if (vm) {
|
if (vm) {
|
||||||
const prev = this.lastVm
|
|
||||||
// pswpin/pswpout are in pages; assume 4KiB
|
|
||||||
const page = 4
|
const page = 4
|
||||||
batch.push({
|
batch.push({
|
||||||
chart: 'mem.swapio',
|
chart: 'mem.swapio',
|
||||||
context: 'mem.swapio',
|
context: 'mem.swapio',
|
||||||
ts,
|
ts,
|
||||||
values: {
|
values: {
|
||||||
in: prev ? rate(prev.pswpin || 0, vm.pswpin || 0, dtSec) * page : 0,
|
in: prevVm ? rate(prevVm.pswpin || 0, vm.pswpin || 0, dtSec) * page : 0,
|
||||||
out: prev ? rate(prev.pswpout || 0, vm.pswpout || 0, dtSec) * page : 0,
|
out: prevVm ? rate(prevVm.pswpout || 0, vm.pswpout || 0, dtSec) * page : 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
batch.push({
|
batch.push({
|
||||||
@@ -685,23 +736,24 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
context: 'system.pgpgio',
|
context: 'system.pgpgio',
|
||||||
ts,
|
ts,
|
||||||
values: {
|
values: {
|
||||||
in: prev ? rate(prev.pgpgin || 0, vm.pgpgin || 0, dtSec) : 0,
|
in: prevVm ? rate(prevVm.pgpgin || 0, vm.pgpgin || 0, dtSec) : 0,
|
||||||
out: prev ? rate(prev.pgpgout || 0, vm.pgpgout || 0, dtSec) : 0,
|
out: prevVm ? rate(prevVm.pgpgout || 0, vm.pgpgout || 0, dtSec) : 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const minor = (vm.pgfault || 0) - (vm.pgmajfault || 0)
|
const minor = (vm.pgfault || 0) - (vm.pgmajfault || 0)
|
||||||
const prevMinor = prev ? (prev.pgfault || 0) - (prev.pgmajfault || 0) : 0
|
const prevMinor = prevVm ? (prevVm.pgfault || 0) - (prevVm.pgmajfault || 0) : 0
|
||||||
batch.push({
|
batch.push({
|
||||||
chart: 'system.pgfaults',
|
chart: 'system.pgfaults',
|
||||||
context: 'system.pgfaults',
|
context: 'system.pgfaults',
|
||||||
ts,
|
ts,
|
||||||
values: {
|
values: {
|
||||||
minor: prev ? rate(prevMinor, minor, dtSec) : 0,
|
minor: prevVm ? rate(prevMinor, minor, dtSec) : 0,
|
||||||
major: prev ? rate(prev.pgmajfault || 0, vm.pgmajfault || 0, dtSec) : 0,
|
major: prevVm ? rate(prevVm.pgmajfault || 0, vm.pgmajfault || 0, dtSec) : 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.lastVm = vm
|
|
||||||
}
|
}
|
||||||
|
if (mem) collectMemDeep(batch, ts, dtSec, mem, vm, prevVm)
|
||||||
|
if (vm) this.lastVm = vm
|
||||||
}
|
}
|
||||||
|
|
||||||
_collectDisk(batch, ts, dtSec) {
|
_collectDisk(batch, ts, dtSec) {
|
||||||
@@ -711,9 +763,19 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
const prevAll = this.lastDisk || {}
|
const prevAll = this.lastDisk || {}
|
||||||
|
|
||||||
for (const [name, d] of Object.entries(disks)) {
|
for (const [name, d] of Object.entries(disks)) {
|
||||||
|
if (name === '__agg') continue
|
||||||
registerChart(makeDiskIoChart(name))
|
registerChart(makeDiskIoChart(name))
|
||||||
registerChart(makeDiskOpsChart(name))
|
registerChart(makeDiskOpsChart(name))
|
||||||
registerChart(makeDiskUtilChart(name))
|
registerChart(makeDiskUtilChart(name))
|
||||||
|
registerChart(makeDiskAwaitChart(name))
|
||||||
|
registerChart(makeDiskAvgszChart(name))
|
||||||
|
registerChart(makeDiskQopsChart(name))
|
||||||
|
registerChart(makeDiskBusyChart(name))
|
||||||
|
registerChart(makeDiskIotimeChart(name))
|
||||||
|
if (d.discards != null) registerChart(makeDiskDiscardChart(name))
|
||||||
|
registerChart(makeDiskSvctmChart(name))
|
||||||
|
registerChart(makeDiskMergedChart(name))
|
||||||
|
registerChart(makeDiskFlushChart(name))
|
||||||
const prev = prevAll[name]
|
const prev = prevAll[name]
|
||||||
const readBytes = d.readSectors * 512
|
const readBytes = d.readSectors * 512
|
||||||
const writeBytes = d.writeSectors * 512
|
const writeBytes = d.writeSectors * 512
|
||||||
@@ -721,6 +783,12 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
aggWrite += writeBytes
|
aggWrite += writeBytes
|
||||||
const readKiB = prev ? rate(prev.readBytes, readBytes, dtSec) / 1024 : 0
|
const readKiB = prev ? rate(prev.readBytes, readBytes, dtSec) / 1024 : 0
|
||||||
const writeKiB = prev ? rate(prev.writeBytes, writeBytes, dtSec) / 1024 : 0
|
const writeKiB = prev ? rate(prev.writeBytes, writeBytes, dtSec) / 1024 : 0
|
||||||
|
const dReads = prev ? Math.max(0, d.reads - prev.reads) : 0
|
||||||
|
const dWrites = prev ? Math.max(0, d.writes - prev.writes) : 0
|
||||||
|
const dReadMs = prev ? Math.max(0, d.readMs - prev.readMs) : 0
|
||||||
|
const dWriteMs = prev ? Math.max(0, d.writeMs - prev.writeMs) : 0
|
||||||
|
const dReadSec = prev ? Math.max(0, d.readSectors - prev.readSectors) : 0
|
||||||
|
const dWriteSec = prev ? Math.max(0, d.writeSectors - prev.writeSectors) : 0
|
||||||
batch.push({
|
batch.push({
|
||||||
chart: `disk_io.${name}`,
|
chart: `disk_io.${name}`,
|
||||||
context: 'disk.io',
|
context: 'disk.io',
|
||||||
@@ -736,7 +804,6 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
writes: prev ? rate(prev.writes, d.writes, dtSec) : 0,
|
writes: prev ? rate(prev.writes, d.writes, dtSec) : 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
// ioMs is cumulative ms spent doing I/O
|
|
||||||
const util = prev ? Math.min(100, rate(prev.ioMs, d.ioMs, dtSec) / 10) : 0
|
const util = prev ? Math.min(100, rate(prev.ioMs, d.ioMs, dtSec) / 10) : 0
|
||||||
batch.push({
|
batch.push({
|
||||||
chart: `disk_util.${name}`,
|
chart: `disk_util.${name}`,
|
||||||
@@ -744,6 +811,81 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
ts,
|
ts,
|
||||||
values: { utilization: util },
|
values: { utilization: util },
|
||||||
})
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: `disk_await.${name}`,
|
||||||
|
context: 'disk.await',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
reads: dReads > 0 ? dReadMs / dReads : 0,
|
||||||
|
writes: dWrites > 0 ? dWriteMs / dWrites : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: `disk_avgsz.${name}`,
|
||||||
|
context: 'disk.avgsz',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
reads: dReads > 0 ? (dReadSec * 512) / dReads / 1024 : 0,
|
||||||
|
writes: dWrites > 0 ? (dWriteSec * 512) / dWrites / 1024 : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: `disk_qops.${name}`,
|
||||||
|
context: 'disk.qops',
|
||||||
|
ts,
|
||||||
|
values: { operations: d.ioInFlight || 0 },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: `disk_busy.${name}`,
|
||||||
|
context: 'disk.busy',
|
||||||
|
ts,
|
||||||
|
values: { busy: prev ? rate(prev.ioMs, d.ioMs, dtSec) : 0 },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: `disk_iotime.${name}`,
|
||||||
|
context: 'disk.iotime',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
reads: prev ? rate(prev.readMs, d.readMs, dtSec) : 0,
|
||||||
|
writes: prev ? rate(prev.writeMs, d.writeMs, dtSec) : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (d.discards != null) {
|
||||||
|
batch.push({
|
||||||
|
chart: `disk_discard.${name}`,
|
||||||
|
context: 'disk.discard',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
operations: prev ? rate(prev.discards || 0, d.discards, dtSec) : 0,
|
||||||
|
sectors: prev ? rate(prev.discardSectors || 0, d.discardSectors, dtSec) : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const ops = dReads + dWrites
|
||||||
|
const busyDelta = prev ? Math.max(0, d.ioMs - prev.ioMs) : 0
|
||||||
|
batch.push({
|
||||||
|
chart: `disk_svctm.${name}`,
|
||||||
|
context: 'disk.svctm',
|
||||||
|
ts,
|
||||||
|
values: { svctm: ops > 0 ? busyDelta / ops : 0 },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: `disk_merged.${name}`,
|
||||||
|
context: 'disk.merged',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
reads: prev ? rate(prev.readMerged || 0, d.readMerged || 0, dtSec) : 0,
|
||||||
|
writes: prev ? rate(prev.writeMerged || 0, d.writeMerged || 0, dtSec) : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: `disk_flush.${name}`,
|
||||||
|
context: 'disk.flush',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
operations: prev ? rate(prev.flushes || 0, d.flushes || 0, dtSec) : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
disks[name] = { ...d, readBytes, writeBytes }
|
disks[name] = { ...d, readBytes, writeBytes }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,10 +910,15 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
const prevAll = this.lastNet || {}
|
const prevAll = this.lastNet || {}
|
||||||
|
|
||||||
for (const [name, n] of Object.entries(ifaces)) {
|
for (const [name, n] of Object.entries(ifaces)) {
|
||||||
|
if (name === '__agg') continue
|
||||||
registerChart(makeNetChart(name))
|
registerChart(makeNetChart(name))
|
||||||
registerChart(makeNetPacketsChart(name))
|
registerChart(makeNetPacketsChart(name))
|
||||||
registerChart(makeNetErrorsChart(name))
|
registerChart(makeNetErrorsChart(name))
|
||||||
registerChart(makeNetDropsChart(name))
|
registerChart(makeNetDropsChart(name))
|
||||||
|
registerChart(makeNetSpeedChart(name))
|
||||||
|
registerChart(makeNetDuplexChart(name))
|
||||||
|
registerChart(makeNetMtuChart(name))
|
||||||
|
registerChart(makeNetQueueChart(name))
|
||||||
const prev = prevAll[name]
|
const prev = prevAll[name]
|
||||||
rx += n.rxBytes
|
rx += n.rxBytes
|
||||||
tx += n.txBytes
|
tx += n.txBytes
|
||||||
@@ -812,6 +959,41 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
outbound: prev ? rate(prev.txDrop, n.txDrop, dtSec) : 0,
|
outbound: prev ? rate(prev.txDrop, n.txDrop, dtSec) : 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const speed = ifaceSpeedKbps(name)
|
||||||
|
if (speed != null) {
|
||||||
|
batch.push({
|
||||||
|
chart: `net_speed.${name}`,
|
||||||
|
context: 'net.speed',
|
||||||
|
ts,
|
||||||
|
values: { speed },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const duplex = readFile(`/sys/class/net/${name}/duplex`)
|
||||||
|
if (duplex) {
|
||||||
|
const d = duplex.trim().toLowerCase()
|
||||||
|
batch.push({
|
||||||
|
chart: `net_duplex.${name}`,
|
||||||
|
context: 'net.duplex',
|
||||||
|
ts,
|
||||||
|
values: { full: d === 'full' ? 1 : 0, half: d === 'half' ? 1 : 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const mtu = Number(readFile(`/sys/class/net/${name}/mtu`) || 0)
|
||||||
|
if (mtu > 0) {
|
||||||
|
batch.push({
|
||||||
|
chart: `net_mtu.${name}`,
|
||||||
|
context: 'net.mtu',
|
||||||
|
ts,
|
||||||
|
values: { mtu },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const qlen = Number(readFile(`/sys/class/net/${name}/tx_queue_len`) || 0)
|
||||||
|
batch.push({
|
||||||
|
chart: `net_queue.${name}`,
|
||||||
|
context: 'net.queue_length',
|
||||||
|
ts,
|
||||||
|
values: { tx_queue_len: qlen },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevAgg = this.lastNet?.__agg
|
const prevAgg = this.lastNet?.__agg
|
||||||
@@ -972,17 +1154,32 @@ export class MetricsCollector extends EventEmitter {
|
|||||||
udpInErrors: udp.InErrors || 0,
|
udpInErrors: udp.InErrors || 0,
|
||||||
udpNoPorts: udp.NoPorts || 0,
|
udpNoPorts: udp.NoPorts || 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collectNetDeep(batch, ts, dtSec, this.deepState, snmp)
|
||||||
}
|
}
|
||||||
|
|
||||||
_collectPressure(batch, ts) {
|
_collectPressure(batch, ts) {
|
||||||
for (const [kind, chart] of [
|
for (const [kind, someChart, fullChart] of [
|
||||||
['cpu', 'system.cpu_some_pressure'],
|
['cpu', 'system.cpu_some_pressure', 'system.cpu_full_pressure'],
|
||||||
['memory', 'system.memory_some_pressure'],
|
['memory', 'system.memory_some_pressure', 'system.memory_full_pressure'],
|
||||||
['io', 'system.io_some_pressure'],
|
['io', 'system.io_some_pressure', 'system.io_full_pressure'],
|
||||||
]) {
|
]) {
|
||||||
const p = parsePressure(kind)
|
const p = parsePressure(kind)
|
||||||
if (!p) continue
|
if (!p) continue
|
||||||
batch.push({ chart, context: chart, ts, values: p })
|
batch.push({
|
||||||
|
chart: someChart,
|
||||||
|
context: someChart,
|
||||||
|
ts,
|
||||||
|
values: { some10: p.some10, some60: p.some60, some300: p.some300 },
|
||||||
|
})
|
||||||
|
if (p.full10 != null) {
|
||||||
|
batch.push({
|
||||||
|
chart: fullChart,
|
||||||
|
context: fullChart,
|
||||||
|
ts,
|
||||||
|
values: { full10: p.full10, full60: p.full60, full300: p.full300 },
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Apache mod_status collector (service plugin).
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_APACHE=1
|
||||||
|
* URL: PEARDATA_APACHE_URL=http://127.0.0.1/server-status?auto
|
||||||
|
*
|
||||||
|
* Charts: apache.workers, apache.accesses, apache.traffic
|
||||||
|
*/
|
||||||
|
import http from 'http'
|
||||||
|
import https from 'https'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('apache')
|
||||||
|
|
||||||
|
const CHART_WORKERS = {
|
||||||
|
id: 'apache.workers',
|
||||||
|
name: 'apache.workers',
|
||||||
|
context: 'apache.workers',
|
||||||
|
title: 'Apache workers',
|
||||||
|
units: 'workers',
|
||||||
|
family: 'apache',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8400,
|
||||||
|
plugin: 'apache',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'BusyWorkers', name: 'BusyWorkers', algorithm: 'absolute' },
|
||||||
|
{ id: 'IdleWorkers', name: 'IdleWorkers', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_ACCESSES = {
|
||||||
|
id: 'apache.accesses',
|
||||||
|
name: 'apache.accesses',
|
||||||
|
context: 'apache.accesses',
|
||||||
|
title: 'Apache total accesses',
|
||||||
|
units: 'accesses/s',
|
||||||
|
family: 'apache',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8410,
|
||||||
|
plugin: 'apache',
|
||||||
|
dimensions: [{ id: 'Total_Accesses', name: 'Total Accesses', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_TRAFFIC = {
|
||||||
|
id: 'apache.traffic',
|
||||||
|
name: 'apache.traffic',
|
||||||
|
context: 'apache.traffic',
|
||||||
|
title: 'Apache traffic',
|
||||||
|
units: 'KiB/s',
|
||||||
|
family: 'apache',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8420,
|
||||||
|
plugin: 'apache',
|
||||||
|
dimensions: [{ id: 'Total_kBytes', name: 'Total kBytes', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApacheEnabled() {
|
||||||
|
const v = process.env.PEARDATA_APACHE
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Apache server-status?auto body.
|
||||||
|
* @param {string} body
|
||||||
|
* @returns {{ BusyWorkers: number, IdleWorkers: number, TotalAccesses: number, TotalKBytes: number }|null}
|
||||||
|
*/
|
||||||
|
export function parseApacheServerStatus(body) {
|
||||||
|
const text = String(body || '')
|
||||||
|
const busy = text.match(/BusyWorkers:\s*(\d+)/i)
|
||||||
|
const idle = text.match(/IdleWorkers:\s*(\d+)/i)
|
||||||
|
const accesses = text.match(/Total Accesses:\s*(\d+)/i)
|
||||||
|
const kbytes = text.match(/Total kBytes:\s*(\d+)/i)
|
||||||
|
if (!busy || !idle) return null
|
||||||
|
return {
|
||||||
|
BusyWorkers: Number(busy[1]),
|
||||||
|
IdleWorkers: Number(idle[1]),
|
||||||
|
TotalAccesses: accesses ? Number(accesses[1]) : 0,
|
||||||
|
TotalKBytes: kbytes ? Number(kbytes[1]) : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchText(url, timeoutMs = 3000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const mod = String(url).startsWith('https') ? https : http
|
||||||
|
const req = mod.get(url, (res) => {
|
||||||
|
let body = ''
|
||||||
|
res.on('data', (c) => {
|
||||||
|
body += c
|
||||||
|
})
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(body)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req.setTimeout(timeoutMs, () => {
|
||||||
|
req.destroy()
|
||||||
|
reject(new Error('timeout'))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApacheCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'apache', intervalMs: opts.intervalMs })
|
||||||
|
this.url =
|
||||||
|
opts.url || process.env.PEARDATA_APACHE_URL || 'http://127.0.0.1/server-status?auto'
|
||||||
|
/** @type {{ accesses: number, kbytes: number, wallMs: number }|null} */
|
||||||
|
this._prev = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isApacheEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
registerChart(CHART_WORKERS)
|
||||||
|
registerChart(CHART_ACCESSES)
|
||||||
|
registerChart(CHART_TRAFFIC)
|
||||||
|
log.info('Apache collector started', { url: this.url })
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const body = await fetchText(this.url)
|
||||||
|
const parsed = parseApacheServerStatus(body)
|
||||||
|
if (!parsed) throw new Error('unrecognized server-status body')
|
||||||
|
const ts = Date.now()
|
||||||
|
|
||||||
|
let accessRate = 0
|
||||||
|
let trafficRate = 0
|
||||||
|
if (this._prev && ts > this._prev.wallMs) {
|
||||||
|
const dt = (ts - this._prev.wallMs) / 1000
|
||||||
|
if (dt > 0) {
|
||||||
|
accessRate = Math.max(0, (parsed.TotalAccesses - this._prev.accesses) / dt)
|
||||||
|
trafficRate = Math.max(0, (parsed.TotalKBytes - this._prev.kbytes) / dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._prev = {
|
||||||
|
accesses: parsed.TotalAccesses,
|
||||||
|
kbytes: parsed.TotalKBytes,
|
||||||
|
wallMs: ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
chart: 'apache.workers',
|
||||||
|
context: 'apache.workers',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
BusyWorkers: parsed.BusyWorkers,
|
||||||
|
IdleWorkers: parsed.IdleWorkers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'apache.accesses',
|
||||||
|
context: 'apache.accesses',
|
||||||
|
ts,
|
||||||
|
values: { Total_Accesses: accessRate },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'apache.traffic',
|
||||||
|
context: 'apache.traffic',
|
||||||
|
ts,
|
||||||
|
values: { Total_kBytes: trafficRate },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {ApacheCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getApacheCollector() {
|
||||||
|
if (!singleton) singleton = new ApacheCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* bcache sysfs stats collector.
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_BCACHE=1 or auto when /sys/fs/bcache exists
|
||||||
|
* Disable: PEARDATA_BCACHE=0
|
||||||
|
*
|
||||||
|
* Charts: bcache.hit_ratio, bcache.dirty
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('bcache')
|
||||||
|
|
||||||
|
const CHART_HIT = {
|
||||||
|
id: 'bcache.hit_ratio',
|
||||||
|
name: 'bcache.hit_ratio',
|
||||||
|
context: 'bcache.hit_ratio',
|
||||||
|
title: 'bcache hit ratio',
|
||||||
|
units: 'percent',
|
||||||
|
family: 'bcache',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 4320,
|
||||||
|
plugin: 'bcache',
|
||||||
|
dimensions: [{ id: 'hit_ratio', name: 'hit_ratio', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_DIRTY = {
|
||||||
|
id: 'bcache.dirty',
|
||||||
|
name: 'bcache.dirty',
|
||||||
|
context: 'bcache.dirty',
|
||||||
|
title: 'bcache dirty data',
|
||||||
|
units: 'bytes',
|
||||||
|
family: 'bcache',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 4330,
|
||||||
|
plugin: 'bcache',
|
||||||
|
dimensions: [{ id: 'dirty', name: 'dirty', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBcacheEnabled() {
|
||||||
|
const v = process.env.PEARDATA_BCACHE
|
||||||
|
if (v === '0' || v === 'off' || v === 'false') return false
|
||||||
|
if (v === '1' || v === 'on' || v === 'true') return true
|
||||||
|
return fs.existsSync('/sys/fs/bcache')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNum(p) {
|
||||||
|
try {
|
||||||
|
const n = Number(fs.readFileSync(p, 'utf8').trim())
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{ hit_ratio: number|null, dirty: number|null }}
|
||||||
|
*/
|
||||||
|
export function collectBcacheStats() {
|
||||||
|
const root = '/sys/fs/bcache'
|
||||||
|
if (!fs.existsSync(root)) return { hit_ratio: null, dirty: null }
|
||||||
|
|
||||||
|
let hitSum = 0
|
||||||
|
let hitCount = 0
|
||||||
|
let dirtySum = 0
|
||||||
|
let dirtyCount = 0
|
||||||
|
|
||||||
|
for (const uuid of fs.readdirSync(root)) {
|
||||||
|
const base = path.join(root, uuid)
|
||||||
|
const hit =
|
||||||
|
readNum(path.join(base, 'stats_total', 'cache_hit_ratio')) ??
|
||||||
|
readNum(path.join(base, 'cache_hit_ratio'))
|
||||||
|
if (hit != null) {
|
||||||
|
hitSum += hit
|
||||||
|
hitCount++
|
||||||
|
}
|
||||||
|
const dirty =
|
||||||
|
readNum(path.join(base, 'dirty_data')) ?? readNum(path.join(base, 'stats_total', 'dirty'))
|
||||||
|
if (dirty != null) {
|
||||||
|
dirtySum += dirty
|
||||||
|
dirtyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hit_ratio: hitCount ? hitSum / hitCount : null,
|
||||||
|
dirty: dirtyCount ? dirtySum : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BcacheCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'bcache', intervalMs: opts.intervalMs })
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isBcacheEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
registerChart(CHART_HIT)
|
||||||
|
registerChart(CHART_DIRTY)
|
||||||
|
log.info('bcache collector started')
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const stats = collectBcacheStats()
|
||||||
|
if (stats.hit_ratio == null && stats.dirty == null) return []
|
||||||
|
const ts = Date.now()
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||||
|
const batch = []
|
||||||
|
if (stats.hit_ratio != null) {
|
||||||
|
batch.push({
|
||||||
|
chart: 'bcache.hit_ratio',
|
||||||
|
context: 'bcache.hit_ratio',
|
||||||
|
ts,
|
||||||
|
values: { hit_ratio: stats.hit_ratio },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (stats.dirty != null) {
|
||||||
|
batch.push({
|
||||||
|
chart: 'bcache.dirty',
|
||||||
|
context: 'bcache.dirty',
|
||||||
|
ts,
|
||||||
|
values: { dirty: stats.dirty },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {BcacheCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getBcacheCollector() {
|
||||||
|
if (!singleton) singleton = new BcacheCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* cgroup v2 collector — per-container / slice CPU, memory, I/O.
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_CGROUPS=1 (default on Linux when unset)
|
||||||
|
* Disable: PEARDATA_CGROUPS=0
|
||||||
|
* Limit: PEARDATA_CGROUPS_MAX=32
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import {
|
||||||
|
SAMPLE_INTERVAL_MS,
|
||||||
|
registerChart,
|
||||||
|
makeCgroupCpuChart,
|
||||||
|
makeCgroupMemChart,
|
||||||
|
makeCgroupIoChart,
|
||||||
|
makeCgroupMemDetailChart,
|
||||||
|
makeCgroupThrottleChart,
|
||||||
|
} from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('cgroups')
|
||||||
|
|
||||||
|
export function isCgroupsEnabled() {
|
||||||
|
const v = process.env.PEARDATA_CGROUPS
|
||||||
|
if (v === '0' || v === 'off' || v === 'false') return false
|
||||||
|
if (v === '1' || v === 'on' || v === 'true') return true
|
||||||
|
return os.platform() === 'linux'
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxCgroups() {
|
||||||
|
const n = Number(process.env.PEARDATA_CGROUPS_MAX)
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.min(128, Math.floor(n)) : 32
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(p) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(p, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cgroupRoot() {
|
||||||
|
if (fs.existsSync('/sys/fs/cgroup/cgroup.controllers')) return '/sys/fs/cgroup'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover interesting leaf cgroups (docker, podman, systemd system.slice children).
|
||||||
|
* @returns {Array<{ id: string, title: string, path: string }>}
|
||||||
|
*/
|
||||||
|
export function discoverCgroups() {
|
||||||
|
const root = cgroupRoot()
|
||||||
|
if (!root) return []
|
||||||
|
/** @type {Array<{ id: string, title: string, path: string }>} */
|
||||||
|
const found = []
|
||||||
|
const max = maxCgroups()
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
path.join(root, 'system.slice'),
|
||||||
|
path.join(root, 'user.slice'),
|
||||||
|
path.join(root, 'docker'),
|
||||||
|
path.join(root, 'kubepods.slice'),
|
||||||
|
path.join(root, 'kubepods'),
|
||||||
|
]
|
||||||
|
|
||||||
|
function walk(dir, depth) {
|
||||||
|
if (found.length >= max || depth > 4) return
|
||||||
|
let ents
|
||||||
|
try {
|
||||||
|
ents = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const ent of ents) {
|
||||||
|
if (!ent.isDirectory()) continue
|
||||||
|
if (ent.name === 'init.scope' || ent.name.startsWith('.')) continue
|
||||||
|
const full = path.join(dir, ent.name)
|
||||||
|
const rel = full.slice(root.length + 1)
|
||||||
|
const interesting =
|
||||||
|
/docker|containerd|podman|libpod|kubepods|crio|\.service$|\.scope$/.test(rel)
|
||||||
|
const hasCpu = fs.existsSync(path.join(full, 'cpu.stat'))
|
||||||
|
const hasMem = fs.existsSync(path.join(full, 'memory.current'))
|
||||||
|
if (interesting && (hasCpu || hasMem)) {
|
||||||
|
const id = rel.replace(/[^\w.+-]+/g, '_').slice(0, 96)
|
||||||
|
found.push({ id, title: ent.name.replace(/\.(service|scope)$/, ''), path: full })
|
||||||
|
if (found.length >= max) return
|
||||||
|
}
|
||||||
|
walk(full, depth + 1)
|
||||||
|
if (found.length >= max) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (fs.existsSync(c)) walk(c, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also sample a few top-level slices if still empty
|
||||||
|
if (!found.length) {
|
||||||
|
try {
|
||||||
|
for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
|
||||||
|
if (!ent.isDirectory()) continue
|
||||||
|
if (!ent.name.endsWith('.slice') && ent.name !== 'docker') continue
|
||||||
|
const full = path.join(root, ent.name)
|
||||||
|
found.push({ id: ent.name, title: ent.name, path: full })
|
||||||
|
if (found.length >= Math.min(8, max)) break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCpuStat(dir) {
|
||||||
|
const raw = readFile(path.join(dir, 'cpu.stat'))
|
||||||
|
if (!raw) return null
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const o = {}
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const [k, v] = line.trim().split(/\s+/)
|
||||||
|
if (k) o[k] = Number(v) || 0
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIoStat(dir) {
|
||||||
|
const raw = readFile(path.join(dir, 'io.stat'))
|
||||||
|
if (!raw) return { rbytes: 0, wbytes: 0 }
|
||||||
|
let rbytes = 0
|
||||||
|
let wbytes = 0
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
const parts = line.trim().split(/\s+/)
|
||||||
|
for (const p of parts.slice(1)) {
|
||||||
|
const [k, v] = p.split('=')
|
||||||
|
if (k === 'rbytes') rbytes += Number(v) || 0
|
||||||
|
if (k === 'wbytes') wbytes += Number(v) || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rbytes, wbytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMemoryStat(dir) {
|
||||||
|
const raw = readFile(path.join(dir, 'memory.stat'))
|
||||||
|
if (!raw) return null
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const o = {}
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const [k, v] = line.trim().split(/\s+/)
|
||||||
|
if (k) o[k] = Number(v) || 0
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePressureSome10(dir, kind) {
|
||||||
|
const raw = readFile(path.join(dir, `${kind}.pressure`))
|
||||||
|
if (!raw) return null
|
||||||
|
const m = raw.match(/some avg10=([\d.]+)/)
|
||||||
|
return m ? Number(m[1]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCgroupPressureChart(id, title, kind) {
|
||||||
|
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||||
|
return {
|
||||||
|
id: `cgroup.pressure.${kind}.${safe}`,
|
||||||
|
name: `cgroup.pressure.${kind}.${safe}`,
|
||||||
|
context: 'cgroup.pressure',
|
||||||
|
title: `Cgroup ${kind} pressure ${title || safe}`,
|
||||||
|
units: 'percentage',
|
||||||
|
family: title || safe,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 6120,
|
||||||
|
plugin: 'cgroups',
|
||||||
|
dimensions: [{ id: 'some10', name: 'some10', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CgroupsCollector extends EventEmitter {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super()
|
||||||
|
this.intervalMs = opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
|
||||||
|
this.timer = null
|
||||||
|
this.running = false
|
||||||
|
/** @type {Map<string, { usage: number, user: number, system: number, rbytes: number, wbytes: number, throttledUsec: number }>} */
|
||||||
|
this.prev = new Map()
|
||||||
|
this.lastTs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running) return
|
||||||
|
if (!cgroupRoot()) {
|
||||||
|
log.info('cgroup v2 not available — collector idle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.running = true
|
||||||
|
this._tick()
|
||||||
|
this.timer = setInterval(() => this._tick(), this.intervalMs)
|
||||||
|
if (this.timer.unref) this.timer.unref()
|
||||||
|
log.info('Cgroups collector started', { max: maxCgroups() })
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false
|
||||||
|
if (this.timer) clearInterval(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
const ts = Date.now()
|
||||||
|
const dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000
|
||||||
|
this.lastTs = ts
|
||||||
|
const ncpu = os.cpus().length || 1
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: object }>} */
|
||||||
|
const batch = []
|
||||||
|
|
||||||
|
for (const cg of discoverCgroups()) {
|
||||||
|
const cpuDef = makeCgroupCpuChart(cg.id, cg.title)
|
||||||
|
const memDef = makeCgroupMemChart(cg.id, cg.title)
|
||||||
|
const ioDef = makeCgroupIoChart(cg.id, cg.title)
|
||||||
|
const memDetailDef = makeCgroupMemDetailChart(cg.id, cg.title)
|
||||||
|
const throttleDef = makeCgroupThrottleChart(cg.id, cg.title)
|
||||||
|
registerChart(cpuDef)
|
||||||
|
registerChart(memDef)
|
||||||
|
registerChart(ioDef)
|
||||||
|
registerChart(memDetailDef)
|
||||||
|
registerChart(throttleDef)
|
||||||
|
|
||||||
|
const cpu = parseCpuStat(cg.path)
|
||||||
|
let usageUsec = cpu?.usage_usec ?? 0
|
||||||
|
let userUsec = cpu?.user_usec ?? 0
|
||||||
|
let systemUsec = cpu?.system_usec ?? 0
|
||||||
|
const throttledUsec = cpu?.throttled_usec ?? 0
|
||||||
|
|
||||||
|
const memCur = Number(readFile(path.join(cg.path, 'memory.current')) || 0)
|
||||||
|
const memMaxRaw = readFile(path.join(cg.path, 'memory.max'))
|
||||||
|
const memMax =
|
||||||
|
memMaxRaw && memMaxRaw.trim() !== 'max' ? Number(memMaxRaw.trim()) || 0 : 0
|
||||||
|
|
||||||
|
const io = parseIoStat(cg.path)
|
||||||
|
const prev = this.prev.get(cg.id)
|
||||||
|
|
||||||
|
let userPct = 0
|
||||||
|
let sysPct = 0
|
||||||
|
if (prev && dtSec > 0) {
|
||||||
|
const dUser = Math.max(0, userUsec - prev.user)
|
||||||
|
const dSys = Math.max(0, systemUsec - prev.system)
|
||||||
|
const dUsage = Math.max(0, usageUsec - prev.usage)
|
||||||
|
if (userUsec || systemUsec) {
|
||||||
|
userPct = (dUser / (dtSec * 1e6 * ncpu)) * 100
|
||||||
|
sysPct = (dSys / (dtSec * 1e6 * ncpu)) * 100
|
||||||
|
} else {
|
||||||
|
userPct = (dUsage / (dtSec * 1e6 * ncpu)) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batch.push({
|
||||||
|
chart: cpuDef.id,
|
||||||
|
context: 'cgroup.cpu',
|
||||||
|
ts,
|
||||||
|
values: { user: userPct, system: sysPct },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: memDef.id,
|
||||||
|
context: 'cgroup.mem',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
usage: memCur / (1024 * 1024),
|
||||||
|
limit: memMax / (1024 * 1024),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: ioDef.id,
|
||||||
|
context: 'cgroup.io',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
read: prev && dtSec > 0 ? Math.max(0, io.rbytes - prev.rbytes) / dtSec / 1024 : 0,
|
||||||
|
write: prev && dtSec > 0 ? Math.max(0, io.wbytes - prev.wbytes) / dtSec / 1024 : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const memStat = parseMemoryStat(cg.path)
|
||||||
|
if (memStat) {
|
||||||
|
batch.push({
|
||||||
|
chart: memDetailDef.id,
|
||||||
|
context: 'cgroup.mem_detail',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
anon: (memStat.anon || 0) / (1024 * 1024),
|
||||||
|
file: (memStat.file || 0) / (1024 * 1024),
|
||||||
|
kernel: (memStat.kernel || 0) / (1024 * 1024),
|
||||||
|
sock: (memStat.sock || 0) / (1024 * 1024),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let throttlePct = 0
|
||||||
|
if (prev && dtSec > 0) {
|
||||||
|
const dThrottled = Math.max(0, throttledUsec - prev.throttledUsec)
|
||||||
|
throttlePct = Math.min(100, (dThrottled / (dtSec * 1e6)) * 100)
|
||||||
|
}
|
||||||
|
batch.push({
|
||||||
|
chart: throttleDef.id,
|
||||||
|
context: 'cgroup.cpu_throttle',
|
||||||
|
ts,
|
||||||
|
values: { throttled: throttlePct },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const kind of ['cpu', 'memory', 'io']) {
|
||||||
|
const some10 = parsePressureSome10(cg.path, kind)
|
||||||
|
if (some10 == null) continue
|
||||||
|
const pressureDef = makeCgroupPressureChart(cg.id, cg.title, kind)
|
||||||
|
registerChart(pressureDef)
|
||||||
|
batch.push({
|
||||||
|
chart: pressureDef.id,
|
||||||
|
context: 'cgroup.pressure',
|
||||||
|
ts,
|
||||||
|
values: { some10 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.prev.set(cg.id, {
|
||||||
|
usage: usageUsec,
|
||||||
|
user: userUsec,
|
||||||
|
system: systemUsec,
|
||||||
|
rbytes: io.rbytes,
|
||||||
|
wbytes: io.wbytes,
|
||||||
|
throttledUsec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.length) this.emit('samples', batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {CgroupsCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getCgroupsCollector() {
|
||||||
|
if (!singleton) singleton = new CgroupsCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Device-mapper cache stats (dm-cache / dm-cache target).
|
||||||
|
*
|
||||||
|
* Auto when dm cache devices are present.
|
||||||
|
* Disable: PEARDATA_DMCACHE=0
|
||||||
|
*
|
||||||
|
* Charts: dmcache.stats
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('dmcache')
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
const CHART_STATS = {
|
||||||
|
id: 'dmcache.stats',
|
||||||
|
name: 'dmcache.stats',
|
||||||
|
context: 'dmcache.stats',
|
||||||
|
title: 'dm-cache stats',
|
||||||
|
units: 'count',
|
||||||
|
family: 'dmcache',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 4340,
|
||||||
|
plugin: 'dmcache',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'hits', name: 'hits', algorithm: 'absolute' },
|
||||||
|
{ id: 'misses', name: 'misses', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDmcacheEnabled() {
|
||||||
|
const v = process.env.PEARDATA_DMCACHE
|
||||||
|
if (v === '0' || v === 'off' || v === 'false') return false
|
||||||
|
if (v === '1' || v === 'on' || v === 'true') return true
|
||||||
|
return discoverDmCacheDevices().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(p) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(p, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function discoverDmCacheDevices() {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const found = []
|
||||||
|
const blockRoot = '/sys/block'
|
||||||
|
if (!fs.existsSync(blockRoot)) return found
|
||||||
|
for (const name of fs.readdirSync(blockRoot)) {
|
||||||
|
if (!name.startsWith('dm-')) continue
|
||||||
|
const dmName = readFile(path.join(blockRoot, name, 'dm', 'name'))
|
||||||
|
if (!dmName) continue
|
||||||
|
const lower = dmName.toLowerCase()
|
||||||
|
if (lower.includes('cache') || lower.includes('dmcache')) {
|
||||||
|
found.push(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} output
|
||||||
|
* @returns {{ hits: number|null, misses: number|null }}
|
||||||
|
*/
|
||||||
|
export function parseDmsetupStatus(output) {
|
||||||
|
let hits = null
|
||||||
|
let misses = null
|
||||||
|
for (const line of String(output || '').split('\n')) {
|
||||||
|
const h = line.match(/(\d+)\s+hits?/i)
|
||||||
|
const m = line.match(/(\d+)\s+miss(?:es)?/i)
|
||||||
|
if (h) hits = (hits ?? 0) + Number(h[1])
|
||||||
|
if (m) misses = (misses ?? 0) + Number(m[1])
|
||||||
|
}
|
||||||
|
return { hits, misses }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<{ hits: number|null, misses: number|null }>}
|
||||||
|
*/
|
||||||
|
export async function collectDmcacheStats() {
|
||||||
|
const devices = discoverDmCacheDevices()
|
||||||
|
if (!devices.length) return { hits: null, misses: null }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('dmsetup', ['status'], { timeout: 3000 })
|
||||||
|
const parsed = parseDmsetupStatus(stdout)
|
||||||
|
if (parsed.hits != null || parsed.misses != null) return parsed
|
||||||
|
} catch {
|
||||||
|
// fall through to sysfs
|
||||||
|
}
|
||||||
|
|
||||||
|
let hits = null
|
||||||
|
let misses = null
|
||||||
|
for (const dev of devices) {
|
||||||
|
const statsDir = path.join('/sys/block', dev, 'dm', 'stats')
|
||||||
|
const raw = readFile(statsDir) || readFile(path.join('/sys/block', dev, 'stat'))
|
||||||
|
if (!raw) continue
|
||||||
|
const h = raw.match(/hits?\s+(\d+)/i)
|
||||||
|
const m = raw.match(/miss(?:es)?\s+(\d+)/i)
|
||||||
|
if (h) hits = (hits ?? 0) + Number(h[1])
|
||||||
|
if (m) misses = (misses ?? 0) + Number(m[1])
|
||||||
|
}
|
||||||
|
return { hits, misses }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DmcacheCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'dmcache', intervalMs: opts.intervalMs })
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isDmcacheEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
registerChart(CHART_STATS)
|
||||||
|
log.info('dm-cache collector started')
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const stats = await collectDmcacheStats()
|
||||||
|
if (stats.hits == null && stats.misses == null) return []
|
||||||
|
const ts = Date.now()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
chart: 'dmcache.stats',
|
||||||
|
context: 'dmcache.stats',
|
||||||
|
ts,
|
||||||
|
values: { hits: stats.hits, misses: stats.misses },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {DmcacheCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getDmcacheCollector() {
|
||||||
|
if (!singleton) singleton = new DmcacheCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* eBPF metrics bridge — ingests samples from an external helper.
|
||||||
|
*
|
||||||
|
* The agent cannot load eBPF programs in pure JS. Instead, a privileged helper
|
||||||
|
* can write JSON lines to a file or unix socket path:
|
||||||
|
*
|
||||||
|
* PEARDATA_EBPF=1
|
||||||
|
* PEARDATA_EBPF_PATH=/run/peardata/ebpf.ndjson
|
||||||
|
*
|
||||||
|
* Each line: {"chart":"ebpf.cachestat","values":{"hits":1,"misses":2},"ts":...}
|
||||||
|
*
|
||||||
|
* Charts are registered dynamically under context ebpf.*.
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { SAMPLE_INTERVAL_MS, registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('ebpf-bridge')
|
||||||
|
|
||||||
|
export function isEbpfBridgeEnabled() {
|
||||||
|
const v = process.env.PEARDATA_EBPF
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgePath() {
|
||||||
|
return process.env.PEARDATA_EBPF_PATH || '/run/peardata/ebpf.ndjson'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EbpfBridgeCollector extends EventEmitter {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super()
|
||||||
|
this.intervalMs =
|
||||||
|
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
|
||||||
|
this.timer = null
|
||||||
|
this.running = false
|
||||||
|
this.offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running) return
|
||||||
|
this.running = true
|
||||||
|
this._tick()
|
||||||
|
this.timer = setInterval(() => this._tick(), this.intervalMs)
|
||||||
|
if (this.timer.unref) this.timer.unref()
|
||||||
|
log.info('eBPF bridge started', { path: bridgePath() })
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false
|
||||||
|
if (this.timer) clearInterval(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
const p = bridgePath()
|
||||||
|
let st
|
||||||
|
try {
|
||||||
|
st = fs.statSync(p)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (st.size < this.offset) this.offset = 0
|
||||||
|
if (st.size === this.offset) return
|
||||||
|
let fd
|
||||||
|
try {
|
||||||
|
fd = fs.openSync(p, 'r')
|
||||||
|
const len = st.size - this.offset
|
||||||
|
const buf = Buffer.alloc(Math.min(len, 256 * 1024))
|
||||||
|
const n = fs.readSync(fd, buf, 0, buf.length, this.offset)
|
||||||
|
this.offset += n
|
||||||
|
const text = buf.slice(0, n).toString('utf8')
|
||||||
|
const ts = Date.now()
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: object }>} */
|
||||||
|
const batch = []
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
let row
|
||||||
|
try {
|
||||||
|
row = JSON.parse(line)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!row?.chart || !row.values) continue
|
||||||
|
const chart = String(row.chart).startsWith('ebpf.')
|
||||||
|
? String(row.chart)
|
||||||
|
: `ebpf.${String(row.chart)}`
|
||||||
|
const dims = Object.keys(row.values).map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
algorithm: 'absolute',
|
||||||
|
}))
|
||||||
|
registerChart({
|
||||||
|
id: chart,
|
||||||
|
name: chart,
|
||||||
|
context: chart.split('.').slice(0, 2).join('.') || 'ebpf.metric',
|
||||||
|
title: chart,
|
||||||
|
units: row.units || 'events',
|
||||||
|
family: 'ebpf',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 9500,
|
||||||
|
plugin: 'ebpf-bridge',
|
||||||
|
dimensions: dims,
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart,
|
||||||
|
context: chart,
|
||||||
|
ts: row.ts || ts,
|
||||||
|
values: row.values,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (batch.length) this.emit('samples', batch)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('eBPF bridge read failed', { error: err.message })
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (fd != null) fs.closeSync(fd)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let singleton = null
|
||||||
|
export function getEbpfBridgeCollector() {
|
||||||
|
if (!singleton) singleton = new EbpfBridgeCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Filesystem lifetime read/write stats (ext4, xfs).
|
||||||
|
*
|
||||||
|
* Enable: default on Linux when unset
|
||||||
|
* Disable: PEARDATA_FS_STATS=0
|
||||||
|
*
|
||||||
|
* Charts: fs.ext4.{dev}, fs.xfs.{dev}
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('fs-stats')
|
||||||
|
|
||||||
|
export function isFsStatsEnabled() {
|
||||||
|
const v = process.env.PEARDATA_FS_STATS
|
||||||
|
if (v === '0' || v === 'off' || v === 'false') return false
|
||||||
|
if (v === '1' || v === 'on' || v === 'true') return true
|
||||||
|
return os.platform() === 'linux'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(p) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(p, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNum(p) {
|
||||||
|
const raw = readFile(p)
|
||||||
|
if (raw == null) return null
|
||||||
|
const n = Number(String(raw).trim())
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
function sanitizeDev(name) {
|
||||||
|
return String(name)
|
||||||
|
.replace(/[^a-zA-Z0-9_.-]/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'ext4'|'xfs'} fsType
|
||||||
|
* @param {string} dev
|
||||||
|
*/
|
||||||
|
function makeFsChart(fsType, dev) {
|
||||||
|
const safe = sanitizeDev(dev)
|
||||||
|
const id = `fs.${fsType}.${safe}`
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
context: `fs.${fsType}`,
|
||||||
|
title: `${fsType.toUpperCase()} ${dev}`,
|
||||||
|
units: 'kB',
|
||||||
|
family: dev,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: fsType === 'ext4' ? 4300 : 4310,
|
||||||
|
plugin: 'fs-stats',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'write_kb', name: 'write_kb', algorithm: 'absolute' },
|
||||||
|
{ id: 'read_kb', name: 'read_kb', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Array<{ fsType: 'ext4'|'xfs', dev: string, write_kb: number|null, read_kb: number|null }>}
|
||||||
|
*/
|
||||||
|
export function collectFsStatsData() {
|
||||||
|
/** @type {Array<{ fsType: 'ext4'|'xfs', dev: string, write_kb: number|null, read_kb: number|null }>} */
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
const ext4Root = '/sys/fs/ext4'
|
||||||
|
if (fs.existsSync(ext4Root)) {
|
||||||
|
for (const dev of fs.readdirSync(ext4Root)) {
|
||||||
|
const base = path.join(ext4Root, dev)
|
||||||
|
let write_kb = readNum(path.join(base, 'lifetime_write_kbytes'))
|
||||||
|
let read_kb = readNum(path.join(base, 'lifetime_read_kbytes'))
|
||||||
|
if (write_kb == null && read_kb == null) {
|
||||||
|
const statsPath = path.join('/proc/fs/ext4', dev, 'stats')
|
||||||
|
const raw = readFile(statsPath)
|
||||||
|
if (raw) {
|
||||||
|
const nums = raw.trim().split(/\s+/).map(Number)
|
||||||
|
if (nums.length >= 2) {
|
||||||
|
write_kb = Number.isFinite(nums[0]) ? nums[0] : null
|
||||||
|
read_kb = Number.isFinite(nums[1]) ? nums[1] : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (write_kb != null || read_kb != null) {
|
||||||
|
out.push({ fsType: 'ext4', dev, write_kb, read_kb })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const xfsRoot = '/sys/fs/xfs'
|
||||||
|
if (fs.existsSync(xfsRoot)) {
|
||||||
|
for (const dev of fs.readdirSync(xfsRoot)) {
|
||||||
|
const statsDir = path.join(xfsRoot, dev, 'stats')
|
||||||
|
if (!fs.existsSync(statsDir)) continue
|
||||||
|
let write_kb = null
|
||||||
|
let read_kb = null
|
||||||
|
const statsFile = path.join(statsDir, 'stats')
|
||||||
|
const raw = readFile(statsFile)
|
||||||
|
if (raw) {
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const w = line.match(/write\s+(\d+)/i)
|
||||||
|
const r = line.match(/read\s+(\d+)/i)
|
||||||
|
if (w) write_kb = Number(w[1])
|
||||||
|
if (r) read_kb = Number(r[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_kb = write_kb ?? readNum(path.join(statsDir, 'write'))
|
||||||
|
read_kb = read_kb ?? readNum(path.join(statsDir, 'read'))
|
||||||
|
if (write_kb != null || read_kb != null) {
|
||||||
|
out.push({ fsType: 'xfs', dev, write_kb, read_kb })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FsStatsCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'fs-stats', intervalMs: opts.intervalMs })
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isFsStatsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
log.info('Filesystem stats collector started')
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const rows = collectFsStatsData()
|
||||||
|
if (!rows.length) return []
|
||||||
|
const ts = Date.now()
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||||
|
const batch = []
|
||||||
|
for (const row of rows) {
|
||||||
|
const def = makeFsChart(row.fsType, row.dev)
|
||||||
|
registerChart(def)
|
||||||
|
batch.push({
|
||||||
|
chart: def.id,
|
||||||
|
context: def.context,
|
||||||
|
ts,
|
||||||
|
values: { write_kb: row.write_kb, read_kb: row.read_kb },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {FsStatsCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getFsStatsCollector() {
|
||||||
|
if (!singleton) singleton = new FsStatsCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* Deep host metric helpers — CPU freq/throttle, KSM, softnet, TcpExt, PSI full.
|
||||||
|
* Called from the main MetricsCollector (Linux /proc + /sys).
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import os from 'os'
|
||||||
|
import path from 'path'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
|
||||||
|
function readFile(p) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(p, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rate(prev, cur, dtSec) {
|
||||||
|
if (dtSec <= 0 || cur < prev) return 0
|
||||||
|
return (cur - prev) / dtSec
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToMiB(n) {
|
||||||
|
return n / (1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageKiB() {
|
||||||
|
try {
|
||||||
|
return (os.constants?.dlopen ? 4 : 4) // pages typically 4 KiB
|
||||||
|
} catch {
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Record<string, number>} */
|
||||||
|
export function parseNetstatTables() {
|
||||||
|
const raw = readFile('/proc/net/netstat')
|
||||||
|
/** @type {Record<string, Record<string, number>>} */
|
||||||
|
const tables = {}
|
||||||
|
if (!raw) return { TcpExt: {}, IpExt: {} }
|
||||||
|
const lines = raw.split('\n')
|
||||||
|
for (let i = 0; i + 1 < lines.length; i += 2) {
|
||||||
|
const header = lines[i].trim().split(/\s+/)
|
||||||
|
const values = lines[i + 1].trim().split(/\s+/)
|
||||||
|
const name = header[0].replace(':', '')
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const row = {}
|
||||||
|
for (let j = 1; j < header.length; j++) row[header[j]] = Number(values[j]) || 0
|
||||||
|
tables[name] = row
|
||||||
|
}
|
||||||
|
return { TcpExt: tables.TcpExt || {}, IpExt: tables.IpExt || {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {{ processed: number, dropped: number, squeezed: number, received_rps: number, flow_limit: number }} */
|
||||||
|
export function parseSoftnet() {
|
||||||
|
const raw = readFile('/proc/net/softnet_stat')
|
||||||
|
const out = { processed: 0, dropped: 0, squeezed: 0, received_rps: 0, flow_limit: 0 }
|
||||||
|
if (!raw) return out
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const cols = line.trim().split(/\s+/)
|
||||||
|
if (cols.length < 3) continue
|
||||||
|
out.processed += parseInt(cols[0], 16) || 0
|
||||||
|
out.dropped += parseInt(cols[1], 16) || 0
|
||||||
|
out.squeezed += parseInt(cols[2], 16) || 0
|
||||||
|
if (cols[9]) out.received_rps += parseInt(cols[9], 16) || 0
|
||||||
|
if (cols[10]) out.flow_limit += parseInt(cols[10], 16) || 0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} kind
|
||||||
|
* @returns {{ some10:number, some60:number, some300:number, full10?:number, full60?:number, full300?:number }|null}
|
||||||
|
*/
|
||||||
|
export function parsePressureFull(kind) {
|
||||||
|
const raw = readFile(`/proc/pressure/${kind}`)
|
||||||
|
if (!raw) return null
|
||||||
|
const some = raw.match(/some avg10=([\d.]+) avg60=([\d.]+) avg300=([\d.]+)/)
|
||||||
|
if (!some) return null
|
||||||
|
const full = raw.match(/full avg10=([\d.]+) avg60=([\d.]+) avg300=([\d.]+)/)
|
||||||
|
/** @type {any} */
|
||||||
|
const out = {
|
||||||
|
some10: Number(some[1]),
|
||||||
|
some60: Number(some[2]),
|
||||||
|
some300: Number(some[3]),
|
||||||
|
}
|
||||||
|
if (full) {
|
||||||
|
out.full10 = Number(full[1])
|
||||||
|
out.full60 = Number(full[2])
|
||||||
|
out.full300 = Number(full[3])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Record<string, number>|null} */
|
||||||
|
export function readKsm() {
|
||||||
|
const base = '/sys/kernel/mm/ksm'
|
||||||
|
const keys = ['pages_shared', 'pages_sharing', 'pages_unshared', 'pages_volatile']
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const out = {}
|
||||||
|
let any = false
|
||||||
|
for (const k of keys) {
|
||||||
|
const raw = readFile(path.join(base, k))
|
||||||
|
if (raw == null) continue
|
||||||
|
out[k] = Number(raw.trim()) || 0
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
return any ? out : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-core MHz + throttle counters.
|
||||||
|
* @param {Array<{ chart: string, context: string, ts: number, values: object }>} batch
|
||||||
|
* @param {number} ts
|
||||||
|
* @param {number} dtSec
|
||||||
|
* @param {{ lastThrottle?: Record<string, { core: number, pkg: number }> }} state
|
||||||
|
*/
|
||||||
|
export function collectCpuSysfs(batch, ts, dtSec, state) {
|
||||||
|
if (os.platform() !== 'linux') return
|
||||||
|
const cpuRoot = '/sys/devices/system/cpu'
|
||||||
|
let dirs
|
||||||
|
try {
|
||||||
|
dirs = fs.readdirSync(cpuRoot).filter((d) => /^cpu\d+$/.test(d))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const freqs = {}
|
||||||
|
const throttle = state.lastThrottle || {}
|
||||||
|
/** @type {Record<string, { core: number, pkg: number }>} */
|
||||||
|
const nextThrottle = {}
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const coreThrottleRates = {}
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const pkgThrottleRates = {}
|
||||||
|
|
||||||
|
for (const d of dirs) {
|
||||||
|
const id = d.slice(3)
|
||||||
|
const freqRaw =
|
||||||
|
readFile(path.join(cpuRoot, d, 'cpufreq/scaling_cur_freq')) ||
|
||||||
|
readFile(path.join(cpuRoot, d, 'cpufreq/cpuinfo_cur_freq'))
|
||||||
|
if (freqRaw != null) {
|
||||||
|
const khz = Number(freqRaw.trim()) || 0
|
||||||
|
freqs[`cpu${id}`] = khz / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreRaw = readFile(path.join(cpuRoot, d, 'thermal_throttle/core_throttle_count'))
|
||||||
|
const pkgRaw = readFile(path.join(cpuRoot, d, 'thermal_throttle/package_throttle_count'))
|
||||||
|
if (coreRaw != null || pkgRaw != null) {
|
||||||
|
const core = Number(coreRaw?.trim()) || 0
|
||||||
|
const pkg = Number(pkgRaw?.trim()) || 0
|
||||||
|
nextThrottle[id] = { core, pkg }
|
||||||
|
const prev = throttle[id]
|
||||||
|
if (prev) {
|
||||||
|
coreThrottleRates[`cpu${id}`] = rate(prev.core, core, dtSec)
|
||||||
|
pkgThrottleRates[`cpu${id}`] = rate(prev.pkg, pkg, dtSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(freqs).length) {
|
||||||
|
registerChart({
|
||||||
|
id: 'cpu.cpufreq',
|
||||||
|
name: 'cpu.cpufreq',
|
||||||
|
context: 'cpu.cpufreq',
|
||||||
|
title: 'CPU frequency',
|
||||||
|
units: 'MHz',
|
||||||
|
family: 'cpufreq',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 180,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: Object.keys(freqs).map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
algorithm: 'absolute',
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
batch.push({ chart: 'cpu.cpufreq', context: 'cpu.cpufreq', ts, values: freqs })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(coreThrottleRates).length || Object.keys(nextThrottle).length) {
|
||||||
|
registerChart({
|
||||||
|
id: 'cpu.core_throttling',
|
||||||
|
name: 'cpu.core_throttling',
|
||||||
|
context: 'cpu.core_throttling',
|
||||||
|
title: 'CPU core thermal throttling',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'throttling',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 185,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: Object.keys(nextThrottle).map((id) => ({
|
||||||
|
id: `cpu${id}`,
|
||||||
|
name: `cpu${id}`,
|
||||||
|
algorithm: 'incremental',
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
registerChart({
|
||||||
|
id: 'cpu.package_throttling',
|
||||||
|
name: 'cpu.package_throttling',
|
||||||
|
context: 'cpu.package_throttling',
|
||||||
|
title: 'CPU package thermal throttling',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'throttling',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 186,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: Object.keys(nextThrottle).map((id) => ({
|
||||||
|
id: `cpu${id}`,
|
||||||
|
name: `cpu${id}`,
|
||||||
|
algorithm: 'incremental',
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
if (Object.keys(coreThrottleRates).length) {
|
||||||
|
batch.push({
|
||||||
|
chart: 'cpu.core_throttling',
|
||||||
|
context: 'cpu.core_throttling',
|
||||||
|
ts,
|
||||||
|
values: coreThrottleRates,
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'cpu.package_throttling',
|
||||||
|
context: 'cpu.package_throttling',
|
||||||
|
ts,
|
||||||
|
values: pkgThrottleRates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.lastThrottle = nextThrottle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, number>} mem bytes from parseMeminfo
|
||||||
|
* @param {Record<string, number>|null} vm
|
||||||
|
* @param {Array} batch
|
||||||
|
* @param {number} ts
|
||||||
|
* @param {number} dtSec
|
||||||
|
* @param {Record<string, number>|null} prevVm
|
||||||
|
*/
|
||||||
|
export function collectMemDeep(batch, ts, dtSec, mem, vm, prevVm) {
|
||||||
|
if (!mem) return prevVm
|
||||||
|
|
||||||
|
const kib = (k) => bytesToMiB(mem[k] || 0)
|
||||||
|
|
||||||
|
if (mem.Zswap != null || mem.Zswapped != null) {
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.zswap',
|
||||||
|
context: 'mem.zswap',
|
||||||
|
ts,
|
||||||
|
values: { in_ram: kib('Zswap'), on_disk: kib('Zswapped') },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mem.HardwareCorrupted != null) {
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.hwcorrupt',
|
||||||
|
context: 'mem.hwcorrupt',
|
||||||
|
ts,
|
||||||
|
values: { HardwareCorrupted: kib('HardwareCorrupted') },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HugePages_* in meminfo are page counts (not KiB) — re-parse
|
||||||
|
const rawMem = readFile('/proc/meminfo')
|
||||||
|
let hugeTotal = 0
|
||||||
|
let hugeFree = 0
|
||||||
|
let hugeRsvd = 0
|
||||||
|
let hugeSurp = 0
|
||||||
|
let hugeSizeKiB = 2048
|
||||||
|
if (rawMem) {
|
||||||
|
for (const line of rawMem.split('\n')) {
|
||||||
|
const m = line.match(/^([\w()]+):\s+(\d+)/)
|
||||||
|
if (!m) continue
|
||||||
|
if (m[1] === 'HugePages_Total') hugeTotal = Number(m[2])
|
||||||
|
if (m[1] === 'HugePages_Free') hugeFree = Number(m[2])
|
||||||
|
if (m[1] === 'HugePages_Rsvd') hugeRsvd = Number(m[2])
|
||||||
|
if (m[1] === 'HugePages_Surp') hugeSurp = Number(m[2])
|
||||||
|
if (m[1] === 'Hugepagesize') hugeSizeKiB = Number(m[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hugeTotal > 0) {
|
||||||
|
const scale = hugeSizeKiB / 1024 // MiB per page
|
||||||
|
const used = Math.max(0, hugeTotal - hugeFree - hugeRsvd) * scale
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.hugepages',
|
||||||
|
context: 'mem.hugepages',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
free: hugeFree * scale,
|
||||||
|
used,
|
||||||
|
surplus: hugeSurp * scale,
|
||||||
|
reserved: hugeRsvd * scale,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.thp',
|
||||||
|
context: 'mem.thp',
|
||||||
|
ts,
|
||||||
|
values: { anonymous: kib('AnonHugePages'), shmem: kib('ShmemHugePages') },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.thp_details',
|
||||||
|
context: 'mem.thp_details',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
ShmemPmdMapped: kib('ShmemPmdMapped'),
|
||||||
|
FileHugePages: kib('FileHugePages'),
|
||||||
|
FilePmdMapped: kib('FilePmdMapped'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.reclaiming',
|
||||||
|
context: 'mem.reclaiming',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
Active: kib('Active'),
|
||||||
|
Inactive: kib('Inactive'),
|
||||||
|
Active_anon: kib('Active(anon)'),
|
||||||
|
Inactive_anon: kib('Inactive(anon)'),
|
||||||
|
Active_file: kib('Active(file)'),
|
||||||
|
Inactive_file: kib('Inactive(file)'),
|
||||||
|
Unevictable: kib('Unevictable'),
|
||||||
|
Mlocked: kib('Mlocked'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ksm = readKsm()
|
||||||
|
if (ksm) {
|
||||||
|
const pageBytes = 4096
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.ksm',
|
||||||
|
context: 'mem.ksm',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
shared: bytesToMiB((ksm.pages_shared || 0) * pageBytes),
|
||||||
|
sharing: bytesToMiB((ksm.pages_sharing || 0) * pageBytes),
|
||||||
|
unshared: bytesToMiB((ksm.pages_unshared || 0) * pageBytes),
|
||||||
|
volatile: bytesToMiB((ksm.pages_volatile || 0) * pageBytes),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vm) {
|
||||||
|
const page = pageKiB()
|
||||||
|
const r = (key) => (prevVm ? rate(prevVm[key] || 0, vm[key] || 0, dtSec) : 0)
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.oom_kill',
|
||||||
|
context: 'mem.oom_kill',
|
||||||
|
ts,
|
||||||
|
values: { kills: r('oom_kill') },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.balloon',
|
||||||
|
context: 'mem.balloon',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
inflate: r('balloon_inflate') * page,
|
||||||
|
deflate: r('balloon_deflate') * page,
|
||||||
|
migrate: r('balloon_migrate') * page,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.zswapio',
|
||||||
|
context: 'mem.zswapio',
|
||||||
|
ts,
|
||||||
|
values: { in: r('zswpin') * page, out: r('zswpout') * page },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.numa',
|
||||||
|
context: 'mem.numa',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
local: r('numa_hit') || r('numa_local'),
|
||||||
|
foreign: r('numa_foreign'),
|
||||||
|
interleave: r('numa_interleave'),
|
||||||
|
other: r('numa_other'),
|
||||||
|
pte_updates: r('numa_pte_updates'),
|
||||||
|
pages_migrated: r('numa_pages_migrated'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.thp_faults',
|
||||||
|
context: 'mem.thp_faults',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
alloc: r('thp_fault_alloc'),
|
||||||
|
fallback: r('thp_fault_fallback'),
|
||||||
|
fallback_charge: r('thp_fault_fallback_charge'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.thp_split',
|
||||||
|
context: 'mem.thp_split',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
split: r('thp_split_page') || r('thp_split'),
|
||||||
|
failed: r('thp_split_page_failed'),
|
||||||
|
split_pmd: r('thp_split_pmd'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.ksm_cow',
|
||||||
|
context: 'mem.ksm_cow',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
swapin: r('ksm_swpin_copy') * page,
|
||||||
|
write: r('cow_ksm') * page,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array} batch
|
||||||
|
* @param {number} ts
|
||||||
|
* @param {number} dtSec
|
||||||
|
* @param {{ lastSoftnet?: object, lastTcpExt?: object, lastIcmp?: object }} state
|
||||||
|
* @param {{ tcp: object, icmp?: object }} snmp
|
||||||
|
*/
|
||||||
|
export function collectNetDeep(batch, ts, dtSec, state, snmp) {
|
||||||
|
const soft = parseSoftnet()
|
||||||
|
const prevS = state.lastSoftnet
|
||||||
|
batch.push({
|
||||||
|
chart: 'system.softnet_stat',
|
||||||
|
context: 'system.softnet_stat',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
processed: prevS ? rate(prevS.processed, soft.processed, dtSec) : 0,
|
||||||
|
dropped: prevS ? rate(prevS.dropped, soft.dropped, dtSec) : 0,
|
||||||
|
squeezed: prevS ? rate(prevS.squeezed, soft.squeezed, dtSec) : 0,
|
||||||
|
received_rps: prevS ? rate(prevS.received_rps, soft.received_rps, dtSec) : 0,
|
||||||
|
flow_limit: prevS ? rate(prevS.flow_limit, soft.flow_limit, dtSec) : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
state.lastSoftnet = soft
|
||||||
|
|
||||||
|
const icmp = snmp.icmp || {}
|
||||||
|
const prevI = state.lastIcmp
|
||||||
|
if (icmp.InMsgs != null || icmp.OutMsgs != null) {
|
||||||
|
batch.push({
|
||||||
|
chart: 'ipv4.icmp',
|
||||||
|
context: 'ipv4.icmp',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
received: prevI ? rate(prevI.InMsgs || 0, icmp.InMsgs || 0, dtSec) : 0,
|
||||||
|
sent: prevI ? rate(prevI.OutMsgs || 0, icmp.OutMsgs || 0, dtSec) : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'ipv4.icmp_errors',
|
||||||
|
context: 'ipv4.icmp_errors',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
InErrors: prevI ? rate(prevI.InErrors || 0, icmp.InErrors || 0, dtSec) : 0,
|
||||||
|
OutErrors: prevI ? rate(prevI.OutErrors || 0, icmp.OutErrors || 0, dtSec) : 0,
|
||||||
|
InCsumErrors: prevI ? rate(prevI.InCsumErrors || 0, icmp.InCsumErrors || 0, dtSec) : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
state.lastIcmp = icmp
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TcpExt } = parseNetstatTables()
|
||||||
|
const prev = state.lastTcpExt
|
||||||
|
const tcp = snmp.tcp || {}
|
||||||
|
const r = (key) => (prev ? rate(prev[key] || 0, TcpExt[key] || 0, dtSec) : 0)
|
||||||
|
const rt = (key) => (prev ? rate(prev[`tcp_${key}`] || 0, tcp[key] || 0, dtSec) : 0)
|
||||||
|
|
||||||
|
batch.push({
|
||||||
|
chart: 'ip.tcpsyncookies',
|
||||||
|
context: 'ip.tcpsyncookies',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
received: r('SyncookiesRecv'),
|
||||||
|
sent: r('SyncookiesSent'),
|
||||||
|
failed: r('SyncookiesFailed'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'ip.tcp_syn_queue',
|
||||||
|
context: 'ip.tcp_syn_queue',
|
||||||
|
ts,
|
||||||
|
values: { drops: r('TCPReqQFullDrop'), cookies: r('TCPReqQFullDoCookies') },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'ip.tcp_accept_queue',
|
||||||
|
context: 'ip.tcp_accept_queue',
|
||||||
|
ts,
|
||||||
|
values: { overflows: r('ListenOverflows'), drops: r('ListenDrops') },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'ip.tcpconnaborts',
|
||||||
|
context: 'ip.tcpconnaborts',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
baddata: r('TCPAbortOnData'),
|
||||||
|
userclosed: r('TCPAbortOnClose'),
|
||||||
|
nomemory: r('TCPAbortOnMemory'),
|
||||||
|
timeout: r('TCPAbortOnTimeout'),
|
||||||
|
linger: r('TCPAbortOnLinger'),
|
||||||
|
failed: r('TCPAbortFailed'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'ip.tcpofo',
|
||||||
|
context: 'ip.tcpofo',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
inqueue: r('TCPOFOQueue'),
|
||||||
|
dropped: r('TCPOFODrop'),
|
||||||
|
merged: r('TCPOFOMerge'),
|
||||||
|
pruned: r('OfoPruned'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'ip.tcpreorders',
|
||||||
|
context: 'ip.tcpreorders',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
timestamp: r('TCPTSReorder'),
|
||||||
|
sack: r('TCPSACKReorder'),
|
||||||
|
fack: r('TCPFACKReorder'),
|
||||||
|
reno: r('TCPRenoReorder'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'ip.tcphandshake',
|
||||||
|
context: 'ip.tcphandshake',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
EstabResets: rt('EstabResets'),
|
||||||
|
OutRsts: rt('OutRsts'),
|
||||||
|
AttemptFails: rt('AttemptFails'),
|
||||||
|
SynRetrans: r('TCPSynRetrans'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'ip.tcpmemorypressures',
|
||||||
|
context: 'ip.tcpmemorypressures',
|
||||||
|
ts,
|
||||||
|
values: { pressures: r('TCPMemoryPressures') },
|
||||||
|
})
|
||||||
|
|
||||||
|
state.lastTcpExt = {
|
||||||
|
...TcpExt,
|
||||||
|
tcp_EstabResets: tcp.EstabResets || 0,
|
||||||
|
tcp_OutRsts: tcp.OutRsts || 0,
|
||||||
|
tcp_AttemptFails: tcp.AttemptFails || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ifaceSpeedKbps(iface) {
|
||||||
|
const raw = readFile(`/sys/class/net/${iface}/speed`)
|
||||||
|
if (raw == null) return null
|
||||||
|
const mbps = Number(raw.trim())
|
||||||
|
if (!Number.isFinite(mbps) || mbps <= 0) return null
|
||||||
|
return mbps * 1000
|
||||||
|
}
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
/**
|
||||||
|
* Additional host collectors: softirqs, IRQ, cpuidle, NUMA nodes, pagetype,
|
||||||
|
* conntrack, inotify, icmpmsg, ipv6, powercap, edac, debugfs extras.
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import {
|
||||||
|
registerChart,
|
||||||
|
makeIrqChart,
|
||||||
|
makeCpuIdleChart,
|
||||||
|
makeNumaNodeMemChart,
|
||||||
|
} from '../../../shared/metrics.js'
|
||||||
|
|
||||||
|
function readFile(p) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(p, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rate(prev, cur, dtSec) {
|
||||||
|
if (dtSec <= 0 || cur < prev) return 0
|
||||||
|
return (cur - prev) / dtSec
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToMiB(n) {
|
||||||
|
return n / (1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Record<string, number>} */
|
||||||
|
export function parseSoftirqs() {
|
||||||
|
const raw = readFile('/proc/softirqs')
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const out = {}
|
||||||
|
if (!raw) return out
|
||||||
|
const lines = raw.trim().split('\n')
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
const p = line.trim().split(/\s+/)
|
||||||
|
const name = p[0]?.replace(':', '')
|
||||||
|
if (!name) continue
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 1; i < p.length; i++) sum += Number(p[i]) || 0
|
||||||
|
out[name] = sum
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Array<{ irq: string, name: string, count: number }>} */
|
||||||
|
export function parseInterrupts() {
|
||||||
|
const raw = readFile('/proc/interrupts')
|
||||||
|
if (!raw) return []
|
||||||
|
const lines = raw.trim().split('\n')
|
||||||
|
const out = []
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
const m = line.match(/^\s*(\d+):\s+(.+)$/)
|
||||||
|
if (!m) continue
|
||||||
|
const irq = m[1]
|
||||||
|
const rest = m[2].trim().split(/\s+/)
|
||||||
|
// last non-numeric tokens are name
|
||||||
|
let count = 0
|
||||||
|
let i = 0
|
||||||
|
while (i < rest.length && /^\d+$/.test(rest[i])) {
|
||||||
|
count += Number(rest[i]) || 0
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
const name = rest.slice(i).join(' ').slice(0, 48) || irq
|
||||||
|
out.push({ irq, name, count })
|
||||||
|
}
|
||||||
|
// keep top IRQs by count to limit cardinality
|
||||||
|
out.sort((a, b) => b.count - a.count)
|
||||||
|
return out.slice(0, Number(process.env.PEARDATA_IRQ_MAX) || 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSnmp6() {
|
||||||
|
const raw = readFile('/proc/net/snmp6')
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const out = {}
|
||||||
|
if (!raw) return out
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const [k, v] = line.trim().split(/\s+/)
|
||||||
|
if (k) out[k] = Number(v) || 0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIcmpMsg() {
|
||||||
|
const raw = readFile('/proc/net/snmp')
|
||||||
|
if (!raw) return {}
|
||||||
|
const lines = raw.split('\n')
|
||||||
|
for (let i = 0; i + 1 < lines.length; i += 2) {
|
||||||
|
if (!lines[i].startsWith('IcmpMsg:')) continue
|
||||||
|
const header = lines[i].trim().split(/\s+/)
|
||||||
|
const values = lines[i + 1].trim().split(/\s+/)
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const row = {}
|
||||||
|
for (let j = 1; j < header.length; j++) row[header[j]] = Number(values[j]) || 0
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConntrack(state) {
|
||||||
|
const entries = Number(readFile('/proc/sys/net/netfilter/nf_conntrack_count') || 0)
|
||||||
|
const max = Number(readFile('/proc/sys/net/netfilter/nf_conntrack_max') || 0)
|
||||||
|
const raw = readFile('/proc/net/stat/nf_conntrack')
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const sums = {
|
||||||
|
searched: 0,
|
||||||
|
found: 0,
|
||||||
|
new: 0,
|
||||||
|
invalid: 0,
|
||||||
|
drop: 0,
|
||||||
|
insert_failed: 0,
|
||||||
|
}
|
||||||
|
if (raw) {
|
||||||
|
const lines = raw.trim().split('\n')
|
||||||
|
const header = lines[0]?.trim().split(/\s+/) || []
|
||||||
|
const idx = (name) => header.indexOf(name)
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
const cols = line.trim().split(/\s+/)
|
||||||
|
const get = (name) => {
|
||||||
|
const i = idx(name)
|
||||||
|
return i >= 0 ? parseInt(cols[i], 16) || 0 : 0
|
||||||
|
}
|
||||||
|
sums.searched += get('searched')
|
||||||
|
sums.found += get('found')
|
||||||
|
sums.new += get('new')
|
||||||
|
sums.invalid += get('invalid')
|
||||||
|
sums.drop += get('drop')
|
||||||
|
sums.insert_failed += get('insert_failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { entries, max, ...sums }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePagetypeGlobal() {
|
||||||
|
const raw = readFile('/proc/pagetypeinfo')
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const orders = {}
|
||||||
|
for (let i = 0; i <= 10; i++) orders[`order${i}`] = 0
|
||||||
|
if (!raw) return orders
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
if (!line.includes('Node') || !line.includes('free')) continue
|
||||||
|
const nums = line.match(
|
||||||
|
/\s(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*$/
|
||||||
|
)
|
||||||
|
if (!nums) continue
|
||||||
|
for (let i = 0; i <= 10; i++) orders[`order${i}`] += Number(nums[i + 1]) || 0
|
||||||
|
}
|
||||||
|
return orders
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectNumaNodes(batch, ts) {
|
||||||
|
const root = '/sys/devices/system/node'
|
||||||
|
let nodes
|
||||||
|
try {
|
||||||
|
nodes = fs.readdirSync(root).filter((d) => /^node\d+$/.test(d))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nodes.length < 1) return
|
||||||
|
for (const n of nodes) {
|
||||||
|
const id = n.slice(4)
|
||||||
|
const meminfo = readFile(path.join(root, n, 'meminfo'))
|
||||||
|
if (!meminfo) continue
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const vals = {}
|
||||||
|
for (const line of meminfo.split('\n')) {
|
||||||
|
const m = line.match(/Node \d+ (\w+):\s+(\d+)/)
|
||||||
|
if (m) vals[m[1]] = (Number(m[2]) || 0) * 1024
|
||||||
|
}
|
||||||
|
const def = makeNumaNodeMemChart(id)
|
||||||
|
registerChart(def)
|
||||||
|
const total = vals.MemTotal || 0
|
||||||
|
const free = vals.MemFree || 0
|
||||||
|
batch.push({
|
||||||
|
chart: def.id,
|
||||||
|
context: def.context,
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
MemTotal: bytesToMiB(total),
|
||||||
|
MemFree: bytesToMiB(free),
|
||||||
|
MemUsed: bytesToMiB(Math.max(0, total - free)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* C-state residency % from cpuidle time counters.
|
||||||
|
* @param {Array} batch
|
||||||
|
* @param {number} ts
|
||||||
|
* @param {number} dtSec
|
||||||
|
* @param {{ lastIdle?: Record<string, Record<string, number>> }} state
|
||||||
|
*/
|
||||||
|
export function collectCpuidle(batch, ts, dtSec, state) {
|
||||||
|
if (os.platform() !== 'linux') return
|
||||||
|
const cpuRoot = '/sys/devices/system/cpu'
|
||||||
|
let dirs
|
||||||
|
try {
|
||||||
|
dirs = fs.readdirSync(cpuRoot).filter((d) => /^cpu\d+$/.test(d))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const prevAll = state.lastIdle || {}
|
||||||
|
/** @type {Record<string, Record<string, number>>} */
|
||||||
|
const nextAll = {}
|
||||||
|
|
||||||
|
for (const d of dirs) {
|
||||||
|
const coreId = d.slice(3)
|
||||||
|
const idleDir = path.join(cpuRoot, d, 'cpuidle')
|
||||||
|
let states
|
||||||
|
try {
|
||||||
|
states = fs.readdirSync(idleDir).filter((s) => /^state\d+$/.test(s))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const times = {}
|
||||||
|
for (const s of states) {
|
||||||
|
const name = (readFile(path.join(idleDir, s, 'name')) || s).trim().toLowerCase().replace(/\s+/g, '_')
|
||||||
|
const t = Number(readFile(path.join(idleDir, s, 'time')) || 0) // µs
|
||||||
|
times[name || s] = t
|
||||||
|
}
|
||||||
|
nextAll[coreId] = times
|
||||||
|
const prev = prevAll[coreId]
|
||||||
|
if (!prev || dtSec <= 0) continue
|
||||||
|
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const deltas = {}
|
||||||
|
let sum = 0
|
||||||
|
for (const [k, v] of Object.entries(times)) {
|
||||||
|
const dlt = Math.max(0, v - (prev[k] || 0))
|
||||||
|
deltas[k] = dlt
|
||||||
|
sum += dlt
|
||||||
|
}
|
||||||
|
// active ≈ wall time − idle (µs); wall ≈ dtSec * 1e6
|
||||||
|
const wall = dtSec * 1e6
|
||||||
|
const active = Math.max(0, wall - sum)
|
||||||
|
sum += active
|
||||||
|
if (sum <= 0) continue
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const pcts = { active: (active / sum) * 100 }
|
||||||
|
for (const [k, v] of Object.entries(deltas)) pcts[k] = (v / sum) * 100
|
||||||
|
|
||||||
|
const def = makeCpuIdleChart(coreId)
|
||||||
|
def.dimensions = Object.keys(pcts).map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
algorithm: 'absolute',
|
||||||
|
}))
|
||||||
|
registerChart(def)
|
||||||
|
batch.push({
|
||||||
|
chart: def.id,
|
||||||
|
context: 'cpuidle.cpu_cstate_residency_time',
|
||||||
|
ts,
|
||||||
|
values: pcts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
state.lastIdle = nextAll
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectPowercap(batch, ts, state) {
|
||||||
|
const base = '/sys/class/powercap'
|
||||||
|
let ents
|
||||||
|
try {
|
||||||
|
ents = fs.readdirSync(base).filter((e) => e.includes('intel-rapl'))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let energy = 0
|
||||||
|
for (const e of ents) {
|
||||||
|
if (e.includes(':')) continue // only package-level intel-rapl:0
|
||||||
|
const raw = readFile(path.join(base, e, 'energy_uj'))
|
||||||
|
if (raw == null) continue
|
||||||
|
energy += Number(raw.trim()) || 0
|
||||||
|
}
|
||||||
|
// also try intel-rapl:0 specifically
|
||||||
|
const pkg = readFile(path.join(base, 'intel-rapl:0', 'energy_uj'))
|
||||||
|
if (pkg != null) energy = Number(pkg.trim()) || energy
|
||||||
|
|
||||||
|
const prev = state.lastEnergyUj
|
||||||
|
const prevTs = state.lastEnergyTs
|
||||||
|
if (prev != null && prevTs && ts > prevTs) {
|
||||||
|
const dt = (ts - prevTs) / 1000
|
||||||
|
const watts = Math.max(0, (energy - prev) / 1e6 / dt)
|
||||||
|
batch.push({
|
||||||
|
chart: 'system.power',
|
||||||
|
context: 'system.power',
|
||||||
|
ts,
|
||||||
|
values: { package: watts },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
state.lastEnergyUj = energy
|
||||||
|
state.lastEnergyTs = ts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectEdac(batch, ts) {
|
||||||
|
const root = '/sys/devices/system/edac/mc'
|
||||||
|
let mcs
|
||||||
|
try {
|
||||||
|
mcs = fs.readdirSync(root).filter((d) => d.startsWith('mc'))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let ce = 0
|
||||||
|
let ue = 0
|
||||||
|
for (const mc of mcs) {
|
||||||
|
ce += Number(readFile(path.join(root, mc, 'ce_count')) || 0)
|
||||||
|
ue += Number(readFile(path.join(root, mc, 'ue_count')) || 0)
|
||||||
|
}
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.edac',
|
||||||
|
context: 'mem.edac',
|
||||||
|
ts,
|
||||||
|
values: { corrected: ce, uncorrected: ue },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectDebugfsExtras(batch, ts) {
|
||||||
|
// zswap pool (optional)
|
||||||
|
const pool = readFile('/sys/kernel/debug/zswap/pool_total_size')
|
||||||
|
const stored = readFile('/sys/kernel/debug/zswap/stored_pages')
|
||||||
|
if (pool != null || stored != null) {
|
||||||
|
registerChart({
|
||||||
|
id: 'mem.zswap_pool',
|
||||||
|
name: 'mem.zswap_pool',
|
||||||
|
context: 'mem.zswap_pool',
|
||||||
|
title: 'Zswap pool (debugfs)',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'zswap',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 2485,
|
||||||
|
plugin: 'debugfs',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'pool', name: 'pool', algorithm: 'absolute' },
|
||||||
|
{ id: 'stored', name: 'stored', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.zswap_pool',
|
||||||
|
context: 'mem.zswap_pool',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
pool: bytesToMiB(Number(pool || 0)),
|
||||||
|
stored: bytesToMiB((Number(stored || 0) || 0) * 4096),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extfrag index — sample first node/zone order0 as signal
|
||||||
|
const frag = readFile('/sys/kernel/debug/extfrag/extfrag_index')
|
||||||
|
if (frag) {
|
||||||
|
const nums = frag.match(/-?[\d.]+/g)
|
||||||
|
if (nums?.length) {
|
||||||
|
registerChart({
|
||||||
|
id: 'mem.fragmentation_index',
|
||||||
|
name: 'mem.fragmentation_index',
|
||||||
|
context: 'mem.fragmentation_index',
|
||||||
|
title: 'Memory fragmentation index (debugfs)',
|
||||||
|
units: 'index',
|
||||||
|
family: 'fragmentation',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 277,
|
||||||
|
plugin: 'debugfs',
|
||||||
|
dimensions: [{ id: 'index', name: 'index', algorithm: 'absolute' }],
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.fragmentation_index',
|
||||||
|
context: 'mem.fragmentation_index',
|
||||||
|
ts,
|
||||||
|
values: { index: Number(nums[0]) || 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, number>} mem
|
||||||
|
*/
|
||||||
|
export function collectMemExtras(batch, ts, mem) {
|
||||||
|
if (!mem) return
|
||||||
|
const mib = (k) => bytesToMiB(mem[k] || 0)
|
||||||
|
if (mem.HighTotal != null || mem.LowTotal != null) {
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.high_low',
|
||||||
|
context: 'mem.high_low',
|
||||||
|
ts,
|
||||||
|
values: { high: mib('HighTotal'), low: mib('LowTotal') },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mem.CmaTotal != null) {
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.cma',
|
||||||
|
context: 'mem.cma',
|
||||||
|
ts,
|
||||||
|
values: { total: mib('CmaTotal'), free: mib('CmaFree') },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.directmaps',
|
||||||
|
context: 'mem.directmaps',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
'4k': mib('DirectMap4k'),
|
||||||
|
'2M': mib('DirectMap2M') || mib('DirectMap4M'),
|
||||||
|
'1G': mib('DirectMap1G'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectThpMore(batch, ts, dtSec, vm, prevVm) {
|
||||||
|
if (!vm) return
|
||||||
|
const r = (k) => (prevVm ? rate(prevVm[k] || 0, vm[k] || 0, dtSec) : 0)
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.thp_file',
|
||||||
|
context: 'mem.thp_file',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
alloc: r('thp_file_alloc'),
|
||||||
|
fallback: r('thp_file_fallback'),
|
||||||
|
mapped: r('thp_file_mapped'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.thp_zero',
|
||||||
|
context: 'mem.thp_zero',
|
||||||
|
ts,
|
||||||
|
values: { alloc: r('thp_zero_page_alloc'), failed: r('thp_zero_page_alloc_failed') },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.thp_collapse',
|
||||||
|
context: 'mem.thp_collapse',
|
||||||
|
ts,
|
||||||
|
values: { alloc: r('thp_collapse_alloc'), failed: r('thp_collapse_alloc_failed') },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.thp_swapout',
|
||||||
|
context: 'mem.thp_swapout',
|
||||||
|
ts,
|
||||||
|
values: { swapout: r('thp_swpout'), fallback: r('thp_swpout_fallback') },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.thp_compact',
|
||||||
|
context: 'mem.thp_compact',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
success: r('compact_success'),
|
||||||
|
fail: r('compact_fail'),
|
||||||
|
stall: r('compact_stall'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectKsmExtras(batch, ts) {
|
||||||
|
const keys = ['pages_shared', 'pages_sharing', 'pages_unshared', 'pages_volatile']
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const ksm = {}
|
||||||
|
for (const k of keys) {
|
||||||
|
const raw = readFile(path.join('/sys/kernel/mm/ksm', k))
|
||||||
|
if (raw == null) return
|
||||||
|
ksm[k] = Number(raw.trim()) || 0
|
||||||
|
}
|
||||||
|
const page = 4096
|
||||||
|
const offered =
|
||||||
|
((ksm.pages_sharing || 0) +
|
||||||
|
(ksm.pages_shared || 0) +
|
||||||
|
(ksm.pages_unshared || 0) +
|
||||||
|
(ksm.pages_volatile || 0)) *
|
||||||
|
page
|
||||||
|
const saved = (ksm.pages_sharing || 0) * page
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.ksm_savings',
|
||||||
|
context: 'mem.ksm_savings',
|
||||||
|
ts,
|
||||||
|
values: { offered: bytesToMiB(offered), saved: bytesToMiB(saved) },
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.ksm_ratios',
|
||||||
|
context: 'mem.ksm_ratios',
|
||||||
|
ts,
|
||||||
|
values: { savings: offered > 0 ? (saved / offered) * 100 : 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master tick for host-more extras.
|
||||||
|
*/
|
||||||
|
export function collectHostMore(batch, ts, dtSec, state, { mem, vm, prevVm } = {}) {
|
||||||
|
// softirqs
|
||||||
|
const soft = parseSoftirqs()
|
||||||
|
const prevSoft = state.lastSoftirqs
|
||||||
|
if (Object.keys(soft).length) {
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const values = {}
|
||||||
|
for (const k of Object.keys(soft)) {
|
||||||
|
values[k] = prevSoft ? rate(prevSoft[k] || 0, soft[k] || 0, dtSec) : 0
|
||||||
|
}
|
||||||
|
batch.push({ chart: 'system.softirqs', context: 'system.softirqs', ts, values })
|
||||||
|
state.lastSoftirqs = soft
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRQs
|
||||||
|
const irqs = parseInterrupts()
|
||||||
|
const prevIrq = state.lastIrqs || {}
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const nextIrq = {}
|
||||||
|
for (const { irq, name, count } of irqs) {
|
||||||
|
nextIrq[irq] = count
|
||||||
|
const def = makeIrqChart(irq, name)
|
||||||
|
registerChart(def)
|
||||||
|
batch.push({
|
||||||
|
chart: def.id,
|
||||||
|
context: 'system.intr_irq',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
interrupts: prevIrq[irq] != null ? rate(prevIrq[irq], count, dtSec) : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
state.lastIrqs = nextIrq
|
||||||
|
|
||||||
|
// icmpmsg
|
||||||
|
const icmpMsg = parseIcmpMsg()
|
||||||
|
const prevMsg = state.lastIcmpMsg
|
||||||
|
if (Object.keys(icmpMsg).length) {
|
||||||
|
const pick = (k) => (prevMsg ? rate(prevMsg[k] || 0, icmpMsg[k] || 0, dtSec) : 0)
|
||||||
|
batch.push({
|
||||||
|
chart: 'ipv4.icmpmsg',
|
||||||
|
context: 'ipv4.icmpmsg',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
InEchoes: pick('InType8') || pick('InEchoes'),
|
||||||
|
OutEchoes: pick('OutType8') || pick('OutEchoes'),
|
||||||
|
InDestUnreachs: pick('InType3') || pick('InDestUnreachs'),
|
||||||
|
OutDestUnreachs: pick('OutType3') || pick('OutDestUnreachs'),
|
||||||
|
InTimeExcds: pick('InType11') || pick('InTimeExcds'),
|
||||||
|
OutTimeExcds: pick('OutType11') || pick('OutTimeExcds'),
|
||||||
|
InRedirects: pick('InType5') || pick('InRedirects'),
|
||||||
|
OutRedirects: pick('OutType5') || pick('OutRedirects'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
state.lastIcmpMsg = icmpMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipv6
|
||||||
|
const s6 = parseSnmp6()
|
||||||
|
const prev6 = state.lastSnmp6
|
||||||
|
if (Object.keys(s6).length) {
|
||||||
|
const r = (k) => (prev6 ? rate(prev6[k] || 0, s6[k] || 0, dtSec) : 0)
|
||||||
|
batch.push({
|
||||||
|
chart: 'ipv6.packets',
|
||||||
|
context: 'ipv6.packets',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
received: r('Ip6InReceives'),
|
||||||
|
sent: r('Ip6OutRequests'),
|
||||||
|
forwarded: r('Ip6OutForwDatagrams') || r('Ip6InForwDatagrams'),
|
||||||
|
delivered: r('Ip6InDelivers'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'ipv6.errors',
|
||||||
|
context: 'ipv6.errors',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
InHdrErrors: r('Ip6InHdrErrors'),
|
||||||
|
InAddrErrors: r('Ip6InAddrErrors'),
|
||||||
|
InDiscards: r('Ip6InDiscards'),
|
||||||
|
OutDiscards: r('Ip6OutDiscards'),
|
||||||
|
InTruncatedPkts: r('Ip6InTruncatedPkts'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
batch.push({
|
||||||
|
chart: 'ipv6.icmp',
|
||||||
|
context: 'ipv6.icmp',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
received: r('Icmp6InMsgs'),
|
||||||
|
sent: r('Icmp6OutMsgs'),
|
||||||
|
InErrors: r('Icmp6InErrors'),
|
||||||
|
OutErrors: r('Icmp6OutErrors'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
state.lastSnmp6 = s6
|
||||||
|
}
|
||||||
|
|
||||||
|
// conntrack
|
||||||
|
const ct = parseConntrack()
|
||||||
|
const prevCt = state.lastCt
|
||||||
|
batch.push({
|
||||||
|
chart: 'net.conntrack',
|
||||||
|
context: 'net.conntrack',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
entries: ct.entries,
|
||||||
|
max: ct.max,
|
||||||
|
searched: prevCt ? rate(prevCt.searched, ct.searched, dtSec) : 0,
|
||||||
|
found: prevCt ? rate(prevCt.found, ct.found, dtSec) : 0,
|
||||||
|
new: prevCt ? rate(prevCt.new, ct.new, dtSec) : 0,
|
||||||
|
invalid: prevCt ? rate(prevCt.invalid, ct.invalid, dtSec) : 0,
|
||||||
|
drop: prevCt ? rate(prevCt.drop, ct.drop, dtSec) : 0,
|
||||||
|
insert_failed: prevCt ? rate(prevCt.insert_failed, ct.insert_failed, dtSec) : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
state.lastCt = ct
|
||||||
|
|
||||||
|
// inotify limits
|
||||||
|
batch.push({
|
||||||
|
chart: 'system.inotify',
|
||||||
|
context: 'system.inotify',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
max_user_watches: Number(readFile('/proc/sys/fs/inotify/max_user_watches') || 0),
|
||||||
|
max_user_instances: Number(readFile('/proc/sys/fs/inotify/max_user_instances') || 0),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
batch.push({
|
||||||
|
chart: 'mem.pagetype_global',
|
||||||
|
context: 'mem.pagetype_global',
|
||||||
|
ts,
|
||||||
|
values: parsePagetypeGlobal(),
|
||||||
|
})
|
||||||
|
|
||||||
|
collectNumaNodes(batch, ts)
|
||||||
|
collectCpuidle(batch, ts, dtSec, state)
|
||||||
|
collectPowercap(batch, ts, state)
|
||||||
|
collectEdac(batch, ts)
|
||||||
|
collectDebugfsExtras(batch, ts)
|
||||||
|
if (mem) collectMemExtras(batch, ts, mem)
|
||||||
|
collectThpMore(batch, ts, dtSec, vm, prevVm)
|
||||||
|
collectKsmExtras(batch, ts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Disk I/O latency probe via ioping or fsync fallback.
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_IOPING=1
|
||||||
|
* Path: PEARDATA_IOPING_PATH=/ (default)
|
||||||
|
*
|
||||||
|
* Charts: ioping.latency
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('ioping')
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
const CHART_LATENCY = {
|
||||||
|
id: 'ioping.latency',
|
||||||
|
name: 'ioping.latency',
|
||||||
|
context: 'ioping.latency',
|
||||||
|
title: 'I/O ping latency',
|
||||||
|
units: 'ms',
|
||||||
|
family: 'ioping',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 4200,
|
||||||
|
plugin: 'ioping',
|
||||||
|
dimensions: [{ id: 'latency_ms', name: 'latency_ms', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIopingEnabled() {
|
||||||
|
const v = process.env.PEARDATA_IOPING
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetPath() {
|
||||||
|
return process.env.PEARDATA_IOPING_PATH || '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} output
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
export function parseIopingOutput(output) {
|
||||||
|
const m = String(output || '').match(/([\d.]+)\s*ms/i)
|
||||||
|
if (m) return Number(m[1])
|
||||||
|
const n = Number(String(output || '').trim().split(/\s+/).pop())
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} target
|
||||||
|
* @returns {Promise<number|null>}
|
||||||
|
*/
|
||||||
|
export async function probeIoping(target) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('ioping', ['-c', '1', '-q', target], {
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
return parseIopingOutput(stdout)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} target
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
export function probeFsyncFallback(target) {
|
||||||
|
const dir = fs.existsSync(target) && fs.statSync(target).isDirectory() ? target : os.tmpdir()
|
||||||
|
const file = path.join(dir, `.peardata-ioping-${process.pid}`)
|
||||||
|
const started = Date.now()
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(file, `${Date.now()}\n`)
|
||||||
|
const fd = fs.openSync(file, 'r+')
|
||||||
|
fs.fsyncSync(fd)
|
||||||
|
fs.closeSync(fd)
|
||||||
|
return Date.now() - started
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(file)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IopingCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'ioping', intervalMs: opts.intervalMs })
|
||||||
|
this.target = opts.path || targetPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isIopingEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
registerChart(CHART_LATENCY)
|
||||||
|
log.info('Ioping collector started', { path: this.target })
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const ts = Date.now()
|
||||||
|
let latency = await probeIoping(this.target)
|
||||||
|
if (latency == null) latency = probeFsyncFallback(this.target)
|
||||||
|
if (latency == null) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
chart: 'ioping.latency',
|
||||||
|
context: 'ioping.latency',
|
||||||
|
ts,
|
||||||
|
values: { latency_ms: latency },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {IopingCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getIopingCollector() {
|
||||||
|
if (!singleton) singleton = new IopingCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* IPMI sensor collector via ipmitool.
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_IPMI=1
|
||||||
|
*
|
||||||
|
* Charts: sensors.ipmi.temp.*, sensors.ipmi.fan.*
|
||||||
|
*/
|
||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('ipmi')
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
export function isIpmiEnabled() {
|
||||||
|
const v = process.env.PEARDATA_IPMI
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
function sanitizeSensor(name) {
|
||||||
|
return String(name)
|
||||||
|
.replace(/[^a-zA-Z0-9_.-]/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'temp'|'fan'} kind
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
function makeIpmiChart(kind, name) {
|
||||||
|
const safe = sanitizeSensor(name)
|
||||||
|
const id = kind === 'temp' ? `sensors.ipmi.temp.${safe}` : `sensors.ipmi.fan.${safe}`
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
context: kind === 'temp' ? 'sensors.ipmi.temperature' : 'sensors.ipmi.fan',
|
||||||
|
title: kind === 'temp' ? `IPMI temperature ${name}` : `IPMI fan ${name}`,
|
||||||
|
units: kind === 'temp' ? 'Celsius' : 'RPM',
|
||||||
|
family: 'ipmi',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: kind === 'temp' ? 7020 : 7030,
|
||||||
|
plugin: 'ipmi',
|
||||||
|
dimensions: [{ id: 'value', name: 'value', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `ipmitool sensor` output lines.
|
||||||
|
* @param {string} stdout
|
||||||
|
* @returns {Array<{ name: string, kind: 'temp'|'fan', value: number }>}
|
||||||
|
*/
|
||||||
|
export function parseIpmitoolSensor(stdout) {
|
||||||
|
/** @type {Array<{ name: string, kind: 'temp'|'fan', value: number }>} */
|
||||||
|
const out = []
|
||||||
|
for (const line of String(stdout || '').split('\n')) {
|
||||||
|
const t = line.trim()
|
||||||
|
if (!t) continue
|
||||||
|
const parts = t.split('|').map((s) => s.trim())
|
||||||
|
if (parts.length < 4) continue
|
||||||
|
const name = parts[0]
|
||||||
|
const reading = parts[1]
|
||||||
|
const type = parts[2]?.toLowerCase() || ''
|
||||||
|
const lowerName = name.toLowerCase()
|
||||||
|
const isTemp =
|
||||||
|
type.includes('degrees') ||
|
||||||
|
type.includes('temperature') ||
|
||||||
|
/temp|degrees c/i.test(lowerName)
|
||||||
|
const isFan = type.includes('rpm') || /fan|rpm/i.test(lowerName)
|
||||||
|
if (!isTemp && !isFan) continue
|
||||||
|
const val = Number(String(reading).replace(/[^\d.-]/g, ''))
|
||||||
|
if (!Number.isFinite(val)) continue
|
||||||
|
out.push({ name, kind: isFan ? 'fan' : 'temp', value: val })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readIpmitoolSensors(timeoutMs = 5000) {
|
||||||
|
const { stdout } = await execFileAsync('ipmitool', ['sensor'], {
|
||||||
|
timeout: timeoutMs,
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
return parseIpmitoolSensor(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IpmiCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'ipmi', intervalMs: opts.intervalMs })
|
||||||
|
/** @type {boolean|null} */
|
||||||
|
this._available = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isIpmiEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
log.info('IPMI collector started')
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
let sensors
|
||||||
|
try {
|
||||||
|
sensors = await readIpmitoolSensors()
|
||||||
|
this._available = true
|
||||||
|
} catch (err) {
|
||||||
|
if (this._available !== false) {
|
||||||
|
log.info('ipmitool unavailable — collector idle', { error: err.message })
|
||||||
|
this._available = false
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const ts = Date.now()
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||||
|
const batch = []
|
||||||
|
for (const s of sensors) {
|
||||||
|
const def = makeIpmiChart(s.kind, s.name)
|
||||||
|
registerChart(def)
|
||||||
|
batch.push({
|
||||||
|
chart: def.id,
|
||||||
|
context: def.context,
|
||||||
|
ts,
|
||||||
|
values: { value: s.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {IpmiCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getIpmiCollector() {
|
||||||
|
if (!singleton) singleton = new IpmiCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Linux MD RAID /proc/mdstat helper + collector.
|
||||||
|
*
|
||||||
|
* Always-on when /proc/mdstat has active arrays.
|
||||||
|
*
|
||||||
|
* Charts: md.health, md.array.{name}
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import os from 'os'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { SAMPLE_INTERVAL_MS, registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('mdstat')
|
||||||
|
|
||||||
|
const CHART_HEALTH = {
|
||||||
|
id: 'md.health',
|
||||||
|
name: 'md.health',
|
||||||
|
context: 'md.health',
|
||||||
|
title: 'MD RAID arrays',
|
||||||
|
units: 'arrays',
|
||||||
|
family: 'md',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 4500,
|
||||||
|
plugin: 'mdstat',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'active', name: 'active', algorithm: 'absolute' },
|
||||||
|
{ id: 'degraded', name: 'degraded', algorithm: 'absolute' },
|
||||||
|
{ id: 'recovering', name: 'recovering', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(p) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(p, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isMdstatUseful() {
|
||||||
|
if (os.platform() !== 'linux') return false
|
||||||
|
const raw = readFile('/proc/mdstat')
|
||||||
|
if (!raw) return false
|
||||||
|
return /^md\d+\s*:/m.test(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMdstatEnabled() {
|
||||||
|
return isMdstatUseful()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
function makeMdArrayChart(name) {
|
||||||
|
const safe = String(name).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
||||||
|
return {
|
||||||
|
id: `md.array.${safe}`,
|
||||||
|
name: `md.array.${safe}`,
|
||||||
|
context: 'md.array',
|
||||||
|
title: `MD array ${name}`,
|
||||||
|
units: 'disks',
|
||||||
|
family: name,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 4510,
|
||||||
|
plugin: 'mdstat',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'active', name: 'active', algorithm: 'absolute' },
|
||||||
|
{ id: 'total', name: 'total', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} raw
|
||||||
|
* @returns {{ health: { active: number, degraded: number, recovering: number }, arrays: Array<{ name: string, active: number, total: number, degraded: boolean, recovering: boolean }> }}
|
||||||
|
*/
|
||||||
|
export function parseMdstat(raw) {
|
||||||
|
const text = String(raw || '')
|
||||||
|
/** @type {Array<{ name: string, active: number, total: number, degraded: boolean, recovering: boolean }>} */
|
||||||
|
const arrays = []
|
||||||
|
const lines = text.split('\n')
|
||||||
|
let i = 0
|
||||||
|
while (i < lines.length) {
|
||||||
|
const hdr = lines[i].match(/^(md\d+)\s*:\s*(.*)$/)
|
||||||
|
if (!hdr) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const name = hdr[1]
|
||||||
|
const hdrRest = hdr[2] || ''
|
||||||
|
const detail = lines[i + 1] || ''
|
||||||
|
const block = `${hdrRest} ${detail}`.toLowerCase()
|
||||||
|
const bracket = detail.match(/\[(\d+)\/(\d+)\]/)
|
||||||
|
const active = bracket ? Number(bracket[1]) : 0
|
||||||
|
const total = bracket ? Number(bracket[2]) : 0
|
||||||
|
const diskState = detail.match(/\[([U_.]+)\]/)
|
||||||
|
const degraded =
|
||||||
|
/degraded|faulty|F/i.test(block) ||
|
||||||
|
(diskState ? /[_F]/.test(diskState[1]) : active < total && total > 0)
|
||||||
|
const recovering = /recovery|resync|reshape|check|repair/i.test(block)
|
||||||
|
arrays.push({ name, active, total, degraded, recovering })
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
health: {
|
||||||
|
active: arrays.length,
|
||||||
|
degraded: arrays.filter((a) => a.degraded).length,
|
||||||
|
recovering: arrays.filter((a) => a.recovering).length,
|
||||||
|
},
|
||||||
|
arrays,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append mdstat samples to an existing batch.
|
||||||
|
* @param {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} batch
|
||||||
|
* @param {number} ts
|
||||||
|
*/
|
||||||
|
export function collectMdstat(batch, ts) {
|
||||||
|
if (!isMdstatUseful()) return
|
||||||
|
const raw = readFile('/proc/mdstat')
|
||||||
|
if (!raw) return
|
||||||
|
const parsed = parseMdstat(raw)
|
||||||
|
registerChart(CHART_HEALTH)
|
||||||
|
batch.push({
|
||||||
|
chart: 'md.health',
|
||||||
|
context: 'md.health',
|
||||||
|
ts,
|
||||||
|
values: parsed.health,
|
||||||
|
})
|
||||||
|
for (const arr of parsed.arrays) {
|
||||||
|
const def = makeMdArrayChart(arr.name)
|
||||||
|
registerChart(def)
|
||||||
|
batch.push({
|
||||||
|
chart: def.id,
|
||||||
|
context: def.context,
|
||||||
|
ts,
|
||||||
|
values: { active: arr.active, total: arr.total },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MdstatCollector extends EventEmitter {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super()
|
||||||
|
this.intervalMs =
|
||||||
|
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
|
||||||
|
this.timer = null
|
||||||
|
this.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running || !isMdstatUseful()) return
|
||||||
|
this.running = true
|
||||||
|
this._tick()
|
||||||
|
this.timer = setInterval(() => this._tick(), this.intervalMs)
|
||||||
|
if (this.timer.unref) this.timer.unref()
|
||||||
|
log.info('Mdstat collector started')
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false
|
||||||
|
if (this.timer) clearInterval(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
const ts = Date.now()
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||||
|
const batch = []
|
||||||
|
collectMdstat(batch, ts)
|
||||||
|
if (batch.length) this.emit('samples', batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {MdstatCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getMdstatCollector() {
|
||||||
|
if (!singleton) singleton = new MdstatCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Memcached stats collector (service plugin).
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_MEMCACHED=1
|
||||||
|
* Addr: PEARDATA_MEMCACHED_URL=127.0.0.1:11211
|
||||||
|
*
|
||||||
|
* Charts: memcached.ops, memcached.memory
|
||||||
|
*/
|
||||||
|
import net from 'net'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('memcached')
|
||||||
|
|
||||||
|
const CHART_OPS = {
|
||||||
|
id: 'memcached.ops',
|
||||||
|
name: 'memcached.ops',
|
||||||
|
context: 'memcached.ops',
|
||||||
|
title: 'Memcached operations',
|
||||||
|
units: 'ops/s',
|
||||||
|
family: 'memcached',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8500,
|
||||||
|
plugin: 'memcached',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'get_hits', name: 'get_hits', algorithm: 'absolute' },
|
||||||
|
{ id: 'get_misses', name: 'get_misses', algorithm: 'absolute' },
|
||||||
|
{ id: 'cmd_get', name: 'cmd_get', algorithm: 'absolute' },
|
||||||
|
{ id: 'cmd_set', name: 'cmd_set', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_MEMORY = {
|
||||||
|
id: 'memcached.memory',
|
||||||
|
name: 'memcached.memory',
|
||||||
|
context: 'memcached.memory',
|
||||||
|
title: 'Memcached memory',
|
||||||
|
units: 'bytes',
|
||||||
|
family: 'memcached',
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 8510,
|
||||||
|
plugin: 'memcached',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'bytes', name: 'bytes', algorithm: 'absolute' },
|
||||||
|
{ id: 'limit_maxbytes', name: 'limit_maxbytes', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMemcachedEnabled() {
|
||||||
|
const v = process.env.PEARDATA_MEMCACHED
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} urlOrHost
|
||||||
|
* @returns {{ host: string, port: number }}
|
||||||
|
*/
|
||||||
|
export function parseMemcachedAddr(urlOrHost) {
|
||||||
|
const raw = String(urlOrHost || '127.0.0.1:11211').trim()
|
||||||
|
if (raw.includes('://')) {
|
||||||
|
try {
|
||||||
|
const u = new URL(raw)
|
||||||
|
return {
|
||||||
|
host: u.hostname || '127.0.0.1',
|
||||||
|
port: Number(u.port) || 11211,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [host, port] = raw.split(':')
|
||||||
|
return { host: host || '127.0.0.1', port: Number(port) || 11211 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} body
|
||||||
|
* @returns {Record<string, number>}
|
||||||
|
*/
|
||||||
|
export function parseMemcachedStats(body) {
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const out = {}
|
||||||
|
for (const line of String(body || '').split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^STAT\s+(\S+)\s+(\S+)/)
|
||||||
|
if (!m) continue
|
||||||
|
const n = Number(m[2])
|
||||||
|
if (Number.isFinite(n)) out[m[1]] = n
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ host: string, port: number }} addr
|
||||||
|
* @param {number} [timeoutMs]
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export function fetchMemcachedStats(addr, timeoutMs = 3000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.createConnection({ host: addr.host, port: addr.port })
|
||||||
|
let buf = ''
|
||||||
|
let settled = false
|
||||||
|
const done = (err, data) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
try {
|
||||||
|
socket.destroy()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve(data)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => done(new Error('memcached timeout')), timeoutMs)
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.write('stats\r\n')
|
||||||
|
})
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buf += chunk.toString('utf8')
|
||||||
|
if (buf.includes('END')) done(null, buf)
|
||||||
|
})
|
||||||
|
socket.on('error', (err) => done(err))
|
||||||
|
socket.on('end', () => {
|
||||||
|
if (buf) done(null, buf)
|
||||||
|
else done(new Error('memcached closed with no data'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemcachedCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'memcached', intervalMs: opts.intervalMs })
|
||||||
|
this.addr = parseMemcachedAddr(
|
||||||
|
opts.url || process.env.PEARDATA_MEMCACHED_URL || '127.0.0.1:11211'
|
||||||
|
)
|
||||||
|
/** @type {{ get_hits: number, get_misses: number, cmd_get: number, cmd_set: number, wallMs: number }|null} */
|
||||||
|
this._prev = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isMemcachedEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
registerChart(CHART_OPS)
|
||||||
|
registerChart(CHART_MEMORY)
|
||||||
|
log.info('Memcached collector started', this.addr)
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const raw = await fetchMemcachedStats(this.addr)
|
||||||
|
const stats = parseMemcachedStats(raw)
|
||||||
|
const ts = Date.now()
|
||||||
|
|
||||||
|
const cur = {
|
||||||
|
get_hits: Number(stats.get_hits) || 0,
|
||||||
|
get_misses: Number(stats.get_misses) || 0,
|
||||||
|
cmd_get: Number(stats.cmd_get) || 0,
|
||||||
|
cmd_set: Number(stats.cmd_set) || 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const rates = { get_hits: 0, get_misses: 0, cmd_get: 0, cmd_set: 0 }
|
||||||
|
if (this._prev && ts > this._prev.wallMs) {
|
||||||
|
const dt = (ts - this._prev.wallMs) / 1000
|
||||||
|
if (dt > 0) {
|
||||||
|
for (const k of Object.keys(rates)) {
|
||||||
|
rates[k] = Math.max(0, (cur[k] - this._prev[k]) / dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._prev = { ...cur, wallMs: ts }
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
chart: 'memcached.ops',
|
||||||
|
context: 'memcached.ops',
|
||||||
|
ts,
|
||||||
|
values: rates,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'memcached.memory',
|
||||||
|
context: 'memcached.memory',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
bytes: Number(stats.bytes) || 0,
|
||||||
|
limit_maxbytes: Number(stats.limit_maxbytes) || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {MemcachedCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getMemcachedCollector() {
|
||||||
|
if (!singleton) singleton = new MemcachedCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* MongoDB TCP probe (+ optional serverStatus HTTP sidecar).
|
||||||
|
* Enable: PEARDATA_MONGODB=1
|
||||||
|
* URL: PEARDATA_MONGODB_URL=127.0.0.1:27017
|
||||||
|
*/
|
||||||
|
import net from 'net'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
|
||||||
|
export function isMongodbEnabled() {
|
||||||
|
const v = process.env.PEARDATA_MONGODB
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint() {
|
||||||
|
const raw = process.env.PEARDATA_MONGODB_URL || '127.0.0.1:27017'
|
||||||
|
const u = raw.replace(/^mongodb:\/\//, '')
|
||||||
|
const [host, port] = u.split(':')
|
||||||
|
return { host: host || '127.0.0.1', port: Number(port) || 27017 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeTcp(host, port, timeoutMs = 1500) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const start = Date.now()
|
||||||
|
const sock = net.connect({ host, port }, () => {
|
||||||
|
const ms = Date.now() - start
|
||||||
|
sock.end()
|
||||||
|
resolve({ up: 1, latency_ms: ms })
|
||||||
|
})
|
||||||
|
sock.setTimeout(timeoutMs)
|
||||||
|
sock.on('timeout', () => {
|
||||||
|
sock.destroy()
|
||||||
|
resolve({ up: 0, latency_ms: timeoutMs })
|
||||||
|
})
|
||||||
|
sock.on('error', () => resolve({ up: 0, latency_ms: timeoutMs }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MongodbCollector extends CollectorPlugin {
|
||||||
|
constructor() {
|
||||||
|
super({ name: 'mongodb' })
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isMongodbEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const ts = Date.now()
|
||||||
|
const { host, port } = endpoint()
|
||||||
|
const probe = await probeTcp(host, port)
|
||||||
|
registerChart({
|
||||||
|
id: 'mongodb.up',
|
||||||
|
name: 'mongodb.up',
|
||||||
|
context: 'mongodb.up',
|
||||||
|
title: 'MongoDB up',
|
||||||
|
units: 'boolean',
|
||||||
|
family: 'mongodb',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8700,
|
||||||
|
plugin: 'mongodb',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'up', name: 'up', algorithm: 'absolute' },
|
||||||
|
{ id: 'latency_ms', name: 'latency_ms', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: object }>} */
|
||||||
|
const batch = [
|
||||||
|
{ chart: 'mongodb.up', context: 'mongodb.up', ts, values: probe },
|
||||||
|
]
|
||||||
|
const statsUrl = process.env.PEARDATA_MONGODB_STATS_URL
|
||||||
|
if (statsUrl) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(statsUrl, { signal: AbortSignal.timeout(2000) })
|
||||||
|
const text = await res.text()
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const vals = {}
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const [k, v] = line.split(/[=\s]+/)
|
||||||
|
if (k && v != null && Number.isFinite(Number(v))) vals[k] = Number(v)
|
||||||
|
}
|
||||||
|
registerChart({
|
||||||
|
id: 'mongodb.stats',
|
||||||
|
name: 'mongodb.stats',
|
||||||
|
context: 'mongodb.stats',
|
||||||
|
title: 'MongoDB stats',
|
||||||
|
units: 'value',
|
||||||
|
family: 'mongodb',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8710,
|
||||||
|
plugin: 'mongodb',
|
||||||
|
dimensions: Object.keys(vals).map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
algorithm: 'absolute',
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
batch.push({ chart: 'mongodb.stats', context: 'mongodb.stats', ts, values: vals })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let singleton = null
|
||||||
|
export function getMongodbCollector() {
|
||||||
|
if (!singleton) singleton = new MongodbCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* MySQL collector (service plugin).
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_MYSQL=1
|
||||||
|
* Addr: PEARDATA_MYSQL_URL=127.0.0.1:3306
|
||||||
|
* Optional HTTP stats (key=value): PEARDATA_MYSQL_STATS_URL
|
||||||
|
*
|
||||||
|
* Charts: mysql.up, mysql.stats
|
||||||
|
*/
|
||||||
|
import net from 'net'
|
||||||
|
import http from 'http'
|
||||||
|
import https from 'https'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('mysql')
|
||||||
|
|
||||||
|
const CHART_UP = {
|
||||||
|
id: 'mysql.up',
|
||||||
|
name: 'mysql.up',
|
||||||
|
context: 'mysql.up',
|
||||||
|
title: 'MySQL availability',
|
||||||
|
units: 'boolean',
|
||||||
|
family: 'mysql',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8300,
|
||||||
|
plugin: 'mysql',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'up', name: 'up', algorithm: 'absolute' },
|
||||||
|
{ id: 'latency_ms', name: 'latency_ms', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_STATS = {
|
||||||
|
id: 'mysql.stats',
|
||||||
|
name: 'mysql.stats',
|
||||||
|
context: 'mysql.stats',
|
||||||
|
title: 'MySQL stats',
|
||||||
|
units: 'count',
|
||||||
|
family: 'mysql',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8310,
|
||||||
|
plugin: 'mysql',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'questions', name: 'questions', algorithm: 'absolute' },
|
||||||
|
{ id: 'threads_connected', name: 'threads_connected', algorithm: 'absolute' },
|
||||||
|
{ id: 'slow_queries', name: 'slow_queries', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMysqlEnabled() {
|
||||||
|
const v = process.env.PEARDATA_MYSQL
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} urlOrHost
|
||||||
|
* @returns {{ host: string, port: number }}
|
||||||
|
*/
|
||||||
|
export function parseMysqlAddr(urlOrHost) {
|
||||||
|
const raw = String(urlOrHost || '127.0.0.1:3306').trim()
|
||||||
|
if (raw.includes('://')) {
|
||||||
|
try {
|
||||||
|
const u = new URL(raw)
|
||||||
|
return {
|
||||||
|
host: u.hostname || '127.0.0.1',
|
||||||
|
port: Number(u.port) || 3306,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [host, port] = raw.split(':')
|
||||||
|
return { host: host || '127.0.0.1', port: Number(port) || 3306 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} body
|
||||||
|
* @returns {Record<string, number>}
|
||||||
|
*/
|
||||||
|
export function parseMysqlStats(body) {
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const out = {}
|
||||||
|
for (const line of String(body || '').split(/\r?\n/)) {
|
||||||
|
const t = line.trim()
|
||||||
|
if (!t || t.startsWith('#')) continue
|
||||||
|
const m = t.match(/^([a-zA-Z0-9_]+)\s*[=:]\s*([0-9.]+)/)
|
||||||
|
if (m) out[m[1]] = Number(m[2])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ host: string, port: number }} addr
|
||||||
|
* @param {number} [timeoutMs]
|
||||||
|
* @returns {Promise<{ up: number, latency_ms: number }>}
|
||||||
|
*/
|
||||||
|
export function probeMysqlTcp(addr, timeoutMs = 3000) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const started = Date.now()
|
||||||
|
const socket = net.createConnection({ host: addr.host, port: addr.port })
|
||||||
|
let settled = false
|
||||||
|
const finish = (up) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
try {
|
||||||
|
socket.destroy()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
resolve({ up, latency_ms: Date.now() - started })
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => finish(0), timeoutMs)
|
||||||
|
socket.on('connect', () => finish(1))
|
||||||
|
socket.on('error', () => finish(0))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchText(url, timeoutMs = 3000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const mod = String(url).startsWith('https') ? https : http
|
||||||
|
const req = mod.get(url, (res) => {
|
||||||
|
let body = ''
|
||||||
|
res.on('data', (c) => {
|
||||||
|
body += c
|
||||||
|
})
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(body)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req.setTimeout(timeoutMs, () => {
|
||||||
|
req.destroy()
|
||||||
|
reject(new Error('timeout'))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MysqlCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'mysql', intervalMs: opts.intervalMs })
|
||||||
|
this.addr = parseMysqlAddr(opts.url || process.env.PEARDATA_MYSQL_URL || '127.0.0.1:3306')
|
||||||
|
this.statsUrl = opts.statsUrl || process.env.PEARDATA_MYSQL_STATS_URL || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isMysqlEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
registerChart(CHART_UP)
|
||||||
|
if (this.statsUrl) registerChart(CHART_STATS)
|
||||||
|
log.info('MySQL collector started', {
|
||||||
|
addr: this.addr,
|
||||||
|
statsUrl: this.statsUrl || null,
|
||||||
|
})
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const ts = Date.now()
|
||||||
|
const probe = await probeMysqlTcp(this.addr)
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||||
|
const batch = [
|
||||||
|
{
|
||||||
|
chart: 'mysql.up',
|
||||||
|
context: 'mysql.up',
|
||||||
|
ts,
|
||||||
|
values: { up: probe.up, latency_ms: probe.latency_ms },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (this.statsUrl) {
|
||||||
|
try {
|
||||||
|
const body = await fetchText(this.statsUrl)
|
||||||
|
const s = parseMysqlStats(body)
|
||||||
|
registerChart(CHART_STATS)
|
||||||
|
batch.push({
|
||||||
|
chart: 'mysql.stats',
|
||||||
|
context: 'mysql.stats',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
questions: s.questions ?? s.Queries ?? null,
|
||||||
|
threads_connected: s.threads_connected ?? s.Threads_connected ?? null,
|
||||||
|
slow_queries: s.slow_queries ?? s.Slow_queries ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('MySQL stats URL failed', { error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {MysqlCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getMysqlCollector() {
|
||||||
|
if (!singleton) singleton = new MysqlCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* NVIDIA GPU metrics via nvidia-smi.
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_NVIDIA=1
|
||||||
|
*
|
||||||
|
* Charts: gpu.util.{i}, gpu.mem.{i}, gpu.temp.{i}, gpu.power.{i}
|
||||||
|
*/
|
||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('nvidia')
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
const QUERY =
|
||||||
|
'index,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw'
|
||||||
|
|
||||||
|
export function isNvidiaEnabled() {
|
||||||
|
const v = process.env.PEARDATA_NVIDIA
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} index
|
||||||
|
* @param {'util'|'mem'|'temp'|'power'} kind
|
||||||
|
*/
|
||||||
|
function makeGpuChart(index, kind) {
|
||||||
|
const id = `gpu.${kind}.${index}`
|
||||||
|
const titles = {
|
||||||
|
util: `GPU ${index} utilization`,
|
||||||
|
mem: `GPU ${index} memory`,
|
||||||
|
temp: `GPU ${index} temperature`,
|
||||||
|
power: `GPU ${index} power`,
|
||||||
|
}
|
||||||
|
const units = {
|
||||||
|
util: 'percentage',
|
||||||
|
mem: 'MiB',
|
||||||
|
temp: 'Celsius',
|
||||||
|
power: 'Watts',
|
||||||
|
}
|
||||||
|
const contexts = {
|
||||||
|
util: 'gpu.utilization',
|
||||||
|
mem: 'gpu.memory',
|
||||||
|
temp: 'gpu.temperature',
|
||||||
|
power: 'gpu.power',
|
||||||
|
}
|
||||||
|
const dims =
|
||||||
|
kind === 'mem'
|
||||||
|
? [
|
||||||
|
{ id: 'used', name: 'used', algorithm: 'absolute' },
|
||||||
|
{ id: 'total', name: 'total', algorithm: 'absolute' },
|
||||||
|
]
|
||||||
|
: [{ id: 'value', name: 'value', algorithm: 'absolute' }]
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
context: contexts[kind],
|
||||||
|
title: titles[kind],
|
||||||
|
units: units[kind],
|
||||||
|
family: `gpu${index}`,
|
||||||
|
chartType: kind === 'mem' ? 'area' : 'line',
|
||||||
|
priority: 7200 + index,
|
||||||
|
plugin: 'nvidia',
|
||||||
|
dimensions: dims,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} stdout
|
||||||
|
* @returns {Array<{ index: number, util: number, memUsed: number, memTotal: number, temp: number, power: number }>}
|
||||||
|
*/
|
||||||
|
export function parseNvidiaSmiCsv(stdout) {
|
||||||
|
/** @type {Array<{ index: number, util: number, memUsed: number, memTotal: number, temp: number, power: number }>} */
|
||||||
|
const out = []
|
||||||
|
for (const line of String(stdout || '').split('\n')) {
|
||||||
|
const t = line.trim()
|
||||||
|
if (!t) continue
|
||||||
|
const parts = t.split(',').map((s) => s.trim())
|
||||||
|
if (parts.length < 6) continue
|
||||||
|
out.push({
|
||||||
|
index: Number(parts[0]) || out.length,
|
||||||
|
util: Number(parts[1]) || 0,
|
||||||
|
memUsed: Number(parts[2]) || 0,
|
||||||
|
memTotal: Number(parts[3]) || 0,
|
||||||
|
temp: Number(parts[4]) || 0,
|
||||||
|
power: Number(parts[5]) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryNvidiaGpus(timeoutMs = 5000) {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
'nvidia-smi',
|
||||||
|
[`--query-gpu=${QUERY}`, '--format=csv,noheader,nounits'],
|
||||||
|
{ timeout: timeoutMs, encoding: 'utf8' }
|
||||||
|
)
|
||||||
|
return parseNvidiaSmiCsv(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NvidiaCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'nvidia', intervalMs: opts.intervalMs })
|
||||||
|
/** @type {boolean|null} */
|
||||||
|
this._available = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isNvidiaEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
log.info('NVIDIA collector started')
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
let gpus
|
||||||
|
try {
|
||||||
|
gpus = await queryNvidiaGpus()
|
||||||
|
this._available = true
|
||||||
|
} catch (err) {
|
||||||
|
if (this._available !== false) {
|
||||||
|
log.info('nvidia-smi unavailable — collector idle', { error: err.message })
|
||||||
|
this._available = false
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const ts = Date.now()
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||||
|
const batch = []
|
||||||
|
for (const g of gpus) {
|
||||||
|
const utilDef = makeGpuChart(g.index, 'util')
|
||||||
|
const memDef = makeGpuChart(g.index, 'mem')
|
||||||
|
const tempDef = makeGpuChart(g.index, 'temp')
|
||||||
|
const powerDef = makeGpuChart(g.index, 'power')
|
||||||
|
registerChart(utilDef)
|
||||||
|
registerChart(memDef)
|
||||||
|
registerChart(tempDef)
|
||||||
|
registerChart(powerDef)
|
||||||
|
batch.push(
|
||||||
|
{ chart: utilDef.id, context: utilDef.context, ts, values: { value: g.util } },
|
||||||
|
{
|
||||||
|
chart: memDef.id,
|
||||||
|
context: memDef.context,
|
||||||
|
ts,
|
||||||
|
values: { used: g.memUsed, total: g.memTotal },
|
||||||
|
},
|
||||||
|
{ chart: tempDef.id, context: tempDef.context, ts, values: { value: g.temp } },
|
||||||
|
{ chart: powerDef.id, context: powerDef.context, ts, values: { value: g.power } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {NvidiaCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getNvidiaCollector() {
|
||||||
|
if (!singleton) singleton = new NvidiaCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
* Emits:
|
* Emits:
|
||||||
* processes.top_cpu — % of one host CPU for top processes (by name)
|
* processes.top_cpu — % of one host CPU for top processes (by name)
|
||||||
* processes.top_rss — RSS MiB for top processes (by RSS)
|
* processes.top_rss — RSS MiB for top processes (by RSS)
|
||||||
|
* processes.top_io — read+write byte rates for top processes (by I/O)
|
||||||
|
* processes.top_threads — thread count for top processes (by threads)
|
||||||
*
|
*
|
||||||
* Linux /proc only; no-op elsewhere.
|
* Linux /proc only; no-op elsewhere.
|
||||||
*/
|
*/
|
||||||
@@ -46,7 +48,7 @@ function sanitizeDim(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {Array<{ pid: number, name: string, utime: number, stime: number, rssPages: number }>|null}
|
* @returns {Array<{ pid: number, name: string, utime: number, stime: number, rssPages: number, threads: number, readBytes: number|null, writeBytes: number|null }>|null}
|
||||||
*/
|
*/
|
||||||
export function listProcStats() {
|
export function listProcStats() {
|
||||||
if (os.platform() !== 'linux') return null
|
if (os.platform() !== 'linux') return null
|
||||||
@@ -56,7 +58,7 @@ export function listProcStats() {
|
|||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
/** @type {Array<{ pid: number, name: string, utime: number, stime: number, rssPages: number }>} */
|
/** @type {Array<{ pid: number, name: string, utime: number, stime: number, rssPages: number, threads: number, readBytes: number|null, writeBytes: number|null }>} */
|
||||||
const out = []
|
const out = []
|
||||||
for (const ent of dirs) {
|
for (const ent of dirs) {
|
||||||
if (!/^\d+$/.test(ent)) continue
|
if (!/^\d+$/.test(ent)) continue
|
||||||
@@ -77,12 +79,37 @@ export function listProcStats() {
|
|||||||
const stime = Number(rest[12])
|
const stime = Number(rest[12])
|
||||||
const rssPages = Number(rest[21])
|
const rssPages = Number(rest[21])
|
||||||
if (!Number.isFinite(utime) || !Number.isFinite(stime)) continue
|
if (!Number.isFinite(utime) || !Number.isFinite(stime)) continue
|
||||||
|
|
||||||
|
let threads = 0
|
||||||
|
try {
|
||||||
|
const status = fs.readFileSync(path.join('/proc', ent, 'status'), 'utf8')
|
||||||
|
const m = status.match(/^Threads:\s*(\d+)/m)
|
||||||
|
if (m) threads = Number(m[1]) || 0
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
let readBytes = null
|
||||||
|
let writeBytes = null
|
||||||
|
try {
|
||||||
|
const ioRaw = fs.readFileSync(path.join('/proc', ent, 'io'), 'utf8')
|
||||||
|
const rb = ioRaw.match(/^read_bytes:\s*(\d+)/m)
|
||||||
|
const wb = ioRaw.match(/^write_bytes:\s*(\d+)/m)
|
||||||
|
if (rb) readBytes = Number(rb[1]) || 0
|
||||||
|
if (wb) writeBytes = Number(wb[1]) || 0
|
||||||
|
} catch {
|
||||||
|
// unreadable for this pid
|
||||||
|
}
|
||||||
|
|
||||||
out.push({
|
out.push({
|
||||||
pid,
|
pid,
|
||||||
name: sanitizeDim(name),
|
name: sanitizeDim(name),
|
||||||
utime,
|
utime,
|
||||||
stime,
|
stime,
|
||||||
rssPages: Number.isFinite(rssPages) ? rssPages : 0,
|
rssPages: Number.isFinite(rssPages) ? rssPages : 0,
|
||||||
|
threads,
|
||||||
|
readBytes,
|
||||||
|
writeBytes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -98,7 +125,7 @@ function pageSize() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string[]} names
|
* @param {string[]} names
|
||||||
* @param {'cpu'|'rss'} kind
|
* @param {'cpu'|'rss'|'io'|'threads'} kind
|
||||||
*/
|
*/
|
||||||
function registerTopChart(names, kind) {
|
function registerTopChart(names, kind) {
|
||||||
const dims = [...new Set(names)].slice(0, topN()).map((id) => ({
|
const dims = [...new Set(names)].slice(0, topN()).map((id) => ({
|
||||||
@@ -109,9 +136,10 @@ function registerTopChart(names, kind) {
|
|||||||
if (!dims.length) {
|
if (!dims.length) {
|
||||||
dims.push({ id: '_idle', name: '_idle', algorithm: 'absolute' })
|
dims.push({ id: '_idle', name: '_idle', algorithm: 'absolute' })
|
||||||
}
|
}
|
||||||
const def =
|
/** @type {import('../../../shared/metrics.js').ChartDef} */
|
||||||
kind === 'cpu'
|
let def
|
||||||
? {
|
if (kind === 'cpu') {
|
||||||
|
def = {
|
||||||
id: 'processes.top_cpu',
|
id: 'processes.top_cpu',
|
||||||
name: 'processes.top_cpu',
|
name: 'processes.top_cpu',
|
||||||
context: 'processes.top_cpu',
|
context: 'processes.top_cpu',
|
||||||
@@ -123,7 +151,8 @@ function registerTopChart(names, kind) {
|
|||||||
plugin: 'processes',
|
plugin: 'processes',
|
||||||
dimensions: dims,
|
dimensions: dims,
|
||||||
}
|
}
|
||||||
: {
|
} else if (kind === 'rss') {
|
||||||
|
def = {
|
||||||
id: 'processes.top_rss',
|
id: 'processes.top_rss',
|
||||||
name: 'processes.top_rss',
|
name: 'processes.top_rss',
|
||||||
context: 'processes.top_rss',
|
context: 'processes.top_rss',
|
||||||
@@ -135,6 +164,33 @@ function registerTopChart(names, kind) {
|
|||||||
plugin: 'processes',
|
plugin: 'processes',
|
||||||
dimensions: dims,
|
dimensions: dims,
|
||||||
}
|
}
|
||||||
|
} else if (kind === 'io') {
|
||||||
|
def = {
|
||||||
|
id: 'processes.top_io',
|
||||||
|
name: 'processes.top_io',
|
||||||
|
context: 'processes.top_io',
|
||||||
|
title: 'Top processes I/O',
|
||||||
|
units: 'KiB/s',
|
||||||
|
family: 'processes',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 6020,
|
||||||
|
plugin: 'processes',
|
||||||
|
dimensions: dims,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
def = {
|
||||||
|
id: 'processes.top_threads',
|
||||||
|
name: 'processes.top_threads',
|
||||||
|
context: 'processes.top_threads',
|
||||||
|
title: 'Top processes threads',
|
||||||
|
units: 'threads',
|
||||||
|
family: 'processes',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 6030,
|
||||||
|
plugin: 'processes',
|
||||||
|
dimensions: dims,
|
||||||
|
}
|
||||||
|
}
|
||||||
registerChart(def)
|
registerChart(def)
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
@@ -144,7 +200,7 @@ export class ProcessCollector extends EventEmitter {
|
|||||||
super()
|
super()
|
||||||
this.intervalMs = opts.intervalMs || Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS
|
this.intervalMs = opts.intervalMs || Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS
|
||||||
this._timer = null
|
this._timer = null
|
||||||
/** @type {Map<number, { ticks: number, wallMs: number, name: string }>|null} */
|
/** @type {Map<number, { ticks: number, wallMs: number, name: string, readBytes: number, writeBytes: number }>|null} */
|
||||||
this._prev = null
|
this._prev = null
|
||||||
this._pageBytes = 4096
|
this._pageBytes = 4096
|
||||||
}
|
}
|
||||||
@@ -176,6 +232,8 @@ export class ProcessCollector extends EventEmitter {
|
|||||||
|
|
||||||
/** @type {Map<string, number>} */
|
/** @type {Map<string, number>} */
|
||||||
const cpuByName = new Map()
|
const cpuByName = new Map()
|
||||||
|
/** @type {Map<string, number>} */
|
||||||
|
const ioByName = new Map()
|
||||||
if (this._prev) {
|
if (this._prev) {
|
||||||
for (const p of procs) {
|
for (const p of procs) {
|
||||||
const prev = this._prev.get(p.pid)
|
const prev = this._prev.get(p.pid)
|
||||||
@@ -188,20 +246,41 @@ export class ProcessCollector extends EventEmitter {
|
|||||||
const pct = (dTicks / 100 / dSec) * 100
|
const pct = (dTicks / 100 / dSec) * 100
|
||||||
const key = p.name
|
const key = p.name
|
||||||
cpuByName.set(key, (cpuByName.get(key) || 0) + Math.min(100 * ncpu, pct))
|
cpuByName.set(key, (cpuByName.get(key) || 0) + Math.min(100 * ncpu, pct))
|
||||||
|
|
||||||
|
if (
|
||||||
|
p.readBytes != null &&
|
||||||
|
p.writeBytes != null &&
|
||||||
|
prev.readBytes != null &&
|
||||||
|
prev.writeBytes != null
|
||||||
|
) {
|
||||||
|
const dBytes =
|
||||||
|
Math.max(0, p.readBytes - prev.readBytes) +
|
||||||
|
Math.max(0, p.writeBytes - prev.writeBytes)
|
||||||
|
const rateKib = dBytes / dSec / 1024
|
||||||
|
ioByName.set(key, (ioByName.get(key) || 0) + rateKib)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {Map<number, { ticks: number, wallMs: number, name: string }>} */
|
/** @type {Map<number, { ticks: number, wallMs: number, name: string, readBytes: number, writeBytes: number }>} */
|
||||||
const next = new Map()
|
const next = new Map()
|
||||||
for (const p of procs) {
|
for (const p of procs) {
|
||||||
next.set(p.pid, { ticks: p.utime + p.stime, wallMs, name: p.name })
|
next.set(p.pid, {
|
||||||
|
ticks: p.utime + p.stime,
|
||||||
|
wallMs,
|
||||||
|
name: p.name,
|
||||||
|
readBytes: p.readBytes ?? 0,
|
||||||
|
writeBytes: p.writeBytes ?? 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
this._prev = next
|
this._prev = next
|
||||||
|
|
||||||
const cpuRanked = [...cpuByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
|
const cpuRanked = [...cpuByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
|
||||||
|
const ioRanked = [...ioByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
|
||||||
const rssRanked = [...procs]
|
const rssRanked = [...procs]
|
||||||
.sort((a, b) => b.rssPages - a.rssPages)
|
.sort((a, b) => b.rssPages - a.rssPages)
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
|
const threadRanked = [...procs].sort((a, b) => b.threads - a.threads).slice(0, limit)
|
||||||
|
|
||||||
/** @type {Map<string, number>} */
|
/** @type {Map<string, number>} */
|
||||||
const rssByName = new Map()
|
const rssByName = new Map()
|
||||||
@@ -211,6 +290,13 @@ export class ProcessCollector extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const rssTop = [...rssByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
|
const rssTop = [...rssByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
|
||||||
|
|
||||||
|
/** @type {Map<string, number>} */
|
||||||
|
const threadsByName = new Map()
|
||||||
|
for (const p of threadRanked) {
|
||||||
|
threadsByName.set(p.name, (threadsByName.get(p.name) || 0) + p.threads)
|
||||||
|
}
|
||||||
|
const threadsTop = [...threadsByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
|
||||||
|
|
||||||
const cpuDef = registerTopChart(
|
const cpuDef = registerTopChart(
|
||||||
cpuRanked.map(([n]) => n),
|
cpuRanked.map(([n]) => n),
|
||||||
'cpu'
|
'cpu'
|
||||||
@@ -219,6 +305,14 @@ export class ProcessCollector extends EventEmitter {
|
|||||||
rssTop.map(([n]) => n),
|
rssTop.map(([n]) => n),
|
||||||
'rss'
|
'rss'
|
||||||
)
|
)
|
||||||
|
const ioDef = registerTopChart(
|
||||||
|
ioRanked.map(([n]) => n),
|
||||||
|
'io'
|
||||||
|
)
|
||||||
|
const threadsDef = registerTopChart(
|
||||||
|
threadsTop.map(([n]) => n),
|
||||||
|
'threads'
|
||||||
|
)
|
||||||
|
|
||||||
/** @type {Record<string, number|null>} */
|
/** @type {Record<string, number|null>} */
|
||||||
const cpuValues = {}
|
const cpuValues = {}
|
||||||
@@ -230,6 +324,16 @@ export class ProcessCollector extends EventEmitter {
|
|||||||
for (const d of rssDef.dimensions) rssValues[d.id] = 0
|
for (const d of rssDef.dimensions) rssValues[d.id] = 0
|
||||||
for (const [name, mib] of rssTop) rssValues[name] = mib
|
for (const [name, mib] of rssTop) rssValues[name] = mib
|
||||||
|
|
||||||
|
/** @type {Record<string, number|null>} */
|
||||||
|
const ioValues = {}
|
||||||
|
for (const d of ioDef.dimensions) ioValues[d.id] = 0
|
||||||
|
for (const [name, rate] of ioRanked) ioValues[name] = rate
|
||||||
|
|
||||||
|
/** @type {Record<string, number|null>} */
|
||||||
|
const threadValues = {}
|
||||||
|
for (const d of threadsDef.dimensions) threadValues[d.id] = 0
|
||||||
|
for (const [name, count] of threadsTop) threadValues[name] = count
|
||||||
|
|
||||||
this.emit('samples', [
|
this.emit('samples', [
|
||||||
{
|
{
|
||||||
chart: 'processes.top_cpu',
|
chart: 'processes.top_cpu',
|
||||||
@@ -243,6 +347,18 @@ export class ProcessCollector extends EventEmitter {
|
|||||||
ts,
|
ts,
|
||||||
values: rssValues,
|
values: rssValues,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
chart: 'processes.top_io',
|
||||||
|
context: 'processes.top_io',
|
||||||
|
ts,
|
||||||
|
values: ioValues,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'processes.top_threads',
|
||||||
|
context: 'processes.top_threads',
|
||||||
|
ts,
|
||||||
|
values: threadValues,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('Process tick failed', { error: err.message })
|
log.warn('Process tick failed', { error: err.message })
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Prometheus text exposition scraper (service plugin).
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_PROMETHEUS=1
|
||||||
|
* URLs: PEARDATA_PROMETHEUS_URLS=comma-separated scrape URLs
|
||||||
|
*
|
||||||
|
* Charts: prom.{sanitized_metric} — one gauge/counter value per metric (max 40)
|
||||||
|
*/
|
||||||
|
import http from 'http'
|
||||||
|
import https from 'https'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('prometheus')
|
||||||
|
|
||||||
|
const MAX_METRICS = 40
|
||||||
|
|
||||||
|
export function isPrometheusEnabled() {
|
||||||
|
const v = process.env.PEARDATA_PROMETHEUS
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} raw
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function parsePrometheusUrls(raw) {
|
||||||
|
return String(raw || '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize metric name for chart id.
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
export function sanitizeMetricName(name) {
|
||||||
|
return String(name)
|
||||||
|
.replace(/[^a-zA-Z0-9_.-]/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 96)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Prometheus text exposition format.
|
||||||
|
* Skips histogram/summary _bucket lines; returns latest scalar per metric name.
|
||||||
|
* @param {string} body
|
||||||
|
* @returns {Record<string, number>}
|
||||||
|
*/
|
||||||
|
export function parsePrometheusText(body) {
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const out = {}
|
||||||
|
for (const line of String(body || '').split(/\r?\n/)) {
|
||||||
|
const t = line.trim()
|
||||||
|
if (!t || t.startsWith('#')) continue
|
||||||
|
if (t.includes('_bucket{') || t.endsWith('_bucket')) continue
|
||||||
|
if (t.includes('_sum{') || t.endsWith('_sum')) continue
|
||||||
|
if (t.includes('_count{') || t.endsWith('_count')) continue
|
||||||
|
const sp = t.lastIndexOf(' ')
|
||||||
|
if (sp < 0) continue
|
||||||
|
let namePart = t.slice(0, sp).trim()
|
||||||
|
const valStr = t.slice(sp + 1).trim()
|
||||||
|
const brace = namePart.indexOf('{')
|
||||||
|
if (brace >= 0) namePart = namePart.slice(0, brace)
|
||||||
|
if (namePart.endsWith('_bucket') || namePart.endsWith('_sum') || namePart.endsWith('_count')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const val = Number(valStr)
|
||||||
|
if (!Number.isFinite(val)) continue
|
||||||
|
out[namePart] = val
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePromChart(metric) {
|
||||||
|
const safe = sanitizeMetricName(metric)
|
||||||
|
return {
|
||||||
|
id: `prom.${safe}`,
|
||||||
|
name: `prom.${safe}`,
|
||||||
|
context: 'prometheus.metric',
|
||||||
|
title: `Prometheus ${metric}`,
|
||||||
|
units: 'value',
|
||||||
|
family: 'prometheus',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8600,
|
||||||
|
plugin: 'prometheus',
|
||||||
|
dimensions: [{ id: 'value', name: 'value', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchText(url, timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const mod = String(url).startsWith('https') ? https : http
|
||||||
|
const req = mod.get(url, (res) => {
|
||||||
|
let body = ''
|
||||||
|
res.on('data', (c) => {
|
||||||
|
body += c
|
||||||
|
})
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(body)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req.setTimeout(timeoutMs, () => {
|
||||||
|
req.destroy()
|
||||||
|
reject(new Error('timeout'))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrometheusCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'prometheus', intervalMs: opts.intervalMs })
|
||||||
|
this.urls = parsePrometheusUrls(
|
||||||
|
opts.urls || process.env.PEARDATA_PROMETHEUS_URLS || ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isPrometheusEnabled() && this.urls.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
log.info('Prometheus collector started', { urls: this.urls.length })
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const merged = {}
|
||||||
|
for (const url of this.urls) {
|
||||||
|
try {
|
||||||
|
const body = await fetchText(url)
|
||||||
|
const parsed = parsePrometheusText(body)
|
||||||
|
Object.assign(merged, parsed)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Prometheus scrape failed', { url, error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = Object.keys(merged).slice(0, MAX_METRICS)
|
||||||
|
const ts = Date.now()
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number> }>} */
|
||||||
|
const batch = []
|
||||||
|
for (const name of names) {
|
||||||
|
const def = makePromChart(name)
|
||||||
|
registerChart(def)
|
||||||
|
batch.push({
|
||||||
|
chart: def.id,
|
||||||
|
context: def.context,
|
||||||
|
ts,
|
||||||
|
values: { value: merged[name] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {PrometheusCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getPrometheusCollector() {
|
||||||
|
if (!singleton) singleton = new PrometheusCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* RabbitMQ management API collector.
|
||||||
|
* Enable: PEARDATA_RABBITMQ=1
|
||||||
|
* URL: PEARDATA_RABBITMQ_URL=http://guest:[email protected]:15672
|
||||||
|
*/
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
|
||||||
|
export function isRabbitmqEnabled() {
|
||||||
|
const v = process.env.PEARDATA_RABBITMQ
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseUrl() {
|
||||||
|
return (process.env.PEARDATA_RABBITMQ_URL || 'http://guest:[email protected]:15672').replace(
|
||||||
|
/\/$/,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RabbitmqCollector extends CollectorPlugin {
|
||||||
|
constructor() {
|
||||||
|
super({ name: 'rabbitmq' })
|
||||||
|
this._prev = null
|
||||||
|
this._prevTs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isRabbitmqEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const ts = Date.now()
|
||||||
|
const overview = await fetchJson(`${baseUrl()}/api/overview`)
|
||||||
|
if (!overview) {
|
||||||
|
registerChart({
|
||||||
|
id: 'rabbitmq.up',
|
||||||
|
name: 'rabbitmq.up',
|
||||||
|
context: 'rabbitmq.up',
|
||||||
|
title: 'RabbitMQ up',
|
||||||
|
units: 'boolean',
|
||||||
|
family: 'rabbitmq',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8600,
|
||||||
|
plugin: 'rabbitmq',
|
||||||
|
dimensions: [{ id: 'up', name: 'up', algorithm: 'absolute' }],
|
||||||
|
})
|
||||||
|
return [{ chart: 'rabbitmq.up', context: 'rabbitmq.up', ts, values: { up: 0 } }]
|
||||||
|
}
|
||||||
|
const q = overview.queue_totals || {}
|
||||||
|
const m = overview.message_stats || {}
|
||||||
|
const dt = this._prevTs ? (ts - this._prevTs) / 1000 : 0
|
||||||
|
const prev = this._prev
|
||||||
|
const rate = (key) => {
|
||||||
|
const cur = m[key] || 0
|
||||||
|
if (!prev || dt <= 0) return 0
|
||||||
|
return Math.max(0, (cur - (prev[key] || 0)) / dt)
|
||||||
|
}
|
||||||
|
registerChart({
|
||||||
|
id: 'rabbitmq.up',
|
||||||
|
name: 'rabbitmq.up',
|
||||||
|
context: 'rabbitmq.up',
|
||||||
|
title: 'RabbitMQ up',
|
||||||
|
units: 'boolean',
|
||||||
|
family: 'rabbitmq',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8600,
|
||||||
|
plugin: 'rabbitmq',
|
||||||
|
dimensions: [{ id: 'up', name: 'up', algorithm: 'absolute' }],
|
||||||
|
})
|
||||||
|
registerChart({
|
||||||
|
id: 'rabbitmq.queues',
|
||||||
|
name: 'rabbitmq.queues',
|
||||||
|
context: 'rabbitmq.queues',
|
||||||
|
title: 'RabbitMQ queue messages',
|
||||||
|
units: 'messages',
|
||||||
|
family: 'rabbitmq',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8610,
|
||||||
|
plugin: 'rabbitmq',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'ready', name: 'ready', algorithm: 'absolute' },
|
||||||
|
{ id: 'unacked', name: 'unacked', algorithm: 'absolute' },
|
||||||
|
{ id: 'total', name: 'total', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
registerChart({
|
||||||
|
id: 'rabbitmq.rates',
|
||||||
|
name: 'rabbitmq.rates',
|
||||||
|
context: 'rabbitmq.rates',
|
||||||
|
title: 'RabbitMQ message rates',
|
||||||
|
units: 'messages/s',
|
||||||
|
family: 'rabbitmq',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8620,
|
||||||
|
plugin: 'rabbitmq',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'publish', name: 'publish', algorithm: 'incremental' },
|
||||||
|
{ id: 'deliver', name: 'deliver', algorithm: 'incremental' },
|
||||||
|
{ id: 'ack', name: 'ack', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
this._prev = { publish: m.publish || 0, deliver: m.deliver_get || 0, ack: m.ack || 0 }
|
||||||
|
this._prevTs = ts
|
||||||
|
return [
|
||||||
|
{ chart: 'rabbitmq.up', context: 'rabbitmq.up', ts, values: { up: 1 } },
|
||||||
|
{
|
||||||
|
chart: 'rabbitmq.queues',
|
||||||
|
context: 'rabbitmq.queues',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
ready: q.messages_ready || 0,
|
||||||
|
unacked: q.messages_unacknowledged || 0,
|
||||||
|
total: q.messages || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'rabbitmq.rates',
|
||||||
|
context: 'rabbitmq.rates',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
publish: rate('publish'),
|
||||||
|
deliver: rate('deliver_get'),
|
||||||
|
ack: rate('ack'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(2000) })
|
||||||
|
if (!res.ok) return null
|
||||||
|
return await res.json()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let singleton = null
|
||||||
|
export function getRabbitmqCollector() {
|
||||||
|
if (!singleton) singleton = new RabbitmqCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* PearData process self-monitor (always enabled).
|
||||||
|
*
|
||||||
|
* Charts: peardata.cpu, peardata.mem, peardata.charts
|
||||||
|
*/
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart, getAllChartDefs } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('self-monitor')
|
||||||
|
|
||||||
|
const CHART_CPU = {
|
||||||
|
id: 'peardata.cpu',
|
||||||
|
name: 'peardata.cpu',
|
||||||
|
context: 'peardata.cpu',
|
||||||
|
title: 'PearData CPU usage',
|
||||||
|
units: 'percentage',
|
||||||
|
family: 'peardata',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 9000,
|
||||||
|
plugin: 'self-monitor',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'user', name: 'user', algorithm: 'absolute' },
|
||||||
|
{ id: 'system', name: 'system', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_MEM = {
|
||||||
|
id: 'peardata.mem',
|
||||||
|
name: 'peardata.mem',
|
||||||
|
context: 'peardata.mem',
|
||||||
|
title: 'PearData memory',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'peardata',
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 9010,
|
||||||
|
plugin: 'self-monitor',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'rss', name: 'rss', algorithm: 'absolute' },
|
||||||
|
{ id: 'heap', name: 'heap', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_CHARTS = {
|
||||||
|
id: 'peardata.charts',
|
||||||
|
name: 'peardata.charts',
|
||||||
|
context: 'peardata.charts',
|
||||||
|
title: 'Registered chart count',
|
||||||
|
units: 'charts',
|
||||||
|
family: 'peardata',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 9020,
|
||||||
|
plugin: 'self-monitor',
|
||||||
|
dimensions: [{ id: 'count', name: 'count', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelfMonitorEnabled() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SelfMonitorCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'self-monitor', intervalMs: opts.intervalMs })
|
||||||
|
/** @type {{ user: number, system: number }|null} */
|
||||||
|
this._prevCpu = null
|
||||||
|
/** @type {number|null} */
|
||||||
|
this._prevWallMs = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isSelfMonitorEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
registerChart(CHART_CPU)
|
||||||
|
registerChart(CHART_MEM)
|
||||||
|
registerChart(CHART_CHARTS)
|
||||||
|
log.info('Self-monitor collector started')
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const ts = Date.now()
|
||||||
|
const mem = process.memoryUsage()
|
||||||
|
const cpu = process.cpuUsage()
|
||||||
|
|
||||||
|
let userPct = 0
|
||||||
|
let sysPct = 0
|
||||||
|
if (this._prevCpu && this._prevWallMs && ts > this._prevWallMs) {
|
||||||
|
const dtSec = (ts - this._prevWallMs) / 1000
|
||||||
|
if (dtSec > 0) {
|
||||||
|
const dUser = cpu.user - this._prevCpu.user
|
||||||
|
const dSys = cpu.system - this._prevCpu.system
|
||||||
|
userPct = (dUser / 1e6 / dtSec) * 100
|
||||||
|
sysPct = (dSys / 1e6 / dtSec) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._prevCpu = { user: cpu.user, system: cpu.system }
|
||||||
|
this._prevWallMs = ts
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
chart: 'peardata.cpu',
|
||||||
|
context: 'peardata.cpu',
|
||||||
|
ts,
|
||||||
|
values: { user: userPct, system: sysPct },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'peardata.mem',
|
||||||
|
context: 'peardata.mem',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
rss: mem.rss / (1024 * 1024),
|
||||||
|
heap: mem.heapUsed / (1024 * 1024),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'peardata.charts',
|
||||||
|
context: 'peardata.charts',
|
||||||
|
ts,
|
||||||
|
values: { count: getAllChartDefs().length },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {SelfMonitorCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getSelfMonitorCollector() {
|
||||||
|
if (!singleton) singleton = new SelfMonitorCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Hardware sensors via sysfs hwmon + thermal zones.
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_SENSORS=1 (default on Linux when unset)
|
||||||
|
* Disable: PEARDATA_SENSORS=0
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import {
|
||||||
|
SAMPLE_INTERVAL_MS,
|
||||||
|
registerChart,
|
||||||
|
makeSensorTempChart,
|
||||||
|
makeThermalZoneChart,
|
||||||
|
} from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('sensors')
|
||||||
|
|
||||||
|
export function isSensorsEnabled() {
|
||||||
|
const v = process.env.PEARDATA_SENSORS
|
||||||
|
if (v === '0' || v === 'off' || v === 'false') return false
|
||||||
|
if (v === '1' || v === 'on' || v === 'true') return true
|
||||||
|
return os.platform() === 'linux'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(p) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(p, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSensorFanChart(chip, label) {
|
||||||
|
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
||||||
|
return {
|
||||||
|
id: `sensors.fan.${id}`,
|
||||||
|
name: `sensors.fan.${id}`,
|
||||||
|
context: 'sensors.fan',
|
||||||
|
title: `Fan ${chip} ${label}`,
|
||||||
|
units: 'RPM',
|
||||||
|
family: chip,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 7040,
|
||||||
|
plugin: 'sensors',
|
||||||
|
dimensions: [{ id: 'rpm', name: 'rpm', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSensorVoltageChart(chip, label) {
|
||||||
|
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
||||||
|
return {
|
||||||
|
id: `sensors.voltage.${id}`,
|
||||||
|
name: `sensors.voltage.${id}`,
|
||||||
|
context: 'sensors.voltage',
|
||||||
|
title: `Voltage ${chip} ${label}`,
|
||||||
|
units: 'mV',
|
||||||
|
family: chip,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 7050,
|
||||||
|
plugin: 'sensors',
|
||||||
|
dimensions: [{ id: 'millivolts', name: 'millivolts', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSensorPowerChart(chip, label) {
|
||||||
|
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
||||||
|
return {
|
||||||
|
id: `sensors.power.${id}`,
|
||||||
|
name: `sensors.power.${id}`,
|
||||||
|
context: 'sensors.power',
|
||||||
|
title: `Power ${chip} ${label}`,
|
||||||
|
units: 'microWatts',
|
||||||
|
family: chip,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 7060,
|
||||||
|
plugin: 'sensors',
|
||||||
|
dimensions: [{ id: 'microwatts', name: 'microwatts', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Array<{ chartId: string, context: string, values: Record<string, number> }>}
|
||||||
|
*/
|
||||||
|
export function sampleSensors() {
|
||||||
|
/** @type {Array<{ chartId: string, context: string, values: Record<string, number> }>} */
|
||||||
|
const out = []
|
||||||
|
const hwmonRoot = '/sys/class/hwmon'
|
||||||
|
try {
|
||||||
|
const chips = fs.readdirSync(hwmonRoot)
|
||||||
|
for (const chip of chips) {
|
||||||
|
const dir = path.join(hwmonRoot, chip)
|
||||||
|
const name = (readFile(path.join(dir, 'name')) || chip).trim()
|
||||||
|
let ents
|
||||||
|
try {
|
||||||
|
ents = fs.readdirSync(dir)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const ent of ents) {
|
||||||
|
const m = ent.match(/^temp(\d+)_input$/)
|
||||||
|
if (!m) continue
|
||||||
|
const n = m[1]
|
||||||
|
const raw = readFile(path.join(dir, ent))
|
||||||
|
if (raw == null) continue
|
||||||
|
const milli = Number(raw.trim())
|
||||||
|
if (!Number.isFinite(milli)) continue
|
||||||
|
const label =
|
||||||
|
(readFile(path.join(dir, `temp${n}_label`)) || `temp${n}`).trim() || `temp${n}`
|
||||||
|
const def = makeSensorTempChart(name, label)
|
||||||
|
registerChart(def)
|
||||||
|
out.push({
|
||||||
|
chartId: def.id,
|
||||||
|
context: def.context,
|
||||||
|
values: { temperature: milli / 1000 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const fan = ent.match(/^fan(\d+)_input$/)
|
||||||
|
if (fan) {
|
||||||
|
const n = fan[1]
|
||||||
|
const raw = readFile(path.join(dir, ent))
|
||||||
|
if (raw == null) continue
|
||||||
|
const rpm = Number(raw.trim())
|
||||||
|
if (!Number.isFinite(rpm)) continue
|
||||||
|
const label =
|
||||||
|
(readFile(path.join(dir, `fan${n}_label`)) || `fan${n}`).trim() || `fan${n}`
|
||||||
|
const def = makeSensorFanChart(name, label)
|
||||||
|
registerChart(def)
|
||||||
|
out.push({ chartId: def.id, context: def.context, values: { rpm } })
|
||||||
|
}
|
||||||
|
const vin = ent.match(/^in(\d+)_input$/)
|
||||||
|
if (vin) {
|
||||||
|
const n = vin[1]
|
||||||
|
const raw = readFile(path.join(dir, ent))
|
||||||
|
if (raw == null) continue
|
||||||
|
const mv = Number(raw.trim())
|
||||||
|
if (!Number.isFinite(mv)) continue
|
||||||
|
const label =
|
||||||
|
(readFile(path.join(dir, `in${n}_label`)) || `in${n}`).trim() || `in${n}`
|
||||||
|
const def = makeSensorVoltageChart(name, label)
|
||||||
|
registerChart(def)
|
||||||
|
out.push({ chartId: def.id, context: def.context, values: { millivolts: mv } })
|
||||||
|
}
|
||||||
|
const pwr = ent.match(/^power(\d+)_input$/)
|
||||||
|
if (pwr) {
|
||||||
|
const n = pwr[1]
|
||||||
|
const raw = readFile(path.join(dir, ent))
|
||||||
|
if (raw == null) continue
|
||||||
|
const uw = Number(raw.trim())
|
||||||
|
if (!Number.isFinite(uw)) continue
|
||||||
|
const label =
|
||||||
|
(readFile(path.join(dir, `power${n}_label`)) || `power${n}`).trim() || `power${n}`
|
||||||
|
const def = makeSensorPowerChart(name, label)
|
||||||
|
registerChart(def)
|
||||||
|
out.push({ chartId: def.id, context: def.context, values: { microwatts: uw } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no hwmon
|
||||||
|
}
|
||||||
|
|
||||||
|
const thermalRoot = '/sys/class/thermal'
|
||||||
|
try {
|
||||||
|
for (const zone of fs.readdirSync(thermalRoot)) {
|
||||||
|
if (!zone.startsWith('thermal_zone')) continue
|
||||||
|
const dir = path.join(thermalRoot, zone)
|
||||||
|
const type = (readFile(path.join(dir, 'type')) || zone).trim()
|
||||||
|
const raw = readFile(path.join(dir, 'temp'))
|
||||||
|
if (raw == null) continue
|
||||||
|
const milli = Number(raw.trim())
|
||||||
|
if (!Number.isFinite(milli) || milli === 0) continue
|
||||||
|
// some platforms report already in C
|
||||||
|
const celsius = milli > 1000 ? milli / 1000 : milli
|
||||||
|
const def = makeThermalZoneChart(zone, type)
|
||||||
|
registerChart(def)
|
||||||
|
out.push({ chartId: def.id, context: def.context, values: { temperature: celsius } })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no thermal
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SensorsCollector extends EventEmitter {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super()
|
||||||
|
this.intervalMs =
|
||||||
|
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
|
||||||
|
this.timer = null
|
||||||
|
this.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running) return
|
||||||
|
this.running = true
|
||||||
|
this._tick()
|
||||||
|
this.timer = setInterval(() => this._tick(), this.intervalMs)
|
||||||
|
if (this.timer.unref) this.timer.unref()
|
||||||
|
log.info('Sensors collector started')
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false
|
||||||
|
if (this.timer) clearInterval(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
const ts = Date.now()
|
||||||
|
const samples = sampleSensors()
|
||||||
|
if (!samples.length) return
|
||||||
|
const batch = samples.map((s) => ({
|
||||||
|
chart: s.chartId,
|
||||||
|
context: s.context,
|
||||||
|
ts,
|
||||||
|
values: s.values,
|
||||||
|
}))
|
||||||
|
this.emit('samples', batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {SensorsCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getSensorsCollector() {
|
||||||
|
if (!singleton) singleton = new SensorsCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* SMART disk health collector via smartctl.
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_SMART=1
|
||||||
|
*
|
||||||
|
* Charts: smart.temp.{dev}, smart.reallocated.{dev}
|
||||||
|
*/
|
||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import { CollectorPlugin } from './plugin.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('smart')
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
const DEVICE_TIMEOUT_MS = 2000
|
||||||
|
|
||||||
|
export function isSmartEnabled() {
|
||||||
|
const v = process.env.PEARDATA_SMART
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} dev
|
||||||
|
*/
|
||||||
|
function sanitizeDev(dev) {
|
||||||
|
return String(dev)
|
||||||
|
.replace(/[^a-zA-Z0-9_.-]/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} dev
|
||||||
|
* @param {'temp'|'reallocated'} kind
|
||||||
|
*/
|
||||||
|
function makeSmartChart(dev, kind) {
|
||||||
|
const safe = sanitizeDev(dev)
|
||||||
|
const id = kind === 'temp' ? `smart.temp.${safe}` : `smart.reallocated.${safe}`
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
context: kind === 'temp' ? 'smart.temperature' : 'smart.reallocated',
|
||||||
|
title: kind === 'temp' ? `SMART temperature ${dev}` : `SMART reallocated ${dev}`,
|
||||||
|
units: kind === 'temp' ? 'Celsius' : 'sectors',
|
||||||
|
family: safe,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: kind === 'temp' ? 7100 : 7110,
|
||||||
|
plugin: 'smart',
|
||||||
|
dimensions: [{ id: 'value', name: 'value', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} timeoutMs
|
||||||
|
* @returns {Promise<string[]|null>}
|
||||||
|
*/
|
||||||
|
export async function scanSmartDevices(timeoutMs = DEVICE_TIMEOUT_MS) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('smartctl', ['--scan'], {
|
||||||
|
timeout: timeoutMs,
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
/** @type {string[]} */
|
||||||
|
const devs = []
|
||||||
|
for (const line of String(stdout || '').split('\n')) {
|
||||||
|
const parts = line.trim().split(/\s+/)
|
||||||
|
if (parts[0] && !parts[0].startsWith('#')) devs.push(parts[0])
|
||||||
|
}
|
||||||
|
return devs.length ? devs : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} dev
|
||||||
|
* @returns {Promise<{ temp: number|null, reallocated: number|null }|null>}
|
||||||
|
*/
|
||||||
|
export async function readSmartAttributes(dev) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('smartctl', ['-A', '-j', dev], {
|
||||||
|
timeout: DEVICE_TIMEOUT_MS,
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
const json = JSON.parse(stdout)
|
||||||
|
const attrs = json?.ata_smart_attributes?.table || json?.table || []
|
||||||
|
let temp = null
|
||||||
|
let reallocated = null
|
||||||
|
for (const a of attrs) {
|
||||||
|
const id = a.id ?? a.attr_id
|
||||||
|
const val = a.raw?.value ?? a.raw_value ?? a.value
|
||||||
|
if (id === 194 || a.name === 'Temperature_Celsius') temp = Number(val)
|
||||||
|
if (id === 5 || a.name === 'Reallocated_Sector_Ct') reallocated = Number(val)
|
||||||
|
}
|
||||||
|
if (temp == null && json?.temperature?.current != null) temp = Number(json.temperature.current)
|
||||||
|
return { temp: Number.isFinite(temp) ? temp : null, reallocated: Number.isFinite(reallocated) ? reallocated : null }
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('smartctl', ['-A', dev], {
|
||||||
|
timeout: DEVICE_TIMEOUT_MS,
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
let temp = null
|
||||||
|
let reallocated = null
|
||||||
|
for (const line of String(stdout || '').split('\n')) {
|
||||||
|
const m = line.match(/^\s*(\d+)\s+(\S+)\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(\d+)/)
|
||||||
|
if (!m) continue
|
||||||
|
const id = Number(m[1])
|
||||||
|
const val = Number(m[3])
|
||||||
|
if (id === 194) temp = val
|
||||||
|
if (id === 5) reallocated = val
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
temp: Number.isFinite(temp) ? temp : null,
|
||||||
|
reallocated: Number.isFinite(reallocated) ? reallocated : null,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SmartCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'smart', intervalMs: opts.intervalMs })
|
||||||
|
/** @type {string[]|null} */
|
||||||
|
this.devices = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isSmartEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
log.info('SMART collector started')
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
if (this.devices === null) {
|
||||||
|
this.devices = (await scanSmartDevices()) || []
|
||||||
|
if (!this.devices.length) {
|
||||||
|
log.info('smartctl unavailable or no devices — collector idle')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ts = Date.now()
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||||
|
const batch = []
|
||||||
|
for (const dev of this.devices) {
|
||||||
|
const attrs = await readSmartAttributes(dev)
|
||||||
|
if (!attrs) continue
|
||||||
|
if (attrs.temp != null) {
|
||||||
|
const def = makeSmartChart(dev, 'temp')
|
||||||
|
registerChart(def)
|
||||||
|
batch.push({
|
||||||
|
chart: def.id,
|
||||||
|
context: def.context,
|
||||||
|
ts,
|
||||||
|
values: { value: attrs.temp },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (attrs.reallocated != null) {
|
||||||
|
const def = makeSmartChart(dev, 'reallocated')
|
||||||
|
registerChart(def)
|
||||||
|
batch.push({
|
||||||
|
chart: def.id,
|
||||||
|
context: def.context,
|
||||||
|
ts,
|
||||||
|
values: { value: attrs.reallocated },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {SmartCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getSmartCollector() {
|
||||||
|
if (!singleton) singleton = new SmartCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* StatsD UDP ingest — maps gauges/counters into prom-like charts.
|
||||||
|
* Enable: PEARDATA_STATSD=1
|
||||||
|
* Bind: PEARDATA_STATSD_PORT=8125 (default)
|
||||||
|
*/
|
||||||
|
import dgram from 'dgram'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { SAMPLE_INTERVAL_MS, registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('statsd')
|
||||||
|
|
||||||
|
export function isStatsdEnabled() {
|
||||||
|
const v = process.env.PEARDATA_STATSD
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize(name) {
|
||||||
|
return String(name)
|
||||||
|
.replace(/[^a-zA-Z0-9_.-]/g, '_')
|
||||||
|
.slice(0, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StatsdCollector extends EventEmitter {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super()
|
||||||
|
this.port = opts.port ?? (Number(process.env.PEARDATA_STATSD_PORT) || 8125)
|
||||||
|
this.intervalMs =
|
||||||
|
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
|
||||||
|
/** @type {Map<string, number>} */
|
||||||
|
this.values = new Map()
|
||||||
|
this.socket = null
|
||||||
|
this.timer = null
|
||||||
|
this.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running) return
|
||||||
|
this.running = true
|
||||||
|
this.socket = dgram.createSocket('udp4')
|
||||||
|
this.socket.on('message', (msg) => this._onMessage(msg.toString('utf8')))
|
||||||
|
this.socket.on('error', (err) => log.warn('StatsD socket error', { error: err.message }))
|
||||||
|
this.socket.bind(this.port, '127.0.0.1', () => {
|
||||||
|
log.info('StatsD listening', { port: this.port })
|
||||||
|
})
|
||||||
|
this.timer = setInterval(() => this._flush(), this.intervalMs)
|
||||||
|
if (this.timer.unref) this.timer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false
|
||||||
|
if (this.timer) clearInterval(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
try {
|
||||||
|
this.socket?.close()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
this.socket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMessage(text) {
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
// name:value|type
|
||||||
|
const m = line.trim().match(/^([^:]+):(-?[\d.]+)\|([a-z]+)(?:\|@[\d.]+)?$/i)
|
||||||
|
if (!m) continue
|
||||||
|
const name = sanitize(m[1])
|
||||||
|
const value = Number(m[2])
|
||||||
|
const type = m[3].toLowerCase()
|
||||||
|
if (!Number.isFinite(value)) continue
|
||||||
|
if (type === 'c') {
|
||||||
|
this.values.set(name, (this.values.get(name) || 0) + value)
|
||||||
|
} else {
|
||||||
|
this.values.set(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_flush() {
|
||||||
|
if (!this.values.size) return
|
||||||
|
const ts = Date.now()
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: object }>} */
|
||||||
|
const batch = []
|
||||||
|
let n = 0
|
||||||
|
for (const [name, value] of this.values) {
|
||||||
|
if (n++ >= 64) break
|
||||||
|
const id = `statsd.${name}`
|
||||||
|
registerChart({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
context: 'statsd.metric',
|
||||||
|
title: `StatsD ${name}`,
|
||||||
|
units: 'value',
|
||||||
|
family: 'statsd',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8500,
|
||||||
|
plugin: 'statsd',
|
||||||
|
dimensions: [{ id: 'value', name: 'value', algorithm: 'absolute' }],
|
||||||
|
})
|
||||||
|
batch.push({ chart: id, context: 'statsd.metric', ts, values: { value } })
|
||||||
|
}
|
||||||
|
if (batch.length) this.emit('samples', batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {StatsdCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
export function getStatsdCollector() {
|
||||||
|
if (!singleton) singleton = new StatsdCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
/**
|
||||||
|
* Extended host chart catalog (deep /proc + /sys coverage).
|
||||||
|
* Merged into STATIC_CHART_DEFS by shared/metrics.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @typedef {import('./metrics.js').ChartDef} ChartDef */
|
||||||
|
|
||||||
|
/** @type {ChartDef[]} */
|
||||||
|
export const DEEP_STATIC_CHARTS = [
|
||||||
|
// ── Memory deep ───────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'mem.zswap',
|
||||||
|
name: 'mem.zswap',
|
||||||
|
context: 'mem.zswap',
|
||||||
|
title: 'Zswap usage',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'zswap',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 248,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'in_ram', name: 'in_ram', algorithm: 'absolute' },
|
||||||
|
{ id: 'on_disk', name: 'on_disk', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.hwcorrupt',
|
||||||
|
name: 'mem.hwcorrupt',
|
||||||
|
context: 'mem.hwcorrupt',
|
||||||
|
title: 'Hardware corrupted memory',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'hardware',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 249,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'HardwareCorrupted', name: 'HardwareCorrupted', algorithm: 'absolute' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.hugepages',
|
||||||
|
name: 'mem.hugepages',
|
||||||
|
context: 'mem.hugepages',
|
||||||
|
title: 'HugePages',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'hugepages',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 252,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'free', name: 'free', algorithm: 'absolute' },
|
||||||
|
{ id: 'used', name: 'used', algorithm: 'absolute' },
|
||||||
|
{ id: 'surplus', name: 'surplus', algorithm: 'absolute' },
|
||||||
|
{ id: 'reserved', name: 'reserved', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.thp',
|
||||||
|
name: 'mem.thp',
|
||||||
|
context: 'mem.thp',
|
||||||
|
title: 'Transparent HugePages',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'thp',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 253,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'anonymous', name: 'anonymous', algorithm: 'absolute' },
|
||||||
|
{ id: 'shmem', name: 'shmem', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.thp_details',
|
||||||
|
name: 'mem.thp_details',
|
||||||
|
context: 'mem.thp_details',
|
||||||
|
title: 'Transparent HugePages details',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'thp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 254,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'ShmemPmdMapped', name: 'ShmemPmdMapped', algorithm: 'absolute' },
|
||||||
|
{ id: 'FileHugePages', name: 'FileHugePages', algorithm: 'absolute' },
|
||||||
|
{ id: 'FilePmdMapped', name: 'FilePmdMapped', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.reclaiming',
|
||||||
|
name: 'mem.reclaiming',
|
||||||
|
context: 'mem.reclaiming',
|
||||||
|
title: 'Memory reclaiming (LRU)',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'reclaiming',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 255,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'Active', name: 'Active', algorithm: 'absolute' },
|
||||||
|
{ id: 'Inactive', name: 'Inactive', algorithm: 'absolute' },
|
||||||
|
{ id: 'Active_anon', name: 'Active_anon', algorithm: 'absolute' },
|
||||||
|
{ id: 'Inactive_anon', name: 'Inactive_anon', algorithm: 'absolute' },
|
||||||
|
{ id: 'Active_file', name: 'Active_file', algorithm: 'absolute' },
|
||||||
|
{ id: 'Inactive_file', name: 'Inactive_file', algorithm: 'absolute' },
|
||||||
|
{ id: 'Unevictable', name: 'Unevictable', algorithm: 'absolute' },
|
||||||
|
{ id: 'Mlocked', name: 'Mlocked', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.oom_kill',
|
||||||
|
name: 'mem.oom_kill',
|
||||||
|
context: 'mem.oom_kill',
|
||||||
|
title: 'OOM kills',
|
||||||
|
units: 'kills/s',
|
||||||
|
family: 'oom',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 256,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'kills', name: 'kills', algorithm: 'incremental' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.balloon',
|
||||||
|
name: 'mem.balloon',
|
||||||
|
context: 'mem.balloon',
|
||||||
|
title: 'Memory balloon',
|
||||||
|
units: 'KiB/s',
|
||||||
|
family: 'balloon',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 257,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'inflate', name: 'inflate', algorithm: 'incremental' },
|
||||||
|
{ id: 'deflate', name: 'deflate', algorithm: 'incremental' },
|
||||||
|
{ id: 'migrate', name: 'migrate', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.zswapio',
|
||||||
|
name: 'mem.zswapio',
|
||||||
|
context: 'mem.zswapio',
|
||||||
|
title: 'Zswap I/O',
|
||||||
|
units: 'KiB/s',
|
||||||
|
family: 'zswap',
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 258,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'in', name: 'in', algorithm: 'incremental' },
|
||||||
|
{ id: 'out', name: 'out', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.numa',
|
||||||
|
name: 'mem.numa',
|
||||||
|
context: 'mem.numa',
|
||||||
|
title: 'NUMA events',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'numa',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 259,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'local', name: 'local', algorithm: 'incremental' },
|
||||||
|
{ id: 'foreign', name: 'foreign', algorithm: 'incremental' },
|
||||||
|
{ id: 'interleave', name: 'interleave', algorithm: 'incremental' },
|
||||||
|
{ id: 'other', name: 'other', algorithm: 'incremental' },
|
||||||
|
{ id: 'pte_updates', name: 'pte_updates', algorithm: 'incremental' },
|
||||||
|
{ id: 'pages_migrated', name: 'pages_migrated', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.thp_faults',
|
||||||
|
name: 'mem.thp_faults',
|
||||||
|
context: 'mem.thp_faults',
|
||||||
|
title: 'THP fault events',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'thp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 260,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'alloc', name: 'alloc', algorithm: 'incremental' },
|
||||||
|
{ id: 'fallback', name: 'fallback', algorithm: 'incremental' },
|
||||||
|
{ id: 'fallback_charge', name: 'fallback_charge', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.thp_split',
|
||||||
|
name: 'mem.thp_split',
|
||||||
|
context: 'mem.thp_split',
|
||||||
|
title: 'THP split events',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'thp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 261,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'split', name: 'split', algorithm: 'incremental' },
|
||||||
|
{ id: 'failed', name: 'failed', algorithm: 'incremental' },
|
||||||
|
{ id: 'split_pmd', name: 'split_pmd', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.ksm',
|
||||||
|
name: 'mem.ksm',
|
||||||
|
context: 'mem.ksm',
|
||||||
|
title: 'Kernel Same-page Merging',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'ksm',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 262,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'shared', name: 'shared', algorithm: 'absolute' },
|
||||||
|
{ id: 'sharing', name: 'sharing', algorithm: 'absolute' },
|
||||||
|
{ id: 'unshared', name: 'unshared', algorithm: 'absolute' },
|
||||||
|
{ id: 'volatile', name: 'volatile', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.ksm_cow',
|
||||||
|
name: 'mem.ksm_cow',
|
||||||
|
context: 'mem.ksm_cow',
|
||||||
|
title: 'KSM copy-on-write',
|
||||||
|
units: 'KiB/s',
|
||||||
|
family: 'ksm',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 263,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'swapin', name: 'swapin', algorithm: 'incremental' },
|
||||||
|
{ id: 'write', name: 'write', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── PSI full + stall ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'system.cpu_full_pressure',
|
||||||
|
name: 'system.cpu_full_pressure',
|
||||||
|
context: 'system.cpu_full_pressure',
|
||||||
|
title: 'CPU Full Pressure',
|
||||||
|
units: 'percentage',
|
||||||
|
family: 'pressure',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 801,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'full10', name: 'full10', algorithm: 'absolute' },
|
||||||
|
{ id: 'full60', name: 'full60', algorithm: 'absolute' },
|
||||||
|
{ id: 'full300', name: 'full300', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system.memory_full_pressure',
|
||||||
|
name: 'system.memory_full_pressure',
|
||||||
|
context: 'system.memory_full_pressure',
|
||||||
|
title: 'Memory Full Pressure',
|
||||||
|
units: 'percentage',
|
||||||
|
family: 'pressure',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 811,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'full10', name: 'full10', algorithm: 'absolute' },
|
||||||
|
{ id: 'full60', name: 'full60', algorithm: 'absolute' },
|
||||||
|
{ id: 'full300', name: 'full300', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system.io_full_pressure',
|
||||||
|
name: 'system.io_full_pressure',
|
||||||
|
context: 'system.io_full_pressure',
|
||||||
|
title: 'I/O Full Pressure',
|
||||||
|
units: 'percentage',
|
||||||
|
family: 'pressure',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 821,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'full10', name: 'full10', algorithm: 'absolute' },
|
||||||
|
{ id: 'full60', name: 'full60', algorithm: 'absolute' },
|
||||||
|
{ id: 'full300', name: 'full300', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Softnet / ICMP / TCP advanced ─────────────────────────
|
||||||
|
{
|
||||||
|
id: 'system.softnet_stat',
|
||||||
|
name: 'system.softnet_stat',
|
||||||
|
context: 'system.softnet_stat',
|
||||||
|
title: 'Softnet statistics',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'softnet',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 780,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'processed', name: 'processed', algorithm: 'incremental' },
|
||||||
|
{ id: 'dropped', name: 'dropped', algorithm: 'incremental' },
|
||||||
|
{ id: 'squeezed', name: 'squeezed', algorithm: 'incremental' },
|
||||||
|
{ id: 'received_rps', name: 'received_rps', algorithm: 'incremental' },
|
||||||
|
{ id: 'flow_limit', name: 'flow_limit', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ipv4.icmp',
|
||||||
|
name: 'ipv4.icmp',
|
||||||
|
context: 'ipv4.icmp',
|
||||||
|
title: 'IPv4 ICMP messages',
|
||||||
|
units: 'messages/s',
|
||||||
|
family: 'icmp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 755,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'received', name: 'received', algorithm: 'incremental' },
|
||||||
|
{ id: 'sent', name: 'sent', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ipv4.icmp_errors',
|
||||||
|
name: 'ipv4.icmp_errors',
|
||||||
|
context: 'ipv4.icmp_errors',
|
||||||
|
title: 'IPv4 ICMP errors',
|
||||||
|
units: 'messages/s',
|
||||||
|
family: 'icmp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 756,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'InErrors', name: 'InErrors', algorithm: 'incremental' },
|
||||||
|
{ id: 'OutErrors', name: 'OutErrors', algorithm: 'incremental' },
|
||||||
|
{ id: 'InCsumErrors', name: 'InCsumErrors', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip.tcpsyncookies',
|
||||||
|
name: 'ip.tcpsyncookies',
|
||||||
|
context: 'ip.tcpsyncookies',
|
||||||
|
title: 'TCP SYN cookies',
|
||||||
|
units: 'cookies/s',
|
||||||
|
family: 'tcp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 730,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'received', name: 'received', algorithm: 'incremental' },
|
||||||
|
{ id: 'sent', name: 'sent', algorithm: 'incremental' },
|
||||||
|
{ id: 'failed', name: 'failed', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip.tcp_syn_queue',
|
||||||
|
name: 'ip.tcp_syn_queue',
|
||||||
|
context: 'ip.tcp_syn_queue',
|
||||||
|
title: 'TCP SYN queue',
|
||||||
|
units: 'packets/s',
|
||||||
|
family: 'tcp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 731,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'drops', name: 'drops', algorithm: 'incremental' },
|
||||||
|
{ id: 'cookies', name: 'cookies', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip.tcp_accept_queue',
|
||||||
|
name: 'ip.tcp_accept_queue',
|
||||||
|
context: 'ip.tcp_accept_queue',
|
||||||
|
title: 'TCP accept queue',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'tcp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 732,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'overflows', name: 'overflows', algorithm: 'incremental' },
|
||||||
|
{ id: 'drops', name: 'drops', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip.tcpconnaborts',
|
||||||
|
name: 'ip.tcpconnaborts',
|
||||||
|
context: 'ip.tcpconnaborts',
|
||||||
|
title: 'TCP connection aborts',
|
||||||
|
units: 'aborts/s',
|
||||||
|
family: 'tcp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 733,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'baddata', name: 'baddata', algorithm: 'incremental' },
|
||||||
|
{ id: 'userclosed', name: 'userclosed', algorithm: 'incremental' },
|
||||||
|
{ id: 'nomemory', name: 'nomemory', algorithm: 'incremental' },
|
||||||
|
{ id: 'timeout', name: 'timeout', algorithm: 'incremental' },
|
||||||
|
{ id: 'linger', name: 'linger', algorithm: 'incremental' },
|
||||||
|
{ id: 'failed', name: 'failed', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip.tcpofo',
|
||||||
|
name: 'ip.tcpofo',
|
||||||
|
context: 'ip.tcpofo',
|
||||||
|
title: 'TCP out-of-order queue',
|
||||||
|
units: 'packets/s',
|
||||||
|
family: 'tcp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 734,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'inqueue', name: 'inqueue', algorithm: 'incremental' },
|
||||||
|
{ id: 'dropped', name: 'dropped', algorithm: 'incremental' },
|
||||||
|
{ id: 'merged', name: 'merged', algorithm: 'incremental' },
|
||||||
|
{ id: 'pruned', name: 'pruned', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip.tcpreorders',
|
||||||
|
name: 'ip.tcpreorders',
|
||||||
|
context: 'ip.tcpreorders',
|
||||||
|
title: 'TCP reorders',
|
||||||
|
units: 'packets/s',
|
||||||
|
family: 'tcp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 735,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'timestamp', name: 'timestamp', algorithm: 'incremental' },
|
||||||
|
{ id: 'sack', name: 'sack', algorithm: 'incremental' },
|
||||||
|
{ id: 'fack', name: 'fack', algorithm: 'incremental' },
|
||||||
|
{ id: 'reno', name: 'reno', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip.tcphandshake',
|
||||||
|
name: 'ip.tcphandshake',
|
||||||
|
context: 'ip.tcphandshake',
|
||||||
|
title: 'TCP handshake issues',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'tcp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 736,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'EstabResets', name: 'EstabResets', algorithm: 'incremental' },
|
||||||
|
{ id: 'OutRsts', name: 'OutRsts', algorithm: 'incremental' },
|
||||||
|
{ id: 'AttemptFails', name: 'AttemptFails', algorithm: 'incremental' },
|
||||||
|
{ id: 'SynRetrans', name: 'SynRetrans', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip.tcpmemorypressures',
|
||||||
|
name: 'ip.tcpmemorypressures',
|
||||||
|
context: 'ip.tcpmemorypressures',
|
||||||
|
title: 'TCP memory pressures',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'tcp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 737,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'pressures', name: 'pressures', algorithm: 'incremental' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function makeDiskAwaitChart(disk) {
|
||||||
|
return {
|
||||||
|
id: `disk_await.${disk}`,
|
||||||
|
name: `disk_await.${disk}`,
|
||||||
|
context: 'disk.await',
|
||||||
|
title: `Disk ${disk} average wait time`,
|
||||||
|
units: 'milliseconds/operation',
|
||||||
|
family: disk,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 2030,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'reads', name: 'reads', algorithm: 'absolute' },
|
||||||
|
{ id: 'writes', name: 'writes', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDiskAvgszChart(disk) {
|
||||||
|
return {
|
||||||
|
id: `disk_avgsz.${disk}`,
|
||||||
|
name: `disk_avgsz.${disk}`,
|
||||||
|
context: 'disk.avgsz',
|
||||||
|
title: `Disk ${disk} average operation size`,
|
||||||
|
units: 'KiB/operation',
|
||||||
|
family: disk,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 2040,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'reads', name: 'reads', algorithm: 'absolute' },
|
||||||
|
{ id: 'writes', name: 'writes', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDiskQopsChart(disk) {
|
||||||
|
return {
|
||||||
|
id: `disk_qops.${disk}`,
|
||||||
|
name: `disk_qops.${disk}`,
|
||||||
|
context: 'disk.qops',
|
||||||
|
title: `Disk ${disk} queued operations`,
|
||||||
|
units: 'operations',
|
||||||
|
family: disk,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 2050,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'operations', name: 'operations', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDiskBusyChart(disk) {
|
||||||
|
return {
|
||||||
|
id: `disk_busy.${disk}`,
|
||||||
|
name: `disk_busy.${disk}`,
|
||||||
|
context: 'disk.busy',
|
||||||
|
title: `Disk ${disk} busy time`,
|
||||||
|
units: 'milliseconds',
|
||||||
|
family: disk,
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 2060,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'busy', name: 'busy', algorithm: 'incremental' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDiskIotimeChart(disk) {
|
||||||
|
return {
|
||||||
|
id: `disk_iotime.${disk}`,
|
||||||
|
name: `disk_iotime.${disk}`,
|
||||||
|
context: 'disk.iotime',
|
||||||
|
title: `Disk ${disk} I/O time`,
|
||||||
|
units: 'milliseconds/s',
|
||||||
|
family: disk,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 2070,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'reads', name: 'reads', algorithm: 'incremental' },
|
||||||
|
{ id: 'writes', name: 'writes', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDiskDiscardChart(disk) {
|
||||||
|
return {
|
||||||
|
id: `disk_discard.${disk}`,
|
||||||
|
name: `disk_discard.${disk}`,
|
||||||
|
context: 'disk.discard',
|
||||||
|
title: `Disk ${disk} discards`,
|
||||||
|
units: 'operations/s',
|
||||||
|
family: disk,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 2080,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'operations', name: 'operations', algorithm: 'incremental' },
|
||||||
|
{ id: 'sectors', name: 'sectors', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeNetSpeedChart(iface) {
|
||||||
|
return {
|
||||||
|
id: `net_speed.${iface}`,
|
||||||
|
name: `net_speed.${iface}`,
|
||||||
|
context: 'net.speed',
|
||||||
|
title: `Interface ${iface} link speed`,
|
||||||
|
units: 'kilobits/s',
|
||||||
|
family: iface,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 3040,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'speed', name: 'speed', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCpuFreqChart() {
|
||||||
|
return {
|
||||||
|
id: 'cpu.cpufreq',
|
||||||
|
name: 'cpu.cpufreq',
|
||||||
|
context: 'cpu.cpufreq',
|
||||||
|
title: 'CPU frequency',
|
||||||
|
units: 'MHz',
|
||||||
|
family: 'cpufreq',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 180,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCgroupCpuChart(id, title) {
|
||||||
|
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||||
|
return {
|
||||||
|
id: `cgroup.cpu.${safe}`,
|
||||||
|
name: `cgroup.cpu.${safe}`,
|
||||||
|
context: 'cgroup.cpu',
|
||||||
|
title: `Cgroup CPU ${title || safe}`,
|
||||||
|
units: 'percentage',
|
||||||
|
family: title || safe,
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 6000,
|
||||||
|
plugin: 'cgroups',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'user', name: 'user', algorithm: 'absolute' },
|
||||||
|
{ id: 'system', name: 'system', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCgroupMemChart(id, title) {
|
||||||
|
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||||
|
return {
|
||||||
|
id: `cgroup.mem.${safe}`,
|
||||||
|
name: `cgroup.mem.${safe}`,
|
||||||
|
context: 'cgroup.mem',
|
||||||
|
title: `Cgroup memory ${title || safe}`,
|
||||||
|
units: 'MiB',
|
||||||
|
family: title || safe,
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 6100,
|
||||||
|
plugin: 'cgroups',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'usage', name: 'usage', algorithm: 'absolute' },
|
||||||
|
{ id: 'limit', name: 'limit', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCgroupIoChart(id, title) {
|
||||||
|
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||||
|
return {
|
||||||
|
id: `cgroup.io.${safe}`,
|
||||||
|
name: `cgroup.io.${safe}`,
|
||||||
|
context: 'cgroup.io',
|
||||||
|
title: `Cgroup I/O ${title || safe}`,
|
||||||
|
units: 'KiB/s',
|
||||||
|
family: title || safe,
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 6200,
|
||||||
|
plugin: 'cgroups',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'read', name: 'read', algorithm: 'incremental' },
|
||||||
|
{ id: 'write', name: 'write', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSensorTempChart(chip, label) {
|
||||||
|
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
||||||
|
return {
|
||||||
|
id: `sensors.temp.${id}`,
|
||||||
|
name: `sensors.temp.${id}`,
|
||||||
|
context: 'sensors.temperature',
|
||||||
|
title: `Temperature ${chip} ${label}`,
|
||||||
|
units: 'Celsius',
|
||||||
|
family: chip,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 7000,
|
||||||
|
plugin: 'sensors',
|
||||||
|
dimensions: [{ id: 'temperature', name: 'temperature', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeThermalZoneChart(zone, type) {
|
||||||
|
const id = String(zone).replace(/[^a-zA-Z0-9_.-]/g, '_')
|
||||||
|
return {
|
||||||
|
id: `sensors.thermal.${id}`,
|
||||||
|
name: `sensors.thermal.${id}`,
|
||||||
|
context: 'sensors.thermal',
|
||||||
|
title: `Thermal zone ${type || id}`,
|
||||||
|
units: 'Celsius',
|
||||||
|
family: type || id,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 7010,
|
||||||
|
plugin: 'sensors',
|
||||||
|
dimensions: [{ id: 'temperature', name: 'temperature', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
/**
|
||||||
|
* Remaining host / app chart definitions (merged into STATIC_CHART_DEFS).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {import('./metrics.js').ChartDef[]} */
|
||||||
|
export const MORE_STATIC_CHARTS = [
|
||||||
|
// Memory extras
|
||||||
|
{
|
||||||
|
id: 'mem.high_low',
|
||||||
|
name: 'mem.high_low',
|
||||||
|
context: 'mem.high_low',
|
||||||
|
title: 'High / low memory watermarks',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'watermarks',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 264,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'high', name: 'high', algorithm: 'absolute' },
|
||||||
|
{ id: 'low', name: 'low', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.cma',
|
||||||
|
name: 'mem.cma',
|
||||||
|
context: 'mem.cma',
|
||||||
|
title: 'Contiguous Memory Allocator',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'cma',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 265,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'total', name: 'total', algorithm: 'absolute' },
|
||||||
|
{ id: 'free', name: 'free', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.directmaps',
|
||||||
|
name: 'mem.directmaps',
|
||||||
|
context: 'mem.directmaps',
|
||||||
|
title: 'DirectMap page sizes',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'directmap',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 266,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: '4k', name: '4k', algorithm: 'absolute' },
|
||||||
|
{ id: '2M', name: '2M', algorithm: 'absolute' },
|
||||||
|
{ id: '1G', name: '1G', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.ksm_savings',
|
||||||
|
name: 'mem.ksm_savings',
|
||||||
|
context: 'mem.ksm_savings',
|
||||||
|
title: 'KSM memory savings',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'ksm',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 267,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'offered', name: 'offered', algorithm: 'absolute' },
|
||||||
|
{ id: 'saved', name: 'saved', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.ksm_ratios',
|
||||||
|
name: 'mem.ksm_ratios',
|
||||||
|
context: 'mem.ksm_ratios',
|
||||||
|
title: 'KSM savings ratio',
|
||||||
|
units: 'percentage',
|
||||||
|
family: 'ksm',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 268,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'savings', name: 'savings', algorithm: 'absolute' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.thp_file',
|
||||||
|
name: 'mem.thp_file',
|
||||||
|
context: 'mem.thp_file',
|
||||||
|
title: 'THP file events',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'thp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 269,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'alloc', name: 'alloc', algorithm: 'incremental' },
|
||||||
|
{ id: 'fallback', name: 'fallback', algorithm: 'incremental' },
|
||||||
|
{ id: 'mapped', name: 'mapped', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.thp_zero',
|
||||||
|
name: 'mem.thp_zero',
|
||||||
|
context: 'mem.thp_zero',
|
||||||
|
title: 'THP zero-page events',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'thp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 270,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'alloc', name: 'alloc', algorithm: 'incremental' },
|
||||||
|
{ id: 'failed', name: 'failed', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.thp_collapse',
|
||||||
|
name: 'mem.thp_collapse',
|
||||||
|
context: 'mem.thp_collapse',
|
||||||
|
title: 'THP collapse events',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'thp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 271,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'alloc', name: 'alloc', algorithm: 'incremental' },
|
||||||
|
{ id: 'failed', name: 'failed', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.thp_swapout',
|
||||||
|
name: 'mem.thp_swapout',
|
||||||
|
context: 'mem.thp_swapout',
|
||||||
|
title: 'THP swapout events',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'thp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 272,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'swapout', name: 'swapout', algorithm: 'incremental' },
|
||||||
|
{ id: 'fallback', name: 'fallback', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.thp_compact',
|
||||||
|
name: 'mem.thp_compact',
|
||||||
|
context: 'mem.thp_compact',
|
||||||
|
title: 'Memory compaction',
|
||||||
|
units: 'events/s',
|
||||||
|
family: 'thp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 273,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'success', name: 'success', algorithm: 'incremental' },
|
||||||
|
{ id: 'fail', name: 'fail', algorithm: 'incremental' },
|
||||||
|
{ id: 'stall', name: 'stall', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.pagetype_global',
|
||||||
|
name: 'mem.pagetype_global',
|
||||||
|
context: 'mem.pagetype_global',
|
||||||
|
title: 'Free pages by order (global)',
|
||||||
|
units: 'pages',
|
||||||
|
family: 'pagetype',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 274,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: Array.from({ length: 11 }, (_, i) => ({
|
||||||
|
id: `order${i}`,
|
||||||
|
name: `order${i}`,
|
||||||
|
algorithm: 'absolute',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system.softirqs',
|
||||||
|
name: 'system.softirqs',
|
||||||
|
context: 'system.softirqs',
|
||||||
|
title: 'SoftIRQs by vector',
|
||||||
|
units: 'softirqs/s',
|
||||||
|
family: 'softirq',
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 781,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'HI', name: 'HI', algorithm: 'incremental' },
|
||||||
|
{ id: 'TIMER', name: 'TIMER', algorithm: 'incremental' },
|
||||||
|
{ id: 'NET_TX', name: 'NET_TX', algorithm: 'incremental' },
|
||||||
|
{ id: 'NET_RX', name: 'NET_RX', algorithm: 'incremental' },
|
||||||
|
{ id: 'BLOCK', name: 'BLOCK', algorithm: 'incremental' },
|
||||||
|
{ id: 'IRQ_POLL', name: 'IRQ_POLL', algorithm: 'incremental' },
|
||||||
|
{ id: 'TASKLET', name: 'TASKLET', algorithm: 'incremental' },
|
||||||
|
{ id: 'SCHED', name: 'SCHED', algorithm: 'incremental' },
|
||||||
|
{ id: 'HRTIMER', name: 'HRTIMER', algorithm: 'incremental' },
|
||||||
|
{ id: 'RCU', name: 'RCU', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ipv4.icmpmsg',
|
||||||
|
name: 'ipv4.icmpmsg',
|
||||||
|
context: 'ipv4.icmpmsg',
|
||||||
|
title: 'IPv4 ICMP message types',
|
||||||
|
units: 'messages/s',
|
||||||
|
family: 'icmp',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 757,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'InEchoes', name: 'InEchoes', algorithm: 'incremental' },
|
||||||
|
{ id: 'OutEchoes', name: 'OutEchoes', algorithm: 'incremental' },
|
||||||
|
{ id: 'InDestUnreachs', name: 'InDestUnreachs', algorithm: 'incremental' },
|
||||||
|
{ id: 'OutDestUnreachs', name: 'OutDestUnreachs', algorithm: 'incremental' },
|
||||||
|
{ id: 'InTimeExcds', name: 'InTimeExcds', algorithm: 'incremental' },
|
||||||
|
{ id: 'OutTimeExcds', name: 'OutTimeExcds', algorithm: 'incremental' },
|
||||||
|
{ id: 'InRedirects', name: 'InRedirects', algorithm: 'incremental' },
|
||||||
|
{ id: 'OutRedirects', name: 'OutRedirects', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ipv6.packets',
|
||||||
|
name: 'ipv6.packets',
|
||||||
|
context: 'ipv6.packets',
|
||||||
|
title: 'IPv6 packets',
|
||||||
|
units: 'packets/s',
|
||||||
|
family: 'ipv6',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 758,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'received', name: 'received', algorithm: 'incremental' },
|
||||||
|
{ id: 'sent', name: 'sent', algorithm: 'incremental' },
|
||||||
|
{ id: 'forwarded', name: 'forwarded', algorithm: 'incremental' },
|
||||||
|
{ id: 'delivered', name: 'delivered', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ipv6.errors',
|
||||||
|
name: 'ipv6.errors',
|
||||||
|
context: 'ipv6.errors',
|
||||||
|
title: 'IPv6 errors',
|
||||||
|
units: 'packets/s',
|
||||||
|
family: 'ipv6',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 759,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'InHdrErrors', name: 'InHdrErrors', algorithm: 'incremental' },
|
||||||
|
{ id: 'InAddrErrors', name: 'InAddrErrors', algorithm: 'incremental' },
|
||||||
|
{ id: 'InDiscards', name: 'InDiscards', algorithm: 'incremental' },
|
||||||
|
{ id: 'OutDiscards', name: 'OutDiscards', algorithm: 'incremental' },
|
||||||
|
{ id: 'InTruncatedPkts', name: 'InTruncatedPkts', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ipv6.icmp',
|
||||||
|
name: 'ipv6.icmp',
|
||||||
|
context: 'ipv6.icmp',
|
||||||
|
title: 'IPv6 ICMP',
|
||||||
|
units: 'messages/s',
|
||||||
|
family: 'ipv6',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 760,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'received', name: 'received', algorithm: 'incremental' },
|
||||||
|
{ id: 'sent', name: 'sent', algorithm: 'incremental' },
|
||||||
|
{ id: 'InErrors', name: 'InErrors', algorithm: 'incremental' },
|
||||||
|
{ id: 'OutErrors', name: 'OutErrors', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'net.conntrack',
|
||||||
|
name: 'net.conntrack',
|
||||||
|
context: 'net.conntrack',
|
||||||
|
title: 'Connection tracking',
|
||||||
|
units: 'connections',
|
||||||
|
family: 'conntrack',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 770,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'entries', name: 'entries', algorithm: 'absolute' },
|
||||||
|
{ id: 'max', name: 'max', algorithm: 'absolute' },
|
||||||
|
{ id: 'searched', name: 'searched', algorithm: 'incremental' },
|
||||||
|
{ id: 'found', name: 'found', algorithm: 'incremental' },
|
||||||
|
{ id: 'new', name: 'new', algorithm: 'incremental' },
|
||||||
|
{ id: 'invalid', name: 'invalid', algorithm: 'incremental' },
|
||||||
|
{ id: 'drop', name: 'drop', algorithm: 'incremental' },
|
||||||
|
{ id: 'insert_failed', name: 'insert_failed', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system.inotify',
|
||||||
|
name: 'system.inotify',
|
||||||
|
context: 'system.inotify',
|
||||||
|
title: 'Inotify watches',
|
||||||
|
units: 'watches',
|
||||||
|
family: 'inotify',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 790,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'max_user_watches', name: 'max_user_watches', algorithm: 'absolute' },
|
||||||
|
{ id: 'max_user_instances', name: 'max_user_instances', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'md.health',
|
||||||
|
name: 'md.health',
|
||||||
|
context: 'md.health',
|
||||||
|
title: 'MD RAID arrays',
|
||||||
|
units: 'arrays',
|
||||||
|
family: 'md',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 4500,
|
||||||
|
plugin: 'mdstat',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'active', name: 'active', algorithm: 'absolute' },
|
||||||
|
{ id: 'degraded', name: 'degraded', algorithm: 'absolute' },
|
||||||
|
{ id: 'recovering', name: 'recovering', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system.power',
|
||||||
|
name: 'system.power',
|
||||||
|
context: 'system.power',
|
||||||
|
title: 'Package power (RAPL)',
|
||||||
|
units: 'Watts',
|
||||||
|
family: 'power',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 7100,
|
||||||
|
plugin: 'powercap',
|
||||||
|
dimensions: [{ id: 'package', name: 'package', algorithm: 'absolute' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem.edac',
|
||||||
|
name: 'mem.edac',
|
||||||
|
context: 'mem.edac',
|
||||||
|
title: 'EDAC memory errors',
|
||||||
|
units: 'errors',
|
||||||
|
family: 'edac',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 275,
|
||||||
|
plugin: 'edac',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'corrected', name: 'corrected', algorithm: 'absolute' },
|
||||||
|
{ id: 'uncorrected', name: 'uncorrected', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'peardata.cpu',
|
||||||
|
name: 'peardata.cpu',
|
||||||
|
context: 'peardata.cpu',
|
||||||
|
title: 'Agent CPU usage',
|
||||||
|
units: 'percentage',
|
||||||
|
family: 'self',
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 9000,
|
||||||
|
plugin: 'self',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'user', name: 'user', algorithm: 'absolute' },
|
||||||
|
{ id: 'system', name: 'system', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'peardata.mem',
|
||||||
|
name: 'peardata.mem',
|
||||||
|
context: 'peardata.mem',
|
||||||
|
title: 'Agent memory',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'self',
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 9010,
|
||||||
|
plugin: 'self',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'rss', name: 'rss', algorithm: 'absolute' },
|
||||||
|
{ id: 'heap', name: 'heap', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'peardata.charts',
|
||||||
|
name: 'peardata.charts',
|
||||||
|
context: 'peardata.charts',
|
||||||
|
title: 'Agent chart cardinality',
|
||||||
|
units: 'charts',
|
||||||
|
family: 'self',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 9020,
|
||||||
|
plugin: 'self',
|
||||||
|
dimensions: [{ id: 'registered', name: 'registered', algorithm: 'absolute' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function makeDiskSvctmChart(disk) {
|
||||||
|
return {
|
||||||
|
id: `disk_svctm.${disk}`,
|
||||||
|
name: `disk_svctm.${disk}`,
|
||||||
|
context: 'disk.svctm',
|
||||||
|
title: `Disk ${disk} service time`,
|
||||||
|
units: 'milliseconds/operation',
|
||||||
|
family: disk,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 2090,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'svctm', name: 'svctm', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDiskMergedChart(disk) {
|
||||||
|
return {
|
||||||
|
id: `disk_merged.${disk}`,
|
||||||
|
name: `disk_merged.${disk}`,
|
||||||
|
context: 'disk.merged',
|
||||||
|
title: `Disk ${disk} merged operations`,
|
||||||
|
units: 'operations/s',
|
||||||
|
family: disk,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 2095,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'reads', name: 'reads', algorithm: 'incremental' },
|
||||||
|
{ id: 'writes', name: 'writes', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDiskFlushChart(disk) {
|
||||||
|
return {
|
||||||
|
id: `disk_flush.${disk}`,
|
||||||
|
name: `disk_flush.${disk}`,
|
||||||
|
context: 'disk.flush',
|
||||||
|
title: `Disk ${disk} flush requests`,
|
||||||
|
units: 'operations/s',
|
||||||
|
family: disk,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 2096,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'operations', name: 'operations', algorithm: 'incremental' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeNetDuplexChart(iface) {
|
||||||
|
return {
|
||||||
|
id: `net_duplex.${iface}`,
|
||||||
|
name: `net_duplex.${iface}`,
|
||||||
|
context: 'net.duplex',
|
||||||
|
title: `Interface ${iface} duplex`,
|
||||||
|
units: 'state',
|
||||||
|
family: iface,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 3045,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'full', name: 'full', algorithm: 'absolute' },
|
||||||
|
{ id: 'half', name: 'half', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeNetMtuChart(iface) {
|
||||||
|
return {
|
||||||
|
id: `net_mtu.${iface}`,
|
||||||
|
name: `net_mtu.${iface}`,
|
||||||
|
context: 'net.mtu',
|
||||||
|
title: `Interface ${iface} MTU`,
|
||||||
|
units: 'octets',
|
||||||
|
family: iface,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 3046,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'mtu', name: 'mtu', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeNetQueueChart(iface) {
|
||||||
|
return {
|
||||||
|
id: `net_queue.${iface}`,
|
||||||
|
name: `net_queue.${iface}`,
|
||||||
|
context: 'net.queue_length',
|
||||||
|
title: `Interface ${iface} TX queue length`,
|
||||||
|
units: 'packets',
|
||||||
|
family: iface,
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 3047,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'tx_queue_len', name: 'tx_queue_len', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeIrqChart(irq, name) {
|
||||||
|
const id = String(irq).replace(/[^\w.-]/g, '_')
|
||||||
|
return {
|
||||||
|
id: `system.intr_irq.${id}`,
|
||||||
|
name: `system.intr_irq.${id}`,
|
||||||
|
context: 'system.intr_irq',
|
||||||
|
title: `IRQ ${irq} ${name || ''}`.trim(),
|
||||||
|
units: 'interrupts/s',
|
||||||
|
family: 'irq',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 115,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'interrupts', name: 'interrupts', algorithm: 'incremental' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCpuIdleChart(coreId) {
|
||||||
|
return {
|
||||||
|
id: `cpuidle.cpu${coreId}`,
|
||||||
|
name: `cpuidle.cpu${coreId}`,
|
||||||
|
context: 'cpuidle.cpu_cstate_residency_time',
|
||||||
|
title: `CPU ${coreId} C-state residency`,
|
||||||
|
units: 'percentage',
|
||||||
|
family: `cpu${coreId}`,
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 190,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [{ id: 'active', name: 'active', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeNumaNodeMemChart(node) {
|
||||||
|
return {
|
||||||
|
id: `mem.numa_node.${node}`,
|
||||||
|
name: `mem.numa_node.${node}`,
|
||||||
|
context: 'mem.numa_node_mem_usage',
|
||||||
|
title: `NUMA node ${node} memory`,
|
||||||
|
units: 'MiB',
|
||||||
|
family: `node${node}`,
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 276,
|
||||||
|
plugin: 'proc',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'MemTotal', name: 'MemTotal', algorithm: 'absolute' },
|
||||||
|
{ id: 'MemFree', name: 'MemFree', algorithm: 'absolute' },
|
||||||
|
{ id: 'MemUsed', name: 'MemUsed', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCgroupMemDetailChart(id, title) {
|
||||||
|
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||||
|
return {
|
||||||
|
id: `cgroup.mem_detail.${safe}`,
|
||||||
|
name: `cgroup.mem_detail.${safe}`,
|
||||||
|
context: 'cgroup.mem_detail',
|
||||||
|
title: `Cgroup memory detail ${title || safe}`,
|
||||||
|
units: 'MiB',
|
||||||
|
family: title || safe,
|
||||||
|
chartType: 'stacked',
|
||||||
|
priority: 6110,
|
||||||
|
plugin: 'cgroups',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'anon', name: 'anon', algorithm: 'absolute' },
|
||||||
|
{ id: 'file', name: 'file', algorithm: 'absolute' },
|
||||||
|
{ id: 'kernel', name: 'kernel', algorithm: 'absolute' },
|
||||||
|
{ id: 'sock', name: 'sock', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCgroupThrottleChart(id, title) {
|
||||||
|
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||||
|
return {
|
||||||
|
id: `cgroup.throttle.${safe}`,
|
||||||
|
name: `cgroup.throttle.${safe}`,
|
||||||
|
context: 'cgroup.cpu_throttle',
|
||||||
|
title: `Cgroup CPU throttle ${title || safe}`,
|
||||||
|
units: 'percentage',
|
||||||
|
family: title || safe,
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 6010,
|
||||||
|
plugin: 'cgroups',
|
||||||
|
dimensions: [{ id: 'throttled', name: 'throttled', algorithm: 'absolute' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,40 @@
|
|||||||
* (per-CPU, per-disk, per-iface, per-mount) are registered at runtime.
|
* (per-CPU, per-disk, per-iface, per-mount) are registered at runtime.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEEP_STATIC_CHARTS } from './metrics-deep.js'
|
||||||
|
import { MORE_STATIC_CHARTS } from './metrics-more.js'
|
||||||
|
|
||||||
export const SAMPLE_INTERVAL_MS = 1000
|
export const SAMPLE_INTERVAL_MS = 1000
|
||||||
|
|
||||||
|
export {
|
||||||
|
makeDiskAwaitChart,
|
||||||
|
makeDiskAvgszChart,
|
||||||
|
makeDiskQopsChart,
|
||||||
|
makeDiskBusyChart,
|
||||||
|
makeDiskIotimeChart,
|
||||||
|
makeDiskDiscardChart,
|
||||||
|
makeNetSpeedChart,
|
||||||
|
makeCgroupCpuChart,
|
||||||
|
makeCgroupMemChart,
|
||||||
|
makeCgroupIoChart,
|
||||||
|
makeSensorTempChart,
|
||||||
|
makeThermalZoneChart,
|
||||||
|
} from './metrics-deep.js'
|
||||||
|
|
||||||
|
export {
|
||||||
|
makeDiskSvctmChart,
|
||||||
|
makeDiskMergedChart,
|
||||||
|
makeDiskFlushChart,
|
||||||
|
makeNetDuplexChart,
|
||||||
|
makeNetMtuChart,
|
||||||
|
makeNetQueueChart,
|
||||||
|
makeIrqChart,
|
||||||
|
makeCpuIdleChart,
|
||||||
|
makeNumaNodeMemChart,
|
||||||
|
makeCgroupMemDetailChart,
|
||||||
|
makeCgroupThrottleChart,
|
||||||
|
} from './metrics-more.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ id: string, name: string, algorithm: 'absolute'|'incremental', multiplier?: number, divisor?: number }} DimensionDef
|
* @typedef {{ id: string, name: string, algorithm: 'absolute'|'incremental', multiplier?: number, divisor?: number }} DimensionDef
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
@@ -226,6 +258,7 @@ export const STATIC_CHART_DEFS = [
|
|||||||
{ id: 'kernel_stack', name: 'kernel_stack', algorithm: 'absolute' },
|
{ id: 'kernel_stack', name: 'kernel_stack', algorithm: 'absolute' },
|
||||||
{ id: 'page_tables', name: 'page_tables', algorithm: 'absolute' },
|
{ id: 'page_tables', name: 'page_tables', algorithm: 'absolute' },
|
||||||
{ id: 'vmalloc_used', name: 'vmalloc_used', algorithm: 'absolute' },
|
{ id: 'vmalloc_used', name: 'vmalloc_used', algorithm: 'absolute' },
|
||||||
|
{ id: 'percpu', name: 'percpu', algorithm: 'absolute' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -256,6 +289,9 @@ export const STATIC_CHART_DEFS = [
|
|||||||
dimensions: [
|
dimensions: [
|
||||||
{ id: 'dirty', name: 'dirty', algorithm: 'absolute' },
|
{ id: 'dirty', name: 'dirty', algorithm: 'absolute' },
|
||||||
{ id: 'writeback', name: 'writeback', algorithm: 'absolute' },
|
{ id: 'writeback', name: 'writeback', algorithm: 'absolute' },
|
||||||
|
{ id: 'FuseWriteback', name: 'FuseWriteback', algorithm: 'absolute' },
|
||||||
|
{ id: 'NfsWriteback', name: 'NfsWriteback', algorithm: 'absolute' },
|
||||||
|
{ id: 'Bounce', name: 'Bounce', algorithm: 'absolute' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -553,6 +589,8 @@ export const STATIC_CHART_DEFS = [
|
|||||||
{ id: 'some300', name: 'some300', algorithm: 'absolute' },
|
{ id: 'some300', name: 'some300', algorithm: 'absolute' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...DEEP_STATIC_CHARTS,
|
||||||
|
...MORE_STATIC_CHARTS,
|
||||||
]
|
]
|
||||||
|
|
||||||
/** @type {Map<string, ChartDef>} */
|
/** @type {Map<string, ChartDef>} */
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import test from 'brittle'
|
||||||
|
import { DEEP_STATIC_CHARTS } from '../shared/metrics-deep.js'
|
||||||
|
import { STATIC_CHART_DEFS, CHART_BY_ID } from '../shared/metrics.js'
|
||||||
|
import { parseSoftnet, parsePressureFull, parseNetstatTables } from '../server/services/collectors/host-deep.js'
|
||||||
|
import { MetricsCollector } from '../server/services/collector.js'
|
||||||
|
|
||||||
|
test('deep static charts are merged into catalog', (t) => {
|
||||||
|
t.ok(DEEP_STATIC_CHARTS.length > 20)
|
||||||
|
t.ok(STATIC_CHART_DEFS.length >= 35 + DEEP_STATIC_CHARTS.length)
|
||||||
|
for (const id of ['mem.zswap', 'mem.reclaiming', 'ip.tcpsyncookies', 'system.softnet_stat']) {
|
||||||
|
t.ok(CHART_BY_ID.has(id), id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('host-deep parsers tolerate missing proc', (t) => {
|
||||||
|
const soft = parseSoftnet()
|
||||||
|
t.ok(typeof soft.processed === 'number')
|
||||||
|
const tables = parseNetstatTables()
|
||||||
|
t.ok(tables.TcpExt)
|
||||||
|
// may be null off Linux
|
||||||
|
const p = parsePressureFull('cpu')
|
||||||
|
t.ok(p === null || typeof p.some10 === 'number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('collector emits expanded disk/net charts on Linux', async (t) => {
|
||||||
|
const c = new MetricsCollector({ intervalMs: 40 })
|
||||||
|
/** @type {any[]} */
|
||||||
|
let batch = []
|
||||||
|
c.on('samples', (b) => {
|
||||||
|
batch = b
|
||||||
|
})
|
||||||
|
c.start()
|
||||||
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
|
c.stop()
|
||||||
|
|
||||||
|
t.ok(batch.length > 5)
|
||||||
|
const charts = new Set(batch.map((s) => s.chart))
|
||||||
|
t.ok(charts.has('system.cpu'))
|
||||||
|
// expanded dims present when meminfo exists
|
||||||
|
const kernel = batch.find((s) => s.chart === 'mem.kernel')
|
||||||
|
if (kernel) t.ok('percpu' in kernel.values)
|
||||||
|
const wb = batch.find((s) => s.chart === 'mem.writeback')
|
||||||
|
if (wb) t.ok('Bounce' in wb.values)
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import test from 'brittle'
|
||||||
|
import { MORE_STATIC_CHARTS } from '../shared/metrics-more.js'
|
||||||
|
import { CHART_BY_ID, getAllChartDefs } from '../shared/metrics.js'
|
||||||
|
import { parseSoftirqs, parseConntrack, parseSnmp6 } from '../server/services/collectors/host-more.js'
|
||||||
|
import { MetricsCollector } from '../server/services/collector.js'
|
||||||
|
|
||||||
|
test('more static charts merged', (t) => {
|
||||||
|
t.ok(MORE_STATIC_CHARTS.length > 15)
|
||||||
|
for (const id of [
|
||||||
|
'mem.directmaps',
|
||||||
|
'system.softirqs',
|
||||||
|
'net.conntrack',
|
||||||
|
'ipv6.packets',
|
||||||
|
'peardata.cpu',
|
||||||
|
'system.power',
|
||||||
|
]) {
|
||||||
|
t.ok(CHART_BY_ID.has(id), id)
|
||||||
|
}
|
||||||
|
t.ok(getAllChartDefs().length > 80)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('host-more parsers', (t) => {
|
||||||
|
const soft = parseSoftirqs()
|
||||||
|
t.ok(typeof soft === 'object')
|
||||||
|
const ct = parseConntrack()
|
||||||
|
t.ok(typeof ct.entries === 'number')
|
||||||
|
t.ok(typeof parseSnmp6() === 'object')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('collector second tick includes deep/more charts on Linux', async (t) => {
|
||||||
|
const c = new MetricsCollector({ intervalMs: 40 })
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
const charts = new Set()
|
||||||
|
c.on('samples', (batch) => {
|
||||||
|
for (const s of batch) charts.add(s.chart)
|
||||||
|
})
|
||||||
|
c.start()
|
||||||
|
await new Promise((r) => setTimeout(r, 120))
|
||||||
|
c.stop()
|
||||||
|
t.ok(charts.has('system.cpu'))
|
||||||
|
// softirqs / conntrack may be empty arrays of zeros but charts should emit
|
||||||
|
t.ok(charts.has('system.softirqs') || charts.has('net.conntrack') || charts.has('mem.directmaps'))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user