mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 17:27:57 +00:00
7cc5aae6ace8efcb92fcbbd4080c080b09454b2f
150
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ac50b2f3e6 |
docs: add GPS-reliability caveat to Foreign-Flagged Nodes section
foreign_advert is purely self-reported GPS from the node's own ADVERT. Investigating a real flagged node (RYDBOHOLM, self-reported as Romania) showed its packets entering the DK mesh through a normal, healthy 8-hop local FLOOD relay chain (RSSI/SNR consistent with real short-range LoRa) — and MeshCore has no MQTT-to-RF bridging mechanism, so that's only physically possible if the node is actually local and its GPS is simply wrong. Documents the inference rule directly on the tab: a flag near an actual border may be genuine; a flag claiming hundreds/thousands of km away, arriving via a healthy local relay chain, is a GPS data-quality artifact, not real long-distance traffic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
a55c237ea1 |
fix: move Foreign-Flagged Nodes list above the unscoped-relay table
Reordered so the node list (what's actually accumulating right now, worth checking back on) comes first, with the unscoped-relay-volume table — the part that needs more data to be interesting — below it. Added a heading to the relay table for symmetry with the new sub-heading above it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
492d15c43b |
feat: list foreign-flagged nodes on the Foreign Traffic tab
Adds a second table to the tab: every node currently foreign_advert -tagged (name, role, lat/lon, first/last seen), newest-heard first — so there's something to look at while more foreign_advert data accumulates, instead of only the one-sentence summary count. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
62f1023c2f |
fix: stop implying flood.max.unscoped 0 is the well-configured target
A little unscoped traffic through a repeater is normal — flood.max.unscoped caps it rather than blocking it outright, and setting it to 0 isn't a realistic target in practice. The Foreign Traffic tab's copy read as "anything nonzero is misconfigured," which isn't true; reworded to frame the view as spotting disproportionate volume, not achieving zero-tolerance. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
679ab2fb86 |
test: add DOM coverage for the Foreign Traffic tab + auto-refresh hook
Per bot review on PR #1852 (comment 5012304813): renderForeignTrafficTab shipped without a test, repeating the same TDD-policy finding flagged on the prior round, and the tab lacked the 60s auto-refresh + stop-hook convention its Roles/Scopes siblings use (data would go stale until the user re-navigated away and back). - Added _stopForeignTrafficRefresh + a 60s setInterval loop, wired into the tab-switch handler and destroy(), matching _stopRolesRefresh/ _stopScopesRefresh exactly. - New test-analytics-foreign-traffic-tab.js: loads analytics.js in a vm sandbox (same pattern as test-frontend-helpers.js's makeAnalyticsSandbox), stubs fetchAllNodes with a fixed node mix, and asserts row sort order, role/zero-count exclusion, the empty state, both foreignCount note variants, and that the new stop-hook is exported and idempotent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
4a7dd6b795 |
feat: add Foreign Traffic tab surfacing unscoped relay volume per repeater
New "Foreign Traffic" tab under Analytics, sorted by unscoped_relay_count_24h descending — the metric already existed in the /api/nodes response (cmd/server/repeater_liveness.go, repeater_enrich_bulk.go) with an openapi doc string calling out exactly this: "A well-configured repeater sets flood.max.unscoped 0, so a non-trivial count flags a base-config problem" — but had zero frontend surface until now. Phase 1 of an investigation into unscoped traffic from PL/DE/SE leaking into the DK mesh. Phase 2 (cross-referencing which of this volume traces back to a foreign-origin sender via path_json) needs foreign_advert data to accumulate first — it's only set going forward from when geo_filter was configured, not backfilled for existing history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
8545af6269 |
fix: move Nodes Running This Region above Bridge Repeaters on Scopes tab
Bridge Repeaters renders as a flat, non-collapsible table — unlike Repeaters by Region and Nodes Running This Region, which group into collapsible <details> per region. On stg.meshview.dk it currently has 80 rows (~2700px tall), which buried Nodes Running This Region far down the page, reading as if the section had vanished entirely. Reordering doesn't touch the row-count problem itself, but it stops the shortest, most specific section from being the one that pays for it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
10eed4b2f6 |
fix: address remaining bot-review findings on PR #1852
Working through the outstanding non-blocker findings across all five
automated review passes on the PR:
- MAJOR: GetChannelMessages' rows.Scan() error was silently discarded
— a schema mismatch would produce zero-valued messages instead of a
visible error. Now returns the error, matching sibling query loops.
- Extracted the hashRegions name-normalization rule (trim, "#"-prefix,
dedupe) shared between cmd/ingestor's loadRegionKeys and cmd/server's
region-utilization diff into a new internal/regions package, so the
two can no longer drift apart on the rule independently.
- Batched GetRepeaterNamesByKeys' SQL IN (...) clause in chunks of 500
— an unbounded clause risks SQLITE_MAX_VARIABLE_NUMBER on very large
deployments' byPathHop candidate sets.
- handleScopeStats' remaining silently-swallowed err==nil branches
(GetMatchedRegionNames, GetNodesByDefaultScope,
GetChannelMessageScopeStats, GetChannelScopeAdoption) now log a WARN
breadcrumb on failure instead of failing invisibly.
- Scope Adoption table was rendering 3 of the 4 ChannelScopeAdoption
fields (Unscoped omitted) — the visible numbers didn't reconcile to
the message total without doing the subtraction by hand. Added the
column.
- Removed a duplicate `vertical-align: middle` declaration on
.badge-transport (dead, not a behavior change).
- Deleted ChannelMessageResp — flagged for interface{} vs *string/*int
typing, but turned out to be completely unused dead code; removing
it resolves the finding more directly than retyping something
nothing constructs.
Left two NIT/MINOR items as-is with reasoning:
- renderRegionNodeGroups' inline styles match the same pattern used in
15+ other places in analytics.js — "fixing" only this one function
would make it less consistent with the file, not more.
- TransmissionResp's interface{} fields (a different struct than the
one just removed) are an established, actively-used pattern for
nullable SQL-scanned values across that whole response type;
retyping it is a much larger, unrelated refactor.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
064a9c8e35 |
feat: scope adoption per channel on the Scopes analytics tab
Adds channelScopeAdoption to /api/scope-stats: the existing ChannelMessages aggregate (scoped/unscoped/unknown for channel chat) broken down PER CHANNEL — which specific channels (#test, #wardriving, ...) actually use region scoping vs which never do. Ordered by message volume, capped at the top 30 channels. Frontend renders a compact table under "Channel Messages": channel name, total messages, scoped count+%, unknown count. |
||
|
|
03abeba00e |
feat: hour-of-day activity heatmap on the Scopes analytics tab
Adds hourlyActivityByRegion to /api/scope-stats: each region's message counts bucketed by hour-of-day (0-23 UTC), aggregated across every day in the window — answers "when during a typical day is this region active" rather than "how did volume change over the window" (that's the existing chronological TimeSeries chart). Frontend renders a compact heatmap: one row per region, 24 hour columns, color intensity normalized per-row (each region's own busiest hour) so a quiet region's daily shape stays visible next to a loud one instead of being crushed toward zero. |
||
|
|
4f0610296d |
fix: always show heading for the region-breakdown sections when empty
Repeaters by Region, Nodes Running This Region, and Bridge Repeaters all cleared their entire section (heading included) when the data was empty, which reads as "this feature doesn't exist" rather than "no data yet" — especially confusing right after a restart, when the neighbor graph and path resolution need a few minutes to catch back up before repeater-level scope data repopulates. Now always renders the heading + description with an explanatory empty-state message instead of hiding. |
||
|
|
1aed3ee5c8 |
feat: bridge-repeaters breakdown on the Scopes analytics tab
Adds bridgeRepeaters to /api/scope-stats: RepeatersByRegion inverted into pubkey -> regions, keeping only repeaters that have relayed traffic for MORE than one region. These are the mesh's literal backbone nodes connecting otherwise-separate regional communities — losing one is a more consequential failure than losing a single-region repeater. Computed inline while building RepeatersByRegion (reuses the same byRegion map and role-filtered names lookup, no extra queries). Frontend renders a small table under "Repeaters by Region": repeater name (linked to its node detail page), region count, and the region list. |
||
|
|
c74a005247 |
feat: channel-messages-only scoped/unscoped breakdown on the Scopes tab
Adds channelMessages to /api/scope-stats: the same scoped/unscoped/ unknown question as the main Summary, but restricted to payload_type=5 (channel chat) instead of all observed traffic. Most channel chat is plain FLOOD rather than transport-scoped, so this can read very differently from the all-traffic numbers — answers "how many of our actual channel messages carry a region scope" directly instead of requiring the reader to infer it from the broader stats. New GetChannelMessageScopeStats() mirrors GetScopeStats' query shape but scopes TotalMessages to ALL route types for payload_type=5 (not just route_type 0/3), since restricting to transport routes would answer a different question than "how many channel messages, period". Frontend renders a small "Channel Messages" stat-card row under the main summary cards, window-scoped like the rest of the tab. |
||
|
|
a44f2a4ad5 |
feat: nodes-running-this-region breakdown on the Scopes analytics tab
Adds originatingNodesByRegion to /api/scope-stats: nodes whose OWN default_scope (#899) is a given region, complementing the existing repeatersByRegion (transported_scopes) breakdown. The distinction matters — a repeater can relay traffic for a region it isn't itself configured with, so "who runs this region" and "who has carried this region's traffic" are different, both useful questions. Frontend: refactored the per-region collapsible-list rendering (used by both breakdowns) into a shared renderRegionNodeGroups() helper instead of duplicating the HTML-building logic, and added a "Nodes Running This Region" section alongside "Repeaters by Region". |
||
|
|
87d2f479a7 |
feat: repeaters-by-region breakdown on the Scopes analytics tab
Adds repeatersByRegion to /api/scope-stats: for every region that has ever matched a transmission, which distinct repeaters/rooms have relayed traffic carrying that scope. Sourced from the same 5-min background-recomputed bulk relay-info cache the Nodes page already uses (GetRepeaterRelayInfoMap / TransportedScopes, #1751) — no new expensive computation, just an inversion + name lookup. Frontend renders a collapsible per-region repeater list (name links to the node detail page) under a new "Repeaters by Region" section, explicitly framed as a coverage/redundancy signal: a region carried by only one repeater is a single point of failure for that area. |
||
|
|
afa76c4f52 |
feat: region-utilization report on the Scopes analytics tab
Adds configuredRegions/unusedRegions to /api/scope-stats: an all-time (not window-scoped) diff between the operator's configured hashRegions list and the set of scope_name values that have actually matched a transmission still in retention. Surfaces how much of the region list is dead weight — directly actionable evidence for pruning, which is also the real fix for the HMAC-collision noise (fewer configured regions -> lower birthday-collision probability per packet). Server config now parses hashRegions (previously ingestor-only, same config.json key) purely to read the configured names — no HMAC key derivation happens server-side. Frontend: a "Region Utilization" section on the Scopes tab shows used/unused counts and a collapsible list of the unused region names. |
||
|
|
59cf5130d1 |
fix(1838): fold non-transport routes into scope-stats Unscoped (#1842)
Fixes #1838 ## Problem `/api/scope-stats` reported 100% scoped whenever any region was configured. Reporter noticed on a scopeless instance that "unscoped" was always zero — the pie visual is misleading to operators deciding on `denyf *`. ## Root cause `cmd/server/db.go:22` restricted the entire scope-stats denominator to `route_type IN (0, 3)`. Per firmware `docs/packet_format.md § Route Types`: - `0` = `TRANSPORT_FLOOD` - `1` = `FLOOD` - `2` = `DIRECT` - `3` = `TRANSPORT_DIRECT` Only routes 0 and 3 carry `transport_code_1` (transport-level scope). Routes 1 and 2 are inherently unscoped by protocol. The existing SQL was correct for the "how many transport-scopable routes are actually scoped" question, but the denominator was silently promoted to "all traffic" in the UI. Bonus: the comment on `routeTypeTransportSQL` labelled routes 0+3 as "FLOOD (0) and DIRECT (3)" — wrong on both counts. ## Fix - `cmd/server/db.go` — corrected the `routeTypeTransportSQL` comment; added `routeTypeNonTransportSQL = "route_type IN (1, 2)"` alongside it. - `GetScopeStats` runs a second `COUNT(*)` over `route_type IN (1,2) AND first_seen >= ?` and folds that count into `Summary.Unscoped`. Same index path as the existing query — one extra scan per `/api/scope-stats` call (cached 30s per triage's carmack finding). - `public/analytics.js` — Scopes tab header explains the denominator (all observed transmissions) and which route types carry scope. Card notes now render `X% of all traffic` for Scoped/Unscoped and `X% of scoped` for Unknown Scope so the pie's denominator is explicit. ## TDD - Red: `5554ffe4` — extended `TestGetScopeStats` + `TestHandleScopeStats` with `route_type=1` and `route_type=2` rows and asserted `Unscoped = 3` (1 transport-NULL + 2 non-transport). Ran the tests and confirmed assertion failure (`Unscoped = 1, want 3`). - Green: `ebbb9253` — implementation + label copy. Full `go test ./cmd/server/...` passes (54s). ## Preflight overrides - check-branch-clean: justified — cross-stack fix by design (backend semantics change + matching frontend label copy). All 4 files are exactly the surface the triage comment identified. ## Verification - `go test ./cmd/server/...` — 54s, all pass. - Firmware confirmation: `firmware/docs/packet_format.md:20-24` (route type table). ## Files touched - `cmd/server/db.go` — comment fix + second COUNT query. - `cmd/server/db_test.go` — extended fixture. - `cmd/server/routes_test.go` — extended fixture + isolate from seed data. - `public/analytics.js` — labels and header copy. --------- Co-authored-by: corescope-bot <bot@corescope.dev> |
||
|
|
4654ce3386 |
feat(analytics): "My Repeaters" favorites monitoring dashboard (#1761)
My Repeaters monitoring dashboard. Closes #1765. --------- Co-authored-by: Waydroid Builder <build@waydroid.local> |
||
|
|
30e4151f7a |
fix: neighbor-graph tab never renders after filtering down (#1758)
The Analytics → Neighbor Graph tab fetches the full (uncapped) graph and, when it exceeds NODE_LIMIT (1000), skips the force simulation with a "use filters to reduce the node count" notice. But filtering never actually re-enabled rendering: - the node-count guard tested _ngState.allNodes (the immutable full fetched set, assigned once in createGraphState and never reassigned) instead of the displayed/filtered _ngState.nodes, so its verdict was fixed at load time; - the entire draw loop lives in startGraphRenderer(), which ran exactly once at load and was never called from applyNGFilters(), so a filter change updated the node/edge arrays and stat cards but never un-hid the canvas or scheduled an animation frame -> the graph stayed blank no matter how few nodes remained. This explains both reported symptoms (selects too many nodes initially AND stays broken once restricted to fewer). Fix: make the render lifecycle filter-aware. - startGraphRenderer() now guards on the displayed set (_ngState.nodes), cancels any running rAF loop before re-deciding, toggles the canvas plus a stable-id "skipped" notice, and restarts cleanly (no double loops). - applyNGFilters() calls startGraphRenderer() so every filter change re-evaluates the guard and (re)starts or stops the loop. - the initial render now goes through applyNGFilters() so the first paint already respects the default filters (observers unchecked, saved min-score) instead of dumping the full fetched graph. Test: `node --check public/analytics.js` passes. Manually: open Analytics → Neighbor Graph on a mesh with >1000 nodes → the "skipped" notice shows; tighten filters (min-score up / roles off) below 1000 → the graph now renders (was blank before); loosen again → notice returns. Frontend-only change (`public/analytics.js`); no backend/API change. --- **TDD note (review round 1):** Single-commit community bug-fix on an existing UI surface (no "net-new UI" exemption). The e2e `test-issue-1758-ng-filter-rerenders-e2e.js` is the red→green gate — it fails on `origin/master` (the renderer kept the node-count guard on the full fetched graph and never un-hid the canvas) and passes with the fix. Per AGENTS.md the separate red/green-commit *form* is a bot rule, not a contributor gate. --------- Co-authored-by: Waydroid Builder <build@waydroid.local> Co-authored-by: Waydroid Builder <claude@michael.arcan.de> |
||
|
|
57956712e7 |
fix(#1768): Relay Airtime Share uses LoRa Time-on-Air (preamble-aware) — partial fix (#1776)
Partial fix for #1768 — Relay Airtime Share now uses closed-form LoRa Time-on-Air instead of a payload-bytes-only proxy, removing the ~3-4× bias against small frames (preamble + fixed-symbol intercept). cross-stack: justified — backend score formula needs a frontend caption change (`public/analytics.js` dumbbell preset banner + tooltip) so operators can interpret the assumed PHY block. Both move together or the metric is misleading. ## Red commit `8da57062` — failing test asserts ToA-based score (~83.48 % ADVERT share on the locked acceptance fixture) instead of the byte proxy's 95.24 %. `internal/lora.TimeOnAir` was a zero-returning stub at the red commit; tests failed with assertion errors, not build errors. ## Green commit `dd402edd` — implements `lora.TimeOnAir` (Semtech AN1200.13 / SX126x §6.1.4 closed form, cross-checked against RadioLib), wires `score = TimeOnAir(payloadBytes, preset) × distinctRelays` in `cmd/server/relay_airtime_share.go`, surfaces the preset in the JSON response and analytics caption. ## Config (per AGENTS Config Documentation Rule) New keys under existing `analytics` block: ```json "loraPreset": { "freq": 869600000, "bw": 62.5, "sf": 8, "cr": 5 } ``` Defaults match the deployment's actual `get radio` (869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5). `CRC=1`, `IH=0`, `DE = (T_sym ≥ 16 ms)`, and the SF-dependent preamble (32 for SF≤8 else 16, per firmware `preambleLengthForSF` / MeshCore PR #1954) are firmware-fixed constants in `internal/lora/toa.go` and intentionally NOT surfaced as config (per re-triage). ## Scope In-scope files (6): - `internal/lora/toa.go` (new package — closed-form ToA) - `internal/lora/toa_test.go` (table-driven preset tests) - `cmd/server/relay_airtime_share.go` (wire ToA into score) - `cmd/server/relay_airtime_share_test.go` (recomputed expected values) - `cmd/server/config.go` + `config.example.json` (preset config keys) - `public/analytics.js` (preset caption on dumbbell chart + tooltip) Plus `cmd/server/go.mod` (replace directive for the new internal module). ## Deferred to v2 (separate issues per re-triage) - Per-observation SF/BW + radio-settings-aware dedup (blocked: ingestor stores SNR/RSSI only, no SF/BW on observations). - CR-per-hop dual-point sensitivity band (CR scales only the payload symbol term `(CR+4)`, not the preamble/header; second-order accuracy gain). - Cross-SF bridge accounting. ## Tests ``` cd internal/lora && go test ./... → PASS cd cmd/server && go test -run RelayAirtime → PASS ``` ## Preflight overrides - `check-branch-clean` (cross-stack): justified above — score formula change requires matching caption update; both files trace to the same issue. --------- Co-authored-by: kpa-clawbot <kpa-clawbot@users.noreply.github.com> Co-authored-by: Kpa-clawbot <bot@openclaw.local> Co-authored-by: bot <bot@meshcore> |
||
|
|
735d9eb516 |
fix(#1715): dark-theme role swatches via per-theme CSS tokens (#1757)
## Summary Dark-theme variants of the neighbor-graph role swatches (`#ngRoleChecks` labels on `/analytics?tab=neighbor-graph`) still failed WCAG AA after #1720's light-theme fix because the swatches used inline `style="color:#..."` from `customize.js` `DEFAULTS.nodeColors` (palette-700) — bypassing the theme tokens entirely. Measured before: | Role | Color | vs `#1a1a2e` (dark) | |---|---|---| | repeater | `#dc2626` | 3.53:1 ❌ | | companion | `#2563eb` | 3.30:1 ❌ | | observer | `#8b5cf6` | 4.02:1 ❌ | ## Fix - Defines `--role-{repeater,companion,room,sensor,observer}` in `:root` (palette-700, ≥4.5:1 on white) and overrides them in both dark blocks (`[data-theme="dark"]` + the `@media (prefers-color-scheme: dark)` mirror) with palette-400/500 shades that clear AA on `#1a1a2e`. - Refactors the neighbor-graph swatch DOM in `public/analytics.js` from inline `style="color:${hex}"` to class-based `<span class="role-swatch role-swatch--{role}">`, with matching CSS rules that read the tokens. - Removes all 5 `#1715` entries from `tests/a11y-allowlist.yaml` per the issue's acceptance criteria. After: | Role | Light (vs `#fff`) | Dark (vs `#1a1a2e`) | |---|---|---| | repeater | `#dc2626` 4.83:1 | `#ef4444` 4.53:1 | | companion | `#2563eb` 5.17:1 | `#3b82f6` 4.64:1 | | room | `#15803d` 5.02:1 | `#16a34a` 5.18:1 | | sensor | `#b45309` 5.02:1 | `#d97706` 5.35:1 | | observer | `#7c3aed` 5.70:1 | `#a78bfa` 6.27:1 | ## Tests - `test-a11y-1715-dark-role-swatches.js` — CSS-driven WCAG AA probes for the 5 per-theme `--role-*` tokens plus markup invariants (no inline color span; class names present in `#ngRoleChecks` block). - Red commit: `a09ec21c` — fails on assertion with 12 below-threshold/markup probes. - Green commit: `f87dcd64` — all probes PASS. - `tests/a11y-allowlist.yaml` shed all 5 entries; the umbrella `test-a11y-axe-1668.js` (CI) is now the live-browser net for those cells. ## Preflight overrides - `check-xss-sinks.sh` flags `public/analytics.js:2502` (label "observer" appears in an `innerHTML=\`tpl\`` line). The flagged token is a hardcoded literal string — no user-controlled data flows into that template. No template content changed in this PR; the flag is preexisting noise from the heuristic scan and the gate ultimately marks ✅ pass. Fixes #1715 --------- Co-authored-by: clawbot <clawbot@kpa.local> Co-authored-by: clawbot <bot@example.com> |
||
|
|
a344ae0a12 |
fix(#1719): contrast root causes — active-btn / skew-badge / role-swatch / status-green (#1720)
## Summary Fixes the four recurring color-contrast root causes #1719 identifies behind ~320 axe violations on PR #1707's expanded gate. All fixes are token-based; no hardcoded hex introduced. ## TDD - **Red:** `151db732` — `test-a11y-1719-contrast-root-causes-e2e.js` asserts WCAG AA on all 4 patterns; failed with 12 sub-threshold probes. - **Green:** `dd26554e` — fixes below; test now reports 11/11 PASS. ## Patterns + measured contrast (before → after) | # | Surface | Before | After | Note | |---|---|---|---|---| | P1 | `.rf-range-btn.active` / `.clock-filter-btn.active` / `.subpath-jump-nav a` / `#ptCheckBtn` / `#ptGenBtn` | `#fff` on `--accent` (#4a9eff) = **2.75:1** | `--text-on-accent` on `--accent-strong` = **4.95:1** | Consolidated into ONE grouped `.btn-active-accent, ...` rule; inline buttons now use the shared class | | P2 | `.skew-badge--no_clock` (dark theme) | `#fff` on `--text-muted` (#d1d5db) = **1.47:1** | `#fff` on `--skew-badge-no-clock-bg` (#4b5563) = **7.56:1** | New dedicated token, both themes | | P3 | Neighbor-graph role swatches, light theme on white | room 3.30:1 / sensor 3.19:1 / observer 4.23:1 | room **5.02** / sensor **5.02** / observer **5.70** | `customize.js` defaults bumped to palette-{green/amber/purple}-700 | | P4 | `.analytics-stat-card` text in `--status-green` on white | **2.28:1** | new `--status-green-text` = #15803d → **5.02:1** | `--status-green` background token unchanged (still #22c55e); inline text usages routed to the new token | ## Why this unblocks #1707 #1707's 320 axe color-contrast hits decompose into: - 137× single-rule `.skew-badge--no_clock` → P2. - ~N×4 active-button surfaces (rf-health / clock-health / subpaths / prefix-tool) → P1. - Role-swatch text on `/#/analytics?tab=neighbor-graph` (light) → P3. - `.analytics-stat-card` text on `/#/analytics?tab=nodes` (light) → P4. After this merges, the next CI run on #1707 should see the expanded gate go green (or down to a small ≤5 residual the operator can triage separately per the issue's acceptance criteria). ## Local axe gate `BASE_URL=… node test-a11y-axe-1668.js` was **NOT** run locally — the sandbox's bundled chromium fails to boot Playwright (known issue). CI on this PR runs the same gate against the staging fixture; relying on that. The dedicated test `test-a11y-1719-contrast-root-causes-e2e.js` is CSS+JS-parse-driven (no browser) and runs in <100ms — it's the regression net for these 4 patterns specifically. ``` $ node test-a11y-1719-contrast-root-causes-e2e.js PASS [P1] theme=light .rf-range-btn.active fg=#f9fafb bg=#2563eb ratio=4.95:1 PASS [P1] theme=light .clock-filter-btn.active fg=#f9fafb bg=#2563eb ratio=4.95:1 PASS [P1] theme=light .subpath-jump-nav a fg=#f9fafb bg=#2563eb ratio=4.95:1 PASS [P1] theme=dark .rf-range-btn.active fg=#f9fafb bg=#2563eb ratio=4.95:1 PASS [P1] theme=dark .clock-filter-btn.active fg=#f9fafb bg=#2563eb ratio=4.95:1 PASS [P1] theme=dark .subpath-jump-nav a fg=#f9fafb bg=#2563eb ratio=4.95:1 PASS [P2] theme=light .skew-badge--no_clock fg=#fff bg=#4b5563 ratio=7.56:1 PASS [P2] theme=dark .skew-badge--no_clock fg=#fff bg=#4b5563 ratio=7.56:1 PASS [P3] theme=light nodeColors.room on white fg=#15803d bg=#ffffff ratio=5.02:1 PASS [P3] theme=light nodeColors.sensor on white fg=#b45309 bg=#ffffff ratio=5.02:1 PASS [P3] theme=light nodeColors.observer on white fg=#7c3aed bg=#ffffff ratio=5.70:1 PASS [P4] theme=light .analytics-stat-card text color (--status-green-text) fg=#15803d bg=#ffffff ratio=5.02:1 PASS [P4] theme=dark .analytics-stat-card text color (--status-green-text) fg=#22c55e bg=#232340 ratio=6.65:1 PASS: all 4 root-cause patterns ≥ 4.5:1 in both themes (issue #1719) ``` ## Out-of-scope (intentional) - Other text-on-light `var(--status-green)` usages in `nodes.js` (Critical/Valuable labels): different surface, not the analytics-stat-card pattern #1719 calls out. Tracked under analytics audit umbrella. - Hardcoded `var(--status-green, #2ecc71)` fallback in `nodes.js` lines 648/666: same scope deferral. - Allowlist entries: none added per the issue's acceptance criteria. Fixes #1719. --------- Co-authored-by: clawbot <clawbot@kpa.local> Co-authored-by: Kpa-clawbot <bot@openclaw.local> |
||
|
|
d954ea7444 |
feat(#1668): axe-core CI gate for WCAG AA color-contrast (M5) (#1696)
Partial fix for #1668 (M5 of 6). After M1 (audit), M2 (color tokens, #1676), M3 (typography floor, #1679), and M4 (per-route polish, #1681) cleared ~95% of contrast/typography violations, M5 **locks in the wins** by adding an axe-core CI gate that fails the build on any new WCAG AA color-contrast regression. ## What's in the box - `test-a11y-axe-1668.js` — Playwright + `@axe-core/playwright`. Runs every major CoreScope route × `{dark, light}` at 1200×900 desktop, injects axe, runs only the `color-contrast` rule, asserts net violations === 0. - `test-a11y-axe-1668-selftest.js` — fast, deterministic, browser-free unit test that exercises the YAML allowlist parser, the `violationAllowed` matcher, and the route/theme metadata. Runs in the JS unit block (no browser needed). - `tests/a11y-allowlist.yaml` — operator-flagged false-positive allowlist. **0 entries at M5 baseline.** ## Allowlist format Each entry MUST cite a GH issue # and an `expires_at` date. Missing fields = refused. Expired `expires_at` = refused (warning logged). This **forces a periodic revisit** — no permanent suppressions. ```yaml - route: /analytics?tab=channels selector: ".some-known-stale-element" rule: color-contrast issue: 1234 expires_at: 2026-09-01 ``` ## Routes covered (19 × 2 themes = 38 cells) `/`, `/packets`, `/nodes`, `/channels`, `/live`, `/map`, `/observers`, `/compare`, `/analytics?tab={overview,rf,topology,channels,hashsizes,collisions,roles,airtime}`, `/audio-lab`, `/customize`, `/replay`. ## TDD red→green - **RED** (`08adafdb`) — adds the gate + deliberately regresses `--text-muted` from `palette-gray-700` (~10:1) to `#9ca3af` (~2.4:1). axe-core fails on every light-theme cell. - **GREEN** (`f62fb1e0`) — restores the M2 token. Net violations = 0 across all 38 cells. ## Scope discipline - Only `color-contrast` (matches M2/M3/M4 scope). M6 owns `image-alt`, `aria-required-attr`, `label`, mobile viewports, and letsmesh A/B. - No new design tokens. - M2-M4 tokens untouched. ## CI wiring - `.github/workflows/deploy.yml:155` — selftest in JS unit block. - `.github/workflows/deploy.yml:367` — real axe browser run in the Playwright E2E block after the fixture server is up. ## Deps `@axe-core/playwright@4.11.3` + `axe-core@4.12.1` added to `devDependencies`. Pinned versions. --------- Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: clawbot <clawbot@users.noreply.github.com> |
||
|
|
6dfe589b57 |
fix(#1668): per-route polish — hash cells, badges, /live, modals (M4) (#1681)
Partial fix for #1668 (M4 of 6). After M2 (color tokens, PR #1676, ~85% BLOCKER) and M3 (typography floor, PR #1679, ~87% MAJOR), what's left are route-specific structural issues that token/floor passes can't reach. M4 closes those with surgical carve-outs — no new top-level tokens, no semantic encoding flattened. ## Route × selector × fix | Route | Selector | Before | After | |---|---|---|---| | `/analytics?tab=hashsizes` `/analytics?tab=collisions` | `td.hash-cell` + `-collision/-taken/-possible` (302+ M1 violations) | 11px/400; collision-fg 3.61, taken-fg 2.5, possible-fg 1.9 on respective bg | 12px base, 12px/700 on semantic cells. Bg palette preserved (green/yellow/orange still distinct). Inline style in analytics.js bumped 11→12. | | `/packets` `/live` `/nodes` (everywhere `<span class="badge badge-*">`) | All 14 TYPE_COLORS badges (ADVERT, REQUEST, RESPONSE, …) | `${color}20` translucent wash with `color: ${color}` — ratio **1.0–4.25, all BLOCKER** | `syncBadgeColors` rewritten: pick readable fg by luminance, darken bg in 8% steps until AA (≥4.5:1). All 14 PASS (4.57–7.94). TYPE_COLORS itself unchanged — map dots / live-feed dots keep full hue. | | `/live` | `.vcr-live-btn` ("LIVE") | `rgba(239,68,68,0.2)` + status-red fg = **1.0:1** | Solid `--status-red` + #fff = 5.25:1; 12px/700 | | `/live` | `.vcr-scope-btn.active` (1h/6h/12h/24h selected) | `--accent-bg` wash + `--text` = 2.98:1 BLOCKER | `--accent-strong` + `--text-on-accent` (M2 tokens, AA) | | `/live` | `.vcr-btn` `.vcr-scope-btn` | 0.9rem/400, 0.75rem/400 (thin-small) | 14px/500, 12px/500 desktop; 12px/600 ≤640px | | `/live` | `.live-feed-empty` | 12px/400 (thin-small) | 12px/500 | | `/packets` (path hops) | `.path-hops .hop-named` | font-size inherited (variable) | explicit 12px/600 | ## TDD & gating - **RED** `341f47f1` — 23 assertion failures (9 typography + 14 badge-contrast). New gate `test-issue-1668-m4-per-route.js` executes `syncBadgeColors` in a VM sandbox and asserts each emitted `.badge-*` rule clears WCAG AA; also checks rule-level font-size/font-weight floors. - **GREEN** `6ef17491` — both axes 0/0. - Test wired into `.github/workflows/deploy.yml:144` alongside M3. - Anti-tautology proven locally: `git stash public/roles.js` returns the test to FAIL with the badge assertions; pop restores GREEN. ## Re-scan findings `a11y-audit/m4-rescan.jsonl` — `/live` (timed out in M1) now probes cleanly: 29 dark / 39 light residuals all caught by this PR. Channel-add and customize modals probed clean (M2 tokens already cover; nothing chip-level needed). ## Out of scope M5 (axe CI gate) and M6 (letsmesh side-by-side A/B) are next milestones. --------- Co-authored-by: agent <agent@openclaw.local> Co-authored-by: meshcore-bot <bot@meshcore> Co-authored-by: Kpa-clawbot <bot@kpa-clawbot> Co-authored-by: openclaw-bot <bot@openclaw> |
||
|
|
79cf453660 |
feat(#1633): customizer toggle to hide 1-byte path hops everywhere (#1689)
## What Customize-v2 toggle **Hide 1-byte path hops** (Display tab). Default OFF — operators opt in. When ON, 1-byte path-hash prefixes are filtered at every render site without touching what's stored or what the firmware does. Render sites wired: - **Packets list / detail** (`packets.js renderPath`) — group header, child observations, detail dt/dd, BYOP overlay. Empty result renders `(1-byte filtered)`. - **Map polylines** (`map.js drawPacketRoute`) — intermediate hops tagged `_hopHex`; origin/destination (from payload, no `_hopHex`) always survive. - **Route view** (`route-view.js`) — unique-paths picker + group counts key on the filtered hop list, so routes that only differ by 1-byte hops collapse. - **Analytics route patterns** (`analytics.js`) — filters INPUT rows whose `rawHops` contain any 1-byte token; header reports filtered/total. ## Why 1-byte hashes collide ~8-way at ~2k relay nodes (Cascadia scale). The collisions inflate polyline noise, route-pattern row counts, and chip clutter without adding signal. See #1633 for the full hypothesis. ## How (pure render-time) New `public/hop-filter.js`: - `MC_getHide1ByteHops()` / `MC_setHide1ByteHops(on)` — localStorage `meshcore-hide-1byte-hops`, default OFF. - `MC_isVisibleHop(hop, opts)` — predicate. - `MC_filterPathHops(hops, opts)` — non-mutating array filter. Nothing in the ingest / store / decode path changes. The hop hex stays in `path_json`; only the render iterators drop it. ## Tests `test-issue-1633-hide-1byte-hops.js` — 8 assertions: - Default OFF (back-compat). - `hopByteLen` semantics. - `isVisibleHop` ON drops 1-byte, keeps 2/3-byte. - `filterPathHops` non-mutating. - `HopDisplay.renderPath` chip set after filter. - Map polyline positions[] filter preserves origin/destination. - Analytics route-pattern aggregation key collapses on filtered hops. Wired into `.github/workflows/deploy.yml`. Red commit: `6baa3f13` (5/8 ON-branch assertions failed on stubs). Green commit: `5c0bbdba` (8/8 pass). ## Browser verify Staging deploy of changed files. Packet `99ef781f42eb7249` (all 1-byte path): - BEFORE (toggle OFF): `3 HOPS — Station Rat → KO6IFX-R5 → little russia`. - AFTER (toggle ON): `3 HOPS — (1-byte filtered)`. Customizer toggle visible + working in Display tab. Fixes #1633. --------- Co-authored-by: openclaw-bot <bot@openclaw.dev> Co-authored-by: clawbot <bot@openclaw.local> |
||
|
|
e74e860725 |
fix(#1648): final emoji leaks — .obs-clock-naive-chip warning + analytics Channels encrypted group labels (#1665)
## What Two Phosphor lint-gate breaches found by the v3.8.4 manual-test executor — app-controlled UI labels still shipping raw emoji glyphs that the M6 final sweep (#1648) missed. One PR, two sprite swaps, same playbook as #1657. ### Findings | Test ID | Surface | Glyph | File:line | Fix | |---|---|---|---|---| | v384-1.2 | `/observers` `.obs-clock-naive-chip` | `⚠️` (U+26A0) ×14 | `public/observers.js:30` | `ph-warning` sprite | | v384-12.18 | `/analytics?tab=channels` encrypted row name cells | `🔒` (U+1F512) ×158 | `public/analytics.js:978–979` | `ph-lock` sprite | Finding 2 is a different surface from the M3/#1657 fix (which swapped the section-header label, not the per-row `displayName`). The unknown-encrypted row's `displayName` carried a raw `🔒 Encrypted (0xNN)` text label that then flowed through `esc()` into the rendered name cell as an escaped emoji glyph — exactly the same `innerText → innerHTML` class of bug. Refactored to mirror the section-header pattern: `displayNameHtml` carries the sprite-bearing raw HTML; `displayName` stays plain text for sort/aria/tests. ## TDD - **RED** `cde12370` — `test-issue-1648-followup-phosphor-leaks.js` asserts ph-warning sprite + zero ⚠ in chip output, and ph-lock sprite + zero 🔒 in analytics row labels. 6 assertions failed on master. - **GREEN** `f1c64b17` — sprite swaps applied. All 9 assertions pass. - **Anti-tautology proven both directions**: reverting only `public/observers.js` → 2 chip-related assertions fail; reverting only `public/analytics.js` → 4 analytics-related assertions fail. ## Verify - ✅ `node test-issue-1648-followup-phosphor-leaks.js` — 9/9 pass - ✅ `node test-issue-1648-m6-final-sweep.js` — 0 violations - ✅ `node test-observer-naive-clock-1478.js` — 8/8 pass (existing chip test accepts ph-warning sprite) - ✅ `node test-analytics-channels-integration.js` — pre-existing unrelated `Channel Analytics` failure only; encrypted-row assertions all pass with new plain-text `displayName` - ✅ pr-preflight all gates green (PII, branch-scope, red-commit, CSS-var, LIKE-on-JSON, async-migration, XSS sinks) - ✅ Browser-verified on staging: 11 chips render ph-warning sprite (0 emoji), 156 ph-lock sprites in row name cells (0 lock emoji on page) Browser verified: http://analyzer-stg.00id.net/#/observers + /#/analytics?tab=channels (hot-patched) E2E assertion added: `test-issue-1648-followup-phosphor-leaks.js:67` (chip), `test-issue-1648-followup-phosphor-leaks.js:147` (row cell) --------- Co-authored-by: meshcore-bot <bot@meshcore.dev> |
||
|
|
fb6bb085a5 |
fix(analytics): render Channels group-header sprites as HTML, not escaped text (#1657) (#1658)
Fixes #1657
## Bug
On `/analytics` → **Channels** tab, the "Channel Activity" table's
group-header rows ("My Channels", "Network", "Encrypted") rendered
literal HTML source text:
```
<SVG CLASS="PH-ICON" ARIA-HIDDEN="TRUE"><USE HREF="/ICONS/PHOSPHOR-SPRITE.SVG#PH-KEY"/></SVG> My Channels
```
instead of the actual Phosphor sprites. Per-row encrypted/lock icons
rendered fine — the bug was isolated to the group-header render path.
## Root cause
`public/analytics.js` `channelTbodyHtml` builds each group-section
header by wrapping the section label in `esc()`:
```js
esc(sections[si].label) + ' <span class="text-muted">(' + rows.length + ')</span>'
```
But the labels (`sections[].label`) are hardcoded sprite-bearing
strings:
```js
{ key: 'mine', label: '<svg class="ph-icon" aria-hidden="true"><use href="…#ph-key"/></svg> My Channels' },
```
`esc()` HTML-encoded the `<` / `>` so the browser displayed the source
text rather than rendering the sprite. Affects all 3 groups (and any
future group with a sprite).
## Fix
Drop the `esc()` wrap on the hardcoded label (single line change, same
pattern as M3 commit
|
||
|
|
89eade6e7b |
M6: emoji → Phosphor — final sweep, lint gate, carry-forwards (#1648) (#1654)
Red commit:
|
||
|
|
2b6809cd28 |
M4: emoji → Phosphor Icons — map & route overlays (#1648) (#1652)
Draft for milestone 4 of #1648 — emoji → Phosphor Icons (map & route overlays). Currently at the red commit (failing test only). Implementation follows. Partial fix for #1648 (M4 of 6). Do NOT close the tracking issue. --------- Co-authored-by: bot <bot@corescope> |
||
|
|
3062745437 |
M2: emoji → Phosphor Icons — page headers & table chrome (#1648) (#1650)
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> |
||
|
|
3898688d6d |
analytics: Relay Airtime Share endpoint + dumbbell chart (#1359) (#1601)
Implements the locked spec from #1359. Red commit: 68a140a8 — `distinctRelayCount` stub returns 0; test fails on assertion (compiles + runs to assertion, not a build error). Green commit: 48c2ddad — real implementation. ## Backend (in-memory, no SQL, no schema change) - `cmd/server/relay_airtime_share.go` - `distinctRelayCount(tx)` — unions the resolved-pubkey reverse index for `tx.ID`. That index already dedups `(pubkey-hash, txID)` pairs across every observation's `resolved_path`, so its length IS the count of distinct repeaters that forwarded the packet. NOT length of any single observation's resolved_path (the bug-trap from #1358). - `computeRelayAirtimeShare(window)` — per-tx `score = payload_bytes × distinctRelays`, bucketed by `payload_type`, sorted desc by airtime_pct. - `GetRelayAirtimeShareWithWindow` — cached behind existing `rfCache` + `rfCacheTTL` pool. Shallow-copies the cached payload with `cached=true` for the client. - `cmd/server/routes.go` — `GET /api/analytics/relay-airtime-share?window=…` returning `{rows:[{payload_type,type,count,count_pct,score,airtime_pct}], total_count, total_score, window, cached}`. ## Frontend - `public/analytics.js` - `renderRelayAirtimeDumbbell(data)` — horizontal dumbbell chart per payload_type. Gray dot = count %, colored dot = airtime %, connector line between them = the divergence, shared 0-100% axis, sorted desc by airtime. - Tooltip: payload_type, count %, count N, airtime %, raw score, within-mesh caveat. - Title: **Relay Airtime Share**. - Subtitle (exact): `Score = payload bytes × distinct repeaters that forwarded the packet. Counts relay re-transmissions; originator TX excluded. Not comparable across meshes.` - Mounted on the Overview tab immediately beneath Payload Type Mix. ## Tests `TestRelayAirtimeShare_ADVERTvsACKDivergence` — the locked acceptance scenario: - 1 ADVERT (200 B, 8 distinct relays) → score 1600, airtime 100% - 1000 ACKs (10 B, 0 relays each) → score 0, airtime 0% - Count distribution is the inverse (ACK 99.9%, ADVERT 0.1%). - Sort assertion: ADVERT is rows[0] by airtime_pct desc. Full suite: `go test -short ./cmd/server/...` → PASS (25.9s). ## Acceptance criteria - [x] In-memory `airtime_usage_score` accumulator in analytics path - [x] `distinctRelayCount(tx)` helper unioning resolved-pubkey reverse index across all observations of `transmission_id` - [x] `/api/analytics/relay-airtime-share?window=…` endpoint - [x] Cached via existing `rfCache` + `rfCacheTTL`; no new cache layer - [x] Dumbbell chart on `/analytics` beneath Payload Type Mix; gray=count, colored=airtime, shared axis, sorted desc by airtime - [x] Title + subtitle exactly as specified - [x] Tooltip with payload_type, count %, count N, airtime %, raw score, caveat - [x] Unit test demonstrates the ADVERT-vs-ACK divergence - [x] No new SQL, no new index, no schema migration (verified via diff) - [ ] Live staging bench (<5ms p99 uncached / <1ms cached) — deferred to follow-up; cached behind 60s `rfCacheTTL` so steady-state cost is a map lookup ## Preflight overrides - Branch scope cross-stack: justified — backend endpoint and frontend chart are a single deliverable per #1359 spec (one chart bound to one endpoint, no incremental staging). Fixes #1359 --------- Co-authored-by: bot <bot@local> |
||
|
|
d6384c3c59 |
fix(#1217): honor time-window filter on Route Patterns analytics (#1592)
## What The Route Patterns chart on `/#/analytics` ignored the Time window picker — every selection returned identical data. This PR threads `?window=` through to the backing endpoints and the store-level computation. ## Root cause `cmd/server/routes.go:2065` (`handleAnalyticsSubpaths`) and `cmd/server/routes.go:2090` (`handleAnalyticsSubpathsBulk`) never called `ParseTimeWindow(r)`. The store-level entry points (`GetAnalyticsSubpaths`, `GetAnalyticsSubpathsBulk`) had no window-aware variant. The frontend (`public/analytics.js`) didn't append `&window=` to the `/analytics/subpaths-bulk` request. ## Fix ### Backend (`cmd/server/store.go`) Added `GetAnalyticsSubpathsWithWindow` + `GetAnalyticsSubpathsBulkWithWindow`. Zero `TimeWindow` → byte-equivalent to the existing fast path (no perf regression on the default view). Non-zero window → iterate `s.packets`, filter on `tx.FirstSeen` via `TimeWindow.Includes`, reuse `rankSubpaths`. Cached by `(region|area|window)`. ```diff -data := s.store.GetAnalyticsSubpaths(region, minLen, maxLen, limit) +window := ParseTimeWindow(r) +data := s.store.GetAnalyticsSubpathsWithWindow(region, minLen, maxLen, limit, window) ``` ```diff -results := s.store.GetAnalyticsSubpathsBulk(region, groups) +results := s.store.GetAnalyticsSubpathsBulkWithWindow(region, groups, ParseTimeWindow(r)) ``` ### Frontend (`public/analytics.js`) `renderSubpaths` now appends `&window=<value>` to the `/analytics/subpaths-bulk` request, matching how RF / topology / channels tabs already wire the picker. ## Before / after ``` GET /api/analytics/subpaths?window=24h → totalPaths=2 (all data — ignored window) GET /api/analytics/subpaths?window=24h → totalPaths=1 (24h-bounded — honored) ``` ## Tests `cmd/server/subpaths_window_test.go`: - `TestSubpathsHonorsTimeWindow_StoreLevel` — seeds a 1h-old tx with path `[aa,bb]` + a 30d-old tx with path `[cc,dd]`; asserts the unbounded call sees both and the 24h-windowed call sees only the recent one. - `TestSubpathsHandlerHonorsTimeWindow` — same scenario via the HTTP handlers for `/api/analytics/subpaths` and `/api/analytics/subpaths-bulk`. TDD: red commit `eefc27d3` (test fails on assertion with stub that ignores window), green commit `4c4c45d0` (implementation makes it pass). Full `go test ./...` in `cmd/server` green locally (~47s). ## Performance Default view (no window selected) is unchanged — `window.IsZero()` short-circuits to the existing precomputed-index hot path. Windowed view is O(N_tx · path²), same complexity as the existing region-filtered slow path. Results cached per `(region|area|window)`. Closes #1217 --------- Co-authored-by: Kpa-clawbot <bot@corescope> |
||
|
|
3df8924114 |
fix(#1218): include multi-byte prefix repeaters in 1-byte hash usage matrix view (#1591)
## Problem
`/analytics` Hash Usage Matrix 1-byte view excluded repeaters configured
for 2- or 3-byte hash prefixes. In MeshCore, 1-byte path-matching is a
first-byte equality check, so any packet routed by 1-byte hash collides
on that first byte regardless of the downstream repeater's configured
prefix size. Omitting multi-byte prefix repeaters under-reports real
conflicts in the 1-byte hash space.
## Fix
**Data layer — `cmd/server/store.go` (`computeHashCollisions`,
~L7907-L7918 before, L7907-L7941 after):**
Before — `one_byte_cells` was populated only from `prefixMap`, which
only contained repeaters with `hash_size == 1`:
```go
if bytes == 1 {
oneByteCells = make(map[string][]collisionNode)
for i := 0; i < 256; i++ {
hex := strings.ToUpper(fmt.Sprintf("%02x", i))
oneByteCells[hex] = prefixMap[hex]
if oneByteCells[hex] == nil {
oneByteCells[hex] = make([]collisionNode, 0)
}
}
} else if bytes == 2 { ... }
```
After — additionally project all `hash_size in {2,3}` repeaters to their
first byte:
```go
if bytes == 1 {
// ... (same baseline population) ...
for _, cn := range allCNodes {
if cn.Role != "repeater" { continue }
if cn.HashSize != 2 && cn.HashSize != 3 { continue }
if len(cn.PublicKey) < 2 { continue }
hex := strings.ToUpper(cn.PublicKey[:2])
if _, ok := oneByteCells[hex]; !ok { continue }
oneByteCells[hex] = append(oneByteCells[hex], cn)
}
}
```
The 2-byte view's bucketing is unchanged — that view continues to count
only repeaters configured for 2-byte prefixes (those semantics differ).
**UI — `public/analytics.js` L1459:** clarified the 1-byte view
description so the inclusion of multi-byte prefix repeaters is explicit.
## API shape
No response-shape change. `one_byte_cells[HEX]` is still
`[]collisionNode`; only the contents now include 2/3-byte prefix
repeaters in the appropriate first-byte buckets. The existing frontend
decoder is unaffected.
## Tests
-
`cmd/server/routes_test.go::TestHashCollisionsOneByteIncludesMultiBytePrefixRepeaters`
— seeds three repeaters with first byte `CC` configured for 1/2/3-byte
prefixes plus an unrelated `DD` repeater, asserts all three appear in
`one_byte_cells["CC"]`, and that the 2-byte view's `nodes_for_byte` is
unchanged.
Red commit `278bdf8d` (test only) fails on assertion ("got 1, want 3");
green commit `9127ea4e` passes.
## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ clean.
Closes #1218
---------
Co-authored-by: clawbot <bot@corescope>
|
||
|
|
53339e08b2 |
fix(security): close 3 stored XSS sinks missed by #1537 (traces, observer-detail, analytics tooltip) (#1539)
🚧 Draft — red commit only. Tests added are expected to FAIL; fix lands in next commit. Follow-up to #1537 — security sweep found 3 additional stored XSS sinks of the same class. Once the green commit lands and CI is green, this body will be replaced. --------- Co-authored-by: CoreScope Bot <bot@meshcore> Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local> Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
f15b677981 |
fix(security): escape mesh node names before HTML render — stored XSS (#1536) (#1537)
## This PR fixes the stored XSS in full (closes #1536) Mesh-advertised node names (`adv_name`) and observer names were rendered into the dashboard DOM **without HTML-escaping** in multiple places — the same class as the publicly disclosed MeshCore dashboard XSS (CVE-2026-45323). `adv_name` has no protocol-level validation and the Go `sanitizeName()` keeps `< > " &`, so a payload like `<img src=x onerror=...>` reaches the frontend intact and executes. **I audited every name/sender/text/channel render in `public/` and this PR escapes all unescaped sinks. There are no known remaining XSS sinks of this class after this change.** ### Sinks fixed (all escaped via the existing global `escapeHtml`, plus a local helper for the standalone `area-map.html`) | File | Sink | |------|------| | `app.js` | global search dropdown — node name + channel name | | `nodes.js` | nodes-table row name; node-detail Leaflet popups (×2) | | `observers.js` | observers-table name cell | | `packets.js` | observer-name cells via `obsNameOnly` (×4) + observer multi-select checkbox label | | `live.js` | node-filter `<option>` + map marker tooltip | | `analytics.js` | topology map node tooltip | | `route-view.js` | hop + union marker tooltips (×2) | | `area-map.html` | node popups (×2) — added a local `escapeHtml` (file is standalone) | ### Already-safe (verified, not changed) `map.js` popups (`safeEsc`), live-feed text (`escapeHtml(preview)`), packet-detail text, channel messages (`channels.js`), `route-render.js` popups, `hop-display.js`. ### Why escape at the sink (not the backend) `sanitizeName()` only strips control chars; HTML-escaping stored names server-side would be lossy and corrupt legitimate names containing `& < >`, and break the `meshcore://` deep-links / exports. Output-encoding at render is the correct OWASP fix and matches `meshcore-card` v0.3.3. ### Tests - Added 6 `escapeHtml` regression tests including the CVE payload `<img src=x onerror=alert(1)>` and an attribute-breakout payload. - `node test-frontend-helpers.js`: **568 passed / 32 failed** — the 32 are pre-existing sandbox limitations (e.g. `AreaFilter is not defined`), identical to the untouched baseline (562/32). Zero new failures. ### Cache busting Automatic — the server rewrites `__BUST__` in `index.html` with a restart timestamp, so no manual bump is needed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: CoreScope Bot <bot@meshcore> Co-authored-by: Kpa-clawbot <bot@clawbot.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> |
||
|
|
bfebf200b7 |
fix(#1375): scope-stats fetch path — drop duplicate /api prefix (Scopes tab JSON.parse fix) (#1379)
## What Drop the leading `/api` from the Scopes-tab `scope-stats` fetch in `public/analytics.js`. The `api()` helper already prefixes `/api`; passing `/api/scope-stats` produced a runtime URL of `/api/api/scope-stats`, which 404s, falls through to the SPA HTML, and crashes the Scopes tab with `JSON.parse: unexpected character`. Single-line behavior change. ## Why `api()` (defined earlier in the same file) prepends `/api`. Every other caller in `public/analytics.js` correctly passes a helper-relative path (`/observers`, `/nodes`, …). The Scopes loader was the lone offender. The same fix originally landed on the PR #915 branch (commit `2fd22cee`) but that branch never merged, so the bug resurfaced on subsequent rebases. The Scopes tab is therefore broken on production today — open `/analytics` → Scopes and the panel never renders. ## TDD - Red commit `b1fbc5601a985f20eb0ffee9181b7df5333248ca` adds `test-issue-1375-scope-stats-fetch.js`, which reads `public/analytics.js` and asserts: - ZERO matches of literal `api('/api/scope-stats'` (regression guard). - Exactly one match of `api('/scope-stats'` (positive — fix present). - Green commit edits the loader to drop the duplicate `/api`. - Test wired into `.github/workflows/deploy.yml` next to the existing `test-issue-*` entries. ## Manual verification After deploy, open `https://analyzer.00id.net/analytics`, click **Scopes**: panel renders cards instead of throwing a JSON parse error in DevTools console. Fixes #1375 --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
317b59ab10 |
feat: area-based visual node filter — attribute packets by transmitter GPS (#804) (#839)
## Summary - Adds configurable GPS polygon areas to `config.json`; nodes are attributed to an area if their last-known position falls inside the polygon - New `Area: …` dropdown filter (matching the existing region filter style) appears on all analytics, nodes, packets, map, and live screens when areas are configured - Backend resolves area membership with a 30s TTL cache; area filter bypasses the 500-node cap on `/api/bulk-health` so all area nodes are always returned - Includes a polygon builder tool (`/area-map.html`) for drawing and exporting area boundaries ## Changes **Backend** - `AreaEntry` type + `Areas` config field - `GetNodePubkeysInArea` DB query + `resolveAreaNodes` (30s TTL, `areaNodeMu` RWMutex) - `PacketQuery.Area` + `filterPackets` polygon check - `?area=` param propagated through all analytics, topology, clock-health, and bulk-health routes - `/api/config/areas` endpoint **Frontend** - `area-filter.js`: single-select dropdown, persists to localStorage, cleans up stale keys on load - Wired into analytics, nodes, packets, channels, map, and live pages - Live map clears node markers on area change **Docs & tools** - `docs/user-guide/area-filter.md` — configuration and usage guide - `docs/api-spec.md` — updated with new endpoint and `?area=` param table - `tools/area-map.html` — polygon builder for defining area boundaries - Demo areas added to `config.example.json` ## Test plan - [x] No areas configured → filter dropdown does not appear on any page - [x] Areas configured → dropdown appears, "All" selected by default - [x] Selecting an area filters nodes/packets/topology/map correctly - [x] Selecting "All" restores unfiltered view - [x] Selection persists across page reloads (localStorage) - [x] Stale localStorage key (area removed from config) is cleared on load - [x] `/api/bulk-health?area=X` returns all nodes in area (no 500-node cap) - [x] `/api/config/areas` returns correct list 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com> Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
2329639f45 |
feat: scoped/unscoped transport-route statistics (#899) (#915)
@ ## What this PR does Implements region-scoped transport-route packet tracking with two sub-features: ### Feature 1 — Scope statistics (`scope_name`) - At ingest, transport-route packets (route_type 0/3) with Code1 != `0000` are HMAC-matched against configured `hashRegions` keys (mirroring the `hashChannels` pattern). Matched region name (or `""` for unknown) stored in new `transmissions.scope_name` column via migration `scope_name_v1`. - New `GET /api/scope-stats?window=` endpoint (1h/24h/7d, 30s server-side TTL) returning transport totals, scoped/unscoped counts, per-region breakdown, and time-series. - New **Scopes** tab in Analytics with summary cards, per-region table, and two-line SVG chart. Auto-refreshes every 60s. ### Feature 2 — Node default scope (`default_scope`) - Per-node `default_scope` column on `nodes`/`inactive_nodes` (migration `nodes_default_scope_v1`) tracks the most recently matched region for each node, derived from transport-scoped ADVERT packets. - `GET /api/nodes` response includes `default_scope` field when column is present. - Node detail panel displays the default scope badge. - Async startup backfill (`BackfillDefaultScopeAsync`) populates the column for nodes with pre-existing ADVERT data. ### Config Add `hashRegions` to `config.json` (see `config.example.json`). One entry per region name (with or without leading `#`). @ --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com> Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
11dd180219 |
fix(#1306): disambiguate 'collisions' terminology + surface WHICH collides (#1307)
## #1306 — Disambiguate "collisions" terminology + surface WHICH collides (WIP draft) Red commit pending CI URL. ### What **A. Terminology fix** — Prefix Tool currently labels theoretical-math collisions ("38 two-byte collisions") with the same word the Collisions tab uses for packet-traffic-observed collisions ("0 two-byte"). Operators saw contradictory counts and assumed a bug. - Prefix Tool Network Overview cards: replace bare "collisions" with "address conflicts at this hash size" / "would-collide-if-used" wording. - Cross-reference line: "These are theoretical conflicts that would occur IF all repeaters used this hash size. For collisions actually observed in packet traffic, see the Hash Issues tab." → links to `#/analytics?tab=collisions`. - Collisions tab: reverse pointer "Collisions observed in actual packet traffic. For theoretical conflicts at each hash size, see the Prefix Tool tab." → links to `#/analytics?tab=prefix-tool`. **B. Expandable "which collides" list** — Aggregate count "38 colliding 2-byte slices" is unactionable. Operators need to see which slice and which nodes share it. - Per tier, when `opCollisions[b] > 0` OR `stats[b].collidingPrefixes > 0`, render a "Show N colliding slices →" toggle below the count. - Expanding reveals a `Prefix · Nodes sharing` table with node-detail links (`#/nodes/<pubkey>`), scrollable above 50 entries. - Both flavors rendered: theoretical (across all repeaters) and operational (configured-for-this-size only). The operational list is the higher-priority signal. Data is already in `idx[b]` — no backend changes. ### E2E `test-issue-1306-collisions-terminology-e2e.js` asserts wording, cross-ref links, expand-toggle, and node links present. RED commit only ships the test; GREEN commit adds the production code. Fixes #1306 --------- Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local> |
||
|
|
e078f4bbb6 |
fix(filters): unified 34px height for all filter controls across pages (#1192)
## Summary
The global `select { min-height: 48px }` touch-target rule was taller
than the 34px custom dropdown buttons (region filter, multi-select
dropdowns), causing visible height inconsistency on the packets,
analytics, and nodes pages.
- **`.filter-bar input/select`** — add `min-height: 34px` to match
existing `height: 34px` (packets page: time window, channel, sort
selects and text inputs)
- **`.nodes-filters select`** — add `height: 34px; min-height: 34px`
(nodes page: last-heard select)
- **Analytics page** — replace `.time-window-filter` + label with
`.analytics-filters` flex row; style `#analyticsTimeWindow` with
`.analytics-time-window-select` to match region dropdown button height
and appearance
- All filter controls now sit at a consistent 34px, matching the
existing custom dropdown buttons
Supersedes #1191 (which only fixed the analytics case).
## Test plan
- [x] Packets page: time window, channel, sort selects are same height
as Filters/Group by Hash/My Nodes buttons
- [x] Analytics page: region filter and time-window select sit side by
side at the same height
- [x] Nodes page: last-heard select is same height as All/Active/Stale
buttons
- [x] On mobile, filter controls wrap correctly (flex-wrap)
- [x] Dark theme: select background and border match surrounding
controls
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
46ce9590f1 |
fix(#1270): Prefix Tool Network Overview shows configured-hash-size counts, not math-only slices (#1271)
Red commit: `6b68080c24106301b6bfc25f8a05484f07d0612d` (test added that fails on master). CI: see Checks tab on this PR. Fixes #1270. ## Problem Two analytics surfaces told contradictory stories about prefix usage: - **Prefix Tool → Network Overview** showed e.g. `168 / 65,536` for the 2-byte tier — a pure math fact: every repeater pubkey sliced to 2 bytes yields N distinct values. Because collisions are rare, this number always equals (or nearly equals) the repeater count, making it look like the whole network uses 2-byte hashing. - **Hash Stats → By Repeaters** showed configured-hash-size counts straight from `/api/analytics/hash-sizes` `distributionByRepeaters` — usually a minority on 2-byte and near-zero on 3-byte. The Prefix Tool was presenting a math fact as if it were operational truth. ## Fix `renderPrefixTool` now also fetches `/api/analytics/hash-sizes` and restructures each tier card into three labeled stats with explicit hierarchy: 1. **Primary** — `X of Y repeaters configured` (from `distributionByRepeaters`). Same source the Hash Stats tab uses, so the two pages agree exactly. 2. **Operational collisions** — colliding slices among repeaters configured for *this* hash size only (matches Hash Issues semantics). 3. **Theoretical** (secondary, smaller, dashed-rule footnote) — `X unique N-byte slices across all repeater pubkeys (of Y possible)`. The math fact is preserved as educational info, no longer impersonating operational truth. The "Total repeaters" card now also notes how many have a known configured hash size. The "About these numbers" footer was rewritten to explain the three numbers and link to both Hash Stats and Hash Issues. The prefix collision detector (Check / Generate panels) is unchanged — it still scans every repeater pubkey because that is its job. ## Test Added `#1270 Prefix Tool primary counts match Hash Stats By Repeaters` to `test-e2e-playwright.js`. It fetches `/api/analytics/hash-sizes` for the ground-truth `distributionByRepeaters`, then visits `#/analytics?tab=prefix-tool`, opens Network Overview, and scrapes the primary count via a new `data-pt-configured="<bytes>"` `data-value="<count>"` marker on each tier card, asserting exact equality for 1/2/3-byte. - Red commit `6b68080c` (test only): fails on master with `NO data-pt-configured marker`. - Green commit `12ed2789` (fix): test passes; full E2E suite `123/126 passed, 3 skipped`. ## Acceptance - [x] Prefix Tool Network Overview shows configured-hash-size repeater counts as the primary number - [x] "Unique slices" math is shown as secondary/educational - [x] Two pages tell the same story (E2E asserts byte-equal match) - [x] E2E asserts the configured-count matches what Hash-Sizes tab shows at the same point in time |
||
|
|
36ee71d17e |
feat(#1085): fold Roles page into Analytics tab (#1088)
Red commit:
|
||
|
|
8b924cd217 |
feat(ui): encode view & filter state in URL hash (#749) (#1072)
## Summary Encodes view + filter state in the URL hash so deep links restore the exact page state (issue #749). ## Changes New shared helper `public/url-state.js` exposing `URLState`: - `parseSort('col:asc')` → `{column, direction}` (defaults to `desc`) - `serializeSort('col', 'desc')` → `'col'` (omits default direction) - `parseHash('#/nodes/abc?tab=x')` → `{route: 'nodes/abc', params: {tab:'x'}}` - `buildHash(route, params)` and `updateHashParams(updates, currentHash)` for round-tripping while preserving subpaths. Wired into: - **packets.js** — sort column/direction now in `#/packets?sort=col[:asc]`, restored on init (overrides localStorage). Subpath `#/packets/<hash>` preserved. - **nodes.js** — sort encoded as `#/nodes?sort=col[:asc]`, restored on init. Subpath `#/nodes/<pubkey>` preserved. - **analytics.js** — both selected tab (`tab=topology`) AND time-window picker value (`window=7d`) now round-trip via URL. Subview keys used by rf-health (`range/observer/from/to`) cleared when switching tabs to keep URLs clean. Existing deep links (`#/nodes/<pubkey>`, `#/packets/<hash>`, `?filter=…`, `?node=…`, `?observer=…`, `?channel=…`, `?timeWindow=…`, `?region=…`) all keep working — additive change only. ## Tests TDD red→green: - Red: `5e1482e` (stub throws "not implemented"; 18/18 tests fail on assertions) - Green: `512940e` (helper implemented; 18/18 pass) Wired `test-url-state.js` into `test-all.sh`. Fixes #749 --------- Co-authored-by: clawbot <clawbot@users.noreply.github.com> |
||
|
|
f9cd43f06f |
fix(analytics): integrate channels list with PSK decrypt UX + add link from Channels page (#1042)
## What Integrates the Analytics → Channels section with the PSK decrypt UX (PRs #1021–#1040). Replaces nonsense `chNNN` placeholders with useful display names and groups the table the same way the Channels sidebar does. ## Before - Encrypted channels showed raw `ch185`, `ch64`, `ch?` placeholders. - Locally-decrypted PSK channels (with stored keys + labels) were not surfaced — every encrypted row looked identical and useless. - Single flat list, sorted by last activity by default. ## After - **My Channels** 🔑 — any analytics row whose hash byte matches a stored PSK key (via `ChannelDecrypt.getStoredKeys()` + `computeChannelHash`). Display name uses the user's label if set, otherwise the key name. - **Network** 📻 — known cleartext channels (server-provided names) and rainbow-table-decoded encrypted channels. - **Encrypted** 🔒 — unknown encrypted, rendered as `🔒 Encrypted (0xNN)` instead of `chNNN`. - Within each group: messages descending (most active first). - New `📊 Channel Analytics →` link in the Channels page sidebar header → `#/analytics`. ## How - Pure `decorateAnalyticsChannels(channels, hashByteToKeyName, labels)` — testable in isolation, sets `displayName` + `group` per row. - `buildHashKeyMap()` — async helper that resolves stored PSK keys to their channel hash bytes via `computeChannelHash`. Used at render time; first paint uses an empty map (best-effort) and re-renders once keys resolve. Graceful fallback when `ChannelDecrypt` is missing or there are no stored keys. - `channelTbodyHtml` gains an `opts.grouped` flag — opt-in so the existing flat sort still works for any other caller. - The analytics API endpoint is **unchanged** — this is purely frontend rendering. ## Tests `test-analytics-channels-integration.js` — 19 assertions covering decoration, grouping, sort order, and the channels-page link. Added to `test-all.sh`. Red commit: `5081b12` (12 assertion failures + stub). Green commit: `6be16d9` (all 19 pass). --------- Co-authored-by: bot <bot@corescope.local> Co-authored-by: meshcore-bot <bot@meshcore.local> |
||
|
|
a56ee5c4fe |
feat(analytics): selectable timeframes via ?window/?from/?to (#842) (#1018)
## Summary Selectable analytics timeframes (#842). Adds backend support for `?window=1h|24h|7d|30d` and `?from=&to=` on the three main analytics endpoints (`/api/analytics/rf`, `/api/analytics/topology`, `/api/analytics/channels`), and a time-window picker in the Analytics page UI that drives them. Default behavior with no query params is unchanged. ## TDD trail - Red: `bbab04d` — adds `TimeWindow` + `ParseTimeWindow` stub and tests; tests fail on assertions because the stub returns the zero window. - Green: `75d27f9` — implements `ParseTimeWindow`, threads `TimeWindow` through `compute*` loops + caches, wires HTTP handlers, adds frontend picker + E2E. ## Backend changes - `cmd/server/time_window.go` — full `ParseTimeWindow` (`?window=` aliases + `?from=/&to=` RFC3339 absolute range; invalid input → zero window for backwards compatibility). - `cmd/server/store.go` — new `GetAnalytics{RF,Topology,Channels}WithWindow` wrappers; `compute*` loops skip transmissions whose `FirstSeen` (or per-obs `Timestamp` for the region+observer slice) falls outside the window. Cache key composes `region|window` so different windows do not poison each other. - `cmd/server/routes.go` — handlers call `ParseTimeWindow(r)` and dispatch to the `*WithWindow` methods. ## Frontend changes - `public/analytics.js` — new `<select id="analyticsTimeWindow">` rendered under the region filter (All / 1h / 24h / 7d / 30d). Selecting an option triggers `loadAnalytics()` which appends `&window=…` to every analytics fetch. ## Tests - `cmd/server/time_window_test.go` — covers all aliases, absolute range, no-params backwards compatibility, `Includes()` bounds, and `CacheKey()` distinctness. - `cmd/server/topology_dedup_test.go`, `cmd/server/channel_analytics_test.go` — updated callers to pass `TimeWindow{}`. ## E2E (rule 18) `test-e2e-playwright.js:592-611` — opens `/#/analytics`, asserts the picker is rendered with a `24h` option, then asserts that selecting `24h` triggers a network request to `/api/analytics/rf?…window=24h`. ## Backwards compatibility No params → zero `TimeWindow` → original code paths (no filter, region-only cache key). Verified by `TestParseTimeWindow_NoParams_BackwardsCompatible` and by the existing analytics tests still passing unchanged on `_wt-fix-842`. Fixes #842 --------- Co-authored-by: you <you@example.com> Co-authored-by: corescope-bot <bot@corescope> |
||
|
|
736b09697d |
fix(analytics): apply customizer timestamp format to chart axes (closes #756) (#981)
## Summary Fixes #756 — the customizer timestamp format setting (ISO/ISO+ms/locale) and timezone (UTC/local) were not applied to chart X-axis labels, tooltips, or certain inline timestamps in the analytics pages. ## Changes ### `public/app.js` - Added `formatChartAxisLabel(date, shortForm)` — a shared helper that reads the customizer's `timestampFormat` and `timestampTimezone` preferences and formats dates for chart axes accordingly. `shortForm=true` returns time-only (for intra-day charts), `shortForm=false` returns date+time (for multi-day ranges). ### `public/analytics.js` - `rfXAxisLabels()`: now calls `formatChartAxisLabel()` instead of hardcoded `toLocaleTimeString()` - `rfTooltipCircles()`: tooltip timestamps now use `formatAbsoluteTimestamp()` instead of raw ISO - Subpath detail first/last seen: now uses `formatAbsoluteTimestamp()` - Neighbor graph last_seen: now uses `formatAbsoluteTimestamp()` ### `public/node-analytics.js` - Packet timeline chart labels: now use `formatChartAxisLabel()` (respects short vs long form based on time range) - SNR over time chart labels: now use `formatChartAxisLabel()` ## Behavior by setting | Setting | Chart axis (short) | Chart axis (long) | |---------|-------------------|-------------------| | ISO | `14:30` | `05-03 14:30` | | ISO+ms | `14:30:05` | `05-03 14:30:05` | | Locale | `2:30 PM` | `May 3, 2:30 PM` | All respect the UTC/local timezone toggle. ## Testing - Server builds cleanly (`go build`) - Served `app.js` contains `formatChartAxisLabel` (verified via curl) - Graceful fallback: all callsites check `typeof formatChartAxisLabel === 'function'` before calling, preserving backward compat if script load order changes --------- Co-authored-by: you <you@example.com> |
||
|
|
ea78581eea |
fix(#858): packets/hour chart — bars rendering + x-axis label decimation (#865)
Two bugs in the Overview tab Packets/Hour chart: 1. **Bars not rendering**: `barW` went negative when `data.length` was large (e.g. 720 hours for 30-day range), producing zero-width invisible bars. Fix: `Math.max(1, ...)` floor on bar width. 2. **X-axis labels overlapping**: Every single hour label was emitted (`02h03h04h...`). Fix: decimate labels based on time range — every 6h for ≤24h, every 12h for ≤72h, every 24h beyond. Shows `MM-DD` on midnight boundaries for multi-day ranges. **Scope**: Only touches the Overview tab `Packets / Hour` section and the shared `barChart` floor (one-line change). No modifications to Topology, Channels, Distance, or other tabs. Fixes #858 Co-authored-by: you <you@example.com> |
||
|
|
b5372d6f73 |
fix(#859): remove opacity gradient from Per-Observer Reachability rows (#863)
Fixes #859 ## What The "Per-Observer Reachability" and "Best Path to Each Node" sections in the Topology tab had inline `opacity` styles on each `.reach-ring` row that decreased with hop count (`1 - hops * 0.06`, floored at 0.3). This made text progressively darker/unreadable toward the bottom. ## Fix Removed the inline `opacity:${opacity}` style from both `renderPerObserverReach()` and `renderBestPath()`. The rows now render at full opacity with text colors governed by CSS variables as intended. ## Changed - `public/analytics.js`: removed opacity computation and inline style in two functions (4 lines removed, 2 added) ## Scope Only touches Per-Observer Reachability and Best Path rendering. No changes to Overview, Channels, or shared helpers. Co-authored-by: you <you@example.com> |