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>
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>
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>
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>
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.
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>
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.
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.
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.
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").
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.
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.
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>
## 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>
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>
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>
## 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>
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>
## Summary
Replaces the three drifted per-surface payload-type label vocabularies
with a single canonical map keyed by firmware enum name.
Per the locked triage comment on #1799
([comment-4823975431](https://github.com/Kpa-clawbot/CoreScope/issues/1799#issuecomment-4823975431)):
> Create `public/payload-labels.js` exporting `{GRP_DATA: {short:'Group
Data', long:'Group data packet', enumId:6}, ...}`. Migrate `packets.js
typeMap`, `packet-filter.js FW_PAYLOAD_TYPES`, `live.js TYPE_COLORS
legend` to consume it. E2E that scrapes each surface and asserts label
equality.
## Changes
- **`public/payload-labels.js`** (new) — canonical map exposed as
`window.PayloadLabels` and `window.PayloadLabelsApi`. Keys are firmware
enum names; values carry `{short, long, enumId}` plus derived
`SHORT_BY_ID` / `FW_PAYLOAD_TYPES` / `TYPE_ALIASES` for legacy callers.
- **`public/packets.js`** — `TYPE_NAMES` + `typeMap` now read from
`PayloadLabelsApi.SHORT_BY_ID`. Literal kept only as a defensive
fallback for the case where the script tag fails to load.
- **`public/packet-filter.js`** — `FW_PAYLOAD_TYPES` + `TYPE_ALIASES`
now sourced from `PayloadLabelsApi`. Literal fallback retained so `node
test-packet-filter.js` still works headlessly.
- **`public/live.js`** — legend `<li>` rows are now generated from
`window.PayloadLabels` in stable order, killing the third-vocabulary
`Message — Group text` / `Direct — Direct message` drift the #1797
review surfaced.
- **`public/index.html`** — `<script src="payload-labels.js">` loaded
before `roles.js` / `packet-filter.js` / `packets.js`.
- **`test-issue-1799-label-vocab-e2e.js`** (new) — Playwright E2E.
Scrapes `#liveLegend` rows and the `/packets` type-filter checklist,
asserts each label matches `window.PayloadLabels[ENUM].short` for
`TXT_MSG`, `GRP_TXT`, `GRP_DATA`. Also verifies `window.PacketFilter`
still recognises the enum names.
- **`.github/workflows/deploy.yml`** — wired the new E2E into the
existing Playwright block.
## TDD trail
- Red commit `eb392d4` — adds the failing E2E only (asserts
`window.PayloadLabels` exists and labels match; both fail).
- Green commit `44e902a` — introduces the canonical map and migrates the
three surfaces.
## Verification
- `node test-packet-filter.js` — 92/92 pass with the new fallback
wiring.
- Preflight: `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh
origin/master` — clean.
Browser verified: E2E `test-issue-1799-label-vocab-e2e.js` exercises
`/live` legend + `/packets` type filter against a Playwright headless
Chromium; CI's Playwright block runs it on every push.
E2E assertion added: `test-issue-1799-label-vocab-e2e.js:139` —
`assert(fromLegend === canon, ...)` and `assert(fromPackets === canon,
...)` per enum.
Fixes#1799
---------
Co-authored-by: mc-bot <bot@corescope>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@kpa.com>
Co-authored-by: clawbot <bot@clawbot.local>
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>
## Summary
Partial fix for #1770 (S quick-fix path only; L refactor remains as
follow-up).
The packets-view virtual-scroller assumes a constant
`VSCROLL_ROW_HEIGHT`, but the base rule at `public/style.css` L1097 lets
`td.col-details` wrap on narrow viewports (`white-space: normal;
word-break: break-word`). Wrapped rows produce variable row heights →
visible jitter when scrolling past ~900px on iOS.
**Quick-fix (S path):** under the existing `@media (max-width: 640px)`
block in `public/style.css`, clamp `.col-details` to a single line:
```css
.data-table td.col-details {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
```
Trade-off accepted in triage: Details column truncates on mobile in
exchange for smooth scrolling. The base rule keeps wrapping on desktop
(≥641px) so nothing changes there.
**Out of scope:** the full L-path fix (per-row measurement,
`_rowHeightsPx[]`, cumulative offsets, re-measure on hop-resolver
finalize) — tracked separately on #1770.
## TDD
- **Red commit** `7f58bedc` — adds
`test-issue-1770-mobile-row-clamp.js`, a CSS-grep test (same pattern as
`test-issue-1364-pill-no-clamp.js`) that walks every `@media (max-width:
640px)` block in `public/style.css` and asserts a `.col-details` rule
declares `white-space: nowrap`, `overflow: hidden`, and `text-overflow:
ellipsis`. Verified to FAIL on master (assertion failure, not a parse
error) and PASS after the CSS change.
- **Green commit** `d46271b8` — applies the 5-line CSS clamp inside the
existing mobile breakpoint at L2362.
## Files touched
- `public/style.css` (+13)
- `test-issue-1770-mobile-row-clamp.js` (+101, new)
## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all gates pass (PII, branch scope, red commit, css-vars, css
self-fallback, LIKE-on-JSON, sync migration, async-migration, XSS). No
warnings.
---------
Co-authored-by: clawbot <bot@clawbot.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Summary
Extend CONTROL packet decoding to surface DISCOVER_REQ / DISCOVER_RESP
subtype plus body fields in the packet detail view. Previously only the
byte0 zero-hop flag was decoded; the body was rendered as opaque hex.
## What changed
**Backend** — `cmd/ingestor/decoder.go` `decodeControl()`
- New `Payload` fields (all omitempty): `CtrlSubtype`, `CtrlFilter`,
`CtrlTag`, `CtrlSince`, `CtrlNodeType`, `CtrlSNR`, `CtrlPubKey`.
- Subtype derived from `byte0 & 0xF0`: `0x80` → `DISCOVER_REQ`, `0x90` →
`DISCOVER_RESP`, otherwise `UNKNOWN`.
- REQ body parsed when `len(buf) >= 6`: `filter:u8 | tag:u32 LE`, plus
optional `since:u32 LE` when 4 more bytes remain.
- RESP body parsed when `len(buf) >= 6`: `node_type` (low nibble of
byte0), `snr:i8`, `tag:u32 LE`, and `pubkey` hex — 32 bytes when full, 8
bytes when prefix-only.
- Every field gated on length; short/truncated bodies emit subtype only
and never panic.
- `CtrlZeroHop` retained for backwards compatibility (rename flagged for
follow-up per triage).
**Frontend** — `public/packets.js` `getDetailPreview()`
- New `decoded.type === 'CONTROL'` branch renders subtype + present body
fields (filter / tag / since / node_type / snr / pubkey). Each field
shown only when populated, so truncated CONTROL still gets a subtype
label.
## Wire format reference
- `firmware/src/Mesh.cpp:69` — `CTL_TYPE_NODE_DISCOVER_REQ=0x80`,
`CTL_TYPE_NODE_DISCOVER_RESP=0x90`.
- `firmware/examples/simple_repeater/MyMesh.cpp:773-820` — body parse /
build.
## Tests (red → green, per AGENTS.md STRICT TDD)
- `cmd/ingestor/issue1802_test.go` — 6 cases: REQ full body (with
since), REQ no-since, RESP 32B pubkey, RESP 8B prefix pubkey, RESP
truncated pubkey (no panic, no pubkey emitted), short body (subtype
only), unknown subtype. Red commit `43713d3a` → green commit `d4b28180`.
Pre-existing CONTROL tests (`TestDecodeControlZeroHop`,
`TestDecodeControlMultiHop`) still pass.
- `test-packets.js` — 3 cases on `getDetailPreview`: DISCOVER_REQ
(filter+tag rendered), DISCOVER_RESP (snr+pubkey rendered), UNKNOWN
subtype label. Red commit `be23e349` → green commit `845d6c48`.
## Preflight overrides
- `check-branch-clean` (cross-stack): justified — issue #1802 explicitly
spans backend decoder (`cmd/ingestor/decoder.go`) and frontend renderer
(`public/packets.js`) per triage comment. Tests in both layers.
Single-purpose PR.
## Scope discipline
Files touched: `cmd/ingestor/decoder.go`,
`cmd/ingestor/issue1802_test.go`, `public/packets.js`,
`test-packets.js`. No other files. No firmware changes. No
`cmd/server/decoder.go` changes. No `CtrlZeroHop` rename (deferred per
triage).
Fixes#1802
---------
Co-authored-by: clawbot <bot@meshcore.local>
## Summary
Fixes#1798. Extends the Packets-page `typeMap` in `public/packets.js`
to include three firmware payload types that were previously missing
from the multi-select checklist:
- `10` — Multipart
- `11` — Control
- `15` — Raw Custom
Other surfaces (`public/packet-filter.js` `FW_PAYLOAD_TYPES`,
`public/live.js` `TYPE_COLORS`, `public/map.js`) already knew about
these types; only the Packets-page checklist UI omitted them, forcing
operators to hand-type filter expressions to filter on them.
## Red → green
- Red commit: `359e3645ac41506e563c19dfbd49983fb4ec9638` — adds E2E that
opens `#typeMenu` and asserts each new `data-type-id="10|11|15"`
checkbox renders with the exact label. Fails on assertion (DOM selectors
return null) against the pre-fix `typeMap`.
- Green commit: `f484e8cb88659b14fed7aa7fefcdfb3f0eb6c186` — single-line
literal extension; test goes green.
## E2E assertion added
`test-e2e-playwright.js:576` — `Packets type filter includes
Multipart/Control/Raw Custom (#1798)` (asserts the three new
`data-type-id` checkboxes render with their exact labels in the rendered
Packets-page checklist DOM).
## Files touched
- `public/packets.js` — extend `typeMap` literal
- `test-e2e-playwright.js` — new E2E test asserting the three checkboxes
render
## Browser verified
E2E test scrapes the rendered Packets-page DOM via Playwright; CI runs
it against the local Go server fixture in the `e2e-test` job.
Fixes#1798
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
Fixes#1800.
## Three changes
1. **`routed_through` field** — new `FIELDS` entry. Resolves against
`packet.resolved_path` (handles both the JSON-string form from
`/api/packets/by-id` and the already-parsed-array form from
`/api/packets`). Returns a space-joined lower-case hex string so
`contains` / `starts_with` / `==` work the same way they already do for
`hash`.
2. **`path` desc clarified + `path_prefixes` alias** — `path` desc now
reads `Hop path as 1-byte prefixes joined (e.g. a3→7f). For pubkey
search use routed_through.` `path_prefixes` is added as a
discoverability alias and resolves to the same value.
3. **Lexer hex-token error** — when the number/duration tokenizer hits
an unknown unit AND the slice (extended forward through any remaining
`[0-9a-fA-F]`) is pure hex of length ≥ 4, the lexer now returns:
```
Hex value must be quoted: try 'field == "<hex>"' or use the
starts_with/contains operator
```
instead of `Invalid duration unit 'f' at position N (expected
s/m/h/d/w)`. The duration-unit error is preserved for non-hex cases
(`age < 5x` still errors with the original message).
## TDD
Red commit `e44ac00a` adds 7 assertions that fail with the unmodified
code (proven by stashing the impl and re-running — output: `7 failed`).
Green commit `7b623721` makes them pass.
Tests added in `test-packet-filter.js` (`#1800: …`):
- `routed_through starts_with "2f0b00"` matches packet with JSON-string
`resolved_path`
- same, against array-form `resolved_path` (handles real `/api/packets`
shape)
- `routed_through contains "<full-pubkey>"` matches
- `routed_through contains "2f0b"` matches
- `routed_through starts_with "deadbe"` does NOT match
- `path 2f0b001247a047ca` → error contains `Hex value must be quoted`
- regression: `path contains "a3"` still matches `path_json=["a3","7f"]`
- `routed_through` listed in `FIELDS`
- `path_prefixes` alias resolves like `path`
`node test-packet-filter.js` → `=== Results: 92 passed, 0 failed ===`
Sibling JS tests (`test-packet-filter-ux.js`,
`test-packet-filter-time.js`, in-file self-tests) all green.
## Browser verification
Browser tool was unavailable this session, so I executed
`public/packet-filter.js` in a Node VM context (identical execution) and
exercised it against a live `/api/packets?limit=200` response from
staging:
- `routed_through starts_with "41b1"` returned 1 matching packet (whose
`resolved_path[0]` is
`41b1eabc3c6e88997242051ee53fa5840761dff02ac5f6d9904f23985395ec31`)
- `routed_through starts_with "bccf91"` matched a packet with that hop
- `routed_through starts_with "deadbe"` matched nothing (correct)
- `PF.suggest('routed_t', 8)` returned `['routed_through']`
- `PF.suggest('route', 5)` returned `['route', 'routed_through']`
- `PF.compile('path 2f0b001247a047ca').error` is verbatim: `Hex value
must be quoted: try 'field == "<hex>"' or use the starts_with/contains
operator`
- `PF.compile('age < 5x').error` is still `Invalid duration unit 'x' at
position 7 (expected s/m/h/d/w)` — duration-unit message preserved for
non-hex cases.
## Out of scope (per issue)
- No server-side filter pushdown.
- No operator-list changes.
- No `resolved_path` changes — it already ships on `/api/packets`,
`/api/packets/by-id`, `/api/live`.
---------
Co-authored-by: meshcore-bot <meshcore-bot@users.noreply.github.com>
Red commit: 11d8c51e8a (CI:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2Fissue-1792)
## What
Render GRP_DATA (PAYLOAD_TYPE 0x06) channel hash + (when decrypted)
inner `data_type` / `data_len` / blob hex in the packets table "details"
cell, mirroring the existing GRP_TXT branch in `public/packets.js
getDetailPreview()`.
Previously these packets showed only the opaque payload bytes —
operators had no way to see channel distribution or recognize specific
data_type values at a glance.
## How
`public/packets.js` — one new branch right after the GRP_TXT branch:
- Always renders `Ch 0xNN` (from `channelHashHex` or computed from
`channelHash`).
- `decryptionStatus = no_key | decryption_failed` → status label, same
shape as GRP_TXT.
- `decryptionStatus = decrypted` → adds `type=0xNNNN len=N` plus a
`<code>` block with the blob hex, truncated to 32 hex chars (16 bytes)
with `…` when longer.
Inner layout per `firmware/src/helpers/BaseChatMesh.cpp:382-385` (uint16
LE data_type, u8 data_len, blob).
## TDD
- **Red commit** `11d8c51e`: 4 new assertion-shaped failures in
`test-packets.js` getDetailPreview suite (`packets.js tests: 70 passed,
17 failed` → +4 vs baseline 13).
- **Green commit** `3466ed09`: 17 → 13 failed (baseline
emoji-vs-Phosphor drift, unrelated). All 4 GRP_DATA assertions pass.
## Optional check #2 — backend JSON parity
Confirmed `cmd/server/decoder.go` and `cmd/ingestor/decoder.go` use
identical JSON tags (`channelHashHex`, `decryptionStatus`, `dataType`,
`dataLen`, `decryptedBlob`). No backend change needed — server emits
envelope-only fields, ingestor adds the inner fields when a channel key
matches; the frontend handles both shapes.
## Scope
- 2 files: `public/packets.js` (+17 lines), `test-packets.js` (+48 lines
test).
- No public API change. No CSS. No migration.
Preflight clean (PII, branch scope, red commit, CSS, LIKE-on-JSON,
sync/async migration, XSS sinks — all pass).
Fixes#1792
---------
Co-authored-by: Kpa-clawbot <bot@example.com>
Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Fixes#1791.
## What
Adds `6:'Group Data'` to the `typeMap` in `public/packets.js` so the
Packets-view "message type" multi-select shows a Group Data checkbox.
The filter pipeline already keys by integer payload_type, so this just
registers the missing option. Also aligns the Live-view legend label in
`public/live.js` to "Group Data" for cross-view consistency.
## Why
Triage (in #1791) confirmed payload_type=6 (GRP_DATA) was the only
ordinary type omitted from the static `typeMap`. `packet-filter.js`,
`live.js`, `app.js`, and `map.js` all already know about it — only the
Packets-page checklist was missing it.
## Test (TDD red → green)
Branch history (4 production commits before round-1 review):
- `19ed5beb` — **test-only red commit**: adds Playwright E2E that opens
the type-filter menu, asserts a `data-type-id="6"` checkbox labeled
"Group Data" exists, selects it, and asserts every visible row's type
badge reads "Group Data". Also seeds one GRP_DATA packet into the CI
fixture (`.github/workflows/deploy.yml`) so the filter has a row to
match.
- `823a7d8d` — adds the one-line `typeMap` entry. First CI run on this
commit failed on an unrelated test (not the #1791 assertion); the #1791
test ran and passed.
- `eec2428` — fixture cleanup: `path_json=[]`/`resolved_path=[]` so the
seeded GRP_DATA hop-row count matches the raw_hex `path_len=0`. CI
green.
- `8f85f5f` — labels the type-6 entry "Group Data" (was briefly "Grp
Data"). CI green.
E2E assertion: `test-e2e-playwright.js` block `Packets type filter
includes Group Data (#1791)`.
## Round-1 review follow-ups
- `e3651c99` — `public/live.js` legend: `'Grp Data'` → `'Group Data'`.
- `4475c2f7` — test cleanup hardening: error string aligned to
assertion, duplicated selector extracted, regex tightened to strict
equality, `#typeMenu` explicitly closed, `meshcore-time-window`
localStorage key cleared, page reloaded so the in-memory `selectedTypes`
Set is reset.
- `b90bc33f` — `.github/workflows/deploy.yml`: drop self-referential
`#1797` citation from fixture comment, switch synthetic fixture id from
`-1` to `-1000000` sentinel with explanatory comment.
## Scope
Single-line typeMap registration plus its E2E test scaffolding, fixture
seed, and the live.js label alignment.
---------
Co-authored-by: clawbot <bot@openclaw.dev>
Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
## What
Scrubbing the Live page timeline no longer re-renders all node dots.
## Why
`vcrReplayFromTs()` ran `clearNodeMarkers()` (wiping `nodesLayer` and
`nodeMarkers`) and then `loadNodes()` rebuilt every marker from scratch.
A single scrub click destroyed and recreated the entire node layer;
visible flicker plus unnecessary DOM work (even though `addNodeMarker()`
already no-ops nodes that still exist).
## How
- `vcrReplayFromTs()` now clears only the transient animation/path
layers.
- The time-scoped branch of `loadNodes()` reconciles against the
existing markers: removes only nodes absent at the target time, adds
genuinely new ones, leaves shared dots untouched.
## Testing
- `test-live.js`: 95/95 pass
- `node --check public/live.js` clean
The live-map animation overlay re-clears and re-draws its full backing
store
every animation frame. Two unbounded multipliers make that expensive:
1. devicePixelRatio is uncapped in updateAnimCanvas(). The canvas is
already
~1.4x the screen area (20% pad per side), so at DPR 2-3 it allocates and
fills 5-12x the screen's pixels per frame. Cap at 1.5 — lines stay
crisp,
per-frame fill cost drops up to ~4x on hi-DPI displays.
2. renderAnimations() reschedules via rAF with no rate limit, so on
120/144Hz
displays it does 2-2.4x the work for no visible gain. Add a ~60fps
guard.
Progress is time-based (tickDt, itself capped at 32ms), so skipping
frames
preserves motion exactly. Paused frames fall through to the existing
sleep.
No behavior change on a standard 60Hz / 1x-DPI display. Existing
animation
tests (test-live-dt-cap-1524, test-live-anims) unaffected.
Co-authored-by: Michael <claude@michael.arcan.de>
Co-authored-by: efiten <erwin.fiten@gmail.com>
## Summary
Adds **Firmware** and **Client** columns to the observers table
(`#/observers`). Both values already come back from `/api/observers`
(`firmware`, `client_version`) — they were just never rendered. Fleet
operators have been asking to sort/scan firmware versions to coordinate
upgrades.
Closes#1789.
## Changes
- `public/observers.js`
- Two new `<th data-priority="4" data-sort-key="...">` headers
(Firmware, Client). Priority 4 matches Clock Offset / Uptime so
`TableResponsive` hides them first on narrow viewports.
- Two new `<td class="mono">` cells with
`data-value="${escapeHtml(raw)}"` for sort and the rendered text
escape-wrapped.
- `truncateBuildSuffix()` helper trims the long `" Build: ..."` tail
from firmware in the displayed text; the full string is preserved in
`title=` for hover.
- `test-issue-1789-observer-firmware-cols.js` — TDD red→green
static-source regression test (same pattern as
`test-observers-headings.js`).
- `test-observers-headings.js` — updated expected heading list with the
two new columns (existing #1039 invariant test).
- `test-all.sh` — wires the new test into CI.
## TDD evidence
- Red: `c6c4e594c1084d664730666ba069871ac6d9755c` — test commit fails on
assertions (not import errors): 5/6 cases fail because the
headers/cells/title attr don't yet exist; the column-count invariant
still passes because both thead and tbody are unmodified.
- Green: `02a0246a185950c903828ac1225d6795f5a3b2f4` — implementation;
all 6 cases pass.
## Browser verified
To be verified post-deploy on staging
(`http://analyzer-stg.00id.net/#/observers`). No backend changes —
purely additive frontend render of fields that are already on the wire.
## Perf
No new API calls, no extra fetches, two extra template-literal cells per
observer row (~10s of observers in prod). O(n) render unchanged.
---------
Co-authored-by: clawbot <bot@meshcore.local>
Fixes#1782.
## Problem
When `clientRxCoverage` is enabled, the **Coverage** route
(`#/rx-coverage`) is reachable from the desktop top-nav but
**unreachable on mobile** — neither the bottom-nav "More" sheet (phones,
≤768px) nor the edge-swipe drawer (touch tablets, >768px) lists it.
## Root cause
`public/roles.js` injects the Coverage link **only into the desktop
top-nav** (`.nav-links`), gated on `window.MC_CLIENT_RX_COVERAGE`. The
two mobile nav surfaces build their long-tail lists from **independent
hardcoded arrays** that omitted `rx-coverage`:
- `public/bottom-nav.js` → `MORE_ROUTES`
- `public/nav-drawer.js` → `ROUTES`
Both even carry `!! MANUAL SYNC REQUIRED !!` comments. Because the link
is injected into the DOM (not these arrays) and is config-gated, it
never reached mobile.
## Fix
Both surfaces now insert the Coverage entry **right after Analytics**
(matching the desktop top-nav insertion point) when
`window.MC_CLIENT_RX_COVERAGE` is true. The check is evaluated at **lazy
build time** (first sheet/drawer open), by which point `MeshConfigReady`
has resolved the flag. Default-off behaviour is unchanged, so the
default nav still matches the existing nav-overflow tests.
## Testing
Adds `test-rx-coverage-mobile-nav-e2e.js`, which:
- skips cleanly when Chromium is unavailable (`CHROMIUM_REQUIRE=1` makes
it a hard fail) or when `clientRxCoverage` is disabled — mirroring
`test-node-reach-coverage-e2e.js`;
- at 360px asserts Coverage is present in the bottom-nav More sheet,
ordered after Analytics, and that tapping it navigates to
`#/rx-coverage`;
- at 1024px asserts Coverage is present in the edge-swipe drawer,
ordered after Analytics.
Verified locally against a server built from this branch with
`clientRxCoverage` enabled (migrated `test-fixtures/e2e-fixture.db`):
- new test: **3/3 pass**; reverting the two source files makes it **fail
3/3** (true regression test);
- existing nav e2e suites still green: `test-nav-drawer-1064-e2e.js`
(11/11), `test-bottom-nav-1061-e2e.js` (31/31),
`test-nav-more-floor-1139-e2e.js` (10/10).
## Notes
- No perf impact: the route list is built once, lazily, on first
sheet/drawer open.
- The hardcoded `MORE_ROUTES` / `ROUTES` arrays remain the source of
truth for the always-on routes; this only conditionally appends the one
opt-in route, consistent with how `roles.js` already gates the desktop
link.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Closes#1780.
## What
Adds an opt-in **"Multibyte only"** toggle to the live map controls.
When ON, packets whose path hash size is `< 2` bytes (single-byte, or
unresolvable) are excluded from the entire live view — feed, map
polylines/rain, and the packet counter — in both LIVE and REPLAY modes.
- **Default OFF** — no behavior change for existing users.
- Persisted in `localStorage` under `live-multibyte-only`.
- Distinct from the existing global "hide 1-byte path hops" toggle: that
filters individual hops within a path at every render site; this filters
whole packets, on the live view only. They share no state.
## How
- **`public/hop-filter.js`** — new pure, dependency-free classifier
`MC_packetHashSize(rawHex, routeType)` returning `1|2|3`, or `0` when
unresolvable. Reads the path-length byte from `raw_hex` (`(pathByte >>
6) + 1`), offset `5` for transport routes (route_type 0/3) else `1` —
mirroring the existing `getPathLenOffset`/`computeBreakdownRanges` logic
in `app.js`. Lives next to the existing `hopByteLen`/`MC_*` family;
`app.js` is untouched (no duplication of the byte math).
- **`public/live.js`** — `groupIsMultibyte(packets)` consumes that
helper; applied at two render-time sites: the top of `renderPacketTree`
(above the counter increment, so the counter reflects multibyte-only)
and inside the `rebuildFeedList` group loop (so toggling re-filters the
buffered feed). Toggle markup + change handler mirror the existing
`liveFavoritesToggle` pattern.
## Why read from `raw_hex` and not the path hops
The hash size is a property of the whole packet and is present even for
zero-hop packets (where there are no hops to inspect), so reading the
path-length byte is correct in all cases. Unresolvable size is treated
as single-byte (excluded when ON) — we only show packets we can
positively confirm are multibyte.
## Performance (hot path)
The filter runs in the packet-render hot path, so: classification is
**O(1) per packet group** — it reads the first resolvable observation's
`raw_hex` (a short hex string, single `parseInt` of one byte) and
short-circuits. No per-packet API calls, no allocation in the loop, no
added O(n²). When the toggle is OFF (default) the check is a single
boolean guard and does nothing else. The buffered-feed re-filter reuses
the existing `rebuildFeedList` pass — no extra traversal.
## Tests
- **Unit** (`test-live-multibyte-filter.js`, 9 cases):
single/2-byte/3-byte classification, transport-route offset,
missing/short/garbage `raw_hex` → 0, whitespace tolerance.
- **E2E** (`test-live-multibyte-only-e2e.js`, Playwright): toggle
present and defaults OFF; ON hides a single-byte packet while a
multibyte one renders; OFF restores it; setting persists across reload.
Registered in the CI live-E2E block in `deploy.yml`.
## Docs
User-guide entry added in `docs/user-guide/live.md`.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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>
Resolves#1774.
## What
Adds `payload.destHash` and `payload.srcHash` to the filter autocomplete
suggestions array in `public/packet-filter.js`.
## Why
The decoder already emits `destHash` and `srcHash` JSON keys for `REQ` /
`RESPONSE` / `TXT_MSG` / `PATH` / `ANON_REQ` packets (see
`cmd/ingestor/decoder.go`), and the generic `payload.*` accessor in the
filter language (`packet-filter.js:296-308`) already evaluates these
fields correctly — i.e. `payload.destHash == "2f"` has always worked.
The gap was purely autocomplete + docs: the two names were missing from
the `FIELDS` (SUGGESTIONS) array, so operators discovering filters via
the suggestion popup couldn't find them.
Per the triage on #1774, the canonical names match the decoder's
serialization (`destHash` / `srcHash`), not the reporter's proposed
`dest` / `src` — so the filter token agrees with the raw-JSON view and
the packet detail's `Dest Hash (1B)` label.
## Tests
- Red commit (`test-packet-filter.js`): asserts
`filter('payload.destHash == "2f"')` matches a fake REQ packet AND that
`FIELDS` contains entries named `payload.destHash` / `payload.srcHash`.
Fails on the FIELDS assertion only (filter() already works).
- Green commit: adds the two entries. All 83 packet-filter tests pass.
## Scope
Single-file change in `public/packet-filter.js` (+2 lines) + 23 lines of
test coverage. No decoder changes, no backend changes, no API signature
changes.
Fixes#1774.
---------
Co-authored-by: clawbot <bot@corescope>
Red commit: d33a43c7 (CI run: will fail on assertion — 8 tests assert
new tone)
## Summary
Tones down the naive-clock observer banner from a big yellow alert card
to a small, neutral inline notice. Operators and firmware devs found the
original disproportionately scary.
**Before:** Yellow-bordered card, ⚠️ emoji, bold "Naive observer clock —
timing is being clamped" heading, shame words ("muddies
propagation-delay analytics"), multi-line fix instructions.
**After:** Single muted-color line with `role="note"`, no emoji, no
alarm styling. Fix guidance collapsed into a `<details>` expander.
- No backend behavior changes — clamping logic is untouched
- Observer list chip (`observers.js`) left as-is
Refs #1478
---------
Co-authored-by: Kpa-clawbot <bot@openclaw.local>