feat(geofilter-docs): app-served docs page (#820) (#900)

## Summary

- Adds `public/geofilter-docs.html` — a self-contained, app-served
documentation page for the geofilter feature, matching the builder's
dark theme
- Updates the GeoFilter Builder's help-bar "Documentation" link from
GitHub markdown URL to the local `/geofilter-docs.html`

## Docs coverage

Polygon syntax, coordinate ordering (`[lat, lon]` — not GeoJSON `[lon,
lat]`), multi-polygon clarification (single polygon only), examples
(Belgium rectangle + irregular shape), legacy bounding box format, prune
script usage.

## Test plan

- [x] Open `/geofilter-docs.html` — dark theme renders, all sections
visible
- [x] Open `/geofilter-builder.html` → click "Documentation" → navigates
to `/geofilter-docs.html` in same tab
- [x] Click "← GeoFilter Builder" on docs page → navigates back to
`/geofilter-builder.html`

Closes #820

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
efiten
2026-05-01 08:50:54 +02:00
committed by GitHub
parent e460932668
commit 69080a852f
3 changed files with 337 additions and 1 deletions
@@ -0,0 +1,204 @@
# Scope Stats Page — Design Spec
**Issue**: Kpa-clawbot/CoreScope#899
**Date**: 2026-04-23
**Branch target**: `master`
---
## Overview
Add a dedicated **Scopes** page showing scope/region statistics for MeshCore transport-route packets. Scope filtering in MeshCore uses `TRANSPORT_FLOOD` (route_type 0) and `TRANSPORT_DIRECT` (route_type 3) packets that carry two 16-bit transport codes. Code1 ≠ `0000` means the packet is region-scoped.
Feature 3 from the issue (default scope per client via advert) is **not implemented** — the advert format has no scope field in the current firmware.
---
## How Scopes Work (Firmware)
Transport code derivation (authoritative source: `meshcore-dev/MeshCore`):
```
key = SHA256("#regionname")[:16] // TransportKeyStore::getAutoKeyFor
Code1 = HMAC-SHA256(key, type || payload) // TransportKey::calcTransportCode, 2-byte output
```
Code1 is a **per-message** HMAC — the same region produces a different Code1 for every message. Identifying a region from Code1 requires knowing the region name in advance and recomputing the HMAC.
`Code1 = 0000` is the "no scope" sentinel (also `FFFF` is reserved). Packets with route_type 1 or 2 (plain FLOOD/DIRECT) carry no transport codes.
---
## Config
Add `hashRegions` to the ingestor `Config` struct in `cmd/ingestor/config.go`, mirroring `hashChannels`:
```json
"hashRegions": ["#belgium", "#eu", "#brussels"]
```
Normalization (same rules as `hashChannels`):
- Trim whitespace
- Prepend `#` if missing
- Skip empty entries
---
## Ingestor Changes
### Key derivation (`loadRegionKeys`)
```go
func loadRegionKeys(cfg *Config) map[string][]byte {
// key = first 16 bytes of SHA256("#regionname")
}
```
Returns `map[string][]byte` (region name → 16-byte HMAC key). Called once at startup, stored on the `Store`.
### Decoder: expose raw payload bytes
Add `PayloadRaw []byte` to `DecodedPacket` in `cmd/ingestor/decoder.go`. Populated from the raw `buf` slice at the payload offset — zero-copy slice, no allocation. This is the **encrypted** payload bytes, matching what the firmware feeds into `calcTransportCode`.
### At-ingest region matching
In `BuildPacketData`:
- Skip if `route_type` not in `{0, 3}``scope_name` stays `nil`
- If `Code1 == "0000"``scope_name = nil` (unscoped transport, no scope involvement)
- If `Code1 != "0000"` → try each region key:
```
HMAC-SHA256(key, payloadType_byte || PayloadRaw) → first 2 bytes as uint16
```
First match → `scope_name = "#regionname"`. No match → `scope_name = ""` (unknown scope).
Add `ScopeName *string` to `PacketData`.
### MQTT-sourced packets (DM / CHAN paths in main.go)
These are injected directly without going through `BuildPacketData`. They use `route_type = 1` (FLOOD), so they are never transport-route packets. No scope matching needed for these paths.
---
## Database
### Migration
```sql
ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL;
CREATE INDEX idx_tx_scope_name ON transmissions(scope_name) WHERE scope_name IS NOT NULL;
```
### Column semantics
| Value | Meaning |
|-------|---------|
| `NULL` | Either: non-transport-route packet (route_type 1/2), or transport-route with Code1=0000 |
| `""` (empty string) | Transport-route, Code1 ≠ 0000, but no configured region matched |
| `"#belgium"` | Matched named region |
The API stats queries resolve the NULL ambiguity by always filtering `route_type IN (0, 3)` first:
- `unscoped` count = `route_type IN (0,3) AND scope_name IS NULL`
- `scoped` count = `route_type IN (0,3) AND scope_name IS NOT NULL`
### Backfill
On migration, re-decode `raw_hex` for all rows where `route_type IN (0, 3)` and `scope_name IS NULL`. Run the same HMAC matching logic. Rows with `Code1 = 0000` remain `NULL`.
The backfill runs in the existing migration framework in `cmd/ingestor/db.go`. If no regions are configured, backfill is skipped.
---
## API
### `GET /api/scope-stats`
**Query param**: `window` — one of `1h`, `24h` (default), `7d`
**Time-series bucket sizes**:
| Window | Bucket |
|--------|--------|
| `1h` | 5 min |
| `24h` | 1 hour |
| `7d` | 6 hours|
**Response**:
```json
{
"window": "24h",
"summary": {
"transportTotal": 1240,
"scoped": 890,
"unscoped": 350,
"unknownScope": 42
},
"byRegion": [
{ "name": "#belgium", "count": 612 },
{ "name": "#eu", "count": 236 }
],
"timeSeries": [
{ "t": "2026-04-23T10:00:00Z", "scoped": 45, "unscoped": 18 },
{ "t": "2026-04-23T11:00:00Z", "scoped": 51, "unscoped": 22 }
]
}
```
- `transportTotal` = `scoped + unscoped` (transport-route packets only)
- `scoped` = Code1 ≠ 0000 (named + unknown)
- `unscoped` = transport-route with Code1 = 0000
- `unknownScope` = scoped but no region name matched (subset of `scoped`)
- `byRegion` sorted by count descending, excludes unknown
- `timeSeries` covers the full window at the bucket granularity
Route: `GET /api/scope-stats` registered in `cmd/server/routes.go`.
No auth required (same as other read endpoints).
TTL cache: 30 seconds (heavier query than `/api/stats`).
---
## Frontend
### Navigation
Add nav link between Channels and Nodes in `public/index.html`:
```html
<a href="#/scopes" class="nav-link" data-route="scopes">Scopes</a>
```
### `public/scopes.js`
Three sections on the page:
**1. Summary cards** (reuse existing card CSS pattern from home/analytics pages)
- Transport total, Scoped, Unscoped, Unknown scope
- Each card shows count + percentage of transport total
**2. Per-region table**
Columns: Region, Messages, % of Scoped
Sorted by count descending. Last row: "Unknown scope" (italic) if unknownScope > 0.
Shows "No regions configured" message if `byRegion` is empty and `unknownScope = 0`.
**3. Time-series chart**
- Window selector: `1h / 24h / 7d` (default 24h)
- Two lines: **Scoped** (blue) and **Unscoped** (grey)
- Uses the same lightweight canvas chart pattern as other pages (no external chart lib)
### Cache buster
`scopes.js` added to the `__BUST__` entries in `index.html` in the same commit.
---
## Testing
- Unit tests for `loadRegionKeys`: normalization, key bytes match firmware SHA256 derivation
- Unit tests for HMAC matching: known Code1 value computed from firmware logic, verified against Go implementation
- Integration test: ingest a synthetic transport-route packet with a known region, assert `scope_name` column is set correctly
- API test: `GET /api/scope-stats` returns correct summary counts against fixture DB
---
## Out of Scope
- Feature 3 (default scope per client via advert) — firmware has no advert scope field
- Drill-down from region row to filtered packet list (deferred)
- Private regions (`$`-prefixed) — use secret keys not publicly derivable
+1 -1
View File
@@ -70,7 +70,7 @@
<div id="help-bar">
Copy the JSON above → paste as a top-level key in <code>config.json</code> → restart the server.
Nodes with no GPS fix always pass through. Remove the <code>geo_filter</code> block to disable filtering.
&nbsp;·&nbsp; <a href="https://github.com/Kpa-clawbot/CoreScope/blob/master/docs/user-guide/geofilter.md" target="_blank">Documentation</a>
&nbsp;·&nbsp; <a href="/geofilter-docs.html">Documentation</a>
</div>
<script>
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoFilter Docs — CoreScope</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; flex-direction: column; }
header { padding: 12px 16px; background: #0f0f23; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 16px; }
header h1 { font-size: 1rem; font-weight: 600; color: #4a9eff; }
#back-link { font-size: 0.8rem; color: #4a9eff; text-decoration: none; white-space: nowrap; }
#back-link:hover { text-decoration: underline; }
main { flex: 1; max-width: 800px; margin: 0 auto; padding: 32px 24px; width: 100%; }
h2 { font-size: 1.1rem; font-weight: 600; color: #4a9eff; margin: 32px 0 12px; border-bottom: 1px solid #222; padding-bottom: 6px; }
h2:first-of-type { margin-top: 0; }
h3 { font-size: 0.95rem; font-weight: 600; color: #c0c0c0; margin: 20px 0 8px; }
p { font-size: 0.9rem; line-height: 1.6; color: #ccc; margin-bottom: 10px; }
ul { padding-left: 20px; margin-bottom: 10px; }
li { font-size: 0.9rem; line-height: 1.7; color: #ccc; }
code { font-family: monospace; font-size: 0.85rem; color: #7ec8e3; background: #111; border: 1px solid #333; border-radius: 3px; padding: 1px 5px; }
pre { background: #111; border: 1px solid #333; border-radius: 6px; padding: 14px 16px; overflow-x: auto; margin: 10px 0 16px; }
pre code { background: none; border: none; padding: 0; font-size: 0.82rem; color: #7ec8e3; }
.note { background: #1a2a1a; border: 1px solid #2a4a2a; border-radius: 6px; padding: 10px 14px; margin: 12px 0; }
.note p { color: #aaddaa; margin: 0; }
.warn { background: #2a1a0a; border: 1px solid #5a3a0a; border-radius: 6px; padding: 10px 14px; margin: 12px 0; }
.warn p { color: #ddbb88; margin: 0; }
table { width: 100%; border-collapse: collapse; margin: 10px 0 16px; font-size: 0.88rem; }
th { background: #0f0f23; color: #888; font-weight: 500; text-align: left; padding: 8px 12px; border: 1px solid #333; }
td { padding: 8px 12px; border: 1px solid #222; color: #ccc; vertical-align: top; }
td code { font-size: 0.82rem; }
</style>
</head>
<body>
<header>
<a href="/geofilter-builder.html" id="back-link">← GeoFilter Builder</a>
<h1>GeoFilter Docs</h1>
</header>
<main>
<h2>How it works</h2>
<p>Geographic filtering restricts which nodes are ingested and returned in API responses. It operates at two levels:</p>
<ul>
<li><strong>Ingest time</strong> — ADVERT packets carrying GPS coordinates are rejected by the ingestor if the node falls outside the configured area. The node never reaches the database.</li>
<li><strong>API responses</strong> — Nodes already in the database are filtered from the <code>/api/nodes</code> response if they fall outside the area. This covers nodes ingested before the filter was configured.</li>
</ul>
<div class="note"><p>Nodes with no GPS fix (<code>lat=0, lon=0</code> or missing coordinates) always pass the filter regardless of configuration.</p></div>
<h2>Configuration</h2>
<p>Add a <code>geo_filter</code> block to <code>config.json</code>:</p>
<pre><code>"geo_filter": {
"polygon": [
[51.55, 3.80],
[51.55, 5.90],
[50.65, 5.90],
[50.65, 3.80]
],
"bufferKm": 20
}</code></pre>
<table>
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>polygon</code></td><td><code>[[lat, lon], ...]</code></td><td>Array of at least 3 coordinate pairs defining the boundary</td></tr>
<tr><td><code>bufferKm</code></td><td>number</td><td>Extra distance (km) around the polygon edge that is also accepted. <code>0</code> = exact boundary</td></tr>
</tbody>
</table>
<p>Both the server and the ingestor read <code>geo_filter</code> from <code>config.json</code>. Restart both after changing this section.</p>
<p>To disable filtering entirely, remove the <code>geo_filter</code> block.</p>
<h2>Coordinate ordering</h2>
<div class="warn"><p><strong>Important:</strong> Coordinates are <code>[lat, lon]</code> — latitude first, longitude second. This is the opposite of GeoJSON, which uses <code>[lon, lat]</code>. Swapping them will place your polygon in the wrong location.</p></div>
<h2>Multi-polygon</h2>
<p>Only a single polygon is supported. If your deployment area consists of multiple disconnected regions, draw a single convex hull that covers all of them, or use the largest region with a generous <code>bufferKm</code> value.</p>
<h2>Examples</h2>
<h3>Belgium (bounding rectangle)</h3>
<pre><code>"geo_filter": {
"polygon": [
[51.55, 3.80],
[51.55, 5.90],
[50.65, 5.90],
[50.65, 3.80]
],
"bufferKm": 20
}</code></pre>
<h3>Irregular shape</h3>
<pre><code>"geo_filter": {
"polygon": [
[51.10, 3.70],
[51.55, 4.20],
[51.30, 5.10],
[50.80, 5.50],
[50.50, 4.80],
[50.70, 3.90]
],
"bufferKm": 10
}</code></pre>
<h2>Legacy bounding box</h2>
<p>An older bounding box format is also supported as a fallback when no <code>polygon</code> is present:</p>
<pre><code>"geo_filter": {
"latMin": 50.65,
"latMax": 51.55,
"lonMin": 3.80,
"lonMax": 5.90
}</code></pre>
<p>Prefer the polygon format — it supports irregular shapes and the <code>bufferKm</code> margin.</p>
<h2>Cleaning up historical nodes</h2>
<p>The ingestor prevents new out-of-bounds nodes from being ingested, but does not retroactively remove nodes stored before the filter was configured. Use the prune script for that:</p>
<pre><code># Dry run — shows what would be deleted without making any changes
python3 scripts/prune-nodes-outside-geo-filter.py --dry-run
# Default paths: /app/data/meshcore.db and /app/config.json
python3 scripts/prune-nodes-outside-geo-filter.py
# Custom paths
python3 scripts/prune-nodes-outside-geo-filter.py /path/to/meshcore.db \
--config /path/to/config.json
# In Docker — run inside the container
docker exec -it meshcore-analyzer \
python3 /app/scripts/prune-nodes-outside-geo-filter.py --dry-run</code></pre>
<p>The script reads <code>geo_filter.polygon</code> and <code>geo_filter.bufferKm</code> from config, lists nodes that fall outside, then asks for <code>yes</code> confirmation before deleting. Nodes without coordinates are always kept.</p>
<p>This is a one-time migration tool — run it once after first configuring <code>geo_filter</code> to clean up pre-filter data.</p>
</main>
</body>
</html>