mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 13:29:54 +00:00
8933e2c703aaa48afcb7d3ffdccdd511bf091387
78
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8933e2c703 |
fix(channels): keep client-only state across a channel-list refresh (#2095) (#2096)
Fixes #2095. `loadChannels()` replaced the `channels` array with the server snapshot and carried nothing across, and `mergeUserChannels()` only ever ran from `init()`. So any refresh destroyed every field that exists only in the tab. **No race needed for the worst of it.** Change the region filter or toggle show-encrypted with a PSK channel open, and: - the **My Channels** section disappears — `renderChannelList()` derives it as `channels.filter(c => c.userAdded === true)`, and the server returns neither `userAdded` nor a `user:`-prefixed hash - every unread badge resets to 0 - the user's own labels vanish - `reconcileSelectionAfterChannelRefresh()` cannot find the `user:*` hash in the snapshot, so it nulls `selectedHash`, sets `messages = []`, rewrites the URL to `#/channels` and replaces the open conversation with "Choose a channel from the sidebar" ## What this does `mergeClientChannelState(fresh, prev)` mirrors `mergeWsAppendedIntoRest()`, which already does exactly this job for `messages` (#1498). Same shape: pure, takes both arrays as parameters, returns a fresh array, never aliases or mutates an input. It carries `unread`, `userAdded` and `userLabel` by hash, and keeps `lastActivityMs` / `lastSender` / `lastMessage` / `messageCount` when a WebSocket batch landed while the request was in flight and is therefore newer than the snapshot. Those four move together: a sender without its message reads as a different message. `mergeUserChannels()` now runs inside `loadChannels()`, before the render and before the reconcile, so all three call sites get it instead of `init()` alone. ## What it deliberately does not do **It does not resurrect a channel the snapshot left out.** A region-filter change legitimately narrows the list, so carrying survivors over would defeat the filter, which is a worse bug than the one being fixed. The helper only enriches rows already present in the fresh snapshot. That leaves half of finding 2 in the issue unfixed: a channel pushed by the WebSocket handler during the initial in-flight window is still dropped. That one self-heals on the channel's next packet, and the reverted preview line is overwritten by the next WS batch. Fixing it properly needs a way to tell "dropped because the snapshot is stale" from "dropped because the filter excludes it", which is a larger change than this. ## Tests `tests/unit/test-issue-2095-channels-client-state.js`, 11 cases, registered in `test-all.sh`. Part 1 exercises the helper directly. Part 2 drives the real `loadChannels()` through the existing `_channelsLoadChannelsForTest` hook with a stubbed `api()`, which is what proves the helper is wired in rather than merely defined. **Verified by mutation.** With the wiring removed from `loadChannels()` but the helper left in place, the three reproduction cases fail with exactly the reported symptoms: ``` FAIL a refresh keeps the My Channels rows My Channels lost on refresh (got ["public1"]) FAIL a refresh keeps unread badges the unread badge reset to 0 on refresh undefined !== 7 FAIL a refresh does not close an open PSK conversation the open PSK channel was deselected + null - 'user:MyPSK' ``` The fourth case, "a refresh still drops a channel the server filtered out", stays green throughout, so the fix cannot be defeating the region filter. Full frontend suite green: `sh test-all.sh` exits 0. ## Not done **No browser validation.** This is frontend JS covered by unit tests that call the production function through its own hook, but I did not run Playwright against a server, so I am not claiming a browser check I did not do. `init()` still calls `mergeUserChannels()` and `renderChannelList()` after `loadChannels()` resolves, which is now redundant and costs one extra full sidebar render per page load. Left alone deliberately: it is idempotent, and the comment there records the regression it was added to fix (`test-channel-issue-1111-e2e.js`, case 2). Worth removing separately with that e2e test watched. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3caa847323 |
fix(nodes): the advert section is called "Recent Adverts", and says why (#2071)
Closes #2042, reported by @damn-simple-scripts. ## Which of the two fixes The report offered widening the section to all packets the node originated, or renaming it, and proposed the rename. Widening sounds like the better fix, so I measured before agreeing. On a production database: | | | |---|---| | transmissions total | 1,251,967 | | with `from_pubkey` populated | 191,237 | | of those, `payload_type = 4` (ADVERT) | **191,237** | | with `from_pubkey` and **not** an advert | **0** | The ingestor fills that column for adverts only. Attributing a relayed CHAN or TXT packet back to its sender is the path-resolution problem, not a filter this section could apply — so "show all packets from this node" is not a small change, it is a different feature resting on attribution the data does not carry. So the rename is correct, and the numbers say so rather than my preference. ## The change Both copies renamed — the full node page and the side pane. Both already read `nodeData.recentAdverts`, so the field feeding them said "adverts" while the heading said "packets". The heading also gained a `title` naming `from_pubkey` as the reason it is adverts only. Renaming without explaining invites the same report from the next reader; the tooltip is where that explanation costs nothing. ## Tests `tests/unit/test-issue-2042-recent-adverts-label.js`, four cases: both copies present and headed "Recent Adverts", no copy back to "Recent Packets", the advert field still feeding them, and the explanation still in place. The third matters most — it ties the label to its data source, so pointing this section at a different field in future fails here rather than silently making the label wrong again. ## Not done, deliberately The reporter's follow-up idea, splitting flood adverts from zero-hop adverts into two lists. They wrote that it can be dropped if an issue should tackle one thing, so it is not here. It is feasible now: a zero-hop advert is `ROUTE_TYPE_DIRECT` with an empty path, established while fixing #2064. That deserves its own issue rather than a paragraph in this one — say the word and I will open it. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2cfe9cbbc5 |
fix(a11y): raise the Scope Audit observed-chip contrast above 4.5:1 (#2070)
Closes #1996. ## Reproduced first The chips tint their own background — `color-mix(in srgb, var(--status-green) 16%, transparent)` — which darkens whatever surface sits behind them. With `--status-green-text` (green-700, `#15803d`) on top: | surface | composited chip | ratio | |---|---|---| | `--surface-0` `#f4f5f7` | `#d2eddf` | **4.04:1** | | `--card-bg` `#ffffff` | `#dcf6e5` | **4.38:1** | Both under the 4.5:1 the #1719 gate requires for normal text. The first row is the same ratio **and the same hex** @n30nex measured in the browser, which is how I know the model in the test agrees with what a visitor actually sees. ## Fixed on the text, not by thinning the tint Dropping the tint to 12% reaches 4.52:1. That is two hundredths above the line, and a margin that thin fails again the next time a surface value moves — the same trap #2039 hit with a threshold sitting inside the healthy band. One palette step darker gives **6.23:1** on white and **5.74:1** on `--surface-0`, and keeps the tint that makes a chip read as a chip rather than as plain text. - `--palette-green-800: #166534` added. The greens ran 300–700 while the blues already reach 900, so this fills the scale rather than inventing a colour. - `--sa-chip-observed-fg` defined per theme: green-800 in light, and the bright `#22c55e` **kept** in dark, where the chip already passed at 6.48:1 / 5.73:1. Dark is deliberately untouched. - The chip reads the variable, so the customizer still governs it and no literal enters a component. `.sa-chip-verified` needed no change: `scope-audit.js:106` only ever adds it alongside `sa-chip-observed` or `sa-chip-unobserved`, and it contributes an underline. So the fix covers both classes the issue names — worth stating, since the title mentions both. ## Tests `tests/unit/test-a11y-1996-scope-audit-chips.js`, 7 cases: both themes × both surfaces, that the chip still tints (so the suite cannot pass by testing nothing), that its colour comes from a variable rather than a literal, and a guard asserting green-700 **would** still fail — so a quiet revert to `--status-green-text` turns this red instead of passing. **Red-run confirmed:** with the old colour restored it fails at exactly 4.04:1 and 4.38:1, naming the composited `rgb(210,237,223)`. Two deliberate choices in the test: - **A separate suite, not a case in `test-a11y-1719`.** That suite's `parseColor` handles hex and `rgb()` only; teaching it `color-mix()` is a larger change than this fix. These chips are the only contrast-critical user of the function today. If a second appears, the two should merge, and the file says so. - **Derived from the stylesheets, not a rendered page.** The default Scope Audit fixture renders no chips at all, which is precisely why the existing browser coverage missed this. A stylesheet-derived check cannot be defeated by a fixture that shows nothing. `check-css-vars` passes (180 definitions, 0 undefined) and `test-test-inventory` passes with the new file classified. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
435ac25dd6 |
feat(nav): show the running version in the nav-drawer footer (#2068)
Takes over #1985 by @SaarMesh-Bot, as offered there on 2026-09-16 and 2026-09-17. **Their commit is the first here, unchanged and under their authorship**; the rest clears the two review points. Closing #1985 in favour of this so the rebase and the fixes travel together, not to reassign the work. ## The feature, unchanged The frontend never surfaced which build was running, though `/api/health` has reported `{version, commit, buildTime}` all along. A footer on the nav drawer now renders `CoreScope <version>`, linking to the releases page, with commit and build time in the tooltip. Colours come from existing CSS variables, the label is set with `textContent`, and a failed health call leaves a neutral label rather than an empty footer — all as the author wrote it. ## Review point 1: fetch on open, not on page load The `/api/health` call sat in `buildDom()`, which runs on page load. The drawer may never be opened, and **cannot** be opened at ≤768px, where the module is disabled by design. So every visitor's browser was requesting the endpoint to fill a footer most of them would never see. Moved into `open()`, after the width gate. `fetchVersion` still caches its promise for the page lifetime, so re-opening costs nothing. ## Review point 2: the fallback is pinned `tests/unit/test-nav-drawer-version-footer.js`, six cases: - a rejected fetch, a non-ok response, and a 200 without `version` each leave the neutral `CoreScope` label — not a blank footer and not `CoreScope undefined`, which is what an instance shows exactly when someone is trying to read its version - a normal response renders the version, with commit and build time in the tooltip - a version carrying markup lands verbatim in `textContent`, and `innerHTML` is never touched - the endpoint is requested once however often the footer is filled, pinning the cache from the other side It **slices `fetchVersion` and `fillVersion` out of the shipped `public/nav-drawer.js`** and evaluates them rather than copying them into the test, so it exercises what ships — same approach as `tests/unit/test-direct-rf-heard-by.js`. Both slice markers are asserted, so a rename fails loudly instead of quietly testing nothing. Each case re-evaluates the slice, because `versionPromise` caches for the page lifetime and a shared sandbox would hand the second case the first case's answer. Listed in `test-all.sh`, which per #2036 is the only frontend runner. ## A note on the third commit The first version of the test used `setImmediate` to settle the promise queue. It ran fine under node and **failed eslint**, which treats these as browser code. I ran eslint and committed in the same command and pushed without reading its output. Fixed in the commit after, using `setTimeout(r, 0)`. Recording it because the PR would otherwise show a lint failure in its history with no explanation. ## Verification 6 of 6 in the new suite, eslint clean on both changed files, `test-test-inventory.js` passes with the new file classified. Cherry-picked cleanly onto current master. --------- Co-authored-by: SaarMesh-Bot <bot@saarmesh.de> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
d1b615fc0d |
fix(node-health): list only observers that heard the node on air (#2057)
Closes #2056. ## What changes The node detail "Heard By" card now lists only observers that received the node's **own transmission off the air**, and reports the rest as a count. ``` HEARD BY — DIRECT (8 OBSERVERS) OBSERVER REGION PACKETS AVG SNR AVG RSSI BE-DUF-SiSCD-01 — 16276 7.9 dB -108 dBm BE-BRU-Moris repeater — 13775 -5.6 dB -122 dBm ... Seen via relay by 29 observers. Those observers heard a repeater that forwarded this node's traffic, not this node. ``` and for a node nothing hears: ``` HEARD BY — DIRECT (0 OBSERVERS) No observer is within radio range of this node. Seen via relay by 2 observers. … ``` ## The rule, and where it comes from Read out of the firmware rather than assumed: | | | |---|---| | `Packet.h:83` | `setPathHashSizeAndCount(sz,n) { path_len = ((sz-1)<<6) \| (n&63); }` — hash size rides in the packet's `path_len` byte | | `Mesh.cpp:649,678` | only `sendFlood()` sets it, so the **originator** decides; `CommonCLI.h:69` defaults `path_hash_mode = 0`, i.e. one byte | | `Mesh.cpp:349` | a forwarding repeater appends its hash with the packet's size — it cannot upgrade a packet, and the **last hop is who was heard** | | `Mesh.cpp:89,103` | on a direct route a forwarder matches the head of the path and calls `removeSelfFromPath` before retransmitting, so the path is the **remaining** route and the transmitter is not in it | So an observation credits exactly one node: 1. Route type must be `ROUTE_TYPE_FLOOD` or `ROUTE_TYPE_TRANSPORT_FLOOD`. Direct routes never qualify (38% of transmissions over 7 days). 2. Empty path → the originator, known only for ADVERTs. 3. Otherwise the last hop. 4. The hop must resolve to exactly one candidate. Same gate `resolvePathForObsColdLoad` already applies: under-attribute rather than guess. It drops 418,530 of 1,455,721 flood observations with a path over 7 days (28.8%), and it is what stops the wrong-band credits. ## Measured effect | node | before | after | |---|---|---| | BE-BRU-Moris | 36 observers | 3 | | BE-KRO-RP01 \| ON1KW | 40 | 3 | | BE-BRE-ON8AR | 38 | 2 | | NL-BXE-RP01 \| 433 | 35 | 0 | Network-wide over 7 days, 234 of 1,860 nodes have at least one direct observer (161 have exactly one, maximum 8). The direct list is therefore empty for most nodes, with the relay count below it. That is the correct reading: no observer is in radio range of them. Independent corroboration on staging: for BE-WIL-3EIK-01 the eight direct observers are exactly the top eight entries of its Neighbors table by score and observation count. ## Perf justification `GetNodeHealth` is fast today precisely because it never walks observations — it uses one representative observation per transmission. Direct-RF needs the per-observation path, and that cannot be a per-request walk: the reference store holds **232,928 transmissions / 2,887,861 observations**, one node's `byNode` slice alone holds **55,458 transmissions / 1,450,544 observations**, and `/api/nodes/bulk-health?limit=200` would multiply that. So the aggregate is rebuilt by a background recomputer on the existing `newAnalyticsRecomputer` pattern, published into an `atomic.Value`. Reads are `O(direct observers)`, which is **cheaper than before** — the old code built per-observer sums over every transmission in `byNode` on every request. Proof, `BenchmarkBuildDirectHeardIndex`: ``` BenchmarkBuildDirectHeardIndex-12 1 63067900 ns/op ``` 3,000,000 observations (60,000 transmissions × 50 observations, 8-hop paths, 64 candidate repeaters) in **63 ms**, once per recompute interval. Per observation the walk does one route-type check, one backward scan of `PathJSON` for the last quoted token (no allocation, no `json.Unmarshal`), one prefix-map lookup and one counter update. Rebuilding wholesale also means eviction needs no bookkeeping: a pass simply does not see evicted transmissions. The alternative — a field on `StoreObs` updated incrementally — would have needed the call at five construction sites (`store.go:942,1264,2854,3179`, `chunked_load.go:609`), which is the duplication that caused #1558, plus matching decrements at eviction. ## API Both `GetNodeHealth` and `GetBulkHealth` carried a near-identical copy of the observer loop; they now share one builder. - `observers` — direct-RF only. Same field names, so no client migration. Rows are a named `HealthObserverRow` instead of `map[string]interface{}` (one fewer occurrence in a touched file, per the AGENTS.md ratchet). - `relayObserverCount` — new integer, observers that saw traffic through the node without hearing it. `stats.totalPackets` and `stats.avgHops` still count relayed traffic, so without this number the card would contradict the figures printed beside it. `docs/api-spec.md` is updated for both endpoints. It also documented an `iata` field on these rows that the endpoint has never emitted; removed. ## Tests - `cmd/server/direct_heard_test.go` — table test over the rule: flood with empty path and known originator, flood whose last hop is the node, flood whose last hop is another node, direct and transport-direct routes (never credit), ambiguous last-hop prefix, listener-only candidate, 1-byte and 2-byte hop sizes; plus aggregation and row-building. - `cmd/server/node_health_direct_rf_test.go` — end-to-end through the handler: an observer that only saw relayed traffic must not appear in `observers` but must be counted in `relayObserverCount`. Plus the benchmark. - `tests/unit/test-direct-rf-heard-by.js` — slices the card template out of `public/nodes.js` and evaluates it, so it tests the shipped markup rather than a copy: heading, empty state, relay line, singular/plural, signal columns, listener/repeater badge tri-state. - `cmd/server/node_health_can_relay_case_1290_test.go` — updated to seed a genuinely direct reception, since a relay-only observer no longer carries a badge. - `cmd/server/analytics_recompute_after_load_test.go` — recomputer count 10 → 11. Verified locally: `cmd/server` suite green, `sh test-all.sh` green (180 suites), `tests/e2e/test-e2e-playwright.js` 131/134 passed with 3 skipped and 0 failures against the seeded fixture, plus `test-issue-1147-section-order-e2e.js`, `test-issue-1151-orphan-separators-e2e.js` and `test-issue-1281-location-row-e2e.js`, which all assert on this card. `gofmt` clean, `vet` clean across all modules. Browser-validated on staging: both the full detail page and the side pane, on a node with 8 direct observers and on the 433 MHz node with none. No console errors. ## What this does not do `prefixMap.resolveWithContext` still guesses on ambiguous hops, so paths, neighbor edges and analytics keep their current attribution. Making it abstain is a much larger change and needs its own issue. The "Regions" line and Region column on this card read `o.iata`, which this endpoint has never emitted, so both have always been dead. Left as found rather than widened into this change. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5c016a210a |
fix(analytics): show a building state on the distance index's 202, and stop caching it (#2051)
Closes #1997. ## What was wrong `/api/analytics/distance` answers `202 {status:"building", retry_after_seconds:5}` with no `summary` until the lazy index (#1011) has been built. 1. `renderDistanceTab` read `data.summary.totalHops` straight away. The TypeError is caught by the tab's own try/catch, so it never reaches `window.onerror`: it is painted into the tab as `Failed to load distance analytics: Cannot read properties of undefined (reading 'totalHops')`. 2. `api()` caches any `res.ok` body, and `res.ok` is true for 202, so the placeholder was stored for the `analyticsRF` TTL. Even a correct retry read the cached "building" body back. That is the half that made the broken state outlast the index build. ## What changed - `public/app.js:173` skips the cache write when `res.status === 202`. A 200 still caches, unchanged. - `public/analytics.js` renders a building notice and retries itself, honouring `retry_after_seconds` clamped to [1s, 30s]. The timer is cleared on tab switch and in `destroy()`, and a new render supersedes a pending retry, so two renders cannot write into the same tab. The notice uses `.text-center`/`.text-muted` rather than the `.spinner` class used at `analytics.js:1683`, because `.spinner` has no CSS anywhere in the repo and renders nothing. ## Tests `tests/unit/test-issue-1997-distance-building.js` (7 assertions, wired into `test-all.sh`) pins the two pure decisions the renderer makes and `api()`'s refusal to cache a 202 while still caching a 200. Red-run on the unfixed sources: 6 of 7 fail, and the "a 200 is still cached" control stays green. `tests/e2e/test-issue-1997-distance-building-e2e.js` (classified in `scripts/non-unit-tests.json`, invoked from `deploy.yml` with `CHROMIUM_REQUIRE=1`) serves both responses by route interception, so it does not depend on whether the server under test has an index built. It asserts: the building state appears, the tab does not paint the error text, a retry arrives with no interaction, the retry replaces the placeholder once the server answers 200, and no retry fires after leaving the tab. Check (2) deliberately asserts on the rendered text and not on `pageerror`: the TypeError is caught, so a `pageerror` assertion would pass on the broken build too. ## Verification No local cgo toolchain here since #1992, so I could not build a server to run the E2E against. Instead I ran its five steps in Playwright against a live instance with this branch's `public/app.js` and `public/analytics.js` injected in place of the deployed ones (both files are byte-identical between that instance and upstream master, so the injection is faithful): | check | deployed build | this branch | |---|---|---| | (1) building state shown | fail | pass | | (2) not rendered as data | fail | pass | | (3) retried on its own | fail (1 request) | pass (2 requests) | | (4) real payload after retry | fail | pass | | (5) no retry after leaving the tab | pass | pass | (5) passes on the broken build too: it schedules no retry at all, so it is a control and only means anything together with (3). The committed E2E suite has not been run as committed. CI is its first real run. ## Not done - The server still recomputes on every 202 poll rather than signalling readiness. - No other analytics tab was audited for the same assume-a-summary pattern. - One pre-existing unit suite (`test-preflight-xss-gate.js`) fails on this Windows machine with a cp1252 `UnicodeEncodeError` from its Python helper, on a clean tree as well as with this change. Unrelated, and green on Linux CI. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bbf54cfe1b |
fix(rx-coverage): open at the configured map default, with its own saved viewport (#2033)
The coverage page opened at a hardcoded [51.0, 4.8] zoom 8 regardless of deployment, ignoring /api/config/map (#2032). It now follows the same precedence as the main map (URL hash, then saved position, then /api/config/map, then [37.6, -122.1] zoom 9) and persists its own position across visits, syncing lat/lon/zoom into the hash so a view is shareable. The saved position lives under its own key, rx-coverage-view, and the page never reads or writes the main map's map-view: sharing the configured default was the bug, sharing the session position was not. Both suites assert map-view stays untouched after a pan, so reintroducing a shared write fails instead of passing quietly. Also fixed here: selectedRx is now percent-encoded into the hash, and a generation counter stops a late /api/config/map response or a stale 150ms layout timer from building a map for a page that was already left. Reviewed twice. Verified by mutation rather than by reading: writing map-view too, ignoring the saved coverage position, and dropping the /api/config/map fetch each make the unit suite exit 1, so it covers the feature, the fix and the rejected alternative. The deploy.yml invocation was confirmed to land inside Run Playwright E2E tests (fail-fast) by parsing the workflow, and CI run 35261689694 is the E2E suite's first real execution: Go prints 'RX coverage viewport regressions OK' and Playwright prints 'RX coverage viewport browser regressions OK'. Worth recording for the next reviewer: that E2E asserted localStorage.getItem('map-view'), so wiring it into deploy.yml without updating the assertion would have turned the job red on its first ever run. It was registered in scripts/non-unit-tests.json but invoked by nothing, the gap tracked as #2037. Merged by the interim maintainer without a second human reviewer: CI and the mutation checks above are the independent checks. Fixes #2032 |
||
|
|
893773338e |
chore(tests): move root test-*.js into tests/unit and tests/e2e (#2036)
Moves 290 root test-*.js into tests/unit (177, listed in test-all.sh) and tests/e2e (113, classified in scripts/non-unit-tests.json), per #1981 and PR-D of #1385. Root goes from 348 entries to 48. test-all.sh and test-fixtures/ stay put. The inventory guard now fails if a test reappears in the root or sits in the wrong folder.
Verified independently of the diff: the invoked sets are unchanged (test-all.sh 177 before and after, deploy.yml 96 before and after, both identical as sets), and a full local run of test-all.sh on master and on the branch produced 4702 output lines each whose only differences are absolute paths, stack-trace line numbers shifted by the REPO_ROOT line, the inventory wording and two perf ratios. The guard was mutation-checked: a test back in the root, a unit suite in tests/e2e, and a suite dropped from test-all.sh each make it exit 1. CI run 35246304316 ran 97 suites from tests/e2e and is green.
Follow-up
|
||
|
|
52b9474d7a |
feat(map): filter repeaters by region name (#1862) (#2022)
Fixes #1862 ## What Adds a **Region Scope** picker to the map controls: pick `#be` and the map keeps the nodes that declare `#be` or were seen carrying `#be` traffic. It combines with the #2006 scope-state filter and persists in localStorage the same way. While a region is picked, a small "Region: #be · reset" chip sits on the map itself, so the filter stays visible when the controls panel is collapsed (the default on phones) and can be cleared from there. ## API `/api/nodes` and `/api/nodes/{pubkey}` gain two fields on repeater/room rows: - `declared_regions`: named regions from the node's newest declared-regions answer, split by the same function the Scope Audit now uses for `declaredRegions` (`splitDeclaredRegions`, `cmd/server/scope_config_state.go`), so both pages list a repeater under the same names. `[]` means it answered and named no region. Absent means it never answered, other roles, no declared-regions source, or the declared-regions lookup failed. - `declared_regions_truncated`: present, and `true`, only when that answer was flagged as truncated, so the list is partial. Never `false`: the `nodes.configured_scope` source does not record truncation, so absence does not mean the list is complete. The observed side reuses `transported_scopes`. Documented in `docs/api-spec.md` and the served OpenAPI spec. ### Why no `?hashRegion=` query parameter The observed side lives in the in-memory store. Filtering it after the SQL `LIMIT`/`OFFSET` would corrupt `total` and paging, and the map pages through `/api/nodes`. Same reasoning as the Data path section of #2001. ## Map behaviour - Filtering is client-side over the nodes `fetchAllNodes` already loaded: no new request. One pass over the loaded nodes to build the picker counts, one Set lookup per node per render. The marker filter is `nodePassesMapFilters` (`public/map.js:219`) and the observer stand-down `observerLayerShown` (`:212`), both exported and tested. - The picker and hint count only nodes with a map position, the same test the marker filter applies first, so a count never promises markers the map cannot draw. - The observer layer stands down while a region is picked, for the same reason it does for the scope-state filter. - The popup lists declared and observed regions separately. A truncated declared answer carries the same `truncated` badge the Scope Audit shows. - Absence is not read as a finding: the hint under the picker says a node left off the map is not proof it lacks the region. ## Tests - Go: `node_declared_regions_api_test.go` covers `declared_regions` on list and detail endpoints, the no-source case, agreement with `/api/scope-audit`, `declared_regions_truncated` (truncated, truncated-empty, complete, configured_scope-only, newer untruncated answer, companion) and the OpenAPI schema. - JS: `test-issue-1862-map-region-filter.js` (30 tests) covers the pure pieces (evidence, counts, options, hint, popup rows, `nodePassesMapFilters`, `observerLayerShown`) and, at page level, runs the registered map page through `init()` and `loadNodes()` in a vm sandbox: picker built from loaded nodes, markers filtered by a stored region, observer pins standing down, popup rows, the change handler persisting, and the chip showing, resetting and rendering its text as text. - Mutation-checked: removing the region check, the observer stand-down, the picker build in `loadNodes`, the popup rows, the persist on change, the chip reset, or the truncated flag (Go or JS) each fails a test. `test-issue-2001-map-scope-state.js` still passes. - `go test ./...` in `cmd/server` passes; `check-css-vars` and `check-xss-sinks --diff` are clean. ## Staging validation Build `c646310f`, Chrome, no console errors: - Picking `#be`: chip "Region: #be · reset" at the top of the map, hint "221 nodes with a map position have evidence for #be: 129 declare it, 191 seen carrying its traffic. Absence here is not proof: ...". - Clicking reset: picker back to "All regions", stored choice cleared, chip hidden. - First version, same instance: `declared_regions` on 136 of 500 `/api/nodes` rows; returning to "All regions" blocked the main thread 5.4 s against 3.2 s for the existing Status filter returning to "All". The review measured the region filter's own added work at about 0.08 ms per render plus popup rows that are already built for every marker, so most of that time is the existing full re-render. ## Not verified - The chip placement at phone width, and on narrow desktop widths where it may sit under the expanded controls panel. - The truncated badge with real truncated answers (staging has none today). - `test-map-clustering.js` has one failing test on `upstream/master` too; untouched here. - Marker badges from the original issue body are not implemented; the popup rows are the per-node display. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dda04ae918 |
fix(packets): empty the observer and type selections on Clear Filters (#2012) (#2015)
## What was wrong On the Packets page, Clear Filters reset `filters.observer`, `filters.type`, localStorage and every checkbox in both multi-select menus, but not the Sets that hold the selection: `selectedObservers` (`public/packets.js:1793`) and `selectedTypes` (`public/packets.js:1845`). The next `change` event (`public/packets.js:1826`, `:1876`) added to the stale Set, so select observer A, Clear, select observer B wrote `A,B` to the URL and the trigger read "2 Observers". Types behaved the same way. Clear also unchecked the "All Observers" / "All Types" rows although no filter was active. ## What changed - `public/packets.js:1999-2006`: the Clear handler empties both Sets and rebuilds both menus through `buildObserverMenu()` / `buildTypeMenu()` plus `updateObsTrigger()` / `updateTypeTrigger()`, replacing the hand-written checkbox and trigger resets. All four functions are in the same scope as the handler. - `test-issue-2012-clear-filters-selection.js` (new): runs the real multi-select section and Clear handler from `packets.js` in one function scope against a small fake DOM. For observers and types it drives select A, Clear, select B and asserts that only B is selected (filters, localStorage, trigger text) and that the All row is checked after Clear. - `test-clear-filters.js`: the handler body now uses names this test did not provide, so menu-agnostic cases get empty stand-ins. The old "unchecks every checkbox" case expected the All row to be unchecked (the bug), so it now asserts that the Sets are emptied and both menus rebuilt. - Registered the new test in `test-all.sh` and the unit-test step in `.github/workflows/deploy.yml`. ## Tests - `node test-issue-2012-clear-filters-selection.js`: 4 passed. With the `packets.js` change reverted: 0 passed, 4 failed (`obsA,obsB`, `4,5`, All row `false`). - `node test-clear-filters.js`: 6 passed, 2 failed, the same counts as on master. The two failing `updatePacketsUrl` cases fail on master with `location is not defined`. This file is not run by `test-all.sh` or CI. - Also green: `test-packets-local-channels.js`, `test-issue-1415-packets-layout.js`, `test-frontend-helpers.js`, `test-observer-iata-1188.js`, `test-packet-filter.js`, `test-packet-filter-ux.js`, `test-xss-escape-sinks.js`, `scripts/check-css-vars.js`. ## Browser validation Deployed together with #2013's, #1851's and #1868's branches to a staging instance with live traffic (build `e84d2da6`), in Chrome: - Observers: select BE-BRU-Moris, Clear (URL back to `#/packets`, trigger "All Observers", All row checked), select BE-BRU-Bécodok: URL holds only Bécodok's key, trigger shows Bécodok. - Types: select one type, Clear (All row checked, "All Types"), select another: stored filter holds only the second. ## Not verified - Other ways of resetting filters (navigating back to `#/packets` without params) were not checked for the same stale Sets. - The full `test-all.sh` run was not done locally. Fixes #2012 ## Review follow-up (commit `4b14f54e`) An independent review of this PR reproduced the bug on master and confirmed the fix in headless Chromium against a real server. It found one related pre-existing problem, now fixed here: - The type multi-select change handler never called `updatePacketsUrl()`, which is what shows or hides the Clear button (`public/packets.js` ~779-783). With only a type selected the button stayed hidden, so the Types half of #2012 could not be reached by a click. The handler now makes that call, like the observer handler does (`public/packets.js:1887`). Type is not part of the URL, so the call only toggles the button. - The regression test now runs the real `updatePacketsUrl()` and adds two cases: picking only a type, and only an observer, shows the Clear button, and Clear hides it again. The type case fails without the new call. - The test's fake DOM also provides `#observerList` and `#observerSearchInput`, so it keeps working if #1884 merges first. Checked against a local merge of #1884: 6 of 6 pass. That merge has one conflict in the Clear handler; whichever PR lands second must keep both the Set clear and the search box reset. - Not mentioned before: the fix also resets the type trigger's `title` tooltip through `updateTypeTrigger()`, which the old handler left stale. Validated on staging together with the other follow-ups (build `c646310f`). Not verified: mobile viewport, full Playwright suite. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
efdb3ea0b3 |
feat(analytics): retransmission pressure over time (#1699) (#2023)
## Summary Adds `GET /api/analytics/retransmissions` and a "Retransmission Pressure (proxy)" chart on the Analytics Topology tab, implementing the metric agreed in #1699: for each flood, the number of distinct repeaters in the union of the paths of all its observations (`[A]`, `[A,B,C]`, `[A,D]` gives 4), averaged per time bucket. Topology is the tab that already shows hop counts and repeaters in paths, so the chart sits there instead of in a new tab. ## Definition - Flood routes only (`route_type` 0/1); TRACE excluded. Direct routes carry the route still to travel (firmware `src/Mesh.cpp:78-106,334-342`), zero-hop sends are direct (`src/Mesh.cpp:717-737`), TRACE path bytes are SNR values (`src/Mesh.cpp:59-61`, refused by `sendFlood` at `src/Mesh.cpp:637-641`). Firmware commit 0679dbef. - **Flood events, not hashes.** `transmissions.hash` is UNIQUE and the packet hash excludes the path (`src/Packet.cpp:41-50`), so when the same bytes flood again the observations land on the same transmission. Observations are sorted by time and split into events wherever two consecutive observations are more than 5 minutes apart. Each event is counted on its own and bucketed by its first observation. - Why 5 minutes: a node holds a flood for at most 32 s (`src/Dispatcher.cpp:11,243-251`) plus a random retransmit delay. On live over 7 days, 72,806 of 74,347 flood transmissions span 60 s or less, and of 1,372,283 consecutive observation gaps, 52 fall between 60 s and 300 s against 1,823 above 300 s. - Events that start before the store retention floor (now minus `retentionHours`) are left out for every request shape. The store keeps older observations only for hashes heard again recently, so they do not represent that period. Eviction of those transmissions is tracked in #2024. - A flood event heard only with an empty path counts as 0 repeaters. - **Prefixes are not resolved to nodes, and a prefix counts once per event**, whether it repeats across observations or inside one path. On live (7 days), a repeated 2-byte prefix inside one path occurs in 1.08% of flood transmissions and 6,178 of 6,596 such repeats match exactly one known node; for 3-byte it is 0.69% and 104 of 104. That is one node forwarding again after its 160-slot cyclic duplicate filter dropped the hash (`src/helpers/SimpleMeshTables.h:9,52-57`). A repeated 1-byte prefix (44.9% of 1-byte transmissions) is mostly two nodes; counting it once keeps the value a lower bound. `summary.one_byte_packets` reports how many events that affects. - Observations are stored once per observer and path per hash, so a later event of the same hash only holds pairs not stored before; its count is a lower bound too. On live these are 1,466 of 75,356 events (1.9%), and they are kept in the average. - Resolution was not used: on live, 1-byte observations nearly all have `resolved_path` NULL, and cold load refuses context-based resolution of history (`cmd/server/neighbor_persist.go:155-168`). - Buckets `5m|15m|1h|6h|1d`. `region` filters on observers like `/api/analytics/rf`, after the event split; a region with no known observers is not filtered, the same as the other analytics endpoints. `area` is not supported. ## Implementation - `cmd/server/retransmission_pressure.go:255` `addPath`: scans path JSON directly into a generation-stamped hash set, no allocation per observation. - `cmd/server/retransmission_pressure.go:367` `computeRetransmissionPressure`: one pass under `s.mu.RLock`. Per flood transmission it sorts the observations by cached parsed time into a reused scratch slice, splits events and counts each in `addEvent` (`:319`). O(T + O log k + H). - `cmd/server/retransmission_pressure.go:470` `GetRetransmissionPressure`: default shape from the recomputer (#1659 warm-up gate). Other shapes come from a typed TTL cache (max 64 entries) cleared on new paths and eviction (`cmd/server/store.go:2289,2336`); concurrent misses on one key share one compute through singleflight (`store.go:204`). - `cmd/server/retransmission_pressure.go:515` handler, `cmd/server/routes.go:331`, `cmd/server/openapi.go:108`, `docs/api-spec.md:1283`. - `public/analytics.js:761` card, `:855` `renderRetransmissionChart` (CSS variables only, lines break at missing buckets, caption states it is a proxy, names the observer coverage bias, the once-per-flood prefix rule and the 5 minute event split), `:829` loader with stale-response guard. ## Performance - `BenchmarkComputeRetransmissionPressure`, 50k transmissions x 20 observations, `-cpu 1`, i5-1335U: median 161 ms/op (132 ms/op before the event split); first pass after startup with timestamps not yet parsed 192 ms/op. About 22 KB and 281 allocations per op. - On staging the default shape is served from the recomputer in 0.3 s; the post-load recompute of this recomputer took 994 ms on a 121k-transmission store (log line quoted in #2025). A 336h store would be about twice that, every recompute interval, under the store read lock. ## Tests - `cmd/server/retransmission_pressure_test.go`: union counting (reporter example, overlaps, once per event for 1/2/3-byte, width/case, growth); route/TRACE/zero-hop filter, bucketing, window by event start; event split and the 5 minute settle gap (boundary, chained steps, unsorted input), retention floor; region filter, region applied after the split, unknown region, 1-byte share; recomputer read, TTL cache invalidation on new paths and on eviction, cache expiry, singleflight, recomputer gate wiring, handler, warm-up gate. - `test-issue-1699-retransmission-chart.js` (33 tests, registered in `test-all.sh` and `deploy.yml`). - Mutation-checked: 18 mutations of the event split, floor, prefix rule, bucketing, region order, cache clears, expiry, gate wiring and singleflight, all killed. - `go test ./...` in cmd/server passes; `scripts/check-css-vars.js` OK. ## Staging validation Build `c646310f` (this rework plus #2025 and the other review follow-ups), after a container restart and full load: default shape 74,974 flood events, average 27.08 repeaters, 169 hourly buckets from 2026-09-06 16:00 (the 168h floor) to the current hour, highest hourly average 53.3. Before the rework the same instance showed buckets back to 2026-07-18, averages up to 148, and for the first minutes after a restart only 5,911 packets. ## Merge order with #2025 #2025 fixes the recomputer startup for all analytics endpoints (the stale first snapshot seen here). Whichever of the two merges second has to add `recompRetransmissions` to `analyticsRecomputersLocked`, wire it to that PR's `loadedGate` instead of `LoadComplete`, bump the recomputer count in `TestAnalyticsRecomputers_PostLoadOrder` from 9 to 10, and make `TestStartAnalyticsRecomputers_RetransmissionsGatedOnLoadComplete` call `signalStartupLoadDone()`. That resolution is what ran on staging. ## Not verified - Recompute timing on a production-size (336h) store; only extrapolated. - Phone-width layout and dark theme of the reworked chart. - E2E Playwright suite. Fixes #1699 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
914bd4cf0e |
feat(channels): show each message's region (#1851) (#2018)
## Show each channel message's region scope Each message in the Channels view now shows the region scope it was sent with, as a small chip in the meta line: the region name (for example `#be`), `unknown scope`, or nothing. This is the channel-message part of #1852 by @dborup, extracted as a focused change. #1852 was closed unmerged because it had grown to the whole fork diff. The implementation follows dborup's commits |
||
|
|
2c6d7bb6ae |
feat(packets): add filter to All Observer dropdown (#1884)
Lets users type a prefix to filter the observer checkbox list, instead of scrolling a long list. --------- Co-authored-by: efiten <erwin.fiten@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a059299588 |
feat(node-analytics): hop-count statistics per node (#1812) (#2021)
## Summary
Adds per-node hop-count statistics so repeater operators can choose
`flood.max`, `flood.max.unscoped` and `flood.max.advert` from what their
node actually sees.
- New endpoint `GET /api/nodes/{pubkey}/hop_analytics?days=N`
(`cmd/server/routes.go:299`, `cmd/server/node_hop_analytics.go:312`),
separate from `/analytics` as requested in the issue.
- New card "Hop Count at This Node" on the node analytics page
(`public/node-hop-analytics.js`, wired at
`public/node-analytics.js:130,174`): histogram of hop counts with a box
plot on the same x axis, filters `flood.max` (default),
`flood.max.advert`, `flood.max.unscoped`, driven by the existing range
picker.
- The existing "Hop Distribution" chart is unchanged: it shows path
length at the observer, a different quantity.
- No `direct` tag, although the issue lists one: for DIRECT packets the
path is the remaining route and no flood limit applies, so there is no
hop count to report.
## Hop count definition (firmware 0679dbef)
- `src/helpers/RoutingPolicy.h:15-21`: limits compare
`getPathHashCount()`; `.unscoped` applies to route type FLOOD, `.advert`
to adverts.
- `src/Mesh.cpp:344-350`: `routeRecvPacket` checks with n hashes in the
path, then writes its own hash at index n. So hops = the node's
zero-based index in the path, no +1.
- `src/Mesh.cpp:265-285`: a node forwards a flood once;
`src/Mesh.cpp:651,680`: an originator never forwards its own flood.
- DIRECT packets are excluded: their path is the remaining route
(`src/Mesh.cpp:78-103,334-341`).
Response: `{timeRange, packets: [{hash, timestamp, hops, tags}],
ambiguous}`. Tags: `flood`, `scoped` or `unscoped`, `advert`. Documented
in `docs/api-spec.md:679` and `cmd/server/openapi.go:90`.
## Attribution
`cmd/server/node_hop_analytics.go:198-309`. The result depends only on
the observed paths, the prefix map and the neighbor graph, so it is the
same after a restart as after live ingest.
- Every observation of every flood packet in the window is read.
`byNode` holds the server resolver's pick at ingest and other picks
after a cold load; `byPathHop` indexes only each packet's longest path,
which for a busy relay often runs through another branch of the flood.
- A packet counts when the node's prefix sits at exactly one index
across its observations, and either the node is the only relay candidate
for that prefix (`prefixMap.relayCandidates`,
`cmd/server/store.go:6795`), or the hop resolves to the node under the
ingestor's strict rule (`cmd/ingestor/path_resolver.go:143-214`) in at
least one observation and to another node in none. Strict rule: earlier
hops identified without a tiebreak, exactly one candidate adjacent in
`neighbor_edges` to the previous hop (the originator for hop 0 of an
advert), nodes already on the path excluded.
- The server resolver's tiebreaks (affinity, GPS distance, advert count,
pubkey order) are not used.
- Everything else with the node's prefix goes to `ambiguous`. In
practice that is most packets with a colliding 1-byte path hash.
On a read-only 7-day dump of a 1,669-node mesh DB, for one busy
repeater: 23,081 packets attributed, 11,437 ambiguous. Taking candidates
from `byPathHop` instead gave 9,995 attributed, with the histogram mode
moved from 2 to 3-5 hops.
## Performance
Scans `s.packets` under the read lock, no SQL per packet. Per
observation: one substring test for the node's first prefix byte; the
hop scan only for observations containing it; the strict walk only for
colliding prefixes, with per-request caches for candidates and
adjacency. `BenchmarkNodeHopPackets` models one 7-day request at that
scale (73,782 flood packets, 1,430,280 observations): 44-87 ms/op, 13.4
MB, 40 allocs on a throttling laptop.
Response size for that repeater over 7 days: about 23k entries, 2.3 MB
JSON, 375 KB gzipped. `hash` and `timestamp` are 61% of the raw and 91%
of the gzipped bytes; they stay because the issue asks for them so a
client can join entries to packets and bin by time.
## Tests
- Go: `cmd/server/node_hop_analytics_test.go`: 12 unit tests, a
live-ingest test through `IngestNewFromDB` (a colliding prefix without
independent attribution goes to `ambiguous`, not to the node the
resolver picked), live ingest versus cold load of the same DB, route
test, benchmark. 15 mutations of the attribution logic each fail a test.
- JS: `test-node-hop-analytics.js` (filters, histogram, quartiles and
whiskers with a fixture that separates 1.5 IQR from 3 IQR, render),
registered in `test-all.sh` and `.github/workflows/deploy.yml`.
- `gofmt`, `go vet ./...`, `go test ./...` in `cmd/server`,
`scripts/check-css-vars.js` pass.
## Staging validation
Build `c646310f` (this PR's review follow-up together with the other
open follow-ups), after a container restart and full load, on a busy
Belgian repeater:
- `hop_analytics?days=7`: 23,302 packets, 11,548 ambiguous, median 4,
adverts never above hop 7 (matching the firmware default
`flood_max_advert = 8`, `examples/simple_repeater/MyMesh.cpp:922`), 1.2
s. The first version reported 23,035 packets and 86 ambiguous in 534 ms,
because it trusted the resolver's pick for colliding prefixes.
- The card rendered on the first version with no console errors; the
rework does not touch the frontend beyond a test fixture.
## Not verified
- Response time and lock hold for 30 days on the busiest node on a
14-day store.
- Server relay candidates exclude companions and listeners while the
ingestor's prefix index does not, so a few strict attributions can
differ from the ingestor's persisted `resolved_path`.
- Identical numbers across a second container restart were shown in a Go
test, not repeated on staging.
- Dark theme, phone width, and switching the range picker in the
browser.
- Filter state is not reflected in the URL hash (the range picker is not
either).
Fixes #1812
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
7f8f71e773 |
fix(live): reconnect when the websocket goes silent (#1074) (#2020)
## Problem #1074 reports that after a proxy dropped the WebSocket, live updates only came back 8 to 10 minutes later. The client only reconnects from `onclose` (`public/app.js:791` on master). A half-open connection (a proxy or NAT dropping state without a FIN reaching the browser, a laptop that slept) can keep a WebSocket OPEN for minutes without `onclose`, and nothing retries in the meantime. The server does ping every 30s (`cmd/server/websocket.go:252` on master), but ping frames are answered by the browser below the page and JS cannot observe them. The only app-level frames are packet broadcasts (`websocket.go:337, 343, 366`), which stop on a quiet mesh. So the client had no signal to tell a quiet mesh from a dead socket. ## Change Server (`cmd/server/websocket.go`): - On the existing ping tick, `writePump` also writes the text frame `{"type":"heartbeat"}` (`:283`, bytes at `:80`). One 20-byte frame per client per 30s, from the goroutine that already writes the ping, no hub lock. - The interval moves to `Hub.pingInterval` (default 30s, `:118`) so a test can shorten it. Client (`public/app.js`): - Every frame refreshes `wsLastMessageAt` (`:841`). One timer (`checkWSLiveness`, `:806`) fires at last message + `WS_STALE_MS` (75s, one late or lost heartbeat of slack) and replaces the socket if it is still silent. It is armed at socket creation, so a stuck handshake is covered too. - `dropWS` (`:796`) detaches the old socket's handlers before `close()`, so a late close event cannot schedule a second connection. - `connectWS` (`:818`) cancels a pending reconnect and drops the previous socket, so the watchdog, resume checks, `onclose` and pull-to-reconnect cannot stack sockets. Before this, `pullReconnect` on a non-open socket left a third socket 3s later. The 3s `WS_RECONNECT_MS` delay after `onclose` is unchanged (`:837`). - `visibilitychange` (to visible) and `online` run the check immediately (`:861`), because a hidden or sleeping tab's timers can run late. - Heartbeat frames are matched by exact bytes (`:842`) and are not pulsed or dispatched to `onWS` listeners. Compatibility: tabs loaded before the deploy dispatch heartbeats to their listeners until reloaded. Every current listener filters on `msg.type`, so the visible effect is a logo pulse and a `/stats` cache refresh every 30s. Perf: one `Date.now()` and one string compare per WS message on the client; one extra 20-byte write per client per 30s on the server. ## Tests - `test-ws-stale-watchdog-1074.js`: real `app.js` in a vm with a fake clock, timers and WebSocket. 12 tests: silence past the threshold replaces the socket exactly once; a handshake that never opens is replaced; heartbeats and packet traffic keep the socket; heartbeats are not dispatched; resume and `online` after silence reconnect immediately, with recent traffic they do not, and hiding does not trigger a check; repeated resume events open one socket; after `onclose` only the reconnect timer is pending; pull-to-reconnect leaves one socket. 9 of 12 fail on master. 12 of 12 source mutations (threshold, reconnect path, detaching, timer cleanup, resume wiring, heartbeat filter) are caught. Registered in `test-all.sh` and the deploy.yml unit step. - `TestWritePumpSendsAppHeartbeat`: fails with a read timeout without the heartbeat, even with pings every 20ms. `TestHubDefaultPingInterval` pins the 30s interval that `WS_STALE_MS` assumes. - `go test ./...` in `cmd/server` passes; gofmt and go vet are clean. ## Browser validation On a staging instance (build `139e484e`, together with #1979's branch), in Chrome, no console errors: - A `{"type":"heartbeat"}` frame arrived on the open socket within the observation window. - Silent socket: after `ws.onmessage = null`, the page replaced the socket after 76.2s (threshold 75s plus a 250ms poll); the old socket ended in CLOSED, the new one OPEN. - Normal close: `ws.close()` led to a new OPEN socket after 4.1s, and exactly one new `WebSocket` was constructed. ## Not verified - The reporter's proxy setup was not reproduced; that their delay was a half-open socket is a hypothesis consistent with the symptom. Hence `Refs`, not `Fixes`. - Laptop sleep and the `visibilitychange` / `online` resume path were only covered by the unit test, not in a browser. - Behaviour under Chrome's intensive background-timer throttling and mobile tab freezing was not measured; a frozen but healthy tab may do one unnecessary reconnect on resume. - Go tests were run without `-race`. Refs #1074 ## Review follow-up (commit `72e5e906`) An independent review found no blocking bug: all data writes stay on the write goroutine, pong-based dead-client detection still works, and no ordering of onclose, watchdog, resume checks and pull ends with two live sockets or none. It reproduced the silent-socket case in headless Chromium through a blackholing TCP proxy (replacement 75.0 s after the last frame). Changed: 1. **Startup wiring tested.** The first resume test now boots through the page's real `DOMContentLoaded` listeners, so removing `setupWSResumeCheck()` from startup makes it fail. 2. **Wall clock stepping back.** If the clock steps back after the last message, the watchdog no longer re-arms for the size of the step (a 1 h step used to delay detection by about an hour). A negative silence reading is treated as stale, so the socket is replaced within `WS_STALE_MS` of the step (`public/app.js:810-814`). A step in either direction costs at most one extra reconnect on a healthy socket. `Date.now()` stays the clock so a tab resumed after sleep is still checked against real elapsed time. 3. **Pull-to-reconnect at once.** On an OPEN socket, pull-to-reconnect now replaces it through `connectWS()` instead of closing it and waiting for onclose, which took 63 s on a half-open connection in the review's measurement (`public/app.js:927-934`). This was slow on master too; it is safe now that `connectWS()` detaches the old socket. Tests: 12 to 16 in `test-ws-stale-watchdog-1074.js`; `test-pull-to-reconnect.js`, `test-pull-to-reconnect-1091.js` and `test-live.js` pass. Correction to the compatibility note: tabs opened before the deploy treat the heartbeat like any other message. Besides the logo pulse, `app.js` runs `updateNavStats` on every message and invalidates the cached `/stats` and `/nodes` responses 5 s later; `packets.js` also pushes every message into `pauseBuffer` unfiltered (~1310-1313), so an old tab with Packets paused sees its counter rise by 2 per minute. Cosmetic: heartbeats are filtered out on replay, and a reload ends it. Not verified: real hidden-tab or mobile freeze behaviour, Firefox and Safari, and the reporter's proxy setup. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0fea3f2a75 |
feat(analytics): break scope adverts down by node role (#1979) (#2019)
## Summary Adds a breakdown of flood adverts by sender role to `/api/scope-stats` and the Scopes tab, in the descriptive shape agreed in #1979: per node role, how many flood adverts were unscoped, scoped with an unnamed region, or scoped with a named region. It reports what was sent, not why. ## Changes - `cmd/server/db.go:3114-3144`: one grouped query in `GetScopeStats`. ADVERT packets on flood routes (TRANSPORT_FLOOD 0, FLOOD 1) in the window, `LEFT JOIN nodes` on `from_pubkey`, split by the three `scope_name` states (NULL, empty string, name). A missing or empty role becomes `"unknown"`. Ordered by total descending, then role. Zero-hop adverts are excluded because firmware sends them as DIRECT/TRANSPORT_DIRECT (`src/Mesh.cpp:717-730`, `Mesh::sendZeroHop`), so they would inflate "unscoped". - `cmd/server/types.go:116-133`: `ScopeAdvertRoleCount` and `ScopeStatsResponse.AdvertsByRole` (`advertsByRole`, always an array). - `public/analytics.js:4760`: `scopeAdvertsByRoleHtml` renders a table under the time-series chart with the count per state and its share of the row. Role text is escaped. It reuses the existing `/scope-stats` response, so there is no extra request. - `docs/api-spec.md:1763-1786`: documents the new field. `/api/scope-stats` is in `openapi_known_gaps.json`, so there is no `openapi.go` entry to update. API addition (existing fields unchanged): "advertsByRole": [ { "role": "repeater", "unscoped": 7741, "unknownScope": 7, "named": 1562 } ] ## Performance The query runs inside `GetScopeStats`, so it shares the existing 30s cache per window. The unary `+` on `payload_type` keeps SQLite on the `first_seen` range index. Without it the planner picked the `payload_type` index and walked every stored advert whatever the window. Read-only timing on a production DB (1,063,345 transmissions, 161,634 adverts, sqlite3 CLI 3.45.1): | Window | payload_type index | first_seen index (this PR) | |---|---|---| | 7d | 0.231s | 0.059s | | 24h | 0.214s | 0.008s | | 1h | 0.213s | 0.001s | ## Tests - `TestGetScopeStatsAdvertsByRole` (`cmd/server/db_test.go:2295`): the three states, flood-only routes, non-advert and out-of-window exclusion, `unknown` for a missing node row, an empty role and a NULL `from_pubkey`, and ordering. Mutation checked: widening to routes 0-3 and dropping the empty-role fallback both fail it. - `TestGetScopeStatsAdvertsByRoleEmpty` (`:2372`): empty result is `[]`, not null. - `test-issue-1979-scope-adverts-by-role.js`: renders the real `analytics.js` helper in a vm sandbox. Covers row order, totals and shares, columns, escaping (mutation checked), the empty state, and the non-causal caption. Registered in `test-all.sh` and the deploy.yml unit step. - `go test ./...` in `cmd/server` passes, gofmt and go vet are clean, `check-css-vars.js` OK, `check-xss-sinks.sh --diff` exits 0. ## Browser validation On a staging instance with live traffic (build `139e484e`, together with #1074's branch), in Chrome, no console errors: `/#/analytics?tab=scopes` shows "Flood adverts by node role" under the time-series chart with its caption, and a table of 6 roles for the default window, for example `repeater 1.361 | 1.109 (81.5%) | 2 (0.1%) | 250 (18.4%)`. Shares in each row add up to 100%. `/api/scope-stats?window=7d` returns `advertsByRole` with the same six roles. ## Not verified - Timings are from the sqlite3 CLI, not the modernc driver in the server process. - Role is the sender's current `nodes.role`. A node whose advert type changed within the window is counted under its latest role. The data also contains a raw `type-13` role, shown as is. - Switching the window in the browser was not exercised; the API was checked for 7d. - No Playwright E2E added. Fixes #1979 ## Review follow-up (commit `43af46b8`) An independent review found no correctness, security or performance problem: counts are per transmission, the window matches the rest of the Scopes tab, zero-hop adverts are DIRECT per firmware, and the `+t.payload_type` hint holds on modernc SQLite 3.46.0. It found two test gaps and a docs gap. Changed: - **Ordering.** The Go fixture gave companion, repeater and unknown 3 adverts each, so ordering by total was never checked (`ORDER BY COUNT(*) ASC` still passed). The fixture now has repeater 4, unknown 3, companion 2, sensor 2, so the expected order differs from alphabetical and the companion/sensor tie checks the role-name tie-break. Reversing the count order, dropping it, or reversing the tie-break now fails the test. - **Column positions.** The JS test only checked that each cell string appeared somewhere in the row, so swapping two columns passed. It now compares each row's cells and the header cells by position; both swaps fail. - **Docs.** `docs/api-spec.md` and the `ScopeAdvertRoleCount` comment now name every source of `unknown`: a NULL `from_pubkey` (legacy rows the #1143 backfill has not reached), a sender with no `nodes` row, including one moved to `inactive_nodes` by node retention (inside the 7d window only with `retention.nodeDays` below 7), and an empty role. Not added: a query-plan test pinning the `+t.payload_type` hint. The SQL is inline in `GetScopeStats`, so the test would have to copy it or the query would have to move into a constant; left for a follow-up if wanted. The raw `type-13` role in the table is the ingestor's placeholder for reserved advert types 5-15 (`cmd/ingestor/decoder.go:1229`, #1279), shown as is. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3ca176a676 |
fix(packets): decode CONTROL discover fields for humans (#1868) (#2017)
## What
CONTROL `DISCOVER_REQ` / `DISCOVER_RESP` packets on the Packets page now
show their fields in a readable form:
- Node type and the `DISCOVER_REQ` type filter bitmask render as
Companion / Repeater / Room Server / Sensor instead of a number or hex.
- SNR renders in dB (wire value / 4, sign kept: `-11` shows `-2.75 dB`)
instead of the raw wire byte.
- The responder pubkey renders as the node name (detail header, and the
row preview once the node index is loaded) or a link to the node (detail
field table) when the node is known, and as the first 8 hex chars when
it is not. The full key no longer appears in the row.
- The detail field table has Subtype / Type Filter / Tag / Since and
Node Type / SNR / Tag / Public Key rows with byte offsets, instead of
one generic `Raw` row. Unknown sub-types keep a `Raw` row.
## Credit
Port of @dborup's fix in dborup/CoreScope@ad1680ca (merged on their fork
as
|
||
|
|
cd9b4c04d0 |
test: unify frontend test runs and prevent inventory drift (#1965)
Make `test-all.sh` the authoritative standalone frontend runner for npm and CI. Restore stale assertions and reject missing, duplicate, removed or undocumented inventory entries. Fixes #1858. Rebased onto `a2f039d4`. Retains release-routing, map scope-state and Scope Audit stylesheet tests, adds `test-packets-local-channels.js` to the sorted runner, and classifies `test-neighbor-map-btn-clip-e2e.js` under browser. Its separate CI browser step is preserved. Inventory: 280 root suites, 167 standalone, 113 requiring separate setup. The icon repair fixes two suites red on master: `test-issue-1648-m2-emoji-scan.js` and `test-issue-1648-m6-final-sweep.js`. Node/live configured-scope confirmations now use the existing accessible Phosphor check sprite. Values, visibility conditions and scanner assertions are preserved. - Red evidence: `89e45a9` inventory assertions; `4e255df` accessible confirmation assertion. The latest rebase also reproduced both unclassified-file failures before adding their entries. This follow-up only changes runner/classification configuration and counts; no test files modified. - Local validation: all 167 standalone suites; 27 M2 browser checks and 16 neighbor geometry checks in Chromium. Syntax, whitespace, PII, CSS-variable and XSS checks passed. - Browser coverage includes populated/empty/null configured scopes in node and live views. - No new dependencies, requests, application settings or Go changes. Workflow outside the unit step matches master. - Windows validation uses process-local UTF-8 settings. Encoding and node-reach confirmation follow-ups remain separate, as requested. ## Preflight override External `run-all.sh` is unavailable; applicable repository checks were run directly. |
||
|
|
a2f039d4c7 |
fix(packets): show browser-added channels in the channel filter (#2009)
Verified against production: ingestor writes enc_<HH> (db.go:2282), store.go:3536 filters on it, live values are uppercase two-digit hex with thousands of packets (enc_28: 2708), /api/channels omits them, and /api/packets?channel=enc_28 returns rows. New test passes at 14 and fails when the dedupe guard is disabled. |
||
|
|
51a2a7dd2f |
fix(scope-audit): ship the stylesheet the page paints its badges with (#2005)
Fixes #2004. Two review rounds; findings and evidence on the PR. Verified by injecting the stylesheet into a running deployment and reading computed styles before and after: the badges gain background, size, uppercase and padding; at 430px the Config column stays visible; the sorted column keeps its accent. The new test fails on the eight unstyled classes against the pre-fix tree, on .ns-truncated against the first fix, and on a re-added column-hiding rule. |
||
|
|
296456f9c1 |
feat(map): colour and filter repeaters by scope-configuration state (#2006)
Closes #2001. Two review rounds plus a re-review; findings and evidence on the PR. Verified on a deployment against live data: the field over 1653 nodes, marker tints per filter state, the marker title and popup Scope row reaching the DOM, and the colorblind-preset cascade. The audit and the map are held to the same classification by an end-to-end test that fails when either side's wildcard handling drifts. |
||
|
|
2288e28d4e |
fix: publish release artifacts after successful image retagging (#1964)
Successful release fast paths publish image tags but never dispatch the job that creates the GitHub release and decrypt binaries. Dispatch `deploy.yml` from both image routes. A default-off `images_published` input skips E2E and image rebuilding only for an already-published tag; missing or mismatched images keep the complete fallback without requiring new inputs on older workflow definitions. Go validation still gates the release binaries, checkout and version flags retain the tagged source, and the existing release action uploads both architectures before publication. Missing binary files now fail publication. Fixes #1956. Validation: - `node test-issue-1956-release-routing.js` executes the actual workflow shell steps with registry and dispatch commands stubbed. Covers matching, missing and mismatched images; failed retag and Go validation; branch/PR boundaries; and both tagged binary commands. - The original test commit fails because a matching image dispatches zero artifact workflows; the fix passes the same assertion. - Existing release workflow Go checks, decrypt/channel tests, YAML parsing and actionlint pass. - Both static Linux amd64 and arm64 binaries cross-build with verified architecture and version metadata. Actual registry publication and GitHub release creation were not exercised. Existing immutable releases and old tags that contain older workflow definitions are outside this fix. Following #1922, this is a focused release-routing PR. A separate repair for #1858 rewrites the shared frontend test runner; merging this first lets that repair retain this regression in its authoritative list. Please assess current Go and E2E job results separately from workflow-approval or staging-runner state. |
||
|
|
aabbd50d27 |
feat(nodes): export the visible node list as MeshCore companion contacts JSON (#1889)
## What
Adds an **Export JSON** button to the Nodes topbar that downloads the
currently visible node list as a MeshCore companion-app config file:
```json
{
"contacts": [
{
"type": 2,
"name": "BE-MGU-RP03 | ON7YT",
"custom_name": null,
"public_key": "7a7a37d4819fb27440ed1439ca7d281fdecceb83b27e61a69745feb004d726a4",
"flags": 0,
"latitude": "51.07307",
"longitude": "5.5796",
"last_advert": 1786545431,
"last_modified": 1786545431,
"out_path_list": null
}
]
}
```
That is the exact shape the companion app itself writes, so the file
imports straight back into a companion app as contacts. The practical
use case is per-area: pick an area in the area filter, export, and hand
someone the repeaters for that region instead of having them wait for
adverts.
## Scope of the export
WYSIWYG — the button exports the rows the table is showing, in the
table's current order, so area, region, role tab, search, last-heard and
status filters all carry over. The button label shows how many contacts
the file will contain and is disabled when that count is zero.
## Field mapping
| JSON field | Source | Notes |
|---|---|---|
| `type` | `node.role` | repeater→2, companion→1, room→3, sensor→4;
unknown/empty→1 |
| `name` | `node.name` | unchanged, emoji included |
| `custom_name` | — | always `null` |
| `public_key` | `node.public_key` | full 64-hex |
| `flags` | — | always `0` |
| `latitude` / `longitude` | `node.lat` / `node.lon` | stringified, as
the format expects |
| `last_advert` | `node.last_seen` | RFC3339 → unix seconds |
| `last_modified` | mirrors `last_advert` | no separate source exists |
| `out_path_list` | — | always `null`; the companion app discovers
routes itself |
Nodes are skipped when they have no name, a pubkey shorter than 64 hex
chars, or no usable position (missing, non-numeric, or null island).
Filename: `corescope_nodes_<area|all>_YYYY-MM-DD-HHMMSS.json`.
## Shape of the change
The mapping lives in a self-contained `public/nodes-export.js`
(`window.NodesExport.buildContacts/filename/download`); `nodes.js` only
gains the button markup, a click handler and a small
`updateExportBtn()`. No backend change, no new API call — the export
reuses the already-fetched node list, so there is nothing per-node to
fetch.
## Tests
- `test-nodes-export.js` — field mapping and key order, role→type table,
skip rules, order preservation, filename format (added to
`test-all.sh`).
- `test-nodes-export-wiring.js` — `index.html` loads the module before
`nodes.js`; the button lives in the topbar and hands the filtered
`nodes` array plus `AreaFilter.getSelected()` to
`NodesExport.download()` (added to `test-all.sh`).
- `test-nodes-export-e2e.js` — Playwright: downloads the file, validates
the JSON shape per contact, asserts the button count matches the file,
and that narrowing the search shrinks the export set.
## Browser validation
Deployed to staging and checked in Chromium:
- Desktop 1400×900 — button renders at the right of the topbar next to
the count pills, `Export JSON (1962)`.
- Mobile 390×844 — topbar stacks, button visible, no horizontal overflow
(`scrollWidth == clientWidth`).
- E2E against staging: 1761 contacts exported,
`corescope_nodes_all_2026-08-12-164349.json`, shape validated, search
narrowing confirmed.
- With the `BE-LIM` area filter active: 44 contacts,
`corescope_nodes_BE-LIM_2026-08-12-164445.json`, type histogram `{1: 1,
2: 42, 3: 1}`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
0fd22039cb |
feat(map): Important Links overlay (rebase of #1771 onto master) (#1928)
Continues #1771 by @ArcanConsulting. Both commits are theirs, authorship unchanged; I only rebased them onto current master. Opening it here rather than force-pushing to someone else's branch. ## Why the rebase was needed #1771 went CONFLICTING through no fault of its author: #1760 landed first and both PRs append a line to `test-all.sh` at the same spot. That was the entire conflict. ## What I changed One line, and it is the conflict resolution: `test-all.sh` now runs **both** test files rather than either. ``` node test-repeater-metric-scatter.js # from #1760 node test-top-routes-overlay.js # from this PR ``` Nothing else was touched. `public/map.js` and `test-issue-1329-map-controls-accordion-e2e.js` are byte-for-byte as the author wrote them. ## Verification on the rebased tree | | result | |---|---| | `test-top-routes-overlay.js` (this PR's own) | 20 passed, 0 failed | | `test-repeater-metric-scatter.js` (#1760's, must still pass) | 31 passed, 0 failed | | `test-frontend-helpers.js` | 627 passed, 0 failed | ## The one review point that still stands From my review on #1771, unchanged by the rebase and not something I fixed on the author's behalf: `test-top-routes-overlay.js` extracts the ranking core by `indexOf`-slicing `public/map.js` between the literals `const TOP_ROUTES_AXES` and `function clearTopRoutes`, then `new Function`s the result. There is a guard assertion for the rename case, which is thoughtful, but it still breaks on any reordering of map.js and it tests a string rather than the module. Two PRs in this same queue do it properly and are worth copying: #1821 exports `applyObserverFilter` through `_packetsTestAPI`, and #1912 puts `hashPrefixInfo` on `window`. Happy to take that as a follow-up rather than block the overlay on it. @ArcanConsulting — this is your work and the credit is yours. Say the word and I will close this and hand the rebase back, or push it to your branch instead if you would rather #1771 stayed the vehicle. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Wzwr3eXseyNM7Xj598djjE --------- Co-authored-by: Arcan Consulting - Michael J. Arcan <github@arcan-it.de> |
||
|
|
c5a71b34ec |
fix(#1890): drop the hardcoded og:url so shared links stay on the instance (#1893)
Fixes #1890. ## The problem `public/index.html:16` shipped this to every deployment: ```html <meta property="og:url" content="https://analyzer.00id.net"> ``` Open Graph consumers — Facebook and Messenger among them — treat `og:url` as the canonical destination. Clicking the preview of a link shared from *any* CoreScope instance navigated to that one host. The direct link text still resolved correctly, which is why this went unnoticed; the preview card and the surrounding message body did not. It is the only occurrence in the frontend. ## The change Remove the tag. `og:url` is optional — with no tag present, consumers fall back to the URL they crawled, which is correct for every deployment and needs no configuration. ## Why not the config-driven variant The issue also proposes deriving the URL from `config.json`. I did not take that shape, on purpose: `index.html` is pre-processed **once at startup** — `spaHandler` reads it and substitutes `__BUST__` (`cmd/server/main.go:565`), then serves the same byte slice for every request. A correct per-host `og:url` therefore needs either a new public-URL config key or per-request templating of the index. Both are decisions about config surface and request-path cost that belong to you, and neither is needed to stop the redirect. Happy to follow up with whichever shape you prefer — this PR is the part that is unambiguous. ## What is left alone `og:image` still points at `raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png`. That is the project's own asset, a shared project resource rather than a redirect target, so it is correct for every instance to reference it. ## Test `test-issue-1890-og-url.js`, a static scan, registered in `test-all.sh`: - no `og:url` meta tag - no `rel="canonical"` link - no `00id.net` reference anywhere in `index.html` - `og:title` / `og:description` / `og:image` still present That last assertion is deliberate: without it the guard could be satisfied by deleting the whole embed block. Watched fail first — 2 passed, 2 failed before the change, 4 passed after. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7402e8d9d9 |
feat(analytics): repeater metric scatter tab (#1760)
Repeater metric scatter tab. Closes #1763. --------- Co-authored-by: Waydroid Builder <build@waydroid.local> |
||
|
|
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> |
||
|
|
4654ce3386 |
feat(analytics): "My Repeaters" favorites monitoring dashboard (#1761)
My Repeaters monitoring dashboard. Closes #1765. --------- Co-authored-by: Waydroid Builder <build@waydroid.local> |
||
|
|
707d70c738 |
fix(packets): clamp .col-details to one line on mobile (#1770 S path) (#1805)
## 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> |
||
|
|
9757178aad |
fix(#1789): add Firmware + Client columns to observers table (#1790)
## 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> |
||
|
|
5c0de8fb41 |
feat(live): optional "Multibyte only" view filter (#1780) (#1781)
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> |
||
|
|
72d451221c |
tone down naive-clock observer notice (#1478 follow-up) (#1759)
Red commit:
|
||
|
|
89eade6e7b |
M6: emoji → Phosphor — final sweep, lint gate, carry-forwards (#1648) (#1654)
Red commit:
|
||
|
|
b812a98a71 |
M3: emoji → Phosphor Icons — detail panes & badges (#1648) (#1651)
Red commit:
|
||
|
|
3062745437 |
M2: emoji → Phosphor Icons — page headers & table chrome (#1648) (#1650)
Red commit:
|
||
|
|
55e4d957b1 |
M1: emoji → Phosphor Icons — top-nav, mobile nav, Compare (#1648) (#1649)
Red commit:
|
||
|
|
9002b25bce |
fix(nodes): paginate /api/nodes across map/live/analytics/packets/area-map (500-row cap) (#1637)
## Summary The server clamps `/api/nodes` `?limit` to **500** (DoS guard, PR #1540 / v3.8.3) and orders by `last_seen DESC`. Every node-list consumer issued a single big-`?limit` fetch and trusted it as the full set, so on >500-node meshes the top-500-by-advert window silently hid the tail. Because `nodes.last_seen` is updated **only on self-adverts** (never on relay traffic; `UpsertNode` is called solely from the advert path), a repeater that relays constantly but last advertised hours ago fell outside that window and **vanished from the map and live view** — while still showing "Active" in its detail panel and (since #1606) in the paginated Nodes list. #1606 fixed only the Nodes page (`nodes.js`). This generalizes that fix to the deferred siblings. ## Changes - **`public/app.js`** — new shared `fetchAllNodes(extraQuery, opts)`: pages `limit=500` + `offset` until a short page (the server's `total` is unreliable — clamped to the page size and overwritten with the filtered length under area/region filters, so we stop on a short page, not on `total`), dedups by `public_key`, returns the real deduped count as `total`. - **`public/map.js`**, **`public/live.js`** (keeps the `LIVE_MAP_MAX_NODES` ceiling via `safetyCap`), **`public/analytics.js`** (×2), **`public/packets.js`** now use the helper. - **`public/area-map.html`** is standalone (cross-origin `baseUrl`, no `app.js`) so it gets an inline copy of the same loop. - **`.eslintrc.json`** — declare `fetchAllNodes` global (no-undef). ## Tests - **`test-fetch-all-nodes-pagination.js`** — unit-tests the helper via the real `api()`+`fetch` path: pagination past 500, short-page stop vs. the unreliable server `total`, dedup across a page boundary, counts pass-through, `safetyCap` bound. 5/5. - **`test-map-nodes-pagination-e2e.js`** — browser E2E (Playwright) proving `map.js` surfaces a 501st node reachable only on page 2 and renders its marker. Verified **red→green**: against the pre-fix single fetch all 3 assertions fail (500 nodes, page-2 node absent, no marker); after the fix all pass. Wired into `deploy.yml`. ## Verification - unit 5/5, E2E 3/3, `test-frontend-helpers.js` 611/611, `npx eslint public/*.js` → 0 errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
dc433e417f |
fix(#1614): getTileUrl() invokes function-typed provider urls (+ regression tests) (#1615)
Fixes #1614 ## Problem `window.getTileUrl()` in `public/roles.js` returned the active provider's `url` property as-is. After #1533 added carto/osm/stamen providers with lazy-resolved URLs (`url: function () { ... }`), the helper returned the function itself instead of a URL template string. Callers handed that function to `L.tileLayer()`, which stringified the source as the template — every tile 404'd, the map went blank, and Leaflet logged no error. User-visible impact: node-detail inset map and analytics minimap rendered zero tiles whenever a function-`url` provider was the active dark-theme pick. ## Root cause `public/roles.js:365-381` — `return p.url || p.baseUrl;` with no `typeof === 'function'` invocation. The provider registry in `public/map-tile-providers.js:45-53` declares almost every provider with `url: function() { ... }` for lazy config resolution (cartocdn domain, OSM provider/token, Stamen API key). ## Fix One-line change in the consumer (`getTileUrl()`). Invoke `url` / `baseUrl` if it's a function; otherwise return it verbatim. `map-tile-providers.js` is not touched — it remains the source of truth for the lazy-resolver pattern. ```js var u = p.url || p.baseUrl; return (typeof u === 'function') ? u() : u; ``` ## Callers reviewed | Caller | Disposition | | --- | --- | | `public/nodes.js:94` (`_applyTilesToNodeMap`) | Routes through `window.getTileUrl()` → fixed transitively | | `public/analytics.js:2055` (`L.tileLayer(getTileUrl(), …)`) | Routes through `getTileUrl()` → fixed transitively | | No other `getTileUrl()` callers | `grep -n "getTileUrl\b" public/*.js` confirms only the two above | ## Commits (red → green) - `a2b23392` — `test(#1614): red — getTileUrl() must return string, not function` — adds `test-issue-1614-tile-url-function.js`. Verified to fail on assertion (not build error) before the fix landed; passes after. - `26fcacd1` — `fix(#1614): invoke provider url() when it's a function` — minimal one-line fix in `roles.js` plus wiring the new test into `deploy.yml` and `test-all.sh`. ## Tests Unit test asserts the public contract from three angles so any regression of either branch fails CI: 1. Dark + `url: function()` → returns a string template containing `{z}/{x}/{y}`. 2. Dark + `url: 'https://…'` → returns the string verbatim (no double-invoke). 3. Dark + `baseUrl: function()` fallback → also invoked, also returns a string. Wired into CI via `.github/workflows/deploy.yml` and `test-all.sh`. ## E2E coverage Skipped intentionally. The existing Playwright harness (`test-e2e-playwright.js`) runs against a deployed BASE_URL and is not invoked from the Go CI workflow (`deploy.yml`). Adding a new E2E flow there would require standing up a leaflet/tile-loading harness for a single one-line regression. The unit test covers the exact `getTileUrl()` contract that this bug violates and would have caught it; if reviewers want a Playwright assertion later we can add it as a follow-up. Manual verification was performed against staging (`http://analyzer-stg.00id.net/#/nodes/...`). ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` — clean (all gates pass, PII clean, red commit verified). --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
571c960ca0 |
feat(a11y/#1380): colorblind sim overlay (Brettel/Vienot) + reset-to-Wong button (#1600)
Implements the two deferred a11y stretch goals from #1361 / PR #1378. ## What 1. **Brettel/Vienot 1997 dichromatic simulation overlay** — `public/index.html` ships inline `<svg>` defs with `<filter id="cb-deut|cb-prot|cb-trit|cb-achromat">` using `feColorMatrix`. Activation rule: `body[data-cb-sim="X"] { filter: url(#cb-X); }`. `public/customize-v2.js` renders a radio group (off/deut/prot/trit/achromat) under the existing CB preset section. Preview-only — **not persisted**, per the issue spec. 2. **Reset to default Wong button** — `data-cv2-cb-reset` button that calls `MeshCorePresets.applyPreset('default')` and removes `localStorage["meshcore-cb-preset"]`. Two helpers exposed on `window._customizerV2` for unit-test drive: `applyCbSim(id)` and `resetCbPreset()`. ## TDD (red → green) - **Red:** `49155723` — `test-issue-1380-cb-sim-overlay.js` + `test-issue-1380-cb-reset-button.js`. Both load `customize-v2.js` and (for reset) `cb-presets.js` in a vm sandbox; failure is assertion (not compile). - **Green:** `5d8f3c1f` — both tests pass (21 + 7 assertions). ## Files changed - `public/index.html` — inline SVG `<defs>` + 4-rule `<style>` block. - `public/customize-v2.js` — render fns `_renderCbSimSelector` + `_renderCbResetButton`, change/click handlers, helper exports. - `test-issue-1380-cb-sim-overlay.js` (new) — string-asserts on index.html SVG filters / CSS rules / customize-v2 hooks + vm.createContext drive of `applyCbSim`. - `test-issue-1380-cb-reset-button.js` (new) — vm.createContext seeds `meshcore-cb-preset=trit`, calls `resetCbPreset()`, asserts storage cleared + `body[data-cb-preset="default"]`. - `test-all.sh` + `.github/workflows/deploy.yml` — register both tests. ## Out of scope - No new preset palettes (locked from MVP). - No persistence for the sim overlay (preview-only per spec — `localStorage` intentionally untouched by sim radio). - No colorblind-sim JS library — pure inline SVG `feColorMatrix`. Browser verified: filter rule matches via CSS sandbox; visual confirmation deferred to operator (single-tab radio, no fetch). E2E DOM assertion lives in the cv2 vm tests. Fixes #1380 --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
d7bd9d57b8 |
feat(live): fullscreen toggle + collapse controls by default (closes #1532) (#1572)
Closes #1532.
## What
Implements the triage's 3-step fix path + tufte keyboard shortcut:
1. **`.live-controls` collapsed by default at all viewports** (was
≤768px only). The existing ⚙ pin reveals the toggles row on demand —
parity with the map-controls accordion pattern in `map.js`.
2. **New `#liveFullscreenToggle` button (⛶) next to ⚙.** Click or press
`F` to flip `body.live-fullscreen`. CSS under that class hides:
- `.live-header-body` (title)
- `.live-controls-body` (toggle row contents)
- `.vcr-controls` and `.vcr-bar` (timeline scrubber)
- `.bottom-nav`
- secondary panels (`.live-feed`, `.live-legend`, related show-buttons)
3. **`.live-stats-row` stays pinned top-right** with translucent chip
styling so the 3 KPI pills (nodes / active / pkts·min) earn permanent
residence per the tufte finding.
## Tufte rationale (from triage)
> data-ink ratio is poor — 11 controls + 3 KPIs displayed permanently
steal pixels from THE data (the firework animation). Defaults-on chrome
should collapse behind a pin/cog; only the 3 stat pills earn permanent
residence (sparkline-grade density). … "Fullscreen" is the right
primitive — Tufte's "shrink principle" says strip until unreadable, then
add back.
## Keyboard shortcut
`F` toggles fullscreen. Guards:
- Skips when focus is in `INPUT`/`TEXTAREA`/`SELECT`/contenteditable (no
interference with node-filter / audio sliders typing).
- Skips when modifier keys are held.
- Only fires on the `.live-page` route.
- State persists across reloads via `localStorage('live-fullscreen')`.
## TDD
| Commit | SHA | What |
|--------|-----|------|
| RED | `852a474b` | Source-invariant assertion test
`test-issue-1532-live-fullscreen.js` (17 assertions, all fail against
master). |
| GREEN | `906c6cc0` | Implementation: HTML button, JS click+keydown
wiring, CSS body-class rules + top-level `.is-collapsed` rule. |
Verify the RED commit gates the change:
```
git checkout
|
||
|
|
75a38f0285 |
Additional live map performance optimizations (#1521)
This PR introduces a major performance optimization by migrating the final DOM-heavy animation (node pulses) into the hardware-accelerated canvas engine by migrating the concentric "pulse" rings (rendered when a node receives a packet) from DOM-based Leaflet L.circleMarker elements into the high-performance HTML5 canvas animation loop (activePulses). This completely eliminates DOM thrashing when dozens of nodes broadcast simultaneously, ensuring a buttery-smooth 60 FPS even under extreme packet volume. |
||
|
|
268751ff56 |
fix(#1485): put live map animations on custom pane z=650 (above markerPane) (#1491)
## Summary Animations on the live map (packet pulses, hop-to-hop trails, drawAnimatedLine, pulseNode rings, matrix chars) render BEHIND the node base layer — community-confirmed by @EldoonNemar in #1485 after pulling latest and rebuilding. The live map looks completely static because every node marker paints on top of moving packets. Closes #1485 ## Root cause PR #1334 ("role-aware marker shapes + outline-ring highlight") swapped node markers: - **Before:** `L.circleMarker([n.lat, n.lon], {...})` — rendered into the default Leaflet `overlayPane` (z=400) alongside other vector shapes. - **After:** `L.marker([n.lat, n.lon], { icon: L.divIcon({...}) })` — rendered into the default Leaflet `markerPane` (z=600). `animLayer` and `pathsLayer` (built from `L.polyline` / `L.circleMarker` shapes) still default to `overlayPane` @ 400. With nodes now in pane 600, every node marker occluded every animation. CDP confirmed pre-fix: ``` overlayPane z=400 (animations live here) ← 2 children markerPane z=600 (nodes live here) ← 516 children ← occludes ``` ## Fix Create a custom Leaflet pane `liveAnimPane` at `z-index: 650` (strictly above markerPane) and pin both `animLayer` and `pathsLayer` to it via the `{ pane: 'liveAnimPane' }` option on `L.layerGroup`. Polylines + circleMarkers added to those groups inherit the pane from their parent, so all `drawAnimatedLine` / `pulseNode` / `animatePath` / matrix-char shapes now paint above markers. `pointerEvents: 'none'` on the pane so it does not steal hover/click events from the markerPane beneath (`clickablePathsLayer` keeps the default overlayPane and continues to handle path clicks). Diff is +14 / -2 in `public/live.js`. No CSS changes, no API changes, no protocol changes. ## TDD Red commit (`b7ca794f`): test asserts on `public/live.js` source — 1. `map.createPane('liveAnimPane')` is called in init 2. that pane is assigned `style.zIndex` ≥ 650 (strictly above markerPane @ 600) 3. `animLayer` AND `pathsLayer` are constructed with `{ pane: 'liveAnimPane' }` 4. (sanity) animLayer still hosts ≥3 animation shapes, pathsLayer ≥3 trail shapes — regression detector if someone moves circles to the default pane. CI must fail on `b7ca794f` (RED). Fix lands in `627ce341` (GREEN). Test reruns 5× clean — non-flaky (source invariants). ## Browser verified Local headless chromium (CDP) against `http://analyzer-stg.00id.net/#/live`: - **Before fix:** overlayPane z=400 (2 anim children), markerPane z=600 (516 marker children) — animations buried. - **After hot-deploy:** liveAnimPane z=650 above markerPane z=600 — animations visible on top. Will attach screenshot post-merge once staging redeploys. E2E assertion added: `test-issue-1485-live-anim-z.js:54` (`liveAnimPane z-index >= 650`). ## Test wiring `test-all.sh` line 51 added; CI runs the new test alongside the existing 1418/1420/1438/1470 suite. ## Credit Reported by @EldoonNemar in #1485 — pulled via git, built the docker image, noticed the regression same day. Bug-report quality was excellent (concise repro: "live map now shows the animated packets behind the node base layer so you can't actually see the nodes moving"). --------- Co-authored-by: mc-bot <bot@meshcore.local> |
||
|
|
93b2f4b6bb |
fix(#1473): treat 0x00 and 0xFF as reserved prefixes (matrix + generator) (#1474)
## Summary Two CoreScope surfaces treated `0x00` and `0xFF` as ordinary node prefixes, but the MeshCore firmware actively rerolls any identity whose public-key first byte is `0x00` or `0xFF` (see [`examples/simple_repeater/main.cpp:64`](https://github.com/meshcore-dev/MeshCore/blob/6b52fb32301c273fc78d96183501eb23ad33c5bb/examples/simple_repeater/main.cpp#L64)): ```cpp while (count < 10 && (the_mesh.self_id.pub_key[0] == 0x00 || the_mesh.self_id.pub_key[0] == 0xFF)) { // reserved id hashes the_mesh.self_id = radio_new_identity(); count++; } ``` As a result the analyzer was steering new operators toward identities the firmware will silently refuse — `0xFF` is also used as a wildcard flood marker in parts of the routing flow, so this isn't cosmetic. Reporter: **@halo779** (community). ## What this PR does * **`public/prefix-reserved.js`** — small new module, single source of truth. Exposes `isReservedPrefix`, `filterReserved`, `reservedCount`, `markReservedCells`. Firmware citation lives in the file header. * **Hash matrix (1-byte view)** — cells `00` and `FF` get the `.prefix-reserved` class, lose `.hash-active` so the matrix click handler skips them, and pick up an `aria-disabled` + a tooltip explaining why. * **Prefix generator** — random sampling, enumeration fallback, and the "available count" all filter out reserved prefixes. A visible note under the generator card cites `simple_repeater/main.cpp:64` directly. * **Prefix checker** — pasting a reserved prefix or full pubkey now surfaces a red `⚠️ Reserved prefix` alert above the per-tier breakdown. * **`public/style.css`** — `.prefix-reserved` greys + strikes through the cell and sets `pointer-events: none`. * **`public/index.html`** — loads `prefix-reserved.js` before `analytics.js`. ## Tests Red-then-green visible in commit history: * `test-issue-1473-reserved-prefixes.js` — `isReservedPrefix()` semantics (case + multi-byte) and `markReservedCells()` behavior on a mock 256-cell matrix. * `test-issue-1473-prefix-generator.js` — `filterReserved`, `reservedCount` per byte length, RNG-bias simulator showing the generator never returns a reserved prefix, enumeration-first-free skips `00`, and an assertion that `analytics.js` actually wires `PrefixReserved` into the generator. Both added to `test-all.sh`. Fixes #1473 --------- Co-authored-by: clawbot <bot@openclaw.invalid> |
||
|
|
d964c27964 |
feat(mobile): packets UX overhaul + nav surface + map inset + channel synthesis fixes (#1471)
## Summary Mobile UX overhaul for the packets surface plus two discoverable defects found along the way. All UI changes are mobile-only (`@media (max-width: 900px)` or `isMobile()` gates) — desktop unchanged. ## Closes - #1415 — packets layout cross-viewport jank - #1458 — Tufte mobile packets critique (P0s) - #1461 — Tufte v2 mobile packets critique (P0/P1) - #1467 — Favorites/Search/Customize unreachable on mobile - #1468 — client-side "unknown" channel synthesis - #1470 — node-detail map inset doesn't honor customizer dark provider ## Commits 1. `fix(#1468): drop client-side "unknown" channel synthesis` — `channels.js` 2. `feat(#1470): node-detail map inset honors customizer dark-tile provider` — `nodes.js`, `roles.js` 3. `feat(mobile): packets UX overhaul + bottom-nav More controls (#1415, #1458, #1461, #1467)` — `style.css`, `index.html`, `mobile-page-actions.js` (new) ## Mobile-list view changes - Kill empty chevron rail - Slim sticky THEAD (24px, retains sort affordance per operator preference) - Hide entire page-header on mobile - Mirror pause + Filters pill into navbar via new `mobile-page-actions.js` - Convert group-header `toggle-select` → `select-hash` on mobile (no dead-end expand) ## Mobile detail-panel changes - Drop redundant src→dst line (identity already in sticky header) - Hide boxed "decoded message" duplication card - Hide PAYLOAD TYPE row (already in header badge) - 2-col label/value grid (cuts panel height ~40%) - Sticky in-sheet header for packet identity - Kill iOS-style drag handle (conflicts with browser pull-to-refresh) - Make ✕ close visible + always reachable - Outer sheet `overflow:hidden`, inner content `overflow-y:auto` (scrollable region distinct, scrollbar visible) - Bottom-nav clearance (`padding-bottom: 60px`) - Close detail sheet on route change away from /packets - Tap-to-toast popovers for score tooltips (`title=` doesn't fire on touch) ## Mobile nav surface - Mirror Favorites ⭐ / Search 🔍 / Customize 🎨 into bottom-nav More sheet (#1467) - Brand stays in top nav; per-page controls (pause, Filters) injected into `.nav-left` ## Other fixes shipped together - **#1468**: drop CHAN messages with no decoded channel name (eliminates fake "unknown" channel row) - **#1470**: `_applyTilesToNodeMap` helper — node-detail inset map reads from `MC_TILE_PROVIDERS[active]` instead of hardcoded OSM; honors customizer's dark-tile provider pick + applies invert filter for inverted variants - `getTileUrl()` + new `getActiveTileProvider()` in `roles.js` now consult `MC_TILE_PROVIDERS` ## CDP verification (local chromium) Tested on staging at viewport 390×844 + 1206×928. | Surface | Before | After | |---|---|---| | Chrome above first data row | 231px (27% viewport) | ~80px (10% viewport) | | Packets visible above fold | 10 | 17 | | Detail panel duplications | 3× identity | 1× (header only) | | Mobile group-expand UX | dead-end (no chevron) | converts to select-hash | | Score tooltips on touch | broken (title= silent) | tap → toast popover | | Node detail map inset (dark mode) | always OSM light tiles | honors customizer provider + invert filter | | Bottom-nav More controls | Dark mode only | + Favorites, Search, Customize | ## What's NOT in this PR - Paths-through-node sort fix lives in #1431 (parallel PR for #1145) - Detail-panel hex byte-grid behind disclosure — operator wants it; follow-up - Group-header row sizing (some render 200–700px tall) — existing behavior, follow-up ## Test plan - [ ] Existing frontend tests stay green (`test-issue-1415-packets-layout.js`, `test-issue-1420-tile-providers.js`, `test-issue-1454-channels-toggle.js` all pass locally on this branch) - [ ] Existing Playwright E2E stays green - [ ] CDP on local chromium: 390×844 mobile + 1024×768 tablet + 1440×900 desktop — no regressions --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
d24246395d |
fix(#1456): rename Usefulness → Traffic share + add traffic_share_score field (#1457)
## Summary Rename the "Usefulness" UI label to "Traffic share", add hover tooltips for both Traffic share and Bridge score, and introduce a new `traffic_share_score` field on `/api/nodes` (alongside the legacy `usefulness_score`, kept for API back-compat). Closes #1456. ## Why The "Usefulness" label implied a composite score that doesn't exist yet — only the Traffic-share axis (axis 1 of 4 from #672) and the Bridge axis (axis 2 of 4 from #1275) are wired today. A node with low traffic but critical structural position read as "not useful" — exactly wrong. Neither score had a tooltip explaining what it measured. ## Changes ### Frontend (`public/nodes.js`) - Visible label `Usefulness` → `Traffic share` (with ⓘ glyph) - Tooltip explains traffic-share semantics, cross-references Bridge for structural importance, points at #672 for the 4-axis roadmap - Bridge row gets a parallel ⓘ glyph and a tooltip naming "betweenness centrality" + the "quiet but irreplaceable chokepoint" interpretation - Prefers new `traffic_share_score` with graceful fallback to legacy `usefulness_score` ### Backend (`cmd/server/routes.go`) - `/api/nodes` and `/api/nodes/{pubkey}` now emit BOTH `usefulness_score` (kept for API compat) AND `traffic_share_score` (new canonical name), populated with the same value - Inline comment documents the deprecation path: when the #672 composite ships, `usefulness_score` becomes the composite and `traffic_share_score` keeps the per-axis value ## Tests - `test-issue-1456-score-labels.js` — file-grep pins on `nodes.js` (label, tooltip fragments, percent formatting, dual-field read with fallback) - `cmd/server/traffic_share_score_test.go` — `/api/nodes` + `/api/nodes/{pk}` responses contain both fields with equal values TDD: red commit (`8bd235a0`) added failing tests; green commit (`c4d3aee5`) implemented. `go test ./cmd/server/...` passes (47s). ## Out of scope - Renaming the backend field (would break consumers) - Wiring axes 3 (Coverage) and 4 (Redundancy) — tracked in #672 - Changing the score calculation --------- Co-authored-by: clawbot <bot@openclaw.local> |
||
|
|
d00ba91b1a |
feat(#1454): customizer toggle for show encrypted channels (#1455)
## Summary Adds a customizer checkbox that toggles `localStorage["channels-show-encrypted"]` — the read-gate that controls whether `/api/channels` is fetched with `?includeEncrypted=true`. Today operators can only flip that gate from DevTools; this PR gives them the obvious affordance. Default behavior is unchanged: key remains unset → server filters encrypted entries → ~19 channels rendered. Toggle ON sets the key to `"true"` → fetch grows to ~265 with `Encrypted (0xAB)` entries. ## Behavior - **Display tab → new "Channels" subsection → "Show encrypted channels" checkbox.** - ON writes `localStorage["channels-show-encrypted"] = "true"`. - OFF *removes* the key (never writes `"false"`) so the read-gate cleanly returns false and the customizer match-default detection still works. - Toggling dispatches `mc-channels-show-encrypted-changed`; `channels.js` listens and re-fetches via `loadChannels()` — no page reload. - Tooltip / hint copy: "Encrypted channels appear as 'Encrypted (0xAB)' with no name. Operators usually leave this off." ## TDD `test-issue-1454-channels-toggle.js` — source-grep invariants: - Red commit `feb9dcee`: assertions on customizer + listener — failed (production code not yet present). - Green commit `d8742f2c`: production patch — passes. Read-gate at `public/channels.js:1564` is left untouched; the test asserts it. ## Out of scope - Migration of legacy localStorage values into customizer overrides (no override store needed — we keep using the raw localStorage key as the single source of truth). - Per-region toggle. - Decryption key UI. Closes #1454 --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
58282c91d8 |
fix(#1065): gesture hints touch-gate + width:fit-content + CSS-parse safety (#1452)
## Summary Three follow-up fixes for #1065 gesture-hint discoverability: 1. **Touch-capability gate.** New `hasTouchCapability()` helper probes `'ontouchstart' in window`, `navigator.maxTouchPoints`, and `(pointer: coarse)`. Every `HINTS[*].relevant()` predicate now returns `false` immediately on mouse-only viewports, so desktop browsers no longer get "swipe a row left" tips. 2. **`width: fit-content` on the pill wrap.** The `.gesture-hint` block previously had no explicit width and defaulted to block-level full-width. Combined with `translateX(-50%)` on `.gesture-hint-bottom` this rendered as a 100vw-wide bar centered with a negative-X transform, i.e. pushed off-screen-left on narrow viewports (384px wrap on 390px viewport). 3. **CSS-parse safety.** Moved the in-body comment (which contained an em-dash) outside the rule block. An earlier attempt to add `width: fit-content` together with an in-body em-dash comment caused the parent `.gesture-hint` rule to vanish from the CSSOM in Chrome (children `.gesture-hint-*` remained). Putting the comment above the block sidesteps the parser bug. ## Test `test-issue-1065-gesture-hints-gates.js` — pure source-file assertions, no browser required. Red commit first (7 fails), green commit second (10/10 pass). Wired into `test-all.sh`. ## Verification After hot-deploy on staging: - Desktop (no touch): `document.querySelectorAll('.gesture-hint').length` === 0 - Mobile emulated (touch): hint rendered, `getBoundingClientRect().x >= 0`, `width <= 360`, `width < viewport_width` - CSSOM: parent `.gesture-hint` rule present with `width: fit-content` + `max-width: 360px` --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
b5a1642024 |
fix(#1450): preserve custom logo aspect ratio (svg/img CSS split) (#1451)
## Summary
Custom navbar logos via `branding.logoUrl` were rendered squished. The
CSS rule `.brand-logo { width: 125px }` was pinned to the default
inline-SVG wordmark's viewBox aspect (~3.08:1), and when customize-v2
swapped the inline `<svg>` for an `<img>`, that `<img>` inherited the
same fixed 125px width — stretching every non-3.08:1 image into a pill.
## Root cause
- `public/style.css:520` — `.brand-logo { width: 125px }` applied
regardless of element type.
- `public/customize-v2.js:75-77` — `_setBrandLogoUrl` additionally
hardcoded `width="125" height="36"` attributes on the created `<img>`,
overriding any CSS aspect rescue.
- Mobile media query (`style.css:1729`) had the same issue with `width:
112px`.
## Fix
Split the CSS rule by element type:
- `svg.brand-logo` — keeps 125×36 pin for the default wordmark (no
regression).
- `img.brand-logo` — `width: auto`, `max-width: 200px`, `object-fit:
contain` so the operator image's natural aspect is preserved with a sane
cap so very-wide logos can't blow nav layout.
- Mobile `@media` mirrors the split (svg 112×32 pinned, img auto width
with 180px cap).
- Drop the hardcoded `width=125`/`height=36` attrs from the `<img>`
created in `customize-v2 _setBrandLogoUrl`.
## TDD
Red commit `a20b7d7`: 4 assertions, all fail on master.
Green commit `533f464`: same 4 assertions, all pass.
```
✓ img.brand-logo CSS rule exists and uses width:auto (not pinned)
✓ svg.brand-logo CSS rule still pins width:125px (no default regression)
✓ mobile media-query splits the .brand-logo rule into svg/img variants
✓ customize-v2 _setBrandLogoUrl does NOT hardcode width/height attrs on the IMG
```
## Verification plan post-merge
Hot-deploy to staging and CDP-verify:
1. Default SVG wordmark still renders at 125×36 (no default regression).
2. Square 100×100 data-URI logo renders as ~36×36 (was 125×36 pill).
3. Tall 100×300 data-URI logo renders as ~12×36 (was 125×36 pill).
Closes #1450
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
|
||
|
|
ddf14d1954 |
feat(#1446): CB preset is an end-user opt-in (closes #1446, fixes #1444 cascade) (#1447)
## Summary Reframes the CB-preset feature as an **end-user opt-in** layered above operator config — not the canonical color source for the app. Implements the cascade defined in #1446's acceptance test and fixes the #1444 cascade trap as a side effect. **Cascade (top wins):** ``` user per-role override > active CB preset > server config.nodeColors > built-in :root defaults ``` Red commit: |
||
|
|
604c3552c7 |
fix(#1438): customizer per-role override writes --mc-role-{role} on reload (#1443)
## Summary Closes the final gap left by #1439 (marker SVG `fill="var(--mc-role-X)"` migration) and #1441 (body.style write in `setRoleColorOverride`). Both prior PRs made marker SVGs read from `--mc-role-{role}` CSS vars, and made the LIVE customizer pick path write that var via `setRoleColorOverride`. But the second leg of the round-trip was still broken: **On page reload**, `customize-v2.js applyCSS()` replays `userOverrides.nodeColors` from localStorage and writes only `--node-{role}` (the legacy var). `setRoleColorOverride` is **not** replayed. Result: marker fills revert to the active preset's colors even though the operator's custom hex is still in localStorage. ## Fix Extend the per-role loop in `applyCSS` to write **both** `--node-{role}` (legacy compat) and `--mc-role-{role}` (the var marker SVGs now read). ```js for (var role in nc) { root.setProperty('--node-' + role, nc[role]); root.setProperty('--mc-role-' + role, nc[role]); // NEW } ``` `public/customize.js` `setRoleColorOverride` path: already correct in `roles.js` (#1441 wrote the body.style hop with the explicit #1438 comment). No change needed there — the gap was specifically the reload-time replay in customize-v2. ## Test New `test-issue-1438-customizer-mcrole.js` — source-invariant assertions on the loop body. Red commit fails on the `--mc-role-` assertion; green commit passes 4/4. Added to `test-all.sh`. ## Verification plan Post-merge hot-deploy + CDP verify on `analyzer-stg.00id.net`: 1. `setOverride('nodeColors','repeater','#ff00ff')` → `applyCSS(computeEffective())` 2. Assert `getComputedStyle(documentElement).getPropertyValue('--mc-role-repeater') === '#ff00ff'` 3. Sample a repeater marker SVG, assert `getComputedStyle(...).fill === 'rgb(255, 0, 255)'` 4. Screenshot Closes #1438. --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |