mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-16 06:02:37 +00:00
34b41fd5b6df5b685e69482d9b74ff00528641db
2848
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
34b41fd5b6 |
Add topographic map layers (#1891)
This adds two optional map tile providers: - OpenTopoMap - USGS They are disabled by default, but can be quite useful for visualizing repeater sites with terrain features visible. |
||
|
|
1720060284 |
fix(#1827): avoid per-observation SQL fetch in handleObserverAnalytics hot loop (#1829)
## Summary Fixes the CPU/DoS issue in #1827: observer detail pages were saturating CPU on busy observers — 6-7 concurrently loaded tabs pegged 12 cores for seconds, and auto-refresh made it self-sustaining. ## Root cause `handleObserverAnalytics` iterated every observation in the requested window and called `enrichObs()` per observation just to read `payload_type` and `decoded_json` for the `packetTypes`/`nodesTimeline` aggregates. `enrichObs()` also runs an on-demand SQL `SELECT resolved_path FROM observations WHERE id=?` (`fetchResolvedPathForObs`) and builds a full response map — both of which are unused by this aggregation loop. `resolved_path` is only actually consumed by the `<=20` kept `recentPackets` entries. Per the triage in #1827 (@carmack): *"Replacing `enrichObs(obs)` with a direct `s.store.byTxID[obs.TransmissionID].PayloadType` read (as sketched in the body) drops a map alloc + interface boxes per obs on the loop that saturated the operator's 12 cores. Byte-identical output. That's ~90% of the value."* This PR implements exactly that fast-path. ## Change - Aggregate loop (`packetTypes`, `nodesTimeline`): read `payload_type`/`decoded_json` directly off the transmission via `s.store.byTxID[obs.TransmissionID]` — no SQL, no per-obs map allocation. - `recentPackets` (`<=20` entries): unchanged, still calls `enrichObs()` since it needs `resolved_path`/`raw_hex`/etc. for display. - Output is unchanged: `packetTypes`/`nodesTimeline` are computed from the exact same underlying fields (`tx.PayloadType`, `tx.DecodedJSON`), just without the O(N) SQL round-trips. ## Scope This is the concrete hot-path fix from #1827's triage — not the broader `/api/observers/{id}/analytics` endpoint-split proposal in #1828, which (per that issue's discussion) is a separate P3 follow-up. #1828's own triage converged on this same `byTxID` fast-path as "the ground-work minimum" before any endpoint splitting. ## Testing - Existing `TestObserverAnalytics` passes unchanged. - Extended `TestObserverAnalytics/default` to assert `packetTypes` counts come out correct (`{"4":2,"5":1}` for the seeded fixture) via the new `byTxID` path, and that `recentPackets` still carries `resolved_path` where present (confirming the `enrichObs()` path for those 20 entries is untouched). - `go build ./...` and `go vet ./...` clean in `cmd/server`. - Full `go test ./...` in `cmd/server`: passes except 4 pre-existing test-order-dependent failures in `TestHandleNodePaths_*` (unrelated to this change — reproduced identically on a fresh, unpatched clone of `upstream/master`). --------- Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
1441734991 |
fix(#1901): detectSchema fails loud instead of caching wrong schema mode (#1909)
Fixes #1901. Thanks to @MarekWo for the exceptionally thorough report — root cause, repro, and a prioritised fix checklist in one. This implements it. ## Problem `detectSchema()` swallowed any probe-query error with a bare `return`, so a single transient failure of the first `PRAGMA table_info(observations)` at startup left `isV3` (and the feature flags) at their zero value **for the entire process lifetime**. The server then ran v2 SQL against a v3 DB: Packets page empty, `/api/channels/<name>/messages` → 500, logs full of `no such column: o.observer_id`, while the database was perfectly healthy. Nothing re-checked the flag, so only a manual restart recovered it. ## Fix Works through the report's checklist: - **Don't swallow the error.** `detectSchema` now returns `error` and `OpenDB` aborts on it. `main.go` already `log.Fatalf`s on an `OpenDB` failure, so the supervisord/Docker restart policy retries and a transient cause clears on the next attempt — strictly better than serving a broken read API. - **Log the mode unconditionally** — `[db] schema mode: v3 (observer_idx)` / `v2 (observer_id)`. A clean startup log is now positive evidence detection ran, not just an absence of errors. - **Run detection on a single pinned connection** (`conn.Conn(ctx)`) rather than an arbitrary pooled one, so the startup race in the report's hypothesis can't quietly hand detection a fresh, not-yet-openable handle — if the connection can't be acquired, we fail loud. - **`Close()` no longer checkpoints the read-only handle.** `PRAGMA wal_checkpoint(TRUNCATE)` on a `mode=ro` connection always failed with `disk I/O error (778)` and looked like a storage fault on every shutdown (the report's aside). The ingestor (the writer) owns WAL checkpointing. The three near-identical PRAGMA scan loops are consolidated into one `schemaColumns()` helper that returns errors instead of ignoring `Scan` failures. ### On the "single source of truth" item The report suggests deriving `isV3` from `dbschema.TableHasColumn(...)`. I kept the PRAGMA-scan structure here because `detectSchema` sets six flags from three tables in a single pass; swapping to `TableHasColumn` would mean six separate probe calls and wouldn't actually be cleaner. The goal it was aimed at — never cache a false negative — is met by making the existing scan fail loud. Happy to switch to the single-probe-per-column shape if you'd prefer it. ### Honest note on the connection `conn.Conn(ctx)` pins *a* single connection for all four probes and fails loud if it can't be acquired; it does not guarantee the literal connection `Ping()` validated (`database/sql` doesn't expose that). The fail-fast is what actually closes the bug — a mis-detected schema aborts startup instead of persisting for the process lifetime. ## Tests - `TestDetectSchemaFailsLoudOnProbeError` — injects a probe failure through a `rowQuerier` and asserts the error propagates and `isV3` stays unset (the invariant the old bare-`return` violated). - `TestDetectSchemaV3AndV2` — covers both schema shapes through `OpenDB`. `go vet ./cmd/server` and `go build` are clean; targeted `go test -run 'DetectSchema|OpenDB'` is green. Heads-up on the full `go test ./cmd/server` run: a handful of `TestHandleNodePaths_*` / `TestHandleAnalytics*` tests return `503 index loading`, plus one intentional panic test — these fail identically on pristine `master` (`a06ac8ac`) with this branch stashed, i.e. they're pre-existing/timing-related and untouched by this change. Out of scope (per the issue): frontend behaviour when the API 500s. 🤖 Authored with [Claude](https://claude.com) · Co-Authored-By trailer on the commit. Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
c5a71b34ec |
fix(#1890): drop the hardcoded og:url so shared links stay on the instance (#1893)
Fixes #1890. ## The problem `public/index.html:16` shipped this to every deployment: ```html <meta property="og:url" content="https://analyzer.00id.net"> ``` Open Graph consumers — Facebook and Messenger among them — treat `og:url` as the canonical destination. Clicking the preview of a link shared from *any* CoreScope instance navigated to that one host. The direct link text still resolved correctly, which is why this went unnoticed; the preview card and the surrounding message body did not. It is the only occurrence in the frontend. ## The change Remove the tag. `og:url` is optional — with no tag present, consumers fall back to the URL they crawled, which is correct for every deployment and needs no configuration. ## Why not the config-driven variant The issue also proposes deriving the URL from `config.json`. I did not take that shape, on purpose: `index.html` is pre-processed **once at startup** — `spaHandler` reads it and substitutes `__BUST__` (`cmd/server/main.go:565`), then serves the same byte slice for every request. A correct per-host `og:url` therefore needs either a new public-URL config key or per-request templating of the index. Both are decisions about config surface and request-path cost that belong to you, and neither is needed to stop the redirect. Happy to follow up with whichever shape you prefer — this PR is the part that is unambiguous. ## What is left alone `og:image` still points at `raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png`. That is the project's own asset, a shared project resource rather than a redirect target, so it is correct for every instance to reference it. ## Test `test-issue-1890-og-url.js`, a static scan, registered in `test-all.sh`: - no `og:url` meta tag - no `rel="canonical"` link - no `00id.net` reference anywhere in `index.html` - `og:title` / `og:description` / `og:image` still present That last assertion is deliberate: without it the guard could be satisfied by deleting the whole embed block. Watched fail first — 2 passed, 2 failed before the change, 4 passed after. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
647841c990 |
fix: stop watchdog force-reconnect from racing paho's own retry loop (#1897)
Relates to #1335, which was already closed by PR #1336 shipping the naive `client.Disconnect(250); client.Connect()` force-reconnect. That fix has its own bug: liveness.IsConnectedFn (paho's IsConnected()) reports true for the entire time paho is actively retrying, not just when genuinely connected, so the watchdog's stall check cannot tell a half-open TCP socket (the original #1335 case) from a broker that paho is already correctly reconnecting to. Unconditionally calling Disconnect(250) then Connect() on that second, transitional case races paho's status machine and permanently kills its retry loop, requiring another watchdog trigger to recover, sometimes compounding into 100+ minute outages. This is a different failure mode from #1749/PR #1853: that bug is a blocking log.Print() write freezing the entire watchdog loop before ForceReconnectFn is ever called. This bug only manifests once ForceReconnectFn does fire, so the two fixes are independent and touch disjoint files. buildForceReconnectFn now gates Disconnect() on IsConnectionOpen() (true only when status is strictly connected) so it only tears down a genuinely open connection, and logs Connect()'s error token instead of discarding it. |
||
|
|
0d6f59ab2d |
fix(#1864): decode ANON_REQ source pubkey instead of treating it like REQUEST (#1866)
Fixes #1864. ## Problem `PAYLOAD_TYPE_ANON_REQ` was effectively treated like `REQUEST`. The two differ on the wire: ``` REQUEST : <dest hash 1B> <source hash 1B> <hmac 2B> <encrypted> ANON_REQ: <dest hash 1B> <source pubkey 32B, full> <hmac 2B> <encrypted> ``` The decoders read the right bytes but surfaced the sender key as `ephemeralPubKey`, which meant: - `store.go`'s node indexer keys on `pubKey`/`destPubKey`/`srcPubKey`, so ANON_REQ packets were **not** indexed — they didn't show up on a node's packet view; and - the packets list "details" rendered a bare `anon → <destHash>`, throwing away the sender identity the packet actually carries. - the detail side-view byte breakdown fell through the REQ catch-all, mislabelling a nonexistent 1-byte "Src Hash" and placing MAC/Encrypted-Data at the wrong offsets (`+2`/`+4` instead of `+33`/`+35`). ## Fix **Backend** (`cmd/ingestor` + `cmd/server` decoders) - Surface the ANON_REQ sender key as `srcPubKey` (json) so it's indexed and resolvable. The frontend keeps a legacy `ephemeralPubKey` reader so packets decoded before this rename still resolve — no DB migration needed. - `TestDecodeAnonReqValid` now asserts the full 32-byte `srcPubKey`. **Frontend** - `hop-resolver.js`: new O(1) `nameForKey(pubkey)` using the existing `pubkeyIdx` (all nodes). - `getDetailPreview`: resolve the source pubkey to a node **name** when known, else show the first 8 hex chars — no more bare `anon`. - Detail side-view: explicit ANON_REQ breakdown — `Dest Hash (1B)` | `Src Public Key (32B)` (node-linked) | `MAC @+33` | `Encrypted Data @+35`. - Detail header `srcLabel` falls back to the resolved ANON_REQ sender. All rendered names are `escapeHtml`-wrapped. ## Testing - `go test ./...` green for both `cmd/ingestor` and `cmd/server` (incl. strengthened `TestDecodeAnonReqValid`). - `node --check` on `packets.js`; brace/paren balance + markers verified on `hop-resolver.js`. - No HTML sink lines added → XSS preflight gate unaffected; every interpolated name is escaped. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
a3454e7508 |
fix(#1858): replace emoji map icon with Phosphor sprite in rx-coverage (#1860)
## Summary `public/rx-coverage.js` still carried a literal `🗺️` (U+1F5FA) in the Mobile RX coverage page header — missed by the #1648 emoji → Phosphor migration. Replaced with `ph-map-trifold` from the existing sprite, matching how every other page header renders (`analytics.js`, `home.js`, `node-analytics.js`, `customize-v2.js`). ```diff -'<h2 style="margin:4px 0 2px;font-size:18px">🗺️ Mobile RX coverage</h2>' + +'<h2 style="margin:4px 0 2px;font-size:18px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg> Mobile RX coverage</h2>' + ``` ## How it got through This is not a gap in the tooling — `test-issue-1648-m6-final-sweep.js` catches it correctly. On current `master` (`a06ac8ac`): ``` ✗ 1 emoji-as-icon violation(s): public/rx-coverage.js:29 [U+1F5FA] '<h2 style="margin:4px 0 2px;font-size:18px">🗺️ Mobile RX coverage</h2>' + ``` The gate is in `test-all.sh` but not in the CI test list in `deploy.yml`, so it never runs. That divergence is filed separately as #1858 — this PR is the concrete defect it let through. ## Test plan - [x] `node test-issue-1648-m6-final-sweep.js` — `✓ lint gate: 0 violations across public/** and cmd/**` (was 1 violation before) - [x] `node test-issue-1648-m6-lint-self.js` — green, including the anti-tautology probe (it requires a clean repo to run at all, so it was failing on master purely as a cascade from the above) - [x] `eslint public/rx-coverage.js` — 0 errors (1 pre-existing `no-unused-vars` warning on `selectedName`, untouched) - [x] `ph-map-trifold` confirmed present in `public/icons/phosphor-sprite.svg` Single-line change, no behaviour change beyond the icon glyph. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
52d08214bb |
fix(#1749): decouple watchdog emit from blocking I/O (root cause) (#1853)
Closes the gap left by #1810: that PR added defer/recover around the watchdog per-source work so a **panic** inside emit cannot kill the loop, but the actual production incident is caused by emit **blocking**, not panicking. ## Root cause In production `emit` is `log.Print`. `log.Print`'s underlying `write()` can block indefinitely if the sink is backpressured (Docker JSON-file log driver falling behind under load, a full stderr pipe, journald hiccups, etc.). A blocked syscall is not a panic -- `recover()` does nothing for it. Because emit was called **synchronously** inside the per-source work, a single stuck `write()` froze the entire tick loop forever -- no further source was ever checked and no further tick was ever processed again. This exactly reproduces the original #1749 incident even after #1810 landed: 3 independent MQTT sources going silent within ~60s of each other (one shared dependency -- the watchdog goroutine itself -- died, not 3 independent paho clients), zero WATCHDOG log lines for the rest of the 75-minute window, every other goroutine in the process continuing to run fine (a hang, not a crash), and only a full container restart recovering it. ## Fix `newAsyncEmit` decouples "decide to log" from "perform the write": the watchdog loop now only ever does a non-blocking channel send. A single background goroutine drains the channel and performs the (potentially blocking) write. If that goroutine itself gets stuck, the bounded queue (256) fills and further sends are dropped -- counted via the new `WatchdogLogDropCount`, surfaced through `/api/mqtt/status` and the ingestor stats snapshot alongside `WatchdogLastTickUnix` / `WatchdogPanicCount`. Worst case under a persistent backpressure event is now lost log lines (visible and counted), not a silently dead watchdog (invisible and undetectable -- the actual #1749 failure). ## Tests - `TestNewAsyncEmit_NeverBlocksWhenWriterStuck_1749` -- floods emit() past queue capacity while the writer is permanently blocked; every call must return immediately and drops must be counted. - `TestMQTTStallWatchdog_LoopSurvivesStuckWriter_1749` -- end-to-end, wires `runLivenessWatchdogLoop` exactly as production does (via `newAsyncEmit` around a permanently-blocking `realEmit`) with 3 registered sources, reproducing the incident shape and asserting the loop keeps ticking regardless. - `TestRunLivenessWatchdog_ProductionWiringUsesAsyncEmit_1749` -- smoke-tests the real entrypoint starts, ticks, and stops cleanly. - `WatchdogLogDropCount` round-trip tests in both the ingestor stats snapshot and the server's `/api/mqtt/status` handler, mirroring the existing `WatchdogPanicCount` coverage from #1810. All pre-existing watchdog/liveness tests (#1749, #1810 r1, force-reconnect) continue to pass unmodified; full ingestor suite green (verified 5x consecutive runs for flake-freedom). Note: the server package has pre-existing test-suite-wide flakiness in unrelated `TestHandleNodePaths_*` tests (confirmed reproducible on unmodified master too, non-deterministic which subset fails per run) -- unrelated to this change and out of scope here. --------- Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
4fc42d30a7 |
fix(frontend): relay-aware staleness for infra nodes + dim-not-delete (#1598, PR A) (#1815)
Implements **PR A** from the #1598 triage fix path (r6 — all three operator decisions locked: confidence ≥0.75 [PR B], **dim-not-delete**, **in-place `getNodeStatus` signature extension**). ## Changes **`public/roles.js` — `getNodeStatus()`** - Now accepts a full node object (preferred); the legacy `(role, lastSeenMs)` signature keeps working unchanged. - For infra roles (repeater/room), freshness = `max(advert-based timestamp, last_relayed)`. Freshness precedence mirrors existing call sites: `_liveSeen` > `_lastHeard` > `last_heard` > `last_seen`. - `last_relayed` is only consulted for infra — companions keep pure advert/heard-based staleness (per @liquidraver's collision caveat; the ≥0.75-confidence `_liveSeen` refresh is PR B). **`public/live.js` — `pruneStaleNodes()`** - Repeater/room markers are **dimmed, never deleted**, regardless of `_fromAPI` origin. WS-only non-infra nodes are still removed to prevent unbounded memory growth. **Call sites** — all seven (`nodes.js` ×3, `map.js` ×3, `live.js` ×1) now pass the node object. The Nodes-page status explanation shows "Last relayed …" when relay participation is the fresher signal, so an Active badge next to an old "last heard" isn't confusing. **Tests** — 16 new `getNodeStatus` unit tests incl. the triage's backbone-repeater fixture (`last_seen`=25h, `last_relayed`=5min → `active`); `pruneStaleNodes` tests updated for dim-not-delete plus a new relay-aware prune test. ## Test results `node test-frontend-helpers.js`: **641 passed, 2 failed** — the 2 failures (favStar ★ assertions) are pre-existing on current master (verified on a clean upstream clone). ## Validation offer live.saarmesh.de currently has **160 infra nodes past `infraSilentMs`=72h while actively relaying** (incl. KatS disaster-relief repeaters) — a ready-made test population. Happy to run this branch there and report before/after node visibility. Refs #1598 (PR A of two; PR B = `_liveSeen` refresh on `resolved_path` ≥0.75 confidence). --------- Co-authored-by: Mathias Kasper <fallisaar@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: SaarMesh-Bot <bot@saarmesh.de> |
||
|
|
6528c7ba3e |
fix(#1854): move relay last_seen touch to the ingestor — server writes have been no-ops since mode=ro (#1855)
Fixes #1854. Refs #1598, #1611, #1845.
## The bug
`cmd/server/db.go:54` opens SQLite `mode=ro` (#1283/#1289).
`touchRelayLastSeen` → `TouchNodeLastSeen` issues `UPDATE nodes SET
last_seen` on that handle. It has failed on every call since, with the
error discarded at the call site:
```go
if err := s.db.TouchNodeLastSeen(pk, ts); err == nil {
s.lastSeenTouched[pk] = now
}
```
`nodes.last_seen` has therefore tracked ADVERT arrivals only. Verified
on live.saarmesh.de (1388 nodes): 1362 have `last_seen` within one
minute of their own most recent ADVERT. Reproduced directly with the
server's DSN in #1854.
Secondary effect: `lastSeenTouched` is populated only in the success
branch, so the debounce never engaged — the server retried the failing
UPDATE for every resolved pubkey in every decode window.
## The fix
The writer moves to `cmd/ingestor`, which owns `nodes` per #1283/#1287
and since #1547 already resolves hop prefixes to full pubkeys for
`observations.resolved_path`. The touch hooks into that existing
resolution point, so there is no new IPC surface and no second resolver.
Only unambiguously resolved hops qualify — a 1-byte prefix collision
cannot keep a silent node alive.
I considered the `internal/mbcapqueue` snapshot handoff used for
#903/#1324 and did not need it: that pattern exists because the
capability computation lives in the server's analytics cycle. Path
resolution already happens in the ingestor, so a file handoff would add
a hop for nothing.
`Store.TouchRelayNodes`:
- monotonic guard in SQL (`last_seen IS NULL OR last_seen < ?`) —
out-of-order ingest never rewinds
- 5-minute debounce keyed on `rxTime`, matching the interval the server
intended
- UPDATE only — unknown pubkeys never create rows
- unparsable `rxTime` is a no-op rather than writing garbage into the
node directory
- `Stats.RelayTouches` for `/api/perf` visibility
- debounce records the *attempt*, not the row match, so an unknown
pubkey is not retried per observation
## Server-side removal
`touchRelayLastSeen`, `DB.TouchNodeLastSeen`, the `lastSeenTouched` map
and the now-unused `allResolvedPKs` decode-window map are deleted.
`readonly_invariant_test.go` gains `UPDATE\s+nodes\s+SET\s+last_seen`.
`cmd/server/touch_last_seen_test.go` and two tests in
`resolved_index_test.go` go with it. Worth stating why they were green
for months: they build their `PacketStore` on `setupTestDB`, which opens
read-write. The production constraint is the one thing they did not
reproduce, which is why the added invariant regex — not a replacement
unit test — is the right guard here.
## Tests
Five tests in `cmd/ingestor/relay_touch_test.go`, committed red first
(
|
||
|
|
176bb53335 |
fix(#1784): ship pathTrust default 1, not 2 (#1929)
Follows #1841. Moves the pathTrust default from 2 back to 1. ## Why #1784's first acceptance criterion is **"Default behaviour remains backward-compatible"**, and its example config shows `minHashBytesForMapping: 1`. What shipped is 2. The problem is not the value. It is that **there is no way to undo it from the UI.** #1841 adds no control for the threshold: it is `config.json` only, and changing it needs a restart. The customizer gains a hint that says exactly that. So an instance that upgrades without touching config switches to the stricter rule, and the only visible symptom is that the neighbour graph and the resolved paths quietly get smaller. The existing "Hide 1-byte path hops" toggle (#1633) is a *display* filter and does not change what counts as evidence, so it is not an escape hatch either. The two are easy to confuse. ## How much this actually moves Measured on a live instance via `/api/analytics/hash-sizes`, not estimated: | path-hop observations | count | share | |---|---|---| | 1-byte prefix | 116,923 | **56.0%** | | 2-byte prefix | 86,031 | 41.2% | | 3-byte prefix | 5,753 | 2.8% | | repeaters by observed hash size | count | |---|---| | 1-byte | **645 (41%)** | | 2-byte | 865 | | 3-byte | 63 | At threshold 2 the 1-byte column stops counting as mapping evidence. `MeetsPathTrust` also drops the legacy bucket-0 observations with it (pre-#1638 persisted neighbor edges that carry no per-mode breakdown), so already-stored edges lose their evidence status on upgrade too. ## What this does not change The knob works and is untouched. Operators who want the stricter behaviour set `minHashBytesForMapping` to 2 or 3, which is the opt-in #1784 describes. Only the default moves. Nothing about storage changes; packets and paths were never affected either way. ## Also fixes an inconsistency inside #1841 Five frontend consumers already fall back to **1** when `MC_getPathTrustThreshold()` is unavailable: `analytics.js`, `live.js`, `map.js`, `nodes.js`, `route-view.js`. Two fell back to **2**: `hop-filter.js` (the getter itself) and `customize-v2.js`. They now all agree. ## Tests - `internal/packetpath`: `TestMeetsPathTrust_ZeroValueOptIn` was asserting the old default *through behaviour*, so it would need rewriting on any future default change. It now asserts the property instead: an absent JSON field resolves to `DefaultMinHashBytesForMapping`, behaves identically to naming that value outright, and an explicit stricter setting still wins. Package tests pass. - `test-issue-1633-hide-1byte-hops.js`: the case pinning the getter's default is updated, with the reasoning in a comment so the next person sees why it is 1. **37 passed, 0 failed** (master baseline: 37 passed, 0 failed). - `cmd/server` config tests pass. ## One thing I want to flag rather than paper over The test I changed was named `default is 2 (operator-confirmed)`. I am overriding something that was confirmed with an operator, and I am not claiming that confirmation was wrong. My reading is that it was about the threshold being a *useful* value, which it is, rather than about it being the default in a build with no UI to change it. If the intent really was "2 out of the box for everyone", say so and I will close this. @Bjorkan as the issue author, @nullrouten0 and @Saarlandpower since you have touched adjacent code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Wzwr3eXseyNM7Xj598djjE Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b3a306b81f |
fix(#1888): count only live observers in the store's /api/stats query (#1892)
Fixes #1888. ## The mismatch `/api/stats.totalObservers` and `/api/observers` counted different sets: | Source | Predicate | |---|---| | `cmd/server/store.go:2089` (store path) | `SELECT COUNT(*) FROM observers` — every row | | `cmd/server/db.go:336` (DB fallback) | `WHERE inactive IS NULL OR inactive = 0` | | `db.GetObservers()` → `/api/observers` | `WHERE inactive IS NULL OR inactive = 0` | `handleStats` uses the store path whenever a `PacketStore` exists (`routes.go:785`), which is every normal deployment. So the header count came from the unfiltered query while the Observers page listed the filtered set. The two stats implementations also disagreed with each other for the same database, which is a bug on its own. ## Reproduction The gap is exactly the observers the `observerDays` retention sweep has soft-deleted. On the instance I reproduced against: ``` GET /api/stats → totalObservers: 79 GET /api/observers → observers.length == 51 ``` ```sql SELECT 'all', COUNT(*) FROM observers -- 79 UNION ALL SELECT 'active', COUNT(*) FROM observers WHERE inactive IS NULL OR inactive = 0 -- 51 UNION ALL SELECT 'inactive', COUNT(*) FROM observers WHERE inactive = 1; -- 28 ``` 79 − 28 = 51. Same shape as the 82 vs 51 in the issue. ## The change One line: the store's stats query gets the same predicate the other two already use, so all three agree. ## Deliberately out of scope Two things the issue raises that this does **not** fix, called out so they are not mistaken for done: - **Config blacklist.** `buildObserversDefaultResponse` drops blacklisted observers in the handler loop (`routes.go:2752`), which no SQL count can see. A deployment with a non-empty `observerBlacklist` will still show a stats count higher than the list, by the number of blacklisted-but-live observers. Closing that needs config plumbing into the count and is a separate change — happy to follow up if wanted. - **Map controls.** The third surface named in the issue derives its count from node role aggregates (`roleCounts`), not from the observer set at all. That is a frontend concern and untouched here. ## Tests `cmd/server/observer_count_1888_test.go`, three cases, each watched fail first: 1. `TestStoreStatsTotalObserversExcludesSoftDeleted` — `TotalObservers = 5, want 4` 2. `TestStoreStatsTotalObserversMatchesObserverList` — `stats totalObservers = 5 but /api/observers lists 4` 3. `TestStoreAndDBStatsAgreeOnTotalObservers` — `store path reports 5 observers, DB fallback reports 4` The fixture includes a row with `inactive = NULL` alongside `inactive = 0` and `inactive = 1`, since `GetObservers` treats NULL as live and only the `1` may be excluded. `cd cmd/server && go test ./...` → ok (87s). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
02c2338d64 |
fix(packets): observer filter hides multi-observer rows in grouped mode (#1748) (#1821)
Closes #1748. ## Root cause `renderTableRows()` in `public/packets.js` re-filtered the already server-filtered `/api/packets` (grouped) results client-side, comparing `filters.observer` against each row's `observer_id`. But that field is only the **representative** observer chosen for display — `QueryGroupedPackets` (`cmd/server/db.go:554-606`) picks the observation with the longest observed path via a `LEFT JOIN ... ORDER BY length(path_json) DESC LIMIT 1`, purely for display purposes. The server-side filter (`buildTransmissionWhere`, `cmd/server/db.go:725-790`) is already correct: it uses an `EXISTS` subquery over **all** observations of a transmission, so any row it returns was genuinely seen by at least one selected observer. The client then discarded rows *again*, checking only the representative's `observer_id`, with a fallback to `p._children` — which is `undefined` on initial page load (only fetched lazily on row-expand or when the observer-sort dropdown changes, see the `obsSortSel` change handler). Net effect: a multi-observer transmission stayed visible under an observer filter only when the filtered observer happened to also be the representative (longest-path) observer — which matches the reported "works only for whichever observer logged it first" behavior in dense meshes, where longest-path and earliest-seen correlate. ## Fix Skip the client-side observer re-filter entirely when `groupByHash` is active — the server's `EXISTS` filter is authoritative for grouped rows and needs no client-side correction. The flat/expanded-mode path (single-observation rows, each with its own exact `observer_id` from `buildPacketWhere`) keeps the existing children-aware filter unchanged, which already had test coverage under #537 for the case where `_children` is already populated. ## Tests Added 6 cases in `test-frontend-helpers.js` covering the specific gap #537's tests didn't reach — grouped mode with `_children` still `undefined` (the actual initial-load state that triggers this bug). New tests confirm: - A multi-observer row whose representative doesn't match the filter is kept in grouped mode (the core bug). - Grouped mode never re-filters client-side (trusts the server). - Flat mode behavior is unchanged (matches by own `observer_id`, falls back to already-loaded `_children`). Full run: `test-frontend-helpers.js` 631 passed / 2 failed (same 2 pre-existing `favStar` failures reproduce identically on unmodified `master` — unrelated). `test-packet-filter.js` 92/92. `test-aging.js` 18/18. Operator context: running CoreScope for SaarMesh (SaarLorLux, DE/FR/LU, 800+ nodes, 14 observers) — this was hiding a large share of traffic whenever filtering by a non-primary observer in our dense mesh. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
0352c9a287 |
fix(clock-skew): restrict per-node skew to self-originated adverts (#1816, #1818) (#1820)
Closes #1816. Closes #1818 (confirmed duplicate of #1816 by the triage bot). ## Root cause `byNode` is an involvement index (`indexResolvedPathHops`, `store.go:1696-1705`, #1558/#1352): a transmission is indexed under every relay-hop pubkey found in an observation's `resolved_path`, not just its originator. `getNodeClockSkewLocked` (`clock_skew.go:489`) iterated every ADVERT transaction under a pubkey without checking who actually signed it, so a relay inherited the clock skew of every broken-clock node it forwarded as if it were its own. This produced: - Fleet-wide false `no_clock`/`bimodal_clock` classifications on healthy relays whose only "bad" samples were adverts they merely relayed. - Bit-identical `RecentMedianSkewSec` "clusters" across unrelated relays that all forwarded the same broken-clock originator. - Single relays showing a multi-day skew even though their own self-adverts are healthy, because 1-2 relayed adverts from a broken originator landed in the tail of their small recent-window sample (the #1818 "island" repro from @cwichura). ## Fix Add `txOriginatedBy(tx, pubkey)`: ADVERTs are self-signed, so `decoded["pubKey"]` is the originator per protocol (case-insensitive compare as a defensive measure). Apply it as a guard in both the main skew-aggregation loop and the per-hash evidence loop in `getNodeClockSkewLocked`. `byNode` itself is untouched — #1558/#1352 still rely on the broader involvement index for other consumers. ## Tests - Existing `clock_skew_test.go` / `clock_skew_issue1094_test.go` / `clock_skew_issue1285_test.go` fixtures built synthetic ADVERT transactions without a `pubKey` field and seeded `s.byNode` directly, bypassing the normal `indexByNode` path where every real ADVERT carries `pubKey`. Added `pubKey` to each fixture so it reflects a self-originated advert, which is what these tests already intended to represent. All pre-existing tests pass unchanged in behavior. - New `clock_skew_issue1816_test.go`: - `TestTxOriginatedBy` — unit coverage of the new guard (self, foreign, missing pubKey, case-insensitivity). - `TestIssue1816_RelayDoesNotInheritOriginatorSkew` — a relay with healthy self-adverts plus relayed adverts from a broken-clock originator (matching the report's +100.5k s band) must report `ok` severity based only on its own adverts. - `TestIssue1816_PureRelaysReportNoSkew_NoBitIdenticalCluster` — five relay pubkeys that only ever forward a broken originator's advert (never self-advert) must report `nil`, not a bit-identical copy of the originator's skew. - `TestIssue1818_TwoForeignAdvertsDoNotPoisonIslandNode` — reproduces the cwichura island scenario: 8 healthy self-adverts + 2 foreign adverts at ~10 days skew must not flip severity or pollute `RecentMedianSkewSec`. Full suite: `go test ./...` passes (one pre-existing, unrelated flaky test — `TestHandleNodePaths_PrefixCollision_1352`, an index-loading race — reproduces intermittently on unmodified `master` too). Operator context: running CoreScope for SaarMesh (SaarLorLux, DE/FR/LU, 800+ nodes); this bug was surfacing as fleet-wide clock-skew false positives on our infra nodes. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
9ef4179ef1 |
fix(release): report correct version on fast-path retagged images (#1807) (#1814)
Fixes #1807. Implements the fix path from the triage (env → image-version file → baked version, zero rebuild cost): ## Changes **`cmd/server/main.go` — `resolveVersion()` fallback chain** 1. `CORESCOPE_VERSION` env (operator override) 2. `.image-version` file in the working dir (`/app` in the container) — mirrors the existing `.git-commit` pattern in `resolveCommit()` 3. ldflags-baked `Version` 4. `"unknown"` Edge builds are unaffected: no env, no file → baked `"edge"` as before. **`.github/workflows/release-fast-path.yml` — retag step** Instead of a plain `crane tag :edge → :vX.Y.Z`, the fast path now runs `crane mutate` on `:edge` with: - `--append` of a deterministic one-file layer containing `/app/.image-version` = `vX.Y.Z` - `--label org.opencontainers.image.version=vX.Y.Z` - `--tag :vX.Y.Z` `vX.Y`, `vX` and `latest` are then pointed at the mutated image. Still no rebuild — the mutation is a manifest + single ~100-byte layer operation. ## Notes - The release tags no longer share the exact digest with `:edge` (they carry one extra layer); the fallback SHA check is unaffected since it compares the `org.opencontainers.image.revision` label against `github.sha`. - The layer tar uses `--owner=0 --group=0 --mtime='UTC 2020-01-01'` for reproducibility. - Operators can also fix existing deployments immediately with `-e CORESCOPE_VERSION=v3.9.2`, no image change needed. ## Testing - `go build ./cmd/server` + `go vet` clean (golang:1.24) - Workflow YAML validated - Reporter context: running the affected v3.9.2 fast-path image in production (live.saarmesh.de), happy to verify the next tagged release end-to-end. --------- Co-authored-by: Mathias Kasper <fallisaar@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: SaarMesh-Bot <bot@saarmesh.de> |
||
|
|
8c48f2b49a |
feat(#1784): wire display consumers to path trust threshold (#1841)
Fixes #1784 ## Summary Step 4 of the #1784 multi-PR project — wires all frontend display consumers to respect the configured `pathTrust.minHashBytesForMapping` value. **Depends on:** #1840 (must be merged first — provides `MC_meetsPathTrust` / `MC_pathBelowTrust` / `MC_getPathTrustThreshold` helpers and `PATH_TRUST` global). ## Changes ### `map.js` — trust-gated route display - `drawPacketRoute` checks `MC_pathBelowTrust` before drawing polylines - When all path hops are below the configured threshold, shows a "Route not displayed" message with config guidance instead of speculative polylines - Cleans up the trust message control when a new route is drawn ### `analytics.js` — subpath trust filtering - `renderTable` in `renderSubpaths` filters route patterns whose hops are below trust threshold - Combined filter logic: both the 1-byte hide toggle AND trust threshold are applied together - Info line shows which filters are active ("1-byte hide + 2-byte trust", etc.) - No-data message explains which filters caused exclusion ### `route-view.js` — speculative path annotation - Path picker groups tagged with `belowTrust` flag when any hop doesn't meet the configured threshold - Speculative paths show `(speculative, <N-byte hops)` annotation with tooltip explaining the trust threshold - Tooltip includes guidance on changing `pathTrust.minHashBytesForMapping` in config.json ### `live.js` — Paths Through widget - `_pathHopsBelowTrust()` helper checks whether all hops in a path are below trust threshold - Widget fallback message distinguishes between "1-byte filtered" (display toggle) and "N-byte trust threshold" (server config) - Message includes the config key for operators to adjust ### `nodes.js` — confidence weight adjustment - `modeWeight` initialization considers the trust threshold - Hash modes below threshold get zero confidence weight - Bucket-0 (legacy/unknown) excluded at threshold >= 2 per #1784 bucket-0 policy ## Testing `test-issue-1633-hide-1byte-hops.js`: - 5 new source-grep guards verifying each consumer references the trust threshold helpers - All 26 tests passing (21 existing + 5 new) ## Files changed (6 files, +136/-6) ``` public/analytics.js | 28 ++++++++++++++++++--- public/live.js | 19 ++++++++++++++- public/map.js | 21 ++++++++++++++++ public/nodes.js | 8 ++++++ public/route-view.js | 16 +++++++++++- test-issue-1633-hide-1byte-hops.js | 50 ++++++++++++++++++++++++++++++ ``` --- **Depends on:** #1840 **Written by:** DeepSeek V4 Pro in Max Mode |
||
|
|
4c45dec79f |
fix(#1902): don't attribute transported scopes from the 1-byte hop prefix (#1903)
Fixes #1902. ## The bug `byPathHop` is keyed on the raw hop string from `path_json`, and both relay-info paths look up the full pubkey **and** fold in `key[:2]` — the 1-byte wire prefix. `TransportedScopes` (#1751) accumulated over that folded set, so every node sharing a pubkey first byte reported the same scopes. On the live network all four active nodes with prefix `f7` returned an identical set: ``` f79616... BE repeater ['#be','#be-van','#de','#de-nw','#nl'] f7e718... BE repeater ['#be','#be-van','#de','#de-nw','#nl'] f788ad... BE repeater ['#be','#be-van','#de','#de-nw','#nl'] f752c2... DE/NRW repeat. ['#be','#be-van','#de','#de-nw','#nl'] ``` Their real sets, from unambiguous full-pubkey hops over the same 7 days, are disjoint: ``` f79616... (BE) #be 471, #eu 9, #nl 6, #de 3, #be-van 1 f752c2... (DE/NRW) #de 13, #de-nw 11 f7e718... (BE) (none) ``` A sysop reads a scope badge as a statement about how their repeater is configured, so a Belgian repeater badged `#de-nw` is a wrong answer, not an imprecise one. ## The change The prefix fold stays for the counters — that is the documented #662 trade-off, "a possible over-count for clearly false zeros", and `RelayCount1h/24h`, `LastRelayed` and `UnscopedRelayCount24h` are magnitudes where an over-count is tolerable. Scopes are not a magnitude. A 1-byte hop names one of N nodes and cannot substantiate a categorical claim. Entries reached only through the prefix bucket are now flagged (`relayEntry.viaPrefix` / a `viaPrefix` argument to the bulk `visit` closure) and excluded from scope accumulation only. Both computation paths are changed together so `/api/nodes` (bulk) and the node-detail endpoint (per-node) stay in parity: - `cmd/server/repeater_liveness.go` — `collectRelayEntriesLocked` / `computeRelayInfoFromEntries` - `cmd/server/repeater_enrich_bulk.go` — `computeRepeaterRelayInfoMap` The `public/nodes.js` tooltip is updated to describe what the field now actually means. Attribution does not collapse: `observations.resolved_path` carries full pubkeys for ~27% of observations on the live instance (408k of 1.54M over 7 days), and those rows produce the correct per-node sets above. A node with no resolved hop yet shows no badge rather than a borrowed one. ## Tests `TestTransportedScopes_CrossBucketFold` pinned the old behaviour ("a scope seen only in the prefix bucket must surface on the full key"), which is the bug. It is replaced by `TestTransportedScopes_PrefixBucketNotAttributed`, which asserts on **both** paths that: 1. a scope evidenced only by a 1-byte hop is not attributed; 2. a scope also present under the full key still is; 3. `RelayCount24h` still counts all three packets — narrowing scopes must not narrow the counters, i.e. the #662 fold is untouched. Red before the change, green after. ``` cd cmd/server && go test ./... ok github.com/corescope/server 98.2s node test-packet-filter.js 92 passed, 0 failed node test-aging.js 18 passed, 0 failed node test-frontend-helpers.js 625 passed, 2 failed ``` The two frontend failures (`favStar returns filled star for favorite`, `favStar returns empty star for non-favorite`) and `cmd/ingestor`'s `TestWriteStatsAtomic_SymlinkAtDestIsReplaced` are **pre-existing** — I ran them on a pristine `upstream/master` worktree and got byte-identical results (the ingestor one is a Windows symlink-privilege limitation, not a code failure). ## Perf No new work in any loop. The bulk path gains one bool argument to an existing closure and one `&& !viaPrefix` on a branch that already ran; the per-node path gains one bool field on `relayEntry`, which is stack/slice-local and not retained. Same complexity, same allocations. ## What I could not verify end-to-end, and why I built a fixture from live data (2512 nodes, 17k transmissions, 529k observations, including all eight `f7` nodes) and ran the before/after binaries against it. Neither reproduced the live field — both returned no `transported_scopes` and `relay_count_24h: 0` for every node. That turns out to be a **separate cold-start bug**: `LoadChunked` calls `indexResolvedPathHops` per observation while scanning chunks, which adds full-pubkey keys to `byPathHop`, and then the post-load block at `cmd/server/chunked_load.go:459` calls `buildPathHopIndex()`, which begins with `s.byPathHop = make(...)` and rebuilds from raw hops only. Every resolved full-pubkey key from the scan is discarded: ``` [store] Built path-hop index: 2924 unique keys <- raw hops only [store] LoadChunked: 17056 transmissions (527331 observations) ``` So on a freshly started server the full-pubkey buckets are empty and only refill from live ingestion. That is being filed separately; it is orthogonal to this change, but it does mean `transported_scopes` will be sparse for a while after any restart until it is fixed. This PR is therefore verified by unit tests on both computation paths plus the live-data derivation above, not by a local end-to-end run. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9bd5f5a3a2 |
fix(ingestor): a retained status message is not observer liveness (#1885)
## Problem The broker replays every retained `status` message on subscribe, so each ingestor restart pushes all of them through the status path in `handleMessage`. That path stamps `last_seen` with `time.Now()` (deliberately, per #1465). Observed on a live deployment on 2026-08-11: **23 observers all carried `last_seen = 2026-08-06T08:20:09Z`** — 15 seconds after container start — and 18 of them had sent no actual packet in over a month. Their retained publish dates lined up almost 1:1 with `last_packet_at`, i.e. the replay was their only sign of "life": | observer | last real packet | retained status published | |---|---|---| | ON8AR - Observer | never | 2026-03-20 | | BE-BGS-RRY120-RES | never | 2026-04-02 | | A3BEF374 | 2026-04-30 | 2026-04-30 | | BE-BGS-RRY120-RUDY | 2026-05-18 | 2026-05-18 | | BE-JBE-ETG-O1 | 2026-06-10 | 2026-06-10 | That makes dead observers immortal, three ways per restart: 1. `last_seen` jumps forward, so `RemoveStaleObservers` can never age them out as long as a restart happens inside `observerDays`. 2. The unconditional `inactive = 0` reactivation at the end of `UpsertObserverAt` undoes any soft-delete that did land. 3. A metrics sample is filed at ingest time, dating a months-old reading as a present-tense measurement. `UpsertObserverAt`'s docstring already claimed retained replays were a no-op for `last_seen` thanks to the `MAX` guard. That held only while the caller passed the envelope timestamp; #1465 switched it to ingest time, which defeats the guard. ## Fix The retained path now updates metadata only, via a new `UpsertObserverRetained`: - no `last_seen` advance - no `inactive = 0` reactivation - no `packet_count` bump - no metrics sample - **no INSERT** — a retained-only observer the analyzer has never heard from live describes a past that may be months old and does not belong in the list. A live message from the same observer creates the row through the normal path moments later. Live status handling is unchanged. ## Tests Seven tests in `cmd/ingestor/retained_status_test.go`, written before the fix: - 4 that failed on the bug: `last_seen` advance, reactivation of a soft-deleted row, creation of a never-seen observer, metrics-sample insert - 2 regression guards pinning live (non-retained) behaviour: `last_seen` still advances, unknown observer still created - 1 asserting retained metadata is still applied — the snapshot is the observer's last known state, only the liveness signal is suppressed `mockMessage` gained a `retained` field so `Retained()` is controllable. Meta flattening is extracted to `observerMetaColumns` so both write paths bind identical args. Full `cmd/ingestor` suite passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7402e8d9d9 |
feat(analytics): repeater metric scatter tab (#1760)
Repeater metric scatter tab. Closes #1763. --------- Co-authored-by: Waydroid Builder <build@waydroid.local> |
||
|
|
f49e3fcc26 |
perf: use cached ParsedDecoded() instead of repeated json.Unmarshal (#1871)
## Problem
`StoreTx.ParsedDecoded()` already caches the result of `json.Unmarshal`
on first call via `sync.Once`. However, 13 call sites in `store.go` and
1 in `routes.go` were independently unmarshaling `DecodedJSON` into
local `map[string]interface{}` variables on every access, completely
ignoring the cache.
## Impact
For a store with 50k+ transmissions, each analytics endpoint that
iterates all packets re-parses 50k JSON strings per request. With
multiple endpoints, this means hundreds of thousands of redundant
`json.Unmarshal` calls per page load, each allocating new maps and
slices.
## Fix
Replace each `var d map[string]interface{};
json.Unmarshal([]byte(tx.DecodedJSON), &d)` with `d :=
tx.ParsedDecoded()`, which returns the cached parse result (parsed once,
reused forever).
### Call sites changed (14 total):
- `untrackAdvertPubkey` — advert PK extraction during eviction
- Ingestion path — payload field for API responses
- `evictStaleInternal` — node cleanup during eviction
- `GetAnalyticsTopology` — node PK extraction
- `GetAnalyticsHashCollisions` — advert PK extraction
- `GetAnalyticsDistance` — region node PK building
- `resolveAreaNodes` — node PK extraction
- `GetRecentPackets` — payload field for API response
- `routes.go` byType grouping — type field extraction
### Left unchanged (3 sites):
Three call sites that unmarshal into typed structs (`grpDec`,
`decodedMsg`, `decodedGrp`) cannot use `ParsedDecoded()` since they need
specific struct types.
## Testing
- `go build` passes
- No behavior change — same data, same logic, just avoids redundant
parses
---------
Co-authored-by: Joel Claw <358739783+Joel-Claw@users.noreply.github.com>
|
||
|
|
b38536911a |
fix(#1858): unstick npm test — three stale assertions and a Windows-only allowlist bug (#1895)
Partial fix for #1858. Clears four blockers; does **not** close the issue. ## Why partial matters here `test-all.sh` aborts at the first failing script. So the 9 red tests the issue counts are not 9 problems a contributor can work through — the run stops at `test-frontend-helpers.js` and everything after it is invisible. You have to fix them in order just to see the next one. That is what this does, as far as I could go without guessing. ## Three assertions that outlived their features Each was left behind by a merged PR that changed the thing being asserted. None could pass again without reverting that work. | Test | Asserted | Reality | |---|---|---| | `test-frontend-helpers.js` | `★` / `☆` glyphs in `favStar` | #1648 replaced them with `#ph-star-fill` / `#ph-star` sprite refs | | `test-channel-psk-ux.js` | literal `🔓` | same migration → `#ph-lock-open` | | `test-analytics-channels-integration.js` | a `#/analytics` chip in `channels.js` | #1367 / PR #1376 deliberately dropped it | The `favStar` rewrite also tightens two assertions that were not doing their job: `html.includes('on')` matched the word "favorites", and the negative case checked `!html.includes(' on')` rather than the class. Both now assert `aria-pressed` and the class explicitly. For the analytics chip I removed the assertion rather than weakening it, and left the reason in place so it does not get "restored" by someone reading a bare deletion: ```js // The "Channels page links to Analytics" assertion that used to live here was // removed: #1367 / PR #1376 ... deliberately deleted the `#/analytics` chip. ``` ## A Windows-only bug in the emoji sweep `test-issue-1648-m6-final-sweep.js` reports **64 violations on Windows and 1 on Linux**. `walkFiles` builds each path with `path.relative`, which yields `public\cb-presets.js` on Windows. Every entry in `tests/emoji-allowlist.txt` — and the ignore list in the same function — is written with `/`. So `matchesGlob` matched nothing, and every deliberately-annotated line (WCAG contrast `✓`/`✗` notes, `⌈⌉` ceiling brackets in audio-lab) was reported as a violation. One line, normalising to forward slashes. 64 → 1. The remaining 1 is the `🗺️` in `public/rx-coverage.js` that **PR #1860 already fixes**, and `test-issue-1648-m6-lint-self.js` cascades from it (`repo MUST be clean before anti-tautology probe`). Both go green when #1860 merges. Not duplicated here. ## Still red, deliberately untouched Four tests fail for reasons that need a behavioural decision rather than a test edit: - `test-issue-1438-customizer-mcrole.js` — `--mc-role-companion` empty - `test-issue-1446-cb-preset-cascade.js` — see below - `test-issue-1470-node-tile-helper.js` — `getActiveTileProvider` returns `undefined` for voyager; `getTileUrl` yields the `dark_all` URL instead - `test-issue-1485-live-anim-z.js` — animLayer hosts 2 animation shapes, ≥3 expected ### #1446 may not be a stale test Worth a second look before anyone edits it: - **Scenario 6** (roles.js + cb-presets.js, stored preset `deut`) **passes** — `--mc-role-repeater` = `#fe6100`. - **Scenario 5** is the same setup plus `customize-v2.js` and `_customizerV2.init({nodeColors:{repeater:'#aaaaaa'}})`, and the var comes back **empty** — not the preset colour, not the server colour. If that is the real behaviour, a colourblind user with a saved preset loses their role colours the moment the customizer initialises against a server config carrying `nodeColors`. I left it alone: the three-way cascade (preset vs server config vs user override) is exactly what #1446 defined, and picking a side changes colours for the users the feature exists for. That is your call, not mine. ## Not addressed: the structural half Three hand-maintained test lists (267 files, 159 in `deploy.yml`, 53 in `test-all.sh`, no CI invocation of the latter) is the root cause the issue identifies, and it is a workflow decision rather than a bug fix. Happy to do it in whatever shape you want — the obvious one being CI calling `test-all.sh` and the two lists collapsing into one — but that changes what gates merges, so it should be your choice. ## Verification ``` node test-frontend-helpers.js → 627 passed, 0 failed node test-channel-psk-ux.js → 20 passed, 0 failed node test-analytics-channels-integration.js → 23 passed node test-issue-1648-m6-final-sweep.js → 64 violations → 1 ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8ce5291b7c |
fix(map): apply the CARTO key on every map surface (rebase of #1916 onto #1919) (#1926)
Continues #1916 by @nullrouten0. The commit is theirs, authorship
unchanged; I rebased it onto master and resolved the fallout from #1919.
Opening it here rather than force-pushing to someone else's branch.
## Why this is needed after #1919
#1919 shipped the Carto key, but only for the five `BASE_STYLES` entries
in `map-tile-providers.js`. Four map surfaces still request unkeyed
tiles and get them back stamped `API KEY REQUIRED` at HTTP 200:
- `public/roles.js` — `getTileUrl()` returns the bare `TILE_LIGHT`
constant in light mode and never consults the registry (the dark branch
has consulted it since #1461). Affects `analytics.js:2200` and
`nodes.js:94`, so the analytics map and the node-detail map stay
watermarked in light theme even with a key configured.
- `public/customize-v2.js:1838` and `:2047` — the two geo-filter maps in
the customizer.
- `public/geofilter-builder.html` — standalone page, outside the SPA, so
it fetches `/api/config/client` itself.
@nullrouten0 had already found and fixed all four, plus written the
docs, before #1919 was merged. Their diagnosis of the light-mode branch
is in the PR verbatim.
## What I changed while rebasing
1. **`carto.token` → `carto.key`.** #1919 shipped `key` and operators
already have it in their configs; renaming now would break them
silently. All of #1916's code, tests and docs follow suit.
2. **Dropped the duplicate key getter.** `_getCartoKeyParam` did the
same job as master's `_getCartoKey`; kept master's.
3. **Merged the two test suites.** Master's five cases plus four of
#1916's that master does not cover: the `api_key`-is-ignored assertion,
lazy resolution after async config, and both `MC_tileUrlById` cases. 42
passed, 0 failed.
4. **Corrected one factual claim in the docs.** #1916 stated CARTO
offers no referrer or origin restriction. CARTO does ask for a domain
when the key is issued. Whether that is enforced per request is not
something I verified, so the note now says exactly that rather than
asserting either way.
5. Merged the two config comments, keeping the operationally useful
part: unkeyed tiles return **200** with a watermark, so nothing errors
and no healthcheck fires. Verify by looking at a tile, not at a status
code.
## Verification
Rebased onto `7aa60c03`. No regressions:
| | base
|
||
|
|
7aa60c0350 |
fix: support Carto basemap API key (tiles are watermarked without one) (#1919)
## Problem Carto began requiring an API key for its raster basemaps in 2026-08. Requests without a key still return `HTTP 200`, but the returned PNG is stamped `API KEY REQUIRED / carto.com/basemaps/apikey`. Because `carto-dark` / `carto-light` are the built-in defaults, **every CoreScope instance that has not changed tile providers now renders watermarked maps.** Reproduce without involving CoreScope at all — the watermark is baked into the bytes Carto serves: ```bash curl -o tile.png https://a.basemaps.cartocdn.com/light_all/11/1152/683.png # HTTP 200, and tile.png carries the watermark. (11/1152/683 is Lublin, PL.) ``` ## Why `carto.domain` cannot express this Carto takes the key as a query parameter on the **same** host: ``` https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png?key=YOUR_KEY ``` The existing `tiles.providers.carto.domain` option builds `https://{s}.<domain>.cartocdn.com` — an enterprise subdomain, which is a different mechanism. There was no way to supply a key, so I added one rather than repurposing `domain`. ## What this changes Adds `tiles.providers.carto.key`, appended as `?key=` (URL-encoded) to all five Carto styles: `carto-dark`, `carto-light`, `carto-voyager`, `carto-voyager-dark`, `positron-dark`. ```json "carto": { "enabled": true, "key": "", "domain": "" } ``` When no key is set the emitted URLs are byte-identical to today's, so this is a no-op for anyone who has not requested one. `key` and `domain` compose, so enterprise users keep their subdomain and gain the key. Free keys are available at <https://carto.com/basemaps/apikey> (5M tiles/month, non-commercial tier). **Security note:** like the existing OSM and Stamen tokens, this key reaches the browser — unavoidable for raster tiles. The `_comment_carto` text in `config.example.json` tells operators to restrict the key by domain in the Carto dashboard. ## Tests 5 new cases in `test-issue-1420-tile-providers.js` — **38 passed, 0 failed**: 1. no `?key=` emitted when no key is configured (guards the no-op claim) 2. all five Carto styles append the key when it is set 3. the key is URL-encoded 4. the key does not leak into OSM / Esri provider URLs 5. `key` coexists with the enterprise `domain` option Also ran the required frontend suite: `test-packet-filter.js` (92 passed), `test-aging.js` (18 passed), `test-frontend-helpers.js` (625 passed, 2 failed). **The 2 failures are pre-existing on `master`** — I ran the same file from a clean `origin/master` worktree and got an identical 625/2. They are `favStar returns filled star for favorite` / `... for non-favorite`, unrelated to tiles. ## Performance Not a hot path. `url()` returns a Leaflet URL *template* that is built once per tile-layer construction (`map.js:289`, `live.js:1422`), not once per tile — `_getCartoKey()` is called at exactly the same frequency as the existing `_getCartoBase()`, and does one property read plus one `encodeURIComponent`. ## Validation Verified against a live instance (<https://analyzer.marwoj.net>) running this logic as a mounted patch, with a real Carto key: - **before** — `light_all` and `dark_all` tiles both watermarked - **after** — both clean, with the key restricted to the instance's domain in the Carto dashboard - confirmed the key survives `config.json` → `/api/config/client` → `MC_MAP_CFG` and reaches the tile URLs ## Heads-up for maintainers Carto is retiring raster basemaps and steering users to vector, so this fix has a shelf life. It restores the default experience now; a vector migration is a larger, separate piece of work. I am not sure whether you want an issue filed to track that — happy to open one if it helps. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
589fa9878d |
fix(#1923): pin the packets time window in the slide-over E2E (#1924)
Closes #1923.
## What was wrong
`test-slideover-1056-e2e.js` runs twice in CI: inside the main E2E
suite, and again as the #1616 flake-gate, which is the last step before
coverage collection. That second run starts roughly 20 minutes after
`tools/freshen-fixture.sh` set the fixture's newest packet to "now".
The packets page defaults to a 15-minute time window
(`DEFAULT_TIME_WINDOW`, `public/packets.js:746`). By the time the
flake-gate runs, every fixture packet is outside it, the table renders
zero data rows, and `packets@800: page renders + first row exists` times
out after 30s. The two tests that need a row to click fail with it, so
one expired window reports as three failures.
Whether it fires depends on how long the preceding suite took, which is
why it read as flake and hit PRs that cannot have caused it. #1760 (a
frontend analytics tab, 2026-07-09) and #1872 (Go-only, deletes a dead
struct field, 2026-07-28) failed on the identical three tests three
weeks apart, and neither touches the packets page.
## The change
Pin `?timeWindow=1440` on both packets navigations in that file, so it
tests the slide-over rather than the clock. Two lines plus the comment
explaining why, no product code touched.
The value must be greater than 0. `public/packets.js:1092` reads the
parameter under `_urlTimeWindow > 0`, so `timeWindow=0` does **not**
disable the filter, it silently leaves the 15-minute default in place.
That is worth knowing before anyone "simplifies" it to zero.
## Verification
Reproduced first, then fixed. All against `upstream/master`
|
||
|
|
a06ac8aceb |
fix(#1849): render — for TRACE Hop Bytes column (path bytes are SNR, not hop hashes) (#1850)
Fixes #1849. ## Problem The packets table "HB" (Hop Bytes / col-hashsize) column always shows `1` for TRACE packets. TRACE packets use header path bytes as per-hop SNR readings, not truncated hop hashes — see `internal/packetpath/route.go` `PathBytesAreHops(TRACE) = false`. The high-2-bit "hash_size" derivation applied to a SNR byte is meaningless (typically `1`). ## Fix At the 3 render sites in `public/packets.js` (`buildGroupRowHtml` header row, its child rows, `buildFlatRowHtml`), when `payload_type === 9` (TRACE) render `—` in the `col-hashsize` cell with a `title` tooltip explaining that TRACE path bytes are SNR readings and directing users to the sidebar decoder for the actual hop count. Non-TRACE rows unchanged. ## TDD - Red commit: `dd7a4e78` — `test-issue-1849-trace-hashbytes.js` asserts col-hashsize cell equals `—` with a title tooltip for TRACE, numeric for non-TRACE. CI must fail on this commit. - Green commit: `115368d0` — 3-site fix in `public/packets.js`. ## Verification ``` $ node test-issue-1849-trace-hashbytes.js ✅ All 4 tests passed ``` Preexisting failures in `test-packets.js` (13) are unchanged by this PR (verified via `git stash`) — unrelated to the surface touched here. ## Scope - `public/packets.js`: 3 render sites, +21/-8. - `test-issue-1849-trace-hashbytes.js`: new unit test. - `test-all.sh`: wire new test. No public API change. --------- Co-authored-by: clawbot <bot@example.invalid> |
||
|
|
8c3e397d39 |
fix(1846): drop 1200px cap on .observers-page (#1847)
Red commit:
|
||
|
|
c7f0c6931f |
fix(1843): restore QR quiet zone on node-details codes (#1844)
## Summary QR codes on node-details pages didn't scan. Two `public/nodes.js` render sites called `qr.createSvgTag(3, 0)`; the vendor lib treats the second arg as an **absolute pixel margin**, so `0` removed the QR quiet zone. QR spec requires ≥4 modules of light border, so every scanner rejected the code. With `cellSize=3`, four modules = 12 pixels. Changed both call sites to `createSvgTag(3, 12)`: - `nodes.js:813` — node-details list detail - `nodes.js:1705` — node-map overlay (also swaps background to transparent; with margin restored, dark modules stay 12px from the SVG edge so the transparent overlay still parses over the map tile) Out of scope, filed as follow-up in triage: contrast tuning of the transparent-overlay branch. ## TDD Red commit: `3be8552b` — [test-only, CI red](https://github.com/Kpa-clawbot/CoreScope/commit/3be8552baff64795504e8dcb888ed25cbd6a50be) Green commit: `6f7e83a6` — two-line fix + test passes ## Test `test-issue-1843-node-qr-quiet-zone.js` — Node/vm harness that loads `public/vendor/qrcode.js`, grep-asserts both `nodes.js` call sites use margin ≥ 12px, renders the SVG, and checks the `<path>` `M` coordinates keep all dark modules ≥ 12px from every viewBox edge (and viewBox = `modules*cellSize + 2*margin`). Verified red on parent commit (test fails with margin=0 leaving dark modules at 0,0), green after fix (dark modules at 12,12 with 12px free border). ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` — all gates clean. Browser verified: pending — will validate on staging after CI. (Frontend UX bug; DOM/grep test above exercises the same SVG code path that scanners consume.) E2E assertion added: `test-issue-1843-node-qr-quiet-zone.js:63` (viewBox + `<path>` coord grep on real-rendered SVG). Fixes #1843. --------- Co-authored-by: corescope-bot <bot@corescope.dev> |
||
|
|
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> |
||
|
|
d60188e481 |
refactor(1828): split handleObserverAnalytics into 5 helpers + byTxID fast-path (#1839)
## Summary Phase A of #1828: extract the 5 aggregate builders in `handleObserverAnalytics` into pure helpers in a new `cmd/server/observer_analytics.go`. Handler becomes a snapshot + filter + 5 composed calls. Also adopts the `byTxID` direct-read in `buildPacketTypes` (issue body's core observation): the payload-type histogram no longer allocates a full `enrichObs` map + interface-boxed fields just to read `tx.PayloadType`. That's the ~90% perf win the triage called out. Scope is exactly Phase A per the second triage comment. Phase B (sub-endpoints, caching, SQL migration) is deferred to a follow-up. ## Byte-identical output - Timeline / NodesTimeline: same key set, same sort, same labels. - PacketTypes: same keys/counts. Both legacy (`enriched["payload_type"].(int)`) and new (`tx.PayloadType == nil` guard) skip obs whose tx is missing or `PayloadType` is `nil`. - SnrDistribution: same 2-unit floor bucketing (negative-side rounding preserved), same ascending sort. - RecentPackets: still the first 20 enriched observations (`enrichObs` kept only here, where the extra fields are actually needed). ## TDD - Red commit: `9dc62f43` — 7 unit tests fail on assertions (not build errors) against stubs. - Green commit: `8d41011d` — implementations + handler rewire. All new tests + existing `TestObserverAnalytics*` handler tests pass. ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` → clean (all 8 hard gates + 3 warnings pass). ## Non-goals - No new endpoints. - No SQL migration. - No public API signature change. - Snapshot count unchanged (still one under RLock, per #1481 P0-2). Fixes #1828. --------- Co-authored-by: fix-1828-bot <bot@corescope.local> Co-authored-by: clawbot <bot@corescope> |
||
|
|
9d47a4adea |
fix(1836): normalize pubkey case on observer↔node cross-nav links (#1837)
Fixes #1836. Observer↔node cross-nav links from #1826 land on 404 because pubkeys are stored lowercase in `nodes` and uppercase in `observers`, and the backend `WHERE` lookups are case-sensitive. The two link builders now normalize case at the boundary. ## Fix - `public/observer-detail.js`: observer → node href passes `currentId.toLowerCase()`. - `public/nodes.js`: node → observer href passes `n.public_key.toUpperCase()`. ## TDD - Red: `test-issue-1836-crossnav-case-normalization.js` asserts the two hrefs contain `.toLowerCase()` / `.toUpperCase()`. Fails on master. - Green: 2-line production change makes the test pass. Scope: 2 production line edits + 1 new test file. --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
d2ef624c2e |
feat(api): flood_advert_count_7d on the node detail endpoint (#1831)
Adds, per node, how many distinct FLOOD adverts it originated in the last 7 days. Zero-hop adverts (route_type DIRECT) are excluded, so a nearby observer hearing a node's cheap local adverts does not inflate the number - the existing advert_count mixes both kinds and cannot tell a chatty flooder (mesh-wide airtime) from the recommended 240-minute zero-hop cadence (local only). Consumers (the ArcScope repeater advisor) rate advert hygiene against the community practice of one flood advert every ~49h; with the mixed total, a correctly configured repeater looked chatty whenever an observer sat within zero-hop range. Implemented like the relay-liveness fields: a pure, unit-tested counter over (first_seen, route_type, hash) entries with the same timestamp parsing and hash dedup, fed by a from_pubkey-indexed query capped at the 2000 most recent advert rows. The flood route-type constant is named advertRouteTypeFlood so this merges independently of the open unscoped-relay PR (#1823). --------- Co-authored-by: Waydroid Builder <build@waydroid.local> |
||
|
|
4f7bb245d4 |
fix(#1833): pin legend toggle button above VCR bar on Live view (#1834)
## Summary Fixes #1833 — the `.legend-toggle-btn` (palette icon) on the Live view was hardcoded to `bottom: 1rem`, so on typical desktop viewports it sat underneath the VCR playback bar and was unreachable. The reporter had to hide the legend via `localStorage` to work around it. ## Fix `public/live.css:1313` — one-character-class change: ```diff .legend-toggle-btn { position: fixed; - bottom: 1rem; + bottom: calc(var(--vcr-bar-height, 58px) + 10px); right: 1rem; ``` Mirrors the existing pattern already used by every other bottom-pinned Live overlay: - `.live-feed` (live.css:1204) - `.live-overlay[data-position="br"]` (live.css:1372) - `.feed-show-btn` (live.css:903) `--vcr-bar-height` is maintained by the ResizeObserver on `.vcr-bar`, so the button now tracks bar growth (mobile two-row layout, safe-area-inset) instead of overlapping. Same class of regression as #685 / #1206 / #1107 — an overlay that was missed in the prior sweep. ## TDD - Red commit `f6a938b7`: `test-issue-1833-legend-toggle-vcr-offset.js` asserts `.legend-toggle-btn`'s `bottom` declaration references `var(--vcr-bar-height`. Fails on assertion (not build error) against the hardcoded `1rem`. - Green commit `e399092b`: the CSS one-liner above. All 3 assertions pass. Grep-based CSS assertion per AGENTS.md § "E2E-DOM-grep exemption" — there is no existing Playwright test in this area that measures the toggle-button rect against `--vcr-bar-height`, and standing one up would be disproportionate for a single-property fix that mirrors three existing, tested overlay patterns. ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` — all hard gates pass, no warnings. ## Browser verified Not required per fix-issue skill (CSS-only, mirrors three existing tested patterns). Staging will pick up the change on merge; visual regression will be caught if the mirrored pattern breaks (grep test in this PR + existing E2E tests on the sister overlays). --------- Co-authored-by: corescope-bot <bot@corescope.local> Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
56fe844871 |
test: dedupe the unscoped-relay tests via a shared fixture (#1832)
Follow-up to #1823: TestRepeaterUnscopedRelayCount and its _Bulk twin were ~30 verbatim lines apart (DB, node insert, store seeding, assertions), differing only in the lookup under test - seeding changes had to land twice. Both now use a shared seedUnscopedRelayFixture + assertUnscopedCounts and contain only their respective lookup call. No behaviour change; the relay-liveness suite passes. Co-authored-by: Waydroid Builder <build@waydroid.local> |
||
|
|
bd0a58e14c |
feat(api): add unscoped_relay_count_24h per-node field (#1823)
## What Adds a per-node API field `unscoped_relay_count_24h` on repeater/room nodes: the number of the node's 24h relay-hops that were unscoped floods (route_type == ROUTE_TYPE_FLOOD). A strict subset of relay_count_24h. ## Why A well-configured repeater runs `flood.max.unscoped 0` and should not rebroadcast unscoped floods — each one is re-sent by every repeater that hears it, so one packet turns into mesh-wide traffic. Exposing this lets clients (the ArcScope repeater advisor) detect and flag that base-config problem from observed packets. ## How Computed like relay_count_24h in both paths (bulk /api/nodes + per-node detail) with a route_type==FLOOD filter; reuses the byPathHop index, no migration. Wired into both handlers + OpenAPI schema + unit tests (per-node and bulk). Co-authored-by: Waydroid Builder <build@waydroid.local> |
||
|
|
ba68069c23 |
fix(#1825): add cross-nav links between observer and node detail pages (#1826)
Adds cross-navigation between the observer detail page and the node
detail page for the same pubkey (community feature request from
cwichura).
**Changes**
- `public/observer-detail.js`: new `<a
href="#/nodes/${encodeURIComponent(currentId)}">View node detail →</a>`
inside `.page-header`, next to the `<h2 id="obsTitle">`.
- `public/nodes.js`: new sibling `<a
href="#/observers/${encodeURIComponent(n.public_key)}"
class="btn-primary">Observer →</a>` in the same button row as the
`Analytics` / `Reach` anchors on the full node detail page. Uses the
existing `ph-eye` phosphor icon.
**Test — TDD red→green**
- Red commit: `8c2315e1` (`test(#1825): red — observer<->node cross-link
anchors missing`) — 4/4 assertions fail on master; CI RED.
- Green commit: `ff8f6ed7` — minimum production change; 4/4 assertions
pass locally.
Test file: `test-issue-1825-observer-node-cross-links.js` —
static-source DOM-grep style consistent with the neighbouring
`test-issue-1789-observer-firmware-cols.js` /
`test-observers-headings.js` pattern. It asserts:
1. observer-detail.js contains
`href="#/nodes/${encodeURIComponent(currentId)}"`.
2. That anchor sits inside the `.page-header` block.
3. nodes.js contains
`href="#/observers/${encodeURIComponent(n.public_key)}"`.
4. That anchor is a sibling of the analytics/reach anchors in the same
flex row.
**Notes**
- Pubkeys are `encodeURIComponent`-escaped on both sides
(defense-in-depth; MeshCore pubkeys are hex only).
- No API changes. No CSS changes. No new dependencies.
Fixes #1825
---------
Co-authored-by: meshcore-bot <meshcore-bot@users.noreply.github.com>
|
||
|
|
096e16409c |
fix(#1741): wrap test-DB insert loops in a single transaction (#1819)
## Fixes #1741 `TestBoundedLoad_OldestLoadedSet` (and any test building a 5000-row fixture) hung/timed out, blocking reliable `go test ./cmd/server` and CI. ## Root cause The four test-DB builders in `cmd/server/bounded_load_test.go` (`createTestDBAt`, `createTestDBWithObs`, `createTestDBWithAgedPackets`) inserted rows in a loop with no `BEGIN`/`COMMIT`. With the pure-Go `modernc.org/sqlite` driver every `Exec` auto-commits → one fsync per row → ~2N fsyncs for N transmissions (tx + obs). At `numTx=5000` that's ~10k fsyncs and the fixture blows past the test timeout. Sibling tests with `numTx<=3000` happened to stay under the timeout, so only the 5000-row cases visibly hung. ## Fix Wrap each insert loop in a single `BEGIN`/`COMMIT` so the whole fixture build becomes one commit. Fixtures now finish in well under a second regardless of `numTx`; the tests' actual assertions (`oldestLoaded` set, newest-first ordering, bounded load) are exercised instead of the timeout masking them. Also made the prepared-statement `Exec` calls check their error (previously discarded) so a failed insert surfaces instead of silently leaving the DB short. No production code changed — test infrastructure only. ## Verified - `TestBoundedLoad_OldestLoadedSet`: **0.18s** (was: 30s timeout / FAIL). - Full `TestBoundedLoad*` + retention group: passes in ~1.2s. - `go test ./...` in `cmd/server`: exit 0 (no longer blocks on this test). Co-authored-by: Waydroid Builder <build@waydroid.local> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
6a32ec2b2d |
fix(#1729): preserve firmware-default Public channel (0x11) in analytics (#1817)
## Fixes #1729 The firmware-default **Public** channel (channel-hash byte `0x11` = 17) was rendered as an opaque **"Encrypted (0x11)"** row at the bottom of the analytics Channels tab, despite the key being well-known and builtin. ## Root cause `computeAnalyticsChannels` applied the #978 rainbow-table validation (`SHA256(SHA256("#name")[:16])[0]`, the **hashtag** hash scheme) to every decoded channel name. The Public channel is a **PSK** channel whose hash byte is key-derived (`SHA256(key)[0]` = 17), not hashtag-derived (`186` for `#Public`). So the ingestor-decoded name `"Public"` failed the hashtag check and was discarded, the row forced to `encrypted=true, name="ch17"`. ## Fix Trust the ingestor's `decryptionStatus`. The ingestor already persists `decryptionStatus:"decrypted"` when it decoded a packet with a real key (PSK), and `"no_key"` / `"decryption_failed"` otherwise. When the packet is `decrypted`, skip the hashtag hash check and keep the name — it came from a key-based decryption, not a rainbow-table lookup. The #978 mismatch rejection still applies to non-decrypted packets, so rainbow-table collisions are still caught. Frontend needs no change: `encrypted=false, name="Public"` lands in the "Network" group (top), not "Encrypted". ## Tests - `makeGrpTx` gains `makeGrpTxWithStatus` companion to set `decryptionStatus`. - `TestComputeAnalyticsChannels_PublicChannelPreserved`: hash 17 / "Public" / `decrypted` → name stays `"Public"`, `encrypted=false`. - `TestComputeAnalyticsChannels_UndecryptedNameStillValidated`: a non-`decrypted` name failing the hashtag check is still downgraded to `ch17` (#978 regression guard). All channel-analytics tests pass; `go build ./...` clean. Co-authored-by: Waydroid Builder <build@waydroid.local> Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
750b8742a7 |
fix(staging-compose): decouple in-container mosquitto from standalone broker (#1813)
Red commit: `3898dbc5` (verified locally — CI run URL pending)
## Problem
A standalone `mqtt-broker` container (`eclipse-mosquitto:2`) was
provisioned out-of-band on the staging VM. It now owns MQTT, is attached
to external docker network `meshcore-net`, and binds host port `8883`.
The current `docker-compose.staging.yml` still:
- Publishes `1883:1883` on the host (dead weight; conflicts the moment
the broker moves to that port).
- Defaults `DISABLE_MOSQUITTO=false`, so the in-container mosquitto
burns RAM and briefly contests the `mqtt-broker` docker DNS name on cold
start.
- Doesn't join `meshcore-net`, so the ingestor can't resolve
`mqtt-broker:1883` via docker DNS without manual surgery.
## Fix (`docker-compose.staging.yml` only)
1. Remove the `1883:1883` host port publish from `staging-go`.
2. Flip `DISABLE_MOSQUITTO` default from `false` to `true`. Operators
can opt back in with the env var.
3. Attach `staging-go` to both `default` and `meshcore-net`; declare
`meshcore-net` as `external: true` so the file never tries to
create/destroy operator state.
Healthcheck and Caddy/443 plumbing untouched (out of scope).
## Test added (TDD framing: Option A — Go shape-asserts)
`cmd/server/staging_compose_broker_test.go:1` adds four regex-based
assertions on the compose file shape:
- staging-go does **not** bind port `1883` in ANY form (quoted/unquoted
short form, or long-form `target: 1883` / `published: 1883`).
- `DISABLE_MOSQUITTO` uses the interpolated default form
`${DISABLE_MOSQUITTO:-true}` (preserves operator override). Bare literal
`true`, or a later `=false` override in the same env block, is rejected.
- Top-level `networks:` declares `meshcore-net` as `external: true`.
- `staging-go` attaches to `meshcore-net` via a real
`services.staging-go.networks:` sub-key (comment-stripped so an
in-comment example can't masquerade).
Regex (not YAML byte-equality) so cosmetic edits don't break the guard.
No new go module deps. Red commit `3898dbc5` fails all 4 assertions on
master. Green commit `38297ff4` makes them pass. Round-1 hardening
commit `9f7155e2` tightens the regexes (per adversarial + kent-beck
must-fixes) and was verified against master's YAML shape — all 4 tests
fail on `origin/master`'s compose, pass on branch, proving the tightened
regexes still gate a real regression.
## Risk
Low, with one intentional semantic change.
- **Semantic change (v3.7+):** `DISABLE_MOSQUITTO` in
`docker-compose.staging.yml` now defaults to `true`. This is a
**deliberate flip** — the standalone `mqtt-broker` container is now
authoritative on the staging host, and running the in-container
mosquitto alongside it wastes RAM and races the docker DNS name
`mqtt-broker` on cold start. Operators who want the pre-v3.7 shape
(in-container mosquitto + host-published `1883`) must explicitly opt
back in via env override AND re-add the `1883:1883` port mapping
(concrete snippet is inline in the compose file and in `DEPLOY.md` under
"Standalone MQTT broker (staging)"). This intent is called out in a
`SEMANTIC CHANGE (v3.7+)` header comment at the top of
`docker-compose.staging.yml`.
- **Deploy prereq:** the external `meshcore-net` docker network MUST
already exist on the host before `docker compose up`. If it doesn't,
compose refuses to start `staging-go`. This is documented inline in the
compose file (with the `docker network create meshcore-net` one-liner)
and in `DEPLOY.md`.
- **Only takes effect where the standalone broker is deployed** — which
it already is on staging today. The legacy `DISABLE_MOSQUITTO=false`
path remains reachable via env override; the ingestor's upstream config
is untouched.
Partial fix — no tracking issue; follow-up to operator-side broker
provisioning.
---------
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
|
||
|
|
fa15ab0a30 |
fix(#1809): gate background loader on LoadChunked completion (#1811)
Partial fix for #1809. Red commit: |
||
|
|
242c7c609b |
fix(mqtt): escalate persistent paho disconnect + recover from emit panic + expose watchdog tick (#1749) (#1810)
# Partial fix for #1749 — MQTT watchdog escalation + panic recovery +
tick exposure
Red commit:
|
||
|
|
b74a64ccfa |
fix(ui): canonical payload label map across packets/live/packet-filter (#1799) (#1804)
## Summary Replaces the three drifted per-surface payload-type label vocabularies with a single canonical map keyed by firmware enum name. Per the locked triage comment on #1799 ([comment-4823975431](https://github.com/Kpa-clawbot/CoreScope/issues/1799#issuecomment-4823975431)): > Create `public/payload-labels.js` exporting `{GRP_DATA: {short:'Group Data', long:'Group data packet', enumId:6}, ...}`. Migrate `packets.js typeMap`, `packet-filter.js FW_PAYLOAD_TYPES`, `live.js TYPE_COLORS legend` to consume it. E2E that scrapes each surface and asserts label equality. ## Changes - **`public/payload-labels.js`** (new) — canonical map exposed as `window.PayloadLabels` and `window.PayloadLabelsApi`. Keys are firmware enum names; values carry `{short, long, enumId}` plus derived `SHORT_BY_ID` / `FW_PAYLOAD_TYPES` / `TYPE_ALIASES` for legacy callers. - **`public/packets.js`** — `TYPE_NAMES` + `typeMap` now read from `PayloadLabelsApi.SHORT_BY_ID`. Literal kept only as a defensive fallback for the case where the script tag fails to load. - **`public/packet-filter.js`** — `FW_PAYLOAD_TYPES` + `TYPE_ALIASES` now sourced from `PayloadLabelsApi`. Literal fallback retained so `node test-packet-filter.js` still works headlessly. - **`public/live.js`** — legend `<li>` rows are now generated from `window.PayloadLabels` in stable order, killing the third-vocabulary `Message — Group text` / `Direct — Direct message` drift the #1797 review surfaced. - **`public/index.html`** — `<script src="payload-labels.js">` loaded before `roles.js` / `packet-filter.js` / `packets.js`. - **`test-issue-1799-label-vocab-e2e.js`** (new) — Playwright E2E. Scrapes `#liveLegend` rows and the `/packets` type-filter checklist, asserts each label matches `window.PayloadLabels[ENUM].short` for `TXT_MSG`, `GRP_TXT`, `GRP_DATA`. Also verifies `window.PacketFilter` still recognises the enum names. - **`.github/workflows/deploy.yml`** — wired the new E2E into the existing Playwright block. ## TDD trail - Red commit `eb392d4` — adds the failing E2E only (asserts `window.PayloadLabels` exists and labels match; both fail). - Green commit `44e902a` — introduces the canonical map and migrates the three surfaces. ## Verification - `node test-packet-filter.js` — 92/92 pass with the new fallback wiring. - Preflight: `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` — clean. Browser verified: E2E `test-issue-1799-label-vocab-e2e.js` exercises `/live` legend + `/packets` type filter against a Playwright headless Chromium; CI's Playwright block runs it on every push. E2E assertion added: `test-issue-1799-label-vocab-e2e.js:139` — `assert(fromLegend === canon, ...)` and `assert(fromPackets === canon, ...)` per enum. Fixes #1799 --------- Co-authored-by: mc-bot <bot@corescope> Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: clawbot <clawbot@kpa.com> Co-authored-by: clawbot <bot@clawbot.local> |
||
|
|
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> |
||
|
|
9ae547ed7b |
test: de-flake distance-202 and anchor-bias tests (deterministic timing) (#1808)
Two server tests flaked intermittently and reddened CI on unrelated (frontend) PRs that merged master: - TestDistanceConcurrentRequestsDuringBuildReturn202 asserted all 10 concurrent requests get 202 'during the build window', but the lazy distance build on the tiny test DB finishes almost instantly, so on a fast machine some requests raced past it and got 200 (~50% flake). Add a nil-by-default distanceBuildHook seam on PacketStore (zero overhead in prod) that the test uses to hold the build open until all requests have been served — making the window guarantee deterministic. - TestHandleNodePaths_AnchorBiasInconsistency_Issue1278 queried /paths right after store.Load(), racing the path-hop index that Load() builds in a background goroutine (#1008); the membership/canonical result was thus non-deterministic (rarer flake, worse under suite load). Wait for PathHopIndexReady() before querying. Both run 30x green and pass -race. No production behavior change (hook is nil). Co-authored-by: Waydroid Builder <claude@michael.arcan.de> |
||
|
|
ec0ebeda2f |
fix(#1793): WebSocket CheckOrigin allowlist (block cross-origin scrapers) (#1795)
## Summary Closes the wide-open `/ws` WebSocket upgrader (`CheckOrigin: return true`) that lets any browser origin scrape live packet data. Replaces it with an explicit allowlist consulted from `cfg.CORSAllowedOrigins`, plus an implicit same-origin allowance and an empty-Origin (non-browser client) allowance. Fixes #1793. ## Rules (`Hub.checkOrigin`) - Empty `Origin` header → **allow** (non-browser clients; per-IP rate/deny gating tracked separately in #1794). - `Origin` host == request `Host` (case-insensitive) → **allow** (same-origin). - `Origin` matches an entry in `cfg.CORSAllowedOrigins` by exact case-insensitive match → **allow**. - `"*"` in `cfg.CORSAllowedOrigins` is **deliberately ignored** for `/ws`. A startup `[ws] WARNING:` is logged once when present. - Anything else → **reject** (gorilla returns 403). ### Deliberate divergence from CORS XHR CORS XHR (`corsMiddleware`) still honors `"*"` for read-only cross-origin GETs. The `/ws` upgrade does NOT, per OWASP's WebSocket Security Cheat Sheet: > Use an allowlist, not a denylist. Avoid wildcards or substring matching. — https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html `"*"` on the WS path would re-open the exact CSWSH/scraping vector this PR closes, so it is rejected with a startup warning rather than silently honored. This intentional asymmetry is documented in the updated `_comment_corsAllowedOrigins` in `config.example.json`. ## TDD red → green - `e5974c6a` **RED** — adds `cmd/server/websocket_checkorigin_test.go` with five cases; `SetAllowedOrigins` introduced as an enforcement stub so the test compiles and fails on the assertion (CI fails on this commit by design). - `a4791dc3` **GREEN** — implements `Hub.checkOrigin`, wires `SetAllowedOrigins` from `main.go`, updates the config example. All tests pass. ## Tests added (`cmd/server/websocket_checkorigin_test.go`) - `TestCheckOriginRejectsForeignOrigin` — foreign Origin → 403 - `TestCheckOriginAllowsEmptyOrigin` — non-browser client → 101 - `TestCheckOriginAllowsSameHost` — same-origin → 101 - `TestCheckOriginAllowsAllowlistedOrigin` — exact allowlist match → 101 - `TestCheckOriginWildcardDoesNotAllowForeignOrigin` — `"*"` in allowlist still rejects foreign origin → 403 ## Files changed - `cmd/server/websocket.go` — `Hub.allowedOrigins`, `SetAllowedOrigins`, `checkOrigin`, wired into `Upgrader.CheckOrigin`. - `cmd/server/main.go` — `hub.SetAllowedOrigins(cfg.CORSAllowedOrigins)` at the single call site. - `cmd/server/websocket_checkorigin_test.go` — new test file. - `config.example.json` — updated `_comment_corsAllowedOrigins` to document `/ws` gating and the `"*"` divergence. ## Out of scope (follow-up) - **#1794** — per-IP rate limit / deny list / connection cap for non-browser clients (which still bypass Origin because they don't send one). Layered defense; not in this PR. ## Verification - `go test ./cmd/server/...` — all server tests pass locally (574s). - Preflight clean (`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`). --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
ae2e3933dd |
feat(server): store memory diagnostics + drop redundant obs.RawHex (#1773)
Drops the redundant per-observation RawHex (~98MB on a live store; reader already falls back to tx.RawHex #881) and adds an opt-in /api/perf?mem=1 memory breakdown (flood-forward share + per-component bytes). Profiled against a live instance. **Savings substantiation:** live-instance profiling shows ~1.66M observations in the store, each previously carrying its own per-observation `raw_hex` (avg ≈118 hex chars ≈59 bytes) that exactly duplicates the parent transmission's `raw_hex`. Dropping the duplicate on every load/ingest path eliminates ≈98 MB of redundant in-memory storage plus ~1.66M string allocations, with no data loss — the read path (`enrichObs`) already falls back to `tx.RawHex` when `obs.RawHex` is empty (verified by the new safety-gate test). The patched build cannot be run against the live instance here; instead the new opt-in `/api/perf?mem=1` diagnostic lets operators measure the real before/after (`trackedMB` and the per-component breakdown) directly after deploy. |
||
|
|
707d70c738 |
fix(packets): clamp .col-details to one line on mobile (#1770 S path) (#1805)
## Summary Partial fix for #1770 (S quick-fix path only; L refactor remains as follow-up). The packets-view virtual-scroller assumes a constant `VSCROLL_ROW_HEIGHT`, but the base rule at `public/style.css` L1097 lets `td.col-details` wrap on narrow viewports (`white-space: normal; word-break: break-word`). Wrapped rows produce variable row heights → visible jitter when scrolling past ~900px on iOS. **Quick-fix (S path):** under the existing `@media (max-width: 640px)` block in `public/style.css`, clamp `.col-details` to a single line: ```css .data-table td.col-details { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } ``` Trade-off accepted in triage: Details column truncates on mobile in exchange for smooth scrolling. The base rule keeps wrapping on desktop (≥641px) so nothing changes there. **Out of scope:** the full L-path fix (per-row measurement, `_rowHeightsPx[]`, cumulative offsets, re-measure on hop-resolver finalize) — tracked separately on #1770. ## TDD - **Red commit** `7f58bedc` — adds `test-issue-1770-mobile-row-clamp.js`, a CSS-grep test (same pattern as `test-issue-1364-pill-no-clamp.js`) that walks every `@media (max-width: 640px)` block in `public/style.css` and asserts a `.col-details` rule declares `white-space: nowrap`, `overflow: hidden`, and `text-overflow: ellipsis`. Verified to FAIL on master (assertion failure, not a parse error) and PASS after the CSS change. - **Green commit** `d46271b8` — applies the 5-line CSS clamp inside the existing mobile breakpoint at L2362. ## Files touched - `public/style.css` (+13) - `test-issue-1770-mobile-row-clamp.js` (+101, new) ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` → all gates pass (PII, branch scope, red commit, css-vars, css self-fallback, LIKE-on-JSON, sync migration, async-migration, XSS). No warnings. --------- Co-authored-by: clawbot <bot@clawbot.local> Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
b3189c613a |
fix(#1802): decode CONTROL DISCOVER_REQ/RESP subtype + body fields (#1806)
## Summary Extend CONTROL packet decoding to surface DISCOVER_REQ / DISCOVER_RESP subtype plus body fields in the packet detail view. Previously only the byte0 zero-hop flag was decoded; the body was rendered as opaque hex. ## What changed **Backend** — `cmd/ingestor/decoder.go` `decodeControl()` - New `Payload` fields (all omitempty): `CtrlSubtype`, `CtrlFilter`, `CtrlTag`, `CtrlSince`, `CtrlNodeType`, `CtrlSNR`, `CtrlPubKey`. - Subtype derived from `byte0 & 0xF0`: `0x80` → `DISCOVER_REQ`, `0x90` → `DISCOVER_RESP`, otherwise `UNKNOWN`. - REQ body parsed when `len(buf) >= 6`: `filter:u8 | tag:u32 LE`, plus optional `since:u32 LE` when 4 more bytes remain. - RESP body parsed when `len(buf) >= 6`: `node_type` (low nibble of byte0), `snr:i8`, `tag:u32 LE`, and `pubkey` hex — 32 bytes when full, 8 bytes when prefix-only. - Every field gated on length; short/truncated bodies emit subtype only and never panic. - `CtrlZeroHop` retained for backwards compatibility (rename flagged for follow-up per triage). **Frontend** — `public/packets.js` `getDetailPreview()` - New `decoded.type === 'CONTROL'` branch renders subtype + present body fields (filter / tag / since / node_type / snr / pubkey). Each field shown only when populated, so truncated CONTROL still gets a subtype label. ## Wire format reference - `firmware/src/Mesh.cpp:69` — `CTL_TYPE_NODE_DISCOVER_REQ=0x80`, `CTL_TYPE_NODE_DISCOVER_RESP=0x90`. - `firmware/examples/simple_repeater/MyMesh.cpp:773-820` — body parse / build. ## Tests (red → green, per AGENTS.md STRICT TDD) - `cmd/ingestor/issue1802_test.go` — 6 cases: REQ full body (with since), REQ no-since, RESP 32B pubkey, RESP 8B prefix pubkey, RESP truncated pubkey (no panic, no pubkey emitted), short body (subtype only), unknown subtype. Red commit `43713d3a` → green commit `d4b28180`. Pre-existing CONTROL tests (`TestDecodeControlZeroHop`, `TestDecodeControlMultiHop`) still pass. - `test-packets.js` — 3 cases on `getDetailPreview`: DISCOVER_REQ (filter+tag rendered), DISCOVER_RESP (snr+pubkey rendered), UNKNOWN subtype label. Red commit `be23e349` → green commit `845d6c48`. ## Preflight overrides - `check-branch-clean` (cross-stack): justified — issue #1802 explicitly spans backend decoder (`cmd/ingestor/decoder.go`) and frontend renderer (`public/packets.js`) per triage comment. Tests in both layers. Single-purpose PR. ## Scope discipline Files touched: `cmd/ingestor/decoder.go`, `cmd/ingestor/issue1802_test.go`, `public/packets.js`, `test-packets.js`. No other files. No firmware changes. No `cmd/server/decoder.go` changes. No `CtrlZeroHop` rename (deferred per triage). Fixes #1802 --------- Co-authored-by: clawbot <bot@meshcore.local> |
||
|
|
120ac052d3 |
fix(packets): add Multipart/Control/Raw Custom to type filter checklist (#1798) (#1803)
## Summary Fixes #1798. Extends the Packets-page `typeMap` in `public/packets.js` to include three firmware payload types that were previously missing from the multi-select checklist: - `10` — Multipart - `11` — Control - `15` — Raw Custom Other surfaces (`public/packet-filter.js` `FW_PAYLOAD_TYPES`, `public/live.js` `TYPE_COLORS`, `public/map.js`) already knew about these types; only the Packets-page checklist UI omitted them, forcing operators to hand-type filter expressions to filter on them. ## Red → green - Red commit: `359e3645ac41506e563c19dfbd49983fb4ec9638` — adds E2E that opens `#typeMenu` and asserts each new `data-type-id="10|11|15"` checkbox renders with the exact label. Fails on assertion (DOM selectors return null) against the pre-fix `typeMap`. - Green commit: `f484e8cb88659b14fed7aa7fefcdfb3f0eb6c186` — single-line literal extension; test goes green. ## E2E assertion added `test-e2e-playwright.js:576` — `Packets type filter includes Multipart/Control/Raw Custom (#1798)` (asserts the three new `data-type-id` checkboxes render with their exact labels in the rendered Packets-page checklist DOM). ## Files touched - `public/packets.js` — extend `typeMap` literal - `test-e2e-playwright.js` — new E2E test asserting the three checkboxes render ## Browser verified E2E test scrapes the rendered Packets-page DOM via Playwright; CI runs it against the local Go server fixture in the `e2e-test` job. Fixes #1798 --------- Co-authored-by: openclaw-bot <bot@openclaw.local> |
||
|
|
3efa37c46c |
feat(server): complete the #672 4-axis repeater usefulness score (#1762)
Adds Coverage (harmonic reach) + Redundancy (Tarjan articulation) axes + composite & grade. Closes #672. **TDD note (BLOCKER-1):** Community PR delivered as a single squashed commit, so there is no separate pre-fix failing-test commit — please accept as a community-PR exemption. The tests are *gating*, not just thorough: each axis test pins a specific topology outcome (coverage on line/star/disconnected/weight-sensitive; redundancy online/triangle/star/bridged-cliques), and an end-to-end `/api/nodes` surface test drives the whole pipeline and asserts the composite diverges from the Traffic axis. Inverting the `1/weight` distance, dropping the NaN/Inf reject, removing the `redundancyMinWeight` floor, or aliasing `usefulness_score` back onto `traffic_share_score` each break a specific assertion. The axis functions are pure (no hidden state), so the suite fully characterises the behavior without the red anchor. Co-authored-by: Waydroid Builder <build@waydroid.local> |