## Summary
Fixes#303 — Repeater hash stats now reflect the **latest advert**
instead of the historical mode (most frequent).
When a node is reconfigured (e.g. from 1-byte to 2-byte hash size), the
analytics and node detail pages now show the updated value immediately
after the next advert is received.
## Changes
### cmd/server/store.go
1. **computeNodeHashSizeInfo** — Changed hash size determination from
statistical mode to latest advert. The most recent advert in
chronological order now determines hash_size. The hash_sizes_seen and
hash_size_inconsistent tracking is preserved for multi-byte analytics.
2. **computeAnalyticsHashSizes** — Two fixes:
- **yNode keyed by pubKey** instead of name, so same-name nodes with
different public keys are counted separately in distributionByRepeaters.
- **Zero-hop adverts included** — advert originator tracking now happens
before the hops check, so zero-hop adverts contribute to per-node stats.
### cmd/server/routes_test.go
Added 4 new tests:
- TestGetNodeHashSizeInfoLatestWins — 4 historical 1-byte adverts + 1
recent 2-byte advert → hash size should be 2 (not 1 from mode)
- TestGetNodeHashSizeInfoNoAdverts — node with no ADVERT packets →
graceful nil, no crash
- TestAnalyticsHashSizeSameNameDifferentPubkey — two nodes named
"SameName" with different pubkeys → counted as 2 separate entries
- Updated TestGetNodeHashSizeInfoDominant comment to reflect new
behavior
## Context
Community report from contributor @kizniche: after reconfiguring a
repeater from 1-byte to 2-byte hash and sending a flood advert, the
analytics page still showed 1-byte. Root cause was the mode-based
computation which required many new adverts to shift the majority. The
upstream firmware bug causing stale path bytes
(meshcore-dev/MeshCore#2154) has been fixed, making the latest advert
reliable.
## Testing
- `go vet ./...` — clean
- `go test ./... -count=1` — all tests pass (including 4 new ones)
- `cmd/ingestor` tests — pass
---------
Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary
Fixes the poor contrast in the node side pane's "Paths through this
node" section in dark mode.
## Root Cause
.node-detail-section (side pane) had no background or border — it
inherited the lighter --detail-bg (#232340) from .panel-right. The same
content on the full detail page sits inside .node-full-card which uses
the darker --card-bg (#1a1a2e) + a visible border, giving it proper
contrast.
| Context | Container | Background | Contrast |
|---------|-----------|------------|----------|
| Full detail page | .node-full-card | --card-bg (darker) | ✅ Good |
| Side pane | .node-detail-section | inherited --detail-bg (lighter) | ❌
Poor |
## Fix
Give .node-detail-section the same card treatment as .node-full-card:
`css
.node-detail-section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}
`
- All colors use CSS variables — no hardcoded hex values
- Both light and dark themes benefit from the card treatment
- No JS changes needed — CSS-only fix
- Cache busters bumped in the same commit
Fixes#334
Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary
Surfaces transport route types in the packets view by adding a **"T"
badge** next to the payload type badge for packets with
`TRANSPORT_FLOOD` (route type 0) or `TRANSPORT_DIRECT` (route type 3)
routes.
This helps mesh analysis — communities can quickly identify transported
packets and gain insights into scope usage adoption.
Closes#241
## What Changed
### Frontend (`public/`)
- **app.js**: Added `isTransportRoute(rt)` and `transportBadge(rt)`
helper functions that render a `<span class="badge
badge-transport">T</span>` badge with the full route type name as a
tooltip
- **packets.js**: Applied `transportBadge()` in all three packet row
render paths:
- Flat (ungrouped) packet rows
- Grouped packet header rows
- Grouped packet child rows
- **style.css**: Added `.badge-transport` class with amber styling and
CSS variable support (`--transport-badge-bg`, `--transport-badge-fg`)
for theme customization
### Backend (`cmd/server/`)
- **decoder_test.go**: Added 6 new tests covering:
- `TestDecodeHeader_TransportFlood` — verifies route type 0 decodes as
TRANSPORT_FLOOD
- `TestDecodeHeader_TransportDirect` — verifies route type 3 decodes as
TRANSPORT_DIRECT
- `TestDecodeHeader_Flood` — verifies route type 1 (non-transport)
decodes correctly
- `TestIsTransportRoute` — verifies the helper identifies transport vs
non-transport routes
- `TestDecodePacket_TransportFloodHasCodes` — verifies transport codes
are extracted from T_FLOOD packets
- `TestDecodePacket_FloodHasNoCodes` — verifies FLOOD packets have no
transport codes
## Visual
In the packets table Type column, transport packets now show:
```
[Channel Msg] [T] ← transport packet
[Channel Msg] ← normal flood packet
```
The "T" badge has an amber color scheme and shows the full route type
name on hover.
## Tests
- All Go tests pass (`cmd/server` and `cmd/ingestor`)
- All frontend tests pass (`test-packet-filter.js`, `test-aging.js`,
`test-frontend-helpers.js`)
- Cache busters bumped in `index.html`
---------
Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Problem
Two data integrity bugs in the Go ingestor cause observer metadata and
signal quality data to be missing for all Go-backend users.
### #320 — Observer metadata never populated
`extractObserverMeta()` reads `battery_mv`, `uptime_secs`, and
`noise_floor` from the **top level** of the MQTT status message.
However, the actual MQTT payload nests these under a `stats` object:
```json
{
"status": "online",
"origin": "ObserverName",
"model": "Heltec V3",
"firmware_version": "v1.14.0-9f1a3ea",
"stats": {
"battery_mv": 4174,
"uptime_secs": 80277,
"noise_floor": -110
}
}
```
Result: battery, uptime, and noise floor are always NULL in the
database.
### #321 — SNR and RSSI always missing on raw packets
The raw packet handler reads `msg["SNR"]` and `msg["RSSI"]` (uppercase
only). Some MQTT bridges send these as lowercase `snr`/`rssi`. The
companion BLE handler already has a case-insensitive fallback — the raw
packet path did not.
Result: SNR/RSSI are NULL for all raw packet observations from bridges
that use lowercase keys.
## Fix
### #320 — Nested stats with top-level fallback
- Added `nestedOrTopLevel()` helper that checks `msg["stats"][key]`
first, then `msg[key]`
- `extractObserverMeta` now uses this helper for `battery_mv`,
`uptime_secs`, `noise_floor`
- Top-level fallback preserved for backward compatibility with bridges
that flatten the structure
- Safe type assertion: `stats, _ :=
msg["stats"].(map[string]interface{})` — no crash if stats is missing or
wrong type
### #321 — Lowercase SNR/RSSI fallback
- Raw packet handler now uses `else if` to check lowercase `snr`/`rssi`
when uppercase keys are absent
- Matches the pattern already used in the companion channel and direct
message handlers
## Tests
10 new test cases added:
| Test | What it verifies |
|------|-----------------|
| `TestExtractObserverMetaNestedStats` | All 5 fields populated from
nested stats object |
| `TestExtractObserverMetaNestedStatsPrecedence` | Nested stats wins
over top-level when both present |
| `TestExtractObserverMetaFlatFallback` | Flat structure still works
(backward compat) |
| `TestExtractObserverMetaEmptyStats` | Empty stats object — no crash,
model still works |
| `TestExtractObserverMetaStatsNotAMap` | stats is a string — no crash,
falls back to top-level |
| `TestExtractObserverMetaNoiseFloorFloat` | Float precision preserved
(noise_floor REAL migration) |
| `TestHandleMessageWithLowercaseSNRRSSI` | Lowercase snr/rssi both
stored correctly |
| `TestHandleMessageSNRRSSIUppercaseWins` | When both cases present,
uppercase takes precedence |
| `TestHandleMessageNoSNRRSSI` | Neither key present — nil, no crash |
| Existing `TestExtractObserverMeta` | Still passes (flat structure
backward compat) |
All tests pass: `go test ./... -count=1` and `go vet ./...` clean.
Closes#320Closes#321
---------
Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Problem
The self-hosted runner (`meshcore-runner-2`) filled its 29GB disk to
100%, blocking all CI runs:
```
Filesystem Size Used Avail Use%
/dev/root 29G 29G 2.3M 100%
Docker Images: 67 total, 2 active, 18.83GB reclaimable (99%)
```
Root cause: no Docker image cleanup after builds. Each CI run builds a
new image but never prunes old ones.
## Fix
### 1. Docker image cleanup after deploy (`deploy` job)
- Runs with `if: always()` so it executes even if deploy fails
- `docker image prune -af --filter "until=24h"` — removes images older
than 24h (safe: current build is minutes old)
- `docker builder prune -f --keep-storage=1GB` — caps build cache
- Logs before/after `docker system df` for visibility
### 2. Runner log cleanup at start of E2E job
- Prunes runner diagnostic logs older than 3 days (was 53MB and growing)
- Reports `df -h` for disk visibility in CI output
## Impact
After manual cleanup today, disk went from 100% → 35% (19GB free). This
PR prevents recurrence.
## Test plan
- [x] Manual cleanup verified on runner via `az vm run-command`
- [ ] Next CI run should show cleanup step output in deploy job logs
Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary
Several features and fixes from a live deployment of the Go v3.0.0
backend.
### geo_filter — full enforcement
- **Go backend config** (`cmd/server/config.go`,
`cmd/ingestor/config.go`): added `GeoFilterConfig` struct so
`geo_filter.polygon` and `bufferKm` from `config.json` are parsed by
both the server and ingestor
- **Ingestor** (`cmd/ingestor/geo_filter.go`, `cmd/ingestor/main.go`):
ADVERT packets from nodes outside the configured polygon + buffer are
dropped *before* any DB write — no transmission, node, or observation
data is stored
- **Server API** (`cmd/server/geo_filter.go`, `cmd/server/routes.go`):
`GET /api/config/geo-filter` endpoint returns the polygon + bufferKm to
the frontend; `/api/nodes` responses filter out any out-of-area nodes
already in the DB
- **Frontend** (`public/map.js`, `public/live.js`): blue polygon overlay
(solid inner + dashed buffer zone) on Map and Live pages, toggled via
"Mesh live area" checkbox, state shared via localStorage
### Automatic DB pruning
- Add `retention.packetDays` to `config.json` to delete transmissions +
observations older than N days on a daily schedule (1 min after startup,
then every 24h). Nodes and observers are never pruned.
- `POST /api/admin/prune?days=N` for manual runs (requires `X-API-Key`
header if `apiKey` is set)
```json
"retention": {
"nodeDays": 7,
"packetDays": 30
}
```
### tools/geofilter-builder.html
Standalone HTML tool (no server needed) — open in browser, click to
place polygon points on a Leaflet map, set `bufferKm`, copy the
generated `geo_filter` JSON block into `config.json`.
### scripts/prune-nodes-outside-geo-filter.py
Utility script to clean existing out-of-area nodes from the database
(dry-run + confirm). Useful after first enabling geo_filter on a
populated DB.
### HB column in packets table
Shows the hop hash size in bytes (1–4) decoded from the path byte of
each packet's raw hex. Displayed as **HB** between Size and Type
columns, hidden on small screens.
## Test plan
- [x] ADVERT from node outside polygon is not stored (no new row in
nodes or transmissions)
- [x] `GET /api/config/geo-filter` returns polygon + bufferKm when
configured, `{polygon: null, bufferKm: 0}` when not
- [x] `/api/nodes` excludes nodes outside polygon even if present in DB
- [x] Map and Live pages show blue polygon overlay when configured;
checkbox toggles it
- [x] `retention.packetDays: 30` deletes old transmissions/observations
on startup and daily
- [x] `POST /api/admin/prune?days=30` returns `{deleted: N, days: 30}`
- [x] `tools/geofilter-builder.html` opens standalone, draws polygon,
copies valid JSON
- [x] HB column shows 1–4 for all packets in grouped and flat view
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- fix packets initial load to honor persisted `meshcore-time-window`
before the filter UI is rendered
- keep the dropdown and effective query window in sync via a shared
`savedTimeWindowMin` value
- add a frontend regression test to ensure `loadPackets()` falls back to
persisted time window when `#fTimeWindow` is not yet present
- bump cache busters in `public/index.html`
## Root cause
`loadPackets()` could run before the filter bar existed, so
`document.getElementById('fTimeWindow')` was null and it fell back to
`15` minutes even though localStorage had a different saved value.
## 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>
## Fix: unprotect /api/decode from API key auth
Fixes#304
### Problem
PR #283 applied `requireAPIKey` to all POST endpoints including
`/api/decode`. But BYOP decode is a stateless read-only decoder — it
never writes to the database. Users see "write endpoints disabled" when
trying to decode packets.
### Fix
- Removed `requireAPIKey` wrapper from `/api/decode` in
`cmd/server/routes.go`
- Updated auth tests to use `/api/perf/reset` (actual write endpoint)
instead of `/api/decode`
- Added tests proving `/api/decode` works without API key, even when
apiKey is configured or empty
### Note
Decoder consolidation (`internal/decoder/` shared package) is tracked
separately and not included here to keep the PR clean.
### Tests
- `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>
## Summary
- show relative build age next to the commit hash in the nav stats
version badge (e.g. `abc1234 (3h ago)`)
- use `stats.buildTime` from `/api/stats` and existing `timeAgo()`
formatting in `public/app.js`
- keep behavior unchanged when `buildTime` is missing/unknown
## What changed
- updated `formatVersionBadge()` signature to accept `buildTime`
- appended a `build-age` span after the commit link when `buildTime` is
valid
- passed `stats.buildTime` from `updateNavStats()`
- updated frontend helper tests for the new function signature
- added regression tests for build-age rendering/skip behavior
- bumped cache busters in `public/index.html`
## API check
- verified Go server already exposes `buildTime` on `/api/stats` and
`/api/health` via `cmd/server/routes.go`
- no backend API changes required
## Tests
- `node test-frontend-helpers.js`
- `node test-packet-filter.js`
- `node test-aging.js`
All passed locally.
## Browser validation
- Not run in this environment (no browser session available).
Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pass region through channel message routes, apply DB/store filtering, normalize IATA at read and write boundaries, and add regression coverage for routes/server/ingestor.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds rule 13 to AGENTS.md: mandatory git worktree isolation for parallel
agents.
- Implementation agents must work in dedicated worktrees, never the main
checkout
- Review agents must read remote branches via `git show
origin/<branch>:`, never the working tree
- Prevents the stale-worktree bug that caused multiple incorrect reviews
tonight
## Summary
This PR performs a **one-time line-ending normalization** with **no
functional changes**.
- Normalized previously CRLF-indexed files to LF using `git add
--renormalize .`
- Added `.git-blame-ignore-revs` to preserve usable blame history for
this bulk formatting-only commit
- Added explicit LF enforcement for shell scripts in `.gitattributes`:
- `manage.sh text eol=lf`
- `*.sh text eol=lf`
## Why this is safe
- This is a text normalization pass only (CRLF → LF)
- No logic, behavior, APIs, or runtime paths were changed
- Review diff noise is expected due to line-ending-only rewrites
## File-count context
- There were **148 known CRLF-indexed files** targeted for normalization
- The renormalization pass touched **151 files total** in this
repository snapshot
## Blame preservation
GitHub reads `.git-blame-ignore-revs` natively, so blame views can skip
the normalization commit.
For local git blame setup:
```bash
git config blame.ignoreRevsFile .git-blame-ignore-revs
```
## Validation
Executed required no-regression checks:
```bash
node test-frontend-helpers.js && node test-packet-filter.js && node test-aging.js
```
All passed.
## Summary
- fix safe .env parser in manage.sh to expand a leading ~ before export
- ensure setup-time PROD_DATA_DIR read from .env also expands ~
- keep behavior unchanged for non-tilde values
## Why
xport "=" does not perform tilde expansion, so values like
PROD_DATA_DIR=~/meshcore-data stayed literal and broke path-based
operations in manage.sh.
## Validation
- ash -n manage.sh
- manual reasoning: PROD_DATA_DIR=~/meshcore-data now resolves to
$HOME/meshcore-data
## Notes
Docker Compose handling is unchanged (compose already expands ~); this
PR only fixes manage.sh runtime parsing.
Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary
- add `DISABLE_MOSQUITTO` support in container startup by switching
supervisord config when disabled
- add a no-mosquitto supervisord config
(`docker/supervisord-go-no-mosquitto.conf`)
- fix Compose port mapping regression so host ports map to fixed
internal listener ports (`80`, `443`, `1883`)
- add compose variants without MQTT port publishing
(`docker-compose.no-mosquitto.yml`,
`docker-compose.staging.no-mosquitto.yml`)
- update `manage.sh` setup flow to ask `Use built-in MQTT broker?
[Y/n]`, skip MQTT port prompt when disabled, persist
`DISABLE_MOSQUITTO`, and use no-mosquitto compose files when
starting/stopping/restarting
- align `.env.example` staging keys with compose
(`STAGING_GO_HTTP_PORT`, `STAGING_GO_MQTT_PORT`)
- fix staging Caddyfile generation to use `STAGING_GO_HTTP_PORT`
- fix `.env.example` staging default comments to match actual values
(82/1885)
## Validation performed
- ✅ `bash -n manage.sh` passes.
- ✅ With `DISABLE_MOSQUITTO=true`, no-mosquitto compose overrides are
selected, Mosquitto is not started, and MQTT port is not published.
- ✅ With `DISABLE_MOSQUITTO=false`, standard compose files are used,
Mosquitto starts, and MQTT port mapping is present.
- ℹ️ Runtime Docker validation requires a running Docker host.
Fixes#267
---------
Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## 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>
## 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>
## 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>
## 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>
## 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>
## 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>
## 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>
## 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>
## 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>
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>
## 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>
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>
- 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>
Fixes#276
## Root cause
TRACE packets store hop IDs in the payload (bytes 9+) rather than in the
header path field. The header path field is overloaded in TRACE packets
to carry RSSI values instead of repeater IDs (as noted in the issue
comments). This meant `Path.Hops` was always empty for TRACE packets —
the raw bytes ended up as an opaque `PathData` hex string with no
structure.
The hashSize encoded in the header path byte (bits 6–7) is still valid
for TRACE and is used to split the payload path bytes into individual
hop prefixes.
## Fix
After decoding a TRACE payload, if `PathData` is non-empty, parse it
into individual hops using `path.HashSize`:
```go
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && path.HashSize > 0 {
for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
path.Hops = append(path.Hops, ...)
}
}
}
```
Applied to both `cmd/ingestor/decoder.go` and `cmd/server/decoder.go`.
## Verification
Packet from the issue: `260001807dca00000000007d547d`
| | Before | After |
|---|---|---|
| `Path.Hops` | `[]` | `["7D", "54", "7D"]` |
| `Path.HashCount` | `0` | `3` |
New test `TestDecodeTracePathParsing` covers this exact packet.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>