S2 — renderAnimations() previously allocated two object literals per anim
per frame. At 50 concurrent anims × 60fps that is ~6000 garbage objects
per second. Hoist to two module-scoped scratch points reused each frame.
S3 — destroy() previously cleared activeAnimations without firing pending
onComplete callbacks. Audio onHop hooks depend on those firing; navigating
away mid-flight would silently swallow them. Iterate the queue and call
onComplete (with try/catch so a misbehaving handler can't block teardown)
BEFORE truncating the array.
Refs #1514
createFadingLeafletLine() shipped with stale 'Uncomment if you created
the custom pane' comments around the pane: 'animationsPane' option, so
post-flight fades rendered on overlayPane (z=400) — UNDER node markers
(z=600). The moving phase already renders on the canvas inside
animationsPane (z=625), so fading polylines now consistently stack
with the trail they came from, above markers but below tooltips/popups.
No marker-overlap concern because fades are short-lived (capped at 5
recent paths, fully transparent within ~520ms).
Refs #1514
The previous DPR change handler removed and re-added itself inside a
try/finally block. Two problems:
1. A throw inside updateAnimCanvas() would still trigger the re-bind, but
leave the canvas in a half-resized state.
2. The remove → matchMedia() → add sequence is not atomic; a synchronous
DPR change between remove and add would be silently dropped.
Switch to addEventListener with {once: true}: the runtime drops the
listener atomically before our handler runs, so re-binding is race-free
and a thrown updateAnimCanvas() doesn't double-bind.
Refs #1514
## What was broken
The nav-pin button state was not persisted across page loads. Every
refresh reset the nav to unpinned regardless of what the user had set,
forcing them to re-pin on every visit.
## What was added
- On init: reads `localStorage.getItem('live-nav-pinned')` and restores
the pinned state into `_navCleanup.pinned` before the button is created;
if pinned, the button gets the `pinned` class, `aria-pressed="true"`,
and `nav-autohide` is removed from the nav.
- On click: after toggling, writes
`localStorage.setItem('live-nav-pinned', _navCleanup.pinned)` inside a
`try/catch` (quota guard, consistent with other live.js localStorage
writes).
localStorage key: `live-nav-pinned`
Closes#1510
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Problem
`GetStoreStats` ran a `SUM(CASE WHEN timestamp > ?)` over the full
`observations` table on **every** `/api/stats` call. The staging pprof
analysis (#1460) identified this as rank #9 CPU consumer:
`GetStoreStats.func2` at 920ms cumulative = ~10% of all server CPU.
The query:
```sql
SELECT
COALESCE(SUM(CASE WHEN timestamp > ? THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN timestamp > ? THEN 1 ELSE 0 END), 0)
FROM observations WHERE timestamp > ?
```
scans ~1.9M rows each time `/api/stats` is polled (every 15s from the
dashboard).
## Fix
Add a **30-second TTL cache** on `PacketStore` for `PacketsLastHour` and
`PacketsLast24h`:
- Cache hit → skip the observations goroutine entirely, use stored
values
- Cache miss → run the query, update cache with result
- The node/observer `COUNT(*)` query is unchanged and always runs fresh
The hour/24h counts are display-only values; 30s accuracy is sufficient.
## Changes
`cmd/server/store.go`:
- 4 new fields on `PacketStore`: `statsCacheMu sync.Mutex`,
`statsCacheTime time.Time`, `statsLastHour int`, `statsLast24h int`
- `GetStoreStats`: check cache before launching goroutines; conditional
`wg.Add`; update cache after successful query
Builds clean. No tests changed.
Closes#1460 (P1#1 from staging CPU profile).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of forcing Leaflet to recalculate and paint heavy SVG DOM nodes
60 times a second for every moving packet, we will draw the flying dots
and lines directly onto a hardware-accelerated HTML5 <canvas> overlaid
on the map. Once the animation finishes, it will drop a static Leaflet
line to handle the fading tail effect.
---------
Co-authored-by: KpaBap <kpabap@gmail.com>
## Summary
Fixes#1498. Roots out the actual WS-vs-REST race that has made
`test-channels-ws-batch-e2e.js` flaky on master for ~2 weeks.
## Root cause
`selectChannel()` and `refreshMessages()` unconditionally replace the
in-memory `messages` array with the REST response. Any WebSocket-pushed
messages appended between `selectedHash` assignment (when the chat view
opens) and the REST resolution were silently stomped. The flaky test
was a real-world manifestation: when the synthetic `processWSBatch`
injection happened to land BEFORE the in-flight
`/channels/<hash>/messages` fetch resolved, the (effectively empty)
fixture REST response wiped it out. This is a production bug too —
real users would lose any live message that arrived during channel
load.
## Why the three prior PRs missed it
- **#1499** — added a 500ms `waitForTimeout` before injection. Often
enough to let the REST fetch resolve first, but not under any added
load.
- **#1502** — skipped the test instead of diagnosing.
- **#1511** — re-enabled with a "wait by hash, not index" predicate.
That fixed the symptom of `messages[length-1]` being some unrelated
packet, but did nothing for the underlying race where the WS-pushed
message gets wiped entirely by the REST replacement.
None of the three PRs reproduced the failure locally. The hypothesis
"closure over stale messages" in the test comment was never
substantiated.
## Fix
Stamp WS-pushed messages with `_fromWS=true` and add a
`mergeWsAppendedIntoRest()` helper that preserves WS-pushed messages
whose `packetHash` isn't already present in the REST response. Applied
to all three REST replacement sites:
- `selectChannel()` REST path
- `decryptAndRender()` (encrypted channel path)
- `refreshMessages()` (background poll)
## Tests
Added `test-channels-ws-race-1498-e2e.js`. Deterministically forces
the race by stubbing `fetch` to delay the
`/channels/<hash>/messages` response 800ms, injects a WS message
during the delay, asserts it survives the late REST resolution.
- Red commit (`9dfc4b08`): test added against unfixed master HEAD →
fails with `WS message stomped by REST fetch — messages after fetch:
{"present":false,"count":0,"hashes":[]}`.
- Green commit (`8f336591`): applies the fix → passes.
Verified the red commit actually fails when the production change is
reverted (TDD discipline check).
## Local repro stats
Used the instrumented frontend (`public-instrumented/`) which exposes
the race more reliably than the raw `public/` build (slower JS load
widens the WS-vs-REST window).
- Before fix: 29/30 pass (1 reproduced "injected message not found"
failure — identical to CI). The new race test: 0/50 pass.
- After fix: original `test-channels-ws-batch-e2e.js` — **50/50 pass**.
New `test-channels-ws-race-1498-e2e.js` — **50/50 pass**.
## CI
Wired the new race test into `.github/workflows/deploy.yml` right
after the existing `test-channels-ws-batch-e2e.js` invocation.
## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all gates pass (PII, branch scope, red commit, CSS vars,
LIKE-on-JSON, sync migration, all warnings).
Browser verified: the fix was validated end-to-end against the local
fixture server (`http://localhost:13581`) using the headless Chromium
the CI uses.
E2E assertion added: `test-channels-ws-race-1498-e2e.js` (deterministic
race regression).
---------
Co-authored-by: bot <bot@local>
Co-authored-by: corescope-bot <bot@corescope.local>
## What
Issue #1396 reported that at viewport ~1024px on `/#/channels`, the
entire inline nav strip was visually empty (no high-priority links, no
active pill, nothing) and the More dropdown showed only "Tools".
## Root cause
Identical to issue #1400 (closed): `min-height: 48px` on `.nav-link`
globally inflated the strip beyond the 52px `top-nav` height. Firefox
flex-centered the over-tall item to a negative y (≈-57px), clipping it
above the viewport behind `overflow:hidden`. **Already fixed by PR
#1401** (removed global `min-height`). Issue #1396 stayed open because:
1. `/#/channels` was never added to the Priority+ E2E test loop
2. The y-position assertion was never added despite being in #1400's
acceptance criteria
3. The exact More-dropdown-contents contract was never locked for
`/#/channels`
## Changes
Extends `test-nav-priority-1391-e2e.js`:
- **`#/channels` added to `NON_HIGH_ROUTES`** — tested at all 6 viewport
widths (1024, 1080, 1100, 1101, 1200, 1300px)
- **Assertion (4)** — `.nav-links top > -1`: directly catches the
strip-clipped-above-viewport bug; the original failure had `y ≈ -57`,
this assertion would have caught it immediately
- **Assertion (5)** — at ≤1100px (force-collapse band), More must
contain EXACTLY the 5 non-active non-high routes; channels stays inline
as the active pill
## Test results
```
30/30 passed (was 24/24; 6 new channels combinations all ✅)
strip top=2.5 at all desktop widths (positive, not clipped)
```
## Notes
- Supersedes draft PR #1397 (Kpa-clawbot RED-only test; never completed)
- No code changes — the underlying CSS fix is already in master via PR
#1401Closes#1396🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#1108
## What
When an operator selects a region on the Live map, default to **hiding**
nodes outside that region. The operator picked the region for a reason —
far-away markers are visual noise. Operators who want the legacy
show-everything behavior can flip the new **Show all nodes** checkbox
next to the region dropdown.
Default: **off (hide non-region nodes)**. State persists in
`localStorage['mc-region-show-all-nodes']`.
## Why
Tracks the request in #1108 — region filtering currently scopes packet
feeds + metrics but the map keeps every node visible, which defeats the
point of selecting a region in the first place.
## How
- `public/region-filter.js`: new `RegionShowAll` module (`get` / `set` /
`onChange` / `STORAGE_KEY`) plus `RegionFilter.nodesRegionQueryString()`
— returns `®ion=…` only when a region is selected **and** showAll is
off. Other surfaces (packets, metrics) continue to use the unconditional
`regionQueryString()`.
- `public/live.js`: `loadNodes()` appends `nodesRegionQueryString()`;
region-change and showAll-change handlers reload nodes so markers update
immediately.
- `public/live.css`: aligns the new toggle with the existing
`.live-toggles` rhythm.
- `test-1108-region-hide-nodes.js`: 7 unit assertions covering
default-off, persistence across reloads, set/get, and the conditional
query-string builder.
## TDD trail
- `dbf6d6db` — red test commit (assertion failures, helpers do not exist
yet)
- `eefa1185` — green commit (helpers + wiring)
## CDP validation (staging, after hot-deploy)
| state | markers |
| --- | --- |
| no region | 517 |
| region=SJC, showAll=off | **497** (region-scoped) |
| region=SJC, showAll=on | 517 (legacy behavior) |
Toggle state survives reload (`RegionShowAll.get() === true` after
refresh).
## Out of scope
- Static `/map` page (`public/map.js`) — its region UI is jump-buttons,
not the shared `RegionFilter` selector. A follow-up could wire
`nodesRegionQueryString` there too, but it's a separate UX surface.
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
Closes#1369.
## What
Cross-domain embed support, shipped as two halves:
### Part A — CORS env override + read-only contract
* `applyCORSEnv()` reads `CORS_ALLOWED_ORIGINS` (comma-separated,
trimmed, empties dropped). Set in env → overrides
`cfg.CORSAllowedOrigins`. Unset/empty → config.json value wins.
* `Access-Control-Allow-Methods` tightened from `GET, POST, OPTIONS` →
`GET, HEAD, OPTIONS`. The cross-domain surface is read-only by contract;
same-origin admin writes don't go through preflight and are unaffected.
* `config.example.json` adds `corsAllowedOrigins: []` + a comment
explaining the env override and the embed URL pattern.
* No wildcards introduced (still supported as `["*"]` for ops that opt
in). No credentialed CORS.
### Part B — `?embed=1` chrome suppression
* `shouldEmbedRoute(basePage, hashSearch)` — pure helper, allowlisted to
`map` and `channels`, requires `embed=1` in the hash querystring.
* `navigate()` toggles `body.embed` based on the helper.
* CSS hides `.top-nav`, `[data-bottom-nav]`, `.nav-drawer`,
`.nav-drawer-backdrop`, zeroes body padding/margin, reclaims `100dvh`
for `#app.app-fixed`.
Use: `<iframe src="https://analyzer.example/#/map?embed=1">`. For
iframe-only display, no CORS entry is needed (the iframe loads the
document, not a JSON API). The CORS allowlist only matters when the
embedding origin's own JS calls `/api/*` directly.
## Tests
| File | Asserts | Status |
|---|---|---|
| `cmd/server/cors_embed_1369_test.go` | 4 (env override, env-empty,
env-trim, GET/HEAD contract, preflight POST rejected) | green |
| `test-embed-mode-1369.js` | 9 (helper allowlist + param parsing) |
green |
| `cmd/server/cors_test.go` | existing | updated to read-only method-set
assertion |
TDD: 2 red commits (one per part, both compile, both fail on assertions)
→ 2 green commits.
## Out of scope (per the issue's narrow ask)
* Other SPA routes do not honor `?embed=1` (their chrome makes layout
assumptions; defer until requested).
* No iframe sandboxing recommendation — that's the embedder's
responsibility.
* No CSP / `X-Frame-Options` change in this PR — frames are already
permitted; add an explicit `frame-ancestors` policy in a follow-up if
operators want to whitelist embedders at the HTTP layer too.
## Security notes (DJB lens)
* Allowlist is exact-match, case-sensitive string compare — no
normalization, no scheme/host parsing, no surprises.
* No `Access-Control-Allow-Credentials` (would let third parties read
auth'd state via cookies).
* No reflection of arbitrary origins (every echoed origin came from the
allowlist).
* Methods narrowed to read-only; even a misconfigured allowlist can't
grant cross-origin writes through this middleware.
🤖 Generated with OpenClaw
---------
Co-authored-by: bot <bot@corescope.local>
## Summary
The channels-ws-batch E2E tests had a race condition causing flaky
failures on CI, blocking PRs #1490, #1500, #1501.
**Root cause:** Tests waited on `messages.length === prev + 1`, but live
WS traffic from the ingestor could bump `length` independently, causing
timeouts. The earlier #1499 fix attempted to find messages by
`m.hash`/`m.id`, but `processWSBatch` stores `packetHash`/`packetId` on
message objects — so the find never matched.
**Fix:** Replace all length-based waiters with `messages.some(m =>
m.packetHash === '<known-hash>')` which is deterministic regardless of
concurrent WS traffic. Also un-skips the explicit-sender test that was
force-skipped in #1502.
## Tests affected
- "processWSBatch with explicit sender appends to messages" —
un-skipped, now passes
- "GRP_TXT shape with 'Sender: text' parses sender from text" —
race-proof
- "dedup by packetHash" — race-proof
- "new WS message while scrolled up" — race-proof
All 6 tests pass locally (6 passed, 0 failed).
Fixes#1498.
---------
Co-authored-by: mc-bot <bot@meshcore.local>
# fix(1506): restore marker-stroke server defaults to v3.7.2 visual
Closes#1506. Refs #1494, #1488.
## Why
PR #1494 introduced operator-tunable marker stroke via
`--mc-marker-stroke-*` CSS vars but chose new server defaults
(translucent white, 1px) that look weak next to the v3.7.2 baseline
(solid white, 2px). Operators upgrading from v3.7.x see a visible
regression on the map.
## What
Restore the v3.7.2 visual as the server default. Customizer + config
plumbing are unchanged — anyone who preferred the thinner translucent
style can dial it back via the in-app customizer (Colors → Marker
Stroke).
| File | Before | After |
|---|---|---|
| `public/style.css` `:root` | `rgba(255,255,255,0.85)` / `1` / `1` |
`#fff` / `2` / `1` |
| `public/customize-v2.js` `msWidth` fallback | `1` | `2` |
| `config.example.json` `markerStroke.color/width` | `rgba(...,0.85)` /
`1` | `#fff` / `2` |
Customizer overrides already in localStorage continue to take effect —
only the unset baseline shifts.
## TDD
- Red commit (`cdabb905`): adds gate F to
`test-issue-1488-marker-stroke-vars.js` asserting style.css /
customize-v2.js / config.example.json defaults match v3.7.2 (solid
white, 2px). Fails on master with 5 assertion errors.
- Green commit (`abfa9b6b`): three small data edits flip all five
assertions to pass.
## Acceptance
- After upgrade, markers visually match v3.7.2 stroke (solid white, 2px)
by default ✅
- Customizer slider still functional ✅
- Existing custom values in localStorage still take effect (no reset) ✅
---------
Co-authored-by: mc-bot <bot@meshcore.local>
## Summary
The version/commit badge currently rendered in the nav stats bar
(alongside packet counts, node counts, and observer counts) is
operator-facing diagnostic information — not something end users need
visible on every page load. For most visitors, it adds visual noise
without adding value.
## Changes
- **perf.js**: Add a **Version** card to the Perf dashboard overview
row. Shows `version` + short `commit` hash, both already available from
`/api/health` (no new API surface needed). Card renders conditionally —
if neither field is set it stays hidden.
- **app.js**: Remove `formatVersionBadge()` and `formatEngineBadge()`
helper functions (now unused); strip the badge call from
`updateNavStats()` so the navbar shows only packet/node/observer counts.
- **style.css**: Remove now-dead `.nav-stats .version-badge`,
`.nav-stats .engine-badge`, and their link sub-rules.
## Rationale
The Perf page is explicitly the right place for this information — it's
already scoped to operators and developers who want to know what version
is running. The navbar is a high-visibility surface shared by all users;
version strings belong in a diagnostic context, not a navigation bar.
Net result: navbar is cleaner for end users; operators can still find
version info immediately on the Perf tab.