Extends GET /api/analytics/wardriving with avgSnr/avgRssi over the
same time buckets as the activity series, plus overall averages.
Frontend adds two min/max-scaled line charts (SNR has no natural
zero floor, RSSI is negative dBm, so these can't reuse the 0-baseline
message-volume chart) and two stat cards.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New GET /api/analytics/wardriving endpoint plus an Analytics tab
covering the three requested angles: activity over time + top
senders, entry-point repeaters (path[0] tally, unique_prefix-only
name resolution), and per-observer coverage using observers' known
IATA coordinates. MeshMapper's on-air ping is an anonymous session
token by default, not the sender's live GPS, so sender position
itself isn't tracked — documented in the tab and OpenAPI description.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Scoped/Unscoped/Unknown counts answered "how much" but not "which
regions" — a channel spanning multiple hashRegions areas looked
identical to one using just one. New "Regions Used" column lists the
distinct scope_name values seen on each channel's scoped messages,
most-used first.
Backend: GetChannelScopeAdoption now also queries per-(channel,
scope_name) counts and attaches them as ChannelScopeAdoption.Regions.
Non-fatal on query failure — adoption counts still render without it.
GetChannelScopeAdoption was capped at the top 30 channels by message
volume, silently hiding quieter channels from the Scopes tab. Channel
cardinality is bounded by the 1-byte hash space, so the cap wasn't
protecting against anything real — removed it.
Client gets a filter (All/Unencrypted/Encrypted) based on the 'enc_'
channel_hash prefix db.go already uses for channels the ingestor
couldn't decrypt, so a visitor can separate plain channels from
encrypted ones without hunting through a long unfiltered list.
Was a collapsed-by-default <details> section further down the Overview
sub-tab; now renders uncollapsed directly under the stat-card rows,
matching the always-visible Channel Messages section's style.
Surfaces the headline number from each of the other two sub-tabs
(regions used, bridge repeaters, nodes without a default scope,
repeaters never relaying) right under the main summary cards, so a
visitor doesn't have to switch sub-tabs to see them.
Also de-duplicates the fetchAllNodes call the two Hygiene sections used
to make independently — it's now fetched once for the highlights row
and reused by both.
Description text was inside <details> as a sibling of <summary>, so
<details> hid it along with the table whenever a section was collapsed.
Moving it inside <summary> (which is always shown regardless of open
state) surfaces the "what is this" context without expanding.
Clarifies the section is specifically about default_scope (not the
separate "never relayed a scope" signal below it), and adds role filter
buttons (e.g. companions only) plus a name/key search box so the list is
usable beyond the top 100 most-recently-active nodes.
The sub-tab split (4b37038) grouped the tab into 3 screens, but each
one still stacked several full sections/tables — Nodes Not Using Any
Scope and Repeaters Never Relaying Any Scope alone can each run 100
rows. Wrapped every subsection (Scope Adoption by Channel, Region
Breakdown & Trend, Activity by Hour of Day, Region Utilization,
Repeaters by Region, Nodes Running This Region, Bridge Repeaters, and
both hygiene lists) in a collapsed-by-default <details>/<summary>,
via a small shared detailsSection(title, description, body) helper.
The summary label carries the real count computed at render time
("Nodes Not Using Any Scope (1423 of 1491)"), so the headline number
is visible without expanding anything — matches the same "see the
count, decide if you need the table" shape the existing per-region
<details> in Repeaters by Region / Nodes Running This Region already
used, just applied one level up. Left the top-of-panel Scope
Statistics and Channel Messages stat-card grids always visible — 4
small cards each, not a scroll problem, and they're the headline
numbers worth seeing on load.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The tab grew to 12 stacked sections as this branch's work landed
(windowed adoption stats, all-time region breakdowns, and the two new
node/repeater hygiene lists) — one long scroll with no way to jump to
a specific concern. Grouped into 3 sub-tabs:
- Overview: the 1h/24h/7d-windowed stuff (stat cards, channel
messages, scope adoption by channel, per-region table, time series,
hour-of-day heatmap). The window picker now lives inside this
panel, not above the sub-tab bar, since it only affects this group
— the other two ignore it entirely.
- Regions: all-time region breakdowns (utilization, repeaters by
region, nodes running this region, bridge repeaters).
- Hygiene: the two adoption-gap lists (nodes not using any scope,
repeaters never relaying any scope).
Purely presentational — all three groups' data still loads together
in one pass (one /scope-stats fetch + the hygiene sections' own
fetchAllNodes calls); only DOM visibility toggles on the active
sub-tab, so there's no new fetch/render logic to get wrong. Selection
persists in sessionStorage (scopes_subtab), same pattern as the
existing window-picker persistence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Distinct from "Nodes Not Using Any Scope": a repeater's own
default_scope (does it send its own scoped adverts?) and
transported_scopes (has it ever relayed ANYONE's region-scoped
traffic?) are different signals. Checked the real numbers on
stg.meshview.dk before building this — of 1181 repeaters with no
default_scope, 168 (14%) still relay scope for others (their
hashRegions config enables relaying even though their own adverts
never carry a matching transport code), so the two lists aren't
interchangeable.
Filters to repeater/room nodes only (the only roles that can relay at
all) with empty/absent transported_scopes, sorted by relay_count_24h
descending — the busiest still-unconfigured repeaters are the most
consequential to fix first. Same pure-function pattern as
computeNodesWithoutScope (computeRepeatersNeverRelayingScope),
reusing the node list already fetched for the sibling section instead
of a second fetchAllNodes call. 6 new unit tests, including one
pinning the exact "no default_scope but does relay for others" case
found in the real data.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The flip side of the existing "Nodes Running This Region" section:
every node with no default_scope configured at all — never set up a
hashRegions region for itself. Computed client-side from the full
node list (default_scope isn't windowed/aggregated server-side the
way the rest of scope-stats is).
On stg.meshview.dk this surfaced a real adoption gap: 1423 of 1491
known nodes (95%) have no scope configured at all, 1128 of them
repeaters. Shows the role breakdown plus a most-recently-active-first
table (capped at 100 rows) — worth reaching out to first, since
they're still transmitting.
Extracted the filter/tally/sort logic into computeNodesWithoutScope,
a pure module-level function (same pattern as packets.js's
reconcileVisibleCols) so it's unit-testable without needing to stand
up the Scopes tab's full DOM skeleton, which updateData reaches into
directly via getElementById rather than writing to a passed-in
element the way renderForeignTrafficTab does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The prior caveat claimed a foreign_advert flag arriving via a healthy
multi-hop local relay chain was "almost always" a wrong/joke GPS
value on an otherwise-local node. That assumed LoRa mesh range tops
out in the tens of km, but this deployment's own node population
(checked earlier this session) spans a dense, continuous mesh across
much of Northern/Western Europe — a FLOOD packet can legitimately
hop node-to-node across hundreds of km through a sufficiently dense
chain, no MQTT bridging or propagation anomaly required. Reworded to
state the traceable-relay-chain case as genuine foreign traffic
reaching the mesh, not data-quality noise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- MAJOR: config.example.json was missing geoFilterExemptNodeList
entirely — every other Config field in this repo gets a sibling
_comment_<field> entry (established, actively-used convention
throughout the file, ~20 existing examples), documented here to
match. Also corrected the adjacent geo_filter _comment, which
predates this PR and claimed it "restricts ingestion" — it doesn't;
that's foreignAdverts.mode=drop's job, unrelated to this flag.
- MINOR: ?geoFilter=<v> only recognized the literal string "1" to
enable and treated anything else (including "true") as disabling —
a real footgun since "true" reads as an obviously valid boolean
value. Now accepts 1/true and 0/false as explicit overrides; any
other value (including absent) falls through to the deployment
default instead of silently flipping it off.
- Tightened the Foreign Traffic tab's stop-hook test: it previously
only asserted stop() doesn't throw, which a no-op stub would also
pass. Sandbox's setInterval/clearInterval are now real spies
tracking live/cleared ids, so the test verifies render() registers
exactly one interval and stop() actually clears that exact id
(idempotently on a second call).
- NIT: relays.sort now wraps unscoped_relay_count_24h in Number(...)
defensively (AGENTS.md § Type Safety — cast at the boundary).
- NIT: foreignNodes.sort now parses last_seen once per node
(decorate-sort-undecorate) instead of twice per comparator call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
foreign_advert is purely self-reported GPS from the node's own
ADVERT. Investigating a real flagged node (RYDBOHOLM, self-reported
as Romania) showed its packets entering the DK mesh through a normal,
healthy 8-hop local FLOOD relay chain (RSSI/SNR consistent with real
short-range LoRa) — and MeshCore has no MQTT-to-RF bridging mechanism,
so that's only physically possible if the node is actually local and
its GPS is simply wrong. Documents the inference rule directly on the
tab: a flag near an actual border may be genuine; a flag claiming
hundreds/thousands of km away, arriving via a healthy local relay
chain, is a GPS data-quality artifact, not real long-distance traffic.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reordered so the node list (what's actually accumulating right now,
worth checking back on) comes first, with the unscoped-relay-volume
table — the part that needs more data to be interesting — below it.
Added a heading to the relay table for symmetry with the new
sub-heading above it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a second table to the tab: every node currently foreign_advert
-tagged (name, role, lat/lon, first/last seen), newest-heard first —
so there's something to look at while more foreign_advert data
accumulates, instead of only the one-sentence summary count.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A little unscoped traffic through a repeater is normal — flood.max.unscoped
caps it rather than blocking it outright, and setting it to 0 isn't a
realistic target in practice. The Foreign Traffic tab's copy read as
"anything nonzero is misconfigured," which isn't true; reworded to
frame the view as spotting disproportionate volume, not achieving
zero-tolerance.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per bot review on PR #1852 (comment 5012304813): renderForeignTrafficTab
shipped without a test, repeating the same TDD-policy finding flagged
on the prior round, and the tab lacked the 60s auto-refresh + stop-hook
convention its Roles/Scopes siblings use (data would go stale until the
user re-navigated away and back).
- Added _stopForeignTrafficRefresh + a 60s setInterval loop, wired into
the tab-switch handler and destroy(), matching _stopRolesRefresh/
_stopScopesRefresh exactly.
- New test-analytics-foreign-traffic-tab.js: loads analytics.js in a vm
sandbox (same pattern as test-frontend-helpers.js's
makeAnalyticsSandbox), stubs fetchAllNodes with a fixed node mix, and
asserts row sort order, role/zero-count exclusion, the empty state,
both foreignCount note variants, and that the new stop-hook is
exported and idempotent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New "Foreign Traffic" tab under Analytics, sorted by
unscoped_relay_count_24h descending — the metric already existed in
the /api/nodes response (cmd/server/repeater_liveness.go,
repeater_enrich_bulk.go) with an openapi doc string calling out
exactly this: "A well-configured repeater sets flood.max.unscoped 0,
so a non-trivial count flags a base-config problem" — but had zero
frontend surface until now.
Phase 1 of an investigation into unscoped traffic from PL/DE/SE
leaking into the DK mesh. Phase 2 (cross-referencing which of this
volume traces back to a foreign-origin sender via path_json) needs
foreign_advert data to accumulate first — it's only set going forward
from when geo_filter was configured, not backfilled for existing
history.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bridge Repeaters renders as a flat, non-collapsible table — unlike
Repeaters by Region and Nodes Running This Region, which group into
collapsible <details> per region. On stg.meshview.dk it currently has
80 rows (~2700px tall), which buried Nodes Running This Region far
down the page, reading as if the section had vanished entirely.
Reordering doesn't touch the row-count problem itself, but it stops
the shortest, most specific section from being the one that pays for
it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Working through the outstanding non-blocker findings across all five
automated review passes on the PR:
- MAJOR: GetChannelMessages' rows.Scan() error was silently discarded
— a schema mismatch would produce zero-valued messages instead of a
visible error. Now returns the error, matching sibling query loops.
- Extracted the hashRegions name-normalization rule (trim, "#"-prefix,
dedupe) shared between cmd/ingestor's loadRegionKeys and cmd/server's
region-utilization diff into a new internal/regions package, so the
two can no longer drift apart on the rule independently.
- Batched GetRepeaterNamesByKeys' SQL IN (...) clause in chunks of 500
— an unbounded clause risks SQLITE_MAX_VARIABLE_NUMBER on very large
deployments' byPathHop candidate sets.
- handleScopeStats' remaining silently-swallowed err==nil branches
(GetMatchedRegionNames, GetNodesByDefaultScope,
GetChannelMessageScopeStats, GetChannelScopeAdoption) now log a WARN
breadcrumb on failure instead of failing invisibly.
- Scope Adoption table was rendering 3 of the 4 ChannelScopeAdoption
fields (Unscoped omitted) — the visible numbers didn't reconcile to
the message total without doing the subtraction by hand. Added the
column.
- Removed a duplicate `vertical-align: middle` declaration on
.badge-transport (dead, not a behavior change).
- Deleted ChannelMessageResp — flagged for interface{} vs *string/*int
typing, but turned out to be completely unused dead code; removing
it resolves the finding more directly than retyping something
nothing constructs.
Left two NIT/MINOR items as-is with reasoning:
- renderRegionNodeGroups' inline styles match the same pattern used in
15+ other places in analytics.js — "fixing" only this one function
would make it less consistent with the file, not more.
- TransmissionResp's interface{} fields (a different struct than the
one just removed) are an established, actively-used pattern for
nullable SQL-scanned values across that whole response type;
retyping it is a much larger, unrelated refactor.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds channelScopeAdoption to /api/scope-stats: the existing
ChannelMessages aggregate (scoped/unscoped/unknown for channel chat)
broken down PER CHANNEL — which specific channels (#test,
#wardriving, ...) actually use region scoping vs which never do.
Ordered by message volume, capped at the top 30 channels.
Frontend renders a compact table under "Channel Messages": channel
name, total messages, scoped count+%, unknown count.
Adds hourlyActivityByRegion to /api/scope-stats: each region's message
counts bucketed by hour-of-day (0-23 UTC), aggregated across every day
in the window — answers "when during a typical day is this region
active" rather than "how did volume change over the window" (that's
the existing chronological TimeSeries chart).
Frontend renders a compact heatmap: one row per region, 24 hour
columns, color intensity normalized per-row (each region's own busiest
hour) so a quiet region's daily shape stays visible next to a loud
one instead of being crushed toward zero.
Repeaters by Region, Nodes Running This Region, and Bridge Repeaters
all cleared their entire section (heading included) when the data was
empty, which reads as "this feature doesn't exist" rather than "no
data yet" — especially confusing right after a restart, when the
neighbor graph and path resolution need a few minutes to catch back
up before repeater-level scope data repopulates. Now always renders
the heading + description with an explanatory empty-state message
instead of hiding.
Adds bridgeRepeaters to /api/scope-stats: RepeatersByRegion inverted
into pubkey -> regions, keeping only repeaters that have relayed
traffic for MORE than one region. These are the mesh's literal
backbone nodes connecting otherwise-separate regional communities —
losing one is a more consequential failure than losing a
single-region repeater.
Computed inline while building RepeatersByRegion (reuses the same
byRegion map and role-filtered names lookup, no extra queries).
Frontend renders a small table under "Repeaters by Region": repeater
name (linked to its node detail page), region count, and the region
list.
Adds channelMessages to /api/scope-stats: the same scoped/unscoped/
unknown question as the main Summary, but restricted to payload_type=5
(channel chat) instead of all observed traffic. Most channel chat is
plain FLOOD rather than transport-scoped, so this can read very
differently from the all-traffic numbers — answers "how many of our
actual channel messages carry a region scope" directly instead of
requiring the reader to infer it from the broader stats.
New GetChannelMessageScopeStats() mirrors GetScopeStats' query shape
but scopes TotalMessages to ALL route types for payload_type=5 (not
just route_type 0/3), since restricting to transport routes would
answer a different question than "how many channel messages, period".
Frontend renders a small "Channel Messages" stat-card row under the
main summary cards, window-scoped like the rest of the tab.
Adds originatingNodesByRegion to /api/scope-stats: nodes whose OWN
default_scope (#899) is a given region, complementing the existing
repeatersByRegion (transported_scopes) breakdown. The distinction
matters — a repeater can relay traffic for a region it isn't itself
configured with, so "who runs this region" and "who has carried this
region's traffic" are different, both useful questions.
Frontend: refactored the per-region collapsible-list rendering (used
by both breakdowns) into a shared renderRegionNodeGroups() helper
instead of duplicating the HTML-building logic, and added a "Nodes
Running This Region" section alongside "Repeaters by Region".
Adds repeatersByRegion to /api/scope-stats: for every region that has
ever matched a transmission, which distinct repeaters/rooms have
relayed traffic carrying that scope. Sourced from the same 5-min
background-recomputed bulk relay-info cache the Nodes page already
uses (GetRepeaterRelayInfoMap / TransportedScopes, #1751) — no new
expensive computation, just an inversion + name lookup.
Frontend renders a collapsible per-region repeater list (name links
to the node detail page) under a new "Repeaters by Region" section,
explicitly framed as a coverage/redundancy signal: a region carried
by only one repeater is a single point of failure for that area.
Adds configuredRegions/unusedRegions to /api/scope-stats: an all-time
(not window-scoped) diff between the operator's configured hashRegions
list and the set of scope_name values that have actually matched a
transmission still in retention. Surfaces how much of the region list
is dead weight — directly actionable evidence for pruning, which is
also the real fix for the HMAC-collision noise (fewer configured
regions -> lower birthday-collision probability per packet).
Server config now parses hashRegions (previously ingestor-only, same
config.json key) purely to read the configured names — no HMAC key
derivation happens server-side.
Frontend: a "Region Utilization" section on the Scopes tab shows
used/unused counts and a collapsible list of the unused region names.
Fixes#1838
## Problem
`/api/scope-stats` reported 100% scoped whenever any region was
configured. Reporter noticed on a scopeless instance that "unscoped" was
always zero — the pie visual is misleading to operators deciding on
`denyf *`.
## Root cause
`cmd/server/db.go:22` restricted the entire scope-stats denominator to
`route_type IN (0, 3)`. Per firmware `docs/packet_format.md § Route
Types`:
- `0` = `TRANSPORT_FLOOD`
- `1` = `FLOOD`
- `2` = `DIRECT`
- `3` = `TRANSPORT_DIRECT`
Only routes 0 and 3 carry `transport_code_1` (transport-level scope).
Routes 1 and 2 are inherently unscoped by protocol. The existing SQL was
correct for the "how many transport-scopable routes are actually scoped"
question, but the denominator was silently promoted to "all traffic" in
the UI. Bonus: the comment on `routeTypeTransportSQL` labelled routes
0+3 as "FLOOD (0) and DIRECT (3)" — wrong on both counts.
## Fix
- `cmd/server/db.go` — corrected the `routeTypeTransportSQL` comment;
added `routeTypeNonTransportSQL = "route_type IN (1, 2)"` alongside it.
- `GetScopeStats` runs a second `COUNT(*)` over `route_type IN (1,2) AND
first_seen >= ?` and folds that count into `Summary.Unscoped`. Same
index path as the existing query — one extra scan per `/api/scope-stats`
call (cached 30s per triage's carmack finding).
- `public/analytics.js` — Scopes tab header explains the denominator
(all observed transmissions) and which route types carry scope. Card
notes now render `X% of all traffic` for Scoped/Unscoped and `X% of
scoped` for Unknown Scope so the pie's denominator is explicit.
## TDD
- Red: `5554ffe4` — extended `TestGetScopeStats` +
`TestHandleScopeStats` with `route_type=1` and `route_type=2` rows and
asserted `Unscoped = 3` (1 transport-NULL + 2 non-transport). Ran the
tests and confirmed assertion failure (`Unscoped = 1, want 3`).
- Green: `ebbb9253` — implementation + label copy. Full `go test
./cmd/server/...` passes (54s).
## Preflight overrides
- check-branch-clean: justified — cross-stack fix by design (backend
semantics change + matching frontend label copy). All 4 files are
exactly the surface the triage comment identified.
## Verification
- `go test ./cmd/server/...` — 54s, all pass.
- Firmware confirmation: `firmware/docs/packet_format.md:20-24` (route
type table).
## Files touched
- `cmd/server/db.go` — comment fix + second COUNT query.
- `cmd/server/db_test.go` — extended fixture.
- `cmd/server/routes_test.go` — extended fixture + isolate from seed
data.
- `public/analytics.js` — labels and header copy.
---------
Co-authored-by: corescope-bot <bot@corescope.dev>
The Analytics → Neighbor Graph tab fetches the full (uncapped) graph
and,
when it exceeds NODE_LIMIT (1000), skips the force simulation with a
"use filters to reduce the node count" notice. But filtering never
actually
re-enabled rendering:
- the node-count guard tested _ngState.allNodes (the immutable full
fetched
set, assigned once in createGraphState and never reassigned) instead of
the
displayed/filtered _ngState.nodes, so its verdict was fixed at load
time;
- the entire draw loop lives in startGraphRenderer(), which ran exactly
once
at load and was never called from applyNGFilters(), so a filter change
updated the node/edge arrays and stat cards but never un-hid the canvas
or
scheduled an animation frame -> the graph stayed blank no matter how few
nodes remained.
This explains both reported symptoms (selects too many nodes initially
AND
stays broken once restricted to fewer).
Fix: make the render lifecycle filter-aware.
- startGraphRenderer() now guards on the displayed set (_ngState.nodes),
cancels any running rAF loop before re-deciding, toggles the canvas plus
a
stable-id "skipped" notice, and restarts cleanly (no double loops).
- applyNGFilters() calls startGraphRenderer() so every filter change
re-evaluates the guard and (re)starts or stops the loop.
- the initial render now goes through applyNGFilters() so the first
paint
already respects the default filters (observers unchecked, saved
min-score)
instead of dumping the full fetched graph.
Test: `node --check public/analytics.js` passes. Manually: open
Analytics → Neighbor Graph on a
mesh with >1000 nodes → the "skipped" notice shows; tighten filters
(min-score up / roles
off) below 1000 → the graph now renders (was blank before); loosen again
→ notice returns.
Frontend-only change (`public/analytics.js`); no backend/API change.
---
**TDD note (review round 1):** Single-commit community bug-fix on an
existing UI surface (no "net-new UI" exemption). The e2e
`test-issue-1758-ng-filter-rerenders-e2e.js` is the red→green gate — it
fails on `origin/master` (the renderer kept the node-count guard on the
full fetched graph and never un-hid the canvas) and passes with the fix.
Per AGENTS.md the separate red/green-commit *form* is a bot rule, not a
contributor gate.
---------
Co-authored-by: Waydroid Builder <build@waydroid.local>
Co-authored-by: Waydroid Builder <claude@michael.arcan.de>
Partial fix for #1768 — Relay Airtime Share now uses closed-form LoRa
Time-on-Air instead of a payload-bytes-only proxy, removing the ~3-4×
bias against small frames (preamble + fixed-symbol intercept).
cross-stack: justified — backend score formula needs a frontend caption
change (`public/analytics.js` dumbbell preset banner + tooltip) so
operators can interpret the assumed PHY block. Both move together or the
metric is misleading.
## Red commit
`8da57062` — failing test asserts ToA-based score (~83.48 % ADVERT share
on the locked acceptance fixture) instead of the byte proxy's 95.24 %.
`internal/lora.TimeOnAir` was a zero-returning stub at the red commit;
tests failed with assertion errors, not build errors.
## Green commit
`dd402edd` — implements `lora.TimeOnAir` (Semtech AN1200.13 / SX126x
§6.1.4 closed form, cross-checked against RadioLib), wires `score =
TimeOnAir(payloadBytes, preset) × distinctRelays` in
`cmd/server/relay_airtime_share.go`, surfaces the preset in the JSON
response and analytics caption.
## Config (per AGENTS Config Documentation Rule)
New keys under existing `analytics` block:
```json
"loraPreset": { "freq": 869600000, "bw": 62.5, "sf": 8, "cr": 5 }
```
Defaults match the deployment's actual `get radio` (869.6 MHz / BW 62.5
kHz / SF 8 / CR 4/5). `CRC=1`, `IH=0`, `DE = (T_sym ≥ 16 ms)`, and the
SF-dependent preamble (32 for SF≤8 else 16, per firmware
`preambleLengthForSF` / MeshCore PR #1954) are firmware-fixed constants
in `internal/lora/toa.go` and intentionally NOT surfaced as config (per
re-triage).
## Scope
In-scope files (6):
- `internal/lora/toa.go` (new package — closed-form ToA)
- `internal/lora/toa_test.go` (table-driven preset tests)
- `cmd/server/relay_airtime_share.go` (wire ToA into score)
- `cmd/server/relay_airtime_share_test.go` (recomputed expected values)
- `cmd/server/config.go` + `config.example.json` (preset config keys)
- `public/analytics.js` (preset caption on dumbbell chart + tooltip)
Plus `cmd/server/go.mod` (replace directive for the new internal
module).
## Deferred to v2 (separate issues per re-triage)
- Per-observation SF/BW + radio-settings-aware dedup (blocked: ingestor
stores SNR/RSSI only, no SF/BW on observations).
- CR-per-hop dual-point sensitivity band (CR scales only the payload
symbol term `(CR+4)`, not the preamble/header; second-order accuracy
gain).
- Cross-SF bridge accounting.
## Tests
```
cd internal/lora && go test ./... → PASS
cd cmd/server && go test -run RelayAirtime → PASS
```
## Preflight overrides
- `check-branch-clean` (cross-stack): justified above — score formula
change requires matching caption update; both files trace to the same
issue.
---------
Co-authored-by: kpa-clawbot <kpa-clawbot@users.noreply.github.com>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Co-authored-by: bot <bot@meshcore>
## Summary
Dark-theme variants of the neighbor-graph role swatches (`#ngRoleChecks`
labels on `/analytics?tab=neighbor-graph`) still failed WCAG AA after
#1720's light-theme fix because the swatches used inline
`style="color:#..."` from `customize.js` `DEFAULTS.nodeColors`
(palette-700) — bypassing the theme tokens entirely.
Measured before:
| Role | Color | vs `#1a1a2e` (dark) |
|---|---|---|
| repeater | `#dc2626` | 3.53:1 ❌ |
| companion | `#2563eb` | 3.30:1 ❌ |
| observer | `#8b5cf6` | 4.02:1 ❌ |
## Fix
- Defines `--role-{repeater,companion,room,sensor,observer}` in `:root`
(palette-700, ≥4.5:1 on white) and overrides them in both dark blocks
(`[data-theme="dark"]` + the `@media (prefers-color-scheme: dark)`
mirror) with palette-400/500 shades that clear AA on `#1a1a2e`.
- Refactors the neighbor-graph swatch DOM in `public/analytics.js` from
inline `style="color:${hex}"` to class-based `<span class="role-swatch
role-swatch--{role}">`, with matching CSS rules that read the tokens.
- Removes all 5 `#1715` entries from `tests/a11y-allowlist.yaml` per the
issue's acceptance criteria.
After:
| Role | Light (vs `#fff`) | Dark (vs `#1a1a2e`) |
|---|---|---|
| repeater | `#dc2626` 4.83:1 | `#ef4444` 4.53:1 |
| companion | `#2563eb` 5.17:1 | `#3b82f6` 4.64:1 |
| room | `#15803d` 5.02:1 | `#16a34a` 5.18:1 |
| sensor | `#b45309` 5.02:1 | `#d97706` 5.35:1 |
| observer | `#7c3aed` 5.70:1 | `#a78bfa` 6.27:1 |
## Tests
- `test-a11y-1715-dark-role-swatches.js` — CSS-driven WCAG AA probes for
the 5 per-theme `--role-*` tokens plus markup invariants (no inline
color span; class names present in `#ngRoleChecks` block).
- Red commit: `a09ec21c` — fails on assertion with 12
below-threshold/markup probes.
- Green commit: `f87dcd64` — all probes PASS.
- `tests/a11y-allowlist.yaml` shed all 5 entries; the umbrella
`test-a11y-axe-1668.js` (CI) is now the live-browser net for those
cells.
## Preflight overrides
- `check-xss-sinks.sh` flags `public/analytics.js:2502` (label
"observer" appears in an `innerHTML=\`tpl\`` line). The flagged token is
a hardcoded literal string — no user-controlled data flows into that
template. No template content changed in this PR; the flag is
preexisting noise from the heuristic scan and the gate ultimately marks
✅ pass.
Fixes#1715
---------
Co-authored-by: clawbot <clawbot@kpa.local>
Co-authored-by: clawbot <bot@example.com>
## Summary
Fixes the four recurring color-contrast root causes #1719 identifies
behind ~320 axe violations on PR #1707's expanded gate. All fixes are
token-based; no hardcoded hex introduced.
## TDD
- **Red:** `151db732` — `test-a11y-1719-contrast-root-causes-e2e.js`
asserts WCAG AA on all 4 patterns; failed with 12 sub-threshold probes.
- **Green:** `dd26554e` — fixes below; test now reports 11/11 PASS.
## Patterns + measured contrast (before → after)
| # | Surface | Before | After | Note |
|---|---|---|---|---|
| P1 | `.rf-range-btn.active` / `.clock-filter-btn.active` /
`.subpath-jump-nav a` / `#ptCheckBtn` / `#ptGenBtn` | `#fff` on
`--accent` (#4a9eff) = **2.75:1** | `--text-on-accent` on
`--accent-strong` = **4.95:1** | Consolidated into ONE grouped
`.btn-active-accent, ...` rule; inline buttons now use the shared class
|
| P2 | `.skew-badge--no_clock` (dark theme) | `#fff` on `--text-muted`
(#d1d5db) = **1.47:1** | `#fff` on `--skew-badge-no-clock-bg` (#4b5563)
= **7.56:1** | New dedicated token, both themes |
| P3 | Neighbor-graph role swatches, light theme on white | room 3.30:1
/ sensor 3.19:1 / observer 4.23:1 | room **5.02** / sensor **5.02** /
observer **5.70** | `customize.js` defaults bumped to
palette-{green/amber/purple}-700 |
| P4 | `.analytics-stat-card` text in `--status-green` on white |
**2.28:1** | new `--status-green-text` = #15803d → **5.02:1** |
`--status-green` background token unchanged (still #22c55e); inline text
usages routed to the new token |
## Why this unblocks #1707#1707's 320 axe color-contrast hits decompose into:
- 137× single-rule `.skew-badge--no_clock` → P2.
- ~N×4 active-button surfaces (rf-health / clock-health / subpaths /
prefix-tool) → P1.
- Role-swatch text on `/#/analytics?tab=neighbor-graph` (light) → P3.
- `.analytics-stat-card` text on `/#/analytics?tab=nodes` (light) → P4.
After this merges, the next CI run on #1707 should see the expanded gate
go green (or down to a small ≤5 residual the operator can triage
separately per the issue's acceptance criteria).
## Local axe gate
`BASE_URL=… node test-a11y-axe-1668.js` was **NOT** run locally — the
sandbox's bundled chromium fails to boot Playwright (known issue). CI on
this PR runs the same gate against the staging fixture; relying on that.
The dedicated test `test-a11y-1719-contrast-root-causes-e2e.js` is
CSS+JS-parse-driven (no browser) and runs in <100ms — it's the
regression net for these 4 patterns specifically.
```
$ node test-a11y-1719-contrast-root-causes-e2e.js
PASS [P1] theme=light .rf-range-btn.active fg=#f9fafb bg=#2563eb ratio=4.95:1
PASS [P1] theme=light .clock-filter-btn.active fg=#f9fafb bg=#2563eb ratio=4.95:1
PASS [P1] theme=light .subpath-jump-nav a fg=#f9fafb bg=#2563eb ratio=4.95:1
PASS [P1] theme=dark .rf-range-btn.active fg=#f9fafb bg=#2563eb ratio=4.95:1
PASS [P1] theme=dark .clock-filter-btn.active fg=#f9fafb bg=#2563eb ratio=4.95:1
PASS [P1] theme=dark .subpath-jump-nav a fg=#f9fafb bg=#2563eb ratio=4.95:1
PASS [P2] theme=light .skew-badge--no_clock fg=#fff bg=#4b5563 ratio=7.56:1
PASS [P2] theme=dark .skew-badge--no_clock fg=#fff bg=#4b5563 ratio=7.56:1
PASS [P3] theme=light nodeColors.room on white fg=#15803d bg=#ffffff ratio=5.02:1
PASS [P3] theme=light nodeColors.sensor on white fg=#b45309 bg=#ffffff ratio=5.02:1
PASS [P3] theme=light nodeColors.observer on white fg=#7c3aed bg=#ffffff ratio=5.70:1
PASS [P4] theme=light .analytics-stat-card text color (--status-green-text) fg=#15803d bg=#ffffff ratio=5.02:1
PASS [P4] theme=dark .analytics-stat-card text color (--status-green-text) fg=#22c55e bg=#232340 ratio=6.65:1
PASS: all 4 root-cause patterns ≥ 4.5:1 in both themes (issue #1719)
```
## Out-of-scope (intentional)
- Other text-on-light `var(--status-green)` usages in `nodes.js`
(Critical/Valuable labels): different surface, not the
analytics-stat-card pattern #1719 calls out. Tracked under analytics
audit umbrella.
- Hardcoded `var(--status-green, #2ecc71)` fallback in `nodes.js` lines
648/666: same scope deferral.
- Allowlist entries: none added per the issue's acceptance criteria.
Fixes#1719.
---------
Co-authored-by: clawbot <clawbot@kpa.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Partial fix for #1668 (M5 of 6).
After M1 (audit), M2 (color tokens, #1676), M3 (typography floor,
#1679), and M4 (per-route polish, #1681) cleared ~95% of
contrast/typography violations, M5 **locks in the wins** by adding an
axe-core CI gate that fails the build on any new WCAG AA color-contrast
regression.
## What's in the box
- `test-a11y-axe-1668.js` — Playwright + `@axe-core/playwright`. Runs
every major CoreScope route × `{dark, light}` at 1200×900 desktop,
injects axe, runs only the `color-contrast` rule, asserts net violations
=== 0.
- `test-a11y-axe-1668-selftest.js` — fast, deterministic, browser-free
unit test that exercises the YAML allowlist parser, the
`violationAllowed` matcher, and the route/theme metadata. Runs in the JS
unit block (no browser needed).
- `tests/a11y-allowlist.yaml` — operator-flagged false-positive
allowlist. **0 entries at M5 baseline.**
## Allowlist format
Each entry MUST cite a GH issue # and an `expires_at` date. Missing
fields = refused. Expired `expires_at` = refused (warning logged). This
**forces a periodic revisit** — no permanent suppressions.
```yaml
- route: /analytics?tab=channels
selector: ".some-known-stale-element"
rule: color-contrast
issue: 1234
expires_at: 2026-09-01
```
## Routes covered (19 × 2 themes = 38 cells)
`/`, `/packets`, `/nodes`, `/channels`, `/live`, `/map`, `/observers`,
`/compare`,
`/analytics?tab={overview,rf,topology,channels,hashsizes,collisions,roles,airtime}`,
`/audio-lab`, `/customize`, `/replay`.
## TDD red→green
- **RED** (`08adafdb`) — adds the gate + deliberately regresses
`--text-muted` from `palette-gray-700` (~10:1) to `#9ca3af` (~2.4:1).
axe-core fails on every light-theme cell.
- **GREEN** (`f62fb1e0`) — restores the M2 token. Net violations = 0
across all 38 cells.
## Scope discipline
- Only `color-contrast` (matches M2/M3/M4 scope). M6 owns `image-alt`,
`aria-required-attr`, `label`, mobile viewports, and letsmesh A/B.
- No new design tokens.
- M2-M4 tokens untouched.
## CI wiring
- `.github/workflows/deploy.yml:155` — selftest in JS unit block.
- `.github/workflows/deploy.yml:367` — real axe browser run in the
Playwright E2E block after the fixture server is up.
## Deps
`@axe-core/playwright@4.11.3` + `axe-core@4.12.1` added to
`devDependencies`. Pinned versions.
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
## What
Customize-v2 toggle **Hide 1-byte path hops** (Display tab). Default OFF
— operators opt in. When ON, 1-byte path-hash prefixes are filtered at
every render site without touching what's stored or what the firmware
does.
Render sites wired:
- **Packets list / detail** (`packets.js renderPath`) — group header,
child observations, detail dt/dd, BYOP overlay. Empty result renders
`(1-byte filtered)`.
- **Map polylines** (`map.js drawPacketRoute`) — intermediate hops
tagged `_hopHex`; origin/destination (from payload, no `_hopHex`) always
survive.
- **Route view** (`route-view.js`) — unique-paths picker + group counts
key on the filtered hop list, so routes that only differ by 1-byte hops
collapse.
- **Analytics route patterns** (`analytics.js`) — filters INPUT rows
whose `rawHops` contain any 1-byte token; header reports filtered/total.
## Why
1-byte hashes collide ~8-way at ~2k relay nodes (Cascadia scale). The
collisions inflate polyline noise, route-pattern row counts, and chip
clutter without adding signal. See #1633 for the full hypothesis.
## How (pure render-time)
New `public/hop-filter.js`:
- `MC_getHide1ByteHops()` / `MC_setHide1ByteHops(on)` — localStorage
`meshcore-hide-1byte-hops`, default OFF.
- `MC_isVisibleHop(hop, opts)` — predicate.
- `MC_filterPathHops(hops, opts)` — non-mutating array filter.
Nothing in the ingest / store / decode path changes. The hop hex stays
in `path_json`; only the render iterators drop it.
## Tests
`test-issue-1633-hide-1byte-hops.js` — 8 assertions:
- Default OFF (back-compat).
- `hopByteLen` semantics.
- `isVisibleHop` ON drops 1-byte, keeps 2/3-byte.
- `filterPathHops` non-mutating.
- `HopDisplay.renderPath` chip set after filter.
- Map polyline positions[] filter preserves origin/destination.
- Analytics route-pattern aggregation key collapses on filtered hops.
Wired into `.github/workflows/deploy.yml`.
Red commit: `6baa3f13` (5/8 ON-branch assertions failed on stubs).
Green commit: `5c0bbdba` (8/8 pass).
## Browser verify
Staging deploy of changed files. Packet `99ef781f42eb7249` (all 1-byte
path):
- BEFORE (toggle OFF): `3 HOPS — Station Rat → KO6IFX-R5 → little
russia`.
- AFTER (toggle ON): `3 HOPS — (1-byte filtered)`.
Customizer toggle visible + working in Display tab.
Fixes#1633.
---------
Co-authored-by: openclaw-bot <bot@openclaw.dev>
Co-authored-by: clawbot <bot@openclaw.local>
## What
Two Phosphor lint-gate breaches found by the v3.8.4 manual-test executor
— app-controlled UI labels still shipping raw emoji glyphs that the M6
final sweep (#1648) missed. One PR, two sprite swaps, same playbook as
#1657.
### Findings
| Test ID | Surface | Glyph | File:line | Fix |
|---|---|---|---|---|
| v384-1.2 | `/observers` `.obs-clock-naive-chip` | `⚠️` (U+26A0) ×14 |
`public/observers.js:30` | `ph-warning` sprite |
| v384-12.18 | `/analytics?tab=channels` encrypted row name cells | `🔒`
(U+1F512) ×158 | `public/analytics.js:978–979` | `ph-lock` sprite |
Finding 2 is a different surface from the M3/#1657 fix (which swapped
the section-header label, not the per-row `displayName`). The
unknown-encrypted row's `displayName` carried a raw `🔒 Encrypted (0xNN)`
text label that then flowed through `esc()` into the rendered name cell
as an escaped emoji glyph — exactly the same `innerText → innerHTML`
class of bug. Refactored to mirror the section-header pattern:
`displayNameHtml` carries the sprite-bearing raw HTML; `displayName`
stays plain text for sort/aria/tests.
## TDD
- **RED** `cde12370` — `test-issue-1648-followup-phosphor-leaks.js`
asserts ph-warning sprite + zero ⚠ in chip output, and ph-lock sprite +
zero 🔒 in analytics row labels. 6 assertions failed on master.
- **GREEN** `f1c64b17` — sprite swaps applied. All 9 assertions pass.
- **Anti-tautology proven both directions**: reverting only
`public/observers.js` → 2 chip-related assertions fail; reverting only
`public/analytics.js` → 4 analytics-related assertions fail.
## Verify
- ✅ `node test-issue-1648-followup-phosphor-leaks.js` — 9/9 pass
- ✅ `node test-issue-1648-m6-final-sweep.js` — 0 violations
- ✅ `node test-observer-naive-clock-1478.js` — 8/8 pass (existing chip
test accepts ph-warning sprite)
- ✅ `node test-analytics-channels-integration.js` — pre-existing
unrelated `Channel Analytics` failure only; encrypted-row assertions all
pass with new plain-text `displayName`
- ✅ pr-preflight all gates green (PII, branch-scope, red-commit,
CSS-var, LIKE-on-JSON, async-migration, XSS sinks)
- ✅ Browser-verified on staging: 11 chips render ph-warning sprite (0
emoji), 156 ph-lock sprites in row name cells (0 lock emoji on page)
Browser verified: http://analyzer-stg.00id.net/#/observers +
/#/analytics?tab=channels (hot-patched)
E2E assertion added: `test-issue-1648-followup-phosphor-leaks.js:67`
(chip), `test-issue-1648-followup-phosphor-leaks.js:147` (row cell)
---------
Co-authored-by: meshcore-bot <bot@meshcore.dev>
Fixes#1657
## Bug
On `/analytics` → **Channels** tab, the "Channel Activity" table's
group-header rows ("My Channels", "Network", "Encrypted") rendered
literal HTML source text:
```
<SVG CLASS="PH-ICON" ARIA-HIDDEN="TRUE"><USE HREF="/ICONS/PHOSPHOR-SPRITE.SVG#PH-KEY"/></SVG> My Channels
```
instead of the actual Phosphor sprites. Per-row encrypted/lock icons
rendered fine — the bug was isolated to the group-header render path.
## Root cause
`public/analytics.js` `channelTbodyHtml` builds each group-section
header by wrapping the section label in `esc()`:
```js
esc(sections[si].label) + ' <span class="text-muted">(' + rows.length + ')</span>'
```
But the labels (`sections[].label`) are hardcoded sprite-bearing
strings:
```js
{ key: 'mine', label: '<svg class="ph-icon" aria-hidden="true"><use href="…#ph-key"/></svg> My Channels' },
```
`esc()` HTML-encoded the `<` / `>` so the browser displayed the source
text rather than rendering the sprite. Affects all 3 groups (and any
future group with a sprite).
## Fix
Drop the `esc()` wrap on the hardcoded label (single line change, same
pattern as M3 commit 4ca73ced for mobile channel avatars). The
`(<count>)` suffix is numeric and was always safe.
## Tests
New `test-issue-1657-analytics-channels-group-sprites-e2e.js` (mobile
375 viewport, matching the bug report):
- (1) at least one group-header row renders
- (2) every header row contains a real `<svg.ph-icon>` child
- (3) per-group sprite refs resolve (My Channels → `#ph-key`, Network →
`#ph-radio`, Encrypted → `#ph-lock`)
- (4) the Channel Activity table's `innerText` contains no literal
`<svg` substring (escape-leak gate)
Wired into the CI E2E lane (`.github/workflows/deploy.yml`) immediately
after the M4 icons E2E.
## TDD evidence
- Red commit: `8f8781c1` — test + CI wiring only, no production change.
Test asserts behavior that did not exist on master → CI fails on this
commit.
- Green commit: `8385fa54` — 1-line fix to `public/analytics.js`. Test
passes.
## Anti-tautology proof
Hot-patched staging with the `analytics.js` from the red commit
(pre-fix), reloaded `/analytics?tab=channels` at 375 viewport, and the
in-browser DOM probe returned:
```
headers[0].text = "<svg class=\"ph-icon\" aria-hidden=\"true\"><use href=\"/icons/phosphor-sprite.svg#ph…"
headers[0].svgs = 0 // (2) would fail
headers[1].svgs = 0 // (2) would fail
literalSvg = true // (4) would fail
```
Restored the fixed file; same probe returned `svgs=1`, correct `uses[]`
refs, `literalSvg=false`.
## Staging verification
Hot-patched `corescope-staging-go:/app/public/analytics.js` (no restart
needed — static file). Mobile dark @ 375 viewport shows Network → radio
sprite and Encrypted → lock sprite rendering correctly. (My Channels
group not present because the e2e fixture has no `mine`-tagged channels
— expected; the test skips that assertion when the row is absent.)
## Scope discipline
Touched only:
- `public/analytics.js` (1-line `esc()` removal + comment)
- `test-issue-1657-…-e2e.js` (new)
- `.github/workflows/deploy.yml` (1-line E2E wire)
No broadening, no helper renames, no related-but-different
escape-removal opportunism.
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
Draft for milestone 4 of #1648 — emoji → Phosphor Icons (map & route
overlays).
Currently at the red commit (failing test only). Implementation follows.
Partial fix for #1648 (M4 of 6). Do NOT close the tracking issue.
---------
Co-authored-by: bot <bot@corescope>
## Summary
The server clamps `/api/nodes` `?limit` to **500** (DoS guard, PR #1540
/ v3.8.3) and orders by `last_seen DESC`. Every node-list consumer
issued a single big-`?limit` fetch and trusted it as the full set, so on
>500-node meshes the top-500-by-advert window silently hid the tail.
Because `nodes.last_seen` is updated **only on self-adverts** (never on
relay traffic; `UpsertNode` is called solely from the advert path), a
repeater that relays constantly but last advertised hours ago fell
outside that window and **vanished from the map and live view** — while
still showing "Active" in its detail panel and (since #1606) in the
paginated Nodes list.
#1606 fixed only the Nodes page (`nodes.js`). This generalizes that fix
to the deferred siblings.
## Changes
- **`public/app.js`** — new shared `fetchAllNodes(extraQuery, opts)`:
pages `limit=500` + `offset` until a short page (the server's `total` is
unreliable — clamped to the page size and overwritten with the filtered
length under area/region filters, so we stop on a short page, not on
`total`), dedups by `public_key`, returns the real deduped count as
`total`.
- **`public/map.js`**, **`public/live.js`** (keeps the
`LIVE_MAP_MAX_NODES` ceiling via `safetyCap`), **`public/analytics.js`**
(×2), **`public/packets.js`** now use the helper.
- **`public/area-map.html`** is standalone (cross-origin `baseUrl`, no
`app.js`) so it gets an inline copy of the same loop.
- **`.eslintrc.json`** — declare `fetchAllNodes` global (no-undef).
## Tests
- **`test-fetch-all-nodes-pagination.js`** — unit-tests the helper via
the real `api()`+`fetch` path: pagination past 500, short-page stop vs.
the unreliable server `total`, dedup across a page boundary, counts
pass-through, `safetyCap` bound. 5/5.
- **`test-map-nodes-pagination-e2e.js`** — browser E2E (Playwright)
proving `map.js` surfaces a 501st node reachable only on page 2 and
renders its marker. Verified **red→green**: against the pre-fix single
fetch all 3 assertions fail (500 nodes, page-2 node absent, no marker);
after the fix all pass. Wired into `deploy.yml`.
## Verification
- unit 5/5, E2E 3/3, `test-frontend-helpers.js` 611/611, `npx eslint
public/*.js` → 0 errors.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Implements the locked spec from #1359.
Red commit: 68a140a8 — `distinctRelayCount` stub returns 0; test fails
on assertion (compiles + runs to assertion, not a build error).
Green commit: 48c2ddad — real implementation.
## Backend (in-memory, no SQL, no schema change)
- `cmd/server/relay_airtime_share.go`
- `distinctRelayCount(tx)` — unions the resolved-pubkey reverse index
for `tx.ID`. That index already dedups `(pubkey-hash, txID)` pairs
across every observation's `resolved_path`, so its length IS the count
of distinct repeaters that forwarded the packet. NOT length of any
single observation's resolved_path (the bug-trap from #1358).
- `computeRelayAirtimeShare(window)` — per-tx `score = payload_bytes ×
distinctRelays`, bucketed by `payload_type`, sorted desc by airtime_pct.
- `GetRelayAirtimeShareWithWindow` — cached behind existing `rfCache` +
`rfCacheTTL` pool. Shallow-copies the cached payload with `cached=true`
for the client.
- `cmd/server/routes.go` — `GET
/api/analytics/relay-airtime-share?window=…` returning
`{rows:[{payload_type,type,count,count_pct,score,airtime_pct}],
total_count, total_score, window, cached}`.
## Frontend
- `public/analytics.js`
- `renderRelayAirtimeDumbbell(data)` — horizontal dumbbell chart per
payload_type. Gray dot = count %, colored dot = airtime %, connector
line between them = the divergence, shared 0-100% axis, sorted desc by
airtime.
- Tooltip: payload_type, count %, count N, airtime %, raw score,
within-mesh caveat.
- Title: **Relay Airtime Share**.
- Subtitle (exact): `Score = payload bytes × distinct repeaters that
forwarded the packet. Counts relay re-transmissions; originator TX
excluded. Not comparable across meshes.`
- Mounted on the Overview tab immediately beneath Payload Type Mix.
## Tests
`TestRelayAirtimeShare_ADVERTvsACKDivergence` — the locked acceptance
scenario:
- 1 ADVERT (200 B, 8 distinct relays) → score 1600, airtime 100%
- 1000 ACKs (10 B, 0 relays each) → score 0, airtime 0%
- Count distribution is the inverse (ACK 99.9%, ADVERT 0.1%).
- Sort assertion: ADVERT is rows[0] by airtime_pct desc.
Full suite: `go test -short ./cmd/server/...` → PASS (25.9s).
## Acceptance criteria
- [x] In-memory `airtime_usage_score` accumulator in analytics path
- [x] `distinctRelayCount(tx)` helper unioning resolved-pubkey reverse
index across all observations of `transmission_id`
- [x] `/api/analytics/relay-airtime-share?window=…` endpoint
- [x] Cached via existing `rfCache` + `rfCacheTTL`; no new cache layer
- [x] Dumbbell chart on `/analytics` beneath Payload Type Mix;
gray=count, colored=airtime, shared axis, sorted desc by airtime
- [x] Title + subtitle exactly as specified
- [x] Tooltip with payload_type, count %, count N, airtime %, raw score,
caveat
- [x] Unit test demonstrates the ADVERT-vs-ACK divergence
- [x] No new SQL, no new index, no schema migration (verified via diff)
- [ ] Live staging bench (<5ms p99 uncached / <1ms cached) — deferred to
follow-up; cached behind 60s `rfCacheTTL` so steady-state cost is a map
lookup
## Preflight overrides
- Branch scope cross-stack: justified — backend endpoint and frontend
chart are a single deliverable per #1359 spec (one chart bound to one
endpoint, no incremental staging).
Fixes#1359
---------
Co-authored-by: bot <bot@local>
## What
The Route Patterns chart on `/#/analytics` ignored the Time window
picker — every selection returned identical data. This PR threads
`?window=` through to the backing endpoints and the store-level
computation.
## Root cause
`cmd/server/routes.go:2065` (`handleAnalyticsSubpaths`) and
`cmd/server/routes.go:2090` (`handleAnalyticsSubpathsBulk`) never called
`ParseTimeWindow(r)`. The store-level entry points
(`GetAnalyticsSubpaths`, `GetAnalyticsSubpathsBulk`) had no window-aware
variant. The frontend (`public/analytics.js`) didn't append `&window=`
to the `/analytics/subpaths-bulk` request.
## Fix
### Backend (`cmd/server/store.go`)
Added `GetAnalyticsSubpathsWithWindow` +
`GetAnalyticsSubpathsBulkWithWindow`. Zero `TimeWindow` →
byte-equivalent to the existing fast path (no perf regression on the
default view). Non-zero window → iterate `s.packets`, filter on
`tx.FirstSeen` via `TimeWindow.Includes`, reuse `rankSubpaths`. Cached
by `(region|area|window)`.
```diff
-data := s.store.GetAnalyticsSubpaths(region, minLen, maxLen, limit)
+window := ParseTimeWindow(r)
+data := s.store.GetAnalyticsSubpathsWithWindow(region, minLen, maxLen, limit, window)
```
```diff
-results := s.store.GetAnalyticsSubpathsBulk(region, groups)
+results := s.store.GetAnalyticsSubpathsBulkWithWindow(region, groups, ParseTimeWindow(r))
```
### Frontend (`public/analytics.js`)
`renderSubpaths` now appends `&window=<value>` to the
`/analytics/subpaths-bulk` request, matching how RF / topology /
channels tabs already wire the picker.
## Before / after
```
GET /api/analytics/subpaths?window=24h → totalPaths=2 (all data — ignored window)
GET /api/analytics/subpaths?window=24h → totalPaths=1 (24h-bounded — honored)
```
## Tests
`cmd/server/subpaths_window_test.go`:
- `TestSubpathsHonorsTimeWindow_StoreLevel` — seeds a 1h-old tx with
path `[aa,bb]` + a 30d-old tx with path `[cc,dd]`; asserts the unbounded
call sees both and the 24h-windowed call sees only the recent one.
- `TestSubpathsHandlerHonorsTimeWindow` — same scenario via the HTTP
handlers for `/api/analytics/subpaths` and
`/api/analytics/subpaths-bulk`.
TDD: red commit `eefc27d3` (test fails on assertion with stub that
ignores window), green commit `4c4c45d0` (implementation makes it pass).
Full `go test ./...` in `cmd/server` green locally (~47s).
## Performance
Default view (no window selected) is unchanged — `window.IsZero()`
short-circuits to the existing precomputed-index hot path. Windowed view
is O(N_tx · path²), same complexity as the existing region-filtered slow
path. Results cached per `(region|area|window)`.
Closes#1217
---------
Co-authored-by: Kpa-clawbot <bot@corescope>
## Problem
`/analytics` Hash Usage Matrix 1-byte view excluded repeaters configured
for 2- or 3-byte hash prefixes. In MeshCore, 1-byte path-matching is a
first-byte equality check, so any packet routed by 1-byte hash collides
on that first byte regardless of the downstream repeater's configured
prefix size. Omitting multi-byte prefix repeaters under-reports real
conflicts in the 1-byte hash space.
## Fix
**Data layer — `cmd/server/store.go` (`computeHashCollisions`,
~L7907-L7918 before, L7907-L7941 after):**
Before — `one_byte_cells` was populated only from `prefixMap`, which
only contained repeaters with `hash_size == 1`:
```go
if bytes == 1 {
oneByteCells = make(map[string][]collisionNode)
for i := 0; i < 256; i++ {
hex := strings.ToUpper(fmt.Sprintf("%02x", i))
oneByteCells[hex] = prefixMap[hex]
if oneByteCells[hex] == nil {
oneByteCells[hex] = make([]collisionNode, 0)
}
}
} else if bytes == 2 { ... }
```
After — additionally project all `hash_size in {2,3}` repeaters to their
first byte:
```go
if bytes == 1 {
// ... (same baseline population) ...
for _, cn := range allCNodes {
if cn.Role != "repeater" { continue }
if cn.HashSize != 2 && cn.HashSize != 3 { continue }
if len(cn.PublicKey) < 2 { continue }
hex := strings.ToUpper(cn.PublicKey[:2])
if _, ok := oneByteCells[hex]; !ok { continue }
oneByteCells[hex] = append(oneByteCells[hex], cn)
}
}
```
The 2-byte view's bucketing is unchanged — that view continues to count
only repeaters configured for 2-byte prefixes (those semantics differ).
**UI — `public/analytics.js` L1459:** clarified the 1-byte view
description so the inclusion of multi-byte prefix repeaters is explicit.
## API shape
No response-shape change. `one_byte_cells[HEX]` is still
`[]collisionNode`; only the contents now include 2/3-byte prefix
repeaters in the appropriate first-byte buckets. The existing frontend
decoder is unaffected.
## Tests
-
`cmd/server/routes_test.go::TestHashCollisionsOneByteIncludesMultiBytePrefixRepeaters`
— seeds three repeaters with first byte `CC` configured for 1/2/3-byte
prefixes plus an unrelated `DD` repeater, asserts all three appear in
`one_byte_cells["CC"]`, and that the 2-byte view's `nodes_for_byte` is
unchanged.
Red commit `278bdf8d` (test only) fails on assertion ("got 1, want 3");
green commit `9127ea4e` passes.
## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ clean.
Closes#1218
---------
Co-authored-by: clawbot <bot@corescope>
🚧 Draft — red commit only. Tests added are expected to FAIL; fix lands
in next commit.
Follow-up to #1537 — security sweep found 3 additional stored XSS sinks
of the same class.
Once the green commit lands and CI is green, this body will be replaced.
---------
Co-authored-by: CoreScope Bot <bot@meshcore>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>