updates
This commit is contained in:
@@ -29,6 +29,21 @@ PEARDATA_DEFAULT_ROLE=viewer
|
|||||||
# PEARDATA_DOCKER_SOCKET=/var/run/docker.sock
|
# PEARDATA_DOCKER_SOCKET=/var/run/docker.sock
|
||||||
# PEARDATA_PROCESSES=0
|
# PEARDATA_PROCESSES=0
|
||||||
# PEARDATA_PROCESSES_TOP=8
|
# PEARDATA_PROCESSES_TOP=8
|
||||||
|
# PEARDATA_PARENT=0
|
||||||
|
# PEARDATA_PARENT_PEERS=
|
||||||
|
# PEARDATA_PARENT_PEERS_FILE=
|
||||||
|
# PEARDATA_PARENT_SEED=
|
||||||
|
# PEARDATA_PARENT_POLL_MS=5000
|
||||||
|
# PEARDATA_NGINX=0
|
||||||
|
# PEARDATA_NGINX_URL=http://127.0.0.1/nginx_status
|
||||||
|
# PEARDATA_REDIS=0
|
||||||
|
# PEARDATA_REDIS_URL=127.0.0.1:6379
|
||||||
|
# PEARDATA_POSTGRES=0
|
||||||
|
# PEARDATA_POSTGRES_HOST=127.0.0.1
|
||||||
|
# PEARDATA_POSTGRES_PORT=5432
|
||||||
|
# PEARDATA_POSTGRES_STATS_URL=
|
||||||
|
# PEARDATA_EXPORT_DIR=./exports
|
||||||
|
# PEARDATA_PUSHGATEWAY_URL=
|
||||||
# See docs/STORAGE-HYPERDB.md
|
# See docs/STORAGE-HYPERDB.md
|
||||||
|
|
||||||
# ── agent-style REST API ───────────────────────────────────
|
# ── agent-style REST API ───────────────────────────────────
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ const els = {
|
|||||||
inviteOut: $('invite-out'),
|
inviteOut: $('invite-out'),
|
||||||
anomalyList: $('anomaly-list'),
|
anomalyList: $('anomaly-list'),
|
||||||
offlineBanner: $('offline-banner'),
|
offlineBanner: $('offline-banner'),
|
||||||
|
fleetStrip: $('fleet-strip'),
|
||||||
|
fleetSummary: $('fleet-summary'),
|
||||||
|
fleetChildren: $('fleet-children'),
|
||||||
statCpu: $('stat-cpu'),
|
statCpu: $('stat-cpu'),
|
||||||
statRam: $('stat-ram'),
|
statRam: $('stat-ram'),
|
||||||
statLoad: $('stat-load'),
|
statLoad: $('stat-load'),
|
||||||
@@ -325,20 +328,76 @@ function prependAnomaly(ev) {
|
|||||||
while (els.anomalyList.children.length > 40) els.anomalyList.lastChild.remove()
|
while (els.anomalyList.children.length > 40) els.anomalyList.lastChild.remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderFleetStrip(fleet, desktopPeers) {
|
||||||
|
if (!els.fleetStrip || !els.fleetChildren) return
|
||||||
|
|
||||||
|
/** @type {Array<{ id: string, label: string, status: string, detail: string }>} */
|
||||||
|
const rows = []
|
||||||
|
|
||||||
|
if (fleet?.enabled && Array.isArray(fleet.children)) {
|
||||||
|
for (const c of fleet.children) {
|
||||||
|
const status = c.connected
|
||||||
|
? c.health === 'critical'
|
||||||
|
? 'critical'
|
||||||
|
: c.health === 'degraded'
|
||||||
|
? 'degraded'
|
||||||
|
: 'ok'
|
||||||
|
: 'offline'
|
||||||
|
const cpu = c.cpu != null ? `${Number(c.cpu).toFixed(0)}%` : '—'
|
||||||
|
const ram = c.ram != null ? `${Number(c.ram).toFixed(0)} MiB` : '—'
|
||||||
|
rows.push({
|
||||||
|
id: c.shortId || String(c.publicKeyHex || '').slice(0, 12),
|
||||||
|
label: c.hostname || c.shortId || 'child',
|
||||||
|
status,
|
||||||
|
detail: `${cpu} · ${ram}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const s = fleet.summary || {}
|
||||||
|
els.fleetSummary.textContent = s.status
|
||||||
|
? `${s.connected ?? 0}/${s.configured ?? rows.length} · ${s.status}${
|
||||||
|
s.avgCpu != null ? ` · avg CPU ${Number(s.avgCpu).toFixed(0)}%` : ''
|
||||||
|
}`
|
||||||
|
: `${rows.length} children`
|
||||||
|
} else if (desktopPeers?.length > 1) {
|
||||||
|
for (const p of desktopPeers) {
|
||||||
|
rows.push({
|
||||||
|
id: String(p.publicKeyHex || '').slice(0, 12),
|
||||||
|
label: p.publicKeyHex?.slice(0, 12) || 'peer',
|
||||||
|
status: p.connected ? 'ok' : 'offline',
|
||||||
|
detail: p.connected ? 'connected' : 'down',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
els.fleetSummary.textContent = `${desktopPeers.filter((p) => p.connected).length}/${desktopPeers.length} dialed`
|
||||||
|
}
|
||||||
|
|
||||||
|
els.fleetChildren.innerHTML = ''
|
||||||
|
for (const row of rows) {
|
||||||
|
const li = document.createElement('li')
|
||||||
|
li.dataset.status = row.status
|
||||||
|
li.innerHTML = `<strong>${escapeHtml(row.label)}</strong>
|
||||||
|
<span class="fleet-id">${escapeHtml(row.id)}</span>
|
||||||
|
<span class="fleet-metrics">${escapeHtml(row.detail)}</span>`
|
||||||
|
els.fleetChildren.appendChild(li)
|
||||||
|
}
|
||||||
|
els.fleetStrip.classList.toggle('hidden', rows.length === 0)
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshMeta() {
|
async function refreshMeta() {
|
||||||
const [info, auth, health, node] = await Promise.all([
|
const [info, auth, health, node, fleet] = await Promise.all([
|
||||||
manager.request(Methods.getServerInfo, {}),
|
manager.request(Methods.getServerInfo, {}),
|
||||||
manager.request(Methods.getAuthStatus, {}),
|
manager.request(Methods.getAuthStatus, {}),
|
||||||
manager.request(Methods.getHealth, {}),
|
manager.request(Methods.getHealth, {}),
|
||||||
manager.request(Methods.getNodeInfo, {}),
|
manager.request(Methods.getNodeInfo, {}),
|
||||||
|
manager.request(Methods.getFleetHealth, {}).catch(() => ({ enabled: false })),
|
||||||
])
|
])
|
||||||
els.serverInfo.textContent = JSON.stringify({ info, node, health }, null, 2)
|
els.serverInfo.textContent = JSON.stringify({ info, node, health, fleet }, null, 2)
|
||||||
els.roleBadge.textContent = auth.role || '—'
|
els.roleBadge.textContent = auth.role || '—'
|
||||||
els.statHealth.textContent = health.status || '—'
|
els.statHealth.textContent = health.status || '—'
|
||||||
els.statHealth.parentElement.dataset.health = health.status || ''
|
els.statHealth.parentElement.dataset.health = health.status || ''
|
||||||
const id = getClientIdentity()
|
const id = getClientIdentity()
|
||||||
els.connMeta.textContent = `you ${id.publicKeyHex.slice(0, 12)}… · ${auth.role} · ${auth.authMode}`
|
els.connMeta.textContent = `you ${id.publicKeyHex.slice(0, 12)}… · ${auth.role} · ${auth.authMode}`
|
||||||
renderPeers()
|
renderPeers()
|
||||||
|
renderFleetStrip(fleet, manager.list())
|
||||||
await populateExploreCharts()
|
await populateExploreCharts()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +415,10 @@ async function populateExploreCharts() {
|
|||||||
id.startsWith('disk_space.') ||
|
id.startsWith('disk_space.') ||
|
||||||
id.startsWith('docker.') ||
|
id.startsWith('docker.') ||
|
||||||
id.startsWith('processes.') ||
|
id.startsWith('processes.') ||
|
||||||
|
id.startsWith('fleet.') ||
|
||||||
|
id.startsWith('nginx.') ||
|
||||||
|
id.startsWith('redis.') ||
|
||||||
|
id.startsWith('postgres.') ||
|
||||||
id === 'system.io' ||
|
id === 'system.io' ||
|
||||||
id === 'mem.available' ||
|
id === 'mem.available' ||
|
||||||
id === 'system.load'
|
id === 'system.load'
|
||||||
@@ -492,11 +555,13 @@ manager.on('push', (ev, conn) => {
|
|||||||
manager.on('connected', () => {
|
manager.on('connected', () => {
|
||||||
setOnline(true)
|
setOnline(true)
|
||||||
renderPeers()
|
renderPeers()
|
||||||
|
renderFleetStrip(null, manager.list())
|
||||||
})
|
})
|
||||||
|
|
||||||
manager.on('disconnected', () => {
|
manager.on('disconnected', () => {
|
||||||
if (!manager.list().some((c) => c.connected)) setOnline(false, { reconnecting: true })
|
if (!manager.list().some((c) => c.connected)) setOnline(false, { reconnecting: true })
|
||||||
renderPeers()
|
renderPeers()
|
||||||
|
renderFleetStrip(null, manager.list())
|
||||||
log('Agent disconnected')
|
log('Agent disconnected')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,21 @@ Permissions: directory `0700`. Do **not** commit `data/` or `.env`.
|
|||||||
| `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_PROCESSES` | off | `1` enables process top-N CPU/RSS charts (Linux `/proc`) |
|
||||||
| `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_PARENT` | off | `1` enables parent peer fleet aggregator |
|
||||||
|
| `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_SEED` | — | Optional `SERVER_SEED` of children for admin proof dial |
|
||||||
|
| `PEARDATA_PARENT_POLL_MS` | `5000` | How often parent polls children |
|
||||||
|
| `PEARDATA_NGINX` | off | `1` enables nginx stub_status collector |
|
||||||
|
| `PEARDATA_NGINX_URL` | `http://127.0.0.1/nginx_status` | stub_status URL |
|
||||||
|
| `PEARDATA_REDIS` | off | `1` enables Redis INFO collector |
|
||||||
|
| `PEARDATA_REDIS_URL` | `127.0.0.1:6379` | Redis host:port or `redis://` URL |
|
||||||
|
| `PEARDATA_POSTGRES` | off | `1` enables Postgres TCP probe (+ optional stats URL) |
|
||||||
|
| `PEARDATA_POSTGRES_HOST` | `127.0.0.1` | Postgres host |
|
||||||
|
| `PEARDATA_POSTGRES_PORT` | `5432` | Postgres port |
|
||||||
|
| `PEARDATA_POSTGRES_STATS_URL` | — | Optional HTTP key=value stats sidecar |
|
||||||
|
| `PEARDATA_EXPORT_DIR` | — | Write `snapshot-*.json` from export job/RPC |
|
||||||
|
| `PEARDATA_PUSHGATEWAY_URL` | — | POST Prometheus text (Pushgateway-compatible) |
|
||||||
|
|
||||||
Storage: `$PEARDATA_DATA_DIR/corestore` (named core `peardata-meta`).
|
Storage: `$PEARDATA_DATA_DIR/corestore` (named core `peardata-meta`).
|
||||||
|
|
||||||
|
|||||||
+29
-4
@@ -75,12 +75,37 @@ Have `pipeline.js` start each enabled collector; all emit `samples` batches into
|
|||||||
2. Charts: `processes.top_cpu`, `processes.top_rss` (dimensions = process comm names).
|
2. Charts: `processes.top_cpu`, `processes.top_rss` (dimensions = process comm names).
|
||||||
3. Linux `/proc` only; safe no-op on other platforms.
|
3. Linux `/proc` only; safe no-op on other platforms.
|
||||||
|
|
||||||
|
### Nginx stub_status plugin
|
||||||
|
|
||||||
|
1. Set `PEARDATA_NGINX=1` and `PEARDATA_NGINX_URL=…`.
|
||||||
|
2. Charts: `nginx.connections`, `nginx.requests`.
|
||||||
|
3. Base class: `server/services/collectors/plugin.js` (`CollectorPlugin`).
|
||||||
|
|
||||||
|
### Redis INFO plugin
|
||||||
|
|
||||||
|
1. Set `PEARDATA_REDIS=1` and optional `PEARDATA_REDIS_URL`.
|
||||||
|
2. Charts: `redis.memory`, `redis.clients`, `redis.stats`.
|
||||||
|
|
||||||
|
### Postgres plugin
|
||||||
|
|
||||||
|
1. Set `PEARDATA_POSTGRES=1` (+ host/port).
|
||||||
|
2. Charts: `postgres.up` (TCP probe); optional `postgres.stats` via `PEARDATA_POSTGRES_STATS_URL`.
|
||||||
|
|
||||||
|
### Export / Prometheus push
|
||||||
|
|
||||||
|
- Job `exportSnapshot` / RPC `exportSnapshot` / REST `GET /api/v3/export`
|
||||||
|
- Job `prometheusPush` → `PEARDATA_PUSHGATEWAY_URL`
|
||||||
|
- Optional file write: `PEARDATA_EXPORT_DIR`
|
||||||
|
|
||||||
## Parent peer (fleet aggregator)
|
## Parent peer (fleet aggregator)
|
||||||
|
|
||||||
1. Parent dials child agents with `ConnectionManager`.
|
Shipped opt-in spike:
|
||||||
2. Subscribes to `push:metrics` / periodically `queryData`.
|
|
||||||
3. Ingests into local store under namespaced chart ids (`childPk.system.cpu`) or labels.
|
1. Set `PEARDATA_PARENT=1` and `PEARDATA_PARENT_PEERS=<childPk>,…`.
|
||||||
4. Exposes `/api/v3/nodes` with multiple entries + `/api/v3/data` across nodes.
|
2. Parent dials children over HyperDHT (`PearDataConnection`), polls health + metrics.
|
||||||
|
3. Emits `fleet.cpu`, `fleet.ram`, `fleet.children` into the local store.
|
||||||
|
4. REST: `GET /api/v3/nodes` (multi), `/api/v3/fleet`, `/api/v3/stream_path`.
|
||||||
|
5. RPC: `getFleetHealth`, `listChildPeers`.
|
||||||
|
|
||||||
## PearDock / PearVirt hooks
|
## PearDock / PearVirt hooks
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -86,10 +86,12 @@ Auth modes at handshake: public key (viewer), capability token / `pd1.` invite,
|
|||||||
|
|
||||||
| Method | Role | Notes |
|
| Method | Role | Notes |
|
||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| `getDbInfo` | viewer | DB + discovery keys, swarm flag |
|
| `getDbInfo` | viewer | DB + discovery keys, swarm flag, remotes |
|
||||||
| `listPeerLinks` | viewer | Linked peers from HyperDB |
|
| `listPeerLinks` | viewer | Linked peers from HyperDB |
|
||||||
| `linkPeer` | admin | Upsert link; optional swarm join |
|
| `linkPeer` | admin | Upsert link; open remote bee + optional swarm join |
|
||||||
| `unlinkPeer` | admin | Remove link |
|
| `unlinkPeer` | admin | Remove link |
|
||||||
|
| `getFleetHealth` | viewer | Parent rollup (when `PEARDATA_PARENT=1`) |
|
||||||
|
| `listChildPeers` | viewer | Dialed child agents |
|
||||||
|
|
||||||
See [STORAGE-HYPERDB.md](./STORAGE-HYPERDB.md).
|
See [STORAGE-HYPERDB.md](./STORAGE-HYPERDB.md).
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -54,9 +54,16 @@ curl -s http://127.0.0.1:19999/api/v3/health | jq
|
|||||||
| GET | `/api/v2/nodes` |
|
| GET | `/api/v2/nodes` |
|
||||||
| GET | `/api/v3/nodes` |
|
| GET | `/api/v3/nodes` |
|
||||||
| GET | `/api/v3/node_instances` |
|
| GET | `/api/v3/node_instances` |
|
||||||
|
| GET | `/api/v3/fleet` |
|
||||||
| GET | `/api/v3/stream_path` |
|
| GET | `/api/v3/stream_path` |
|
||||||
|
|
||||||
Single-agent MVP returns one node (this host). Parent/fleet aggregation is roadmap.
|
Single-agent returns one node. With `PEARDATA_PARENT=1`, `/nodes` and `/fleet` include dialed children.
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| GET | `/api/v3/export` | JSON snapshot; `?format=prometheus` for text; `?write=1` writes `PEARDATA_EXPORT_DIR` |
|
||||||
|
|
||||||
### Contexts & charts
|
### Contexts & charts
|
||||||
|
|
||||||
|
|||||||
+18
-10
@@ -49,7 +49,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
|
|||||||
| Hyperswarm mesh (`PEARDATA_SWARM=1`) | Done (opt-in) |
|
| Hyperswarm mesh (`PEARDATA_SWARM=1`) | Done (opt-in) |
|
||||||
| Configurable retention knobs (tier sizes) | Done (`PEARDATA_TIER*`) |
|
| Configurable retention knobs (tier sizes) | Done (`PEARDATA_TIER*`) |
|
||||||
| Documented warm history across restart (operator M4) | Done (`store-hyperdb-fallback` + STORAGE doc) |
|
| Documented warm history across restart (operator M4) | Done (`store-hyperdb-fallback` + STORAGE doc) |
|
||||||
| Export snapshot job → JSON / Prometheus remote-write | Next |
|
| Export snapshot job → JSON / Prometheus push | Done (`exportSnapshot`, `prometheusPush`) |
|
||||||
| Autobase multi-writer parents | Later (Phase 2c) |
|
| Autobase multi-writer parents | Later (Phase 2c) |
|
||||||
| Rocks engine for local-only desktop cache | Optional |
|
| Rocks engine for local-only desktop cache | Optional |
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
|
|||||||
|
|
||||||
- [x] `queryData` / REST `/data` can return `source: "hyperdb-warm"`
|
- [x] `queryData` / REST `/data` can return `source: "hyperdb-warm"`
|
||||||
- [x] Soak test: empty memory + reopen Corestore → `hyperdb-warm`
|
- [x] Soak test: empty memory + reopen Corestore → `hyperdb-warm`
|
||||||
- [ ] Linked peer pulls warm points over swarm without re-scraping
|
- [x] Linked peer pulls warm points (`source: "hyperdb-remote"`, `test/swarm-pull.test.js`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
|
|||||||
| Docker / container discovery | Done (opt-in `PEARDATA_DOCKER=1`) |
|
| Docker / container discovery | Done (opt-in `PEARDATA_DOCKER=1`) |
|
||||||
| Offline banner (last-known samples) | Done |
|
| Offline banner (last-known samples) | Done |
|
||||||
| Process top-N | Done (opt-in `PEARDATA_PROCESSES=1`) |
|
| Process top-N | Done (opt-in `PEARDATA_PROCESSES=1`) |
|
||||||
| Service plugins | nginx, postgres, redis collectors |
|
| Service plugins | nginx + redis + postgres done |
|
||||||
| PearDock bridge | Container metrics from dock peers |
|
| PearDock bridge | Container metrics from dock peers |
|
||||||
| PearVirt / BareOS adapters | Thin translators into shared contexts |
|
| PearVirt / BareOS adapters | Thin translators into shared contexts |
|
||||||
| Holesail optional REST expose | Tunneled agent API |
|
| Holesail optional REST expose | Tunneled agent API |
|
||||||
@@ -78,18 +78,20 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
|
|||||||
- [x] One container host can emit per-container CPU/mem charts (`docker.cpu.*` / `docker.mem.*`)
|
- [x] One container host can emit per-container CPU/mem charts (`docker.cpu.*` / `docker.mem.*`)
|
||||||
- [x] Plugin docs + Docker collector in [EXTENDING.md](./EXTENDING.md)
|
- [x] Plugin docs + Docker collector in [EXTENDING.md](./EXTENDING.md)
|
||||||
- [x] Process top-N opt-in collector (`processes.top_cpu` / `processes.top_rss`)
|
- [x] Process top-N opt-in collector (`processes.top_cpu` / `processes.top_rss`)
|
||||||
|
- [x] Plugin base + nginx / redis / postgres collectors
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 4 — Smarter anomalies & fleet
|
## Phase 4 — Smarter anomalies & fleet ← **started**
|
||||||
|
|
||||||
| Item | Notes |
|
| Item | Status |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
|
| Parent peer aggregation | Done spike (`PEARDATA_PARENT=1`) |
|
||||||
|
| Fleet REST / RPC | Done (`/api/v3/fleet`, `getFleetHealth`) |
|
||||||
|
| Desktop fleet strip | Done (`getFleetHealth` + multi-dial peers) |
|
||||||
| Anomaly scoring + UI highlight | Beyond binary warn/crit |
|
| Anomaly scoring + UI highlight | Beyond binary warn/crit |
|
||||||
| Streaming z-score / k-means job | `runJob` retrain stub exists |
|
| Streaming z-score / k-means job | `runJob` retrain stub exists |
|
||||||
| Fleet composite views | Multi-node health strip |
|
|
||||||
| `/api/v3/weights` depth | Real metric weights |
|
| `/api/v3/weights` depth | Real metric weights |
|
||||||
| Parent peer aggregation | P2P parent without SaaS |
|
|
||||||
| Notifications | Desktop + optional webhook |
|
| Notifications | Desktop + optional webhook |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -113,8 +115,14 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
|
|||||||
2. ~~Docker collector spike~~ ✅ (`PEARDATA_DOCKER=1`)
|
2. ~~Docker collector spike~~ ✅ (`PEARDATA_DOCKER=1`)
|
||||||
3. ~~Offline banner~~ ✅
|
3. ~~Offline banner~~ ✅
|
||||||
4. ~~Process top-N~~ ✅ (`PEARDATA_PROCESSES=1`)
|
4. ~~Process top-N~~ ✅ (`PEARDATA_PROCESSES=1`)
|
||||||
5. **Parent peer prototype** — aggregate health from N agents
|
5. ~~Parent peer prototype~~ ✅ (`PEARDATA_PARENT=1`)
|
||||||
6. **Swarm pull validation** — linked peer reads warm without re-scrape
|
6. ~~Swarm / linked warm pull~~ ✅ (`hyperdb-remote`)
|
||||||
|
7. ~~Postgres / Redis plugins~~ ✅
|
||||||
|
8. ~~Desktop fleet strip~~ ✅
|
||||||
|
9. ~~Export snapshot / Prometheus push~~ ✅
|
||||||
|
10. **Anomaly scoring UI** — highlight warn/crit on charts
|
||||||
|
11. **Notifications** — desktop + optional webhook
|
||||||
|
12. **PearDock bridge** — container metrics from dock peers
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -137,7 +145,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
|
|||||||
| M3 | `curl \| bash` install; rolling server+client | ✅ |
|
| M3 | `curl \| bash` install; rolling server+client | ✅ |
|
||||||
| M4 | Historical scrub across restart (HyperDB warm) | ✅ |
|
| M4 | Historical scrub across restart (HyperDB warm) | ✅ |
|
||||||
| M5 | Container charts from Docker hosts | ✅ opt-in spike |
|
| M5 | Container charts from Docker hosts | ✅ opt-in spike |
|
||||||
| M6 | Parent peer rolling up a fleet | Planned |
|
| M6 | Parent peer rolling up a fleet | ✅ opt-in spike |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ Live 1-second samples stay in the **memory ring** and are pushed over protomux-r
|
|||||||
| Query fallback (memory → HyperDB) | ✅ | `MetricStore.query()` |
|
| Query fallback (memory → HyperDB) | ✅ | `MetricStore.query()` |
|
||||||
| Agent boot upsert node | ✅ | `server/server.js` |
|
| Agent boot upsert node | ✅ | `server/server.js` |
|
||||||
| RPC: `getDbInfo`, `linkPeer`, `unlinkPeer`, `listPeerLinks` | ✅ | `server/handlers/monitor.js` |
|
| RPC: `getDbInfo`, `linkPeer`, `unlinkPeer`, `listPeerLinks` | ✅ | `server/handlers/monitor.js` |
|
||||||
|
| Open remote bee by `dbKeyHex` + query | ✅ | `server/db/remote.js` → `source: hyperdb-remote` |
|
||||||
|
| Persist `discoveryKeyHex` on peer-link + boot rejoin | ✅ | schema v2 + `rejoinLinkedPeers()` |
|
||||||
| REST: `GET /api/v3/db` | ✅ | `server/rest/routes.js` |
|
| REST: `GET /api/v3/db` | ✅ | `server/rest/routes.js` |
|
||||||
| Hyperswarm replicate | ✅ stub | `server/db/replicate.js` (`PEARDATA_SWARM=1`) |
|
| Hyperswarm replicate | ✅ stub | `server/db/replicate.js` (`PEARDATA_SWARM=1`) |
|
||||||
| Autobase multi-writer parents | ⏳ Phase C | See roadmap |
|
| Autobase multi-writer parents | ⏳ Phase C | See roadmap |
|
||||||
|
|||||||
@@ -86,6 +86,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="fleet-strip" class="fleet-strip panel hidden" aria-live="polite">
|
||||||
|
<header>
|
||||||
|
<h3>Fleet</h3>
|
||||||
|
<span id="fleet-summary" class="muted"></span>
|
||||||
|
</header>
|
||||||
|
<ul id="fleet-children" class="fleet-children"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="charts-grid">
|
<section class="charts-grid">
|
||||||
<article class="panel chart-panel">
|
<article class="panel chart-panel">
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ function setupSchema() {
|
|||||||
{ name: 'syncMode', type: 'string', required: false }, // push | pull | both
|
{ name: 'syncMode', type: 'string', required: false }, // push | pull | both
|
||||||
{ name: 'linkedAt', type: 'uint', required: true },
|
{ name: 'linkedAt', type: 'uint', required: true },
|
||||||
{ name: 'lastSeen', type: 'uint', required: false },
|
{ name: 'lastSeen', type: 'uint', required: false },
|
||||||
|
// Append-only v2: swarm topic to rejoin after restart
|
||||||
|
{ name: 'discoveryKeyHex', type: 'string', required: false },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ export function getCorestore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function closeDb() {
|
export async function closeDb() {
|
||||||
|
try {
|
||||||
|
const { closeRemoteDbs } = await import('./remote.js')
|
||||||
|
await closeRemoteDbs()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
if (model) {
|
if (model) {
|
||||||
await model.close().catch(() => {})
|
await model.close().catch(() => {})
|
||||||
model = null
|
model = null
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export class PearDataModel extends ReadyResource {
|
|||||||
role: link.role || 'viewer',
|
role: link.role || 'viewer',
|
||||||
alias: link.alias || null,
|
alias: link.alias || null,
|
||||||
dbKeyHex: link.dbKeyHex || null,
|
dbKeyHex: link.dbKeyHex || null,
|
||||||
|
discoveryKeyHex: link.discoveryKeyHex || null,
|
||||||
syncMode: link.syncMode || 'both',
|
syncMode: link.syncMode || 'both',
|
||||||
linkedAt: link.linkedAt || Date.now(),
|
linkedAt: link.linkedAt || Date.now(),
|
||||||
lastSeen: link.lastSeen || null,
|
lastSeen: link.lastSeen || null,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Open linked peer HyperDBs (read-only) after Corestore replication.
|
||||||
|
*
|
||||||
|
* Local agent always owns `peardata-meta`. Linked peers' bees are opened by
|
||||||
|
* `dbKeyHex` so warm `@peardata/metric-point` rows become queryable without
|
||||||
|
* re-scraping the remote host.
|
||||||
|
*/
|
||||||
|
import b4a from 'b4a'
|
||||||
|
import { PearDataModel } from './model.js'
|
||||||
|
import { getCorestore } from './index.js'
|
||||||
|
import logger from '../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('db:remote')
|
||||||
|
|
||||||
|
/** @type {Map<string, PearDataModel>} */
|
||||||
|
const remotes = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} dbKeyHex
|
||||||
|
* @returns {Promise<PearDataModel|null>}
|
||||||
|
*/
|
||||||
|
export async function openRemoteDb(dbKeyHex) {
|
||||||
|
const keyHex = String(dbKeyHex || '').toLowerCase()
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(keyHex)) return null
|
||||||
|
if (remotes.has(keyHex)) return remotes.get(keyHex)
|
||||||
|
|
||||||
|
const store = getCorestore()
|
||||||
|
if (!store) {
|
||||||
|
log.warn('openRemoteDb: Corestore not open')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = store.get({ key: b4a.from(keyHex, 'hex') })
|
||||||
|
const model = new PearDataModel(core, { writable: false, autoUpdate: true })
|
||||||
|
await model.ready()
|
||||||
|
remotes.set(keyHex, model)
|
||||||
|
log.info('Opened remote HyperDB', { dbKeyHex: keyHex.slice(0, 16) })
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} dbKeyHex
|
||||||
|
*/
|
||||||
|
export function getRemoteDb(dbKeyHex) {
|
||||||
|
return remotes.get(String(dbKeyHex || '').toLowerCase()) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRemoteDbs() {
|
||||||
|
return [...remotes.entries()].map(([dbKeyHex, model]) => ({
|
||||||
|
dbKeyHex,
|
||||||
|
discoveryKeyHex: model.discoveryKeyHex,
|
||||||
|
length: model.db?.core?.length ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query warm metric points across all open remote DBs (first hit wins per call site).
|
||||||
|
* @param {{ chart: string, afterMs: number, beforeMs: number, limit?: number, tier?: number }} opts
|
||||||
|
*/
|
||||||
|
export async function queryRemoteMetricPoints(opts) {
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, tier: number, values: object }>} */
|
||||||
|
const all = []
|
||||||
|
for (const model of remotes.values()) {
|
||||||
|
try {
|
||||||
|
const rows = await model.queryMetricPoints(opts)
|
||||||
|
if (rows?.length) all.push(...rows)
|
||||||
|
} catch {
|
||||||
|
// ignore per-remote failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all.sort((a, b) => a.ts - b.ts)
|
||||||
|
const limit = opts.limit || 10_000
|
||||||
|
return all.length > limit ? all.slice(-limit) : all
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeRemoteDbs() {
|
||||||
|
for (const [key, model] of remotes) {
|
||||||
|
try {
|
||||||
|
await model.close()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
remotes.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
import Hyperswarm from 'hyperswarm'
|
import Hyperswarm from 'hyperswarm'
|
||||||
import b4a from 'b4a'
|
import b4a from 'b4a'
|
||||||
import { getCorestore, getDb } from './index.js'
|
import { getCorestore, getDb } from './index.js'
|
||||||
|
import { openRemoteDb } from './remote.js'
|
||||||
|
import { getServerPublicKeyHex } from '../core/auth-keys.js'
|
||||||
import logger from '../utils/logger.js'
|
import logger from '../utils/logger.js'
|
||||||
|
|
||||||
const log = logger.child('db:replicate')
|
const log = logger.child('db:replicate')
|
||||||
@@ -61,6 +63,11 @@ export async function startReplication() {
|
|||||||
discoveryKeyHex: model.discoveryKeyHex,
|
discoveryKeyHex: model.discoveryKeyHex,
|
||||||
dbKeyHex: model.publicKeyHex,
|
dbKeyHex: model.publicKeyHex,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await rejoinLinkedPeers().catch((err) => {
|
||||||
|
log.warn('rejoinLinkedPeers failed', { error: err.message })
|
||||||
|
})
|
||||||
|
|
||||||
return swarm
|
return swarm
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +92,58 @@ export async function joinRemoteTopic(discoveryKeyHexOrBuf) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist + open a linked peer for warm pull.
|
||||||
|
* @param {{ discoveryKeyHex?: string|null, dbKeyHex?: string|null, syncMode?: string }} link
|
||||||
|
*/
|
||||||
|
export async function attachLinkedPeer(link) {
|
||||||
|
const mode = link.syncMode || 'both'
|
||||||
|
if (mode !== 'pull' && mode !== 'both') return { joined: false, opened: false }
|
||||||
|
|
||||||
|
let joined = false
|
||||||
|
if (isSwarmEnabled() && link.discoveryKeyHex) {
|
||||||
|
try {
|
||||||
|
await joinRemoteTopic(link.discoveryKeyHex)
|
||||||
|
joined = true
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('joinRemoteTopic failed', { error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let opened = false
|
||||||
|
if (link.dbKeyHex) {
|
||||||
|
const remote = await openRemoteDb(link.dbKeyHex)
|
||||||
|
opened = Boolean(remote)
|
||||||
|
}
|
||||||
|
return { joined, opened }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-join swarm topics + open remote bees from persisted peer-links (boot).
|
||||||
|
*/
|
||||||
|
export async function rejoinLinkedPeers() {
|
||||||
|
const db = getDb()
|
||||||
|
if (!db) return { links: 0, joined: 0, opened: 0 }
|
||||||
|
let localNodeId
|
||||||
|
try {
|
||||||
|
localNodeId = getServerPublicKeyHex().slice(0, 16)
|
||||||
|
} catch {
|
||||||
|
return { links: 0, joined: 0, opened: 0 }
|
||||||
|
}
|
||||||
|
const links = await db.listPeerLinks(localNodeId)
|
||||||
|
let joined = 0
|
||||||
|
let opened = 0
|
||||||
|
for (const link of links) {
|
||||||
|
const res = await attachLinkedPeer(link)
|
||||||
|
if (res.joined) joined++
|
||||||
|
if (res.opened) opened++
|
||||||
|
}
|
||||||
|
if (links.length) {
|
||||||
|
log.info('Rejoined linked peers', { links: links.length, joined, opened })
|
||||||
|
}
|
||||||
|
return { links: links.length, joined, opened }
|
||||||
|
}
|
||||||
|
|
||||||
export async function stopReplication() {
|
export async function stopReplication() {
|
||||||
if (!swarm) return
|
if (!swarm) return
|
||||||
try {
|
try {
|
||||||
|
|||||||
+58
-19
@@ -43,7 +43,9 @@ import {
|
|||||||
import { getJobs, knownJobNames } from '../services/jobs.js'
|
import { getJobs, knownJobNames } from '../services/jobs.js'
|
||||||
import { formatAllMetrics } from '../rest/formatters.js'
|
import { formatAllMetrics } from '../rest/formatters.js'
|
||||||
import { getDb } from '../db/index.js'
|
import { getDb } from '../db/index.js'
|
||||||
import { joinRemoteTopic, isSwarmEnabled } from '../db/replicate.js'
|
import { attachLinkedPeer, isSwarmEnabled } from '../db/replicate.js'
|
||||||
|
import { listRemoteDbs } from '../db/remote.js'
|
||||||
|
import { getParentCollector, isParentEnabled } from '../services/collectors/parent.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('../rpc/session.js').PeerSession} session
|
* @param {import('../rpc/session.js').PeerSession} session
|
||||||
@@ -143,6 +145,7 @@ export function registerMonitorHandlers(session) {
|
|||||||
publicKeyHex: db.publicKeyHex,
|
publicKeyHex: db.publicKeyHex,
|
||||||
discoveryKeyHex: db.discoveryKeyHex,
|
discoveryKeyHex: db.discoveryKeyHex,
|
||||||
swarm: isSwarmEnabled(),
|
swarm: isSwarmEnabled(),
|
||||||
|
remotes: listRemoteDbs(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -163,20 +166,42 @@ export function registerMonitorHandlers(session) {
|
|||||||
role: args.role || 'viewer',
|
role: args.role || 'viewer',
|
||||||
alias: args.alias || null,
|
alias: args.alias || null,
|
||||||
dbKeyHex: args.dbKeyHex || null,
|
dbKeyHex: args.dbKeyHex || null,
|
||||||
|
discoveryKeyHex: args.discoveryKeyHex || null,
|
||||||
syncMode: args.syncMode || 'both',
|
syncMode: args.syncMode || 'both',
|
||||||
})
|
})
|
||||||
if (isSwarmEnabled() && args.discoveryKeyHex && (args.syncMode === 'pull' || args.syncMode === 'both')) {
|
const attach = await attachLinkedPeer({
|
||||||
try {
|
discoveryKeyHex: args.discoveryKeyHex || null,
|
||||||
await joinRemoteTopic(args.discoveryKeyHex)
|
dbKeyHex: args.dbKeyHex || null,
|
||||||
} catch (err) {
|
syncMode: args.syncMode || 'both',
|
||||||
return {
|
})
|
||||||
success: true,
|
return {
|
||||||
linked: true,
|
success: true,
|
||||||
swarmWarning: err.message,
|
linked: true,
|
||||||
}
|
swarmJoined: attach.joined,
|
||||||
|
remoteOpened: attach.opened,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
session.respond('getFleetHealth', async () => {
|
||||||
|
if (!isParentEnabled()) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
local: anomalies.getHealth(),
|
||||||
|
children: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { success: true, linked: true }
|
const parent = getParentCollector()
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
local: anomalies.getHealth(),
|
||||||
|
children: parent.listChildren(),
|
||||||
|
summary: parent.fleetSummary(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
session.respond('listChildPeers', async () => {
|
||||||
|
if (!isParentEnabled()) return { enabled: false, children: [] }
|
||||||
|
return { enabled: true, children: getParentCollector().listChildren() }
|
||||||
})
|
})
|
||||||
|
|
||||||
session.respond('unlinkPeer', async (args) => {
|
session.respond('unlinkPeer', async (args) => {
|
||||||
@@ -262,12 +287,26 @@ export function registerMonitorHandlers(session) {
|
|||||||
return { success: true, peerId: args.peerId }
|
return { success: true, peerId: args.peerId }
|
||||||
})
|
})
|
||||||
|
|
||||||
session.respond('exportSnapshot', async () => ({
|
session.respond('exportSnapshot', async (args) => {
|
||||||
success: true,
|
const { buildSnapshot, writeSnapshotFile, toPrometheusText, pushPrometheusText } =
|
||||||
node: collector.getNodeInfo(getServerPublicKeyHex(), APP_VERSION),
|
await import('../services/export.js')
|
||||||
latest: store.latestValues(),
|
const snapshot = buildSnapshot()
|
||||||
health: anomalies.getHealth(),
|
const written = args?.write ? writeSnapshotFile(snapshot) : { path: null, bytes: 0 }
|
||||||
alerts: listAlerts(),
|
let push = null
|
||||||
ts: Date.now(),
|
if (args?.push || args?.pushUrl || process.env.PEARDATA_PUSHGATEWAY_URL) {
|
||||||
}))
|
try {
|
||||||
|
const url = args?.pushUrl || process.env.PEARDATA_PUSHGATEWAY_URL
|
||||||
|
if (url) {
|
||||||
|
push = await pushPrometheusText(url, toPrometheusText(snapshot.latest))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
push = { error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
file: written.path,
|
||||||
|
push,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-1
@@ -13,6 +13,22 @@ import {
|
|||||||
getProcessCollector,
|
getProcessCollector,
|
||||||
isProcessCollectorEnabled,
|
isProcessCollectorEnabled,
|
||||||
} from './services/collectors/processes.js'
|
} from './services/collectors/processes.js'
|
||||||
|
import {
|
||||||
|
getParentCollector,
|
||||||
|
isParentEnabled,
|
||||||
|
} from './services/collectors/parent.js'
|
||||||
|
import {
|
||||||
|
getNginxCollector,
|
||||||
|
isNginxEnabled,
|
||||||
|
} from './services/collectors/nginx.js'
|
||||||
|
import {
|
||||||
|
getRedisCollector,
|
||||||
|
isRedisEnabled,
|
||||||
|
} from './services/collectors/redis.js'
|
||||||
|
import {
|
||||||
|
getPostgresCollector,
|
||||||
|
isPostgresEnabled,
|
||||||
|
} from './services/collectors/postgres.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'
|
||||||
import {
|
import {
|
||||||
@@ -91,10 +107,45 @@ export function startPipeline() {
|
|||||||
processes.start()
|
processes.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let parent = null
|
||||||
|
if (isParentEnabled()) {
|
||||||
|
parent = getParentCollector()
|
||||||
|
parent.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
parent.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let nginx = null
|
||||||
|
if (isNginxEnabled()) {
|
||||||
|
nginx = getNginxCollector()
|
||||||
|
nginx.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
nginx.on('error', (err) => log.warn('Nginx collector error', { error: err.message }))
|
||||||
|
nginx.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let redis = null
|
||||||
|
if (isRedisEnabled()) {
|
||||||
|
redis = getRedisCollector()
|
||||||
|
redis.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
redis.on('error', (err) => log.warn('Redis collector error', { error: err.message }))
|
||||||
|
redis.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
let postgres = null
|
||||||
|
if (isPostgresEnabled()) {
|
||||||
|
postgres = getPostgresCollector()
|
||||||
|
postgres.on('samples', (batch) => ingestBatch(store, anomalies, batch))
|
||||||
|
postgres.on('error', (err) => log.warn('Postgres collector error', { error: err.message }))
|
||||||
|
postgres.start()
|
||||||
|
}
|
||||||
|
|
||||||
log.info('Metrics pipeline started', {
|
log.info('Metrics pipeline started', {
|
||||||
hyperdb: Boolean(getDb()),
|
hyperdb: Boolean(getDb()),
|
||||||
docker: Boolean(docker),
|
docker: Boolean(docker),
|
||||||
processes: Boolean(processes),
|
processes: Boolean(processes),
|
||||||
|
parent: Boolean(parent),
|
||||||
|
nginx: Boolean(nginx),
|
||||||
|
redis: Boolean(redis),
|
||||||
|
postgres: Boolean(postgres),
|
||||||
})
|
})
|
||||||
return { collector, store, anomalies, docker, processes }
|
return { collector, store, anomalies, docker, processes, parent, nginx, redis, postgres }
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-14
@@ -20,6 +20,8 @@ import { formatAllMetrics } from './formatters.js'
|
|||||||
import { peers } from '../core/peer-registry.js'
|
import { peers } from '../core/peer-registry.js'
|
||||||
import { getDb, isHyperDbEnabled } from '../db/index.js'
|
import { getDb, isHyperDbEnabled } from '../db/index.js'
|
||||||
import { isSwarmEnabled } from '../db/replicate.js'
|
import { isSwarmEnabled } from '../db/replicate.js'
|
||||||
|
import { listRemoteDbs } from '../db/remote.js'
|
||||||
|
import { getParentCollector, isParentEnabled } from '../services/collectors/parent.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} pathname
|
* @param {string} pathname
|
||||||
@@ -52,11 +54,23 @@ export async function handleRest(pathname, query) {
|
|||||||
|
|
||||||
// ── nodes ────────────────────────────────────────────────
|
// ── nodes ────────────────────────────────────────────────
|
||||||
if (path === '/api/v2/nodes' || path === '/api/v3/nodes') {
|
if (path === '/api/v2/nodes' || path === '/api/v3/nodes') {
|
||||||
const node = nodePayload()
|
const nodes = allNodePayloads()
|
||||||
return json({ nodes: [node], ...([node][0] && {}) })
|
return json({ nodes, count: nodes.length })
|
||||||
}
|
}
|
||||||
if (path === '/api/v3/node_instances') {
|
if (path === '/api/v3/node_instances') {
|
||||||
return json({ nodes: [nodePayload()] })
|
return json({ nodes: allNodePayloads() })
|
||||||
|
}
|
||||||
|
if (path === '/api/v3/fleet') {
|
||||||
|
if (!isParentEnabled()) {
|
||||||
|
return json({ enabled: false, local: nodePayload(), children: [] })
|
||||||
|
}
|
||||||
|
const parent = getParentCollector()
|
||||||
|
return json({
|
||||||
|
enabled: true,
|
||||||
|
local: nodePayload(),
|
||||||
|
children: parent.listChildren(),
|
||||||
|
summary: parent.fleetSummary(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── contexts ─────────────────────────────────────────────
|
// ── contexts ─────────────────────────────────────────────
|
||||||
@@ -223,7 +237,27 @@ export async function handleRest(pathname, query) {
|
|||||||
|
|
||||||
// ── functions / settings stubs ───────────────────────────
|
// ── functions / settings stubs ───────────────────────────
|
||||||
if (path === '/api/v3/functions' || path === '/api/v2/functions') {
|
if (path === '/api/v3/functions' || path === '/api/v2/functions') {
|
||||||
return json({ functions: [{ name: 'collectOnce' }, { name: 'snapshot' }, { name: 'gcBuffers' }] })
|
return json({
|
||||||
|
functions: [
|
||||||
|
{ name: 'collectOnce' },
|
||||||
|
{ name: 'snapshot' },
|
||||||
|
{ name: 'exportSnapshot' },
|
||||||
|
{ name: 'prometheusPush' },
|
||||||
|
{ name: 'gcBuffers' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (path === '/api/v3/export' || path === '/api/v2/export') {
|
||||||
|
const { buildSnapshot, writeSnapshotFile, toPrometheusText } = await import(
|
||||||
|
'../services/export.js'
|
||||||
|
)
|
||||||
|
const format = (query.get('format') || 'json').toLowerCase()
|
||||||
|
const snapshot = buildSnapshot()
|
||||||
|
if (query.get('write') === '1') writeSnapshotFile(snapshot)
|
||||||
|
if (format === 'prometheus') {
|
||||||
|
return { status: 200, contentType: 'text/plain; version=0.0.4', body: toPrometheusText(snapshot.latest) }
|
||||||
|
}
|
||||||
|
return json(snapshot)
|
||||||
}
|
}
|
||||||
if (path === '/api/v3/settings' || path === '/api/v3/config') {
|
if (path === '/api/v3/settings' || path === '/api/v3/config') {
|
||||||
return json({
|
return json({
|
||||||
@@ -234,16 +268,27 @@ export async function handleRest(pathname, query) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (path === '/api/v3/stream_path') {
|
if (path === '/api/v3/stream_path') {
|
||||||
return json({
|
const pathNodes = [
|
||||||
path: [
|
{
|
||||||
{
|
node: getServerPublicKeyHex(),
|
||||||
node: getServerPublicKeyHex(),
|
hostname: os.hostname(),
|
||||||
hostname: os.hostname(),
|
hops: 0,
|
||||||
hops: 0,
|
role: isParentEnabled() ? 'parent' : 'agent',
|
||||||
role: 'agent',
|
},
|
||||||
},
|
]
|
||||||
],
|
if (isParentEnabled()) {
|
||||||
})
|
for (const child of getParentCollector().listChildren()) {
|
||||||
|
pathNodes.push({
|
||||||
|
node: child.publicKeyHex,
|
||||||
|
hostname: child.hostname || child.shortId,
|
||||||
|
hops: 1,
|
||||||
|
role: 'child',
|
||||||
|
connected: child.connected,
|
||||||
|
health: child.health,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json({ path: pathNodes })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── health / root ────────────────────────────────────────
|
// ── health / root ────────────────────────────────────────
|
||||||
@@ -258,6 +303,7 @@ export async function handleRest(pathname, query) {
|
|||||||
publicKeyHex: db.publicKeyHex,
|
publicKeyHex: db.publicKeyHex,
|
||||||
discoveryKeyHex: db.discoveryKeyHex,
|
discoveryKeyHex: db.discoveryKeyHex,
|
||||||
swarm: isSwarmEnabled(),
|
swarm: isSwarmEnabled(),
|
||||||
|
remotes: listRemoteDbs(),
|
||||||
collections: [
|
collections: [
|
||||||
'@peardata/node',
|
'@peardata/node',
|
||||||
'@peardata/peer-link',
|
'@peardata/peer-link',
|
||||||
@@ -331,9 +377,30 @@ function nodePayload() {
|
|||||||
nm: os.release(),
|
nm: os.release(),
|
||||||
},
|
},
|
||||||
st: 'online',
|
st: 'online',
|
||||||
|
role: isParentEnabled() ? 'parent' : 'agent',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allNodePayloads() {
|
||||||
|
const nodes = [nodePayload()]
|
||||||
|
if (isParentEnabled()) {
|
||||||
|
for (const child of getParentCollector().listChildren()) {
|
||||||
|
nodes.push({
|
||||||
|
nm: child.hostname || child.shortId,
|
||||||
|
nd: child.publicKeyHex.slice(0, 16),
|
||||||
|
guid: child.publicKeyHex,
|
||||||
|
st: child.connected ? 'online' : 'offline',
|
||||||
|
role: 'child',
|
||||||
|
health: child.health,
|
||||||
|
cpu: child.cpu,
|
||||||
|
ram: child.ram,
|
||||||
|
lastSeen: child.lastSeen,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
function json(body) {
|
function json(body) {
|
||||||
return { status: 200, contentType: 'application/json', body }
|
return { status: 200, contentType: 'application/json', body }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,38 @@ async function shutdown() {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const { getParentCollector, isParentEnabled } = await import(
|
||||||
|
'./services/collectors/parent.js'
|
||||||
|
)
|
||||||
|
if (isParentEnabled()) getParentCollector().stop()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { getNginxCollector, isNginxEnabled } = await import(
|
||||||
|
'./services/collectors/nginx.js'
|
||||||
|
)
|
||||||
|
if (isNginxEnabled()) getNginxCollector().stop()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { getRedisCollector, isRedisEnabled } = await import(
|
||||||
|
'./services/collectors/redis.js'
|
||||||
|
)
|
||||||
|
if (isRedisEnabled()) getRedisCollector().stop()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { getPostgresCollector, isPostgresEnabled } = await import(
|
||||||
|
'./services/collectors/postgres.js'
|
||||||
|
)
|
||||||
|
if (isPostgresEnabled()) getPostgresCollector().stop()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await flushWarmPending()
|
await flushWarmPending()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Nginx stub_status collector (service plugin spike).
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_NGINX=1
|
||||||
|
* URL: PEARDATA_NGINX_URL=http://127.0.0.1/nginx_status
|
||||||
|
*
|
||||||
|
* Charts: nginx.connections, nginx.requests
|
||||||
|
*/
|
||||||
|
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('nginx')
|
||||||
|
|
||||||
|
const CHART_CONNECTIONS = {
|
||||||
|
id: 'nginx.connections',
|
||||||
|
name: 'nginx.connections',
|
||||||
|
context: 'nginx.connections',
|
||||||
|
title: 'Nginx connections',
|
||||||
|
units: 'connections',
|
||||||
|
family: 'nginx',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8000,
|
||||||
|
plugin: 'nginx',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'active', name: 'active', algorithm: 'absolute' },
|
||||||
|
{ id: 'reading', name: 'reading', algorithm: 'absolute' },
|
||||||
|
{ id: 'writing', name: 'writing', algorithm: 'absolute' },
|
||||||
|
{ id: 'waiting', name: 'waiting', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_REQUESTS = {
|
||||||
|
id: 'nginx.requests',
|
||||||
|
name: 'nginx.requests',
|
||||||
|
context: 'nginx.requests',
|
||||||
|
title: 'Nginx requests',
|
||||||
|
units: 'requests/s',
|
||||||
|
family: 'nginx',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8010,
|
||||||
|
plugin: 'nginx',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'accepts', name: 'accepts', algorithm: 'incremental' },
|
||||||
|
{ id: 'handled', name: 'handled', algorithm: 'incremental' },
|
||||||
|
{ id: 'requests', name: 'requests', algorithm: 'incremental' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNginxEnabled() {
|
||||||
|
const v = process.env.PEARDATA_NGINX
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse nginx stub_status body.
|
||||||
|
* @param {string} body
|
||||||
|
* @returns {{ active: number, accepts: number, handled: number, requests: number, reading: number, writing: number, waiting: number }|null}
|
||||||
|
*/
|
||||||
|
export function parseNginxStubStatus(body) {
|
||||||
|
const text = String(body || '')
|
||||||
|
const active = text.match(/Active connections:\s*(\d+)/i)
|
||||||
|
const counters = text.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/m)
|
||||||
|
const states = text.match(/Reading:\s*(\d+)\s+Writing:\s*(\d+)\s+Waiting:\s*(\d+)/i)
|
||||||
|
if (!active || !counters || !states) return null
|
||||||
|
return {
|
||||||
|
active: Number(active[1]),
|
||||||
|
accepts: Number(counters[1]),
|
||||||
|
handled: Number(counters[2]),
|
||||||
|
requests: Number(counters[3]),
|
||||||
|
reading: Number(states[1]),
|
||||||
|
writing: Number(states[2]),
|
||||||
|
waiting: Number(states[3]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 NginxCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'nginx', intervalMs: opts.intervalMs })
|
||||||
|
this.url = opts.url || process.env.PEARDATA_NGINX_URL || 'http://127.0.0.1/nginx_status'
|
||||||
|
/** @type {{ accepts: number, handled: number, requests: number, wallMs: number }|null} */
|
||||||
|
this._prev = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isNginxEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
registerChart(CHART_CONNECTIONS)
|
||||||
|
registerChart(CHART_REQUESTS)
|
||||||
|
log.info('Nginx collector started', { url: this.url })
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const body = await fetchText(this.url)
|
||||||
|
const parsed = parseNginxStubStatus(body)
|
||||||
|
if (!parsed) throw new Error('unrecognized stub_status body')
|
||||||
|
const ts = Date.now()
|
||||||
|
|
||||||
|
let acceptsRate = 0
|
||||||
|
let handledRate = 0
|
||||||
|
let requestsRate = 0
|
||||||
|
if (this._prev && ts > this._prev.wallMs) {
|
||||||
|
const dt = (ts - this._prev.wallMs) / 1000
|
||||||
|
if (dt > 0) {
|
||||||
|
acceptsRate = Math.max(0, (parsed.accepts - this._prev.accepts) / dt)
|
||||||
|
handledRate = Math.max(0, (parsed.handled - this._prev.handled) / dt)
|
||||||
|
requestsRate = Math.max(0, (parsed.requests - this._prev.requests) / dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._prev = {
|
||||||
|
accepts: parsed.accepts,
|
||||||
|
handled: parsed.handled,
|
||||||
|
requests: parsed.requests,
|
||||||
|
wallMs: ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
chart: 'nginx.connections',
|
||||||
|
context: 'nginx.connections',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
active: parsed.active,
|
||||||
|
reading: parsed.reading,
|
||||||
|
writing: parsed.writing,
|
||||||
|
waiting: parsed.waiting,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'nginx.requests',
|
||||||
|
context: 'nginx.requests',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
accepts: acceptsRate,
|
||||||
|
handled: handledRate,
|
||||||
|
requests: requestsRate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {NginxCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getNginxCollector() {
|
||||||
|
if (!singleton) singleton = new NginxCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* Parent peer fleet aggregator (Phase 4 / M6 spike).
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_PARENT=1
|
||||||
|
* Children: PEARDATA_PARENT_PEERS=hex,hex (64-char agent public keys)
|
||||||
|
* Optional: PEARDATA_PARENT_SEED (admin proof), PEARDATA_PARENT_POLL_MS (default 5000)
|
||||||
|
*
|
||||||
|
* Dials child agents over HyperDHT, polls getHealth + getAllMetrics,
|
||||||
|
* and emits namespaced fleet charts into the local pipeline:
|
||||||
|
* fleet.cpu — per-child CPU %
|
||||||
|
* fleet.ram — per-child RAM used MiB
|
||||||
|
* fleet.children — connected / configured counts
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { PearDataConnection } from '../../../client/connection.js'
|
||||||
|
import { Methods } from '../../../shared/protocol.js'
|
||||||
|
import { registerChart } from '../../../shared/metrics.js'
|
||||||
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('parent')
|
||||||
|
|
||||||
|
export function isParentEnabled() {
|
||||||
|
const v = process.env.PEARDATA_PARENT
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function parseParentPeers() {
|
||||||
|
const raw = process.env.PEARDATA_PARENT_PEERS || ''
|
||||||
|
const fromEnv = raw
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter((s) => /^[0-9a-f]{64}$/.test(s))
|
||||||
|
|
||||||
|
const file = process.env.PEARDATA_PARENT_PEERS_FILE
|
||||||
|
if (!file) return [...new Set(fromEnv)]
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(file, 'utf8')
|
||||||
|
const fromFile = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.replace(/#.*$/, '').trim().toLowerCase())
|
||||||
|
.filter((s) => /^[0-9a-f]{64}$/.test(s))
|
||||||
|
return [...new Set([...fromEnv, ...fromFile])]
|
||||||
|
} catch {
|
||||||
|
return [...new Set(fromEnv)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(pk) {
|
||||||
|
return String(pk).slice(0, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} childIds
|
||||||
|
* @param {'cpu'|'ram'} kind
|
||||||
|
*/
|
||||||
|
function registerFleetChart(childIds, kind) {
|
||||||
|
const dims = childIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
algorithm: 'absolute',
|
||||||
|
}))
|
||||||
|
if (!dims.length) dims.push({ id: '_none', name: '_none', algorithm: 'absolute' })
|
||||||
|
const def =
|
||||||
|
kind === 'cpu'
|
||||||
|
? {
|
||||||
|
id: 'fleet.cpu',
|
||||||
|
name: 'fleet.cpu',
|
||||||
|
context: 'fleet.cpu',
|
||||||
|
title: 'Fleet CPU (children)',
|
||||||
|
units: 'percentage',
|
||||||
|
family: 'fleet',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 7000,
|
||||||
|
plugin: 'parent',
|
||||||
|
dimensions: dims,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: 'fleet.ram',
|
||||||
|
name: 'fleet.ram',
|
||||||
|
context: 'fleet.ram',
|
||||||
|
title: 'Fleet RAM used (children)',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'fleet',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 7010,
|
||||||
|
plugin: 'parent',
|
||||||
|
dimensions: dims,
|
||||||
|
}
|
||||||
|
registerChart(def)
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerChildrenChart() {
|
||||||
|
const def = {
|
||||||
|
id: 'fleet.children',
|
||||||
|
name: 'fleet.children',
|
||||||
|
context: 'fleet.children',
|
||||||
|
title: 'Fleet child agents',
|
||||||
|
units: 'agents',
|
||||||
|
family: 'fleet',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 7020,
|
||||||
|
plugin: 'parent',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'connected', name: 'connected', algorithm: 'absolute' },
|
||||||
|
{ id: 'configured', name: 'configured', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
registerChart(def)
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract CPU used % and RAM MiB from getAllMetrics / latest-style payload.
|
||||||
|
* @param {any} metrics
|
||||||
|
*/
|
||||||
|
export function extractChildStats(metrics) {
|
||||||
|
/** @type {{ cpu: number|null, ram: number|null }} */
|
||||||
|
const out = { cpu: null, ram: null }
|
||||||
|
const root = metrics?.body || metrics
|
||||||
|
const charts = root?.charts || root?.latest || root || {}
|
||||||
|
const cpu = charts['system.cpu']
|
||||||
|
const ram = charts['system.ram']
|
||||||
|
const cpuVals = cpu?.dimensions || cpu?.values || cpu
|
||||||
|
const ramVals = ram?.dimensions || ram?.values || ram
|
||||||
|
if (cpuVals && typeof cpuVals === 'object') {
|
||||||
|
const idle = Number(cpuVals.idle?.value ?? cpuVals.idle)
|
||||||
|
if (Number.isFinite(idle)) out.cpu = Math.max(0, 100 - idle)
|
||||||
|
}
|
||||||
|
if (ramVals && typeof ramVals === 'object') {
|
||||||
|
const used = Number(ramVals.used?.value ?? ramVals.used)
|
||||||
|
if (Number.isFinite(used)) out.ram = used
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ParentCollector extends EventEmitter {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super()
|
||||||
|
this.pollMs = opts.pollMs || Number(process.env.PEARDATA_PARENT_POLL_MS) || 5000
|
||||||
|
this.adminSeed = opts.adminSeed || process.env.PEARDATA_PARENT_SEED || null
|
||||||
|
this.peerKeys = opts.peers || parseParentPeers()
|
||||||
|
/** @type {Map<string, { conn: PearDataConnection|null, connected: boolean, hostname: string|null, health: string|null, cpu: number|null, ram: number|null, lastError: string|null, lastSeen: number|null }>} */
|
||||||
|
this.children = new Map()
|
||||||
|
this._timer = null
|
||||||
|
this._dialing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this._timer) return
|
||||||
|
for (const pk of this.peerKeys) {
|
||||||
|
this.children.set(pk, {
|
||||||
|
conn: null,
|
||||||
|
connected: false,
|
||||||
|
hostname: null,
|
||||||
|
health: null,
|
||||||
|
cpu: null,
|
||||||
|
ram: null,
|
||||||
|
lastError: null,
|
||||||
|
lastSeen: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
registerChildrenChart()
|
||||||
|
registerFleetChart(this.peerKeys.map(shortId), 'cpu')
|
||||||
|
registerFleetChart(this.peerKeys.map(shortId), 'ram')
|
||||||
|
log.info('Parent collector started', { children: this.peerKeys.length, pollMs: this.pollMs })
|
||||||
|
this._tick()
|
||||||
|
this._timer = setInterval(() => this._tick(), this.pollMs)
|
||||||
|
if (typeof this._timer.unref === 'function') this._timer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._timer) {
|
||||||
|
clearInterval(this._timer)
|
||||||
|
this._timer = null
|
||||||
|
}
|
||||||
|
for (const [pk, st] of this.children) {
|
||||||
|
if (st.conn) {
|
||||||
|
st.conn.destroy().catch(() => {})
|
||||||
|
st.conn = null
|
||||||
|
}
|
||||||
|
st.connected = false
|
||||||
|
this.children.set(pk, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listChildren() {
|
||||||
|
return [...this.children.entries()].map(([publicKeyHex, st]) => ({
|
||||||
|
publicKeyHex,
|
||||||
|
shortId: shortId(publicKeyHex),
|
||||||
|
connected: st.connected,
|
||||||
|
hostname: st.hostname,
|
||||||
|
health: st.health,
|
||||||
|
cpu: st.cpu,
|
||||||
|
ram: st.ram,
|
||||||
|
lastError: st.lastError,
|
||||||
|
lastSeen: st.lastSeen,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fleetSummary() {
|
||||||
|
const kids = this.listChildren()
|
||||||
|
const connected = kids.filter((k) => k.connected).length
|
||||||
|
const cpus = kids.map((k) => k.cpu).filter((n) => n != null)
|
||||||
|
const avgCpu = cpus.length ? cpus.reduce((a, b) => a + b, 0) / cpus.length : null
|
||||||
|
return {
|
||||||
|
configured: kids.length,
|
||||||
|
connected,
|
||||||
|
avgCpu,
|
||||||
|
status: connected === 0 ? 'offline' : connected < kids.length ? 'degraded' : 'ok',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _ensureDial(pk) {
|
||||||
|
const st = this.children.get(pk)
|
||||||
|
if (!st) return null
|
||||||
|
if (st.conn?.connected) return st.conn
|
||||||
|
try {
|
||||||
|
if (st.conn) await st.conn.destroy().catch(() => {})
|
||||||
|
const conn = new PearDataConnection(pk, {
|
||||||
|
adminSeed: this.adminSeed,
|
||||||
|
timeoutMs: 20_000,
|
||||||
|
})
|
||||||
|
await conn.connect()
|
||||||
|
st.conn = conn
|
||||||
|
st.connected = true
|
||||||
|
st.lastError = null
|
||||||
|
conn.on('disconnected', () => {
|
||||||
|
st.connected = false
|
||||||
|
st.conn = null
|
||||||
|
})
|
||||||
|
this.children.set(pk, st)
|
||||||
|
log.info('Dialed child', { peer: shortId(pk) })
|
||||||
|
return conn
|
||||||
|
} catch (err) {
|
||||||
|
st.connected = false
|
||||||
|
st.conn = null
|
||||||
|
st.lastError = err.message
|
||||||
|
this.children.set(pk, st)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _tick() {
|
||||||
|
if (this._dialing) return
|
||||||
|
this._dialing = true
|
||||||
|
try {
|
||||||
|
const ts = Date.now()
|
||||||
|
/** @type {Record<string, number|null>} */
|
||||||
|
const cpuValues = {}
|
||||||
|
/** @type {Record<string, number|null>} */
|
||||||
|
const ramValues = {}
|
||||||
|
let connected = 0
|
||||||
|
|
||||||
|
for (const pk of this.peerKeys) {
|
||||||
|
const sid = shortId(pk)
|
||||||
|
cpuValues[sid] = null
|
||||||
|
ramValues[sid] = null
|
||||||
|
const conn = await this._ensureDial(pk)
|
||||||
|
const st = this.children.get(pk)
|
||||||
|
if (!conn || !st) continue
|
||||||
|
try {
|
||||||
|
const [health, node, metrics] = await Promise.all([
|
||||||
|
conn.request(Methods.getHealth, {}),
|
||||||
|
conn.request(Methods.getNodeInfo, {}),
|
||||||
|
conn.request(Methods.getAllMetrics, { format: 'json' }),
|
||||||
|
])
|
||||||
|
const stats = extractChildStats(metrics)
|
||||||
|
st.connected = true
|
||||||
|
st.health = health?.status || 'ok'
|
||||||
|
st.hostname = node?.hostname || null
|
||||||
|
st.cpu = stats.cpu
|
||||||
|
st.ram = stats.ram
|
||||||
|
st.lastSeen = ts
|
||||||
|
st.lastError = null
|
||||||
|
cpuValues[sid] = stats.cpu
|
||||||
|
ramValues[sid] = stats.ram
|
||||||
|
connected++
|
||||||
|
this.children.set(pk, st)
|
||||||
|
} catch (err) {
|
||||||
|
st.connected = false
|
||||||
|
st.lastError = err.message
|
||||||
|
this.children.set(pk, st)
|
||||||
|
try {
|
||||||
|
await conn.destroy()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
st.conn = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = this.peerKeys.map(shortId)
|
||||||
|
registerFleetChart(ids, 'cpu')
|
||||||
|
registerFleetChart(ids, 'ram')
|
||||||
|
registerChildrenChart()
|
||||||
|
|
||||||
|
this.emit('samples', [
|
||||||
|
{
|
||||||
|
chart: 'fleet.cpu',
|
||||||
|
context: 'fleet.cpu',
|
||||||
|
ts,
|
||||||
|
values: cpuValues,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'fleet.ram',
|
||||||
|
context: 'fleet.ram',
|
||||||
|
ts,
|
||||||
|
values: ramValues,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'fleet.children',
|
||||||
|
context: 'fleet.children',
|
||||||
|
ts,
|
||||||
|
values: { connected, configured: this.peerKeys.length },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
this._dialing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {ParentCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getParentCollector() {
|
||||||
|
if (!singleton) singleton = new ParentCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Minimal collector plugin base (Phase 3 / EXTENDING.md).
|
||||||
|
*
|
||||||
|
* Subclasses implement `collect()` → sample batch, call `start()` to schedule.
|
||||||
|
*/
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { SAMPLE_INTERVAL_MS } from '../../../shared/metrics.js'
|
||||||
|
|
||||||
|
export class CollectorPlugin extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* @param {{ name: string, intervalMs?: number }} opts
|
||||||
|
*/
|
||||||
|
constructor(opts) {
|
||||||
|
super()
|
||||||
|
this.name = opts.name || 'plugin'
|
||||||
|
this.intervalMs = opts.intervalMs || Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS
|
||||||
|
this._timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
isEnabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>>|Array}
|
||||||
|
*/
|
||||||
|
async collect() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this._timer || !this.isEnabled()) return
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const batch = await this.collect()
|
||||||
|
if (batch?.length) this.emit('samples', batch)
|
||||||
|
} catch (err) {
|
||||||
|
this.emit('error', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
this._timer = setInterval(tick, this.intervalMs)
|
||||||
|
if (typeof this._timer.unref === 'function') this._timer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._timer) {
|
||||||
|
clearInterval(this._timer)
|
||||||
|
this._timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Postgres collector (service plugin spike).
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_POSTGRES=1
|
||||||
|
* TCP: PEARDATA_POSTGRES_HOST / PEARDATA_POSTGRES_PORT (default 5432)
|
||||||
|
* Optional HTTP stats (key=value lines): PEARDATA_POSTGRES_STATS_URL
|
||||||
|
*
|
||||||
|
* Charts:
|
||||||
|
* postgres.up — 1/0 + connect latency
|
||||||
|
* postgres.stats — from HTTP stats URL when set (connections, xact, tuples)
|
||||||
|
*/
|
||||||
|
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('postgres')
|
||||||
|
|
||||||
|
const CHART_UP = {
|
||||||
|
id: 'postgres.up',
|
||||||
|
name: 'postgres.up',
|
||||||
|
context: 'postgres.up',
|
||||||
|
title: 'Postgres availability',
|
||||||
|
units: 'boolean',
|
||||||
|
family: 'postgres',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8200,
|
||||||
|
plugin: 'postgres',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'up', name: 'up', algorithm: 'absolute' },
|
||||||
|
{ id: 'latency_ms', name: 'latency_ms', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_STATS = {
|
||||||
|
id: 'postgres.stats',
|
||||||
|
name: 'postgres.stats',
|
||||||
|
context: 'postgres.stats',
|
||||||
|
title: 'Postgres stats',
|
||||||
|
units: 'count',
|
||||||
|
family: 'postgres',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8210,
|
||||||
|
plugin: 'postgres',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'connections', name: 'connections', algorithm: 'absolute' },
|
||||||
|
{ id: 'xact_commit', name: 'xact_commit', algorithm: 'absolute' },
|
||||||
|
{ id: 'xact_rollback', name: 'xact_rollback', algorithm: 'absolute' },
|
||||||
|
{ id: 'tuples_returned', name: 'tuples_returned', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPostgresEnabled() {
|
||||||
|
const v = process.env.PEARDATA_POSTGRES
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse simple key=value postgres stats (custom exporter / sidecar).
|
||||||
|
* @param {string} body
|
||||||
|
* @returns {Record<string, number>}
|
||||||
|
*/
|
||||||
|
export function parsePostgresStats(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 probePostgresTcp(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 PostgresCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'postgres', intervalMs: opts.intervalMs })
|
||||||
|
this.host = opts.host || process.env.PEARDATA_POSTGRES_HOST || '127.0.0.1'
|
||||||
|
this.port = Number(opts.port || process.env.PEARDATA_POSTGRES_PORT) || 5432
|
||||||
|
this.statsUrl = opts.statsUrl || process.env.PEARDATA_POSTGRES_STATS_URL || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isPostgresEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
registerChart(CHART_UP)
|
||||||
|
if (this.statsUrl) registerChart(CHART_STATS)
|
||||||
|
log.info('Postgres collector started', {
|
||||||
|
host: this.host,
|
||||||
|
port: this.port,
|
||||||
|
statsUrl: this.statsUrl || null,
|
||||||
|
})
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const ts = Date.now()
|
||||||
|
const probe = await probePostgresTcp({ host: this.host, port: this.port })
|
||||||
|
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||||
|
const batch = [
|
||||||
|
{
|
||||||
|
chart: 'postgres.up',
|
||||||
|
context: 'postgres.up',
|
||||||
|
ts,
|
||||||
|
values: { up: probe.up, latency_ms: probe.latency_ms },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (this.statsUrl) {
|
||||||
|
try {
|
||||||
|
const body = await fetchText(this.statsUrl)
|
||||||
|
const s = parsePostgresStats(body)
|
||||||
|
registerChart(CHART_STATS)
|
||||||
|
batch.push({
|
||||||
|
chart: 'postgres.stats',
|
||||||
|
context: 'postgres.stats',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
connections: s.connections ?? s.numbackends ?? null,
|
||||||
|
xact_commit: s.xact_commit ?? null,
|
||||||
|
xact_rollback: s.xact_rollback ?? null,
|
||||||
|
tuples_returned: s.tuples_returned ?? s.tup_returned ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Postgres stats URL failed', { error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {PostgresCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getPostgresCollector() {
|
||||||
|
if (!singleton) singleton = new PostgresCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Redis INFO collector (service plugin).
|
||||||
|
*
|
||||||
|
* Enable: PEARDATA_REDIS=1
|
||||||
|
* Addr: PEARDATA_REDIS_URL=redis://127.0.0.1:6379 (or host:port)
|
||||||
|
*
|
||||||
|
* Charts: redis.memory, redis.clients, redis.stats
|
||||||
|
*/
|
||||||
|
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('redis')
|
||||||
|
|
||||||
|
const CHART_MEMORY = {
|
||||||
|
id: 'redis.memory',
|
||||||
|
name: 'redis.memory',
|
||||||
|
context: 'redis.memory',
|
||||||
|
title: 'Redis memory',
|
||||||
|
units: 'MiB',
|
||||||
|
family: 'redis',
|
||||||
|
chartType: 'area',
|
||||||
|
priority: 8100,
|
||||||
|
plugin: 'redis',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'used', name: 'used', algorithm: 'absolute' },
|
||||||
|
{ id: 'peak', name: 'peak', algorithm: 'absolute' },
|
||||||
|
{ id: 'rss', name: 'rss', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_CLIENTS = {
|
||||||
|
id: 'redis.clients',
|
||||||
|
name: 'redis.clients',
|
||||||
|
context: 'redis.clients',
|
||||||
|
title: 'Redis clients',
|
||||||
|
units: 'clients',
|
||||||
|
family: 'redis',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8110,
|
||||||
|
plugin: 'redis',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'connected', name: 'connected', algorithm: 'absolute' },
|
||||||
|
{ id: 'blocked', name: 'blocked', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_STATS = {
|
||||||
|
id: 'redis.stats',
|
||||||
|
name: 'redis.stats',
|
||||||
|
context: 'redis.stats',
|
||||||
|
title: 'Redis ops',
|
||||||
|
units: 'ops/s',
|
||||||
|
family: 'redis',
|
||||||
|
chartType: 'line',
|
||||||
|
priority: 8120,
|
||||||
|
plugin: 'redis',
|
||||||
|
dimensions: [
|
||||||
|
{ id: 'ops', name: 'ops', algorithm: 'absolute' },
|
||||||
|
{ id: 'hit_rate', name: 'hit_rate', algorithm: 'absolute' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRedisEnabled() {
|
||||||
|
const v = process.env.PEARDATA_REDIS
|
||||||
|
return v === '1' || v === 'on' || v === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} urlOrHost
|
||||||
|
* @returns {{ host: string, port: number }}
|
||||||
|
*/
|
||||||
|
export function parseRedisAddr(urlOrHost) {
|
||||||
|
const raw = String(urlOrHost || '127.0.0.1:6379').trim()
|
||||||
|
if (raw.includes('://')) {
|
||||||
|
try {
|
||||||
|
const u = new URL(raw)
|
||||||
|
return {
|
||||||
|
host: u.hostname || '127.0.0.1',
|
||||||
|
port: Number(u.port) || 6379,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [host, port] = raw.split(':')
|
||||||
|
return { host: host || '127.0.0.1', port: Number(port) || 6379 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} body Redis INFO text
|
||||||
|
* @returns {Record<string, string>}
|
||||||
|
*/
|
||||||
|
export function parseRedisInfo(body) {
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const out = {}
|
||||||
|
for (const line of String(body || '').split(/\r?\n/)) {
|
||||||
|
if (!line || line.startsWith('#')) continue
|
||||||
|
const i = line.indexOf(':')
|
||||||
|
if (i < 0) continue
|
||||||
|
out[line.slice(0, i)] = line.slice(i + 1).trim()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToMiB(n) {
|
||||||
|
return n / (1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ host: string, port: number }} addr
|
||||||
|
* @param {number} [timeoutMs]
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export function fetchRedisInfo(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('redis timeout')), timeoutMs)
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.write('INFO\r\n')
|
||||||
|
})
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buf += chunk.toString('utf8')
|
||||||
|
// RESP bulk: $<len>\r\n<body>\r\n or plain INFO dump
|
||||||
|
if (buf.includes('redis_version:') || buf.includes('used_memory:')) {
|
||||||
|
const idx = buf.indexOf('$')
|
||||||
|
if (idx === 0) {
|
||||||
|
const nl = buf.indexOf('\r\n')
|
||||||
|
if (nl > 0) {
|
||||||
|
const body = buf.slice(nl + 2)
|
||||||
|
if (body.includes('redis_version:') || body.length > 200) done(null, body)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
done(null, buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
socket.on('error', (err) => done(err))
|
||||||
|
socket.on('end', () => {
|
||||||
|
if (buf) done(null, buf)
|
||||||
|
else done(new Error('redis closed with no data'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RedisCollector extends CollectorPlugin {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super({ name: 'redis', intervalMs: opts.intervalMs })
|
||||||
|
this.addr = parseRedisAddr(opts.url || process.env.PEARDATA_REDIS_URL || '127.0.0.1:6379')
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return isRedisEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
registerChart(CHART_MEMORY)
|
||||||
|
registerChart(CHART_CLIENTS)
|
||||||
|
registerChart(CHART_STATS)
|
||||||
|
log.info('Redis collector started', this.addr)
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect() {
|
||||||
|
const raw = await fetchRedisInfo(this.addr)
|
||||||
|
const info = parseRedisInfo(raw)
|
||||||
|
const ts = Date.now()
|
||||||
|
const hits = Number(info.keyspace_hits) || 0
|
||||||
|
const misses = Number(info.keyspace_misses) || 0
|
||||||
|
const denom = hits + misses
|
||||||
|
const hitRate = denom > 0 ? (hits / denom) * 100 : 0
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
chart: 'redis.memory',
|
||||||
|
context: 'redis.memory',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
used: bytesToMiB(Number(info.used_memory) || 0),
|
||||||
|
peak: bytesToMiB(Number(info.used_memory_peak) || 0),
|
||||||
|
rss: bytesToMiB(Number(info.used_memory_rss) || 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'redis.clients',
|
||||||
|
context: 'redis.clients',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
connected: Number(info.connected_clients) || 0,
|
||||||
|
blocked: Number(info.blocked_clients) || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'redis.stats',
|
||||||
|
context: 'redis.stats',
|
||||||
|
ts,
|
||||||
|
values: {
|
||||||
|
ops: Number(info.instantaneous_ops_per_sec) || 0,
|
||||||
|
hit_rate: hitRate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {RedisCollector|null} */
|
||||||
|
let singleton = null
|
||||||
|
|
||||||
|
export function getRedisCollector() {
|
||||||
|
if (!singleton) singleton = new RedisCollector()
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Snapshot export + Prometheus text push (Pushgateway-compatible).
|
||||||
|
*
|
||||||
|
* Jobs:
|
||||||
|
* exportSnapshot — build JSON snapshot (optional write to PEARDATA_EXPORT_DIR)
|
||||||
|
* prometheusPush — POST exposition text to PEARDATA_PUSHGATEWAY_URL
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* PEARDATA_EXPORT_DIR — directory for snapshot-*.json
|
||||||
|
* PEARDATA_PUSHGATEWAY_URL — e.g. http://127.0.0.1:9091/metrics/job/peardata
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import http from 'http'
|
||||||
|
import https from 'https'
|
||||||
|
import os from 'os'
|
||||||
|
import b4a from 'b4a'
|
||||||
|
import { getStore } from './store.js'
|
||||||
|
import { getCollector } from './collector.js'
|
||||||
|
import { getAnomalyEngine } from './anomaly.js'
|
||||||
|
import { listAlerts } from './alerts.js'
|
||||||
|
import { getServerPublicKeyHex } from '../core/auth-keys.js'
|
||||||
|
import { APP_NAME, APP_VERSION } from '../../shared/protocol.js'
|
||||||
|
import { CHART_BY_ID } from '../../shared/metrics.js'
|
||||||
|
import logger from '../utils/logger.js'
|
||||||
|
|
||||||
|
const log = logger.child('export')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function buildSnapshot() {
|
||||||
|
const store = getStore()
|
||||||
|
const collector = getCollector()
|
||||||
|
const pk = (() => {
|
||||||
|
try {
|
||||||
|
return getServerPublicKeyHex()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
app: APP_NAME,
|
||||||
|
version: APP_VERSION,
|
||||||
|
hostname: os.hostname(),
|
||||||
|
publicKeyHex: pk,
|
||||||
|
node: collector.getNodeInfo(pk, APP_VERSION),
|
||||||
|
latest: store.latestValues(),
|
||||||
|
health: getAnomalyEngine().getHealth(),
|
||||||
|
alerts: listAlerts(),
|
||||||
|
ts: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert latest values to Prometheus exposition format.
|
||||||
|
* @param {Record<string, { ts: number, values: Record<string, number|null> }>} [latest]
|
||||||
|
*/
|
||||||
|
export function toPrometheusText(latest) {
|
||||||
|
const data = latest || getStore().latestValues()
|
||||||
|
const lines = [
|
||||||
|
`# HELP peardata_info PearData agent info`,
|
||||||
|
`# TYPE peardata_info gauge`,
|
||||||
|
`peardata_info{version="${APP_VERSION}",hostname="${os.hostname()}"} 1`,
|
||||||
|
]
|
||||||
|
for (const [chart, point] of Object.entries(data)) {
|
||||||
|
const metric = `peardata_${chart.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||||
|
const ctx = CHART_BY_ID.get(chart)?.context || chart
|
||||||
|
for (const [dim, val] of Object.entries(point.values || {})) {
|
||||||
|
if (val == null || Number.isNaN(val)) continue
|
||||||
|
lines.push(
|
||||||
|
`${metric}{dimension="${dim}",context="${ctx}"} ${val}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} snapshot
|
||||||
|
* @returns {{ path: string|null, bytes: number }}
|
||||||
|
*/
|
||||||
|
export function writeSnapshotFile(snapshot) {
|
||||||
|
const dir = process.env.PEARDATA_EXPORT_DIR
|
||||||
|
if (!dir) return { path: null, bytes: 0 }
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
const file = path.join(dir, `snapshot-${snapshot.ts || Date.now()}.json`)
|
||||||
|
const body = JSON.stringify(snapshot, null, 2)
|
||||||
|
fs.writeFileSync(file, body)
|
||||||
|
return { path: file, bytes: body.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST Prometheus text to Pushgateway (or any text receiver).
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string} body
|
||||||
|
* @param {number} [timeoutMs]
|
||||||
|
*/
|
||||||
|
export function pushPrometheusText(url, body, timeoutMs = 10_000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const u = new URL(url)
|
||||||
|
const mod = u.protocol === 'https:' ? https : http
|
||||||
|
const req = mod.request(
|
||||||
|
{
|
||||||
|
hostname: u.hostname,
|
||||||
|
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: u.pathname + u.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; version=0.0.4',
|
||||||
|
'Content-Length': b4a.byteLength(body),
|
||||||
|
},
|
||||||
|
timeout: timeoutMs,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', (c) => {
|
||||||
|
data += c
|
||||||
|
})
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
reject(new Error(`push failed HTTP ${res.statusCode}: ${data.slice(0, 200)}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve({ status: res.statusCode || 200, bytes: body.length })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy()
|
||||||
|
reject(new Error('push timeout'))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
req.write(body)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job handler: build + optional file write.
|
||||||
|
*/
|
||||||
|
export async function jobExportSnapshot() {
|
||||||
|
const snapshot = buildSnapshot()
|
||||||
|
const written = writeSnapshotFile(snapshot)
|
||||||
|
if (written.path) log.info('Wrote snapshot', written)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
ts: snapshot.ts,
|
||||||
|
charts: Object.keys(snapshot.latest || {}).length,
|
||||||
|
file: written.path,
|
||||||
|
bytes: written.bytes,
|
||||||
|
snapshot: written.path ? undefined : snapshot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job handler: push Prometheus text to gateway.
|
||||||
|
* @param {{ url?: string }} [args]
|
||||||
|
*/
|
||||||
|
export async function jobPrometheusPush(args = {}) {
|
||||||
|
const url = args.url || process.env.PEARDATA_PUSHGATEWAY_URL
|
||||||
|
if (!url) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'PEARDATA_PUSHGATEWAY_URL (or args.url) required',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = toPrometheusText()
|
||||||
|
const res = await pushPrometheusText(url, body)
|
||||||
|
log.info('Prometheus push ok', { url, ...res })
|
||||||
|
return { ok: true, ...res, url, lines: body.split('\n').length }
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Pushes } from '../../shared/protocol.js'
|
|||||||
import { peers } from '../core/peer-registry.js'
|
import { peers } from '../core/peer-registry.js'
|
||||||
import { getCollector } from './collector.js'
|
import { getCollector } from './collector.js'
|
||||||
import { getStore } from './store.js'
|
import { getStore } from './store.js'
|
||||||
|
import { jobExportSnapshot, jobPrometheusPush } from './export.js'
|
||||||
|
|
||||||
const JOB_HANDLERS = {
|
const JOB_HANDLERS = {
|
||||||
collectOnce: async () => {
|
collectOnce: async () => {
|
||||||
@@ -17,6 +18,8 @@ const JOB_HANDLERS = {
|
|||||||
snapshot: async () => {
|
snapshot: async () => {
|
||||||
return { ok: true, latest: getStore().latestValues() }
|
return { ok: true, latest: getStore().latestValues() }
|
||||||
},
|
},
|
||||||
|
exportSnapshot: jobExportSnapshot,
|
||||||
|
prometheusPush: jobPrometheusPush,
|
||||||
gcBuffers: async () => {
|
gcBuffers: async () => {
|
||||||
// ring buffers self-trim; placeholder for future disk GC
|
// ring buffers self-trim; placeholder for future disk GC
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { SAMPLE_INTERVAL_MS, CHART_BY_ID, chartSummary } from '../../shared/metrics.js'
|
import { SAMPLE_INTERVAL_MS, CHART_BY_ID, chartSummary } from '../../shared/metrics.js'
|
||||||
import { getDb } from '../db/index.js'
|
import { getDb } from '../db/index.js'
|
||||||
|
import { queryRemoteMetricPoints, listRemoteDbs } from '../db/remote.js'
|
||||||
|
|
||||||
function envInt(name, fallback) {
|
function envInt(name, fallback) {
|
||||||
const n = Number(process.env[name])
|
const n = Number(process.env[name])
|
||||||
@@ -160,6 +161,26 @@ export class MetricStore extends EventEmitter {
|
|||||||
// keep memory result
|
// keep memory result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Linked peer warm pull (replicated Corestore / remote bee)
|
||||||
|
if ((!windowed.length || source !== 'hyperdb-warm') && listRemoteDbs().length) {
|
||||||
|
try {
|
||||||
|
const remote = await queryRemoteMetricPoints({
|
||||||
|
chart,
|
||||||
|
afterMs,
|
||||||
|
beforeMs,
|
||||||
|
limit: Math.max(opts.points || 60, 10_000),
|
||||||
|
tier: 1,
|
||||||
|
})
|
||||||
|
if (remote.length) {
|
||||||
|
if (!windowed.length || remote.length >= windowed.length || windowExceedsMemory) {
|
||||||
|
windowed = remote
|
||||||
|
source = 'hyperdb-remote'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep prior result
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!windowed.length && src.length) {
|
if (!windowed.length && src.length) {
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export const MethodRoles = Object.freeze({
|
|||||||
linkPeer: Roles.admin,
|
linkPeer: Roles.admin,
|
||||||
unlinkPeer: Roles.admin,
|
unlinkPeer: Roles.admin,
|
||||||
|
|
||||||
|
// parent / fleet aggregation
|
||||||
|
getFleetHealth: Roles.viewer,
|
||||||
|
listChildPeers: Roles.viewer,
|
||||||
|
|
||||||
// admin / fleet
|
// admin / fleet
|
||||||
mintInvite: Roles.admin,
|
mintInvite: Roles.admin,
|
||||||
listPeers: Roles.admin,
|
listPeers: Roles.admin,
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ export function validateMethodArgs(method, args = {}) {
|
|||||||
case 'exportSnapshot':
|
case 'exportSnapshot':
|
||||||
case 'getDbInfo':
|
case 'getDbInfo':
|
||||||
case 'listPeerLinks':
|
case 'listPeerLinks':
|
||||||
|
case 'getFleetHealth':
|
||||||
|
case 'listChildPeers':
|
||||||
return { ok: true, args }
|
return { ok: true, args }
|
||||||
|
|
||||||
case 'linkPeer': {
|
case 'linkPeer': {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// This file is autogenerated by the hyperschema compiler
|
// This file is autogenerated by the hyperschema compiler
|
||||||
// Schema Version: 1
|
// Schema Version: 2
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable quotes */
|
/* eslint-disable quotes */
|
||||||
/* eslint-disable space-before-function-paren */
|
/* eslint-disable space-before-function-paren */
|
||||||
|
|
||||||
import { c } from 'hyperschema/runtime'
|
import { c } from 'hyperschema/runtime'
|
||||||
|
|
||||||
const VERSION = 1
|
const VERSION = 2
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
let version = VERSION
|
let version = VERSION
|
||||||
@@ -75,7 +75,7 @@ const encoding1 = {
|
|||||||
preencode(state, m) {
|
preencode(state, m) {
|
||||||
c.string.preencode(state, m.localNodeId)
|
c.string.preencode(state, m.localNodeId)
|
||||||
c.string.preencode(state, m.remotePublicKey)
|
c.string.preencode(state, m.remotePublicKey)
|
||||||
state.end++ // max flag is 16 so always one byte
|
state.end++ // max flag is 32 so always one byte
|
||||||
|
|
||||||
if (m.role) c.string.preencode(state, m.role)
|
if (m.role) c.string.preencode(state, m.role)
|
||||||
if (m.alias) c.string.preencode(state, m.alias)
|
if (m.alias) c.string.preencode(state, m.alias)
|
||||||
@@ -83,6 +83,7 @@ const encoding1 = {
|
|||||||
if (m.syncMode) c.string.preencode(state, m.syncMode)
|
if (m.syncMode) c.string.preencode(state, m.syncMode)
|
||||||
c.uint.preencode(state, m.linkedAt)
|
c.uint.preencode(state, m.linkedAt)
|
||||||
if (m.lastSeen) c.uint.preencode(state, m.lastSeen)
|
if (m.lastSeen) c.uint.preencode(state, m.lastSeen)
|
||||||
|
if (version >= 2 && m.discoveryKeyHex) c.string.preencode(state, m.discoveryKeyHex)
|
||||||
},
|
},
|
||||||
encode(state, m) {
|
encode(state, m) {
|
||||||
const flags =
|
const flags =
|
||||||
@@ -90,7 +91,8 @@ const encoding1 = {
|
|||||||
(m.alias ? 2 : 0) |
|
(m.alias ? 2 : 0) |
|
||||||
(m.dbKeyHex ? 4 : 0) |
|
(m.dbKeyHex ? 4 : 0) |
|
||||||
(m.syncMode ? 8 : 0) |
|
(m.syncMode ? 8 : 0) |
|
||||||
(m.lastSeen ? 16 : 0)
|
(m.lastSeen ? 16 : 0) |
|
||||||
|
((version >= 2 && m.discoveryKeyHex) ? 32 : 0)
|
||||||
|
|
||||||
c.string.encode(state, m.localNodeId)
|
c.string.encode(state, m.localNodeId)
|
||||||
c.string.encode(state, m.remotePublicKey)
|
c.string.encode(state, m.remotePublicKey)
|
||||||
@@ -102,6 +104,7 @@ const encoding1 = {
|
|||||||
if (m.syncMode) c.string.encode(state, m.syncMode)
|
if (m.syncMode) c.string.encode(state, m.syncMode)
|
||||||
c.uint.encode(state, m.linkedAt)
|
c.uint.encode(state, m.linkedAt)
|
||||||
if (m.lastSeen) c.uint.encode(state, m.lastSeen)
|
if (m.lastSeen) c.uint.encode(state, m.lastSeen)
|
||||||
|
if (version >= 2 && m.discoveryKeyHex) c.string.encode(state, m.discoveryKeyHex)
|
||||||
},
|
},
|
||||||
decode(state) {
|
decode(state) {
|
||||||
const r0 = c.string.decode(state)
|
const r0 = c.string.decode(state)
|
||||||
@@ -116,7 +119,8 @@ const encoding1 = {
|
|||||||
dbKeyHex: (flags & 4) !== 0 ? c.string.decode(state) : null,
|
dbKeyHex: (flags & 4) !== 0 ? c.string.decode(state) : null,
|
||||||
syncMode: (flags & 8) !== 0 ? c.string.decode(state) : null,
|
syncMode: (flags & 8) !== 0 ? c.string.decode(state) : null,
|
||||||
linkedAt: c.uint.decode(state),
|
linkedAt: c.uint.decode(state),
|
||||||
lastSeen: (flags & 16) !== 0 ? c.uint.decode(state) : 0
|
lastSeen: (flags & 16) !== 0 ? c.uint.decode(state) : 0,
|
||||||
|
discoveryKeyHex: (version >= 2 && (flags & 32) !== 0) ? c.string.decode(state) : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,7 +368,7 @@ const encoding6 = {
|
|||||||
// @peardata/peer-link/hyperdb#1
|
// @peardata/peer-link/hyperdb#1
|
||||||
const encoding7 = {
|
const encoding7 = {
|
||||||
preencode(state, m) {
|
preencode(state, m) {
|
||||||
state.end++ // max flag is 16 so always one byte
|
state.end++ // max flag is 32 so always one byte
|
||||||
|
|
||||||
if (m.role) c.string.preencode(state, m.role)
|
if (m.role) c.string.preencode(state, m.role)
|
||||||
if (m.alias) c.string.preencode(state, m.alias)
|
if (m.alias) c.string.preencode(state, m.alias)
|
||||||
@@ -372,6 +376,7 @@ const encoding7 = {
|
|||||||
if (m.syncMode) c.string.preencode(state, m.syncMode)
|
if (m.syncMode) c.string.preencode(state, m.syncMode)
|
||||||
c.uint.preencode(state, m.linkedAt)
|
c.uint.preencode(state, m.linkedAt)
|
||||||
if (m.lastSeen) c.uint.preencode(state, m.lastSeen)
|
if (m.lastSeen) c.uint.preencode(state, m.lastSeen)
|
||||||
|
if (version >= 2 && m.discoveryKeyHex) c.string.preencode(state, m.discoveryKeyHex)
|
||||||
},
|
},
|
||||||
encode(state, m) {
|
encode(state, m) {
|
||||||
const flags =
|
const flags =
|
||||||
@@ -379,7 +384,8 @@ const encoding7 = {
|
|||||||
(m.alias ? 2 : 0) |
|
(m.alias ? 2 : 0) |
|
||||||
(m.dbKeyHex ? 4 : 0) |
|
(m.dbKeyHex ? 4 : 0) |
|
||||||
(m.syncMode ? 8 : 0) |
|
(m.syncMode ? 8 : 0) |
|
||||||
(m.lastSeen ? 16 : 0)
|
(m.lastSeen ? 16 : 0) |
|
||||||
|
((version >= 2 && m.discoveryKeyHex) ? 32 : 0)
|
||||||
|
|
||||||
c.uint.encode(state, flags)
|
c.uint.encode(state, flags)
|
||||||
|
|
||||||
@@ -389,6 +395,7 @@ const encoding7 = {
|
|||||||
if (m.syncMode) c.string.encode(state, m.syncMode)
|
if (m.syncMode) c.string.encode(state, m.syncMode)
|
||||||
c.uint.encode(state, m.linkedAt)
|
c.uint.encode(state, m.linkedAt)
|
||||||
if (m.lastSeen) c.uint.encode(state, m.lastSeen)
|
if (m.lastSeen) c.uint.encode(state, m.lastSeen)
|
||||||
|
if (version >= 2 && m.discoveryKeyHex) c.string.encode(state, m.discoveryKeyHex)
|
||||||
},
|
},
|
||||||
decode(state) {
|
decode(state) {
|
||||||
const flags = c.uint.decode(state)
|
const flags = c.uint.decode(state)
|
||||||
@@ -401,7 +408,8 @@ const encoding7 = {
|
|||||||
dbKeyHex: (flags & 4) !== 0 ? c.string.decode(state) : null,
|
dbKeyHex: (flags & 4) !== 0 ? c.string.decode(state) : null,
|
||||||
syncMode: (flags & 8) !== 0 ? c.string.decode(state) : null,
|
syncMode: (flags & 8) !== 0 ? c.string.decode(state) : null,
|
||||||
linkedAt: c.uint.decode(state),
|
linkedAt: c.uint.decode(state),
|
||||||
lastSeen: (flags & 16) !== 0 ? c.uint.decode(state) : 0
|
lastSeen: (flags & 16) !== 0 ? c.uint.decode(state) : 0,
|
||||||
|
discoveryKeyHex: (version >= 2 && (flags & 32) !== 0) ? c.string.decode(state) : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// This file is autogenerated by the hyperschema compiler
|
// This file is autogenerated by the hyperschema compiler
|
||||||
// Schema Version: 1
|
// Schema Version: 2
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable quotes */
|
/* eslint-disable quotes */
|
||||||
/* eslint-disable space-before-function-paren */
|
/* eslint-disable space-before-function-paren */
|
||||||
|
|
||||||
import { c } from 'hyperschema/runtime'
|
import { c } from 'hyperschema/runtime'
|
||||||
|
|
||||||
const VERSION = 1
|
const VERSION = 2
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
let version = VERSION
|
let version = VERSION
|
||||||
@@ -75,7 +75,7 @@ const encoding1 = {
|
|||||||
preencode(state, m) {
|
preencode(state, m) {
|
||||||
c.string.preencode(state, m.localNodeId)
|
c.string.preencode(state, m.localNodeId)
|
||||||
c.string.preencode(state, m.remotePublicKey)
|
c.string.preencode(state, m.remotePublicKey)
|
||||||
state.end++ // max flag is 16 so always one byte
|
state.end++ // max flag is 32 so always one byte
|
||||||
|
|
||||||
if (m.role) c.string.preencode(state, m.role)
|
if (m.role) c.string.preencode(state, m.role)
|
||||||
if (m.alias) c.string.preencode(state, m.alias)
|
if (m.alias) c.string.preencode(state, m.alias)
|
||||||
@@ -83,6 +83,7 @@ const encoding1 = {
|
|||||||
if (m.syncMode) c.string.preencode(state, m.syncMode)
|
if (m.syncMode) c.string.preencode(state, m.syncMode)
|
||||||
c.uint.preencode(state, m.linkedAt)
|
c.uint.preencode(state, m.linkedAt)
|
||||||
if (m.lastSeen) c.uint.preencode(state, m.lastSeen)
|
if (m.lastSeen) c.uint.preencode(state, m.lastSeen)
|
||||||
|
if (version >= 2 && m.discoveryKeyHex) c.string.preencode(state, m.discoveryKeyHex)
|
||||||
},
|
},
|
||||||
encode(state, m) {
|
encode(state, m) {
|
||||||
const flags =
|
const flags =
|
||||||
@@ -90,7 +91,8 @@ const encoding1 = {
|
|||||||
(m.alias ? 2 : 0) |
|
(m.alias ? 2 : 0) |
|
||||||
(m.dbKeyHex ? 4 : 0) |
|
(m.dbKeyHex ? 4 : 0) |
|
||||||
(m.syncMode ? 8 : 0) |
|
(m.syncMode ? 8 : 0) |
|
||||||
(m.lastSeen ? 16 : 0)
|
(m.lastSeen ? 16 : 0) |
|
||||||
|
((version >= 2 && m.discoveryKeyHex) ? 32 : 0)
|
||||||
|
|
||||||
c.string.encode(state, m.localNodeId)
|
c.string.encode(state, m.localNodeId)
|
||||||
c.string.encode(state, m.remotePublicKey)
|
c.string.encode(state, m.remotePublicKey)
|
||||||
@@ -102,6 +104,7 @@ const encoding1 = {
|
|||||||
if (m.syncMode) c.string.encode(state, m.syncMode)
|
if (m.syncMode) c.string.encode(state, m.syncMode)
|
||||||
c.uint.encode(state, m.linkedAt)
|
c.uint.encode(state, m.linkedAt)
|
||||||
if (m.lastSeen) c.uint.encode(state, m.lastSeen)
|
if (m.lastSeen) c.uint.encode(state, m.lastSeen)
|
||||||
|
if (version >= 2 && m.discoveryKeyHex) c.string.encode(state, m.discoveryKeyHex)
|
||||||
},
|
},
|
||||||
decode(state) {
|
decode(state) {
|
||||||
const r0 = c.string.decode(state)
|
const r0 = c.string.decode(state)
|
||||||
@@ -116,7 +119,8 @@ const encoding1 = {
|
|||||||
dbKeyHex: (flags & 4) !== 0 ? c.string.decode(state) : null,
|
dbKeyHex: (flags & 4) !== 0 ? c.string.decode(state) : null,
|
||||||
syncMode: (flags & 8) !== 0 ? c.string.decode(state) : null,
|
syncMode: (flags & 8) !== 0 ? c.string.decode(state) : null,
|
||||||
linkedAt: c.uint.decode(state),
|
linkedAt: c.uint.decode(state),
|
||||||
lastSeen: (flags & 16) !== 0 ? c.uint.decode(state) : 0
|
lastSeen: (flags & 16) !== 0 ? c.uint.decode(state) : 0,
|
||||||
|
discoveryKeyHex: (version >= 2 && (flags & 32) !== 0) ? c.string.decode(state) : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"schema": [
|
"schema": [
|
||||||
{
|
{
|
||||||
"name": "node",
|
"name": "node",
|
||||||
@@ -122,6 +122,12 @@
|
|||||||
"required": false,
|
"required": false,
|
||||||
"type": "uint",
|
"type": "uint",
|
||||||
"version": 1
|
"version": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "discoveryKeyHex",
|
||||||
|
"required": false,
|
||||||
|
"type": "string",
|
||||||
|
"version": 2
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import test from 'brittle'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import { getStore } from '../server/services/store.js'
|
||||||
|
import {
|
||||||
|
toPrometheusText,
|
||||||
|
writeSnapshotFile,
|
||||||
|
buildSnapshot,
|
||||||
|
} from '../server/services/export.js'
|
||||||
|
|
||||||
|
test('toPrometheusText includes peardata_info and dimensions', (t) => {
|
||||||
|
const text = toPrometheusText({
|
||||||
|
'system.cpu': {
|
||||||
|
ts: Date.now(),
|
||||||
|
values: { user: 10, idle: 90 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
t.ok(text.includes('peardata_info'))
|
||||||
|
t.ok(text.includes('peardata_system_cpu'))
|
||||||
|
t.ok(text.includes('dimension="idle"'))
|
||||||
|
t.ok(text.includes(' 90'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('writeSnapshotFile respects PEARDATA_EXPORT_DIR', (t) => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peardata-export-'))
|
||||||
|
const prev = process.env.PEARDATA_EXPORT_DIR
|
||||||
|
process.env.PEARDATA_EXPORT_DIR = dir
|
||||||
|
const snap = { ts: 1234567890, latest: {}, success: true }
|
||||||
|
const written = writeSnapshotFile(snap)
|
||||||
|
t.ok(written.path)
|
||||||
|
t.ok(fs.existsSync(written.path))
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(written.path, 'utf8'))
|
||||||
|
t.is(parsed.ts, 1234567890)
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true })
|
||||||
|
if (prev == null) delete process.env.PEARDATA_EXPORT_DIR
|
||||||
|
else process.env.PEARDATA_EXPORT_DIR = prev
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildSnapshot returns latest from store', (t) => {
|
||||||
|
// ensure singleton has something
|
||||||
|
const store = getStore()
|
||||||
|
store.ingest([
|
||||||
|
{
|
||||||
|
chart: 'system.ram',
|
||||||
|
context: 'system.ram',
|
||||||
|
ts: Date.now(),
|
||||||
|
values: { used: 100, free: 200, cached: 50, buffers: 10 },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const snap = buildSnapshot()
|
||||||
|
t.ok(snap.success)
|
||||||
|
t.ok(snap.latest['system.ram'])
|
||||||
|
t.ok(snap.health)
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import test from 'brittle'
|
||||||
|
import { parseNginxStubStatus, isNginxEnabled } from '../server/services/collectors/nginx.js'
|
||||||
|
|
||||||
|
const SAMPLE = `Active connections: 291
|
||||||
|
server accepts handled requests
|
||||||
|
16630948 16630948 31070465
|
||||||
|
Reading: 6 Writing: 179 Waiting: 106
|
||||||
|
`
|
||||||
|
|
||||||
|
test('parseNginxStubStatus', (t) => {
|
||||||
|
const p = parseNginxStubStatus(SAMPLE)
|
||||||
|
t.ok(p)
|
||||||
|
t.is(p.active, 291)
|
||||||
|
t.is(p.accepts, 16630948)
|
||||||
|
t.is(p.handled, 16630948)
|
||||||
|
t.is(p.requests, 31070465)
|
||||||
|
t.is(p.reading, 6)
|
||||||
|
t.is(p.writing, 179)
|
||||||
|
t.is(p.waiting, 106)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseNginxStubStatus rejects junk', (t) => {
|
||||||
|
t.is(parseNginxStubStatus('hello'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isNginxEnabled', (t) => {
|
||||||
|
const prev = process.env.PEARDATA_NGINX
|
||||||
|
delete process.env.PEARDATA_NGINX
|
||||||
|
t.absent(isNginxEnabled())
|
||||||
|
process.env.PEARDATA_NGINX = '1'
|
||||||
|
t.ok(isNginxEnabled())
|
||||||
|
if (prev == null) delete process.env.PEARDATA_NGINX
|
||||||
|
else process.env.PEARDATA_NGINX = prev
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import test from 'brittle'
|
||||||
|
import {
|
||||||
|
extractChildStats,
|
||||||
|
parseParentPeers,
|
||||||
|
isParentEnabled,
|
||||||
|
} from '../server/services/collectors/parent.js'
|
||||||
|
|
||||||
|
test('isParentEnabled', (t) => {
|
||||||
|
const prev = process.env.PEARDATA_PARENT
|
||||||
|
delete process.env.PEARDATA_PARENT
|
||||||
|
t.absent(isParentEnabled())
|
||||||
|
process.env.PEARDATA_PARENT = '1'
|
||||||
|
t.ok(isParentEnabled())
|
||||||
|
if (prev == null) delete process.env.PEARDATA_PARENT
|
||||||
|
else process.env.PEARDATA_PARENT = prev
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseParentPeers filters hex keys', (t) => {
|
||||||
|
const prev = process.env.PEARDATA_PARENT_PEERS
|
||||||
|
const prevFile = process.env.PEARDATA_PARENT_PEERS_FILE
|
||||||
|
delete process.env.PEARDATA_PARENT_PEERS_FILE
|
||||||
|
process.env.PEARDATA_PARENT_PEERS = `aa${'b'.repeat(62)}, not-a-key, ${'c'.repeat(64)}`
|
||||||
|
const list = parseParentPeers()
|
||||||
|
t.is(list.length, 2)
|
||||||
|
t.ok(list[0].startsWith('aa'))
|
||||||
|
if (prev == null) delete process.env.PEARDATA_PARENT_PEERS
|
||||||
|
else process.env.PEARDATA_PARENT_PEERS = prev
|
||||||
|
if (prevFile == null) delete process.env.PEARDATA_PARENT_PEERS_FILE
|
||||||
|
else process.env.PEARDATA_PARENT_PEERS_FILE = prevFile
|
||||||
|
})
|
||||||
|
|
||||||
|
test('extractChildStats from getAllMetrics body', (t) => {
|
||||||
|
const stats = extractChildStats({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: {
|
||||||
|
charts: {
|
||||||
|
'system.cpu': { dimensions: { idle: 72, user: 20, system: 8 } },
|
||||||
|
'system.ram': { dimensions: { used: 2048, free: 1024 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
t.is(stats.cpu, 28)
|
||||||
|
t.is(stats.ram, 2048)
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import test from 'brittle'
|
||||||
|
import {
|
||||||
|
parsePostgresStats,
|
||||||
|
isPostgresEnabled,
|
||||||
|
} from '../server/services/collectors/postgres.js'
|
||||||
|
|
||||||
|
test('parsePostgresStats', (t) => {
|
||||||
|
const s = parsePostgresStats(`
|
||||||
|
# comment
|
||||||
|
connections=18
|
||||||
|
xact_commit: 1000
|
||||||
|
xact_rollback=2
|
||||||
|
tuples_returned=50000
|
||||||
|
`)
|
||||||
|
t.is(s.connections, 18)
|
||||||
|
t.is(s.xact_commit, 1000)
|
||||||
|
t.is(s.xact_rollback, 2)
|
||||||
|
t.is(s.tuples_returned, 50000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isPostgresEnabled', (t) => {
|
||||||
|
const prev = process.env.PEARDATA_POSTGRES
|
||||||
|
delete process.env.PEARDATA_POSTGRES
|
||||||
|
t.absent(isPostgresEnabled())
|
||||||
|
process.env.PEARDATA_POSTGRES = '1'
|
||||||
|
t.ok(isPostgresEnabled())
|
||||||
|
if (prev == null) delete process.env.PEARDATA_POSTGRES
|
||||||
|
else process.env.PEARDATA_POSTGRES = prev
|
||||||
|
})
|
||||||
@@ -37,6 +37,8 @@ test('method roles cover monitoring surface', (t) => {
|
|||||||
'getDbInfo',
|
'getDbInfo',
|
||||||
'linkPeer',
|
'linkPeer',
|
||||||
'unlinkPeer',
|
'unlinkPeer',
|
||||||
|
'getFleetHealth',
|
||||||
|
'listChildPeers',
|
||||||
]) {
|
]) {
|
||||||
t.ok(MethodRoles[m], m)
|
t.ok(MethodRoles[m], m)
|
||||||
t.is(Methods[m], m)
|
t.is(Methods[m], m)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import test from 'brittle'
|
||||||
|
import {
|
||||||
|
parseRedisInfo,
|
||||||
|
parseRedisAddr,
|
||||||
|
isRedisEnabled,
|
||||||
|
} from '../server/services/collectors/redis.js'
|
||||||
|
|
||||||
|
const SAMPLE = `# Server
|
||||||
|
redis_version:7.2.0
|
||||||
|
# Memory
|
||||||
|
used_memory:1048576
|
||||||
|
used_memory_peak:2097152
|
||||||
|
used_memory_rss:3145728
|
||||||
|
# Clients
|
||||||
|
connected_clients:12
|
||||||
|
blocked_clients:1
|
||||||
|
# Stats
|
||||||
|
instantaneous_ops_per_sec:42
|
||||||
|
keyspace_hits:90
|
||||||
|
keyspace_misses:10
|
||||||
|
`
|
||||||
|
|
||||||
|
test('parseRedisInfo', (t) => {
|
||||||
|
const info = parseRedisInfo(SAMPLE)
|
||||||
|
t.is(info.redis_version, '7.2.0')
|
||||||
|
t.is(info.used_memory, '1048576')
|
||||||
|
t.is(info.connected_clients, '12')
|
||||||
|
t.is(info.instantaneous_ops_per_sec, '42')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseRedisAddr', (t) => {
|
||||||
|
t.is(parseRedisAddr('10.0.0.2:6380').port, 6380)
|
||||||
|
t.is(parseRedisAddr('redis://localhost:6379/0').host, 'localhost')
|
||||||
|
t.is(parseRedisAddr('redis://localhost:6379/0').port, 6379)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isRedisEnabled', (t) => {
|
||||||
|
const prev = process.env.PEARDATA_REDIS
|
||||||
|
delete process.env.PEARDATA_REDIS
|
||||||
|
t.absent(isRedisEnabled())
|
||||||
|
process.env.PEARDATA_REDIS = '1'
|
||||||
|
t.ok(isRedisEnabled())
|
||||||
|
if (prev == null) delete process.env.PEARDATA_REDIS
|
||||||
|
else process.env.PEARDATA_REDIS = prev
|
||||||
|
})
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Linked peer warm pull: replicate Corestore A → B, open remote bee by dbKeyHex,
|
||||||
|
* query metric points without re-scraping.
|
||||||
|
*/
|
||||||
|
import test from 'brittle'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import Corestore from 'corestore'
|
||||||
|
import { PearDataModel } from '../server/db/model.js'
|
||||||
|
import { MetricStore } from '../server/services/store.js'
|
||||||
|
import { openDb, closeDb } from '../server/db/index.js'
|
||||||
|
import { openRemoteDb, closeRemoteDbs, queryRemoteMetricPoints } from '../server/db/remote.js'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const TMP = path.join(__dirname, '..', 'tmp-swarm-pull-test')
|
||||||
|
|
||||||
|
function replicatePair(a, b) {
|
||||||
|
const s1 = a.replicate(true)
|
||||||
|
const s2 = b.replicate(false)
|
||||||
|
s1.pipe(s2).pipe(s1)
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
s1.destroy()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
s2.destroy()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('linked peer pulls warm points over corestore replicate', async (t) => {
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true })
|
||||||
|
const dirA = path.join(TMP, 'a')
|
||||||
|
const dirB = path.join(TMP, 'b-local')
|
||||||
|
|
||||||
|
const storeA = new Corestore(path.join(dirA, 'corestore'))
|
||||||
|
await storeA.ready()
|
||||||
|
const coreA = storeA.get({ name: 'peardata-meta' })
|
||||||
|
const modelA = new PearDataModel(coreA, { autoUpdate: true })
|
||||||
|
await modelA.ready()
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
await modelA.putMetricPoints([
|
||||||
|
{
|
||||||
|
chart: 'system.cpu',
|
||||||
|
context: 'system.cpu',
|
||||||
|
ts: now - 90_000,
|
||||||
|
values: { user: 15, system: 5, nice: 0, iowait: 0, irq: 0, softirq: 0, idle: 80 },
|
||||||
|
tier: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chart: 'system.cpu',
|
||||||
|
context: 'system.cpu',
|
||||||
|
ts: now - 30_000,
|
||||||
|
values: { user: 25, system: 5, nice: 0, iowait: 0, irq: 0, softirq: 0, idle: 70 },
|
||||||
|
tier: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const dbKeyHex = modelA.publicKeyHex
|
||||||
|
|
||||||
|
// B: local agent HyperDB + open A's core by key, then replicate
|
||||||
|
process.env.PEARDATA_DATA_DIR = dirB
|
||||||
|
delete process.env.PEARDATA_HYPERDB
|
||||||
|
await closeDb().catch(() => {})
|
||||||
|
await closeRemoteDbs().catch(() => {})
|
||||||
|
const localB = await openDb()
|
||||||
|
t.ok(localB)
|
||||||
|
|
||||||
|
const remote = await openRemoteDb(dbKeyHex)
|
||||||
|
t.ok(remote)
|
||||||
|
|
||||||
|
const storeB = (await import('../server/db/index.js')).getCorestore()
|
||||||
|
const stop = replicatePair(storeA, storeB)
|
||||||
|
|
||||||
|
// wait for remote core to download blocks
|
||||||
|
const deadline = Date.now() + 10_000
|
||||||
|
let rows = []
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
rows = await queryRemoteMetricPoints({
|
||||||
|
chart: 'system.cpu',
|
||||||
|
afterMs: now - 180_000,
|
||||||
|
beforeMs: now + 1000,
|
||||||
|
tier: 1,
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
if (rows.length >= 2) break
|
||||||
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.ok(rows.length >= 2, `expected warm rows, got ${rows.length}`)
|
||||||
|
|
||||||
|
// MetricStore query should surface hyperdb-remote when memory empty
|
||||||
|
const mem = new MetricStore()
|
||||||
|
const q = await mem.query({
|
||||||
|
chart: 'system.cpu',
|
||||||
|
after: Math.floor((now - 180_000) / 1000),
|
||||||
|
before: Math.floor(now / 1000),
|
||||||
|
points: 60,
|
||||||
|
tier: 1,
|
||||||
|
})
|
||||||
|
t.ok(q.source === 'hyperdb-remote' || q.source === 'hyperdb-warm')
|
||||||
|
t.ok(q.data.length >= 2)
|
||||||
|
|
||||||
|
stop()
|
||||||
|
await closeRemoteDbs()
|
||||||
|
await closeDb()
|
||||||
|
await modelA.close().catch(() => {})
|
||||||
|
await storeA.close().catch(() => {})
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true })
|
||||||
|
delete process.env.PEARDATA_DATA_DIR
|
||||||
|
})
|
||||||
@@ -616,6 +616,71 @@ code {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fleet-strip {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-strip.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-strip header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-strip h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-children {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-children li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-children li .fleet-id {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-children li .fleet-metrics {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-children li[data-status='ok'] {
|
||||||
|
border-color: rgba(62, 207, 142, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-children li[data-status='degraded'],
|
||||||
|
.fleet-children li[data-status='offline'] {
|
||||||
|
border-color: rgba(240, 180, 41, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-children li[data-status='critical'] {
|
||||||
|
border-color: rgba(255, 107, 122, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.charts-grid {
|
.charts-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user