## Summary - Adds configurable GPS polygon areas to `config.json`; nodes are attributed to an area if their last-known position falls inside the polygon - New `Area: …` dropdown filter (matching the existing region filter style) appears on all analytics, nodes, packets, map, and live screens when areas are configured - Backend resolves area membership with a 30s TTL cache; area filter bypasses the 500-node cap on `/api/bulk-health` so all area nodes are always returned - Includes a polygon builder tool (`/area-map.html`) for drawing and exporting area boundaries ## Changes **Backend** - `AreaEntry` type + `Areas` config field - `GetNodePubkeysInArea` DB query + `resolveAreaNodes` (30s TTL, `areaNodeMu` RWMutex) - `PacketQuery.Area` + `filterPackets` polygon check - `?area=` param propagated through all analytics, topology, clock-health, and bulk-health routes - `/api/config/areas` endpoint **Frontend** - `area-filter.js`: single-select dropdown, persists to localStorage, cleans up stale keys on load - Wired into analytics, nodes, packets, channels, map, and live pages - Live map clears node markers on area change **Docs & tools** - `docs/user-guide/area-filter.md` — configuration and usage guide - `docs/api-spec.md` — updated with new endpoint and `?area=` param table - `tools/area-map.html` — polygon builder for defining area boundaries - Demo areas added to `config.example.json` ## Test plan - [x] No areas configured → filter dropdown does not appear on any page - [x] Areas configured → dropdown appears, "All" selected by default - [x] Selecting an area filters nodes/packets/topology/map correctly - [x] Selecting "All" restores unfiltered view - [x] Selection persists across page reloads (localStorage) - [x] Stale localStorage key (area removed from config) is cleared on load - [x] `/api/bulk-health?area=X` returns all nodes in area (no 500-node cap) - [x] `/api/config/areas` returns correct list 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com> Co-authored-by: openclaw-bot <bot@openclaw.local>
59 KiB
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
- GET /api/stats
- GET /api/health
- GET /api/perf
- POST /api/perf/reset
- GET /api/nodes
- GET /api/nodes/search
- GET /api/nodes/bulk-health
- GET /api/nodes/network-status
- GET /api/nodes/:pubkey
- GET /api/nodes/:pubkey/health
- GET /api/nodes/:pubkey/paths
- GET /api/nodes/:pubkey/analytics
- GET /api/packets
- GET /api/packets/timestamps
- GET /api/packets/:id
- POST /api/packets
- POST /api/decode
- GET /api/observers
- GET /api/observers/:id
- GET /api/observers/:id/analytics
- GET /api/channels
- GET /api/channels/:hash/messages
- GET /api/analytics/rf
- GET /api/analytics/topology
- GET /api/analytics/channels
- GET /api/analytics/distance
- GET /api/analytics/hash-sizes
- GET /api/analytics/subpaths
- GET /api/analytics/subpath-detail
- GET /api/scope-stats
- GET /api/resolve-hops
- GET /api/traces/:hash
- GET /api/config/theme
- GET /api/config/regions
- GET /api/config/areas
- GET /api/config/areas/polygons
- GET /api/config/client
- GET /api/config/cache
- GET /api/config/map
- GET /api/iata-coords
- GET /api/nodes/clock-skew
- GET /api/analytics/hash-collisions
- GET /api/audio-lab/buckets
- WebSocket Messages
- 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
| nullmay be absent ornull. - Array fields MUST be
[]when empty, NEVERnull. - 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
{ "error": "string" }
400— Bad request (missing/invalid params)404— Resource not found
GET /api/stats
Server-wide statistics. Lightweight, cached 10s.
Response 200
{
"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
{
"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
{
"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 ifconfig.apiKeyis set)
Response 200
{ "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) |
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
{
"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.
}
],
"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_seenis only present when more than one hash size has been observed.last_heardis only present when in-memory data provides a more recent timestamp thanlast_seen.
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
{
"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):
[
{
"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
{
"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
{
"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).
Response 404
{ "error": "Not found" }
GET /api/nodes/:pubkey/health
Detailed health information for a single node.
Response 200
{
"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
{ "error": "Not found" }
GET /api/nodes/:pubkey/paths
Path analysis for a node — all paths containing this node's prefix.
Response 200
{
"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
{ "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
{
"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
{ "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)
{
"packets": [Packet], // see Packet Object below (observations stripped unless expand=observations)
"total": number,
"limit": number,
"offset": number
}
Response 200 (groupByHash=true)
{
"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)
{
"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):
["2025-07-17T00:00:01.000Z", "2025-07-17T00:00:02.000Z", ...]
Response 400
{ "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
{
"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
{ "error": "Not found" }
POST /api/packets
Ingest a raw packet. Requires API key.
Headers
X-API-Key: <key>(required ifconfig.apiKeyis set)
Request Body
{
"hex": string, // required — raw hex-encoded packet
"observer": string | null, // observer ID
"snr": number | null,
"rssi": number | null,
"region": string | null, // IATA code
"hash": string | null // pre-computed content hash
}
Response 200
{
"id": number, // packet/observation ID
"decoded": { // full decode result
"header": DecodedHeader,
"path": DecodedPath,
"payload": object
}
}
Response 400
{ "error": "hex is required" }
POST /api/decode
Decode a raw packet without storing it.
Request Body
{
"hex": string // required — raw hex-encoded packet
}
Response 200
{
"decoded": {
"header": DecodedHeader,
"path": DecodedPath,
"payload": object
}
}
Response 400
{ "error": "hex is required" }
GET /api/observers
List all observers with packet counts.
Response 200
{
"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
{
"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
{ "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
{
"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
{
"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
{
"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
}
],
"total": number // total deduplicated messages
}
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) |
Response 200
{
"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
{
"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
{
"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/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
{
"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
{
"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
{
"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
{
"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):
[
{
"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
{
"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
{
"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-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
{
"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
]
}
Notes:
transportTotal=scoped+unscoped(only route_type 0 or 3 packets)scoped= packets with Code1 ≠ 0000unscoped= transport-route packets with Code1 = 0000unknownScope= scoped packets that did not match any configured region name- Time-series bucket size depends on window:
1hwindow → 5-minute buckets24hwindow → 1-hour buckets7dwindow → 6-hour buckets
- Cached 30 seconds
Note: On deployments with pre-existing data,
unscopedwill be inflated until the async startup backfill completes, because transport-route rows inserted before thescope_name_v1migration ran havescope_name = NULLand are indistinguishable from Code1=0000 rows. The backfill goroutine populates them at startup but may take several minutes on large databases.
Response 400
{ "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):
{ "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
{
"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:
{
"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
{
"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
{
"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
{
"<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
[
{
"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
[
{
"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
{
"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:
{
"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
{
"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
{
"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
{
"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:
{
"type": string, // "packet" or "message"
"data": object // payload (shape depends on type)
}
Message Type: "packet"
Broadcast on every new packet ingestion.
{
"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.decodedis always present with at leastheader.payloadTypeName.data.packetis included for raw packet ingestion (Format 1 / MQTT), may be absent for companion bridge messages.data.path_jsonis the JSON-stringified version ofdata.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.
{
"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:
{
"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:
{
"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
{
"routeType": number,
"payloadType": number,
"payloadVersion": number,
"payloadTypeName": string // human-readable name
}
DecodedPath
{
"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:
{
"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.