Commit Graph
1060 Commits
Author SHA1 Message Date
dborupandClaude Sonnet 5 fb7bb240cc feat: Wardriving signal quality trends (idea 4)
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>
2026-07-20 14:23:56 +02:00
dborupandClaude Sonnet 5 ea1d48aca8 feat: Wardriving channel analytics tab
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>
2026-07-20 13:50:59 +02:00
dborup 524b6326fd feat: show which regions are actually used per channel in Scope Adoption by Channel
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.
2026-07-20 11:21:46 +02:00
dborup 81bc9930af fix: address bot review on PR #1852 (2 MAJOR races + cleanup)
MAJOR (carmack): handleConfigClient and nodeListPostFilters both read
s.cfg.GeoFilter unlocked, but the field is cfgMu-guarded — PUT
/api/config/geo-filter swaps it under Lock(). Both sites now go through
s.getGeoFilter() like the other existing read sites.

MAJOR (dijkstra): window.MC_GEO_FILTER is set asynchronously by roles.js's
/api/config/client fetch, but nodes.js read it synchronously during
loadNodes(). On a cold page load where the Nodes page renders before that
fetch resolves, every node was silently classified as domestic until the
user clicked a filter chip. loadNodes() now awaits window.MeshConfigReady
(which never rejects) before the geo-scope filter runs. New regression
test proves the race with a manually-controlled promise, and fails
against the pre-fix code (verified via git stash).

MINOR: stale test-file header comment (still described the filter as
using the `foreign` flag, predating 6012ed0's lat/lon rewrite); loose
interface{} != float64 comparison in the geoFilter config test replaced
with explicit type assertions so a shape change fails loudly instead of
comparing unequal to the wrong type.

NIT: trimmed an 8-line struct-field doc comment; added
role="group"/aria-label to both the Status and Domestic/Foreign
filter-button groups on the Nodes page (bot noted Status already lacked
one too).
2026-07-20 10:04:00 +02:00
dborup 2c80b44787 fix: Domestic/Foreign node filter now classifies from lat/lon, not the stale foreign flag
The `foreign` flag only reflects nodes whose ADVERT was decoded and
classified AFTER geo_filter was configured. On stg.meshview.dk this made
the just-shipped filter nearly useless: only 27 of 1503 nodes were
flagged foreign, while 570 actually have GPS placing them outside the
configured box (543 of those simply never got re-flagged).

Exposes the geo_filter box/polygon via /api/config/client (new
GeoFilter field on ClientConfigResponse), and adds nodePassesGeoFilter
+ helpers to app.js — a faithful JS port of internal/geofilter's
PassesFilter/PointInPolygon/DistToSegmentKm, cross-checked line-for-line
against the real Go implementation for bbox, polygon, and buffer cases.
Nodes with no GPS fix (or (0,0)) count as domestic, matching both the
Go semantics and the user's explicit choice for this feature.

nodes.js's Domestic/Foreign filter-group (added in f99df27) now calls
nodePassesGeoFilter(n.lat, n.lon, window.MC_GEO_FILTER) instead of
reading n.foreign.
2026-07-20 08:16:39 +02:00
dborup 06554ba172 feat: Domestic/Foreign filter on the Nodes page
The unfiltered node list is dominated by foreign-flagged nodes (#730
geo_filter classification) on deployments with heavy cross-border
traffic — hard to see which of 1500+ listed nodes are actually local.
Adds an All/Domestic/Foreign filter-group next to the existing Status
filter, narrowing on each node's `foreign` boolean, persisted to
localStorage like the other Nodes-page filters.
2026-07-20 08:16:39 +02:00
dborup d947ada49a feat: show all channels in Scope Adoption, filter encrypted/unencrypted
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.
2026-07-19 22:46:26 +02:00
dborup 097406e822 fix: Activity by Hour of Day always visible, moved under highlight cards
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.
2026-07-19 22:46:26 +02:00
dborup 4fe212a5b8 feat: Regions/Hygiene highlight stat cards on Scopes Overview
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.
2026-07-19 22:46:26 +02:00
dborup 84ba1da0f3 fix: keep Scopes tab section descriptions visible while collapsed
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.
2026-07-19 22:46:26 +02:00
dborup f2fc3931a4 feat: role/search filter for Scopes tab "Nodes Without a Default Scope"
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.
2026-07-19 22:46:26 +02:00
dborup 99b471c41b fix: preserve Scopes tab collapsible section open state across auto-refresh
The 60s auto-refresh rebuilds each section's innerHTML from scratch, which
was silently re-collapsing any <details> a visitor had open mid-read.
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 a301de1ece feat: collapsible <details> sections within each Scopes sub-tab
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>
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 904fd5bf42 feat: split the Scopes tab into Overview/Regions/Hygiene sub-tabs
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>
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 30120bcf2e feat: Repeaters Never Relaying Any Scope section on the Scopes tab
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>
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 40391b126d feat: Nodes Not Using Any Scope section on the Scopes tab
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>
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 4855e26277 fix: correct Foreign-Flagged Nodes caveat — long relay chains are real, not a GPS artifact
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>
2026-07-19 06:46:31 +02:00
dborupandClaude Sonnet 5 7141acca7a fix: address remaining bot-review findings on the geo_filter/Foreign Traffic work
- 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>
2026-07-18 22:19:21 +02:00
dborupandClaude Sonnet 5 ac50b2f3e6 docs: add GPS-reliability caveat to Foreign-Flagged Nodes section
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>
2026-07-18 20:57:32 +02:00
dborupandClaude Sonnet 5 a55c237ea1 fix: move Foreign-Flagged Nodes list above the unscoped-relay table
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>
2026-07-18 20:45:43 +02:00
dborupandClaude Sonnet 5 492d15c43b feat: list foreign-flagged nodes on the Foreign Traffic tab
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>
2026-07-18 20:41:21 +02:00
dborupandClaude Sonnet 5 62f1023c2f fix: stop implying flood.max.unscoped 0 is the well-configured target
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>
2026-07-18 20:34:04 +02:00
dborupandClaude Sonnet 5 679ab2fb86 test: add DOM coverage for the Foreign Traffic tab + auto-refresh hook
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>
2026-07-18 20:23:09 +02:00
dborupandClaude Sonnet 5 4a7dd6b795 feat: add Foreign Traffic tab surfacing unscoped relay volume per repeater
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>
2026-07-18 19:44:39 +02:00
dborupandClaude Sonnet 5 8545af6269 fix: move Nodes Running This Region above Bridge Repeaters on Scopes tab
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>
2026-07-18 19:09:37 +02:00
dborupandClaude Sonnet 5 10eed4b2f6 fix: address remaining bot-review findings on PR #1852
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>
2026-07-18 19:02:20 +02:00
dborupandClaude Sonnet 5 696aecc10d test: add coverage for scopeCellHtml and the column-prefs backfill
Per bot review on PR #1852 (comment 5011643190): two changes in the
recent delta had zero test coverage, which the repo's TDD policy
(AGENTS.md § Test-First Development) requires for both net-new UI
helpers and bug fixes on existing UI.

- scopeCellHtml (public/app.js): unit tests for all three states
  (non-transport dash, resolved region, unresolved "unknown") plus an
  XSS-escaping check.
- Column-prefs backfill (public/packets.js): extracted the inline
  reconciliation logic into a standalone reconcileVisibleCols(),
  exposed via _packetsTestAPI like the rest of packets.js's pure
  helpers, and unit tested directly instead of only through the DOM.

Writing the test surfaced a real bug in the original backfill: it
couldn't distinguish "column didn't exist yet" from "user explicitly
unchecked this column" — both look identical as a missing key in the
saved array, so any existing column a user had hidden (e.g. Observer)
would get silently resurrected on next page load. Fixed by persisting
a second "known columns" baseline (packets-visible-cols-known)
alongside the visibility array: a missing key only gets backfilled as
newly-visible if it's also absent from the known-columns baseline: a
pre-existing key missing from the saved array is a deliberate user
choice and stays hidden. Falls back to the pre-#1852 column list for
visitors whose saved prefs predate persisting the baseline at all.

Also registers scopeCellHtml in .eslintrc.json's cross-file globals
list (same treatment as transportBadge) — packets.js referencing it
was tripping no-undef since app.js and packets.js are separate
non-module scripts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 18:48:15 +02:00
dborupandClaude Sonnet 5 bcbcae1d9d fix: retroactively show newly-added table columns for saved column prefs
The Packets column-visibility toggle persists an explicit array of
visible column keys to localStorage. A returning visitor with a
pre-existing saved array (from before the Scope column existed) never
got it shown, since the load path used the saved array verbatim
instead of reconciling it against the current COL_DEFS. Missing keys
now default to visible (unless narrow-viewport-hidden), same as a
fresh visitor gets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 15:39:02 +02:00
dborupandClaude Sonnet 5 e4593e98d4 feat: add sortable Scope column to Packets table
Region-scope was only visible as a small inline pill inside the Type
column (transportBadge). Split it into its own sortable "Scope" column
between Type and Observer, matching the same three states (region
name / unknown / not applicable for non-transport packets). Wired
into the existing column visibility toggle and mobile narrow-width
hide list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 15:20:22 +02:00
dborup 064a9c8e35 feat: scope adoption per channel on the Scopes analytics tab
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.
2026-07-18 13:53:33 +02:00
dborup 03abeba00e feat: hour-of-day activity heatmap on the Scopes analytics tab
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.
2026-07-18 13:22:57 +02:00
dborup 4f0610296d fix: always show heading for the region-breakdown sections when empty
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.
2026-07-18 13:08:16 +02:00
dborup 1aed3ee5c8 feat: bridge-repeaters breakdown on the Scopes analytics tab
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.
2026-07-18 12:55:36 +02:00
dborup c74a005247 feat: channel-messages-only scoped/unscoped breakdown on the Scopes tab
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.
2026-07-18 12:04:08 +02:00
dborup 40dc297e2b Merge remote-tracking branch 'upstream/master'
# Conflicts:
#	public/packets.js
2026-07-18 11:16:05 +02:00
Kpa-clawbotandclawbot a06ac8aceb fix(#1849): render — for TRACE Hop Bytes column (path bytes are SNR, not hop hashes) (#1850)
Fixes #1849.

## Problem
The packets table "HB" (Hop Bytes / col-hashsize) column always shows
`1` for TRACE packets. TRACE packets use header path bytes as per-hop
SNR readings, not truncated hop hashes — see
`internal/packetpath/route.go` `PathBytesAreHops(TRACE) = false`. The
high-2-bit "hash_size" derivation applied to a SNR byte is meaningless
(typically `1`).

## Fix
At the 3 render sites in `public/packets.js` (`buildGroupRowHtml` header
row, its child rows, `buildFlatRowHtml`), when `payload_type === 9`
(TRACE) render `—` in the `col-hashsize` cell with a `title` tooltip
explaining that TRACE path bytes are SNR readings and directing users to
the sidebar decoder for the actual hop count. Non-TRACE rows unchanged.

## TDD
- Red commit: `dd7a4e78` — `test-issue-1849-trace-hashbytes.js` asserts
col-hashsize cell equals `—` with a title tooltip for TRACE, numeric for
non-TRACE. CI must fail on this commit.
- Green commit: `115368d0` — 3-site fix in `public/packets.js`.

## Verification
```
$ node test-issue-1849-trace-hashbytes.js
✅ All 4 tests passed
```
Preexisting failures in `test-packets.js` (13) are unchanged by this PR
(verified via `git stash`) — unrelated to the surface touched here.

## Scope
- `public/packets.js`: 3 render sites, +21/-8.
- `test-issue-1849-trace-hashbytes.js`: new unit test.
- `test-all.sh`: wire new test.
No public API change.

---------

Co-authored-by: clawbot <bot@example.invalid>
2026-07-18 01:47:37 -07:00
dborup a44f2a4ad5 feat: nodes-running-this-region breakdown on the Scopes analytics 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".
2026-07-18 08:48:09 +02:00
dborup 87d2f479a7 feat: repeaters-by-region breakdown on the Scopes analytics tab
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.
2026-07-17 16:52:00 +02:00
dborup afa76c4f52 feat: region-utilization report on the Scopes analytics tab
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.
2026-07-17 16:29:06 +02:00
dborup 31f3e77bf6 fix: show resolved scope name directly in the packets-tab badge label
The transport badge previously only surfaced a known scope in the
title tooltip, requiring a hover. Now the label itself reads
"T·#region" so it's visible at a glance, matching the "T?" unknown
case which was already inline. Badge gets a max-width + ellipsis so
long region names (e.g. "#dk-trekantsomraadet") don't blow out the
Type column — full name stays in the title.
2026-07-17 15:56:16 +02:00
dborup aaef080f94 feat: surface scope on the Packets tab's transport badge
The packet detail pane already showed scope (with an "unknown scope"
fallback for empty scope_name), but the packets table itself gave no
at-a-glance signal — the existing transportBadge() "T" marker only
encoded route type.

transportBadge() now takes an optional scopeName argument: a resolved
region enriches the tooltip, an empty scope_name (transport-eligible
but unmatched/ambiguous) renders as "T?" with a distinct muted badge
style instead of the confident amber, so it's visually distinguishable
from a resolved scope without relying on color alone. Existing callers
that don't pass scopeName (live.js) are unaffected.

QueryGroupedPackets (SQLite + in-memory) didn't select scope_name at
all — added it, since the Packets tab defaults to the grouped view.
2026-07-17 15:47:54 +02:00
dborup a94d57ed4a feat: show "Scope: unknown" for transport-scoped messages with no match
GetChannelMessages already returned an empty scope string for
transport-eligible packets whose region couldn't be determined (no
configured region matched, or matchScope now reports an HMAC
collision as unknown), but the UI treated empty scope the same as
"not applicable" and rendered nothing — indistinguishable from a
plain FLOOD/DIRECT message that never carries a scope at all.

Adds route_type to the channel-message payload (both SQLite and
in-memory paths, plus the decrypt-candidate and live WS paths) so the
frontend can tell "not transport-scoped" (routeType 1/2, no tag) apart
from "transport-scoped but unresolved" (routeType 0/3 with empty
scope, now shown as "Scope: unknown").
2026-07-17 15:29:41 +02:00
dborup 350bf7eeee fix: surface scope_name on live WS channel-message broadcast
The WS packet broadcast (IngestNewFromDB / IngestNewObservations in
cmd/server/store.go) builds its own map independent of the REST
response helpers, so scope_name was missing there even after it was
added to GetChannelMessages and txToMap. Adds it to both broadcast
paths and wires channels.js's live-append handler to read it, so
brand-new messages show their scope immediately instead of waiting
for the next periodic REST refresh.
2026-07-17 14:44:54 +02:00
dborup c686ae3f37 feat: surface transport scope on channel messages
Adds scope_name to GetChannelMessages (both SQLite and in-memory
store paths) and to the general packet response shape, then renders
it as "Scope: <name>" in the channel chat meta line so operators can
see which region scope a message was transported under.
2026-07-17 14:20:19 +02:00
Kpa-clawbotandcorescope-bot 8c3e397d39 fix(1846): drop 1200px cap on .observers-page (#1847)
Red commit: aabe8143d1

## Summary
Drop `max-width: 1200px` on `.observers-page` in `public/style.css`. The
observers table is column-dense and already runs `data-priority`
auto-hide on narrower viewports, so the 1200px cap crushed columns on
wide monitors while the mobile hide-column CSS had already dropped
columns for narrower widths — the exact "starts auto-hiding but never
expands back" behavior described in #1846.

Removes the cap so the browser layout uses available viewport width (as
`.analytics-page` does).

## Change
- `public/style.css:2113` — remove `max-width: 1200px` from
`.observers-page`.
- `test-issue-1846-observers-width.js` — new regression guard. Parses
`.observers-page` rule from `style.css`; fails if a `max-width` cap
`<1600px` is re-introduced (matches the `.analytics-page` convention
referenced in triage).
- `.github/workflows/deploy.yml` — wire the new test into the frontend
unit test job.

## Test discipline
- Red commit `aabe8143`: test only, no CSS change — CI fails on
assertion (`max-width 1200px must be >= 1600px`).
- Green commit `665e88a6`: CSS fix — test passes.

## Browser verification
Browser tool unavailable in this session (gateway restart required).
Change is a pure CSS width-cap removal on a single class; no JS, no
HTML, no theming implications. The regression guard makes silent
reintroduction impossible.

## E2E assertion
Regression guard is source-grep, not DOM-level:
`test-issue-1846-observers-width.js:41` (`.observers-page` block
max-width allowlist). Justification: bug is a single declarative CSS
attribute; a DOM E2E for it would just assert
`getComputedStyle(...).maxWidth === 'none'`, which mirrors the source
assertion at higher cost. If a follow-up wants a Playwright
viewport-resize check, easy to add.

## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— clean on the green HEAD.

Fixes #1846

---------

Co-authored-by: corescope-bot <bot@corescope.dev>
2026-07-12 22:41:01 -07:00
Kpa-clawbotandcorescope-bot c7f0c6931f fix(1843): restore QR quiet zone on node-details codes (#1844)
## Summary

QR codes on node-details pages didn't scan. Two `public/nodes.js` render
sites called `qr.createSvgTag(3, 0)`; the vendor lib treats the second
arg as an **absolute pixel margin**, so `0` removed the QR quiet zone.
QR spec requires ≥4 modules of light border, so every scanner rejected
the code.

With `cellSize=3`, four modules = 12 pixels. Changed both call sites to
`createSvgTag(3, 12)`:
- `nodes.js:813` — node-details list detail
- `nodes.js:1705` — node-map overlay (also swaps background to
transparent; with margin restored, dark modules stay 12px from the SVG
edge so the transparent overlay still parses over the map tile)

Out of scope, filed as follow-up in triage: contrast tuning of the
transparent-overlay branch.

## TDD

Red commit: `3be8552b` — [test-only, CI
red](https://github.com/Kpa-clawbot/CoreScope/commit/3be8552baff64795504e8dcb888ed25cbd6a50be)
Green commit: `6f7e83a6` — two-line fix + test passes

## Test

`test-issue-1843-node-qr-quiet-zone.js` — Node/vm harness that loads
`public/vendor/qrcode.js`, grep-asserts both `nodes.js` call sites use
margin ≥ 12px, renders the SVG, and checks the `<path>` `M` coordinates
keep all dark modules ≥ 12px from every viewBox edge (and viewBox =
`modules*cellSize + 2*margin`).

Verified red on parent commit (test fails with margin=0 leaving dark
modules at 0,0), green after fix (dark modules at 12,12 with 12px free
border).

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— all gates clean.

Browser verified: pending — will validate on staging after CI. (Frontend
UX bug; DOM/grep test above exercises the same SVG code path that
scanners consume.)

E2E assertion added: `test-issue-1843-node-qr-quiet-zone.js:63` (viewBox
+ `<path>` coord grep on real-rendered SVG).

Fixes #1843.

---------

Co-authored-by: corescope-bot <bot@corescope.dev>
2026-07-10 00:44:48 -07:00
Kpa-clawbotandcorescope-bot 59cf5130d1 fix(1838): fold non-transport routes into scope-stats Unscoped (#1842)
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>
2026-07-09 22:56:29 -07:00
Kpa-clawbotandopenclaw-bot 9d47a4adea fix(1836): normalize pubkey case on observer↔node cross-nav links (#1837)
Fixes #1836.

Observer↔node cross-nav links from #1826 land on 404 because pubkeys are
stored lowercase in `nodes` and uppercase in `observers`, and the
backend `WHERE` lookups are case-sensitive. The two link builders now
normalize case at the boundary.

## Fix
- `public/observer-detail.js`: observer → node href passes
`currentId.toLowerCase()`.
- `public/nodes.js`: node → observer href passes
`n.public_key.toUpperCase()`.

## TDD
- Red: `test-issue-1836-crossnav-case-normalization.js` asserts the two
hrefs contain `.toLowerCase()` / `.toUpperCase()`. Fails on master.
- Green: 2-line production change makes the test pass.

Scope: 2 production line edits + 1 new test file.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-07-08 23:08:42 -07:00
4f7bb245d4 fix(#1833): pin legend toggle button above VCR bar on Live view (#1834)
## Summary

Fixes #1833 — the `.legend-toggle-btn` (palette icon) on the Live view
was hardcoded to `bottom: 1rem`, so on typical desktop viewports it sat
underneath the VCR playback bar and was unreachable. The reporter had to
hide the legend via `localStorage` to work around it.

## Fix

`public/live.css:1313` — one-character-class change:

```diff
 .legend-toggle-btn {
   position: fixed;
-  bottom: 1rem;
+  bottom: calc(var(--vcr-bar-height, 58px) + 10px);
   right: 1rem;
```

Mirrors the existing pattern already used by every other bottom-pinned
Live overlay:

- `.live-feed` (live.css:1204)
- `.live-overlay[data-position="br"]` (live.css:1372)
- `.feed-show-btn` (live.css:903)

`--vcr-bar-height` is maintained by the ResizeObserver on `.vcr-bar`, so
the button now tracks bar growth (mobile two-row layout,
safe-area-inset) instead of overlapping.

Same class of regression as #685 / #1206 / #1107 — an overlay that was
missed in the prior sweep.

## TDD

- Red commit `f6a938b7`: `test-issue-1833-legend-toggle-vcr-offset.js`
asserts `.legend-toggle-btn`'s `bottom` declaration references
`var(--vcr-bar-height`. Fails on assertion (not build error) against the
hardcoded `1rem`.
- Green commit `e399092b`: the CSS one-liner above. All 3 assertions
pass.

Grep-based CSS assertion per AGENTS.md § "E2E-DOM-grep exemption" —
there is no existing Playwright test in this area that measures the
toggle-button rect against `--vcr-bar-height`, and standing one up would
be disproportionate for a single-property fix that mirrors three
existing, tested overlay patterns.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— all hard gates pass, no warnings.

## Browser verified

Not required per fix-issue skill (CSS-only, mirrors three existing
tested patterns). Staging will pick up the change on merge; visual
regression will be caught if the mirrored pattern breaks (grep test in
this PR + existing E2E tests on the sister overlays).

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-07-08 18:14:01 -07:00
Kpa-clawbotandmeshcore-bot ba68069c23 fix(#1825): add cross-nav links between observer and node detail pages (#1826)
Adds cross-navigation between the observer detail page and the node
detail page for the same pubkey (community feature request from
cwichura).

**Changes**
- `public/observer-detail.js`: new `<a
href="#/nodes/${encodeURIComponent(currentId)}">View node detail →</a>`
inside `.page-header`, next to the `<h2 id="obsTitle">`.
- `public/nodes.js`: new sibling `<a
href="#/observers/${encodeURIComponent(n.public_key)}"
class="btn-primary">Observer →</a>` in the same button row as the
`Analytics` / `Reach` anchors on the full node detail page. Uses the
existing `ph-eye` phosphor icon.

**Test — TDD red→green**
- Red commit: `8c2315e1` (`test(#1825): red — observer<->node cross-link
anchors missing`) — 4/4 assertions fail on master; CI RED.
- Green commit: `ff8f6ed7` — minimum production change; 4/4 assertions
pass locally.

Test file: `test-issue-1825-observer-node-cross-links.js` —
static-source DOM-grep style consistent with the neighbouring
`test-issue-1789-observer-firmware-cols.js` /
`test-observers-headings.js` pattern. It asserts:
1. observer-detail.js contains
`href="#/nodes/${encodeURIComponent(currentId)}"`.
2. That anchor sits inside the `.page-header` block.
3. nodes.js contains
`href="#/observers/${encodeURIComponent(n.public_key)}"`.
4. That anchor is a sibling of the analytics/reach anchors in the same
flex row.

**Notes**
- Pubkeys are `encodeURIComponent`-escaped on both sides
(defense-in-depth; MeshCore pubkeys are hex only).
- No API changes. No CSS changes. No new dependencies.

Fixes #1825

---------

Co-authored-by: meshcore-bot <meshcore-bot@users.noreply.github.com>
2026-07-06 20:13:04 -07:00