mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 00:33:38 +00:00
## Summary Adds `GET /api/analytics/retransmissions` and a "Retransmission Pressure (proxy)" chart on the Analytics Topology tab, implementing the metric agreed in #1699: for each flood, the number of distinct repeaters in the union of the paths of all its observations (`[A]`, `[A,B,C]`, `[A,D]` gives 4), averaged per time bucket. Topology is the tab that already shows hop counts and repeaters in paths, so the chart sits there instead of in a new tab. ## Definition - Flood routes only (`route_type` 0/1); TRACE excluded. Direct routes carry the route still to travel (firmware `src/Mesh.cpp:78-106,334-342`), zero-hop sends are direct (`src/Mesh.cpp:717-737`), TRACE path bytes are SNR values (`src/Mesh.cpp:59-61`, refused by `sendFlood` at `src/Mesh.cpp:637-641`). Firmware commit 0679dbef. - **Flood events, not hashes.** `transmissions.hash` is UNIQUE and the packet hash excludes the path (`src/Packet.cpp:41-50`), so when the same bytes flood again the observations land on the same transmission. Observations are sorted by time and split into events wherever two consecutive observations are more than 5 minutes apart. Each event is counted on its own and bucketed by its first observation. - Why 5 minutes: a node holds a flood for at most 32 s (`src/Dispatcher.cpp:11,243-251`) plus a random retransmit delay. On live over 7 days, 72,806 of 74,347 flood transmissions span 60 s or less, and of 1,372,283 consecutive observation gaps, 52 fall between 60 s and 300 s against 1,823 above 300 s. - Events that start before the store retention floor (now minus `retentionHours`) are left out for every request shape. The store keeps older observations only for hashes heard again recently, so they do not represent that period. Eviction of those transmissions is tracked in #2024. - A flood event heard only with an empty path counts as 0 repeaters. - **Prefixes are not resolved to nodes, and a prefix counts once per event**, whether it repeats across observations or inside one path. On live (7 days), a repeated 2-byte prefix inside one path occurs in 1.08% of flood transmissions and 6,178 of 6,596 such repeats match exactly one known node; for 3-byte it is 0.69% and 104 of 104. That is one node forwarding again after its 160-slot cyclic duplicate filter dropped the hash (`src/helpers/SimpleMeshTables.h:9,52-57`). A repeated 1-byte prefix (44.9% of 1-byte transmissions) is mostly two nodes; counting it once keeps the value a lower bound. `summary.one_byte_packets` reports how many events that affects. - Observations are stored once per observer and path per hash, so a later event of the same hash only holds pairs not stored before; its count is a lower bound too. On live these are 1,466 of 75,356 events (1.9%), and they are kept in the average. - Resolution was not used: on live, 1-byte observations nearly all have `resolved_path` NULL, and cold load refuses context-based resolution of history (`cmd/server/neighbor_persist.go:155-168`). - Buckets `5m|15m|1h|6h|1d`. `region` filters on observers like `/api/analytics/rf`, after the event split; a region with no known observers is not filtered, the same as the other analytics endpoints. `area` is not supported. ## Implementation - `cmd/server/retransmission_pressure.go:255` `addPath`: scans path JSON directly into a generation-stamped hash set, no allocation per observation. - `cmd/server/retransmission_pressure.go:367` `computeRetransmissionPressure`: one pass under `s.mu.RLock`. Per flood transmission it sorts the observations by cached parsed time into a reused scratch slice, splits events and counts each in `addEvent` (`:319`). O(T + O log k + H). - `cmd/server/retransmission_pressure.go:470` `GetRetransmissionPressure`: default shape from the recomputer (#1659 warm-up gate). Other shapes come from a typed TTL cache (max 64 entries) cleared on new paths and eviction (`cmd/server/store.go:2289,2336`); concurrent misses on one key share one compute through singleflight (`store.go:204`). - `cmd/server/retransmission_pressure.go:515` handler, `cmd/server/routes.go:331`, `cmd/server/openapi.go:108`, `docs/api-spec.md:1283`. - `public/analytics.js:761` card, `:855` `renderRetransmissionChart` (CSS variables only, lines break at missing buckets, caption states it is a proxy, names the observer coverage bias, the once-per-flood prefix rule and the 5 minute event split), `:829` loader with stale-response guard. ## Performance - `BenchmarkComputeRetransmissionPressure`, 50k transmissions x 20 observations, `-cpu 1`, i5-1335U: median 161 ms/op (132 ms/op before the event split); first pass after startup with timestamps not yet parsed 192 ms/op. About 22 KB and 281 allocations per op. - On staging the default shape is served from the recomputer in 0.3 s; the post-load recompute of this recomputer took 994 ms on a 121k-transmission store (log line quoted in #2025). A 336h store would be about twice that, every recompute interval, under the store read lock. ## Tests - `cmd/server/retransmission_pressure_test.go`: union counting (reporter example, overlaps, once per event for 1/2/3-byte, width/case, growth); route/TRACE/zero-hop filter, bucketing, window by event start; event split and the 5 minute settle gap (boundary, chained steps, unsorted input), retention floor; region filter, region applied after the split, unknown region, 1-byte share; recomputer read, TTL cache invalidation on new paths and on eviction, cache expiry, singleflight, recomputer gate wiring, handler, warm-up gate. - `test-issue-1699-retransmission-chart.js` (33 tests, registered in `test-all.sh` and `deploy.yml`). - Mutation-checked: 18 mutations of the event split, floor, prefix rule, bucketing, region order, cache clears, expiry, gate wiring and singleflight, all killed. - `go test ./...` in cmd/server passes; `scripts/check-css-vars.js` OK. ## Staging validation Build `c646310f` (this rework plus #2025 and the other review follow-ups), after a container restart and full load: default shape 74,974 flood events, average 27.08 repeaters, 169 hourly buckets from 2026-09-06 16:00 (the 168h floor) to the current hour, highest hourly average 53.3. Before the rework the same instance showed buckets back to 2026-07-18, averages up to 148, and for the first minutes after a restart only 5,911 packets. ## Merge order with #2025 #2025 fixes the recomputer startup for all analytics endpoints (the stale first snapshot seen here). Whichever of the two merges second has to add `recompRetransmissions` to `analyticsRecomputersLocked`, wire it to that PR's `loadedGate` instead of `LoadComplete`, bump the recomputer count in `TestAnalyticsRecomputers_PostLoadOrder` from 9 to 10, and make `TestStartAnalyticsRecomputers_RetransmissionsGatedOnLoadComplete` call `signalStartupLoadDone()`. That resolution is what ran on staging. ## Not verified - Recompute timing on a production-size (336h) store; only extrapolated. - Phone-width layout and dark theme of the reworked chart. - E2E Playwright suite. Fixes #1699 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2531 lines
82 KiB
Markdown
2531 lines
82 KiB
Markdown
# CoreScope — API Contract Specification
|
||
|
||
> **Authoritative contract.** Both the Node.js and Go backends MUST conform to this spec.
|
||
> The frontend relies on these exact shapes. Breaking changes require a spec update first.
|
||
|
||
**Version:** 1.1.0
|
||
**Last updated:** 2026-04-22
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
- [Conventions](#conventions)
|
||
- [GET /api/stats](#get-apistats)
|
||
- [GET /api/health](#get-apihealth)
|
||
- [GET /api/perf](#get-apiperf)
|
||
- [POST /api/perf/reset](#post-apiperfreset)
|
||
- [GET /api/nodes](#get-apinodes)
|
||
- [GET /api/nodes/search](#get-apinodessearch)
|
||
- [GET /api/nodes/bulk-health](#get-apinodesbulk-health)
|
||
- [GET /api/nodes/network-status](#get-apinodesnetwork-status)
|
||
- [GET /api/nodes/:pubkey](#get-apinodespubkey)
|
||
- [GET /api/nodes/:pubkey/health](#get-apinodespubkeyhealth)
|
||
- [GET /api/nodes/:pubkey/paths](#get-apinodespubkeypaths)
|
||
- [GET /api/nodes/:pubkey/analytics](#get-apinodespubkeyanalytics)
|
||
- [GET /api/nodes/:pubkey/hop_analytics](#get-apinodespubkeyhop_analytics)
|
||
- [GET /api/nodes/:pubkey/reach](#get-apinodespubkeyreach)
|
||
- [GET /api/packets](#get-apipackets)
|
||
- [GET /api/packets/timestamps](#get-apipacketstimestamps)
|
||
- [GET /api/packets/:id](#get-apipacketsid)
|
||
- [POST /api/decode](#post-apidecode)
|
||
- [GET /api/observers](#get-apiobservers)
|
||
- [GET /api/observers/:id](#get-apiobserversid)
|
||
- [GET /api/observers/:id/analytics](#get-apiobserversidanalytics)
|
||
- [GET /api/channels](#get-apichannels)
|
||
- [GET /api/channels/:hash/messages](#get-apichannelshashmessages)
|
||
- [GET /api/analytics/rf](#get-apianalyticsrf)
|
||
- [GET /api/analytics/topology](#get-apianalyticstopology)
|
||
- [GET /api/analytics/retransmissions](#get-apianalyticsretransmissions)
|
||
- [GET /api/analytics/channels](#get-apianalyticschannels)
|
||
- [GET /api/analytics/distance](#get-apianalyticsdistance)
|
||
- [GET /api/analytics/hash-sizes](#get-apianalyticshash-sizes)
|
||
- [GET /api/analytics/subpaths](#get-apianalyticssubpaths)
|
||
- [GET /api/analytics/subpath-detail](#get-apianalyticssubpath-detail)
|
||
- [GET /api/scope-audit](#get-apiscope-audit)
|
||
- [GET /api/scope-stats](#get-apiscope-stats)
|
||
- [GET /api/resolve-hops](#get-apiresolve-hops)
|
||
- [GET /api/traces/:hash](#get-apitraceshash)
|
||
- [GET /api/config/theme](#get-apiconfigtheme)
|
||
- [GET /api/config/regions](#get-apiconfigregions)
|
||
- [GET /api/config/areas](#get-apiconfigareas)
|
||
- [GET /api/config/areas/polygons](#get-apiconfigareaspolygons)
|
||
- [GET /api/config/client](#get-apiconfigclient)
|
||
- [GET /api/config/cache](#get-apiconfigcache)
|
||
- [GET /api/config/map](#get-apiconfigmap)
|
||
- [GET /api/iata-coords](#get-apiiata-coords)
|
||
- [GET /api/nodes/clock-skew](#get-apinodesclock-skew)
|
||
- [GET /api/analytics/hash-collisions](#get-apianalyticshash-collisions)
|
||
- [GET /api/audio-lab/buckets](#get-apiaudio-labbuckets)
|
||
- [WebSocket Messages](#websocket-messages)
|
||
- [Area Filter](#area-filter)
|
||
|
||
---
|
||
|
||
## Conventions
|
||
|
||
### Types
|
||
|
||
| Notation | Meaning |
|
||
|-----------------|------------------------------------------------------|
|
||
| `string` | JSON string |
|
||
| `number` | JSON number (integer or float) |
|
||
| `boolean` | `true` / `false` |
|
||
| `string (ISO)` | ISO 8601 timestamp, e.g. `"2025-07-17T04:23:01.000Z"` |
|
||
| `string (hex)` | Hex-encoded bytes, uppercase, e.g. `"4F01A3..."` |
|
||
| `number \| null`| May be `null` when data is unavailable |
|
||
| `[T]` | JSON array of type `T`; always `[]` when empty, never `null` |
|
||
| `object` | Nested JSON object (shape defined inline) |
|
||
|
||
### Null Rules
|
||
|
||
- Fields marked `| null` may be absent or `null`.
|
||
- Array fields MUST be `[]` when empty, NEVER `null`.
|
||
- String fields that are "unknown" SHOULD be `null`, not `""`.
|
||
|
||
### Pagination
|
||
|
||
Paginated endpoints accept `limit` (default 50) and `offset` (default 0) as query params.
|
||
They return `total` (the unfiltered/filtered count before pagination).
|
||
|
||
### Error Responses
|
||
|
||
```json
|
||
{ "error": "string" }
|
||
```
|
||
|
||
- `400` — Bad request (missing/invalid params)
|
||
- `404` — Resource not found
|
||
|
||
---
|
||
|
||
## GET /api/stats
|
||
|
||
Server-wide statistics. Lightweight, cached 10s.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"totalPackets": number, // observation count (legacy name)
|
||
"totalTransmissions": number | null, // unique transmission count
|
||
"totalObservations": number, // total observation records
|
||
"totalNodes": number, // active nodes (last 7 days)
|
||
"totalNodesAllTime": number, // all nodes ever seen
|
||
"totalObservers": number, // observer device count
|
||
"packetsLastHour": number, // observations in last hour
|
||
"engine": "node", // backend engine identifier
|
||
"version": string, // package.json version, e.g. "2.6.0"
|
||
"commit": string, // git short SHA or "unknown"
|
||
"counts": {
|
||
"repeaters": number, // active repeaters (last 7 days)
|
||
"rooms": number,
|
||
"companions": number,
|
||
"sensors": number
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/health
|
||
|
||
Server health and telemetry. Used by monitoring.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"status": "ok",
|
||
"engine": "node",
|
||
"version": string,
|
||
"commit": string,
|
||
"uptime": number, // seconds
|
||
"uptimeHuman": string, // e.g. "4h 32m"
|
||
"memory": {
|
||
"rss": number, // MB
|
||
"heapUsed": number, // MB
|
||
"heapTotal": number, // MB
|
||
"external": number // MB
|
||
},
|
||
"eventLoop": {
|
||
"currentLagMs": number,
|
||
"maxLagMs": number,
|
||
"p50Ms": number,
|
||
"p95Ms": number,
|
||
"p99Ms": number
|
||
},
|
||
"cache": {
|
||
"entries": number,
|
||
"hits": number,
|
||
"misses": number,
|
||
"staleHits": number,
|
||
"recomputes": number,
|
||
"hitRate": number // percentage (0–100)
|
||
},
|
||
"websocket": {
|
||
"clients": number // connected WS clients
|
||
},
|
||
"packetStore": {
|
||
"packets": number, // loaded transmissions
|
||
"estimatedMB": number
|
||
},
|
||
"perf": {
|
||
"totalRequests": number,
|
||
"avgMs": number,
|
||
"slowQueries": number,
|
||
"recentSlow": [ // last 5
|
||
{
|
||
"path": string,
|
||
"ms": number,
|
||
"time": string, // ISO timestamp
|
||
"status": number // HTTP status
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/perf
|
||
|
||
Detailed performance metrics per endpoint.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"uptime": number, // seconds since perf stats reset
|
||
"totalRequests": number,
|
||
"avgMs": number,
|
||
"endpoints": {
|
||
"/api/packets": { // keyed by route path
|
||
"count": number,
|
||
"avgMs": number,
|
||
"p50Ms": number,
|
||
"p95Ms": number,
|
||
"maxMs": number
|
||
}
|
||
// ... more endpoints
|
||
},
|
||
"slowQueries": [ // last 20 queries > 100ms
|
||
{
|
||
"path": string,
|
||
"ms": number,
|
||
"time": string, // ISO timestamp
|
||
"status": number
|
||
}
|
||
],
|
||
"cache": {
|
||
"size": number,
|
||
"hits": number,
|
||
"misses": number,
|
||
"staleHits": number,
|
||
"recomputes": number,
|
||
"hitRate": number // percentage (0–100)
|
||
},
|
||
"packetStore": { // from PacketStore.getStats()
|
||
"totalLoaded": number,
|
||
"totalObservations": number,
|
||
"evicted": number,
|
||
"inserts": number,
|
||
"queries": number,
|
||
"inMemory": number,
|
||
"sqliteOnly": boolean,
|
||
"maxPackets": number,
|
||
"estimatedMB": number,
|
||
"maxMB": number,
|
||
"indexes": {
|
||
"byHash": number,
|
||
"byObserver": number,
|
||
"byNode": number,
|
||
"advertByObserver": number
|
||
}
|
||
},
|
||
"sqlite": {
|
||
"dbSizeMB": number,
|
||
"walSizeMB": number,
|
||
"freelistMB": number,
|
||
"walPages": { "total": number, "checkpointed": number, "busy": number } | null,
|
||
"rows": {
|
||
"transmissions": number,
|
||
"observations": number,
|
||
"nodes": number,
|
||
"observers": number
|
||
}
|
||
},
|
||
"goRuntime": { // Go server only
|
||
"heapMB": number, // heap allocation in MB
|
||
"sysMB": number, // total system memory in MB
|
||
"numGoroutine": number, // active goroutines
|
||
"numGC": number, // completed GC cycles
|
||
"gcPauseMs": number // last GC pause in ms
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## POST /api/perf/reset
|
||
|
||
Resets performance counters. Requires API key.
|
||
|
||
### Headers
|
||
|
||
- `X-API-Key: <key>` (required if `config.apiKey` is set)
|
||
|
||
### Response `200`
|
||
|
||
```json
|
||
{ "ok": true }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/nodes
|
||
|
||
Paginated node list with filtering.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|------------|--------|--------------|----------------------------------------------------|
|
||
| `limit` | number | `50` | Page size |
|
||
| `offset` | number | `0` | Pagination offset |
|
||
| `role` | string | — | Filter by role: `repeater`, `room`, `companion`, `sensor` |
|
||
| `region` | string | — | Comma-separated IATA codes for regional filtering |
|
||
| `area` | string | — | Area key from `config.json` — filters to nodes whose GPS falls inside the area polygon (see [Area Filter](#area-filter)) |
|
||
| `lastHeard`| string | — | Recency filter: `1h`, `6h`, `24h`, `7d`, `30d` |
|
||
| `sortBy` | string | `lastSeen` | Sort key: `name`, `lastSeen`, `packetCount` |
|
||
| `search` | string | — | Substring match on `name` |
|
||
| `before` | string | — | ISO timestamp; only nodes with `first_seen <= before` |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"nodes": [
|
||
{
|
||
"public_key": string, // 64-char hex public key
|
||
"name": string | null,
|
||
"role": string, // "repeater" | "room" | "companion" | "sensor"
|
||
"lat": number | null,
|
||
"lon": number | null,
|
||
"last_seen": string (ISO),
|
||
"first_seen": string (ISO),
|
||
"advert_count": number,
|
||
"hash_size": number | null, // latest hash size (1–3 bytes)
|
||
"hash_size_inconsistent": boolean, // true if flip-flopping
|
||
"hash_sizes_seen": [number] | undefined, // present only if >1 unique size seen
|
||
"last_heard": string (ISO) | undefined, // from in-memory packets or path relay
|
||
"default_scope": string | null | undefined, // Most recently observed transport scope for this node. null = never observed transport-scoped, "" = observed scoped but no configured region matched, "#name" = matched region. Only present when ingestor has applied the nodes_default_scope_v1 migration.
|
||
"scope_config_state": string | undefined // Repeater/room only. How this node's region config reads: "full" | "no-unscoped" | "no-scopes" | "no-flood" from its own declared-regions answer, "observed" when it never answered but has been seen forwarding scoped traffic, "none" when it never answered and nothing scoped was observed. Absent for other roles and when the declared-regions lookup failed.
|
||
}
|
||
],
|
||
"total": number, // total matching count (before pagination)
|
||
"counts": {
|
||
"repeaters": number, // global counts (not filtered by current query)
|
||
"rooms": number,
|
||
"companions": number,
|
||
"sensors": number
|
||
}
|
||
}
|
||
```
|
||
|
||
**Notes:**
|
||
- `hash_sizes_seen` is only present when more than one hash size has been observed.
|
||
- `last_heard` is only present when in-memory data provides a more recent timestamp than `last_seen`.
|
||
- `scope_config_state` carries the same four declared states as [GET /api/scope-audit](#get-apiscope-audit) and is computed the same way, from the newest declared-regions answer merged across collectors. The audit lists only repeaters that have answered; this field also classifies the ones that have not, which is what the map colours by. `"none"` means the answer is missing, not that the node is misconfigured: firmware drops scoped floods for regions it holds no key for, so a repeater with no region config and one nobody sends scoped traffic past are indistinguishable here. The four declared states are computed from stored data and do not depend on a window; `"observed"` and `"none"` are separated by `transported_scopes`, which the in-memory packet store accumulates over its retention window, so a node can move between them after a restart. The field is absent entirely when this database carries no declared-regions source at all, since "nobody has answered" is then a claim the schema cannot support.
|
||
|
||
---
|
||
|
||
## GET /api/nodes/search
|
||
|
||
Quick node search for autocomplete/typeahead.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Required | Description |
|
||
|-------|--------|----------|--------------------------------------|
|
||
| `q` | string | yes | Search term (name substring or pubkey prefix) |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"nodes": [
|
||
{
|
||
"public_key": string,
|
||
"name": string | null,
|
||
"role": string,
|
||
"lat": number | null,
|
||
"lon": number | null,
|
||
"last_seen": string (ISO),
|
||
"first_seen": string (ISO),
|
||
"advert_count": number
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Returns `{ "nodes": [] }` when `q` is empty.
|
||
|
||
---
|
||
|
||
## GET /api/nodes/bulk-health
|
||
|
||
Bulk health summary for all nodes. Used by analytics dashboard.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-------------------------------------------------|
|
||
| `limit` | number | `50` | Max nodes (capped at 200) |
|
||
| `region` | string | — | Comma-separated IATA codes for regional filtering |
|
||
|
||
### Response `200`
|
||
|
||
Returns a JSON array (not wrapped in an object):
|
||
|
||
```jsonc
|
||
[
|
||
{
|
||
"public_key": string,
|
||
"name": string | null,
|
||
"role": string,
|
||
"lat": number | null,
|
||
"lon": number | null,
|
||
"stats": {
|
||
"totalTransmissions": number,
|
||
"totalObservations": number,
|
||
"totalPackets": number, // same as totalTransmissions (backward compat)
|
||
"packetsToday": number,
|
||
"avgSnr": number | null,
|
||
"lastHeard": string (ISO) | null
|
||
},
|
||
"observers": [
|
||
{
|
||
"observer_id": string,
|
||
"observer_name": string | null,
|
||
"avgSnr": number | null,
|
||
"avgRssi": number | null,
|
||
"packetCount": number
|
||
}
|
||
]
|
||
}
|
||
]
|
||
```
|
||
|
||
**Note:** This is a bare array, not `{ nodes: [...] }`.
|
||
|
||
---
|
||
|
||
## GET /api/nodes/network-status
|
||
|
||
Aggregate network health status counts.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-------------------------------------|
|
||
| `region` | string | — | Comma-separated IATA codes |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"total": number,
|
||
"active": number, // within degradedMs threshold
|
||
"degraded": number, // between degradedMs and silentMs
|
||
"silent": number, // beyond silentMs
|
||
"roleCounts": {
|
||
"repeater": number,
|
||
"room": number,
|
||
"companion": number,
|
||
"sensor": number
|
||
// may include "unknown" if role is missing
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/nodes/:pubkey
|
||
|
||
Node detail page data.
|
||
|
||
### Path Parameters
|
||
|
||
| Param | Type | Description |
|
||
|----------|--------|----------------------|
|
||
| `pubkey` | string | Node public key (hex)|
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"node": {
|
||
"public_key": string,
|
||
"name": string | null,
|
||
"role": string,
|
||
"lat": number | null,
|
||
"lon": number | null,
|
||
"last_seen": string (ISO),
|
||
"first_seen": string (ISO),
|
||
"advert_count": number,
|
||
"hash_size": number | null,
|
||
"hash_size_inconsistent": boolean,
|
||
"hash_sizes_seen": [number] | undefined
|
||
},
|
||
"recentAdverts": [Packet] // last 20 packets for this node, newest first
|
||
}
|
||
```
|
||
|
||
Where `Packet` is a transmission object (see [Packet Object](#packet-object)).
|
||
|
||
### Response `404`
|
||
|
||
```json
|
||
{ "error": "Not found" }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/nodes/:pubkey/health
|
||
|
||
Detailed health information for a single node.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"node": { // full node row
|
||
"public_key": string,
|
||
"name": string | null,
|
||
"role": string,
|
||
"lat": number | null,
|
||
"lon": number | null,
|
||
"last_seen": string (ISO),
|
||
"first_seen": string (ISO),
|
||
"advert_count": number
|
||
},
|
||
"observers": [
|
||
{
|
||
"observer_id": string,
|
||
"observer_name": string | null,
|
||
"packetCount": number,
|
||
"avgSnr": number | null,
|
||
"avgRssi": number | null,
|
||
"iata": string | null
|
||
}
|
||
],
|
||
"stats": {
|
||
"totalTransmissions": number,
|
||
"totalObservations": number,
|
||
"totalPackets": number, // same as totalTransmissions (backward compat)
|
||
"packetsToday": number,
|
||
"avgSnr": number | null,
|
||
"avgHops": number, // rounded integer
|
||
"lastHeard": string (ISO) | null
|
||
},
|
||
"recentPackets": [ // last 20 packets, observations stripped
|
||
{
|
||
// Packet fields (see Packet Object) minus `observations`
|
||
"observation_count": number // added for display
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Response `404`
|
||
|
||
```json
|
||
{ "error": "Not found" }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/nodes/:pubkey/paths
|
||
|
||
Path analysis for a node — all paths containing this node's prefix.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"node": {
|
||
"public_key": string,
|
||
"name": string | null,
|
||
"lat": number | null,
|
||
"lon": number | null
|
||
},
|
||
"paths": [
|
||
{
|
||
"hops": [
|
||
{
|
||
"prefix": string, // raw hex hop prefix
|
||
"name": string, // resolved node name
|
||
"pubkey": string | null,
|
||
"lat": number | null,
|
||
"lon": number | null
|
||
}
|
||
],
|
||
"count": number, // times this path was seen
|
||
"lastSeen": string (ISO) | null,
|
||
"sampleHash": string // hash of a sample packet using this path
|
||
}
|
||
],
|
||
"totalPaths": number, // unique path signatures
|
||
"totalTransmissions": number // total transmissions with this node in path
|
||
}
|
||
```
|
||
|
||
### Response `404`
|
||
|
||
```json
|
||
{ "error": "Not found" }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/nodes/:pubkey/analytics
|
||
|
||
Per-node analytics over a time range.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|--------|--------|---------|--------------------------|
|
||
| `days` | number | `7` | Lookback window (1–365) |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"node": { // full node row (same shape as nodes table)
|
||
"public_key": string, "name": string | null, "role": string,
|
||
"lat": number | null, "lon": number | null,
|
||
"last_seen": string (ISO), "first_seen": string (ISO), "advert_count": number
|
||
},
|
||
"timeRange": {
|
||
"from": string (ISO),
|
||
"to": string (ISO),
|
||
"days": number
|
||
},
|
||
"activityTimeline": [
|
||
{ "bucket": string (ISO), "count": number } // hourly buckets
|
||
],
|
||
"snrTrend": [
|
||
{
|
||
"timestamp": string (ISO),
|
||
"snr": number,
|
||
"rssi": number | null,
|
||
"observer_id": string | null,
|
||
"observer_name": string | null
|
||
}
|
||
],
|
||
"packetTypeBreakdown": [
|
||
{ "payload_type": number, "count": number }
|
||
],
|
||
"observerCoverage": [
|
||
{
|
||
"observer_id": string,
|
||
"observer_name": string | null,
|
||
"packetCount": number,
|
||
"avgSnr": number | null,
|
||
"avgRssi": number | null,
|
||
"firstSeen": string (ISO),
|
||
"lastSeen": string (ISO)
|
||
}
|
||
],
|
||
"hopDistribution": [
|
||
{ "hops": string, "count": number } // "0", "1", "2", "3", "4+"
|
||
],
|
||
"peerInteractions": [
|
||
{
|
||
"peer_key": string,
|
||
"peer_name": string,
|
||
"messageCount": number,
|
||
"lastContact": string (ISO)
|
||
}
|
||
],
|
||
"uptimeHeatmap": [
|
||
{ "dayOfWeek": number, "hour": number, "count": number } // 0=Sun, 0–23
|
||
],
|
||
"computedStats": {
|
||
"availabilityPct": number, // 0–100
|
||
"longestSilenceMs": number,
|
||
"longestSilenceStart": string (ISO) | null,
|
||
"signalGrade": string, // "A", "A-", "B+", "B", "C", "D"
|
||
"snrMean": number,
|
||
"snrStdDev": number,
|
||
"relayPct": number, // % of packets with >1 hop
|
||
"totalPackets": number,
|
||
"uniqueObservers": number,
|
||
"uniquePeers": number,
|
||
"avgPacketsPerDay": number
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response `404`
|
||
|
||
```json
|
||
{ "error": "Not found" }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/nodes/:pubkey/hop_analytics
|
||
|
||
Hop count at this node for every flood packet it forwarded, to help choose
|
||
`flood.max`, `flood.max.unscoped` and `flood.max.advert`. A repeater checks
|
||
those limits against the number of hashes already in the path, then appends
|
||
its own hash, so the hop count is the node's zero-based index in the observed
|
||
path (firmware `src/helpers/RoutingPolicy.h`, `src/Mesh.cpp` `routeRecvPacket`).
|
||
This is not the `hopDistribution` of `/analytics`, which is the path length at
|
||
the observer.
|
||
|
||
- One entry per packet hash. Values are raw so the client can filter and bin.
|
||
- Only floods (route types 0 and 1). DIRECT packets carry the remaining route,
|
||
not a hop count, and are left out. Packets the node originated are left out.
|
||
- Every observation of every flood packet in the window is read, not only the
|
||
packet's longest path, so a relay on a shorter branch of the flood counts too.
|
||
- A packet is attributed when the node's path prefix sits at exactly one index
|
||
across its observations, and either no other relay-capable node shares that
|
||
prefix, or the hop resolves to the node under the ingestor's strict rule
|
||
(every earlier hop identified without a tiebreak, and exactly one candidate
|
||
is a `neighbor_edges` neighbor of the previous hop, or of the originator for
|
||
an advert) in at least one observation and to another node in none. The
|
||
server's resolved-path pick (affinity, GPS distance, advert count) is not
|
||
used, so the result is the same before and after a restart. Everything else
|
||
with the node's prefix is counted in `ambiguous` and left out; in practice
|
||
that is most packets with a colliding 1-byte path hash.
|
||
- Size: for a busy repeater on a 1,669-node mesh over 7 days (2026-09-13)
|
||
the response held 23,068 entries, 2.3 MB of JSON, 375 KB gzipped. `hash`
|
||
and `timestamp` are 61% of the raw and 91% of the gzipped bytes; they stay
|
||
so a client can join entries to packets and bin by time (issue #1812).
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|--------|--------|---------|--------------------------|
|
||
| `days` | number | `7` | Lookback window (1-365) |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"timeRange": { "from": string (ISO), "to": string (ISO), "days": number },
|
||
"packets": [
|
||
{
|
||
"hash": string,
|
||
"timestamp": string (ISO), // first seen
|
||
"hops": number, // 0 = heard straight from the originator
|
||
"tags": [string] // "flood", then "scoped" or "unscoped", then "advert" if an ADVERT
|
||
}
|
||
],
|
||
"ambiguous": number // prefix matched, hop position not attributable to this node
|
||
}
|
||
```
|
||
|
||
Filters that match the firmware limits: `flood.max` uses all entries,
|
||
`flood.max.unscoped` the entries tagged `unscoped`, `flood.max.advert` the
|
||
entries tagged `advert`.
|
||
|
||
### Response `404`
|
||
|
||
```json
|
||
{ "error": "Not found" }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/nodes/:pubkey/reach
|
||
|
||
Per-node RF reach report (two-way link quality). Computes **directional** link counts from raw
|
||
path adjacency (a flood path is recorded origin→observer, so in `[A,B]` B received
|
||
A directly). A link is **bidirectional** when both directions have observations;
|
||
the **bottleneck** (weaker direction) rates two-way stability. Read-only; bounded
|
||
to a recent window. Identifies nodes only by **unique 2–3 byte** path prefixes
|
||
(1-byte prefixes collide and are excluded).
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|--------|--------|---------|--------------------------------------|
|
||
| `days` | number | `7` | Lookback window, clamped 1–30 |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"node": { "pubkey": string, "name": string, "role": string,
|
||
"lat": number | null, "lon": number | null, "first_seen": string (ISO) },
|
||
"window": { "days": number, "since": string (ISO) },
|
||
"reliable_tokens": [string], // uppercase hex prefixes unique to this node ([] if unidentifiable)
|
||
"importance": {
|
||
"neighbor_degree": number, // all-time, from neighbor_edges
|
||
"degree_rank": number, // 1-based rank among nodes with edges
|
||
"nodes_with_edges": number,
|
||
"relay_observations": number, // windowed obs with this node anywhere in path
|
||
"bidirectional_links":number,
|
||
"direct_observers": number
|
||
},
|
||
"direct_observers": [
|
||
{ "pubkey": string, "name": string, "count": number,
|
||
"avg_snr": number | null, "lat": number | null, "lon": number | null,
|
||
"distance_km": number | null }
|
||
],
|
||
"links": [
|
||
{ "pubkey": string, "name": string, "role": string,
|
||
"lat": number | null, "lon": number | null,
|
||
"we_hear": number, "they_hear": number,
|
||
"bottleneck": number, "bidir": boolean,
|
||
"distance_km": number | null }
|
||
]
|
||
}
|
||
```
|
||
|
||
`reliable_tokens: []` means the node has no unique 1–3 byte prefix and cannot be
|
||
reliably identified in paths; `links`/`direct_observers` will be empty.
|
||
|
||
### Caching & limits
|
||
|
||
- **Response cache:** computed responses are cached for **5 minutes** per
|
||
`pubkey|days`. Polling faster than that returns an identical body — clients
|
||
should not expect sub-5-minute freshness.
|
||
- **Scan cap:** the windowed path scan is hard-capped at **200,000** rows. A node
|
||
with more matching observations in the window is truncated (counts become a
|
||
representative sample rather than exhaustive).
|
||
|
||
### Response `400`
|
||
|
||
Returned when `:pubkey` is not a 64-char hex string.
|
||
|
||
```json
|
||
{ "error": "invalid pubkey: expected 64 hex chars" }
|
||
```
|
||
|
||
### Response `404`
|
||
|
||
Returned when the node is unknown or blacklisted.
|
||
|
||
```json
|
||
{ "error": "Not found" }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/packets
|
||
|
||
Paginated packet (transmission) list with filtering.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|--------------|--------|---------|----------------------------------------------------|
|
||
| `limit` | number | `50` | Page size |
|
||
| `offset` | number | `0` | Pagination offset |
|
||
| `type` | string | — | Filter by payload type (number or name) |
|
||
| `route` | string | — | Filter by route type |
|
||
| `region` | string | — | Filter by region (IATA code substring) |
|
||
| `observer` | string | — | Filter by observer ID |
|
||
| `hash` | string | — | Filter by packet hash |
|
||
| `since` | string | — | ISO timestamp lower bound |
|
||
| `until` | string | — | ISO timestamp upper bound |
|
||
| `node` | string | — | Filter by node pubkey |
|
||
| `nodes` | string | — | Comma-separated pubkeys (multi-node filter) |
|
||
| `order` | string | `DESC` | Sort direction: `asc` or `desc` |
|
||
| `groupByHash`| string | — | Set to `"true"` for grouped response |
|
||
| `expand` | string | — | Set to `"observations"` to include observation arrays |
|
||
|
||
### Response `200` (default)
|
||
|
||
```jsonc
|
||
{
|
||
"packets": [Packet], // see Packet Object below (observations stripped unless expand=observations)
|
||
"total": number,
|
||
"limit": number,
|
||
"offset": number
|
||
}
|
||
```
|
||
|
||
### Response `200` (groupByHash=true)
|
||
|
||
```jsonc
|
||
{
|
||
"packets": [
|
||
{
|
||
"hash": string,
|
||
"first_seen": string (ISO),
|
||
"count": number, // observation count
|
||
"observer_count": number, // unique observers
|
||
"latest": string (ISO),
|
||
"observer_id": string | null,
|
||
"observer_name": string | null,
|
||
"path_json": string | null,
|
||
"payload_type": number,
|
||
"route_type": number,
|
||
"raw_hex": string (hex),
|
||
"decoded_json": string | null,
|
||
"observation_count": number,
|
||
"snr": number | null,
|
||
"rssi": number | null
|
||
}
|
||
],
|
||
"total": number
|
||
}
|
||
```
|
||
|
||
### Response `200` (nodes=... multi-node)
|
||
|
||
```jsonc
|
||
{
|
||
"packets": [Packet],
|
||
"total": number,
|
||
"limit": number,
|
||
"offset": number
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/packets/timestamps
|
||
|
||
Lightweight endpoint returning only timestamps for timeline sparklines.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Required | Description |
|
||
|---------|--------|----------|-----------------------------------|
|
||
| `since` | string | yes | ISO timestamp lower bound |
|
||
|
||
### Response `200`
|
||
|
||
Returns a JSON array of timestamps (strings or numbers):
|
||
|
||
```jsonc
|
||
["2025-07-17T00:00:01.000Z", "2025-07-17T00:00:02.000Z", ...]
|
||
```
|
||
|
||
### Response `400`
|
||
|
||
```json
|
||
{ "error": "since required" }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/packets/:id
|
||
|
||
Single packet detail with byte breakdown and observations.
|
||
|
||
### Path Parameters
|
||
|
||
| Param | Type | Description |
|
||
|-------|--------|----------------------------------------------------------|
|
||
| `id` | string | Packet ID (numeric) or 16-char hex hash |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"packet": Packet, // full packet/transmission object
|
||
"path": [string], // parsed path hops (from packet.paths or [])
|
||
"breakdown": { // byte-level packet structure
|
||
"ranges": [
|
||
{
|
||
"start": number, // byte offset
|
||
"end": number,
|
||
"label": string,
|
||
"hex": string,
|
||
"value": string | number | null
|
||
}
|
||
]
|
||
} | null,
|
||
"observation_count": number,
|
||
"observations": [
|
||
{
|
||
"id": number,
|
||
"transmission_id": number,
|
||
"hash": string,
|
||
"observer_id": string | null,
|
||
"observer_name": string | null,
|
||
"direction": string | null,
|
||
"snr": number | null,
|
||
"rssi": number | null,
|
||
"score": number | null,
|
||
"path_json": string | null,
|
||
"timestamp": string (ISO),
|
||
"raw_hex": string (hex),
|
||
"payload_type": number,
|
||
"decoded_json": string | null,
|
||
"route_type": number
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Response `404`
|
||
|
||
```json
|
||
{ "error": "Not found" }
|
||
```
|
||
|
||
---
|
||
|
||
## POST /api/decode
|
||
|
||
Decode a raw packet without storing it.
|
||
|
||
### Request Body
|
||
|
||
```jsonc
|
||
{
|
||
"hex": string // required — raw hex-encoded packet
|
||
}
|
||
```
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"decoded": {
|
||
"header": DecodedHeader,
|
||
"path": DecodedPath,
|
||
"payload": object
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response `400`
|
||
|
||
```json
|
||
{ "error": "hex is required" }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/observers
|
||
|
||
List all observers with packet counts.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"observers": [
|
||
{
|
||
"id": string,
|
||
"name": string | null,
|
||
"iata": string | null, // region code
|
||
"last_seen": string (ISO),
|
||
"first_seen": string (ISO),
|
||
"packet_count": number,
|
||
"model": string | null, // hardware model
|
||
"firmware": string | null,
|
||
"client_version": string | null,
|
||
"radio": string | null,
|
||
"battery_mv": number | null, // millivolts
|
||
"uptime_secs": number | null,
|
||
"noise_floor": number | null, // dBm
|
||
"packetsLastHour": number, // computed, not from DB
|
||
"lat": number | null, // from matched node
|
||
"lon": number | null, // from matched node
|
||
"nodeRole": string | null // from matched node
|
||
}
|
||
],
|
||
"server_time": string (ISO) // server's current time
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/observers/:id
|
||
|
||
Single observer detail.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"id": string,
|
||
"name": string | null,
|
||
"iata": string | null,
|
||
"last_seen": string (ISO),
|
||
"first_seen": string (ISO),
|
||
"packet_count": number,
|
||
"model": string | null,
|
||
"firmware": string | null,
|
||
"client_version": string | null,
|
||
"radio": string | null,
|
||
"battery_mv": number | null,
|
||
"uptime_secs": number | null,
|
||
"noise_floor": number | null,
|
||
"packetsLastHour": number
|
||
}
|
||
```
|
||
|
||
### Response `404`
|
||
|
||
```json
|
||
{ "error": "Observer not found" }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/observers/:id/analytics
|
||
|
||
Per-observer analytics.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|--------|--------|---------|--------------------------|
|
||
| `days` | number | `7` | Lookback window |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"timeline": [
|
||
{ "label": string, "count": number } // bucketed by hours/days
|
||
],
|
||
"packetTypes": {
|
||
"4": number, // keyed by payload_type number
|
||
"5": number
|
||
},
|
||
"nodesTimeline": [
|
||
{ "label": string, "count": number } // unique nodes per time bucket
|
||
],
|
||
"snrDistribution": [
|
||
{ "range": string, "count": number } // e.g. "6 to 8"
|
||
],
|
||
"recentPackets": [Packet] // last 20 enriched observations
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/channels
|
||
|
||
List decoded channels with message counts.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-------------------------------------|
|
||
| `region` | string | — | Comma-separated IATA codes |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"channels": [
|
||
{
|
||
"hash": string, // channel name (used as key)
|
||
"name": string, // decoded channel name
|
||
"lastMessage": string | null, // text of most recent message
|
||
"lastSender": string | null, // sender of most recent message
|
||
"messageCount": number,
|
||
"lastActivity": string (ISO)
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/channels/:hash/messages
|
||
|
||
Messages for a specific channel.
|
||
|
||
### Path Parameters
|
||
|
||
| Param | Type | Description |
|
||
|--------|--------|-----------------------------|
|
||
| `hash` | string | Channel name (from /api/channels) |
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-----------------|
|
||
| `limit` | number | `100` | Page size |
|
||
| `offset` | number | `0` | Pagination offset (from end) |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"messages": [
|
||
{
|
||
"sender": string,
|
||
"text": string,
|
||
"timestamp": string (ISO),
|
||
"sender_timestamp": number | null, // device timestamp (unreliable)
|
||
"packetId": number,
|
||
"packetHash": string,
|
||
"repeats": number, // dedup count
|
||
"observers": [string], // observer names
|
||
"hops": number,
|
||
"snr": number | null,
|
||
"scope_name": string | null // region scope, see below
|
||
}
|
||
],
|
||
"total": number // total deduplicated messages
|
||
}
|
||
```
|
||
|
||
`scope_name` is the transmission's region scope (`transmissions.scope_name`), the same field `/api/packets` returns:
|
||
|
||
| Value | Meaning |
|
||
|-------|---------|
|
||
| `null` | No transport code: the message was not region-scoped. Also `null` when the database has no `scope_name` column yet (ingestor migration not run). |
|
||
| `""` | Transport-scoped, but the ingestor could not match it to a single region: no region key matched, or several matched with no single operator-configured key among them. |
|
||
| `"#name"` | The matched region name. |
|
||
|
||
The same field is on the WebSocket `packet` broadcast, both top-level and inside `packet`.
|
||
|
||
---
|
||
|
||
## GET /api/analytics/rf
|
||
|
||
RF signal analytics.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-------------------------------------|
|
||
| `region` | string | — | Comma-separated IATA codes |
|
||
| `area` | string | — | Area key — restricts to packets whose transmitter GPS falls in the area (ADVERT packets only; see [Area Filter](#area-filter)) |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"totalPackets": number, // observations with SNR data
|
||
"totalAllPackets": number, // all regional observations
|
||
"totalTransmissions": number, // unique transmission hashes
|
||
"snr": {
|
||
"min": number,
|
||
"max": number,
|
||
"avg": number,
|
||
"median": number,
|
||
"stddev": number
|
||
},
|
||
"rssi": {
|
||
"min": number,
|
||
"max": number,
|
||
"avg": number,
|
||
"median": number,
|
||
"stddev": number
|
||
},
|
||
"snrValues": Histogram, // pre-computed histogram (20 bins)
|
||
"rssiValues": Histogram, // pre-computed histogram (20 bins)
|
||
"packetSizes": Histogram, // pre-computed histogram (25 bins)
|
||
"minPacketSize": number, // bytes
|
||
"maxPacketSize": number,
|
||
"avgPacketSize": number,
|
||
"packetsPerHour": [
|
||
{ "hour": string, "count": number } // "2025-07-17T04"
|
||
],
|
||
"payloadTypes": [
|
||
{ "type": number, "name": string, "count": number }
|
||
],
|
||
"snrByType": [
|
||
{ "name": string, "count": number, "avg": number, "min": number, "max": number }
|
||
],
|
||
"signalOverTime": [
|
||
{ "hour": string, "count": number, "avgSnr": number }
|
||
],
|
||
"scatterData": [
|
||
{ "snr": number, "rssi": number } // max 500 points
|
||
],
|
||
"timeSpanHours": number
|
||
}
|
||
```
|
||
|
||
### Histogram Shape
|
||
|
||
```jsonc
|
||
{
|
||
"bins": [
|
||
{ "x": number, "w": number, "count": number }
|
||
],
|
||
"min": number,
|
||
"max": number
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/analytics/topology
|
||
|
||
Network topology analytics.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-------------------------------------|
|
||
| `region` | string | — | Comma-separated IATA codes |
|
||
| `area` | string | — | Area key — only hops that resolve to nodes inside the area are counted in repeater/pair frequency tables |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"uniqueNodes": number,
|
||
"avgHops": number,
|
||
"medianHops": number,
|
||
"maxHops": number,
|
||
"hopDistribution": [
|
||
{ "hops": number, "count": number } // capped at 25
|
||
],
|
||
"topRepeaters": [
|
||
{
|
||
"hop": string, // raw hex prefix
|
||
"count": number,
|
||
"name": string | null, // resolved name
|
||
"pubkey": string | null
|
||
}
|
||
],
|
||
"topPairs": [
|
||
{
|
||
"hopA": string,
|
||
"hopB": string,
|
||
"count": number,
|
||
"nameA": string | null,
|
||
"nameB": string | null,
|
||
"pubkeyA": string | null,
|
||
"pubkeyB": string | null
|
||
}
|
||
],
|
||
"hopsVsSnr": [
|
||
{ "hops": number, "count": number, "avgSnr": number }
|
||
],
|
||
"observers": [
|
||
{ "id": string, "name": string }
|
||
],
|
||
"perObserverReach": {
|
||
"<observer_id>": {
|
||
"observer_name": string,
|
||
"rings": [
|
||
{
|
||
"hops": number,
|
||
"nodes": [
|
||
{
|
||
"hop": string,
|
||
"name": string | null,
|
||
"pubkey": string | null,
|
||
"count": number,
|
||
"distRange": string | null // e.g. "1-3" or null if constant
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
},
|
||
"multiObsNodes": [
|
||
{
|
||
"hop": string,
|
||
"name": string | null,
|
||
"pubkey": string | null,
|
||
"observers": [
|
||
{
|
||
"observer_id": string,
|
||
"observer_name": string,
|
||
"minDist": number,
|
||
"count": number
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"bestPathList": [
|
||
{
|
||
"hop": string,
|
||
"name": string | null,
|
||
"pubkey": string | null,
|
||
"minDist": number,
|
||
"observer_id": string,
|
||
"observer_name": string
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/analytics/retransmissions
|
||
|
||
Retransmission pressure over time (#1699): a collision-pressure **proxy**, not a
|
||
measured collision rate.
|
||
|
||
For each flood event of a flood-routed packet (`route_type` 0 or 1, TRACE
|
||
excluded) the server takes the union of the paths of all its observations and
|
||
counts the distinct repeaters in it: paths `[A]`, `[A,B,C]` and `[A,D]` give 4.
|
||
Direct routes are excluded because their path is the route still to travel, not
|
||
the forwarders; zero-hop sends are direct routes. A flood event heard only with
|
||
an empty path counts as 0 repeaters.
|
||
|
||
A transmission is one packet hash, and the same bytes can flood again later:
|
||
those observations are stored on the same transmission. Its observations are
|
||
therefore sorted by time and split into flood events wherever two consecutive
|
||
observations are more than 5 minutes apart. Each event is counted on its own and
|
||
bucketed by its first observation. A firmware node holds a flood for at most
|
||
32 s before forwarding it, plus a random retransmit delay, so a flood that is
|
||
still spreading is not split. Every count in the response (`packets`,
|
||
`one_byte_packets`, `no_repeater_packets`) counts flood events. Observations are
|
||
stored once per observer and path per transmission, so a later event holds only
|
||
the observer and path pairs not already stored for that hash, and its count is a
|
||
lower bound.
|
||
|
||
Events that start before the store's retention floor (now minus
|
||
`retentionHours`) are left out for every request shape, including explicit
|
||
`window`, `from` and `to`: the store keeps observations older than that only for
|
||
hashes heard again recently, so they do not represent that period.
|
||
|
||
Hop prefixes are not resolved to nodes. A prefix counts once per flood event,
|
||
whether it repeats across observations or inside one path. A 2- or 3-byte prefix
|
||
that repeats inside one path is one node forwarding the flood again after its
|
||
duplicate filter (a cyclic buffer of 160 hashes) dropped the hash. A repeated
|
||
1-byte prefix can also be two nodes; counting it once keeps the value a lower
|
||
bound, as does merging repeaters that share a prefix across observations. Only
|
||
repeaters that some observer heard are counted, so the value also follows
|
||
observer coverage.
|
||
|
||
The default shape (no `region`, no window, `bucket=1h`) is served from the
|
||
analytics recomputer; other shapes use the TTL cache, and concurrent requests
|
||
for the same uncached shape share one computation. During startup the default
|
||
shape returns `503` with `Retry-After` until the recomputer completes a pass
|
||
after the hot startup window has loaded, or for at most 60 s after the
|
||
recomputer started, whichever comes first. The history beyond the hot window
|
||
keeps loading in the background after that, so until the first recompute pass
|
||
after that load finishes the default shape can cover less than the retention
|
||
window. `?area=` is not
|
||
supported: the area filter works on resolved node public keys and this metric
|
||
does not resolve prefixes.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-------------------------------------|
|
||
| `region` | string | none | Comma-separated IATA codes; only observations from the region's observers feed the union, events none of them heard are skipped. Events are split before this filter. A region with no known observers is not filtered and returns network-wide data, as `/api/analytics/rf` does |
|
||
| `window` | string | none | `1h`, `24h`, `3d`, `7d` or `30d` (relative to now) |
|
||
| `from`, `to` | string (ISO) | none | Absolute window bounds, take precedence over `window` |
|
||
| `bucket` | string | `1h` | `5m`, `15m`, `1h`, `6h` or `1d`; other values fall back to `1h` |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"bucket_seconds": number,
|
||
"window": string, // window label, "" for all data
|
||
"region": string,
|
||
"summary": {
|
||
"packets": number, // flood events that started in the window
|
||
"avg_repeaters": number, // mean distinct repeaters per event
|
||
"observers": number, // distinct observers that heard them
|
||
"one_byte_packets": number, // events on 1-byte hop hashes (most ambiguous)
|
||
"no_repeater_packets": number // events heard with an empty path only
|
||
},
|
||
"buckets": [ // ascending, empty buckets omitted
|
||
{
|
||
"start": string (ISO), // bucket start, UTC
|
||
"packets": number, // flood events that started in the bucket
|
||
"repeater_sum": number,
|
||
"avg_repeaters": number,
|
||
"observers": number
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/analytics/channels
|
||
|
||
Channel analytics.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-------------------------------------|
|
||
| `region` | string | — | Comma-separated IATA codes |
|
||
| `area` | string | — | Area key — area filtering is supported but not exposed in the dashboard (channel stats are observer-based) |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"activeChannels": number,
|
||
"decryptable": number,
|
||
"channels": [
|
||
{
|
||
"hash": string,
|
||
"name": string,
|
||
"messages": number,
|
||
"senders": number, // unique sender count
|
||
"lastActivity": string (ISO),
|
||
"encrypted": boolean
|
||
}
|
||
],
|
||
"topSenders": [
|
||
{ "name": string, "count": number }
|
||
],
|
||
"channelTimeline": [
|
||
{ "hour": string, "channel": string, "count": number }
|
||
],
|
||
"msgLengths": [number] // raw array of message character lengths
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/analytics/distance
|
||
|
||
Hop distance analytics.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-------------------------------------|
|
||
| `region` | string | — | Comma-separated IATA codes |
|
||
| `area` | string | — | Area key — restricts distance calculations to paths where the transmitter GPS falls in the area |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"summary": {
|
||
"totalHops": number,
|
||
"totalPaths": number,
|
||
"avgDist": number, // km, 2 decimal places
|
||
"maxDist": number // km
|
||
},
|
||
"topHops": [
|
||
{
|
||
"fromName": string,
|
||
"fromPk": string,
|
||
"toName": string,
|
||
"toPk": string,
|
||
"dist": number, // km
|
||
"type": string, // "R↔R" | "C↔R" | "C↔C"
|
||
"snr": number | null,
|
||
"hash": string,
|
||
"timestamp": string (ISO)
|
||
}
|
||
],
|
||
"topPaths": [
|
||
{
|
||
"hash": string,
|
||
"totalDist": number, // km
|
||
"hopCount": number,
|
||
"timestamp": string (ISO),
|
||
"hops": [
|
||
{
|
||
"fromName": string,
|
||
"fromPk": string,
|
||
"toName": string,
|
||
"toPk": string,
|
||
"dist": number
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"catStats": {
|
||
"R↔R": { "count": number, "avg": number, "median": number, "min": number, "max": number },
|
||
"C↔R": { "count": number, "avg": number, "median": number, "min": number, "max": number },
|
||
"C↔C": { "count": number, "avg": number, "median": number, "min": number, "max": number }
|
||
},
|
||
"distHistogram": Histogram | [], // empty array if no data
|
||
"distOverTime": [
|
||
{ "hour": string, "avg": number, "count": number }
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/analytics/hash-sizes
|
||
|
||
Hash size analysis across the network.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-------------------------------------|
|
||
| `region` | string | — | Comma-separated IATA codes |
|
||
| `area` | string | — | Area key — restricts to packets from nodes in the area |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"total": number, // packets analyzed
|
||
"distribution": {
|
||
"1": number, // 1-byte hash count
|
||
"2": number, // 2-byte hash count
|
||
"3": number // 3-byte hash count
|
||
},
|
||
"hourly": [
|
||
{ "hour": string, "1": number, "2": number, "3": number }
|
||
],
|
||
"topHops": [
|
||
{
|
||
"hex": string, // raw hop hex
|
||
"size": number, // bytes (ceil(hex.length/2))
|
||
"count": number,
|
||
"name": string | null,
|
||
"pubkey": string | null
|
||
}
|
||
],
|
||
"multiByteNodes": [
|
||
{
|
||
"name": string,
|
||
"hashSize": number,
|
||
"packets": number,
|
||
"lastSeen": string (ISO),
|
||
"pubkey": string | null
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/analytics/hash-collisions
|
||
|
||
Hash collision analysis — packets where the same hash was used by multiple different nodes (ambiguous routing).
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|-------------------------------------|
|
||
| `region` | string | — | Comma-separated IATA codes |
|
||
| `area` | string | — | Area key — restricts to packets from nodes in the area |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"collisions": [
|
||
{
|
||
"hash": string, // hop hex prefix that collides
|
||
"count": number, // number of distinct nodes sharing this prefix
|
||
"nodes": [
|
||
{
|
||
"pubkey": string,
|
||
"name": string | null,
|
||
"count": number // observation count for this node
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"totalCollisions": number,
|
||
"affectedPackets": number
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/nodes/clock-skew
|
||
|
||
Fleet-wide clock skew data. Returns all nodes for which clock skew has been calculated from ADVERT timestamp pairs.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|--------|--------|---------|-----------------------------------------------------|
|
||
| `area` | string | — | Area key — restricts to nodes whose GPS falls in the area |
|
||
|
||
### Response `200`
|
||
|
||
Returns a JSON array (not wrapped in an object):
|
||
|
||
```jsonc
|
||
[
|
||
{
|
||
"pubkey": string,
|
||
"nodeName": string | null,
|
||
"nodeRole": string | null,
|
||
"skewMs": number | null, // current estimated clock offset (ms)
|
||
"driftPerDaySec": number | null, // drift rate (seconds/day)
|
||
"severity": string, // "good" | "warning" | "critical"
|
||
"samples": null // always null in fleet response (too large)
|
||
}
|
||
]
|
||
```
|
||
|
||
**Note:** This is a bare array, not `{ nodes: [...] }`.
|
||
|
||
---
|
||
|
||
## GET /api/analytics/subpaths
|
||
|
||
Subpath frequency analysis.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|----------------------------------------|
|
||
| `minLen` | number | `2` | Minimum subpath length (≥2) |
|
||
| `maxLen` | number | `8` | Maximum subpath length |
|
||
| `limit` | number | `100` | Max results |
|
||
| `region` | string | — | Comma-separated IATA codes |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"subpaths": [
|
||
{
|
||
"path": string, // "Node A → Node B → Node C"
|
||
"rawHops": [string], // ["aa", "bb", "cc"]
|
||
"count": number,
|
||
"hops": number, // length of subpath
|
||
"pct": number // percentage of totalPaths (0–100)
|
||
}
|
||
],
|
||
"totalPaths": number
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/analytics/subpath-detail
|
||
|
||
Detailed stats for a specific subpath.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Required | Description |
|
||
|--------|--------|----------|-------------------------------------|
|
||
| `hops` | string | yes | Comma-separated raw hex hop prefixes |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"hops": [string], // input hops echoed back
|
||
"nodes": [
|
||
{
|
||
"hop": string,
|
||
"name": string,
|
||
"lat": number | null,
|
||
"lon": number | null,
|
||
"pubkey": string | null
|
||
}
|
||
],
|
||
"totalMatches": number,
|
||
"firstSeen": string (ISO) | null,
|
||
"lastSeen": string (ISO) | null,
|
||
"signal": {
|
||
"avgSnr": number | null,
|
||
"avgRssi": number | null,
|
||
"samples": number
|
||
},
|
||
"hourDistribution": [number], // 24-element array (index = UTC hour)
|
||
"parentPaths": [
|
||
{ "path": string, "count": number }
|
||
],
|
||
"observers": [
|
||
{ "name": string, "count": number }
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/scope-audit
|
||
|
||
Network-wide declared-vs-observed region-scope comparison: for every repeater that has
|
||
ever successfully answered a declared-regions request, which of its declared regions have
|
||
no observed forwarding in the window, which scopes it forwards without declaring, and
|
||
whether it contradicts its own `'*'` wildcard. This is the whole-network answer to the
|
||
question `GET /api/nodes/:pubkey/scopes` answers one repeater at a time — see that
|
||
endpoint's notes for the full explanation of the three traps this comparison has to get
|
||
right (the `#`-prefix spelling difference, `'*'` not being a scope, and "never asked"
|
||
not being the same as "declared nothing"), which apply here identically.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|---------------------------------|
|
||
| `window` | string | `24h` | Time window: `1h`, `24h`, `7d` |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"window": string, // echoed window ("1h", "24h", or "7d")
|
||
"since": string (ISO), // start of the observed-forwarding window
|
||
"repeaters": [
|
||
{
|
||
"publicKey": string,
|
||
"name": string, // "" if the node row is gone (pruned/deleted)
|
||
"role": string, // "" if unknown
|
||
"declaredRegions": [string], // '*' excluded — see declaredWildcard
|
||
"declaredWildcard": boolean, // '*' present in the raw declared list
|
||
"configState": string, // "full" | "no-scopes" | "no-unscoped" | "no-flood" — see note below
|
||
"declaredAt": string (ISO),// age of the DECLARED answer, not bounded by window
|
||
"truncated": boolean, // declared list may have had entries silently dropped
|
||
|
||
"notObserved": [string], // declared regions with zero matched-forwarding observed this window
|
||
"undeclaredObserved": [
|
||
{ "scope": string, "packets": number, "firstSeen": string (ISO), "lastSeen": string (ISO) }
|
||
],
|
||
"observedUnscopedPackets": number, // plain-FLOOD packets forwarded this window
|
||
"wildcardContradiction": boolean, // observed unscoped forwarding but '*' not declared
|
||
"ambiguousHops": number, // forwarder hops this window that could not be attributed — see note below
|
||
"observedUnmatchedPackets": number, // forwarded packets whose scope this instance holds no key for — see note below
|
||
"observedUnmatchedSampled": number, // how many of those verification could examine — see note below
|
||
"regionEvidence": { "<region>": number } // declared regions corroborated by this repeater's own unnameable traffic — see note below
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Response `400`
|
||
|
||
```json
|
||
{ "error": "window must be 1h, 24h, or 7d" }
|
||
```
|
||
|
||
**Notes:**
|
||
- Only repeaters with **at least one** declared-regions answer appear in `repeaters`. A
|
||
repeater that was never successfully asked is absent, not shown as a row that declares
|
||
nothing — those are different facts (see `GET /api/nodes/:pubkey/scopes`'s "never asked"
|
||
note).
|
||
- `window` bounds `notObserved` / `undeclaredObserved` / `observedUnscopedPackets` only —
|
||
`declaredAt` is always the latest declared reading regardless of age, exactly like
|
||
`declared.observedAt` on the per-node endpoint. **A "declared but not observed" result
|
||
is weak evidence at `window=1h` (a quiet region can simply have had no traffic) and much
|
||
stronger at `window=7d`; a client MUST show which window a result belongs to and must
|
||
not present a `1h` result as if it were `7d`.** Because `declaredAt` is not bounded by
|
||
`window`, the declared answer and the forwarding evidence are not guaranteed to be
|
||
temporally aligned — a repeater could have declared its regions well before, or even
|
||
after, the window that produced `notObserved` — and the server makes no attempt to
|
||
align them; the `declaredAt` column in the UI is the mitigation (so a stale declared
|
||
answer is visible to the reader), not a guarantee that the two sides describe the same
|
||
period.
|
||
- `ambiguousHops` counts forwarder-hop observations in this window whose truncated hash
|
||
prefix matched more than one declared target's pubkey — i.e. two or more repeaters that
|
||
declared a region list happen to share that prefix. Such a hop is attributed to **none**
|
||
of the matching targets (crediting all of them risks papering over a real gap in a
|
||
colliding neighbour's row; crediting none of them invents no failure) and this counter
|
||
is incremented on **every** matching target instead. A row with a non-zero
|
||
`ambiguousHops` carries weaker evidence than one with zero: any entry in that row's
|
||
`notObserved` could be explained by a prefix collision rather than a genuine absence of
|
||
forwarding, and a client should present it as a caveat rather than a confirmed finding.
|
||
- `observedUnmatchedPackets` counts packets this repeater was observed forwarding whose
|
||
transport scope matched no region key this instance has configured (`hashRegions`), so
|
||
the ingestor stored them with an empty `scope_name`. Those packets name no region and
|
||
therefore cannot satisfy a declared one, which means **a repeater forwarding a region
|
||
this instance cannot name is reported exactly like one forwarding nothing**. A non-zero
|
||
value is a caveat on this row's `notObserved`, in the same spirit as `ambiguousHops` but
|
||
with a different cause and a different fix: `ambiguousHops` is a pubkey-prefix collision
|
||
between two repeaters and nobody's fault, `observedUnmatchedPackets` is a missing entry
|
||
in this instance's own configuration and the operator can act on it. It is **not**
|
||
evidence for or against `declaredWildcard` — unmatched traffic is scoped, so it never
|
||
affects `wildcardContradiction`, which counts only plain unscoped floods.
|
||
Part of this count is explained: packets counted in `regionEvidence` are
|
||
attributable to a declared region after all. A client showing this as a caveat should
|
||
subtract them and report only the remainder, which carries a sharper meaning — traffic
|
||
this repeater forwards for a region it does **not** declare and this instance cannot
|
||
name. Two rules on that subtraction: count only evidence for regions **absent** from
|
||
`notObserved` (a region with a single hit was deliberately not accepted as evidence, so
|
||
its packet is not explained either), and compare against `observedUnmatchedSampled`
|
||
first.
|
||
- `observedUnmatchedSampled` is how many of those packets verification could actually
|
||
examine. Two caps sit between the count and the evidence: the per-repeater working set
|
||
(512 packets) and the per-window sample (the 4096 most recent unnameable packets, which
|
||
a deployment with few `hashRegions` entries will reach). `regionEvidence` can only ever
|
||
count packets inside that sample, so when this field is **smaller** than
|
||
`observedUnmatchedPackets` the difference between the count and the evidence is an
|
||
**upper bound** on the unexplained traffic rather than a figure, and a client should say
|
||
so. Equal values mean the subtraction is exact.
|
||
- `regionEvidence` maps a declared region to how many of this repeater's own unmatched
|
||
forwarded packets derive to it. The server tests each declared region this repeater has
|
||
no *named* evidence for by deriving `SHA256("#region")[:16]` and HMAC-ing that
|
||
repeater's own unmatched packets with it — the same computation the ingestor performs at
|
||
ingest, with the candidate set narrowed to this repeater's declarations. A region
|
||
reaching **2** corroborating packets is removed from `notObserved`: `code1` is two
|
||
bytes, so one match happens by chance with probability 1/65536, while two on the same
|
||
region is (1/65536)². A region with exactly one hit therefore stays in `notObserved`
|
||
**and** appears here with the value 1, so a client can explain why it is still shown as
|
||
not observed. `notObserved` remains the single source of truth for whether a region was
|
||
observed; this field says only *how* that was established. The object is always present
|
||
and may be empty.
|
||
- All scope names in `declaredRegions` / `notObserved` / `undeclaredObserved[].scope` are
|
||
already normalised (no leading `#`) — the server does the `#`/no-`#` reconciliation
|
||
described on the per-node endpoint so this response is directly comparable without a
|
||
client-side normalisation step.
|
||
- `'*'` is never present in `declaredRegions`, `notObserved`, or `undeclaredObserved` — see
|
||
`declaredWildcard` and `wildcardContradiction` for its dedicated (non-scope) treatment,
|
||
same rule as the per-node endpoint.
|
||
- `configState` is a derived reading of `declaredRegions`/`declaredWildcard` together —
|
||
the repeater's overall configuration shape, rather than a per-region detail:
|
||
- `"full"` — named regions **and** `'*'` declared: fully configured, forwards both its
|
||
declared regions and plain unscoped floods.
|
||
- `"no-scopes"` — `'*'` only, no named regions.
|
||
- `"no-unscoped"` — named regions declared, `'*'` absent: this repeater does **not**
|
||
forward plain unscoped floods. This reading is exact — `'*'` absent from the export
|
||
always means the wildcard denies flooding.
|
||
- `"no-flood"` — neither named regions nor `'*'`: the repeater answered, but nothing at
|
||
all is flood-allowed, not even plain unscoped traffic. The "no unscoped forwarding"
|
||
half of this is exact for the same reason as `"no-unscoped"`; the "no named regions"
|
||
half carries the same caveat as `"no-scopes"` below.
|
||
|
||
**Caveat on `"no-scopes"` and `"no-flood"`:** the firmware exports the FLOOD-*allowed*
|
||
set (`region_map.exportNamesTo(..., REGION_DENY_FLOOD)` in
|
||
`examples/simple_repeater/MyMesh.cpp`), not "every region this repeater has configured".
|
||
A repeater with regions defined but every one of them marked deny-flood exports exactly
|
||
the same list as a repeater with no region tree at all — the two are indistinguishable
|
||
from this data alone. In practice `"no-scopes"` is almost always "no scopes configured",
|
||
but a client MUST NOT present it as proven absence of configuration; word it as "no
|
||
region is flood-allowed" rather than "no regions exist".
|
||
- `wildcardContradiction` is `true` when the repeater was observed forwarding unscoped
|
||
(plain-`FLOOD`) traffic this window but its declared list omits `'*'` — it declares it
|
||
will NOT forward those packets, and the traffic says otherwise.
|
||
- Rows are sorted with the interesting cases first: most `notObserved` entries first, then
|
||
`wildcardContradiction`, then most `undeclaredObserved` entries, then alphabetically by
|
||
name. A repeater in full agreement (no `notObserved`, no `wildcardContradiction`, no
|
||
`undeclaredObserved`) sorts to the bottom.
|
||
- Cached 30 seconds per window, mirroring `/api/scope-stats`.
|
||
- Blacklisted nodes and nodes matching an operator-configured hidden-name prefix are
|
||
excluded, same as other multi-node endpoints.
|
||
|
||
---
|
||
|
||
## GET /api/scope-stats
|
||
|
||
Scope-based packet statistics over a time window. Requires ingestor `scope_name_v1` migration to have run.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Default | Description |
|
||
|----------|--------|---------|------------------------------------------------|
|
||
| `window` | string | `24h` | Time window: `1h`, `24h`, `7d` |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"window": string, // echoed window ("1h", "24h", or "7d")
|
||
"summary": {
|
||
"transportTotal": number, // scoped + unscoped transport-route packets
|
||
"scoped": number, // Code1 ≠ 0000 (named + unknown regions)
|
||
"unscoped": number, // transport-route with Code1 = 0000
|
||
"unknownScope": number // scoped but no configured region matched (subset of scoped)
|
||
},
|
||
"byRegion": [
|
||
{ "name": string, "count": number } // region name and packet count
|
||
],
|
||
"timeSeries": [
|
||
{ "t": string (ISO), "scoped": number, "unscoped": number } // bucket timestamps and counts
|
||
],
|
||
"advertsByRole": [
|
||
{
|
||
"role": string, // sender's nodes.role, or "unknown" (see notes below)
|
||
"unscoped": number, // flood adverts with no scope (scope_name NULL)
|
||
"unknownScope": number, // scoped, but no region name could be assigned (scope_name "")
|
||
"named": number // scoped with a named region
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Notes:**
|
||
- `transportTotal` = `scoped` + `unscoped` (only route_type 0 or 3 packets)
|
||
- `scoped` = packets with Code1 ≠ 0000
|
||
- `unscoped` = transport-route packets with Code1 = 0000
|
||
- `unknownScope` = scoped packets that did not match any configured region name
|
||
- Time-series bucket size depends on window:
|
||
- `1h` window → 5-minute buckets
|
||
- `24h` window → 1-hour buckets
|
||
- `7d` window → 6-hour buckets
|
||
- `advertsByRole` (#1979) counts ADVERT packets on flood routes only (TRANSPORT_FLOOD 0, FLOOD 1) in the window,
|
||
grouped by the sender's current `nodes.role`. Zero-hop adverts (DIRECT/TRANSPORT_DIRECT) are excluded.
|
||
Ordered by total adverts descending, then role. It reports what was sent per role, not why.
|
||
`role` is `"unknown"` when the advert row has no `from_pubkey` (legacy rows the ingestor's #1143
|
||
`from_pubkey` backfill has not reached yet), when the sender has no row in `nodes` (including a sender
|
||
the ingestor's node retention moved to `inactive_nodes`, which within the 7d window only happens with
|
||
`retention.nodeDays` below 7), or when its `nodes.role` is empty.
|
||
- Cached 30 seconds
|
||
|
||
> **Note:** On deployments with pre-existing data, `unscoped` will be inflated until the async startup backfill completes, because transport-route rows inserted before the `scope_name_v1` migration ran have `scope_name = NULL` and are indistinguishable from Code1=0000 rows. The backfill goroutine populates them at startup but may take several minutes on large databases.
|
||
|
||
### Response `400`
|
||
|
||
```json
|
||
{ "error": "window must be 1h, 24h, or 7d" }
|
||
```
|
||
|
||
### Response `500` Internal Server Error
|
||
|
||
`scope_name` column does not exist (ingestor has not run migrations yet):
|
||
|
||
```json
|
||
{ "error": "scope_name column not present — run ingestor to apply migrations" }
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/resolve-hops
|
||
|
||
Resolve path hop hex prefixes to node names with regional disambiguation.
|
||
|
||
### Query Parameters
|
||
|
||
| Param | Type | Required | Description |
|
||
|-------------|--------|----------|------------------------------------------|
|
||
| `hops` | string | yes | Comma-separated hex hop prefixes |
|
||
| `observer` | string | no | Observer ID for regional context |
|
||
| `originLat` | number | no | Origin latitude for distance-based disambiguation |
|
||
| `originLon` | number | no | Origin longitude |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"resolved": {
|
||
"<hop>": {
|
||
"name": string | null,
|
||
"pubkey": string | null,
|
||
"ambiguous": boolean | undefined, // true if multiple candidates
|
||
"unreliable": boolean | undefined, // true if failed sanity check
|
||
"candidates": [Candidate],
|
||
"conflicts": [Candidate],
|
||
"globalFallback": boolean | undefined,
|
||
"filterMethod": string | undefined, // "geo" | "observer"
|
||
"hopBytes": number | undefined, // for ambiguous entries
|
||
"totalGlobal": number | undefined,
|
||
"totalRegional": number | undefined,
|
||
"filterMethods": [string] | undefined
|
||
}
|
||
},
|
||
"region": string | null
|
||
}
|
||
```
|
||
|
||
**Candidate shape:**
|
||
|
||
```jsonc
|
||
{
|
||
"name": string,
|
||
"pubkey": string,
|
||
"lat": number | null,
|
||
"lon": number | null,
|
||
"regional": boolean,
|
||
"filterMethod": string,
|
||
"distKm": number | null
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/traces/:hash
|
||
|
||
All observations of a specific packet hash, sorted chronologically.
|
||
|
||
### Path Parameters
|
||
|
||
| Param | Type | Description |
|
||
|--------|--------|----------------|
|
||
| `hash` | string | Packet hash |
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"traces": [
|
||
{
|
||
"observer": string | null, // observer_id
|
||
"observer_name": string | null,
|
||
"time": string (ISO),
|
||
"snr": number | null,
|
||
"rssi": number | null,
|
||
"path_json": string | null
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/config/theme
|
||
|
||
Theme and branding configuration (merged from config.json + theme.json).
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"branding": {
|
||
"siteName": string, // default: "CoreScope"
|
||
"tagline": string // default: "Real-time MeshCore LoRa mesh network analyzer"
|
||
// ... additional branding keys from config/theme files
|
||
},
|
||
"theme": {
|
||
"accent": string, // hex color, default "#4a9eff"
|
||
"accentHover": string,
|
||
"navBg": string,
|
||
"navBg2": string
|
||
// ... additional theme CSS values
|
||
},
|
||
"themeDark": {
|
||
// dark mode overrides (may be empty object)
|
||
},
|
||
"nodeColors": {
|
||
"repeater": string, // hex color
|
||
"companion": string,
|
||
"room": string,
|
||
"sensor": string,
|
||
"observer": string
|
||
},
|
||
"typeColors": {
|
||
// payload type → hex color overrides
|
||
},
|
||
"home": object | null // home page customization
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/config/regions
|
||
|
||
Available regions (IATA codes) merged from config + DB.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"<iata_code>": string // code → display name
|
||
// e.g. "SFO": "San Francisco", "LAX": "Los Angeles"
|
||
}
|
||
```
|
||
|
||
Returns a flat key-value object.
|
||
|
||
---
|
||
|
||
## GET /api/config/areas
|
||
|
||
Available area filters defined in `config.json` under `areas`. Used by the frontend to populate the area pill bar. Entries with an empty `label` (e.g. comment keys) are excluded.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
[
|
||
{
|
||
"key": string, // area key as defined in config (e.g. "bayarea")
|
||
"label": string // display name (e.g. "Bay Area")
|
||
}
|
||
]
|
||
```
|
||
|
||
Returns `[]` when no areas are configured.
|
||
|
||
**Note:** Polygon coordinates are **not** included. Use `/api/config/areas/polygons` for the full geometry.
|
||
|
||
---
|
||
|
||
## GET /api/config/areas/polygons
|
||
|
||
Full area definitions including polygon/bounding-box coordinates. Intended for map rendering tools (e.g. the area-map debug tool).
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
[
|
||
{
|
||
"key": string,
|
||
"label": string,
|
||
"polygon": [[number, number]] | undefined, // [lat, lon] pairs (if polygon-style)
|
||
"latMin": number | undefined, // bounding-box style
|
||
"latMax": number | undefined,
|
||
"lonMin": number | undefined,
|
||
"lonMax": number | undefined
|
||
}
|
||
]
|
||
```
|
||
|
||
Returns `[]` when no areas are configured.
|
||
|
||
---
|
||
|
||
## GET /api/config/client
|
||
|
||
Client-side configuration values.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"roles": object | null,
|
||
"healthThresholds": object | null,
|
||
"tiles": object | null,
|
||
"snrThresholds": object | null,
|
||
"distThresholds": object | null,
|
||
"maxHopDist": number | null,
|
||
"limits": object | null,
|
||
"perfSlowMs": number | null,
|
||
"wsReconnectMs": number | null,
|
||
"cacheInvalidateMs": number | null,
|
||
"externalUrls": object | null,
|
||
"propagationBufferMs": number // default: 5000
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/config/cache
|
||
|
||
Cache TTL configuration (raw values in seconds).
|
||
|
||
### Response `200`
|
||
|
||
Returns the raw `cacheTTL` object from `config.json`, or `{}` if not set:
|
||
|
||
```jsonc
|
||
{
|
||
"stats": number | undefined, // seconds
|
||
"nodeDetail": number | undefined,
|
||
"nodeHealth": number | undefined,
|
||
"nodeList": number | undefined,
|
||
"bulkHealth": number | undefined,
|
||
"networkStatus": number | undefined,
|
||
"observers": number | undefined,
|
||
"channels": number | undefined,
|
||
"channelMessages": number | undefined,
|
||
"analyticsRF": number | undefined,
|
||
"analyticsTopology": number | undefined,
|
||
"analyticsChannels": number | undefined,
|
||
"analyticsHashSizes": number | undefined,
|
||
"analyticsSubpaths": number | undefined,
|
||
"analyticsSubpathDetail": number | undefined,
|
||
"nodeAnalytics": number | undefined,
|
||
"nodeSearch": number | undefined,
|
||
"invalidationDebounce": number | undefined
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/config/map
|
||
|
||
Map default center and zoom.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"center": [number, number], // [lat, lon], default [37.45, -122.0]
|
||
"zoom": number // default 9
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/iata-coords
|
||
|
||
IATA airport/region coordinates for client-side regional filtering.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"coords": {
|
||
"<iata_code>": {
|
||
"lat": number,
|
||
"lon": number,
|
||
"radiusKm": number
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## GET /api/audio-lab/buckets
|
||
|
||
Representative packets bucketed by payload type for audio lab.
|
||
|
||
### Response `200`
|
||
|
||
```jsonc
|
||
{
|
||
"buckets": {
|
||
"<type_name>": [
|
||
{
|
||
"hash": string,
|
||
"raw_hex": string (hex),
|
||
"decoded_json": string | null,
|
||
"observation_count": number,
|
||
"payload_type": number,
|
||
"path_json": string | null,
|
||
"observer_id": string | null,
|
||
"timestamp": string (ISO)
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## WebSocket Messages
|
||
|
||
### Connection
|
||
|
||
Connect to `ws://<host>` (or `wss://<host>` for HTTPS). No authentication.
|
||
The server broadcasts messages to all connected clients.
|
||
|
||
### Message Wrapper
|
||
|
||
All WebSocket messages use this envelope:
|
||
|
||
```jsonc
|
||
{
|
||
"type": string, // "packet" or "message"
|
||
"data": object // payload (shape depends on type)
|
||
}
|
||
```
|
||
|
||
### Message Type: `"packet"`
|
||
|
||
Broadcast on every new packet ingestion.
|
||
|
||
```jsonc
|
||
{
|
||
"type": "packet",
|
||
"data": {
|
||
"id": number, // observation or transmission ID
|
||
"raw": string (hex) | null,
|
||
"decoded": {
|
||
"header": {
|
||
"routeType": number,
|
||
"payloadType": number,
|
||
"payloadVersion": number,
|
||
"payloadTypeName": string // "ADVERT", "GRP_TXT", "TXT_MSG", etc.
|
||
},
|
||
"path": {
|
||
"hops": [string] // hex hop prefixes
|
||
},
|
||
"payload": object // decoded payload (varies by type)
|
||
},
|
||
"snr": number | null,
|
||
"rssi": number | null,
|
||
"hash": string | null,
|
||
"observer": string | null, // observer_id
|
||
"observer_name": string | null,
|
||
"path_json": string | null, // JSON-stringified hops array
|
||
"packet": Packet | undefined, // full packet object (when available)
|
||
"observation_count": number | undefined
|
||
}
|
||
}
|
||
```
|
||
|
||
**Notes:**
|
||
- `data.decoded` is always present with at least `header.payloadTypeName`.
|
||
- `data.packet` is included for raw packet ingestion (Format 1 / MQTT), may be absent for companion bridge messages.
|
||
- `data.path_json` is the JSON-stringified version of `data.decoded.path.hops`.
|
||
|
||
#### Fields consumed by frontend pages:
|
||
|
||
| Field | live.js | packets.js | app.js | channels.js |
|
||
|---------------------------|---------|------------|--------|-------------|
|
||
| `data.id` | ✓ | ✓ | | |
|
||
| `data.hash` | ✓ | ✓ | | |
|
||
| `data.raw` | ✓ | | | |
|
||
| `data.decoded.header.payloadTypeName` | ✓ | ✓ | | |
|
||
| `data.decoded.payload` | ✓ | ✓ | | |
|
||
| `data.decoded.path.hops` | ✓ | | | |
|
||
| `data.snr` | ✓ | | | |
|
||
| `data.rssi` | ✓ | | | |
|
||
| `data.observer` | ✓ | | | |
|
||
| `data.observer_name` | ✓ | | | |
|
||
| `data.packet` | | ✓ | | |
|
||
| `data.observation_count` | | ✓ | | |
|
||
| `data.path_json` | ✓ | | | |
|
||
| (any) | | | ✓ (*) | |
|
||
|
||
(*) `app.js` passes all messages to registered `wsListeners` and uses them only for cache invalidation.
|
||
|
||
### Message Type: `"message"`
|
||
|
||
Broadcast for GRP_TXT (channel message) packets only. Same `data` shape as `"packet"` type.
|
||
`channels.js` listens for this type to update the channel message feed in real time.
|
||
|
||
```jsonc
|
||
{
|
||
"type": "message",
|
||
"data": {
|
||
// identical shape to "packet" data
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Shared Object Shapes
|
||
|
||
### Packet Object
|
||
|
||
A transmission/packet as stored in memory and returned by most endpoints:
|
||
|
||
```jsonc
|
||
{
|
||
"id": number, // transmission ID
|
||
"raw_hex": string (hex) | null,
|
||
"hash": string, // content hash (dedup key)
|
||
"first_seen": string (ISO), // when first observed
|
||
"timestamp": string (ISO), // display timestamp (= first_seen)
|
||
"route_type": number, // 0=DIRECT, 1=FLOOD, 2=reserved, 3=TRANSPORT
|
||
"payload_type": number, // 0=REQ, 1=RESPONSE, 2=TXT_MSG, 3=ACK, 4=ADVERT, 5=GRP_TXT, 7=ANON_REQ, 8=PATH, 9=TRACE, 11=CONTROL
|
||
"payload_version": number | null,
|
||
"decoded_json": string | null, // JSON-stringified decoded payload
|
||
"observation_count": number,
|
||
"observer_id": string | null, // from "best" observation
|
||
"observer_name": string | null,
|
||
"snr": number | null,
|
||
"rssi": number | null,
|
||
"path_json": string | null, // JSON-stringified hop array
|
||
"direction": string | null,
|
||
"score": number | null,
|
||
"observations": [Observation] | undefined // stripped by default on list endpoints
|
||
}
|
||
```
|
||
|
||
### Observation Object
|
||
|
||
A single observation of a transmission by an observer:
|
||
|
||
```jsonc
|
||
{
|
||
"id": number,
|
||
"transmission_id": number,
|
||
"hash": string,
|
||
"observer_id": string | null,
|
||
"observer_name": string | null,
|
||
"direction": string | null,
|
||
"snr": number | null,
|
||
"rssi": number | null,
|
||
"score": number | null,
|
||
"path_json": string | null,
|
||
"timestamp": string (ISO) | number, // ISO string or unix epoch
|
||
// Enriched fields (from parent transmission):
|
||
"raw_hex": string (hex) | null,
|
||
"payload_type": number,
|
||
"decoded_json": string | null,
|
||
"route_type": number
|
||
}
|
||
```
|
||
|
||
### DecodedHeader
|
||
|
||
```jsonc
|
||
{
|
||
"routeType": number,
|
||
"payloadType": number,
|
||
"payloadVersion": number,
|
||
"payloadTypeName": string // human-readable name
|
||
}
|
||
```
|
||
|
||
### DecodedPath
|
||
|
||
```jsonc
|
||
{
|
||
"hops": [string], // hex hop prefixes, e.g. ["a1b2", "c3d4"]
|
||
"hashSize": number, // bytes per hop hash (1–3)
|
||
"hashCount": number // number of hops in path field
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Payload Type Reference
|
||
|
||
| Value | Name | Description |
|
||
|-------|------------|----------------------------------|
|
||
| 0 | `REQ` | Request |
|
||
| 1 | `RESPONSE` | Response |
|
||
| 2 | `TXT_MSG` | Direct text message |
|
||
| 3 | `ACK` | Acknowledgement |
|
||
| 4 | `ADVERT` | Node advertisement |
|
||
| 5 | `GRP_TXT` | Group/channel text message |
|
||
| 7 | `ANON_REQ` | Anonymous request |
|
||
| 8 | `PATH` | Path / traceroute |
|
||
| 9 | `TRACE` | Trace response |
|
||
| 11 | `CONTROL` | Control message |
|
||
|
||
## Route Type Reference
|
||
|
||
| Value | Name | Description |
|
||
|-------|-------------|--------------------------------------|
|
||
| 0 | `DIRECT` | Direct (with transport codes) |
|
||
| 1 | `FLOOD` | Flood/broadcast |
|
||
| 2 | (reserved) | |
|
||
| 3 | `TRANSPORT` | Transport (with transport codes) |
|
||
|
||
---
|
||
|
||
## Area Filter
|
||
|
||
The `?area=<key>` query parameter is a **display-side geographic filter** that attributes data to a region based on the **transmitting node's own GPS coordinates**, as broadcast in its ADVERT packets. It is distinct from the observer-based `?region=` filter.
|
||
|
||
### Configuration
|
||
|
||
Areas are defined in `config.json` under the `areas` key:
|
||
|
||
```jsonc
|
||
{
|
||
"areas": {
|
||
"bayarea": {
|
||
"label": "Bay Area",
|
||
"polygon": [[37.9, -122.5], [37.9, -121.9], [37.3, -121.9], [37.3, -122.5]]
|
||
},
|
||
"sanjose": {
|
||
"label": "San Jose",
|
||
"latMin": 37.25, "latMax": 37.45,
|
||
"lonMin": -122.05, "lonMax": -121.75
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Each entry may use either a `polygon` (array of `[lat, lon]` pairs, minimum 3 points) or a bounding box (`latMin`/`latMax`/`lonMin`/`lonMax`). The polygon check uses standard ray-casting point-in-polygon.
|
||
|
||
### Attribution rules
|
||
|
||
| Packet type | Area-attributable? | Reason |
|
||
|-------------|-------------------|--------|
|
||
| ADVERT (4) | Yes | Carries `public_key` + transmitter GPS in payload |
|
||
| GRP_TXT (5), TXT_MSG (2), REQ (0), others | No | Sender is encrypted; origin cannot be determined |
|
||
|
||
When `?area=` is active, **only ADVERT packets** (and nodes derived from them) are included in filtered results. All other packet types are excluded. This is by design — non-ADVERT packets have encrypted senders and cannot be attributed to a geographic origin.
|
||
|
||
### GPS staleness
|
||
|
||
Node GPS coordinates are read from the `nodes` table, which is updated on ADVERT ingest. A node that moves between areas will not be re-attributed until its next ADVERT (typically 12–24 hours for repeaters). The area node set is cached for 30 seconds server-side.
|
||
|
||
### Endpoints supporting `?area=`
|
||
|
||
| Endpoint | Area support |
|
||
|----------|-------------|
|
||
| `GET /api/nodes` | Filters node list by GPS in area |
|
||
| `GET /api/analytics/rf` | Restricts RF stats to ADVERT packets from area nodes |
|
||
| `GET /api/analytics/topology` | Counts only hops that resolve to nodes in the area |
|
||
| `GET /api/analytics/channels` | Supported (not used by dashboard UI) |
|
||
| `GET /api/analytics/distance` | Restricts distance paths to area-node transmitters |
|
||
| `GET /api/analytics/hash-sizes` | Restricts hash analysis to area-node packets |
|
||
| `GET /api/analytics/hash-collisions` | Restricts collision analysis to area-node packets |
|
||
| `GET /api/nodes/clock-skew` | Restricts fleet clock skew list to nodes in area |
|
||
|
||
### Cross-antimeridian polygons
|
||
|
||
Polygons that span the 180° meridian (antimeridian) are **not supported** — ray-casting point-in-polygon breaks at the date line. Split such areas into two separate entries.
|