Compare commits

...

28 Commits

Author SHA1 Message Date
Kpa-clawbot 1e8405d8b8 Fix Docker Go build workdirs for replace paths
Align builder stage directories with repo layout so cmd/server and cmd/ingestor replace github.com/corescope => ../.. resolves to /build where root go.mod is copied.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 23:29:50 -07:00
Kpa-clawbot b75bd650e9 fix(docker): copy shared module for go mod replace in builder stages
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 21:40:40 -07:00
Kpa-clawbot 152c62b5a8 fix(go): normalize go.mod replace paths for linux CI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 21:10:44 -07:00
Kpa-clawbot 9333d9b151 fix: unprotect decode route and update auth tests
- make POST /api/decode public while keeping /api/perf/reset and POST /api/packets protected

- update API key tests to verify decode works without key and protected endpoints still block

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 21:03:00 -07:00
Kpa-clawbot 295fa0e6ee Fix #304: unblock decode endpoint and consolidate decoder
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 21:03:00 -07:00
Kpa-clawbot 0f41f8daf2 Fix channels region filtering for messages and WS
Refs #280

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 21:03:00 -07:00
Kpa-clawbot f28a3146da Fix channel region crosstalk in frontend (#307)
## Summary
Fixes frontend region crosstalk on Channels page by applying region
filtering to message fetches and live WS GRP_TXT handling.

## Changes
- Append `region` query param to channel message API calls in
`selectChannel` and `refreshMessages`.
- Add WS region guard in `public/channels.js` using observer→IATA map
with selected-region snapshot at handler entry.
- On region switch, reload channels and re-fetch selected channel
messages; if empty under selected region, clear pane and show `Channel
not available in selected region`.
- Bump cache busters in `public/index.html`.
- Add frontend helper tests for extracted WS region filter helper in
`test-frontend-helpers.js`.

## Validation
- `node test-frontend-helpers.js`
- `node test-packet-filter.js`
- `node test-aging.js`

Refs #280

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 20:25:11 -07:00
Kpa-clawbot 3a1d7263b4 Fix issue #266: normalize .env LF + auto-fix CRLF (#305)
## Summary
- renormalized .env.example to LF in git index (git add --renormalize
.env.example)
- added early CRLF detection and automatic conversion for .env in
manage.sh
- retained existing safe .env parsing with \r stripping

## Validation
- 
ode test-packet-filter.js
- 
ode test-aging.js
- 
ode test-frontend-helpers.js
- ash -n manage.sh (validated via Git Bash)

Fixes #266

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 19:47:20 -07:00
Kpa-clawbot 92188e8c12 ci: add manual workflow_dispatch trigger (#302)
Adds `workflow_dispatch` trigger to the CI/CD pipeline so it can be
manually triggered from the Actions tab.

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 19:28:46 -07:00
Kpa-clawbot 380da0ee0b docs: add AGENTS.md rule 12 — PR review follow-up comments (#301)
Adds rule 12 to AGENTS.md: when review feedback is addressed, post a
follow-up comment on the PR listing what was fixed with the commit hash.

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 19:23:30 -07:00
Kpa-clawbot 7155b5b017 Fix observer client/radio identity persistence (#298)
## Summary
- fix observer upsert write path in `cmd/ingestor` to persist identity
fields
- map status payload fields into observer metadata: `model`,
`firmware`/`firmware_version`, `client_version`/`clientVersion`, `radio`
- keep NULL-safe behavior when identity fields are missing
- add regression tests for identity persistence and missing-field
handling

## Root cause
The ingestor only wrote telemetry (`battery_mv`, `uptime_secs`,
`noise_floor`) and never included observer identity columns in the
upsert statement, leaving `model`, `firmware`, `client_version`, and
`radio` NULL on fresh DBs.

## Testing
- `cd cmd/ingestor && go test ./...`
- `cd cmd/server && go test ./...`

Fixes #295

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 19:22:34 -07:00
Kpa-clawbot 30fe629bb4 feat(setup): add port negotiation + managed .env updates (#297)
## Summary
Implement issue #236 by rewriting `manage.sh` setup step 3 into a full
ports negotiation flow with `.env` lifecycle management and preflight
validation.

## What Changed
- Reworked setup step 3 to **Ports & Networking**.
- Added layered port detection (`ss -> lsof -> netstat -> nc`), conflict
reporting, and next-available suggestions.
- Added interactive confirmation/override prompts for HTTP/HTTPS/MQTT
ports.
- Added rerun behavior: when `.env` already has ports, prompt to keep or
re-negotiate.
- Added `.env` managed-key merge/update logic for:
  - `PROD_HTTP_PORT`
  - `PROD_HTTPS_PORT`
  - `PROD_MQTT_PORT`
  - `PROD_DATA_DIR`
- Added `.env` creation from `.env.example` when missing.
- Added atomic `.env` write flow (temp file + move).
- Added preflight port validation before setup step 5 start, and in
`./manage.sh start` (when prod container is not already running).
- Updated `.env.example` comments to clarify managed keys.
- Addressed PR #297 review fixes:
- unified staging container name usage via
`STAGING_CONTAINER="corescope-staging-go"`
  - safe `.env` parsing (removed unsafe `eval`)
- DNS resolution fallback chain: `dig -> host -> nslookup -> getent
hosts`
  - explicit warning when no DNS resolver tool is available
- ensured negotiated `selected_http` is persisted via
`write_env_managed_values` to `PROD_HTTP_PORT`

## How It Works
1. Step 3 loads existing `.env` values (if present) and displays current
managed values.
2. If current ports are set, prompts to keep or re-negotiate.
3. On re-negotiate, checks default ports `80`, `443`, `1883` with
layered detection and suggests alternatives on conflicts.
4. Prompts admin to confirm or override each port.
5. Runs existing Domain/HTTPS/Caddyfile flow unchanged in behavior, but
wired to negotiated HTTP port for HTTP-only mode.
6. Persists managed values to `.env` while preserving all other
keys/comments.
7. Shows final resolved HTTP/HTTPS/MQTT mapping and asks explicit
confirmation before build/start.
8. Before starting containers, validates selected ports are still free
and fails with remediation if not.

## Validation performed
| Scenario | Command / Check | Result |
|---|---|---|
| Required frontend helper tests | `node test-packet-filter.js && node
test-aging.js && node test-frontend-helpers.js` |  Passed (all
assertions green) |
| Script syntax | `bash -n manage.sh` |  Passed |
| Staging container consistency | Verified `logs`/`promote` and
status/restart/stop paths use `STAGING_CONTAINER`
(`corescope-staging-go`) |  Confirmed |
| DNS fallback behavior | Reviewed new `resolve_domain_ipv4` chain (`dig
-> host -> nslookup -> getent`) and no-tool warning path |  Confirmed |
| Port→.env round-trip | Verified step 3 writes `selected_http` via
`write_env_managed_values` to `PROD_HTTP_PORT` |  Confirmed |
| Unsafe `.env` loading removed | Confirmed `eval "$(sed ...)"` replaced
with safe line-by-line key/value export parser |  Confirmed |

Fixes #236

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 19:22:03 -07:00
Kpa-clawbot 65c95611f9 Fix #249 BYOP dialog stacking / close behavior (#300)
## Summary
Fixes BYOP modal stacking on the Packets page by preventing duplicate
global click handlers and enforcing a single BYOP overlay instance.

## Root cause
Packets page init could register document-level click handlers
repeatedly across SPA navigations. Clicking BYOP then spawned multiple
overlays, and each close action removed only one layer.

## Changes
- `public/packets.js`
- Added `bindDocumentHandler(...)` to de-duplicate document click
handlers.
- Applied it to packets action delegation, filter menu outside-click
close, and column menu close.
  - Added `removeAllByopOverlays()` and call it before opening BYOP.
  - Tagged BYOP overlay with `.byop-overlay` class.
  - Updated close logic to remove all BYOP overlays in one click.
- Scoped BYOP result lookup to the active overlay
(`overlay.querySelector`).
  - Added destroy cleanup for document handlers and stray BYOP overlays.
- `test-frontend-helpers.js`
  - Added regression tests for:
    - BYOP singleton overlay behavior
    - one-click close removing all overlays
    - document click handler de-dup logic
- `public/index.html`
  - Bumped cache busters for JS/CSS assets.

## Validation
- `node test-frontend-helpers.js`
- `node test-packet-filter.js`
- `node test-aging.js`

All passed locally.

Fixes #249

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 18:46:51 -07:00
Kpa-clawbot 6ea3e419e3 fix: enforce LF line endings to prevent CRLF diff churn (#299)
## Fix: Enforce LF line endings repo-wide

### Problem
Windows-based agents produce CRLF line endings, causing git diffs to
show every line as changed (1000+ line "rewrites" that are actually
20-line patches). This has hit us on `manage.sh`, `deploy.yml`, and
multiple PRs.

### Fix
Added `* text=auto eol=lf` to `.gitattributes`. Git will now:
- Store all text files as LF in the repo
- Convert CRLF to LF on commit (regardless of OS)
- Check out as LF on all platforms

Also marks common binary formats explicitly.

### Impact
Existing files with CRLF will be normalized on their next commit. No
functional changes.

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 18:39:17 -07:00
Kpa-clawbot 928a3d995a feat(frontend): implement #286 P1 timestamp controls (#296)
## Summary
Implements issue #286 P1 frontend timestamp features on top of P0:

- Added global timestamp timezone toggle in Display tab (`Local time` /
`UTC`)
- Added absolute-mode timestamp format presets (`iso`, `iso-seconds`,
`locale`)
- Added optional custom format input (only when
`SITE_CONFIG.timestamps.allowCustomFormat === true`)
- Extended `formatTimestamp()` / `formatTimestampWithTooltip()` behavior
to honor timezone + format settings
- Preserved server defaults with localStorage override precedence
- Bumped `public/index.html` cache busters in same commit

## Details
### 1) Timezone toggle
- New Display tab control persisted to `meshcore-timestamp-timezone`
- Reads server default from `window.SITE_CONFIG.timestamps.timezone`
with fallback to `local`
- Formatting logic now supports both local and UTC absolute rendering

### 2) Format presets (absolute mode only)
- New Display tab preset dropdown (shown only when timestamp mode =
`absolute`)
- Presets implemented:
  - `iso` → `YYYY-MM-DD HH:mm:ss`
  - `iso-seconds` → `YYYY-MM-DD HH:mm:ss.SSS`
  - `locale` → `toLocaleString()` (or UTC locale when timezone=utc)
- Persisted to `meshcore-timestamp-format`
- Reads server default from `window.SITE_CONFIG.timestamps.formatPreset`
(fallback `iso`)

### 3) Custom format string (guarded)
- Text input only renders when
`window.SITE_CONFIG.timestamps.allowCustomFormat` is `true`
- Persisted to `meshcore-timestamp-custom-format`
- If non-empty and enabled, custom format overrides preset
- Frontend intentionally does not hard-validate the format string;
unsupported patterns fall back to preset behavior

## Tests
Executed required test commands:

```bash
node test-frontend-helpers.js
node test-packet-filter.js
node test-aging.js
```

Added coverage in `test-frontend-helpers.js` for:
- UTC output behavior
- Local output behavior
- `iso-seconds` includes milliseconds
- `locale` format behavior

All passed locally.

Refs #286

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
2026-03-30 18:32:05 -07:00
Kpa-clawbot b654ac6c9f Fix #284: preserve home/theme data on partial FAQ save (#287)
## Fix: FAQ save no longer wipes other home page sections

Fixes #284

### Problem
Editing FAQ in the customizer and saving caused other home page sections
(steps, footer links, hero text) to disappear on reload. Colors could
also reset.

### Root cause
`initState()` in `customize.js` used `||` (OR) logic for the `home`
object — if localStorage had *any* `home.checklist`, it took that and
ignored the server config for other fields. Partial localStorage data
replaced the full server config instead of merging on top.

### Fix
Changed `initState()` to properly layer: `DEFAULTS → server config →
localStorage` for all sections. Each field merges independently — a
partial localStorage save (e.g., only checklist) no longer wipes steps,
footerLinks, or hero fields. Same merge pattern applied to all theme
sections for consistency.

### Files changed
- `public/customize.js` — `initState()` merge logic
- `public/index.html` — cache buster bump
- `test-frontend-helpers.js` — regression tests:
  1. Partial localStorage (checklist only) preserves steps/footerLinks
  2. Server config values survive partial local overrides
  3. Full localStorage properly overrides server config

### Testing
- `node test-frontend-helpers.js` 
- `node test-packet-filter.js` 
- `node test-aging.js` 

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 18:14:59 -07:00
Kpa-clawbot 4ef69f5092 Move UI settings to Display tab in customizer (#294)
## Summary
- Add a new **Display** tab to the customizer tab bar (between Home Page
and Export / Save).
- Move timestamp-related **UI Settings** out of Branding into the new
Display tab.
- Keep Branding focused on site identity fields (name, tagline, logo,
favicon).
- Bump `public/index.html` cache busters so updated frontend assets load
immediately.

## Testing
- `node test-frontend-helpers.js`
- `node test-packet-filter.js`
- `node test-aging.js`

Fixes #293

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 18:10:10 -07:00
Kpa-clawbot 1e1fb298c2 Backend: timestamp config for client defaults (#292)
## Backend: Timestamp Config for Client Defaults

Refs #286 — implements backend scope from the [final
spec](https://github.com/Kpa-clawbot/CoreScope/issues/286#issuecomment-4158891089).

### What changed

**Config struct (`cmd/server/config.go`)**
- Added `TimestampConfig` struct with `defaultMode`, `timezone`,
`formatPreset`, `customFormat`, `allowCustomFormat`
- Added `Timestamps *TimestampConfig` to main `Config` struct
- Normalization method: invalid values fall back to safe defaults
(`ago`/`local`/`iso`)

**Startup warnings (`cmd/server/main.go`)**
- Missing timestamps section: `[config] timestamps not configured —
using defaults (ago/local/iso)`
- Invalid values logged with what was normalized

**API endpoint (`cmd/server/routes.go`)**
- Timestamp config included in `GET /api/config/client` response via
`ClientConfigResponse`
- Frontend reads server defaults from this endpoint

**Config example (`config.example.json`)**
- Added `timestamps` section with documented defaults

### Tests (`cmd/server/`)
- Config loads with timestamps section
- Config loads without timestamps section (defaults applied)
- Invalid values are normalized
- `/api/config/client` returns timestamp config

### Validation
- `cd cmd/server && go test ./...` 

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:41:45 -07:00
Kpa-clawbot 4c371e3231 docs: add rule 11 — PR descriptions must be clean markdown
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:15:01 -07:00
Kpa-clawbot 6a3b8967b4 Frontend: timestamp display enhancement (issue #286) (#291)
## Frontend: Timestamp Display Enhancement

Refs #286 — implements P0 frontend scope from the [final
spec](https://github.com/Kpa-clawbot/CoreScope/issues/286#issuecomment-4158891089).

### What changed

**Shared formatter (`public/app.js`)**
- `formatTimestamp(isoString, mode)` — returns formatted string ("ago"
or absolute)
- `formatTimestampWithTooltip(isoString, mode)` — returns `{ text,
tooltip, isFuture }` for dual-format hover
- `timeAgo()` fixed: null → `"—"`, future timestamps shown with actual
value (not clamped)

**All surfaces updated**
- `public/packets.js` — table rows + detail pane use shared formatter,
hover shows opposite format
- `public/live.js` — fixed inconsistency (`toLocaleTimeString` → shared
formatter), same tooltip treatment
- `public/nodes.js` — node timestamps use shared formatter

**Future clock skew**
- ⚠️ icon shown when timestamp is in the future, tooltip: "Timestamp is
in the future — node clock may be skewed"

**Customizer (`public/customize.js`)**
- New "UI Settings" section with timestamp mode toggle (ago ↔ absolute)
- Labeled as global setting
- Persists to localStorage (`meshcore-timestamp-mode`), falls back to
server default

**CSS (`public/style.css`)**
- `col-time`: min-width + nowrap for ISO timestamps
- Mobile: shorter format (time only) instead of hiding column

### Testing
- `node test-frontend-helpers.js` — formatter unit tests (null, ago,
absolute, future skew)
- `node test-packet-filter.js` — existing tests pass
- `node test-aging.js` — existing tests pass

Cache busters bumped in `public/index.html`.

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:14:37 -07:00
efiten 568e3904ba fix: use dominant (most common) hash size instead of last-seen (#285)
## Problem

Repeaters with 2-byte adverts occasionally appear as 1-byte on the map
and in stats.

**Root cause:** `computeNodeHashSizeInfo()` sets `HashSize` by
overwriting on every packet (`ni.HashSize = hs`), so the last advert
processed wins — regardless of how many previous packets correctly
showed 2-byte.

When a node sends an ADVERT directly (no relay hops), the path byte
encodes `hashCount=0`. Some firmware sets the full path byte to `0x00`
in this case, which decodes as `hashSize=1` even if the node normally
uses 2-byte hashes. If this packet happens to be the last one iterated,
the node shows as 1-byte.

## Fix

Compute the **mode** (most frequent hash size) across all observed
adverts instead of using the last-seen value. On a tie, prefer the
larger value.

```go
counts := make(map[int]int, len(ni.AllSizes))
for _, hs := range ni.Seq {
    counts[hs]++
}
best, bestCount := 1, 0
for hs, cnt := range counts {
    if cnt > bestCount || (cnt == bestCount && hs > best) {
        best = hs
        bestCount = cnt
    }
}
ni.HashSize = best
```

A node with 4× hashSize=2 and 1× hashSize=1 now correctly reports
`HashSize=2`.

## Test

`TestGetNodeHashSizeInfoDominant`: seeds 5 adverts (4× 2-byte, 1×
1-byte) and asserts `HashSize=2`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:26:10 -07:00
efiten 999436d714 feat: geo_filter polygon overlay on map and live pages (Go backend) (#213)
## Summary

- Adds `GeoFilter` struct to `Config` in `cmd/server/config.go` so
`geo_filter.polygon` and `bufferKm` from `config.json` are parsed by the
Go backend
- Adds `GET /api/config/geo-filter` endpoint in `cmd/server/routes.go`
returning the polygon + bufferKm to the frontend
- Restores the blue polygon overlay (solid inner + dashed buffer zone)
on the **Map** page (`public/map.js`)
- Restores the same overlay on the **Live** page (`public/live.js`),
toggled via the "Mesh live area" checkbox

## Test plan

- [x] `GET /api/config/geo-filter` returns `{ polygon: [...], bufferKm:
N }` when configured
- [x] `GET /api/config/geo-filter` returns `{ polygon: null, bufferKm: 0
}` when not configured
- [x] Map page shows blue polygon overlay when `geo_filter.polygon` is
set in config
- [x] Live page shows same overlay, checkbox state shared via
localStorage
- [x] Checkbox is hidden when no polygon is configured

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 15:28:28 -07:00
Kpa-clawbot 16a99159cc fix: config.json lives in data dir, not bind-mounted as file (#282)
Removes the separate config.json file bind mount from both compose
files. The data directory mount already covers it, and the Go server
searches /app/data/config.json via LoadConfig.

- Entrypoint symlinks /app/data/config.json for ingestor compatibility
- manage.sh setup creates config in data dir, prompts admin if missing
- manage.sh start checks config exists before starting, offers to create
- deploy.yml simplified — no more sudo rm or directory cleanup
- Backup/restore updated to use data dir path

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 18:55:44 +00:00
Kpa-clawbot 93f85dee6e Add API key auth to Go write endpoints (#283)
## Summary
- added API key middleware for write routes in cmd/server/routes.go
- protected all current non-GET API routes (POST /api/packets, POST
/api/perf/reset, POST /api/decode)
- middleware enforces X-API-Key against cfg.APIKey and returns 401 JSON
error on missing/wrong key
- preserves backward compatibility: if piKey is empty, requests pass
through
- added startup warning log in cmd/server/main.go when no API key is
configured:
- [security] WARNING: no apiKey configured — write endpoints are
unprotected
- added route tests for missing/wrong/correct key and empty-apiKey
compatibility

## Validation
- cd cmd/server && go test ./... 

## Notes
- config.example.json already contains piKey, so no changes were
required.

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 11:53:35 -07:00
Kpa-clawbot 61ff72fc80 Revert "fix: config.json lives in data dir, not bind-mounted as file"
This reverts commit 57ebd76070.
2026-03-30 10:00:17 -07:00
Kpa-clawbot 57ebd76070 fix: config.json lives in data dir, not bind-mounted as file
Removes the separate config.json file bind mount from both compose
files. The data directory mount already covers it, and the Go server
searches /app/data/config.json via LoadConfig.

- Entrypoint symlinks /app/data/config.json for ingestor compatibility
- manage.sh setup creates config in data dir, prompts admin if missing
- manage.sh start checks config exists before starting, offers to create
- deploy.yml simplified — no more sudo rm or directory cleanup
- Backup/restore updated to use data dir path

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 09:58:22 -07:00
Kyle Gabriel 86b5d4e175 Fix incorrect internal port binding (#270)
Fixes issue #268
2026-03-30 16:48:50 +00:00
VE7KOD faca80e626 feat: add multi-byte hash usage matrix with stats and improved tooltips (#269)
- Add 1/2/3-byte selector to Hash Issues analytics page
- 1-byte and 2-byte modes show 16×16 matrix with stat cards (nodes
tracked, using N-byte ID, prefix space used, prefix collisions)
- 3-byte mode shows summary stat cards instead of unrenderable grid
- Fix "Nodes tracked" to always show total node count across all modes
- Use CSS variable colours for matrix cells (light/dark mode compatible)
- Replace native title tooltips with custom styled popovers
- Hide collision risk card when 3-byte mode is selected
- Fix double-tooltip bug on mode switch via _matrixTipInit guard
- Fix tooltip persisting outside matrix grid on mouseleave

https://dev.ve7kod.ca/#/analytics

Hash Issues

---------

Co-authored-by: Jesse <your@email.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:31:35 +00:00
41 changed files with 8546 additions and 6762 deletions
+46 -44
View File
@@ -1,44 +1,46 @@
# MeshCore Analyzer — Environment Configuration
# Copy to .env and customize. All values have sensible defaults.
#
# This file is read by BOTH docker compose AND manage.sh — one source of truth.
# Each environment keeps config + data together in one directory:
# ~/meshcore-data/config.json, meshcore.db, Caddyfile, theme.json
# ~/meshcore-staging-data/config.json, meshcore.db, Caddyfile
# --- Production ---
# Data directory (database, theme, etc.)
# Default: ~/meshcore-data
# Used by: docker compose, manage.sh
PROD_DATA_DIR=~/meshcore-data
# HTTP port for web UI
# Default: 80
# Used by: docker compose
PROD_HTTP_PORT=80
# HTTPS port for web UI (TLS via Caddy)
# Default: 443
# Used by: docker compose
PROD_HTTPS_PORT=443
# MQTT port for observer connections
# Default: 1883
# Used by: docker compose
PROD_MQTT_PORT=1883
# --- Staging (HTTP only, no HTTPS) ---
# Data directory
# Default: ~/meshcore-staging-data
# Used by: docker compose
STAGING_DATA_DIR=~/meshcore-staging-data
# HTTP port
# Default: 81
# Used by: docker compose
STAGING_HTTP_PORT=81
# MQTT port
# Default: 1884
# Used by: docker compose
STAGING_MQTT_PORT=1884
# MeshCore Analyzer — Environment Configuration
# Copy to .env and customize. All values have sensible defaults.
#
# This file is read by BOTH docker compose AND manage.sh — one source of truth.
# manage.sh setup negotiates and updates only these production managed keys:
# PROD_DATA_DIR, PROD_HTTP_PORT, PROD_HTTPS_PORT, PROD_MQTT_PORT
# Each environment keeps config + data together in one directory:
# ~/meshcore-data/config.json, meshcore.db, Caddyfile, theme.json
# ~/meshcore-staging-data/config.json, meshcore.db, Caddyfile
# --- Production ---
# Data directory (database, theme, etc.)
# Default: ~/meshcore-data
# Used by: docker compose, manage.sh
PROD_DATA_DIR=~/meshcore-data
# HTTP port for web UI
# Default: 80
# Used by: docker compose
PROD_HTTP_PORT=80
# HTTPS port for web UI (TLS via Caddy)
# Default: 443
# Used by: docker compose
PROD_HTTPS_PORT=443
# MQTT port for observer connections
# Default: 1883
# Used by: docker compose
PROD_MQTT_PORT=1883
# --- Staging (HTTP only, no HTTPS) ---
# Data directory
# Default: ~/meshcore-staging-data
# Used by: docker compose
STAGING_DATA_DIR=~/meshcore-staging-data
# HTTP port
# Default: 81
# Used by: docker compose
STAGING_HTTP_PORT=81
# MQTT port
# Default: 1884
# Used by: docker compose
STAGING_MQTT_PORT=1884
+9
View File
@@ -1,3 +1,12 @@
# Force LF line endings for all text files (prevents CRLF churn from Windows agents)
* text=auto eol=lf
# Explicitly mark binary files
*.png binary
*.jpg binary
*.ico binary
*.db binary
# Squad: union merge for append-only team state files
.squad/decisions.md merge=union
.squad/agents/*/history.md merge=union
+7 -7
View File
@@ -5,6 +5,7 @@ on:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
@@ -264,8 +265,7 @@ jobs:
- name: Deploy staging
run: |
# Use docker compose down (not just stop/rm) to properly clean up
# the old container, network, and release memory before starting new one
# Stop old container and release memory
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging down --timeout 30 2>/dev/null || true
# Wait for container to be fully gone and OS to reclaim memory (3GB limit)
@@ -277,14 +277,14 @@ jobs:
done
sleep 5 # extra pause for OS memory reclaim
# Ensure staging config exists (docker creates a directory if bind mount source missing)
# Ensure staging data dir exists (config.json lives here, no separate file mount)
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
mkdir -p "$STAGING_DATA"
# Remove directory-masquerading-as-file left by failed docker mount
[ -d "$STAGING_DATA/config.json" ] && rm -rf "$STAGING_DATA/config.json"
# If no config exists, copy the example (CI doesn't have a real prod config)
if [ ! -f "$STAGING_DATA/config.json" ]; then
echo "Staging config missing — copying from repo config.json"
cp config.json "$STAGING_DATA/config.json" 2>/dev/null || cp config.example.json "$STAGING_DATA/config.json"
echo "Staging config missing — copying config.example.json"
cp config.example.json "$STAGING_DATA/config.json" 2>/dev/null || true
fi
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging up -d staging-go
+6
View File
@@ -91,6 +91,12 @@ Never use `git add -A` or `git add .`. Always list files explicitly: `git add fi
### 10. Don't regress performance
The packets page loads 30K+ packets. Don't add per-packet API calls. Don't add O(n²) loops. Client-side filtering is preferred over server-side. If you need data from the server, fetch it once and cache it.
### 11. PR descriptions must be clean markdown
When opening a pull request, the description must be **valid, readable markdown**. Use real newlines (not `\n` literals), proper code fences, and correct heading syntax. Write it using `--body-file -` (piped from a heredoc or file), never inline `--body` with escaped characters. If the description renders as garbage, fix it before requesting review. This is the first thing reviewers see.
### 12. Post a follow-up comment when review feedback is addressed
When you push fixes for review comments, post a comment on the PR listing what was changed and the commit hash. Reviewers should not have to dig through commits to find what was fixed. Format: "Review feedback addressed (commit `abc1234`)" followed by a numbered list of what was done.
## MeshCore Firmware — Source of Truth
The MeshCore firmware source is cloned at `firmware/` (gitignored — not part of this repo). This is THE authoritative reference for anything related to the protocol, packet format, device behavior, advert structure, flags, hash sizes, route types, or how repeaters/companions/rooms/sensors behave.
+4
View File
@@ -9,6 +9,8 @@ ARG BUILD_TIME=unknown
# Build server
WORKDIR /build/server
COPY cmd/server/go.mod cmd/server/go.sum ./
COPY go.mod /build/go.mod
COPY internal/decoder/ /build/internal/decoder/
RUN go mod download
COPY cmd/server/ ./
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
@@ -16,6 +18,8 @@ RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMI
# Build ingestor
WORKDIR /build/ingestor
COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./
COPY go.mod /build/go.mod
COPY internal/decoder/ /build/internal/decoder/
RUN go mod download
COPY cmd/ingestor/ ./
RUN go build -o /corescope-ingestor .
+8 -2
View File
@@ -7,14 +7,20 @@ ARG GIT_COMMIT=unknown
ARG BUILD_TIME=unknown
# Build server
WORKDIR /build/server
WORKDIR /build
COPY go.mod ./go.mod
COPY internal/decoder/ ./internal/decoder/
WORKDIR /build/cmd/server
COPY cmd/server/go.mod cmd/server/go.sum ./
RUN go mod download
COPY cmd/server/ ./
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
# Build ingestor
WORKDIR /build/ingestor
WORKDIR /build
COPY go.mod ./go.mod
COPY internal/decoder/ ./internal/decoder/
WORKDIR /build/cmd/ingestor
COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./
RUN go mod download
COPY cmd/ingestor/ ./
+35 -14
View File
@@ -15,12 +15,12 @@ import (
// DBStats tracks operational metrics for the ingestor database.
type DBStats struct {
TransmissionsInserted atomic.Int64
ObservationsInserted atomic.Int64
TransmissionsInserted atomic.Int64
ObservationsInserted atomic.Int64
DuplicateTransmissions atomic.Int64
NodeUpserts atomic.Int64
ObserverUpserts atomic.Int64
WriteErrors atomic.Int64
NodeUpserts atomic.Int64
ObserverUpserts atomic.Int64
WriteErrors atomic.Int64
}
// Store wraps the SQLite database for packet ingestion.
@@ -35,8 +35,8 @@ type Store struct {
stmtUpsertNode *sql.Stmt
stmtIncrementAdvertCount *sql.Stmt
stmtUpsertObserver *sql.Stmt
stmtGetObserverRowid *sql.Stmt
stmtUpdateNodeTelemetry *sql.Stmt
stmtGetObserverRowid *sql.Stmt
stmtUpdateNodeTelemetry *sql.Stmt
}
// OpenStore opens or creates a SQLite DB at the given path, applying the
@@ -333,13 +333,17 @@ func (s *Store) prepareStatements() error {
}
s.stmtUpsertObserver, err = s.db.Prepare(`
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, battery_mv, uptime_secs, noise_floor)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = COALESCE(?, name),
iata = COALESCE(?, iata),
last_seen = ?,
packet_count = packet_count + 1,
model = COALESCE(?, model),
firmware = COALESCE(?, firmware),
client_version = COALESCE(?, client_version),
radio = COALESCE(?, radio),
battery_mv = COALESCE(?, battery_mv),
uptime_secs = COALESCE(?, uptime_secs),
noise_floor = COALESCE(?, noise_floor)
@@ -485,17 +489,34 @@ func (s *Store) UpdateNodeTelemetry(pubKey string, batteryMv *int, temperatureC
// ObserverMeta holds optional observer hardware metadata.
type ObserverMeta struct {
BatteryMv *int // millivolts, always integer
UptimeSecs *int64 // seconds, always integer
NoiseFloor *float64 // dBm, may have decimals
Model *string // e.g., L1
Firmware *string // firmware version string
ClientVersion *string // client app version string
Radio *string // radio chipset/platform string
BatteryMv *int // millivolts, always integer
UptimeSecs *int64 // seconds, always integer
NoiseFloor *float64 // dBm, may have decimals
}
// UpsertObserver inserts or updates an observer with optional hardware metadata.
func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error {
now := time.Now().UTC().Format(time.RFC3339)
var model, firmware, clientVersion, radio interface{}
var batteryMv, uptimeSecs, noiseFloor interface{}
if meta != nil {
if meta.Model != nil {
model = *meta.Model
}
if meta.Firmware != nil {
firmware = *meta.Firmware
}
if meta.ClientVersion != nil {
clientVersion = *meta.ClientVersion
}
if meta.Radio != nil {
radio = *meta.Radio
}
if meta.BatteryMv != nil {
batteryMv = *meta.BatteryMv
}
@@ -508,8 +529,8 @@ func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error
}
_, err := s.stmtUpsertObserver.Exec(
id, name, iata, now, now, batteryMv, uptimeSecs, noiseFloor,
name, iata, now, batteryMv, uptimeSecs, noiseFloor,
id, name, iata, now, now, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
name, iata, now, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
)
if err != nil {
s.Stats.WriteErrors.Add(1)
+1315 -1225
View File
File diff suppressed because it is too large Load Diff
+55 -739
View File
@@ -1,739 +1,55 @@
package main
import (
"crypto/aes"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"strings"
"unicode/utf8"
)
// Route type constants (header bits 1-0)
const (
RouteTransportFlood = 0
RouteFlood = 1
RouteDirect = 2
RouteTransportDirect = 3
)
// Payload type constants (header bits 5-2)
const (
PayloadREQ = 0x00
PayloadRESPONSE = 0x01
PayloadTXT_MSG = 0x02
PayloadACK = 0x03
PayloadADVERT = 0x04
PayloadGRP_TXT = 0x05
PayloadGRP_DATA = 0x06
PayloadANON_REQ = 0x07
PayloadPATH = 0x08
PayloadTRACE = 0x09
PayloadMULTIPART = 0x0A
PayloadCONTROL = 0x0B
PayloadRAW_CUSTOM = 0x0F
)
var routeTypeNames = map[int]string{
0: "TRANSPORT_FLOOD",
1: "FLOOD",
2: "DIRECT",
3: "TRANSPORT_DIRECT",
}
var payloadTypeNames = map[int]string{
0x00: "REQ",
0x01: "RESPONSE",
0x02: "TXT_MSG",
0x03: "ACK",
0x04: "ADVERT",
0x05: "GRP_TXT",
0x06: "GRP_DATA",
0x07: "ANON_REQ",
0x08: "PATH",
0x09: "TRACE",
0x0A: "MULTIPART",
0x0B: "CONTROL",
0x0F: "RAW_CUSTOM",
}
// Header is the decoded packet header.
type Header struct {
RouteType int `json:"routeType"`
RouteTypeName string `json:"routeTypeName"`
PayloadType int `json:"payloadType"`
PayloadTypeName string `json:"payloadTypeName"`
PayloadVersion int `json:"payloadVersion"`
}
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
type TransportCodes struct {
Code1 string `json:"code1"`
Code2 string `json:"code2"`
}
// Path holds decoded path/hop information.
type Path struct {
HashSize int `json:"hashSize"`
HashCount int `json:"hashCount"`
Hops []string `json:"hops"`
}
// AdvertFlags holds decoded advert flag bits.
type AdvertFlags struct {
Raw int `json:"raw"`
Type int `json:"type"`
Chat bool `json:"chat"`
Repeater bool `json:"repeater"`
Room bool `json:"room"`
Sensor bool `json:"sensor"`
HasLocation bool `json:"hasLocation"`
HasFeat1 bool `json:"hasFeat1"`
HasFeat2 bool `json:"hasFeat2"`
HasName bool `json:"hasName"`
}
// Payload is a generic decoded payload. Fields are populated depending on type.
type Payload struct {
Type string `json:"type"`
DestHash string `json:"destHash,omitempty"`
SrcHash string `json:"srcHash,omitempty"`
MAC string `json:"mac,omitempty"`
EncryptedData string `json:"encryptedData,omitempty"`
ExtraHash string `json:"extraHash,omitempty"`
PubKey string `json:"pubKey,omitempty"`
Timestamp uint32 `json:"timestamp,omitempty"`
TimestampISO string `json:"timestampISO,omitempty"`
Signature string `json:"signature,omitempty"`
Flags *AdvertFlags `json:"flags,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Name string `json:"name,omitempty"`
Feat1 *int `json:"feat1,omitempty"`
Feat2 *int `json:"feat2,omitempty"`
BatteryMv *int `json:"battery_mv,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
ChannelHash int `json:"channelHash,omitempty"`
ChannelHashHex string `json:"channelHashHex,omitempty"`
DecryptionStatus string `json:"decryptionStatus,omitempty"`
Channel string `json:"channel,omitempty"`
Text string `json:"text,omitempty"`
Sender string `json:"sender,omitempty"`
SenderTimestamp uint32 `json:"sender_timestamp,omitempty"`
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
PathData string `json:"pathData,omitempty"`
Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
TraceFlags *int `json:"traceFlags,omitempty"`
RawHex string `json:"raw,omitempty"`
Error string `json:"error,omitempty"`
}
// DecodedPacket is the full decoded result.
type DecodedPacket struct {
Header Header `json:"header"`
TransportCodes *TransportCodes `json:"transportCodes"`
Path Path `json:"path"`
Payload Payload `json:"payload"`
Raw string `json:"raw"`
}
func decodeHeader(b byte) Header {
rt := int(b & 0x03)
pt := int((b >> 2) & 0x0F)
pv := int((b >> 6) & 0x03)
rtName := routeTypeNames[rt]
if rtName == "" {
rtName = "UNKNOWN"
}
ptName := payloadTypeNames[pt]
if ptName == "" {
ptName = "UNKNOWN"
}
return Header{
RouteType: rt,
RouteTypeName: rtName,
PayloadType: pt,
PayloadTypeName: ptName,
PayloadVersion: pv,
}
}
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
hashSize := int(pathByte>>6) + 1
hashCount := int(pathByte & 0x3F)
totalBytes := hashSize * hashCount
hops := make([]string, 0, hashCount)
for i := 0; i < hashCount; i++ {
start := offset + i*hashSize
end := start + hashSize
if end > len(buf) {
break
}
hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end])))
}
return Path{
HashSize: hashSize,
HashCount: hashCount,
Hops: hops,
}, totalBytes
}
func isTransportRoute(routeType int) bool {
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
}
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: typeName,
DestHash: hex.EncodeToString(buf[0:1]),
SrcHash: hex.EncodeToString(buf[1:2]),
MAC: hex.EncodeToString(buf[2:4]),
EncryptedData: hex.EncodeToString(buf[4:]),
}
}
func decodeAck(buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
checksum := binary.LittleEndian.Uint32(buf[0:4])
return Payload{
Type: "ACK",
ExtraHash: fmt.Sprintf("%08x", checksum),
}
}
func decodeAdvert(buf []byte) Payload {
if len(buf) < 100 {
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
}
pubKey := hex.EncodeToString(buf[0:32])
timestamp := binary.LittleEndian.Uint32(buf[32:36])
signature := hex.EncodeToString(buf[36:100])
appdata := buf[100:]
p := Payload{
Type: "ADVERT",
PubKey: pubKey,
Timestamp: timestamp,
TimestampISO: fmt.Sprintf("%s", epochToISO(timestamp)),
Signature: signature,
}
if len(appdata) > 0 {
flags := appdata[0]
advType := int(flags & 0x0F)
hasFeat1 := flags&0x20 != 0
hasFeat2 := flags&0x40 != 0
p.Flags = &AdvertFlags{
Raw: int(flags),
Type: advType,
Chat: advType == 1,
Repeater: advType == 2,
Room: advType == 3,
Sensor: advType == 4,
HasLocation: flags&0x10 != 0,
HasFeat1: hasFeat1,
HasFeat2: hasFeat2,
HasName: flags&0x80 != 0,
}
off := 1
if p.Flags.HasLocation && len(appdata) >= off+8 {
latRaw := int32(binary.LittleEndian.Uint32(appdata[off : off+4]))
lonRaw := int32(binary.LittleEndian.Uint32(appdata[off+4 : off+8]))
lat := float64(latRaw) / 1e6
lon := float64(lonRaw) / 1e6
p.Lat = &lat
p.Lon = &lon
off += 8
}
if hasFeat1 && len(appdata) >= off+2 {
feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
p.Feat1 = &feat1
off += 2
}
if hasFeat2 && len(appdata) >= off+2 {
feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
p.Feat2 = &feat2
off += 2
}
if p.Flags.HasName {
// Find null terminator to separate name from trailing telemetry bytes
nameEnd := len(appdata)
for i := off; i < len(appdata); i++ {
if appdata[i] == 0x00 {
nameEnd = i
break
}
}
name := string(appdata[off:nameEnd])
name = sanitizeName(name)
p.Name = name
off = nameEnd
// Skip null terminator(s)
for off < len(appdata) && appdata[off] == 0x00 {
off++
}
}
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
// Only sensor nodes (advType=4) carry telemetry bytes.
if p.Flags.Sensor && off+4 <= len(appdata) {
batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4]))
tempC := float64(tempRaw) / 100.0
if batteryMv > 0 && batteryMv <= 10000 {
p.BatteryMv = &batteryMv
}
// Raw int16 / 100 → °C; accept -50°C to 100°C (raw: -5000 to 10000)
if tempRaw >= -5000 && tempRaw <= 10000 {
p.TemperatureC = &tempC
}
}
}
return p
}
// channelDecryptResult holds the decrypted channel message fields.
type channelDecryptResult struct {
Timestamp uint32
Flags byte
Sender string
Message string
}
// countNonPrintable counts characters that are non-printable (< 0x20 except \n, \t).
func countNonPrintable(s string) int {
count := 0
for _, r := range s {
if r < 0x20 && r != '\n' && r != '\t' {
count++
} else if r == utf8.RuneError {
count++
}
}
return count
}
// decryptChannelMessage implements MeshCore channel decryption:
// HMAC-SHA256 MAC verification followed by AES-128-ECB decryption.
func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channelDecryptResult, error) {
channelKey, err := hex.DecodeString(channelKeyHex)
if err != nil || len(channelKey) != 16 {
return nil, fmt.Errorf("invalid channel key")
}
macBytes, err := hex.DecodeString(macHex)
if err != nil || len(macBytes) != 2 {
return nil, fmt.Errorf("invalid MAC")
}
ciphertext, err := hex.DecodeString(ciphertextHex)
if err != nil || len(ciphertext) == 0 {
return nil, fmt.Errorf("invalid ciphertext")
}
// 32-byte channel secret: 16-byte key + 16 zero bytes
channelSecret := make([]byte, 32)
copy(channelSecret, channelKey)
// Verify HMAC-SHA256 (first 2 bytes must match provided MAC)
h := hmac.New(sha256.New, channelSecret)
h.Write(ciphertext)
calculatedMac := h.Sum(nil)
if calculatedMac[0] != macBytes[0] || calculatedMac[1] != macBytes[1] {
return nil, fmt.Errorf("MAC verification failed")
}
// AES-128-ECB decrypt (block-by-block, no padding)
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
}
block, err := aes.NewCipher(channelKey)
if err != nil {
return nil, fmt.Errorf("AES cipher: %w", err)
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
// Parse: timestamp(4 LE) + flags(1) + message(UTF-8, null-terminated)
if len(plaintext) < 5 {
return nil, fmt.Errorf("decrypted content too short")
}
timestamp := binary.LittleEndian.Uint32(plaintext[0:4])
flags := plaintext[4]
messageText := string(plaintext[5:])
if idx := strings.IndexByte(messageText, 0); idx >= 0 {
messageText = messageText[:idx]
}
// Validate decrypted text is printable UTF-8 (not binary garbage)
if !utf8.ValidString(messageText) || countNonPrintable(messageText) > 2 {
return nil, fmt.Errorf("decrypted text contains non-printable characters")
}
result := &channelDecryptResult{Timestamp: timestamp, Flags: flags}
// Parse "sender: message" format
colonIdx := strings.Index(messageText, ": ")
if colonIdx > 0 && colonIdx < 50 {
potentialSender := messageText[:colonIdx]
if !strings.ContainsAny(potentialSender, ":[]") {
result.Sender = potentialSender
result.Message = messageText[colonIdx+2:]
} else {
result.Message = messageText
}
} else {
result.Message = messageText
}
return result, nil
}
func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
if len(buf) < 3 {
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
channelHash := int(buf[0])
channelHashHex := fmt.Sprintf("%02X", buf[0])
mac := hex.EncodeToString(buf[1:3])
encryptedData := hex.EncodeToString(buf[3:])
hasKeys := len(channelKeys) > 0
// Match Node.js: only attempt decryption if encrypted data >= 5 bytes (10 hex chars)
if hasKeys && len(encryptedData) >= 10 {
for name, key := range channelKeys {
result, err := decryptChannelMessage(encryptedData, mac, key)
if err != nil {
continue
}
text := result.Message
if result.Sender != "" && result.Message != "" {
text = result.Sender + ": " + result.Message
}
return Payload{
Type: "CHAN",
Channel: name,
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "decrypted",
Sender: result.Sender,
Text: text,
SenderTimestamp: result.Timestamp,
}
}
return Payload{
Type: "GRP_TXT",
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "decryption_failed",
MAC: mac,
EncryptedData: encryptedData,
}
}
return Payload{
Type: "GRP_TXT",
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "no_key",
MAC: mac,
EncryptedData: encryptedData,
}
}
func decodeAnonReq(buf []byte) Payload {
if len(buf) < 35 {
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "ANON_REQ",
DestHash: hex.EncodeToString(buf[0:1]),
EphemeralPubKey: hex.EncodeToString(buf[1:33]),
MAC: hex.EncodeToString(buf[33:35]),
EncryptedData: hex.EncodeToString(buf[35:]),
}
}
func decodePathPayload(buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: "PATH", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "PATH",
DestHash: hex.EncodeToString(buf[0:1]),
SrcHash: hex.EncodeToString(buf[1:2]),
MAC: hex.EncodeToString(buf[2:4]),
PathData: hex.EncodeToString(buf[4:]),
}
}
func decodeTrace(buf []byte) Payload {
if len(buf) < 9 {
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
tag := binary.LittleEndian.Uint32(buf[0:4])
authCode := binary.LittleEndian.Uint32(buf[4:8])
flags := int(buf[8])
p := Payload{
Type: "TRACE",
Tag: tag,
AuthCode: authCode,
TraceFlags: &flags,
}
if len(buf) > 9 {
p.PathData = hex.EncodeToString(buf[9:])
}
return p
}
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
switch payloadType {
case PayloadREQ:
return decodeEncryptedPayload("REQ", buf)
case PayloadRESPONSE:
return decodeEncryptedPayload("RESPONSE", buf)
case PayloadTXT_MSG:
return decodeEncryptedPayload("TXT_MSG", buf)
case PayloadACK:
return decodeAck(buf)
case PayloadADVERT:
return decodeAdvert(buf)
case PayloadGRP_TXT:
return decodeGrpTxt(buf, channelKeys)
case PayloadANON_REQ:
return decodeAnonReq(buf)
case PayloadPATH:
return decodePathPayload(buf)
case PayloadTRACE:
return decodeTrace(buf)
default:
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
}
}
// DecodePacket decodes a hex-encoded MeshCore packet.
func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) {
hexString = strings.ReplaceAll(hexString, " ", "")
hexString = strings.ReplaceAll(hexString, "\n", "")
hexString = strings.ReplaceAll(hexString, "\r", "")
buf, err := hex.DecodeString(hexString)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(buf) < 2 {
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
}
header := decodeHeader(buf[0])
offset := 1
var tc *TransportCodes
if isTransportRoute(header.RouteType) {
if len(buf) < offset+4 {
return nil, fmt.Errorf("packet too short for transport codes")
}
tc = &TransportCodes{
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
}
offset += 4
}
if offset >= len(buf) {
return nil, fmt.Errorf("packet too short (no path byte)")
}
pathByte := buf[offset]
offset++
path, bytesConsumed := decodePath(pathByte, buf, offset)
offset += bytesConsumed
payloadBuf := buf[offset:]
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys)
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
// path field. The header path byte still encodes hashSize in bits 6-7, which
// we use to split the payload path data into individual hop prefixes.
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && path.HashSize > 0 {
hops := make([]string, 0, len(pathBytes)/path.HashSize)
for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize])))
}
path.Hops = hops
path.HashCount = len(hops)
}
}
return &DecodedPacket{
Header: header,
TransportCodes: tc,
Path: path,
Payload: payload,
Raw: strings.ToUpper(hexString),
}, nil
}
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
// It hashes the header byte + payload (skipping path bytes) to produce a
// path-independent identifier for the same transmission.
func ComputeContentHash(rawHex string) string {
buf, err := hex.DecodeString(rawHex)
if err != nil || len(buf) < 2 {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
headerByte := buf[0]
offset := 1
if isTransportRoute(int(headerByte & 0x03)) {
offset += 4
}
if offset >= len(buf) {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
pathByte := buf[offset]
offset++
hashSize := int((pathByte>>6)&0x3) + 1
hashCount := int(pathByte & 0x3F)
pathBytes := hashSize * hashCount
payloadStart := offset + pathBytes
if payloadStart > len(buf) {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
payload := buf[payloadStart:]
toHash := append([]byte{headerByte}, payload...)
h := sha256.Sum256(toHash)
return hex.EncodeToString(h[:])[:16]
}
// PayloadJSON serializes the payload to JSON for DB storage.
func PayloadJSON(p *Payload) string {
b, err := json.Marshal(p)
if err != nil {
return "{}"
}
return string(b)
}
// ValidateAdvert checks decoded advert data before DB insertion.
func ValidateAdvert(p *Payload) (bool, string) {
if p == nil || p.Error != "" {
reason := "null advert"
if p != nil {
reason = p.Error
}
return false, reason
}
pk := p.PubKey
if len(pk) < 16 {
return false, fmt.Sprintf("pubkey too short (%d hex chars)", len(pk))
}
allZero := true
for _, c := range pk {
if c != '0' {
allZero = false
break
}
}
if allZero {
return false, "pubkey is all zeros"
}
if p.Lat != nil {
if math.IsInf(*p.Lat, 0) || math.IsNaN(*p.Lat) || *p.Lat < -90 || *p.Lat > 90 {
return false, fmt.Sprintf("invalid lat: %f", *p.Lat)
}
}
if p.Lon != nil {
if math.IsInf(*p.Lon, 0) || math.IsNaN(*p.Lon) || *p.Lon < -180 || *p.Lon > 180 {
return false, fmt.Sprintf("invalid lon: %f", *p.Lon)
}
}
if p.Name != "" {
for _, c := range p.Name {
if (c >= 0x00 && c <= 0x08) || c == 0x0b || c == 0x0c || (c >= 0x0e && c <= 0x1f) || c == 0x7f {
return false, "name contains control characters"
}
}
if len(p.Name) > 64 {
return false, fmt.Sprintf("name too long (%d chars)", len(p.Name))
}
}
if p.Flags != nil {
role := advertRole(p.Flags)
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
if !validRoles[role] {
return false, fmt.Sprintf("unknown role: %s", role)
}
}
return true, ""
}
// sanitizeName strips non-printable characters (< 0x20 except tab/newline) and DEL.
func sanitizeName(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, c := range s {
if c == '\t' || c == '\n' || (c >= 0x20 && c != 0x7f) {
b.WriteRune(c)
}
}
return b.String()
}
func advertRole(f *AdvertFlags) string {
if f.Repeater {
return "repeater"
}
if f.Room {
return "room"
}
if f.Sensor {
return "sensor"
}
return "companion"
}
func epochToISO(epoch uint32) string {
// Go time from Unix epoch
t := unixTime(int64(epoch))
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}
package main
import dec "github.com/corescope/internal/decoder"
const (
RouteTransportFlood = dec.RouteTransportFlood
RouteFlood = dec.RouteFlood
RouteDirect = dec.RouteDirect
RouteTransportDirect = dec.RouteTransportDirect
PayloadREQ = dec.PayloadREQ
PayloadRESPONSE = dec.PayloadRESPONSE
PayloadTXT_MSG = dec.PayloadTXT_MSG
PayloadACK = dec.PayloadACK
PayloadADVERT = dec.PayloadADVERT
PayloadGRP_TXT = dec.PayloadGRP_TXT
PayloadGRP_DATA = dec.PayloadGRP_DATA
PayloadANON_REQ = dec.PayloadANON_REQ
PayloadPATH = dec.PayloadPATH
PayloadTRACE = dec.PayloadTRACE
PayloadMULTIPART = dec.PayloadMULTIPART
PayloadCONTROL = dec.PayloadCONTROL
PayloadRAW_CUSTOM = dec.PayloadRAW_CUSTOM
)
type Header = dec.Header
type TransportCodes = dec.TransportCodes
type Path = dec.Path
type AdvertFlags = dec.AdvertFlags
type Payload = dec.Payload
type DecodedPacket = dec.DecodedPacket
func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) {
return dec.DecodePacket(hexString, channelKeys)
}
func ComputeContentHash(rawHex string) string {
return dec.ComputeContentHash(rawHex)
}
func PayloadJSON(p *Payload) string {
return dec.PayloadJSON(p)
}
func ValidateAdvert(p *Payload) (bool, string) {
return dec.ValidateAdvert(p)
}
func advertRole(f *AdvertFlags) string {
return dec.AdvertRole(f)
}
func epochToISO(epoch uint32) string {
return dec.EpochToISO(epoch)
}
+3
View File
@@ -3,6 +3,7 @@ module github.com/corescope/ingestor
go 1.22
require (
github.com/corescope v0.0.0
github.com/eclipse/paho.mqtt.golang v1.5.0
modernc.org/sqlite v1.34.5
)
@@ -21,3 +22,5 @@ require (
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)
replace github.com/corescope => ../..
+25
View File
@@ -476,6 +476,31 @@ func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
meta := &ObserverMeta{}
hasData := false
if v, ok := msg["model"].(string); ok && v != "" {
meta.Model = &v
hasData = true
}
if v, ok := msg["firmware"].(string); ok && v != "" {
meta.Firmware = &v
hasData = true
}
if v, ok := msg["firmware_version"].(string); ok && v != "" {
meta.Firmware = &v
hasData = true
}
if v, ok := msg["client_version"].(string); ok && v != "" {
meta.ClientVersion = &v
hasData = true
}
if v, ok := msg["clientVersion"].(string); ok && v != "" {
meta.ClientVersion = &v
hasData = true
}
if v, ok := msg["radio"].(string); ok && v != "" {
meta.Radio = &v
hasData = true
}
if v, ok := msg["battery_mv"]; ok {
if f, ok := toFloat64(v); ok {
iv := int(math.Round(f))
+36 -3
View File
@@ -177,13 +177,13 @@ func TestHandleMessageStatusTopic(t *testing.T) {
source := MQTTSource{Name: "test"}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/status",
payload: []byte(`{"origin":"MyObserver"}`),
payload: []byte(`{"origin":"MyObserver","model":"L1","firmware_version":"v1.2.3","client_version":"2.4.1","radio":"SX1262"}`),
}
handleMessage(store, "test", source, msg, nil)
var name, iata string
err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata)
var name, iata, model, firmware, clientVersion, radio string
err := store.db.QueryRow("SELECT name, iata, model, firmware, client_version, radio FROM observers WHERE id = 'obs1'").Scan(&name, &iata, &model, &firmware, &clientVersion, &radio)
if err != nil {
t.Fatal(err)
}
@@ -193,6 +193,39 @@ func TestHandleMessageStatusTopic(t *testing.T) {
if iata != "SJC" {
t.Errorf("iata=%s, want SJC", iata)
}
if model != "L1" {
t.Errorf("model=%s, want L1", model)
}
if firmware != "v1.2.3" {
t.Errorf("firmware=%s, want v1.2.3", firmware)
}
if clientVersion != "2.4.1" {
t.Errorf("client_version=%s, want 2.4.1", clientVersion)
}
if radio != "SX1262" {
t.Errorf("radio=%s, want SX1262", radio)
}
}
func TestHandleMessageStatusTopicMissingIdentityFields(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test"}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/status",
payload: []byte(`{"origin":"MyObserver","battery_mv":3500}`),
}
handleMessage(store, "test", source, msg, nil)
var model, firmware, clientVersion, radio interface{}
err := store.db.QueryRow("SELECT model, firmware, client_version, radio FROM observers WHERE id = 'obs1'").
Scan(&model, &firmware, &clientVersion, &radio)
if err != nil {
t.Fatal(err)
}
if model != nil || firmware != nil || clientVersion != nil || radio != nil {
t.Errorf("identity fields should remain NULL when absent: model=%v firmware=%v client_version=%v radio=%v", model, firmware, clientVersion, radio)
}
}
func TestHandleMessageSkipStatusTopics(t *testing.T) {
+81
View File
@@ -2,8 +2,10 @@ package main
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
)
// Config mirrors the Node.js config.json structure (read-only fields).
@@ -47,6 +49,10 @@ type Config struct {
Retention *RetentionConfig `json:"retention,omitempty"`
PacketStore *PacketStoreConfig `json:"packetStore,omitempty"`
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
Timestamps *TimestampConfig `json:"timestamps,omitempty"`
}
// PacketStoreConfig controls in-memory packet store limits.
@@ -55,10 +61,37 @@ type PacketStoreConfig struct {
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
}
type GeoFilterConfig struct {
Polygon [][2]float64 `json:"polygon,omitempty"`
BufferKm float64 `json:"bufferKm,omitempty"`
LatMin *float64 `json:"latMin,omitempty"`
LatMax *float64 `json:"latMax,omitempty"`
LonMin *float64 `json:"lonMin,omitempty"`
LonMax *float64 `json:"lonMax,omitempty"`
}
type TimestampConfig struct {
DefaultMode string `json:"defaultMode"` // "ago" | "absolute"
Timezone string `json:"timezone"` // "local" | "utc"
FormatPreset string `json:"formatPreset"` // "iso" | "iso-seconds" | "locale"
CustomFormat string `json:"customFormat"` // freeform, only used when AllowCustomFormat=true
AllowCustomFormat bool `json:"allowCustomFormat"` // admin gate
}
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`
}
func defaultTimestampConfig() TimestampConfig {
return TimestampConfig{
DefaultMode: "ago",
Timezone: "local",
FormatPreset: "iso",
CustomFormat: "",
AllowCustomFormat: false,
}
}
// NodeDaysOrDefault returns the configured retention.nodeDays or 7 if not set.
func (c *Config) NodeDaysOrDefault() int {
if c.Retention != nil && c.Retention.NodeDays > 0 {
@@ -103,8 +136,10 @@ func LoadConfig(baseDirs ...string) (*Config, error) {
if err := json.Unmarshal(data, cfg); err != nil {
continue
}
cfg.NormalizeTimestampConfig()
return cfg, nil
}
cfg.NormalizeTimestampConfig()
return cfg, nil // defaults
}
@@ -192,3 +227,49 @@ func (c *Config) PropagationBufferMs() int {
}
return 5000
}
func (c *Config) NormalizeTimestampConfig() {
defaults := defaultTimestampConfig()
if c.Timestamps == nil {
log.Printf("[config] timestamps not configured — using defaults (ago/local/iso)")
c.Timestamps = &defaults
return
}
origMode := c.Timestamps.DefaultMode
mode := strings.ToLower(strings.TrimSpace(origMode))
switch mode {
case "ago", "absolute":
c.Timestamps.DefaultMode = mode
default:
log.Printf("[config] warning: timestamps.defaultMode=%q is invalid, using %q", origMode, defaults.DefaultMode)
c.Timestamps.DefaultMode = defaults.DefaultMode
}
origTimezone := c.Timestamps.Timezone
timezone := strings.ToLower(strings.TrimSpace(origTimezone))
switch timezone {
case "local", "utc":
c.Timestamps.Timezone = timezone
default:
log.Printf("[config] warning: timestamps.timezone=%q is invalid, using %q", origTimezone, defaults.Timezone)
c.Timestamps.Timezone = defaults.Timezone
}
origPreset := c.Timestamps.FormatPreset
formatPreset := strings.ToLower(strings.TrimSpace(origPreset))
switch formatPreset {
case "iso", "iso-seconds", "locale":
c.Timestamps.FormatPreset = formatPreset
default:
log.Printf("[config] warning: timestamps.formatPreset=%q is invalid, using %q", origPreset, defaults.FormatPreset)
c.Timestamps.FormatPreset = defaults.FormatPreset
}
}
func (c *Config) GetTimestampConfig() TimestampConfig {
if c == nil || c.Timestamps == nil {
return defaultTimestampConfig()
}
return *c.Timestamps
}
+53
View File
@@ -31,6 +31,13 @@ func TestLoadConfigValidJSON(t *testing.T) {
"liveMap": map[string]interface{}{
"propagationBufferMs": 3000,
},
"timestamps": map[string]interface{}{
"defaultMode": "absolute",
"timezone": "utc",
"formatPreset": "iso-seconds",
"customFormat": "2006-01-02 15:04:05",
"allowCustomFormat": true,
},
}
data, _ := json.Marshal(cfgData)
os.WriteFile(filepath.Join(dir, "config.json"), data, 0644)
@@ -48,6 +55,18 @@ func TestLoadConfigValidJSON(t *testing.T) {
if cfg.MapDefaults.Zoom != 12 {
t.Errorf("expected zoom 12, got %d", cfg.MapDefaults.Zoom)
}
if cfg.Timestamps == nil {
t.Fatal("expected timestamps config")
}
if cfg.Timestamps.DefaultMode != "absolute" {
t.Errorf("expected defaultMode absolute, got %s", cfg.Timestamps.DefaultMode)
}
if cfg.Timestamps.Timezone != "utc" {
t.Errorf("expected timezone utc, got %s", cfg.Timestamps.Timezone)
}
if cfg.Timestamps.FormatPreset != "iso-seconds" {
t.Errorf("expected formatPreset iso-seconds, got %s", cfg.Timestamps.FormatPreset)
}
}
func TestLoadConfigFromDataSubdir(t *testing.T) {
@@ -76,6 +95,10 @@ func TestLoadConfigNoFiles(t *testing.T) {
if cfg.Port != 3000 {
t.Errorf("expected default port 3000, got %d", cfg.Port)
}
ts := cfg.GetTimestampConfig()
if ts.DefaultMode != "ago" || ts.Timezone != "local" || ts.FormatPreset != "iso" {
t.Errorf("expected default timestamp config ago/local/iso, got %s/%s/%s", ts.DefaultMode, ts.Timezone, ts.FormatPreset)
}
}
func TestLoadConfigInvalidJSON(t *testing.T) {
@@ -102,6 +125,36 @@ func TestLoadConfigNoArgs(t *testing.T) {
}
}
func TestLoadConfigTimestampNormalization(t *testing.T) {
dir := t.TempDir()
cfgData := map[string]interface{}{
"timestamps": map[string]interface{}{
"defaultMode": "banana",
"timezone": "mars",
"formatPreset": "weird",
},
}
data, _ := json.Marshal(cfgData)
os.WriteFile(filepath.Join(dir, "config.json"), data, 0644)
cfg, err := LoadConfig(dir)
if err != nil {
t.Fatal(err)
}
if cfg.Timestamps == nil {
t.Fatal("expected timestamps to be set")
}
if cfg.Timestamps.DefaultMode != "ago" {
t.Errorf("expected normalized defaultMode ago, got %s", cfg.Timestamps.DefaultMode)
}
if cfg.Timestamps.Timezone != "local" {
t.Errorf("expected normalized timezone local, got %s", cfg.Timestamps.Timezone)
}
if cfg.Timestamps.FormatPreset != "iso" {
t.Errorf("expected normalized formatPreset iso, got %s", cfg.Timestamps.FormatPreset)
}
}
func TestLoadThemeValidJSON(t *testing.T) {
dir := t.TempDir()
themeData := map[string]interface{}{
+47 -537
View File
@@ -1,537 +1,47 @@
package main
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"strings"
"time"
)
// Route type constants (header bits 1-0)
const (
RouteTransportFlood = 0
RouteFlood = 1
RouteDirect = 2
RouteTransportDirect = 3
)
// Payload type constants (header bits 5-2)
const (
PayloadREQ = 0x00
PayloadRESPONSE = 0x01
PayloadTXT_MSG = 0x02
PayloadACK = 0x03
PayloadADVERT = 0x04
PayloadGRP_TXT = 0x05
PayloadGRP_DATA = 0x06
PayloadANON_REQ = 0x07
PayloadPATH = 0x08
PayloadTRACE = 0x09
PayloadMULTIPART = 0x0A
PayloadCONTROL = 0x0B
PayloadRAW_CUSTOM = 0x0F
)
var routeTypeNames = map[int]string{
0: "TRANSPORT_FLOOD",
1: "FLOOD",
2: "DIRECT",
3: "TRANSPORT_DIRECT",
}
// Header is the decoded packet header.
type Header struct {
RouteType int `json:"routeType"`
RouteTypeName string `json:"routeTypeName"`
PayloadType int `json:"payloadType"`
PayloadTypeName string `json:"payloadTypeName"`
PayloadVersion int `json:"payloadVersion"`
}
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
type TransportCodes struct {
Code1 string `json:"code1"`
Code2 string `json:"code2"`
}
// Path holds decoded path/hop information.
type Path struct {
HashSize int `json:"hashSize"`
HashCount int `json:"hashCount"`
Hops []string `json:"hops"`
}
// AdvertFlags holds decoded advert flag bits.
type AdvertFlags struct {
Raw int `json:"raw"`
Type int `json:"type"`
Chat bool `json:"chat"`
Repeater bool `json:"repeater"`
Room bool `json:"room"`
Sensor bool `json:"sensor"`
HasLocation bool `json:"hasLocation"`
HasFeat1 bool `json:"hasFeat1"`
HasFeat2 bool `json:"hasFeat2"`
HasName bool `json:"hasName"`
}
// Payload is a generic decoded payload. Fields are populated depending on type.
type Payload struct {
Type string `json:"type"`
DestHash string `json:"destHash,omitempty"`
SrcHash string `json:"srcHash,omitempty"`
MAC string `json:"mac,omitempty"`
EncryptedData string `json:"encryptedData,omitempty"`
ExtraHash string `json:"extraHash,omitempty"`
PubKey string `json:"pubKey,omitempty"`
Timestamp uint32 `json:"timestamp,omitempty"`
TimestampISO string `json:"timestampISO,omitempty"`
Signature string `json:"signature,omitempty"`
Flags *AdvertFlags `json:"flags,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Name string `json:"name,omitempty"`
ChannelHash int `json:"channelHash,omitempty"`
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
PathData string `json:"pathData,omitempty"`
Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
TraceFlags *int `json:"traceFlags,omitempty"`
RawHex string `json:"raw,omitempty"`
Error string `json:"error,omitempty"`
}
// DecodedPacket is the full decoded result.
type DecodedPacket struct {
Header Header `json:"header"`
TransportCodes *TransportCodes `json:"transportCodes"`
Path Path `json:"path"`
Payload Payload `json:"payload"`
Raw string `json:"raw"`
}
func decodeHeader(b byte) Header {
rt := int(b & 0x03)
pt := int((b >> 2) & 0x0F)
pv := int((b >> 6) & 0x03)
rtName := routeTypeNames[rt]
if rtName == "" {
rtName = "UNKNOWN"
}
ptName := payloadTypeNames[pt]
if ptName == "" {
ptName = "UNKNOWN"
}
return Header{
RouteType: rt,
RouteTypeName: rtName,
PayloadType: pt,
PayloadTypeName: ptName,
PayloadVersion: pv,
}
}
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
hashSize := int(pathByte>>6) + 1
hashCount := int(pathByte & 0x3F)
totalBytes := hashSize * hashCount
hops := make([]string, 0, hashCount)
for i := 0; i < hashCount; i++ {
start := offset + i*hashSize
end := start + hashSize
if end > len(buf) {
break
}
hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end])))
}
return Path{
HashSize: hashSize,
HashCount: hashCount,
Hops: hops,
}, totalBytes
}
func isTransportRoute(routeType int) bool {
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
}
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: typeName,
DestHash: hex.EncodeToString(buf[0:1]),
SrcHash: hex.EncodeToString(buf[1:2]),
MAC: hex.EncodeToString(buf[2:4]),
EncryptedData: hex.EncodeToString(buf[4:]),
}
}
func decodeAck(buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
checksum := binary.LittleEndian.Uint32(buf[0:4])
return Payload{
Type: "ACK",
ExtraHash: fmt.Sprintf("%08x", checksum),
}
}
func decodeAdvert(buf []byte) Payload {
if len(buf) < 100 {
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
}
pubKey := hex.EncodeToString(buf[0:32])
timestamp := binary.LittleEndian.Uint32(buf[32:36])
signature := hex.EncodeToString(buf[36:100])
appdata := buf[100:]
p := Payload{
Type: "ADVERT",
PubKey: pubKey,
Timestamp: timestamp,
TimestampISO: fmt.Sprintf("%s", epochToISO(timestamp)),
Signature: signature,
}
if len(appdata) > 0 {
flags := appdata[0]
advType := int(flags & 0x0F)
hasFeat1 := flags&0x20 != 0
hasFeat2 := flags&0x40 != 0
p.Flags = &AdvertFlags{
Raw: int(flags),
Type: advType,
Chat: advType == 1,
Repeater: advType == 2,
Room: advType == 3,
Sensor: advType == 4,
HasLocation: flags&0x10 != 0,
HasFeat1: hasFeat1,
HasFeat2: hasFeat2,
HasName: flags&0x80 != 0,
}
off := 1
if p.Flags.HasLocation && len(appdata) >= off+8 {
latRaw := int32(binary.LittleEndian.Uint32(appdata[off : off+4]))
lonRaw := int32(binary.LittleEndian.Uint32(appdata[off+4 : off+8]))
lat := float64(latRaw) / 1e6
lon := float64(lonRaw) / 1e6
p.Lat = &lat
p.Lon = &lon
off += 8
}
if hasFeat1 && len(appdata) >= off+2 {
off += 2 // skip feat1 bytes (reserved for future use)
}
if hasFeat2 && len(appdata) >= off+2 {
off += 2 // skip feat2 bytes (reserved for future use)
}
if p.Flags.HasName {
name := string(appdata[off:])
name = strings.TrimRight(name, "\x00")
name = sanitizeName(name)
p.Name = name
}
}
return p
}
func decodeGrpTxt(buf []byte) Payload {
if len(buf) < 3 {
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "GRP_TXT",
ChannelHash: int(buf[0]),
MAC: hex.EncodeToString(buf[1:3]),
EncryptedData: hex.EncodeToString(buf[3:]),
}
}
func decodeAnonReq(buf []byte) Payload {
if len(buf) < 35 {
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "ANON_REQ",
DestHash: hex.EncodeToString(buf[0:1]),
EphemeralPubKey: hex.EncodeToString(buf[1:33]),
MAC: hex.EncodeToString(buf[33:35]),
EncryptedData: hex.EncodeToString(buf[35:]),
}
}
func decodePathPayload(buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: "PATH", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "PATH",
DestHash: hex.EncodeToString(buf[0:1]),
SrcHash: hex.EncodeToString(buf[1:2]),
MAC: hex.EncodeToString(buf[2:4]),
PathData: hex.EncodeToString(buf[4:]),
}
}
func decodeTrace(buf []byte) Payload {
if len(buf) < 9 {
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
tag := binary.LittleEndian.Uint32(buf[0:4])
authCode := binary.LittleEndian.Uint32(buf[4:8])
flags := int(buf[8])
p := Payload{
Type: "TRACE",
Tag: tag,
AuthCode: authCode,
TraceFlags: &flags,
}
if len(buf) > 9 {
p.PathData = hex.EncodeToString(buf[9:])
}
return p
}
func decodePayload(payloadType int, buf []byte) Payload {
switch payloadType {
case PayloadREQ:
return decodeEncryptedPayload("REQ", buf)
case PayloadRESPONSE:
return decodeEncryptedPayload("RESPONSE", buf)
case PayloadTXT_MSG:
return decodeEncryptedPayload("TXT_MSG", buf)
case PayloadACK:
return decodeAck(buf)
case PayloadADVERT:
return decodeAdvert(buf)
case PayloadGRP_TXT:
return decodeGrpTxt(buf)
case PayloadANON_REQ:
return decodeAnonReq(buf)
case PayloadPATH:
return decodePathPayload(buf)
case PayloadTRACE:
return decodeTrace(buf)
default:
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
}
}
// DecodePacket decodes a hex-encoded MeshCore packet.
func DecodePacket(hexString string) (*DecodedPacket, error) {
hexString = strings.ReplaceAll(hexString, " ", "")
hexString = strings.ReplaceAll(hexString, "\n", "")
hexString = strings.ReplaceAll(hexString, "\r", "")
buf, err := hex.DecodeString(hexString)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(buf) < 2 {
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
}
header := decodeHeader(buf[0])
offset := 1
var tc *TransportCodes
if isTransportRoute(header.RouteType) {
if len(buf) < offset+4 {
return nil, fmt.Errorf("packet too short for transport codes")
}
tc = &TransportCodes{
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
}
offset += 4
}
if offset >= len(buf) {
return nil, fmt.Errorf("packet too short (no path byte)")
}
pathByte := buf[offset]
offset++
path, bytesConsumed := decodePath(pathByte, buf, offset)
offset += bytesConsumed
payloadBuf := buf[offset:]
payload := decodePayload(header.PayloadType, payloadBuf)
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
// path field. The header path byte still encodes hashSize in bits 6-7, which
// we use to split the payload path data into individual hop prefixes.
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && path.HashSize > 0 {
hops := make([]string, 0, len(pathBytes)/path.HashSize)
for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize])))
}
path.Hops = hops
path.HashCount = len(hops)
}
}
return &DecodedPacket{
Header: header,
TransportCodes: tc,
Path: path,
Payload: payload,
Raw: strings.ToUpper(hexString),
}, nil
}
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
func ComputeContentHash(rawHex string) string {
buf, err := hex.DecodeString(rawHex)
if err != nil || len(buf) < 2 {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
headerByte := buf[0]
offset := 1
if isTransportRoute(int(headerByte & 0x03)) {
offset += 4
}
if offset >= len(buf) {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
pathByte := buf[offset]
offset++
hashSize := int((pathByte>>6)&0x3) + 1
hashCount := int(pathByte & 0x3F)
pathBytes := hashSize * hashCount
payloadStart := offset + pathBytes
if payloadStart > len(buf) {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
payload := buf[payloadStart:]
toHash := append([]byte{headerByte}, payload...)
h := sha256.Sum256(toHash)
return hex.EncodeToString(h[:])[:16]
}
// PayloadJSON serializes the payload to JSON for DB storage.
func PayloadJSON(p *Payload) string {
b, err := json.Marshal(p)
if err != nil {
return "{}"
}
return string(b)
}
// ValidateAdvert checks decoded advert data before DB insertion.
func ValidateAdvert(p *Payload) (bool, string) {
if p == nil || p.Error != "" {
reason := "null advert"
if p != nil {
reason = p.Error
}
return false, reason
}
pk := p.PubKey
if len(pk) < 16 {
return false, fmt.Sprintf("pubkey too short (%d hex chars)", len(pk))
}
allZero := true
for _, c := range pk {
if c != '0' {
allZero = false
break
}
}
if allZero {
return false, "pubkey is all zeros"
}
if p.Lat != nil {
if math.IsInf(*p.Lat, 0) || math.IsNaN(*p.Lat) || *p.Lat < -90 || *p.Lat > 90 {
return false, fmt.Sprintf("invalid lat: %f", *p.Lat)
}
}
if p.Lon != nil {
if math.IsInf(*p.Lon, 0) || math.IsNaN(*p.Lon) || *p.Lon < -180 || *p.Lon > 180 {
return false, fmt.Sprintf("invalid lon: %f", *p.Lon)
}
}
if p.Name != "" {
for _, c := range p.Name {
if (c >= 0x00 && c <= 0x08) || c == 0x0b || c == 0x0c || (c >= 0x0e && c <= 0x1f) || c == 0x7f {
return false, "name contains control characters"
}
}
if len(p.Name) > 64 {
return false, fmt.Sprintf("name too long (%d chars)", len(p.Name))
}
}
if p.Flags != nil {
role := advertRole(p.Flags)
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
if !validRoles[role] {
return false, fmt.Sprintf("unknown role: %s", role)
}
}
return true, ""
}
// sanitizeName strips non-printable characters (< 0x20 except tab/newline) and DEL.
func sanitizeName(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, c := range s {
if c == '\t' || c == '\n' || (c >= 0x20 && c != 0x7f) {
b.WriteRune(c)
}
}
return b.String()
}
func advertRole(f *AdvertFlags) string {
if f.Repeater {
return "repeater"
}
if f.Room {
return "room"
}
if f.Sensor {
return "sensor"
}
return "companion"
}
func epochToISO(epoch uint32) string {
t := time.Unix(int64(epoch), 0)
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}
package main
import dec "github.com/corescope/internal/decoder"
const (
RouteTransportFlood = dec.RouteTransportFlood
RouteFlood = dec.RouteFlood
RouteDirect = dec.RouteDirect
RouteTransportDirect = dec.RouteTransportDirect
PayloadREQ = dec.PayloadREQ
PayloadRESPONSE = dec.PayloadRESPONSE
PayloadTXT_MSG = dec.PayloadTXT_MSG
PayloadACK = dec.PayloadACK
PayloadADVERT = dec.PayloadADVERT
PayloadGRP_TXT = dec.PayloadGRP_TXT
PayloadGRP_DATA = dec.PayloadGRP_DATA
PayloadANON_REQ = dec.PayloadANON_REQ
PayloadPATH = dec.PayloadPATH
PayloadTRACE = dec.PayloadTRACE
PayloadMULTIPART = dec.PayloadMULTIPART
PayloadCONTROL = dec.PayloadCONTROL
PayloadRAW_CUSTOM = dec.PayloadRAW_CUSTOM
)
type Header = dec.Header
type TransportCodes = dec.TransportCodes
type Path = dec.Path
type AdvertFlags = dec.AdvertFlags
type Payload = dec.Payload
type DecodedPacket = dec.DecodedPacket
func DecodePacket(hexString string) (*DecodedPacket, error) {
return dec.DecodePacket(hexString, nil)
}
func ComputeContentHash(rawHex string) string {
return dec.ComputeContentHash(rawHex)
}
func PayloadJSON(p *Payload) string {
return dec.PayloadJSON(p)
}
func ValidateAdvert(p *Payload) (bool, string) {
return dec.ValidateAdvert(p)
}
+3
View File
@@ -3,6 +3,7 @@ module github.com/corescope/server
go 1.22
require (
github.com/corescope v0.0.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
modernc.org/sqlite v1.34.5
@@ -19,3 +20,5 @@ require (
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)
replace github.com/corescope => ../..
+3
View File
@@ -100,6 +100,9 @@ func main() {
if dbPath != "" {
cfg.DBPath = dbPath
}
if cfg.APIKey == "" {
log.Printf("[security] WARNING: no apiKey configured — write endpoints are BLOCKED (set apiKey in config.json to enable them)")
}
// Resolve DB path
resolvedDB := cfg.ResolveDBPath(configDir)
+37 -12
View File
@@ -30,13 +30,13 @@ type Server struct {
buildTime string
// Cached runtime.MemStats to avoid stop-the-world pauses on every health check
memStatsMu sync.Mutex
memStatsCache runtime.MemStats
memStatsMu sync.Mutex
memStatsCache runtime.MemStats
memStatsCachedAt time.Time
// Cached /api/stats response — recomputed at most once every 10s
statsMu sync.Mutex
statsCache *StatsResponse
statsMu sync.Mutex
statsCache *StatsResponse
statsCachedAt time.Time
}
@@ -102,18 +102,19 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/config/regions", s.handleConfigRegions).Methods("GET")
r.HandleFunc("/api/config/theme", s.handleConfigTheme).Methods("GET")
r.HandleFunc("/api/config/map", s.handleConfigMap).Methods("GET")
r.HandleFunc("/api/config/geo-filter", s.handleConfigGeoFilter).Methods("GET")
// System endpoints
r.HandleFunc("/api/health", s.handleHealth).Methods("GET")
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
r.HandleFunc("/api/perf/reset", s.handlePerfReset).Methods("POST")
r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
// Packet endpoints
r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET")
r.HandleFunc("/api/packets/{id}", s.handlePacketDetail).Methods("GET")
r.HandleFunc("/api/packets", s.handlePackets).Methods("GET")
r.HandleFunc("/api/packets", s.handlePostPacket).Methods("POST")
r.Handle("/api/packets", s.requireAPIKey(http.HandlerFunc(s.handlePostPacket))).Methods("POST")
// Decode endpoint
r.HandleFunc("/api/decode", s.handleDecode).Methods("POST")
@@ -200,6 +201,20 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
})
}
func (s *Server) requireAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.cfg == nil || s.cfg.APIKey == "" {
writeError(w, http.StatusForbidden, "write endpoints disabled — set apiKey in config.json")
return
}
if r.Header.Get("X-API-Key") != s.cfg.APIKey {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
next.ServeHTTP(w, r)
})
}
// --- Config Handlers ---
func (s *Server) handleConfigCache(w http.ResponseWriter, r *http.Request) {
@@ -224,6 +239,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
CacheInvalidateMs: s.cfg.CacheInvalidMs,
ExternalUrls: s.cfg.ExternalUrls,
PropagationBufferMs: float64(s.cfg.PropagationBufferMs()),
Timestamps: s.cfg.GetTimestampConfig(),
})
}
@@ -296,6 +312,15 @@ func (s *Server) handleConfigMap(w http.ResponseWriter, r *http.Request) {
writeJSON(w, MapConfigResponse{Center: center, Zoom: zoom})
}
func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
gf := s.cfg.GeoFilter
if gf == nil || len(gf.Polygon) == 0 {
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0})
return
}
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm})
}
// --- System Handlers ---
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -1156,7 +1181,7 @@ func (s *Server) handleAnalyticsHashSizes(w http.ResponseWriter, r *http.Request
return
}
writeJSON(w, map[string]interface{}{
"total": 0,
"total": 0,
"distribution": map[string]int{"1": 0, "2": 0, "3": 0},
"distributionByRepeaters": map[string]int{"1": 0, "2": 0, "3": 0},
"hourly": []HashSizeHourly{},
@@ -1327,12 +1352,12 @@ func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) {
ID: o.ID, Name: o.Name, IATA: o.IATA,
LastSeen: o.LastSeen, FirstSeen: o.FirstSeen,
PacketCount: o.PacketCount,
Model: o.Model, Firmware: o.Firmware,
Model: o.Model, Firmware: o.Firmware,
ClientVersion: o.ClientVersion, Radio: o.Radio,
BatteryMv: o.BatteryMv, UptimeSecs: o.UptimeSecs,
NoiseFloor: o.NoiseFloor,
NoiseFloor: o.NoiseFloor,
PacketsLastHour: plh,
Lat: lat, Lon: lon, NodeRole: nodeRole,
Lat: lat, Lon: lon, NodeRole: nodeRole,
})
}
writeJSON(w, ObserverListResponse{
@@ -1361,10 +1386,10 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) {
ID: obs.ID, Name: obs.Name, IATA: obs.IATA,
LastSeen: obs.LastSeen, FirstSeen: obs.FirstSeen,
PacketCount: obs.PacketCount,
Model: obs.Model, Firmware: obs.Firmware,
Model: obs.Model, Firmware: obs.Firmware,
ClientVersion: obs.ClientVersion, Radio: obs.Radio,
BatteryMv: obs.BatteryMv, UptimeSecs: obs.UptimeSecs,
NoiseFloor: obs.NoiseFloor,
NoiseFloor: obs.NoiseFloor,
PacketsLastHour: plh,
})
}
+359 -162
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -27,6 +28,94 @@ func setupTestServer(t *testing.T) (*Server, *mux.Router) {
return srv, router
}
func setupTestServerWithAPIKey(t *testing.T, apiKey string) (*Server, *mux.Router) {
t.Helper()
db := setupTestDB(t)
seedTestData(t, db)
cfg := &Config{Port: 3000, APIKey: apiKey}
hub := NewHub()
srv := NewServer(db, cfg, hub)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
return srv, router
}
func TestWriteEndpointsRequireAPIKey(t *testing.T) {
_, router := setupTestServerWithAPIKey(t, "test-secret")
t.Run("perf reset missing key returns 401", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/perf/reset", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
var body map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "unauthorized" {
t.Fatalf("expected unauthorized error, got %v", body["error"])
}
})
t.Run("packets post missing key returns 401", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/packets", bytes.NewBufferString(`{"raw":"0200"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
})
t.Run("decode succeeds without key", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
})
}
func TestWriteEndpointsBlockWhenAPIKeyEmpty(t *testing.T) {
_, router := setupTestServerWithAPIKey(t, "")
t.Run("perf reset blocked when api key unset", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/perf/reset", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 with empty apiKey, got %d (body: %s)", w.Code, w.Body.String())
}
})
t.Run("packets post blocked when api key unset", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/packets", bytes.NewBufferString(`{"raw":"0200"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 with empty apiKey, got %d (body: %s)", w.Code, w.Body.String())
}
})
t.Run("decode remains open when api key unset", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
})
}
func TestHealthEndpoint(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/health", nil)
@@ -1187,6 +1276,19 @@ func TestConfigClientEndpoint(t *testing.T) {
if body["propagationBufferMs"] == nil {
t.Error("expected propagationBufferMs")
}
tsRaw, ok := body["timestamps"].(map[string]interface{})
if !ok {
t.Fatal("expected timestamps object")
}
if tsRaw["defaultMode"] != "ago" {
t.Errorf("expected timestamps.defaultMode=ago, got %v", tsRaw["defaultMode"])
}
if tsRaw["timezone"] != "local" {
t.Errorf("expected timestamps.timezone=local, got %v", tsRaw["timezone"])
}
if tsRaw["formatPreset"] != "iso" {
t.Errorf("expected timestamps.formatPreset=iso, got %v", tsRaw["formatPreset"])
}
}
func TestConfigRegionsEndpoint(t *testing.T) {
@@ -1539,7 +1641,6 @@ func TestHandlerErrorPaths(t *testing.T) {
router := mux.NewRouter()
srv.RegisterRoutes(router)
t.Run("stats error", func(t *testing.T) {
db.conn.Exec("DROP TABLE IF EXISTS transmissions")
req := httptest.NewRequest("GET", "/api/stats", nil)
@@ -1760,197 +1861,239 @@ func TestHandlerErrorBulkHealth(t *testing.T) {
}
}
func TestAnalyticsChannelsNoNullArrays(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/channels", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/channels", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
raw := w.Body.String()
var body map[string]interface{}
if err := json.Unmarshal([]byte(raw), &body); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
raw := w.Body.String()
var body map[string]interface{}
if err := json.Unmarshal([]byte(raw), &body); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"}
for _, field := range arrayFields {
val, exists := body[field]
if !exists {
t.Errorf("missing field %q", field)
continue
}
if val == nil {
t.Errorf("field %q is null, expected empty array []", field)
continue
}
if _, ok := val.([]interface{}); !ok {
t.Errorf("field %q is not an array, got %T", field, val)
}
}
arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"}
for _, field := range arrayFields {
val, exists := body[field]
if !exists {
t.Errorf("missing field %q", field)
continue
}
if val == nil {
t.Errorf("field %q is null, expected empty array []", field)
continue
}
if _, ok := val.([]interface{}); !ok {
t.Errorf("field %q is not an array, got %T", field, val)
}
}
}
func TestAnalyticsChannelsNoStoreFallbackNoNulls(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
router := mux.NewRouter()
srv.RegisterRoutes(router)
db := setupTestDB(t)
seedTestData(t, db)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/analytics/channels", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
req := httptest.NewRequest("GET", "/api/analytics/channels", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"}
for _, field := range arrayFields {
if body[field] == nil {
t.Errorf("field %q is null in DB fallback, expected []", field)
}
}
arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"}
for _, field := range arrayFields {
if body[field] == nil {
t.Errorf("field %q is null in DB fallback, expected []", field)
}
}
}
func TestNodeHashSizeEnrichment(t *testing.T) {
t.Run("nil info leaves defaults", func(t *testing.T) {
node := map[string]interface{}{
"public_key": "abc123",
"hash_size": nil,
"hash_size_inconsistent": false,
}
EnrichNodeWithHashSize(node, nil)
if node["hash_size"] != nil {
t.Error("expected hash_size to remain nil with nil info")
}
})
t.Run("nil info leaves defaults", func(t *testing.T) {
node := map[string]interface{}{
"public_key": "abc123",
"hash_size": nil,
"hash_size_inconsistent": false,
}
EnrichNodeWithHashSize(node, nil)
if node["hash_size"] != nil {
t.Error("expected hash_size to remain nil with nil info")
}
})
t.Run("enriches with computed data", func(t *testing.T) {
node := map[string]interface{}{
"public_key": "abc123",
"hash_size": nil,
"hash_size_inconsistent": false,
}
info := &hashSizeNodeInfo{
HashSize: 2,
AllSizes: map[int]bool{1: true, 2: true},
Seq: []int{1, 2, 1, 2},
Inconsistent: true,
}
EnrichNodeWithHashSize(node, info)
if node["hash_size"] != 2 {
t.Errorf("expected hash_size 2, got %v", node["hash_size"])
}
if node["hash_size_inconsistent"] != true {
t.Error("expected hash_size_inconsistent true")
}
sizes, ok := node["hash_sizes_seen"].([]int)
if !ok {
t.Fatal("expected hash_sizes_seen to be []int")
}
if len(sizes) != 2 || sizes[0] != 1 || sizes[1] != 2 {
t.Errorf("expected [1,2], got %v", sizes)
}
})
t.Run("enriches with computed data", func(t *testing.T) {
node := map[string]interface{}{
"public_key": "abc123",
"hash_size": nil,
"hash_size_inconsistent": false,
}
info := &hashSizeNodeInfo{
HashSize: 2,
AllSizes: map[int]bool{1: true, 2: true},
Seq: []int{1, 2, 1, 2},
Inconsistent: true,
}
EnrichNodeWithHashSize(node, info)
if node["hash_size"] != 2 {
t.Errorf("expected hash_size 2, got %v", node["hash_size"])
}
if node["hash_size_inconsistent"] != true {
t.Error("expected hash_size_inconsistent true")
}
sizes, ok := node["hash_sizes_seen"].([]int)
if !ok {
t.Fatal("expected hash_sizes_seen to be []int")
}
if len(sizes) != 2 || sizes[0] != 1 || sizes[1] != 2 {
t.Errorf("expected [1,2], got %v", sizes)
}
})
t.Run("single size omits sizes_seen", func(t *testing.T) {
node := map[string]interface{}{
"public_key": "abc123",
"hash_size": nil,
"hash_size_inconsistent": false,
}
info := &hashSizeNodeInfo{
HashSize: 3,
AllSizes: map[int]bool{3: true},
Seq: []int{3, 3, 3},
}
EnrichNodeWithHashSize(node, info)
if node["hash_size"] != 3 {
t.Errorf("expected hash_size 3, got %v", node["hash_size"])
}
if node["hash_size_inconsistent"] != false {
t.Error("expected hash_size_inconsistent false")
}
if _, exists := node["hash_sizes_seen"]; exists {
t.Error("hash_sizes_seen should not be set for single size")
}
})
t.Run("single size omits sizes_seen", func(t *testing.T) {
node := map[string]interface{}{
"public_key": "abc123",
"hash_size": nil,
"hash_size_inconsistent": false,
}
info := &hashSizeNodeInfo{
HashSize: 3,
AllSizes: map[int]bool{3: true},
Seq: []int{3, 3, 3},
}
EnrichNodeWithHashSize(node, info)
if node["hash_size"] != 3 {
t.Errorf("expected hash_size 3, got %v", node["hash_size"])
}
if node["hash_size_inconsistent"] != false {
t.Error("expected hash_size_inconsistent false")
}
if _, exists := node["hash_sizes_seen"]; exists {
t.Error("hash_sizes_seen should not be set for single size")
}
})
}
func TestGetNodeHashSizeInfoFlipFlop(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
pk := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'TestNode', 'repeater')", pk)
decoded := `{"name":"TestNode","pubKey":"` + pk + `"}`
raw1 := "04" + "00" + "aabb"
raw2 := "04" + "40" + "aabb"
payloadType := 4
for i := 0; i < 3; i++ {
rawHex := raw1
if i%2 == 1 {
rawHex = raw2
}
tx := &StoreTx{
ID: 9000 + i,
RawHex: rawHex,
Hash: "testhash" + strconv.Itoa(i),
FirstSeen: "2024-01-01T00:00:00Z",
PayloadType: &payloadType,
DecodedJSON: decoded,
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
}
info := store.GetNodeHashSizeInfo()
ni := info[pk]
if ni == nil {
t.Fatal("expected hash info for test node")
}
if len(ni.AllSizes) != 2 {
t.Errorf("expected 2 unique sizes, got %d", len(ni.AllSizes))
}
if !ni.Inconsistent {
t.Error("expected inconsistent flag to be true for flip-flop pattern")
}
}
pk := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'TestNode', 'repeater')", pk)
func TestGetNodeHashSizeInfoDominant(t *testing.T) {
// A node that sends mostly 2-byte adverts but occasionally 1-byte (pathByte=0x00
// on direct sends) should report HashSize=2, not 1.
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
decoded := `{"name":"TestNode","pubKey":"` + pk + `"}`
raw1 := "04" + "00" + "aabb"
raw2 := "04" + "40" + "aabb"
pk := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'Repeater2B', 'repeater')", pk)
payloadType := 4
for i := 0; i < 3; i++ {
rawHex := raw1
if i%2 == 1 {
rawHex = raw2
}
tx := &StoreTx{
ID: 9000 + i,
RawHex: rawHex,
Hash: "testhash" + strconv.Itoa(i),
FirstSeen: "2024-01-01T00:00:00Z",
PayloadType: &payloadType,
DecodedJSON: decoded,
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
}
decoded := `{"name":"Repeater2B","pubKey":"` + pk + `"}`
raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1 (direct send, no hops)
raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2
info := store.GetNodeHashSizeInfo()
ni := info[pk]
if ni == nil {
t.Fatal("expected hash info for test node")
}
if len(ni.AllSizes) != 2 {
t.Errorf("expected 2 unique sizes, got %d", len(ni.AllSizes))
}
if !ni.Inconsistent {
t.Error("expected inconsistent flag to be true for flip-flop pattern")
}
payloadType := 4
// 1 packet with hashSize=1, 4 packets with hashSize=2
raws := []string{raw1byte, raw2byte, raw2byte, raw2byte, raw2byte}
for i, raw := range raws {
tx := &StoreTx{
ID: 8000 + i,
RawHex: raw,
Hash: "dominant" + strconv.Itoa(i),
FirstSeen: "2024-01-01T00:00:00Z",
PayloadType: &payloadType,
DecodedJSON: decoded,
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
}
info := store.GetNodeHashSizeInfo()
ni := info[pk]
if ni == nil {
t.Fatal("expected hash info for test node")
}
if ni.HashSize != 2 {
t.Errorf("HashSize=%d, want 2 (dominant size should win over occasional 1-byte)", ni.HashSize)
}
}
func TestAnalyticsHashSizesNoNullArrays(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/hash-sizes", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/hash-sizes", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
arrayFields := []string{"hourly", "topHops", "multiByteNodes"}
for _, field := range arrayFields {
if body[field] == nil {
t.Errorf("field %q is null, expected []", field)
}
arrayFields := []string{"hourly", "topHops", "multiByteNodes"}
for _, field := range arrayFields {
if body[field] == nil {
t.Errorf("field %q is null, expected []", field)
}
}
}
func TestObserverAnalyticsNoStore(t *testing.T) {
@@ -1963,6 +2106,60 @@ func TestObserverAnalyticsNoStore(t *testing.T) {
t.Fatalf("expected 503, got %d", w.Code)
}
}
func TestConfigGeoFilterEndpoint(t *testing.T) {
t.Run("no geo filter configured", func(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/config/geo-filter", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if body["polygon"] != nil {
t.Errorf("expected polygon to be nil when no geo filter configured, got %v", body["polygon"])
}
})
t.Run("with polygon configured", func(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
lat0, lat1 := 50.0, 51.5
lon0, lon1 := 3.0, 5.5
cfg := &Config{
Port: 3000,
GeoFilter: &GeoFilterConfig{
Polygon: [][2]float64{{lat0, lon0}, {lat1, lon0}, {lat1, lon1}, {lat0, lon1}},
BufferKm: 20,
},
}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = NewPacketStore(db, nil)
srv.store.Load()
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/config/geo-filter", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if body["polygon"] == nil {
t.Error("expected polygon in response when geo filter is configured")
}
if body["bufferKm"] == nil {
t.Error("expected bufferKm in response")
}
})
}
func min(a, b int) int {
if a < b {
return a
+21 -3
View File
@@ -4067,14 +4067,32 @@ func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
ni = &hashSizeNodeInfo{AllSizes: make(map[int]bool)}
info[pk] = ni
}
ni.HashSize = hs
ni.AllSizes[hs] = true
ni.Seq = append(ni.Seq, hs)
}
// Compute flip-flop (inconsistent) flag: need >= 3 observations,
// >= 2 unique sizes, and >= 2 transitions in the sequence.
// Post-process: compute dominant hash size (mode) and flip-flop flag.
// Using the last-seen value would misreport nodes that occasionally send
// with pathByte=0x00 (hashSize=1) when transmitting directly with no
// relay hops, even though their true hash size is 2 or 3.
for _, ni := range info {
// Dominant hash size: pick the most frequently observed size.
// On a tie, prefer the larger value (more specific).
counts := make(map[int]int, len(ni.AllSizes))
for _, hs := range ni.Seq {
counts[hs]++
}
best, bestCount := 1, 0
for hs, cnt := range counts {
if cnt > bestCount || (cnt == bestCount && hs > best) {
best = hs
bestCount = cnt
}
}
ni.HashSize = best
// Flip-flop (inconsistent) flag: need >= 3 observations,
// >= 2 unique sizes, and >= 2 transitions in the sequence.
if len(ni.Seq) < 3 || len(ni.AllSizes) < 2 {
continue
}
+2 -1
View File
@@ -919,7 +919,8 @@ type ClientConfigResponse struct {
WsReconnectMs interface{} `json:"wsReconnectMs"`
CacheInvalidateMs interface{} `json:"cacheInvalidateMs"`
ExternalUrls interface{} `json:"externalUrls"`
PropagationBufferMs float64 `json:"propagationBufferMs"`
PropagationBufferMs float64 `json:"propagationBufferMs"`
Timestamps TimestampConfig `json:"timestamps"`
}
// ─── IATA Coords ───────────────────────────────────────────────────────────────
+7
View File
@@ -144,6 +144,13 @@
"propagationBufferMs": 5000,
"_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh."
},
"timestamps": {
"defaultMode": "ago",
"timezone": "local",
"formatPreset": "iso",
"customFormat": "",
"allowCustomFormat": false
},
"packetStore": {
"maxMemoryMB": 1024,
"estimatedPacketBytes": 450,
-1
View File
@@ -25,7 +25,6 @@ services:
- "6060:6060" # pprof server
- "6061:6061" # pprof ingestor
volumes:
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}/config.json:/app/config.json:ro
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}:/app/data
- caddy-data-staging-go:/data/caddy
environment:
+3 -4
View File
@@ -17,11 +17,10 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${PROD_HTTP_PORT:-80}:${PROD_HTTP_PORT:-80}"
- "${PROD_HTTPS_PORT:-443}:${PROD_HTTPS_PORT:-443}"
- "${PROD_HTTP_PORT:-80}:80"
- "${PROD_HTTPS_PORT:-443}:443"
- "${PROD_MQTT_PORT:-1883}:1883"
volumes:
- ./config.json:/app/config.json:ro
- ./caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro
- ${PROD_DATA_DIR:-~/meshcore-data}:/app/data
- caddy-data:/data/caddy
@@ -35,4 +34,4 @@ services:
volumes:
# Named volumes for Caddy TLS certificates (not user data — managed by Caddy internally)
caddy-data:
caddy-data:
+7 -11
View File
@@ -1,16 +1,12 @@
#!/bin/sh
# Fix: Docker creates a directory when bind-mounting a non-existent file.
# If config.json is a directory (from a failed mount), remove it and use the example.
if [ -d /app/config.json ]; then
echo "[entrypoint] WARNING: config.json is a directory (broken bind mount) — removing and using example"
rm -rf /app/config.json
fi
# Copy example config if no config.json exists (not bind-mounted)
if [ ! -f /app/config.json ]; then
echo "[entrypoint] No config.json found, copying from config.example.json"
cp /app/config.example.json /app/config.json
# Config lives in the data directory (bind-mounted from host)
# The Go server already searches /app/data/config.json via LoadConfig
# but the ingestor expects a direct path — symlink for compatibility
if [ -f /app/data/config.json ]; then
ln -sf /app/data/config.json /app/config.json
elif [ ! -f /app/config.json ]; then
echo "[entrypoint] No config.json found in /app/data/ — using built-in defaults"
fi
# theme.json: check data/ volume (admin-editable on host)
+3
View File
@@ -0,0 +1,3 @@
module github.com/corescope
go 1.22
+740
View File
@@ -0,0 +1,740 @@
package decoder
import (
"crypto/aes"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"strings"
"time"
"unicode/utf8"
)
// Route type constants (header bits 1-0)
const (
RouteTransportFlood = 0
RouteFlood = 1
RouteDirect = 2
RouteTransportDirect = 3
)
// Payload type constants (header bits 5-2)
const (
PayloadREQ = 0x00
PayloadRESPONSE = 0x01
PayloadTXT_MSG = 0x02
PayloadACK = 0x03
PayloadADVERT = 0x04
PayloadGRP_TXT = 0x05
PayloadGRP_DATA = 0x06
PayloadANON_REQ = 0x07
PayloadPATH = 0x08
PayloadTRACE = 0x09
PayloadMULTIPART = 0x0A
PayloadCONTROL = 0x0B
PayloadRAW_CUSTOM = 0x0F
)
var routeTypeNames = map[int]string{
0: "TRANSPORT_FLOOD",
1: "FLOOD",
2: "DIRECT",
3: "TRANSPORT_DIRECT",
}
var payloadTypeNames = map[int]string{
0x00: "REQ",
0x01: "RESPONSE",
0x02: "TXT_MSG",
0x03: "ACK",
0x04: "ADVERT",
0x05: "GRP_TXT",
0x06: "GRP_DATA",
0x07: "ANON_REQ",
0x08: "PATH",
0x09: "TRACE",
0x0A: "MULTIPART",
0x0B: "CONTROL",
0x0F: "RAW_CUSTOM",
}
// Header is the decoded packet header.
type Header struct {
RouteType int `json:"routeType"`
RouteTypeName string `json:"routeTypeName"`
PayloadType int `json:"payloadType"`
PayloadTypeName string `json:"payloadTypeName"`
PayloadVersion int `json:"payloadVersion"`
}
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
type TransportCodes struct {
Code1 string `json:"code1"`
Code2 string `json:"code2"`
}
// Path holds decoded path/hop information.
type Path struct {
HashSize int `json:"hashSize"`
HashCount int `json:"hashCount"`
Hops []string `json:"hops"`
}
// AdvertFlags holds decoded advert flag bits.
type AdvertFlags struct {
Raw int `json:"raw"`
Type int `json:"type"`
Chat bool `json:"chat"`
Repeater bool `json:"repeater"`
Room bool `json:"room"`
Sensor bool `json:"sensor"`
HasLocation bool `json:"hasLocation"`
HasFeat1 bool `json:"hasFeat1"`
HasFeat2 bool `json:"hasFeat2"`
HasName bool `json:"hasName"`
}
// Payload is a generic decoded payload. Fields are populated depending on type.
type Payload struct {
Type string `json:"type"`
DestHash string `json:"destHash,omitempty"`
SrcHash string `json:"srcHash,omitempty"`
MAC string `json:"mac,omitempty"`
EncryptedData string `json:"encryptedData,omitempty"`
ExtraHash string `json:"extraHash,omitempty"`
PubKey string `json:"pubKey,omitempty"`
Timestamp uint32 `json:"timestamp,omitempty"`
TimestampISO string `json:"timestampISO,omitempty"`
Signature string `json:"signature,omitempty"`
Flags *AdvertFlags `json:"flags,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Name string `json:"name,omitempty"`
Feat1 *int `json:"feat1,omitempty"`
Feat2 *int `json:"feat2,omitempty"`
BatteryMv *int `json:"battery_mv,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
ChannelHash int `json:"channelHash,omitempty"`
ChannelHashHex string `json:"channelHashHex,omitempty"`
DecryptionStatus string `json:"decryptionStatus,omitempty"`
Channel string `json:"channel,omitempty"`
Text string `json:"text,omitempty"`
Sender string `json:"sender,omitempty"`
SenderTimestamp uint32 `json:"sender_timestamp,omitempty"`
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
PathData string `json:"pathData,omitempty"`
Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
TraceFlags *int `json:"traceFlags,omitempty"`
RawHex string `json:"raw,omitempty"`
Error string `json:"error,omitempty"`
}
// DecodedPacket is the full decoded result.
type DecodedPacket struct {
Header Header `json:"header"`
TransportCodes *TransportCodes `json:"transportCodes"`
Path Path `json:"path"`
Payload Payload `json:"payload"`
Raw string `json:"raw"`
}
func decodeHeader(b byte) Header {
rt := int(b & 0x03)
pt := int((b >> 2) & 0x0F)
pv := int((b >> 6) & 0x03)
rtName := routeTypeNames[rt]
if rtName == "" {
rtName = "UNKNOWN"
}
ptName := payloadTypeNames[pt]
if ptName == "" {
ptName = "UNKNOWN"
}
return Header{
RouteType: rt,
RouteTypeName: rtName,
PayloadType: pt,
PayloadTypeName: ptName,
PayloadVersion: pv,
}
}
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
hashSize := int(pathByte>>6) + 1
hashCount := int(pathByte & 0x3F)
totalBytes := hashSize * hashCount
hops := make([]string, 0, hashCount)
for i := 0; i < hashCount; i++ {
start := offset + i*hashSize
end := start + hashSize
if end > len(buf) {
break
}
hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end])))
}
return Path{
HashSize: hashSize,
HashCount: hashCount,
Hops: hops,
}, totalBytes
}
func isTransportRoute(routeType int) bool {
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
}
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: typeName,
DestHash: hex.EncodeToString(buf[0:1]),
SrcHash: hex.EncodeToString(buf[1:2]),
MAC: hex.EncodeToString(buf[2:4]),
EncryptedData: hex.EncodeToString(buf[4:]),
}
}
func decodeAck(buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
checksum := binary.LittleEndian.Uint32(buf[0:4])
return Payload{
Type: "ACK",
ExtraHash: fmt.Sprintf("%08x", checksum),
}
}
func decodeAdvert(buf []byte) Payload {
if len(buf) < 100 {
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
}
pubKey := hex.EncodeToString(buf[0:32])
timestamp := binary.LittleEndian.Uint32(buf[32:36])
signature := hex.EncodeToString(buf[36:100])
appdata := buf[100:]
p := Payload{
Type: "ADVERT",
PubKey: pubKey,
Timestamp: timestamp,
TimestampISO: fmt.Sprintf("%s", EpochToISO(timestamp)),
Signature: signature,
}
if len(appdata) > 0 {
flags := appdata[0]
advType := int(flags & 0x0F)
hasFeat1 := flags&0x20 != 0
hasFeat2 := flags&0x40 != 0
p.Flags = &AdvertFlags{
Raw: int(flags),
Type: advType,
Chat: advType == 1,
Repeater: advType == 2,
Room: advType == 3,
Sensor: advType == 4,
HasLocation: flags&0x10 != 0,
HasFeat1: hasFeat1,
HasFeat2: hasFeat2,
HasName: flags&0x80 != 0,
}
off := 1
if p.Flags.HasLocation && len(appdata) >= off+8 {
latRaw := int32(binary.LittleEndian.Uint32(appdata[off : off+4]))
lonRaw := int32(binary.LittleEndian.Uint32(appdata[off+4 : off+8]))
lat := float64(latRaw) / 1e6
lon := float64(lonRaw) / 1e6
p.Lat = &lat
p.Lon = &lon
off += 8
}
if hasFeat1 && len(appdata) >= off+2 {
feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
p.Feat1 = &feat1
off += 2
}
if hasFeat2 && len(appdata) >= off+2 {
feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
p.Feat2 = &feat2
off += 2
}
if p.Flags.HasName {
// Find null terminator to separate name from trailing telemetry bytes
nameEnd := len(appdata)
for i := off; i < len(appdata); i++ {
if appdata[i] == 0x00 {
nameEnd = i
break
}
}
name := string(appdata[off:nameEnd])
name = sanitizeName(name)
p.Name = name
off = nameEnd
// Skip null terminator(s)
for off < len(appdata) && appdata[off] == 0x00 {
off++
}
}
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
// Only sensor nodes (advType=4) carry telemetry bytes.
if p.Flags.Sensor && off+4 <= len(appdata) {
batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4]))
tempC := float64(tempRaw) / 100.0
if batteryMv > 0 && batteryMv <= 10000 {
p.BatteryMv = &batteryMv
}
// Raw int16 / 100 → °C; accept -50°C to 100°C (raw: -5000 to 10000)
if tempRaw >= -5000 && tempRaw <= 10000 {
p.TemperatureC = &tempC
}
}
}
return p
}
// channelDecryptResult holds the decrypted channel message fields.
type channelDecryptResult struct {
Timestamp uint32
Flags byte
Sender string
Message string
}
// countNonPrintable counts characters that are non-printable (< 0x20 except \n, \t).
func countNonPrintable(s string) int {
count := 0
for _, r := range s {
if r < 0x20 && r != '\n' && r != '\t' {
count++
} else if r == utf8.RuneError {
count++
}
}
return count
}
// decryptChannelMessage implements MeshCore channel decryption:
// HMAC-SHA256 MAC verification followed by AES-128-ECB decryption.
func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channelDecryptResult, error) {
channelKey, err := hex.DecodeString(channelKeyHex)
if err != nil || len(channelKey) != 16 {
return nil, fmt.Errorf("invalid channel key")
}
macBytes, err := hex.DecodeString(macHex)
if err != nil || len(macBytes) != 2 {
return nil, fmt.Errorf("invalid MAC")
}
ciphertext, err := hex.DecodeString(ciphertextHex)
if err != nil || len(ciphertext) == 0 {
return nil, fmt.Errorf("invalid ciphertext")
}
// 32-byte channel secret: 16-byte key + 16 zero bytes
channelSecret := make([]byte, 32)
copy(channelSecret, channelKey)
// Verify HMAC-SHA256 (first 2 bytes must match provided MAC)
h := hmac.New(sha256.New, channelSecret)
h.Write(ciphertext)
calculatedMac := h.Sum(nil)
if calculatedMac[0] != macBytes[0] || calculatedMac[1] != macBytes[1] {
return nil, fmt.Errorf("MAC verification failed")
}
// AES-128-ECB decrypt (block-by-block, no padding)
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
}
block, err := aes.NewCipher(channelKey)
if err != nil {
return nil, fmt.Errorf("AES cipher: %w", err)
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
// Parse: timestamp(4 LE) + flags(1) + message(UTF-8, null-terminated)
if len(plaintext) < 5 {
return nil, fmt.Errorf("decrypted content too short")
}
timestamp := binary.LittleEndian.Uint32(plaintext[0:4])
flags := plaintext[4]
messageText := string(plaintext[5:])
if idx := strings.IndexByte(messageText, 0); idx >= 0 {
messageText = messageText[:idx]
}
// Validate decrypted text is printable UTF-8 (not binary garbage)
if !utf8.ValidString(messageText) || countNonPrintable(messageText) > 2 {
return nil, fmt.Errorf("decrypted text contains non-printable characters")
}
result := &channelDecryptResult{Timestamp: timestamp, Flags: flags}
// Parse "sender: message" format
colonIdx := strings.Index(messageText, ": ")
if colonIdx > 0 && colonIdx < 50 {
potentialSender := messageText[:colonIdx]
if !strings.ContainsAny(potentialSender, ":[]") {
result.Sender = potentialSender
result.Message = messageText[colonIdx+2:]
} else {
result.Message = messageText
}
} else {
result.Message = messageText
}
return result, nil
}
func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
if len(buf) < 3 {
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
channelHash := int(buf[0])
channelHashHex := fmt.Sprintf("%02X", buf[0])
mac := hex.EncodeToString(buf[1:3])
encryptedData := hex.EncodeToString(buf[3:])
hasKeys := len(channelKeys) > 0
// Match Node.js: only attempt decryption if encrypted data >= 5 bytes (10 hex chars)
if hasKeys && len(encryptedData) >= 10 {
for name, key := range channelKeys {
result, err := decryptChannelMessage(encryptedData, mac, key)
if err != nil {
continue
}
text := result.Message
if result.Sender != "" && result.Message != "" {
text = result.Sender + ": " + result.Message
}
return Payload{
Type: "CHAN",
Channel: name,
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "decrypted",
Sender: result.Sender,
Text: text,
SenderTimestamp: result.Timestamp,
}
}
return Payload{
Type: "GRP_TXT",
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "decryption_failed",
MAC: mac,
EncryptedData: encryptedData,
}
}
return Payload{
Type: "GRP_TXT",
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "no_key",
MAC: mac,
EncryptedData: encryptedData,
}
}
func decodeAnonReq(buf []byte) Payload {
if len(buf) < 35 {
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "ANON_REQ",
DestHash: hex.EncodeToString(buf[0:1]),
EphemeralPubKey: hex.EncodeToString(buf[1:33]),
MAC: hex.EncodeToString(buf[33:35]),
EncryptedData: hex.EncodeToString(buf[35:]),
}
}
func decodePathPayload(buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: "PATH", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "PATH",
DestHash: hex.EncodeToString(buf[0:1]),
SrcHash: hex.EncodeToString(buf[1:2]),
MAC: hex.EncodeToString(buf[2:4]),
PathData: hex.EncodeToString(buf[4:]),
}
}
func decodeTrace(buf []byte) Payload {
if len(buf) < 9 {
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
tag := binary.LittleEndian.Uint32(buf[0:4])
authCode := binary.LittleEndian.Uint32(buf[4:8])
flags := int(buf[8])
p := Payload{
Type: "TRACE",
Tag: tag,
AuthCode: authCode,
TraceFlags: &flags,
}
if len(buf) > 9 {
p.PathData = hex.EncodeToString(buf[9:])
}
return p
}
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
switch payloadType {
case PayloadREQ:
return decodeEncryptedPayload("REQ", buf)
case PayloadRESPONSE:
return decodeEncryptedPayload("RESPONSE", buf)
case PayloadTXT_MSG:
return decodeEncryptedPayload("TXT_MSG", buf)
case PayloadACK:
return decodeAck(buf)
case PayloadADVERT:
return decodeAdvert(buf)
case PayloadGRP_TXT:
return decodeGrpTxt(buf, channelKeys)
case PayloadANON_REQ:
return decodeAnonReq(buf)
case PayloadPATH:
return decodePathPayload(buf)
case PayloadTRACE:
return decodeTrace(buf)
default:
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
}
}
// DecodePacket decodes a hex-encoded MeshCore packet.
func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) {
hexString = strings.ReplaceAll(hexString, " ", "")
hexString = strings.ReplaceAll(hexString, "\n", "")
hexString = strings.ReplaceAll(hexString, "\r", "")
buf, err := hex.DecodeString(hexString)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(buf) < 2 {
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
}
header := decodeHeader(buf[0])
offset := 1
var tc *TransportCodes
if isTransportRoute(header.RouteType) {
if len(buf) < offset+4 {
return nil, fmt.Errorf("packet too short for transport codes")
}
tc = &TransportCodes{
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
}
offset += 4
}
if offset >= len(buf) {
return nil, fmt.Errorf("packet too short (no path byte)")
}
pathByte := buf[offset]
offset++
path, bytesConsumed := decodePath(pathByte, buf, offset)
offset += bytesConsumed
payloadBuf := buf[offset:]
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys)
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
// path field. The header path byte still encodes hashSize in bits 6-7, which
// we use to split the payload path data into individual hop prefixes.
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && path.HashSize > 0 {
hops := make([]string, 0, len(pathBytes)/path.HashSize)
for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize])))
}
path.Hops = hops
path.HashCount = len(hops)
}
}
return &DecodedPacket{
Header: header,
TransportCodes: tc,
Path: path,
Payload: payload,
Raw: strings.ToUpper(hexString),
}, nil
}
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
// It hashes the header byte + payload (skipping path bytes) to produce a
// path-independent identifier for the same transmission.
func ComputeContentHash(rawHex string) string {
buf, err := hex.DecodeString(rawHex)
if err != nil || len(buf) < 2 {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
headerByte := buf[0]
offset := 1
if isTransportRoute(int(headerByte & 0x03)) {
offset += 4
}
if offset >= len(buf) {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
pathByte := buf[offset]
offset++
hashSize := int((pathByte>>6)&0x3) + 1
hashCount := int(pathByte & 0x3F)
pathBytes := hashSize * hashCount
payloadStart := offset + pathBytes
if payloadStart > len(buf) {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
payload := buf[payloadStart:]
toHash := append([]byte{headerByte}, payload...)
h := sha256.Sum256(toHash)
return hex.EncodeToString(h[:])[:16]
}
// PayloadJSON serializes the payload to JSON for DB storage.
func PayloadJSON(p *Payload) string {
b, err := json.Marshal(p)
if err != nil {
return "{}"
}
return string(b)
}
// ValidateAdvert checks decoded advert data before DB insertion.
func ValidateAdvert(p *Payload) (bool, string) {
if p == nil || p.Error != "" {
reason := "null advert"
if p != nil {
reason = p.Error
}
return false, reason
}
pk := p.PubKey
if len(pk) < 16 {
return false, fmt.Sprintf("pubkey too short (%d hex chars)", len(pk))
}
allZero := true
for _, c := range pk {
if c != '0' {
allZero = false
break
}
}
if allZero {
return false, "pubkey is all zeros"
}
if p.Lat != nil {
if math.IsInf(*p.Lat, 0) || math.IsNaN(*p.Lat) || *p.Lat < -90 || *p.Lat > 90 {
return false, fmt.Sprintf("invalid lat: %f", *p.Lat)
}
}
if p.Lon != nil {
if math.IsInf(*p.Lon, 0) || math.IsNaN(*p.Lon) || *p.Lon < -180 || *p.Lon > 180 {
return false, fmt.Sprintf("invalid lon: %f", *p.Lon)
}
}
if p.Name != "" {
for _, c := range p.Name {
if (c >= 0x00 && c <= 0x08) || c == 0x0b || c == 0x0c || (c >= 0x0e && c <= 0x1f) || c == 0x7f {
return false, "name contains control characters"
}
}
if len(p.Name) > 64 {
return false, fmt.Sprintf("name too long (%d chars)", len(p.Name))
}
}
if p.Flags != nil {
role := AdvertRole(p.Flags)
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
if !validRoles[role] {
return false, fmt.Sprintf("unknown role: %s", role)
}
}
return true, ""
}
// sanitizeName strips non-printable characters (< 0x20 except tab/newline) and DEL.
func sanitizeName(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, c := range s {
if c == '\t' || c == '\n' || (c >= 0x20 && c != 0x7f) {
b.WriteRune(c)
}
}
return b.String()
}
func AdvertRole(f *AdvertFlags) string {
if f.Repeater {
return "repeater"
}
if f.Room {
return "room"
}
if f.Sensor {
return "sensor"
}
return "companion"
}
func EpochToISO(epoch uint32) string {
// Go time from Unix epoch
t := time.Unix(int64(epoch), 0)
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}
File diff suppressed because it is too large Load Diff
+1428 -947
View File
File diff suppressed because it is too large Load Diff
+388 -81
View File
@@ -960,13 +960,23 @@
</div>
<div class="analytics-card" id="hashMatrixSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">🔢 1-Byte Hash Usage Matrix</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a></div>
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Click a cell to see which nodes share that prefix. Green = available, yellow = taken, red = collision.</p>
<div style="display:flex;justify-content:space-between;align-items:center">
<h3 style="margin:0" id="hashMatrixTitle">🔢 Hash Usage Matrix</h3>
<a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a>
</div>
<div style="display:flex;align-items:center;gap:16px;margin:8px 0">
<div class="hash-byte-selector" id="hashByteSelector" style="display:flex;gap:4px">
<button class="hash-byte-btn active" data-bytes="1">1-Byte</button>
<button class="hash-byte-btn" data-bytes="2">2-Byte</button>
<button class="hash-byte-btn" data-bytes="3">3-Byte</button>
</div>
<p class="text-muted" id="hashMatrixDesc" style="margin:0;font-size:0.8em">Click a cell to see which nodes share that prefix.</p>
</div>
<div id="hashMatrix"></div>
</div>
<div class="analytics-card" id="collisionRiskSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">💥 1-Byte Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a></div>
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0" id="collisionRiskTitle">💥 Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a></div>
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading</div></div>
</div>
`;
@@ -1003,10 +1013,43 @@
}
}
// Only repeaters matter for routing — filter out non-repeaters for collision analysis
// Repeaters are confirmed routing nodes; null-role nodes may also route (possible conflict)
const repeaterNodes = allNodes.filter(n => n.role === 'repeater');
renderHashMatrix(data.topHops, repeaterNodes);
renderCollisions(data.topHops, repeaterNodes);
const nullRoleNodes = allNodes.filter(n => !n.role);
const routingNodes = [...repeaterNodes, ...nullRoleNodes];
let currentBytes = 1;
function refreshHashViews(bytes) {
currentBytes = bytes;
hideMatrixTip();
// Update selector button states
document.querySelectorAll('.hash-byte-btn').forEach(b => {
b.classList.toggle('active', Number(b.dataset.bytes) === bytes);
});
// Update titles and description
const matrixTitle = document.getElementById('hashMatrixTitle');
const matrixDesc = document.getElementById('hashMatrixDesc');
const riskTitle = document.getElementById('collisionRiskTitle');
if (matrixTitle) matrixTitle.textContent = bytes === 3 ? '🔢 Hash Usage Matrix' : `🔢 ${bytes}-Byte Hash Usage Matrix`;
if (riskTitle) riskTitle.textContent = `💥 ${bytes}-Byte Collision Risk`;
if (matrixDesc) {
if (bytes === 1) matrixDesc.textContent = 'Click a cell to see which nodes share that 1-byte prefix.';
else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.';
else matrixDesc.textContent = '3-byte prefix space is too large to visualize as a matrix — collision table is shown below.';
}
renderHashMatrix(data.topHops, routingNodes, bytes, allNodes);
// Hide collision risk card for 3-byte — stats are shown in the matrix panel
const riskCard = document.getElementById('collisionRiskSection');
if (riskCard) riskCard.style.display = bytes === 3 ? 'none' : '';
if (bytes !== 3) renderCollisions(data.topHops, routingNodes, bytes);
}
// Wire up selector
document.getElementById('hashByteSelector')?.querySelectorAll('.hash-byte-btn').forEach(btn => {
btn.addEventListener('click', () => refreshHashViews(Number(btn.dataset.bytes)));
});
refreshHashViews(1);
}
function renderHashTimeline(hourly) {
@@ -1033,93 +1076,341 @@
return svg;
}
async function renderHashMatrix(topHops, allNodes) {
// Shared hover tooltip for hash matrix cells.
// Called once per container — reads content from data-tip on each <td>.
// Single shared tooltip element for the entire hash matrix — avoids DOM accumulation on mode switch
let _matrixTip = null;
function getMatrixTip() {
if (!_matrixTip) {
_matrixTip = document.createElement('div');
_matrixTip.className = 'hash-matrix-tooltip';
_matrixTip.style.display = 'none';
document.body.appendChild(_matrixTip);
}
return _matrixTip;
}
function hideMatrixTip() { if (_matrixTip) _matrixTip.style.display = 'none'; }
function initMatrixTooltip(el) {
if (el._matrixTipInit) return;
el._matrixTipInit = true;
el.addEventListener('mouseover', e => {
const td = e.target.closest('td[data-tip]');
if (!td) return;
const tip = getMatrixTip();
tip.innerHTML = td.dataset.tip;
tip.style.display = 'block';
});
el.addEventListener('mousemove', e => {
if (!_matrixTip || _matrixTip.style.display === 'none') return;
const x = e.clientX + 14, y = e.clientY + 14;
_matrixTip.style.left = Math.min(x, window.innerWidth - _matrixTip.offsetWidth - 8) + 'px';
_matrixTip.style.top = Math.min(y, window.innerHeight - _matrixTip.offsetHeight - 8) + 'px';
});
el.addEventListener('mouseout', e => {
if (e.target.closest('td[data-tip]') && !e.relatedTarget?.closest('td[data-tip]')) hideMatrixTip();
});
el.addEventListener('mouseleave', hideMatrixTip);
}
// Pure data helpers — extracted for testability
function buildOneBytePrefixMap(nodes) {
const map = {};
for (let i = 0; i < 256; i++) map[i.toString(16).padStart(2, '0').toUpperCase()] = [];
for (const n of nodes) {
const hex = n.public_key.slice(0, 2).toUpperCase();
if (map[hex]) map[hex].push(n);
}
return map;
}
function buildTwoBytePrefixInfo(nodes) {
const info = {};
for (let i = 0; i < 256; i++) {
const h = i.toString(16).padStart(2, '0').toUpperCase();
info[h] = { groupNodes: [], twoByteMap: {}, maxCollision: 0, collisionCount: 0 };
}
for (const n of nodes) {
const firstHex = n.public_key.slice(0, 2).toUpperCase();
const twoHex = n.public_key.slice(0, 4).toUpperCase();
const entry = info[firstHex];
if (!entry) continue;
entry.groupNodes.push(n);
if (!entry.twoByteMap[twoHex]) entry.twoByteMap[twoHex] = [];
entry.twoByteMap[twoHex].push(n);
}
for (const entry of Object.values(info)) {
const collisions = Object.values(entry.twoByteMap).filter(v => v.length > 1);
entry.collisionCount = collisions.length;
entry.maxCollision = collisions.length ? Math.max(...collisions.map(v => v.length)) : 0;
}
return info;
}
function buildCollisionHops(allNodes, bytes) {
const map = {};
for (const n of allNodes) {
const p = n.public_key.slice(0, bytes * 2).toUpperCase();
if (!map[p]) map[p] = { hex: p, count: 0, size: bytes };
map[p].count++;
}
return Object.values(map).filter(h => h.count > 1);
}
function renderHashMatrix(topHops, allNodes, bytes, totalNodes) {
bytes = bytes || 1;
totalNodes = totalNodes || allNodes;
const el = document.getElementById('hashMatrix');
// Build prefix → node count map
const prefixNodes = {};
for (let i = 0; i < 256; i++) {
const hex = i.toString(16).padStart(2, '0').toUpperCase();
prefixNodes[hex] = allNodes.filter(n => n.public_key.toUpperCase().startsWith(hex));
// 3-byte: show a summary panel instead of a matrix
if (bytes === 3) {
const total = totalNodes.length;
const threeByteNodes = allNodes.filter(n => n.hash_size === 3).length;
const nodesForByte = allNodes.filter(n => n.hash_size === 3 || !n.hash_size);
const prefixMap = {};
for (const n of nodesForByte) {
const p = n.public_key.slice(0, 6).toUpperCase();
if (!prefixMap[p]) prefixMap[p] = 0;
prefixMap[p]++;
}
const uniquePrefixes = Object.keys(prefixMap).length;
const collisions = Object.values(prefixMap).filter(c => c > 1).length;
const spaceSize = 16777216; // 2^24
const pct = uniquePrefixes > 0 ? ((uniquePrefixes / spaceSize) * 100).toFixed(6) : '0';
el.innerHTML = `
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Nodes tracked</div>
<div class="analytics-stat-value">${total.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Using 3-byte ID</div>
<div class="analytics-stat-value">${threeByteNodes.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Prefix space used</div>
<div class="analytics-stat-value" style="font-size:16px">${pct}%</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 16.7M possible</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${collisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${collisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisions}</div>
</div>
</div>
<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>`;
return;
}
const nibbles = '0123456789ABCDEF'.split('');
const cellSize = 36;
const headerSize = 24;
let html = `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
html += `<tr><td style="width:${headerSize}px"></td>`;
for (const n of nibbles) {
html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
}
html += '</tr>';
if (bytes === 1) {
const nodesForByte = allNodes.filter(n => n.hash_size === 1 || !n.hash_size);
const prefixNodes = buildOneBytePrefixMap(nodesForByte);
const oneByteCount = allNodes.filter(n => n.hash_size === 1).length;
const oneUsed = Object.values(prefixNodes).filter(v => v.length > 0).length;
const oneCollisions = Object.values(prefixNodes).filter(v => v.length > 1).length;
const onePct = ((oneUsed / 256) * 100).toFixed(1);
for (let hi = 0; hi < 16; hi++) {
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
for (let lo = 0; lo < 16; lo++) {
const hex = nibbles[hi] + nibbles[lo];
const nodes = prefixNodes[hex] || [];
const count = nodes.length;
let bg, color;
if (count === 0) {
bg = 'var(--card-bg)'; color = 'var(--text-muted)'; // empty — subtle
} else if (count === 1) {
bg = '#dcfce7'; color = '#166534'; // light green — taken, no collision
} else {
// 2+ nodes: orange→red
const t = Math.min((count - 2) / 4, 1);
const r = Math.round(220 + 35 * t);
const g = Math.round(120 * (1 - t));
bg = `rgb(${r},${g},30)`; color = '#fff';
}
const status = count === 0 ? 'available' : count === 1 ? `1 node: ${nodes[0].name || nodes[0].public_key.slice(0,12)}` : `${count} nodes — COLLISION`;
const cellText = count === 0 ? `<span style="font-size:11px">${hex}</span>` : count >= 2 ? `<strong>${count >= 3 ? '3+' : count}</strong>` : String(count);
html += `<td class="hash-cell${count ? ' hash-active' : ''}" data-hex="${hex}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;background:${bg};color:${color};border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:13px;font-weight:${count >= 2 ? '700' : '400'}" title="0x${hex}: ${status}">${cellText}</td>`;
}
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Nodes tracked</div>
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Using 1-byte ID</div>
<div class="analytics-stat-value">${oneByteCount.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Prefix space used</div>
<div class="analytics-stat-value" style="font-size:16px">${onePct}%</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 256 possible</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${oneCollisions}</div>
</div>
</div>`;
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
html += `<tr><td style="width:${headerSize}px"></td>`;
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
html += '</tr>';
}
html += '</table></div>';
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center">
<span><span class="legend-swatch" style="background:var(--card-bg);border:1px solid var(--border)"></span> 0 Available</span>
<span><span class="legend-swatch" style="background:#dcfce7"></span> 1 One node</span>
<span><span class="legend-swatch" style="background:rgb(200,80,30)"></span> 2 Two nodes (collision)</span>
<span><span class="legend-swatch" style="background:rgb(200,0,30)"></span> 3+ Three+ nodes (collision)</span>
</div>`;
el.innerHTML = html;
// Click handler for cells
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
const hex = td.dataset.hex.toUpperCase();
const matches = prefixNodes[hex] || [];
const detail = document.getElementById('hashDetail');
if (!matches.length) {
detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`;
return;
for (let hi = 0; hi < 16; hi++) {
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
for (let lo = 0; lo < 16; lo++) {
const hex = nibbles[hi] + nibbles[lo];
const nodes = prefixNodes[hex] || [];
const count = nodes.length;
const repeaterCount = nodes.filter(n => n.role === 'repeater').length;
const isCollision = count >= 2 && repeaterCount >= 2;
const isPossible = count >= 2 && !isCollision;
let cellClass, bgStyle;
if (count === 0) { cellClass = 'hash-cell-empty'; bgStyle = ''; }
else if (count === 1) { cellClass = 'hash-cell-taken'; bgStyle = ''; }
else if (isPossible) { cellClass = 'hash-cell-possible'; bgStyle = ''; }
else { const t = Math.min((count - 2) / 4, 1); bgStyle = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass = 'hash-cell-collision'; }
const nodeLabel = m => `<div style="font-size:11px">${esc(m.name||m.public_key.slice(0,12))}${!m.role ? ' <span style="opacity:0.7">(unknown role)</span>' : ''}</div>`;
const tip1 = count === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">Available</div>`
: count === 1
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">One node — no collision</div><div class="hash-matrix-tooltip-nodes">${nodeLabel(nodes[0])}</div>`
: isPossible
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — POSSIBLE CONFLICT</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`
: `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — COLLISION</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`;
html += `<td class="hash-cell ${cellClass}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip1.replace(/"/g,'&quot;')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${count >= 2 ? '700' : '400'}">${hex}</td>`;
}
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
`<div style="margin-top:8px">${matches.map(m => {
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
? `<span class="text-muted" style="font-size:0.8em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
: '<span class="text-muted" style="font-size:0.8em">(no coords)</span>';
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
}).join('')}</div>`;
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
html += '</tr>';
}
html += '</table></div>';
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> Available</span>
<span><span class="legend-swatch hash-cell-taken"></span> One node</span>
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
</div>`;
el.innerHTML = html;
initMatrixTooltip(el);
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
const hex = td.dataset.hex.toUpperCase();
const matches = prefixNodes[hex] || [];
const detail = document.getElementById('hashDetail');
if (!matches.length) { detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`; return; }
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
`<div style="margin-top:8px">${matches.map(m => {
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? `<span class="text-muted" style="font-size:0.8em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>` : '<span class="text-muted" style="font-size:0.8em">(no coords)</span>';
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
}).join('')}</div>`;
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
});
});
});
} else if (bytes === 2) {
// 2-byte mode: 16×16 grid of first-byte groups
const nodesForByte = allNodes.filter(n => n.hash_size === 2 || !n.hash_size);
const firstByteInfo = buildTwoBytePrefixInfo(nodesForByte);
const twoByteCount = allNodes.filter(n => n.hash_size === 2).length;
const uniqueTwoBytePrefixes = new Set(nodesForByte.map(n => n.public_key.slice(0, 4).toUpperCase())).size;
const twoCollisions = Object.values(firstByteInfo).filter(v => v.collisionCount > 0).length;
const twoPct = ((uniqueTwoBytePrefixes / 65536) * 100).toFixed(3);
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Nodes tracked</div>
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Using 2-byte ID</div>
<div class="analytics-stat-value">${twoByteCount.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Prefix space used</div>
<div class="analytics-stat-value" style="font-size:16px">${twoPct}%</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">${uniqueTwoBytePrefixes} of 65,536 possible</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${twoCollisions}</div>
</div>
</div>`;
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
html += `<tr><td style="width:${headerSize}px"></td>`;
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
html += '</tr>';
for (let hi = 0; hi < 16; hi++) {
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
for (let lo = 0; lo < 16; lo++) {
const hex = nibbles[hi] + nibbles[lo];
const info = firstByteInfo[hex] || { groupNodes: [], maxCollision: 0, collisionCount: 0 };
const nodeCount = info.groupNodes.length;
const maxCol = info.maxCollision;
// Classify worst overlap in group: confirmed collision (2+ repeaters) or possible (null-role involved)
const overlapping = Object.values(info.twoByteMap || {}).filter(v => v.length > 1);
const hasConfirmed = overlapping.some(ns => ns.filter(n => n.role === 'repeater').length >= 2);
const hasPossible = !hasConfirmed && overlapping.some(ns => ns.length >= 2);
let cellClass2, bgStyle2;
if (nodeCount === 0) { cellClass2 = 'hash-cell-empty'; bgStyle2 = ''; }
else if (maxCol === 0) { cellClass2 = 'hash-cell-taken'; bgStyle2 = ''; }
else if (hasPossible) { cellClass2 = 'hash-cell-possible'; bgStyle2 = ''; }
else { const t = Math.min((maxCol - 2) / 4, 1); bgStyle2 = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass2 = 'hash-cell-collision'; }
const nodeLabel2 = m => esc(m.name||m.public_key.slice(0,8)) + (!m.role ? ' (?)' : '');
const tip2 = nodeCount === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">No nodes in this group</div>`
: info.collisionCount === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions</div>`
: `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${hasConfirmed ? info.collisionCount + ' collision' + (info.collisionCount>1?'s':'') : 'Possible conflict'}</div><div class="hash-matrix-tooltip-nodes">${Object.entries(info.twoByteMap).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> — ${ns.map(nodeLabel2).join(', ')}</div>`).join('')}</div>`;
html += `<td class="hash-cell ${cellClass2}${nodeCount ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip2.replace(/"/g,'&quot;')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle2}border:1px solid var(--border);cursor:${nodeCount ? 'pointer' : 'default'};font-size:11px;font-weight:${maxCol > 0 ? '700' : '400'}">${hex}</td>`;
}
html += '</tr>';
}
html += '</table></div>';
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:420px;font-size:0.85em"></div></div>
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> No nodes in group</span>
<span><span class="legend-swatch hash-cell-taken"></span> Nodes present, no collision</span>
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
</div>`;
el.innerHTML = html;
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
const hex = td.dataset.hex.toUpperCase();
const info = firstByteInfo[hex];
const detail = document.getElementById('hashDetail');
if (!info || !info.groupNodes.length) { detail.innerHTML = ''; return; }
let dhtml = `<strong class="mono" style="font-size:1.1em">0x${hex}__</strong> — ${info.groupNodes.length} node${info.groupNodes.length !== 1 ? 's' : ''} in group`;
if (info.collisionCount === 0) {
dhtml += `<div class="text-muted" style="margin-top:6px;font-size:0.85em">✅ No 2-byte collisions in this group</div>`;
dhtml += `<div style="margin-top:8px">${info.groupNodes.map(m => {
const prefix = m.public_key.slice(0,4).toUpperCase();
return `<div style="padding:2px 0"><code class="mono" style="font-size:0.85em">${prefix}</code> <a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a></div>`;
}).join('')}</div>`;
} else {
dhtml += `<div style="margin-top:8px">`;
for (const [twoHex, nodes] of Object.entries(info.twoByteMap).sort()) {
const isCollision = nodes.length > 1;
dhtml += `<div style="margin-bottom:6px;padding:4px 6px;border-radius:4px;background:${isCollision ? 'rgba(220,50,30,0.1)' : 'transparent'};border:1px solid ${isCollision ? 'rgba(220,50,30,0.3)' : 'transparent'}">`;
dhtml += `<code class="mono" style="font-size:0.9em;font-weight:${isCollision?'700':'400'}">${twoHex}</code>${isCollision ? ' <span style="color:#dc2626;font-size:0.75em;font-weight:700">COLLISION</span>' : ''} `;
dhtml += nodes.map(m => `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link" style="font-size:0.85em">${esc(m.name || m.public_key.slice(0,12))}</a>`).join(', ');
dhtml += `</div>`;
}
dhtml += '</div>';
}
detail.innerHTML = dhtml;
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
});
});
initMatrixTooltip(el);
}
}
async function renderCollisions(topHops, allNodes) {
async function renderCollisions(topHops, allNodes, bytes) {
bytes = bytes || 1;
const el = document.getElementById('collisionList');
const oneByteHops = topHops.filter(h => h.size === 1);
if (!oneByteHops.length) { el.innerHTML = '<div class="text-muted">No 1-byte hops</div>'; return; }
const hopsForSize = topHops.filter(h => h.size === bytes);
// For 2-byte and 3-byte, scan nodes directly — topHops only reliably covers 1-byte path hops
const hopsToCheck = bytes === 1 ? hopsForSize : buildCollisionHops(allNodes, bytes);
if (!hopsToCheck.length && bytes === 1) {
el.innerHTML = `<div class="text-muted" style="padding:8px">No 1-byte hops observed in recent packets.</div>`;
return;
}
try {
const nodes = allNodes;
const collisions = [];
for (const hop of oneByteHops) {
for (const hop of hopsToCheck) {
const prefix = hop.hex.toLowerCase();
const matches = nodes.filter(n => n.public_key.toLowerCase().startsWith(prefix));
if (matches.length > 1) {
@@ -1145,14 +1436,27 @@
collisions.push({ hop: hop.hex, count: hop.count, matches, maxDistKm, classification, withCoords: withCoords.length });
}
}
if (!collisions.length) { el.innerHTML = '<div class="text-muted" style="padding:8px">No collisions detected</div>'; return; }
if (!collisions.length) {
const cleanMsg = bytes === 3
? '✅ No 3-byte prefix collisions detected — all nodes have unique 3-byte prefixes.'
: `✅ No ${bytes}-byte collisions detected`;
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
return;
}
// Sort: local first (most likely to collide), then regional, distant, incomplete
const classOrder = { local: 0, regional: 1, distant: 2, incomplete: 3, unknown: 4 };
collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
const showAppearances = bytes < 3;
el.innerHTML = `<table class="analytics-table">
<thead><tr><th scope="col">Hop</th><th scope="col">Appearances</th><th scope="col">Max Distance</th><th scope="col">Assessment</th><th scope="col">Colliding Nodes</th></tr></thead>
<thead><tr>
<th scope="col">Prefix</th>
${showAppearances ? '<th scope="col">Appearances</th>' : ''}
<th scope="col">Max Distance</th>
<th scope="col">Assessment</th>
<th scope="col">Colliding Nodes</th>
</tr></thead>
<tbody>${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
@@ -1171,12 +1475,12 @@
const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : '<span class="text-muted">—</span>';
return `<tr>
<td class="mono">${c.hop}</td>
<td>${c.count.toLocaleString()}</td>
${showAppearances ? `<td>${c.count.toLocaleString()}</td>` : ''}
<td>${distStr}</td>
<td title="${tooltip}">${badge}</td>
<td>${c.matches.map(m => {
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
: ' <span class="text-muted" style="font-size:0.75em">(no coords)</span>';
return `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a>${loc}`;
}).join('<br>')}</td>
@@ -1638,6 +1942,9 @@ function destroy() { _analyticsData = {}; _channelData = null; }
window._analyticsSaveChannelSort = saveChannelSort;
window._analyticsChannelTbodyHtml = channelTbodyHtml;
window._analyticsChannelTheadHtml = channelTheadHtml;
window._analyticsBuildOneBytePrefixMap = buildOneBytePrefixMap;
window._analyticsBuildTwoBytePrefixInfo = buildTwoBytePrefixInfo;
window._analyticsBuildCollisionHops = buildCollisionHops;
}
registerPage('analytics', { init, destroy });
+122 -7
View File
@@ -88,11 +88,116 @@ window.apiPerf = function() {
function timeAgo(iso) {
if (!iso) return '—';
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
const ms = new Date(iso).getTime();
if (!isFinite(ms)) return '—';
const s = Math.floor((Date.now() - ms) / 1000);
const abs = Math.abs(s);
let value;
let suffix;
if (abs < 60) { value = abs; suffix = 's'; }
else if (abs < 3600) { value = Math.floor(abs / 60); suffix = 'm'; }
else if (abs < 86400) { value = Math.floor(abs / 3600); suffix = 'h'; }
else { value = Math.floor(abs / 86400); suffix = 'd'; }
if (s < 0) return 'in ' + value + suffix;
return value + suffix + ' ago';
}
function getTimestampMode() {
const saved = localStorage.getItem('meshcore-timestamp-mode');
if (saved === 'ago' || saved === 'absolute') return saved;
const serverDefault = window.SITE_CONFIG?.timestamps?.defaultMode;
return serverDefault === 'absolute' ? 'absolute' : 'ago';
}
function getTimestampTimezone() {
const saved = localStorage.getItem('meshcore-timestamp-timezone');
if (saved === 'utc' || saved === 'local') return saved;
const serverDefault = window.SITE_CONFIG?.timestamps?.timezone;
return serverDefault === 'utc' ? 'utc' : 'local';
}
function getTimestampFormatPreset() {
const saved = localStorage.getItem('meshcore-timestamp-format');
if (saved === 'iso' || saved === 'iso-seconds' || saved === 'locale') return saved;
const serverDefault = window.SITE_CONFIG?.timestamps?.formatPreset;
return (serverDefault === 'iso' || serverDefault === 'iso-seconds' || serverDefault === 'locale') ? serverDefault : 'iso';
}
function getTimestampCustomFormat() {
if (window.SITE_CONFIG?.timestamps?.allowCustomFormat !== true) return '';
const saved = localStorage.getItem('meshcore-timestamp-custom-format');
if (saved != null) return String(saved);
const serverDefault = window.SITE_CONFIG?.timestamps?.customFormat;
return serverDefault == null ? '' : String(serverDefault);
}
function pad2(v) { return String(v).padStart(2, '0'); }
function pad3(v) { return String(v).padStart(3, '0'); }
function formatIsoLike(d, timezone, includeMs) {
const useUtc = timezone === 'utc';
const year = useUtc ? d.getUTCFullYear() : d.getFullYear();
const month = useUtc ? d.getUTCMonth() + 1 : d.getMonth() + 1;
const day = useUtc ? d.getUTCDate() : d.getDate();
const hour = useUtc ? d.getUTCHours() : d.getHours();
const minute = useUtc ? d.getUTCMinutes() : d.getMinutes();
const second = useUtc ? d.getUTCSeconds() : d.getSeconds();
const ms = useUtc ? d.getUTCMilliseconds() : d.getMilliseconds();
let out = year + '-' + pad2(month) + '-' + pad2(day) + ' ' + pad2(hour) + ':' + pad2(minute) + ':' + pad2(second);
if (includeMs) out += '.' + pad3(ms);
return out;
}
function formatTimestampCustom(d, formatString, timezone) {
if (!/YYYY|MM|DD|HH|mm|ss|SSS|Z/.test(String(formatString))) return '';
const useUtc = timezone === 'utc';
const replacements = {
YYYY: String(useUtc ? d.getUTCFullYear() : d.getFullYear()),
MM: pad2((useUtc ? d.getUTCMonth() : d.getMonth()) + 1),
DD: pad2(useUtc ? d.getUTCDate() : d.getDate()),
HH: pad2(useUtc ? d.getUTCHours() : d.getHours()),
mm: pad2(useUtc ? d.getUTCMinutes() : d.getMinutes()),
ss: pad2(useUtc ? d.getUTCSeconds() : d.getSeconds()),
SSS: pad3(useUtc ? d.getUTCMilliseconds() : d.getMilliseconds()),
Z: (timezone === 'utc' ? 'UTC' : 'local')
};
return String(formatString).replace(/YYYY|MM|DD|HH|mm|ss|SSS|Z/g, token => replacements[token] || token);
}
function formatAbsoluteTimestamp(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (!isFinite(d.getTime())) return '—';
const timezone = getTimestampTimezone();
const preset = getTimestampFormatPreset();
const customFormat = getTimestampCustomFormat().trim();
if (customFormat) {
const customOut = formatTimestampCustom(d, customFormat, timezone);
if (customOut && !/Invalid Date|NaN|undefined|null/.test(customOut)) return customOut;
}
if (preset === 'iso-seconds') return formatIsoLike(d, timezone, true);
if (preset === 'locale') {
if (timezone === 'utc') return d.toLocaleString([], { timeZone: 'UTC' });
return d.toLocaleString();
}
return formatIsoLike(d, timezone, false);
}
function formatTimestamp(isoString, mode) {
return formatTimestampWithTooltip(isoString, mode).text;
}
function formatTimestampWithTooltip(isoString, mode) {
if (!isoString) return { text: '—', tooltip: '—', isFuture: false };
const d = new Date(isoString);
if (!isFinite(d.getTime())) return { text: '—', tooltip: '—', isFuture: false };
const activeMode = mode === 'absolute' || mode === 'ago' ? mode : getTimestampMode();
const isFuture = d.getTime() > Date.now();
const absolute = formatAbsoluteTimestamp(isoString);
const relative = timeAgo(isoString);
const text = isFuture ? absolute : (activeMode === 'absolute' ? absolute : relative);
const tooltip = isFuture ? relative : (activeMode === 'absolute' ? relative : absolute);
return { text, tooltip, isFuture };
}
function truncate(str, len) {
@@ -347,6 +452,9 @@ window.addEventListener('theme-changed', () => {
window.dispatchEvent(new CustomEvent('theme-refresh'));
}, 300);
});
window.addEventListener('timestamp-mode-changed', () => {
window.dispatchEvent(new CustomEvent('theme-refresh'));
});
window.addEventListener('DOMContentLoaded', () => {
connectWS();
@@ -603,7 +711,14 @@ window.addEventListener('DOMContentLoaded', () => {
// --- Theme Customization ---
// Fetch theme config and apply branding/colors before first render
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
window.SITE_CONFIG = cfg;
window.SITE_CONFIG = cfg || {};
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
const tsCfg = window.SITE_CONFIG.timestamps;
if (tsCfg.defaultMode !== 'absolute' && tsCfg.defaultMode !== 'ago') tsCfg.defaultMode = 'ago';
if (tsCfg.timezone !== 'utc' && tsCfg.timezone !== 'local') tsCfg.timezone = 'local';
if (tsCfg.formatPreset !== 'iso' && tsCfg.formatPreset !== 'iso-seconds' && tsCfg.formatPreset !== 'locale') tsCfg.formatPreset = 'iso';
if (typeof tsCfg.customFormat !== 'string') tsCfg.customFormat = '';
tsCfg.allowCustomFormat = tsCfg.allowCustomFormat === true;
// User's localStorage preferences take priority over server config
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
@@ -677,7 +792,7 @@ window.addEventListener('DOMContentLoaded', () => {
if (favicon) favicon.href = cfg.branding.faviconUrl;
}
}
}).catch(() => { window.SITE_CONFIG = null; }).finally(() => {
}).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } }; }).finally(() => {
if (!location.hash || location.hash === '#/') location.hash = '#/home';
else navigate();
});
+60 -6
View File
@@ -9,8 +9,38 @@
let autoScroll = true;
let nodeCache = {};
let selectedNode = null;
let observerIataMap = {};
var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes
function getSelectedRegionsSnapshot() {
var rp = RegionFilter.getRegionParam();
return rp ? rp.split(',').filter(Boolean) : null;
}
function shouldProcessWSMessageForRegion(msg, selectedRegions, observerRegions) {
if (!selectedRegions || !selectedRegions.length) return true;
var observerId = msg?.data?.packet?.observer_id || msg?.data?.observer_id || null;
if (!observerId) return false;
var observerRegion = observerRegions[observerId];
if (!observerRegion) return false;
return selectedRegions.indexOf(observerRegion) !== -1;
}
async function loadObserverRegions() {
try {
var data = await api('/observers', { ttl: CLIENT_TTL.observers });
var list = data && data.observers ? data.observers : [];
var map = {};
for (var i = 0; i < list.length; i++) {
var o = list[i];
var id = o.id || o.observer_id;
if (!id || !o.iata) continue;
map[id] = o.iata;
}
observerIataMap = map;
} catch {}
}
async function lookupNode(name) {
var cached = nodeCache[name];
if (cached !== undefined) {
@@ -251,8 +281,14 @@
</div>`;
RegionFilter.init(document.getElementById('chRegionFilter'));
regionChangeHandler = RegionFilter.onChange(function () { loadChannels(); });
regionChangeHandler = RegionFilter.onChange(function () {
loadChannels(true).then(async function () {
if (!selectedHash) return;
await refreshMessages({ regionSwitch: true, forceNoCache: true });
});
});
loadObserverRegions();
loadChannels().then(() => {
if (routeParam) selectChannel(routeParam);
});
@@ -383,6 +419,7 @@
});
wsHandler = debouncedOnWS(function (msgs) {
var selectedRegions = getSelectedRegionsSnapshot();
var dominated = msgs.filter(function (m) {
return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT');
});
@@ -394,6 +431,7 @@
for (var i = 0; i < dominated.length; i++) {
var m = dominated[i];
if (!shouldProcessWSMessageForRegion(m, selectedRegions, observerIataMap)) continue;
var payload = m.data?.decoded?.payload;
if (!payload) continue;
@@ -593,23 +631,38 @@
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
try {
const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
const rp = RegionFilter.getRegionParam();
const regionQs = rp ? '&region=' + encodeURIComponent(rp) : '';
const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages });
messages = data.messages || [];
renderMessages();
scrollToBottom();
if (messages.length === 0 && rp) {
msgEl.innerHTML = '<div class="ch-empty">Channel not available in selected region</div>';
} else {
renderMessages();
scrollToBottom();
}
} catch (e) {
msgEl.innerHTML = `<div class="ch-empty">Failed to load messages: ${e.message}</div>`;
}
}
async function refreshMessages() {
async function refreshMessages(opts) {
if (!selectedHash) return;
opts = opts || {};
const msgEl = document.getElementById('chMessages');
if (!msgEl) return;
const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
try {
const data = await api(`/channels/${encodeURIComponent(selectedHash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
const rp = RegionFilter.getRegionParam();
const regionQs = rp ? '&region=' + encodeURIComponent(rp) : '';
const data = await api(`/channels/${encodeURIComponent(selectedHash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages, bust: !!opts.forceNoCache });
const newMsgs = data.messages || [];
if (opts.regionSwitch && rp && newMsgs.length === 0) {
messages = [];
msgEl.innerHTML = '<div class="ch-empty">Channel not available in selected region</div>';
document.getElementById('chScrollBtn')?.classList.add('hidden');
return;
}
// #92: Use message ID/hash for change detection instead of count + timestamp
var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; };
if (newMsgs.length === messages.length && _getLastId(newMsgs) === _getLastId(messages)) return;
@@ -665,5 +718,6 @@
if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); }
}
window._channelsShouldProcessWSMessageForRegion = shouldProcessWSMessageForRegion;
registerPage('channels', { init, destroy });
})();
+1461 -1334
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
// Shared helper — initialises the geo-filter polygon overlay on a Leaflet map.
// Returns the L.layerGroup (or null if no filter is configured / fetch fails).
// The returned layer is added to the map when the checkbox is toggled on, and
// removed when toggled off. The toggle state is persisted in localStorage
// under the key 'meshcore-map-geo-filter'.
//
// Parameters:
// map Leaflet map instance
// checkboxId id of the <input type="checkbox"> that controls visibility
// labelId id of the <label> wrapper to reveal once data is loaded
async function initGeoFilterOverlay(map, checkboxId, labelId) {
try {
const gf = await api('/config/geo-filter', { ttl: 3600 });
if (!gf || !gf.polygon || gf.polygon.length < 3) return null;
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
const innerPoly = L.polygon(latlngs, {
color: '#3b82f6', weight: 2, opacity: 0.8,
fillColor: '#3b82f6', fillOpacity: 0.08
});
const bufferPoly = gf.bufferKm > 0 ? (function () {
let cLat = 0, cLon = 0;
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
const cosLat = Math.cos(cLat * Math.PI / 180);
const outer = gf.polygon.map(function (p) {
const dLatM = (p[0] - cLat) * 111000;
const dLonM = (p[1] - cLon) * 111000 * cosLat;
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
if (dist === 0) return [p[0], p[1]];
const scale = (gf.bufferKm * 1000) / dist;
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
});
return L.polygon(outer, {
color: '#3b82f6', weight: 1.5, opacity: 0.4, dashArray: '6 4',
fillColor: '#3b82f6', fillOpacity: 0.04
});
})() : null;
const layer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
const label = document.getElementById(labelId);
if (label) label.style.display = '';
const el = document.getElementById(checkboxId);
if (el) {
const saved = localStorage.getItem('meshcore-map-geo-filter');
if (saved === 'true') { el.checked = true; layer.addTo(map); }
el.addEventListener('change', function (e) {
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
if (e.target.checked) { layer.addTo(map); } else { map.removeLayer(layer); }
});
}
return layer;
} catch (e) { return null; }
}
+29 -27
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774786038">
<link rel="stylesheet" href="home.css?v=1774786038">
<link rel="stylesheet" href="live.css?v=1774786038">
<link rel="stylesheet" href="style.css?v=1774925610">
<link rel="stylesheet" href="home.css?v=1774925610">
<link rel="stylesheet" href="live.css?v=1774925610">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,29 +81,31 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774786038"></script>
<script src="customize.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774786038"></script>
<script src="hop-resolver.js?v=1774786038"></script>
<script src="hop-display.js?v=1774786038"></script>
<script src="app.js?v=1774786038"></script>
<script src="home.js?v=1774786038"></script>
<script src="packet-filter.js?v=1774786038"></script>
<script src="packets.js?v=1774786038"></script>
<script src="map.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774786039" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774786039" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774925610"></script>
<script src="customize.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774925610"></script>
<script src="hop-resolver.js?v=1774925610"></script>
<script src="hop-display.js?v=1774925610"></script>
<script src="app.js?v=1774925610"></script>
<script src="home.js?v=1774925610"></script>
<script src="packet-filter.js?v=1774925610"></script>
<script src="packets.js?v=1774925610"></script>
<script src="geo-filter-overlay.js?v=1774925610"></script>
<script src="map.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+32 -8
View File
@@ -5,7 +5,7 @@
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer;
let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer, geoFilterLayer;
let nodeMarkers = {};
let nodeData = {};
let packetCount = 0;
@@ -25,6 +25,7 @@
let _lcdClockInterval = null;
let _rateCounterInterval = null;
let _pruneInterval = null;
let activeNodeDetailKey = null;
// === VCR State Machine ===
const VCR = {
@@ -51,6 +52,19 @@
REQUEST: '❓', RESPONSE: '📨', TRACE: '🔍', PATH: '🛤️'
};
function formatLiveTimestampHtml(isoLike) {
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoLike) : '—');
}
const d = isoLike ? new Date(isoLike) : null;
const iso = d && isFinite(d.getTime()) ? d.toISOString() : null;
const f = formatTimestampWithTooltip(iso, getTimestampMode());
const warn = f.isFuture
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
: '';
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
}
function initResizeHandler() {
let resizeTimer = null;
_onResize = function() {
@@ -658,6 +672,7 @@
<span id="audioDesc" class="sr-only">Sonify packets turn raw bytes into generative music</span>
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> Favorites</label>
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
<label id="liveGeoFilterLabel" style="display:none"><input type="checkbox" id="liveGeoFilterToggle"> Mesh live area</label>
</div>
<div class="audio-controls hidden" id="audioControls">
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
@@ -801,6 +816,9 @@
applyFavoritesFilter();
});
// Geo filter overlay
initGeoFilterOverlay(map, 'liveGeoFilterToggle', 'liveGeoFilterLabel').then(function (layer) { geoFilterLayer = layer; });
const matrixToggle = document.getElementById('liveMatrixToggle');
matrixToggle.checked = matrixMode;
matrixToggle.addEventListener('change', (e) => {
@@ -931,6 +949,7 @@
const nodeDetailPanel = document.getElementById('liveNodeDetail');
const nodeDetailContent = document.getElementById('nodeDetailContent');
document.getElementById('nodeDetailClose').addEventListener('click', () => {
activeNodeDetailKey = null;
nodeDetailPanel.classList.add('hidden');
});
@@ -1155,6 +1174,7 @@
}
async function showNodeDetail(pubkey) {
activeNodeDetailKey = pubkey;
const panel = document.getElementById('liveNodeDetail');
const content = document.getElementById('nodeDetailContent');
panel.classList.remove('hidden');
@@ -1172,7 +1192,7 @@
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
const roleLabel = (ROLE_LABELS[n.role] || n.role || 'unknown').replace(/s$/, '');
const hasLoc = n.lat != null && n.lon != null;
const lastSeen = n.last_seen ? timeAgo(n.last_seen) : '—';
const lastSeen = formatLiveTimestampHtml(n.last_seen);
const thresholds = window.getHealthThresholds ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 };
const ageMs = n.last_seen ? Date.now() - new Date(n.last_seen).getTime() : Infinity;
const statusDot = ageMs < thresholds.degradedMs ? 'health-green' : ageMs < thresholds.silentMs ? 'health-yellow' : 'health-red';
@@ -1213,7 +1233,7 @@
<div style="font-size:11px;max-height:200px;overflow-y:auto;">` +
recent.slice(0, 10).map(p => `<div style="padding:2px 0;display:flex;justify-content:space-between;">
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
<span style="color:var(--text-muted)">${p.timestamp ? timeAgo(p.timestamp) : '—'}</span>
<span style="color:var(--text-muted)">${formatLiveTimestampHtml(p.timestamp)}</span>
</div>`).join('') +
'</div>';
}
@@ -1399,7 +1419,7 @@
<span class="feed-type" style="color:${color}">${typeName}</span>
${hopStr}${obsBadge}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${new Date(group.latestTs || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
<span class="feed-time">${formatLiveTimestampHtml(group.latestTs || Date.now())}</span>
`;
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.appendChild(item);
@@ -2263,7 +2283,7 @@
<span class="feed-type" style="color:${color}">${typeName}</span>
${hopStr}${obsBadge}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
`;
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.appendChild(item);
@@ -2331,7 +2351,7 @@
<span class="feed-type" style="color:${color}">${typeName}</span>
${hopStr}${obsBadge}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
`;
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.prepend(item);
@@ -2431,9 +2451,10 @@
}
_navCleanup = null;
}
nodesLayer = pathsLayer = animLayer = heatLayer = null;
nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = null;
stopMatrixRain();
nodeMarkers = {}; nodeData = {};
activeNodeDetailKey = null;
recentPaths = [];
packetCount = 0; activeAnims = 0;
nodeActivity = {}; pktTimestamps = [];
@@ -2445,7 +2466,10 @@
registerPage('live', {
init: function(app, routeParam) {
_themeRefreshHandler = () => { /* live map rebuilds on next packet */ };
_themeRefreshHandler = () => {
rebuildFeedList();
if (activeNodeDetailKey) showNodeDetail(activeNodeDetailKey);
};
window.addEventListener('theme-refresh', _themeRefreshHandler);
return init(app, routeParam);
},
+6
View File
@@ -12,6 +12,7 @@
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all' };
let wsHandler = null;
let heatLayer = null;
let geoFilterLayer = null;
let userHasMoved = false;
let controlsCollapsed = false;
@@ -94,6 +95,7 @@
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
<label for="mcHashLabels"><input type="checkbox" id="mcHashLabels"> Hash prefix labels</label>
<label for="mcGeoFilter" id="mcGeoFilterLabel" style="display:none"><input type="checkbox" id="mcGeoFilter"> Geo filter area</label>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Status</legend>
@@ -225,6 +227,9 @@
});
});
// Geo filter overlay
initGeoFilterOverlay(map, 'mcGeoFilter', 'mcGeoFilterLabel').then(function (layer) { geoFilterLayer = layer; });
// WS for live advert updates
wsHandler = debouncedOnWS(function (msgs) {
if (msgs.some(function (m) { return m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'ADVERT'; })) {
@@ -727,6 +732,7 @@
markerLayer = null;
routeLayer = null;
if (heatLayer) { heatLayer = null; }
geoFilterLayer = null;
}
function toggleHeatmap(on) {
+48 -12
View File
@@ -85,6 +85,24 @@
{ key: 'sensor', label: 'Sensors' },
];
function renderNodeTimestampHtml(isoString) {
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
}
const f = formatTimestampWithTooltip(isoString, getTimestampMode());
const warn = f.isFuture
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
: '';
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
}
function renderNodeTimestampText(isoString) {
if (typeof formatTimestamp !== 'function' || typeof getTimestampMode !== 'function') {
return typeof timeAgo === 'function' ? timeAgo(isoString) : '—';
}
return formatTimestamp(isoString, getTimestampMode());
}
/* === Shared helper functions for node detail rendering === */
function getStatusTooltip(role, status) {
@@ -117,7 +135,7 @@
let explanation = '';
if (status === 'active') {
explanation = 'Last heard ' + (lastHeardTime ? timeAgo(lastHeardTime) : 'unknown');
explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown');
} else {
const ageDays = Math.floor(statusAge / 86400000);
const ageHours = Math.floor(statusAge / 3600000);
@@ -274,8 +292,8 @@
<table class="node-stats-table" id="node-stats">
<tr><td>Status</td><td><span title="${si.statusTooltip}">${statusLabel}</span> <span style="font-size:11px;color:var(--text-muted);margin-left:4px">${statusExplanation}</span></td></tr>
<tr><td>Last Heard</td><td>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '')}</td></tr>
<tr><td>First Seen</td><td>${n.first_seen ? new Date(n.first_seen).toLocaleString() : ''}</td></tr>
<tr><td>Last Heard</td><td>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</td></tr>
<tr><td>First Seen</td><td>${renderNodeTimestampHtml(n.first_seen)}</td></tr>
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
<tr><td>Packets Today</td><td>${stats.packetsToday || 0}</td></tr>
${stats.avgSnr != null ? `<tr><td>Avg SNR</td><td>${Number(stats.avgSnr).toFixed(1)} dB</td></tr>` : ''}
@@ -327,7 +345,7 @@
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
}
return `<div class="node-activity-item">
<span class="node-activity-time">${timeAgo(p.timestamp)}</span>
<span class="node-activity-time">${renderNodeTimestampHtml(p.timestamp)}</span>
<span>${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
<a href="#/packets/${p.hash}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze </a>
</div>`;
@@ -406,7 +424,7 @@
}).join(' → ');
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
<div>${chain}</div>
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze </a></div>
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${renderNodeTimestampHtml(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze </a></div>
</div>`;
}).join('');
}
@@ -429,7 +447,7 @@
}
}
function destroy() {
function destroy() {
if (wsHandler) offWS(wsHandler);
wsHandler = null;
if (detailMap) { detailMap.remove(); detailMap = null; }
@@ -439,6 +457,8 @@
selectedKey = null;
}
let _themeRefreshHandler = null;
let _allNodes = null; // cached full node list
// Build a map of lowercased name → count of distinct pubkeys sharing that name
@@ -677,7 +697,7 @@
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh">★</span> ' : ''}<strong>${n.name || '(unnamed)'}</strong>${dupNameBadge(n.name, n.public_key, dupMap)}</td>
<td class="mono col-pubkey">${truncate(n.public_key, 16)}</td>
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
<td class="${lastSeenClass}">${timeAgo(n.last_heard || n.last_seen)}</td>
<td class="${lastSeenClass}">${renderNodeTimestampHtml(n.last_heard || n.last_seen)}</td>
<td>${n.advert_count || 0}</td>
</tr>`;
}).join('');
@@ -750,8 +770,8 @@
<div class="node-detail-section">
<h4>Overview</h4>
<dl class="detail-meta">
<dt>Last Heard</dt><dd>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '')}</dd>
<dt>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : ''}</dd>
<dt>Last Heard</dt><dd>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</dd>
<dt>First Seen</dt><dd>${renderNodeTimestampHtml(n.first_seen)}</dd>
<dt>Total Packets</dt><dd>${totalPackets}</dd>
<dt>Packets Today</dt><dd>${stats.packetsToday || 0}</dd>
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${Number(stats.avgSnr).toFixed(1)} dB</dd>` : ''}
@@ -789,7 +809,7 @@
return `<div class="advert-entry">
<span class="advert-dot" style="background:${roleColor}"></span>
<div class="advert-info">
<strong>${timeAgo(a.timestamp)}</strong> ${icon} ${pType}${detail}
<strong>${renderNodeTimestampHtml(a.timestamp)}</strong> ${icon} ${pType}${detail}
${a.observation_count > 1 ? ' <span class="badge badge-obs">👁 ' + a.observation_count + '</span>' : ''}
${obs ? ' via ' + escapeHtml(obs) : ''}
${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''}
@@ -863,7 +883,7 @@
}).join(' → ');
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
<div>${chain}</div>
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze </a></div>
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${renderNodeTimestampHtml(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze </a></div>
</div>`;
}).join('');
}
@@ -889,7 +909,23 @@
return false;
}
registerPage('nodes', { init, destroy });
registerPage('nodes', {
init: function(app, routeParam) {
_themeRefreshHandler = () => {
if (directNode) loadFullNode(directNode);
else {
renderRows();
if (selectedKey) selectNode(selectedKey);
}
};
window.addEventListener('theme-refresh', _themeRefreshHandler);
return init(app, routeParam);
},
destroy: function() {
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
return destroy();
}
});
// Test hooks
window._nodesIsAdvertMessage = isAdvertMessage;
+46 -10
View File
@@ -157,9 +157,40 @@
let directPacketId = null;
let directPacketHash = null;
let initGeneration = 0;
let _docActionHandler = null;
let _docMenuCloseHandler = null;
let _docColMenuCloseHandler = null;
let directObsId = null;
function removeAllByopOverlays() {
document.querySelectorAll('.byop-overlay').forEach(function (el) { el.remove(); });
}
function bindDocumentHandler(kind, eventName, handler) {
const prev = kind === 'action'
? _docActionHandler
: kind === 'menu'
? _docMenuCloseHandler
: _docColMenuCloseHandler;
if (prev) document.removeEventListener(eventName, prev);
document.addEventListener(eventName, handler);
if (kind === 'action') _docActionHandler = handler;
else if (kind === 'menu') _docMenuCloseHandler = handler;
else _docColMenuCloseHandler = handler;
}
function renderTimestampCell(isoString) {
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
}
const f = formatTimestampWithTooltip(isoString, getTimestampMode());
const warn = f.isFuture
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
: '';
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
}
async function init(app, routeParam) {
const gen = ++initGeneration;
// Parse ?obs=OBSERVER_ID from routeParam
@@ -226,7 +257,7 @@
}
// Event delegation for data-action buttons
document.addEventListener('click', function (e) {
bindDocumentHandler('action', 'click', function (e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
if (btn.dataset.action === 'pkt-refresh') loadPackets();
@@ -365,6 +396,10 @@
function destroy() {
if (wsHandler) offWS(wsHandler);
wsHandler = null;
if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; }
if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; }
if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; }
removeAllByopOverlays();
packets = [];
hashIndex = new Map(); selectedId = null;
filtersBuilt = false;
@@ -694,7 +729,7 @@
});
// Close multi-select menus on outside click
document.addEventListener('click', (e) => {
bindDocumentHandler('menu', 'click', (e) => {
const obsWrap = document.getElementById('observerFilterWrap');
const typeWrap = document.getElementById('typeFilterWrap');
if (obsWrap && !obsWrap.contains(e.target)) { const m = obsWrap.querySelector('.multi-select-menu'); if (m) m.classList.remove('open'); }
@@ -809,7 +844,7 @@
e.stopPropagation();
colMenu.classList.toggle('open');
});
document.addEventListener('click', () => colMenu.classList.remove('open'));
bindDocumentHandler('colmenu', 'click', () => colMenu.classList.remove('open'));
applyColVisibility();
document.getElementById('hexHashToggle').addEventListener('click', function () {
@@ -1024,7 +1059,7 @@
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
<td class="col-time">${timeAgo(p.latest)}</td>
<td class="col-time">${renderTimestampCell(p.latest)}</td>
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
@@ -1051,7 +1086,7 @@
const childPathStr = renderPath(childPath, c.observer_id);
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : ''}</td>
<td class="col-time">${timeAgo(c.timestamp)}</td>
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
<td class="col-size">${size}B</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
@@ -1080,7 +1115,7 @@
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : ''}</td>
<td class="col-time">${timeAgo(p.timestamp)}</td>
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
<td class="col-size">${size}B</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
@@ -1330,7 +1365,7 @@
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
<dt>Timestamp</dt><dd>${pkt.timestamp}</dd>
<dt>Timestamp</dt><dd>${renderTimestampCell(pkt.timestamp)}</dd>
<dt>Propagation</dt><dd>${propagationHtml}</dd>
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops, pkt.observer_id) : ''}</dd>
</dl>
@@ -1537,9 +1572,10 @@
// BYOP modal — decode only, no DB injection
function showBYOP() {
removeAllByopOverlays();
const triggerBtn = document.querySelector('[data-action="pkt-byop"]');
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.className = 'modal-overlay byop-overlay';
overlay.innerHTML = '<div class="modal byop-modal" role="dialog" aria-label="Decode a Packet" aria-modal="true">'
+ '<div class="byop-header"><h3>📦 Decode a Packet</h3><button class="btn-icon byop-x" title="Close" aria-label="Close dialog">✕</button></div>'
+ '<p class="text-muted" style="margin:0 0 12px;font-size:.85rem">Paste raw hex bytes from your radio or MQTT feed:</p>'
@@ -1550,7 +1586,7 @@
document.body.appendChild(overlay);
const modal = overlay.querySelector('.byop-modal');
const close = () => { overlay.remove(); if (triggerBtn) triggerBtn.focus(); };
const close = () => { removeAllByopOverlays(); if (triggerBtn) triggerBtn.focus(); };
overlay.querySelector('.byop-x').onclick = close;
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
@@ -1586,7 +1622,7 @@
async function doDecode() {
const hex = textarea.value.trim().replace(/[\s\n]/g, '');
const result = document.getElementById('byopResult');
const result = overlay.querySelector('#byopResult');
if (!hex) { result.innerHTML = '<p class="text-muted">Enter hex data</p>'; return; }
if (!/^[0-9a-fA-F]+$/.test(hex)) { result.innerHTML = '<p class="byop-err" role="alert">Invalid hex — only 0-9 and A-F allowed</p>'; return; }
result.innerHTML = '<p class="text-muted">Decoding...</p>';
+19
View File
@@ -280,6 +280,8 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
}
.data-table td.col-details { white-space: normal; word-break: break-word; }
.data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; }
.data-table .col-time { min-width: 108px; white-space: nowrap; }
.timestamp-future-icon { margin-left: 4px; cursor: help; }
.data-table tbody tr:nth-child(even) { background: var(--row-stripe); }
.data-table tbody tr:hover { background: var(--row-hover); cursor: pointer; }
.data-table tbody tr.selected { background: var(--selected-bg); }
@@ -879,6 +881,7 @@ button.ch-item.selected { background: var(--selected-bg); }
.data-table { font-size: 11px; min-width: 0; }
.data-table td { padding: 5px 4px; max-width: 100px; }
.data-table th { padding: 5px 4px; font-size: 10px; }
.data-table .col-time { min-width: 64px; }
.panel-left { overflow-x: auto; }
/* Filters: collapse on mobile */
@@ -1096,6 +1099,22 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.hash-bar-fill { height: 100%; border-radius: 4px; transition: width .3s; }
.hash-cell.hash-active:hover { outline: 2px solid var(--accent); outline-offset: -2px; }
.hash-cell.hash-selected { outline: 2px solid var(--accent); outline-offset: -2px; box-shadow: 0 0 6px var(--accent); }
.hash-cell-empty { background: var(--card-bg); color: var(--text-muted); }
.hash-cell-taken { background: var(--status-green); color: #fff; }
.hash-cell-possible { background: var(--status-yellow); color: #fff; }
.hash-cell-collision { color: #fff; }
.hash-matrix-tooltip {
position: fixed; z-index: 9999; background: var(--surface-1); border: 1px solid var(--border);
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); padding: 8px 12px;
font-size: 12px; min-width: 160px; max-width: 260px; pointer-events: none;
}
.hash-matrix-tooltip-hex { font-family: var(--mono); font-size: 13px; font-weight: 700; margin-bottom: 4px; color: var(--accent); }
.hash-matrix-tooltip-status { color: var(--text-muted); font-size: 11px; }
.hash-matrix-tooltip-nodes { margin-top: 6px; display: flex; flex-direction: column; gap: 2px; }
.hash-byte-selector { display: flex; gap: 4px; }
.hash-byte-btn { padding: 4px 12px; border-radius: 20px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted); font-size: 12px; font-weight: 600; cursor: pointer; transition: background .15s, color .15s; }
.hash-byte-btn:hover { background: var(--border); color: var(--text); }
.hash-byte-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.hash-bar-value { min-width: 120px; text-align: right; font-size: 13px; font-weight: 600; }
.badge-hash-1 { background: #ef444420; color: var(--status-red); }
.badge-hash-2 { background: #22c55e20; color: var(--status-green); }
+392 -21
View File
@@ -104,6 +104,75 @@ console.log('\n=== app.js: timeAgo ===');
const d = new Date(Date.now() - 259200000).toISOString();
assert.strictEqual(timeAgo(d), '3d ago');
});
test('future timestamp returns in-format', () => {
const d = new Date(Date.now() + 120000).toISOString();
assert.strictEqual(timeAgo(d), 'in 2m');
});
}
console.log('\n=== app.js: formatTimestamp / formatTimestampWithTooltip ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
const formatTimestamp = ctx.formatTimestamp;
const formatTimestampWithTooltip = ctx.formatTimestampWithTooltip;
test('formatTimestamp null returns dash', () => {
assert.strictEqual(formatTimestamp(null, 'ago'), '—');
});
test('formatTimestamp ago returns relative string', () => {
const d = new Date(Date.now() - 300000).toISOString();
assert.strictEqual(formatTimestamp(d, 'ago'), '5m ago');
});
test('formatTimestamp absolute returns formatted timestamp', () => {
const d = '2024-01-02T03:04:05.000Z';
const out = formatTimestamp(d, 'absolute');
assert.ok(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(out));
});
test('formatTimestamp absolute with timezone utc uses UTC fields', () => {
const d = '2024-01-02T03:04:05.123Z';
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso');
assert.strictEqual(formatTimestamp(d, 'absolute'), '2024-01-02 03:04:05');
});
test('formatTimestamp absolute with timezone local uses local fields', () => {
const d = '2024-01-02T03:04:05.123Z';
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'local');
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso');
const out = formatTimestamp(d, 'absolute');
const expected = d.replace('T', ' ').slice(0, 19);
assert.strictEqual(out.length, 19);
assert.ok(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(out));
if (new Date(d).getTimezoneOffset() === 0) assert.strictEqual(out, expected);
else assert.notStrictEqual(out, expected);
});
test('formatTimestamp absolute iso-seconds includes milliseconds', () => {
const d = '2024-01-02T03:04:05.123Z';
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso-seconds');
assert.strictEqual(formatTimestamp(d, 'absolute'), '2024-01-02 03:04:05.123');
});
test('formatTimestamp absolute locale uses toLocaleString', () => {
const d = '2024-01-02T03:04:05.123Z';
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'local');
ctx.localStorage.setItem('meshcore-timestamp-format', 'locale');
assert.strictEqual(formatTimestamp(d, 'absolute'), new Date(d).toLocaleString());
});
test('formatTimestampWithTooltip future returns isFuture true', () => {
const d = new Date(Date.now() + 120000).toISOString();
const out = formatTimestampWithTooltip(d, 'ago');
assert.strictEqual(out.isFuture, true);
assert.ok(typeof out.text === 'string' && out.text.length > 0);
assert.strictEqual(out.tooltip, 'in 2m');
});
test('tooltip is opposite format', () => {
const d = '2024-01-02T03:04:05.000Z';
const ago = formatTimestampWithTooltip(d, 'ago');
const absolute = formatTimestampWithTooltip(d, 'absolute');
assert.ok(typeof ago.tooltip === 'string' && ago.tooltip.length > 0);
assert.ok(absolute.tooltip.endsWith('ago') || absolute.tooltip.startsWith('in '));
});
}
console.log('\n=== app.js: escapeHtml ===');
@@ -151,27 +220,7 @@ console.log('\n=== app.js: truncate ===');
// ===== NODES.JS TESTS =====
console.log('\n=== nodes.js: getStatusInfo ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
// nodes.js is an IIFE that registers a page — we need to mock registerPage and other globals
ctx.registerPage = () => {};
ctx.api = () => Promise.resolve([]);
ctx.timeAgo = vm.runInContext(`(${fs.readFileSync('public/app.js', 'utf8').match(/function timeAgo[^}]+}/)[0]})`, ctx);
// Actually, let's load app.js first for its globals
loadInCtx(ctx, 'public/app.js');
ctx.RegionFilter = { init: () => {}, getSelected: () => null, onRegionChange: () => {} };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.invalidateApiCache = () => {};
ctx.favStar = () => '';
ctx.bindFavStars = () => {};
ctx.getFavorites = () => [];
ctx.isFavorite = () => false;
ctx.connectWS = () => {};
loadInCtx(ctx, 'public/nodes.js');
// getStatusInfo is inside the IIFE, not on window. We need to extract it differently.
// Let's use a modified approach - inject a hook before loading
// Placeholder header for continuity; actual nodes tests are below using injected exports.
}
// Since nodes.js functions are inside an IIFE, we need to extract them.
@@ -1269,6 +1318,29 @@ console.log('\n=== compare.js: comparePacketSets ===');
assert.ok(packetsSource.includes("classList.remove('detail-collapsed')"),
'selectPacket should remove detail-collapsed class');
});
test('BYOP uses dedicated overlay class and clears existing overlays before opening', () => {
assert.ok(packetsSource.includes("overlay.className = 'modal-overlay byop-overlay'"),
'BYOP overlay should have byop-overlay class');
assert.ok(/function showBYOP\(\)\s*\{\s*removeAllByopOverlays\(\);/m.test(packetsSource),
'showBYOP should clear existing overlays before creating a new one');
});
test('BYOP close removes all overlays in one click', () => {
assert.ok(packetsSource.includes("const close = () => { removeAllByopOverlays(); if (triggerBtn) triggerBtn.focus(); };"),
'close handler should remove all BYOP overlays');
});
test('packets page de-duplicates document click handlers', () => {
assert.ok(packetsSource.includes("bindDocumentHandler('action', 'click'"),
'action click handler should be bound through bindDocumentHandler');
assert.ok(packetsSource.includes("bindDocumentHandler('menu', 'click'"),
'menu close handler should be bound through bindDocumentHandler');
assert.ok(packetsSource.includes("bindDocumentHandler('colmenu', 'click'"),
'column menu close handler should be bound through bindDocumentHandler');
assert.ok(packetsSource.includes("if (prev) document.removeEventListener(eventName, prev);"),
'bindDocumentHandler should remove previous handler before re-binding');
});
}
// ===== APP.JS: formatEngineBadge =====
@@ -1602,6 +1674,305 @@ console.log('\n=== analytics.js: sortChannels ===');
});
}
// === analytics.js: hash prefix helpers ===
console.log('\n=== analytics.js: hash prefix helpers ===');
{
const ctx = (() => {
const c = makeSandbox();
c.getComputedStyle = () => ({ getPropertyValue: () => '' });
c.registerPage = () => {};
c.api = () => Promise.resolve({});
c.timeAgo = () => '—';
c.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' };
c.onWS = () => {};
c.offWS = () => {};
c.connectWS = () => {};
c.invalidateApiCache = () => {};
c.makeColumnsResizable = () => {};
c.initTabBar = () => {};
c.IATA_COORDS_GEO = {};
loadInCtx(c, 'public/roles.js');
loadInCtx(c, 'public/app.js');
try { loadInCtx(c, 'public/analytics.js'); } catch (e) {
for (const k of Object.keys(c.window)) c[k] = c.window[k];
}
return c;
})();
const buildOne = ctx.window._analyticsBuildOneBytePrefixMap;
const buildTwo = ctx.window._analyticsBuildTwoBytePrefixInfo;
const buildHops = ctx.window._analyticsBuildCollisionHops;
const node = (pk, extra) => ({ public_key: pk, name: pk.slice(0, 4), ...(extra || {}) });
test('buildOneBytePrefixMap exports exist', () => assert.ok(buildOne, 'must be exported'));
test('buildTwoBytePrefixInfo exports exist', () => assert.ok(buildTwo, 'must be exported'));
test('buildCollisionHops exports exist', () => assert.ok(buildHops, 'must be exported'));
// --- 1-byte prefix map ---
test('1-byte map has 256 keys', () => {
const m = buildOne([]);
assert.strictEqual(Object.keys(m).length, 256);
});
test('1-byte map places node in correct bucket', () => {
const n = node('AABBCC');
const m = buildOne([n]);
assert.strictEqual(m['AA'].length, 1);
assert.strictEqual(m['AA'][0].public_key, 'AABBCC');
assert.strictEqual(m['BB'].length, 0);
});
test('1-byte map groups two nodes with same prefix', () => {
const a = node('AA1111'), b = node('AA2222');
const m = buildOne([a, b]);
assert.strictEqual(m['AA'].length, 2);
});
test('1-byte map is case-insensitive for node keys', () => {
const n = node('aabbcc');
const m = buildOne([n]);
assert.strictEqual(m['AA'].length, 1);
});
test('1-byte map: empty input yields all empty buckets', () => {
const m = buildOne([]);
assert.ok(Object.values(m).every(v => v.length === 0));
});
// --- 2-byte prefix info ---
test('2-byte info has 256 first-byte keys', () => {
const info = buildTwo([]);
assert.strictEqual(Object.keys(info).length, 256);
});
test('2-byte info: no nodes → zero collisions', () => {
const info = buildTwo([]);
assert.ok(Object.values(info).every(e => e.collisionCount === 0));
});
test('2-byte info: node placed in correct first-byte group', () => {
const n = node('AABB1122');
const info = buildTwo([n]);
assert.strictEqual(info['AA'].groupNodes.length, 1);
assert.strictEqual(info['BB'].groupNodes.length, 0);
});
test('2-byte info: same 2-byte prefix = collision', () => {
const a = node('AABB0001'), b = node('AABB0002');
const info = buildTwo([a, b]);
assert.strictEqual(info['AA'].collisionCount, 1);
assert.strictEqual(info['AA'].maxCollision, 2);
});
test('2-byte info: different 2-byte prefixes in same group = no collision', () => {
const a = node('AA110001'), b = node('AA220002');
const info = buildTwo([a, b]);
assert.strictEqual(info['AA'].collisionCount, 0);
assert.strictEqual(info['AA'].maxCollision, 0);
});
test('2-byte info: twoByteMap built correctly', () => {
const a = node('AABB0001'), b = node('AABB0002'), c = node('AACC0003');
const info = buildTwo([a, b, c]);
assert.strictEqual(Object.keys(info['AA'].twoByteMap).length, 2);
assert.strictEqual(info['AA'].twoByteMap['AABB'].length, 2);
assert.strictEqual(info['AA'].twoByteMap['AACC'].length, 1);
});
// --- 3-byte stat summary (via buildCollisionHops) ---
test('buildCollisionHops: no collisions returns empty array', () => {
const nodes = [node('AA000001'), node('BB000002'), node('CC000003')];
assert.deepStrictEqual(buildHops(nodes, 1), []);
});
test('buildCollisionHops: detects 1-byte collision', () => {
const nodes = [node('AA000001'), node('AA000002')];
const hops = buildHops(nodes, 1);
assert.strictEqual(hops.length, 1);
assert.strictEqual(hops[0].hex, 'AA');
assert.strictEqual(hops[0].count, 2);
});
test('buildCollisionHops: detects 2-byte collision', () => {
const nodes = [node('AABB0001'), node('AABB0002'), node('AACC0003')];
const hops = buildHops(nodes, 2);
assert.strictEqual(hops.length, 1);
assert.strictEqual(hops[0].hex, 'AABB');
assert.strictEqual(hops[0].count, 2);
});
test('buildCollisionHops: detects 3-byte collision', () => {
const nodes = [node('AABBCC0001'), node('AABBCC0002')];
const hops = buildHops(nodes, 3);
assert.strictEqual(hops.length, 1);
assert.strictEqual(hops[0].hex, 'AABBCC');
});
test('buildCollisionHops: size field set correctly', () => {
const nodes = [node('AABB0001'), node('AABB0002')];
const hops = buildHops(nodes, 2);
assert.strictEqual(hops[0].size, 2);
});
test('buildCollisionHops: empty input returns empty array', () => {
assert.deepStrictEqual(buildHops([], 1), []);
assert.deepStrictEqual(buildHops([], 2), []);
assert.deepStrictEqual(buildHops([], 3), []);
});
}
// ===== CUSTOMIZE.JS: initState merge behavior =====
console.log('\n=== customize.js: initState merge behavior ===');
{
function loadCustomizeExports(ctx) {
const src = fs.readFileSync('public/customize.js', 'utf8');
const withExports = src.replace(
/\}\)\(\);\s*$/,
'window.__customizeExport = { initState: initState, getState: function () { return state; }, getDefaults: function () { return deepClone(DEFAULTS); } };})();'
);
vm.runInContext(withExports, ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
return ctx.window.__customizeExport;
}
test('partial local checklist does not wipe steps/footerLinks and keeps server colors', () => {
const ctx = makeSandbox();
ctx.window.SITE_CONFIG = {
home: {
heroTitle: 'Server Hero',
heroSubtitle: 'Server Subtitle',
steps: [{ emoji: '🧪', title: 'Server Step', description: 'from server' }],
checklist: [{ question: 'Server Q', answer: 'Server A' }],
footerLinks: [{ label: 'Server Link', url: '#/server' }]
},
theme: { accent: '#123456', navBg: '#222222' },
nodeColors: { repeater: '#aa0000' }
};
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
home: { checklist: [{ question: 'Local Q', answer: 'Local A' }] }
}));
const ex = loadCustomizeExports(ctx);
ex.initState();
const state = ex.getState();
assert.strictEqual(state.home.checklist[0].question, 'Local Q');
assert.strictEqual(state.home.steps[0].title, 'Server Step');
assert.strictEqual(state.home.footerLinks[0].label, 'Server Link');
assert.strictEqual(state.home.heroTitle, 'Server Hero');
assert.strictEqual(state.theme.accent, '#123456');
assert.strictEqual(state.nodeColors.repeater, '#aa0000');
});
test('server values survive when localStorage has partial overrides', () => {
const ctx = makeSandbox();
ctx.window.SITE_CONFIG = {
home: {
heroTitle: 'Server Hero',
heroSubtitle: 'Server Subtitle',
steps: [{ emoji: '1️⃣', title: 'Server Step', description: 'server' }],
footerLinks: [{ label: 'Server Footer', url: '#/s' }]
},
theme: { accent: '#111111', navBg: '#222222', navText: '#333333' },
typeColors: { ADVERT: '#00aa00', REQUEST: '#aa00aa' }
};
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
home: { heroTitle: 'Local Hero' },
theme: { accent: '#999999' },
typeColors: { ADVERT: '#ff00ff' }
}));
const ex = loadCustomizeExports(ctx);
ex.initState();
const state = ex.getState();
assert.strictEqual(state.home.heroTitle, 'Local Hero');
assert.strictEqual(state.home.heroSubtitle, 'Server Subtitle');
assert.strictEqual(state.home.steps[0].title, 'Server Step');
assert.strictEqual(state.home.footerLinks[0].label, 'Server Footer');
assert.strictEqual(state.theme.accent, '#999999');
assert.strictEqual(state.theme.navBg, '#222222');
assert.strictEqual(state.typeColors.ADVERT, '#ff00ff');
assert.strictEqual(state.typeColors.REQUEST, '#aa00aa');
});
test('full localStorage values override server config', () => {
const ctx = makeSandbox();
ctx.window.SITE_CONFIG = {
home: {
heroTitle: 'Server Hero',
heroSubtitle: 'Server Subtitle',
steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }],
checklist: [{ question: 'Server Q', answer: 'Server A' }],
footerLinks: [{ label: 'Server Link', url: '#/server' }]
},
theme: { accent: '#101010' }
};
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
home: {
heroTitle: 'Local Hero',
heroSubtitle: 'Local Subtitle',
steps: [{ emoji: 'L', title: 'Local Step', description: 'local' }],
checklist: [{ question: 'Local Q', answer: 'Local A' }],
footerLinks: [{ label: 'Local Link', url: '#/local' }]
},
theme: { accent: '#abcdef', navBg: '#fedcba' }
}));
const ex = loadCustomizeExports(ctx);
ex.initState();
const state = ex.getState();
assert.strictEqual(state.home.heroTitle, 'Local Hero');
assert.strictEqual(state.home.heroSubtitle, 'Local Subtitle');
assert.strictEqual(state.home.steps[0].title, 'Local Step');
assert.strictEqual(state.home.checklist[0].question, 'Local Q');
assert.strictEqual(state.home.footerLinks[0].label, 'Local Link');
assert.strictEqual(state.theme.accent, '#abcdef');
assert.strictEqual(state.theme.navBg, '#fedcba');
});
}
// ===== CHANNELS.JS: WS Region Filter helper =====
console.log('\n=== channels.js: shouldProcessWSMessageForRegion ===');
{
const ctx = makeSandbox();
ctx.registerPage = () => {};
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.debouncedOnWS = (fn) => fn;
ctx.api = () => Promise.resolve({});
ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000 };
ctx.history = { replaceState() {} };
ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64');
ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8');
loadInCtx(ctx, 'public/channels.js');
const shouldProcess = ctx.window._channelsShouldProcessWSMessageForRegion;
test('helper is exported', () => assert.ok(typeof shouldProcess === 'function'));
test('allows all when no region selected', () => {
const msg = { data: { packet: { observer_id: 'obs1' } } };
assert.strictEqual(shouldProcess(msg, null, { obs1: 'SJC' }), true);
assert.strictEqual(shouldProcess(msg, [], { obs1: 'SJC' }), true);
});
test('allows message when observer region matches selection', () => {
const msg = { data: { packet: { observer_id: 'obs1' } } };
assert.strictEqual(shouldProcess(msg, ['SJC', 'SFO'], { obs1: 'SJC' }), true);
});
test('drops message when observer region is outside selection', () => {
const msg = { data: { packet: { observer_id: 'obs2' } } };
assert.strictEqual(shouldProcess(msg, ['SJC'], { obs2: 'LAX' }), false);
});
test('drops message when observer_id is missing under selected region', () => {
const msg = { data: {} };
assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'SJC' }), false);
});
test('drops message when observer region lookup missing', () => {
const msg = { data: { packet: { observer_id: 'obs9' } } };
assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'SJC' }), false);
});
}
// ===== SUMMARY =====
console.log(`\n${'═'.repeat(40)}`);
console.log(` Frontend helpers: ${passed} passed, ${failed} failed`);