mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 16:54:58 +00:00
352967ca37fd5e2a1122dd652fa1e9043fa0a90a
1220 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
352967ca37 |
fix: address all 6 items from independent re-review
1. Hoist getCachedNodesAndPM() before observation loop in IngestNewObservations 2. Include resolved_path in IngestNewObservations broadcast maps 3. Persist resolved paths and edges to SQLite in IngestNewObservations 4. Add error logging for stmt.Exec in backfillResolvedPaths 5. Add error logging for stmt.Exec in buildAndPersistEdges 6. Remove tautological TestNeighborPersistFileCompiles test |
||
|
|
232c637f44 |
fix: address all PR #556 review feedback
1. Hoist getCachedNodesAndPM() before observation loop in IngestNewFromDB 2. Resolve paths for late-arriving observations in IngestNewObservations 3. Extract shared extractEdgesFromObs() helper (DRY — was duplicated 3x) 4. Add error checking in async persistence goroutine (log first error) 5. Read graph ref under lock in IngestNewFromDB persistence block 6. Document graph lifecycle in backfillResolvedPaths 7. Add comment explaining why IngestNewFromDB SQL omits resolved_path 8. Confirm parseTimestamp handles empty strings safely (returns zero time) 9. TestPersistEdge now verifies last_seen is updated to later timestamp 10. Document startup ordering for ensureResolvedPathColumn vs detectSchema |
||
|
|
5ea30d7eb7 |
fix: move SQL writes and path resolution out of store mutex in backfillResolvedPaths
- Collect immutable snapshots (DecodedJSON, PayloadType) under RLock instead of holding StoreTx pointers that could be evicted between unlock/lock - Resolve paths outside the lock (pm and graph are read-only) - Persist to SQLite without holding any lock (separate RW connection) - Only take write lock for in-memory updates at the end - Remove tautological TestNeighborPersistFileCompiles (compilation already validates symbol accessibility) - Remove unnecessary init() with os.MkdirAll on TempDir |
||
|
|
1f84b8e477 |
feat: server-side hop resolution at ingest — resolved_path (#555)
Implements server-side hop prefix resolution at ingest time with a persisted neighbor graph, replacing client-side HopResolver for path resolution. ## Changes ### M1: Persisted neighbor graph (neighbor_edges table) - New SQLite table neighbor_edges (node_a, node_b, count, last_seen) - Load from SQLite on startup → build in-memory NeighborGraph - First-run backfill: scan all packets, extract edges per ADVERT/non-ADVERT rules - Incremental edge upserts during ingest (both in-memory and SQLite) ### M2: resolved_path column on observations - ALTER TABLE observations ADD COLUMN resolved_path TEXT - Resolve hop prefixes at ingest using resolveWithContext with 4-tier priority (affinity → geo → GPS → first match) - Store as JSON array of full 64-char lowercase hex pubkeys (null for unresolved) - Cold startup backfill for observations without resolved_path - ResolvedPath field added to StoreObs and StoreTx structs - Propagated through pickBestObservation to transmission level ### M3: API and WebSocket broadcast - resolved_path included in all packet/observation API responses (omitempty) - Included in WebSocket broadcast messages per observation - TransmissionResp and ObservationResp types updated ### Call site migration - All 7 pm.resolve() call sites migrated to pm.resolveWithContext() with the persisted graph (store.go: distance index, topology, subpaths, subpath detail; routes.go: node paths) - pm.resolve() retained for test compatibility but no longer used in prod ### Schema compatibility - DB.hasResolvedPath flag detects column presence at startup - SQL queries dynamically include/exclude resolved_path column - Graceful degradation when column doesn't exist (tests, old DBs) Fixes #555 |
||
|
|
ddce26ff2d | ci: pin build and deploy jobs to meshcore-vm runner | ||
|
|
ee29cc627f |
perf: parallelize expanded group fetches, use hashIndex Map lookup (#552)
## Summary Fixes #388 — expanded groups were fetched sequentially with O(n) `packets.find()` lookups. ## Changes 1. **Parallel fetch**: Replaced sequential `for...of + await` loop in `loadPackets()` with `Promise.all()` so all expanded group children are fetched concurrently. 2. **O(1) Map lookup**: Replaced 3 instances of `packets.find(p => p.hash === hash)` with `hashIndex.get(hash)`: - `loadPackets()` expanded group restore (~line 553) - `select-observation` click handler (~line 1015) - `pktToggleGroup()` (~line 2012) ## Perf justification - **Before**: N expanded groups → N sequential API calls + N × O(packets.length) array scans - **After**: N parallel API calls + N × O(1) Map lookups - Typical N is 1-3 (minor severity as noted in issue), but the fix is trivial and correct ## Tests All existing tests pass: `test-packet-filter.js` (62), `test-aging.js` (29), `test-frontend-helpers.js` (433). Co-authored-by: you <you@example.com> |
||
|
|
f3caf42be4 |
feat: show transport badge in live packet feed (#551)
## Summary
Show the transport badge ("T") in the live packet feed, matching the
packets table (#337).
## Changes
- Add `transportBadge(pkt.route_type)` to all 4 feed rendering paths in
`live.js`:
- Grouped feed items (initial history load)
- `addFeedItemDOM()` (VCR replay)
- Dedup new feed items (live WebSocket updates)
- Node detail panel recent packets list
- Uses existing `transportBadge()` from `app.js` and `.badge-transport`
CSS from `style.css`
## Testing
- 2 new source-level assertions in `test-live.js` verifying
`transportBadge()` calls exist
- All existing tests pass (67 passed in test-live.js, no new failures)
Fixes #338
Co-authored-by: you <you@example.com>
|
||
|
|
c34744247a |
fix: clean up nodeActivity in pruneStaleNodes to prevent memory leak (#553)
## Summary `nodeActivity` (an object tracking per-node packet counts for heatmap intensity) grows without bound — entries are added on every packet flash but never removed, even when stale nodes are pruned. ## Changes - **Delete `nodeActivity[key]`** alongside `nodeMarkers[key]` and `nodeData[key]` when removing stale WS-only nodes in `pruneStaleNodes()` - **Prune orphaned entries** — after the main prune loop, sweep `nodeActivity` and delete any key that has no corresponding `nodeData` entry (catches edge cases where nodes were removed by other code paths) - Both run every 60s via the existing `pruneStaleNodes` interval timer ## Testing - Added 2 regression tests in `test-frontend-helpers.js` verifying stale node cleanup and orphan removal - All 435 frontend helper tests pass, plus packet-filter (62) and aging (29) Fixes #390 --------- Co-authored-by: you <you@example.com> |
||
|
|
10f712f9d7 |
fix: restructure scroll containers for iOS status bar tap-to-scroll (#330) (#554)
## Summary Fixes #330 — iOS status bar tap-to-scroll broken because `#app` had `overflow: hidden`, preventing `<body>` from being the scroll container. ## Approach: Option B from the issue Instead of a JS polyfill, this restructures scroll containers so `<body>` is the primary scroll container by default, which iOS Safari requires for native status-bar tap-to-scroll. ### How it works **`#app` default (body-scroll mode):** Uses `min-height` instead of fixed `height`, no `overflow: hidden`. Content pushes beyond the viewport and body scrolls naturally. **`#app.app-fixed` (fixed-layout mode):** Restores the original `height: calc(100dvh - 52px); overflow: hidden` for pages that need constrained containers. The router in `app.js` toggles this class based on the current page. ### Fixed-layout pages (`.app-fixed`) These pages need fixed-height containers and are unchanged in behavior: - **packets** — virtual scroll requires fixed-height `.panel-left` to calculate visible rows - **nodes** — split-panel layout with independently scrollable panels - **map** — Leaflet requires fixed-dimension container - **live** — Leaflet map (also has its own `#app:has(.live-page)` override in live.css) - **channels** — split-panel chat layout - **audio-lab** — split-panel layout ### Body-scroll pages (no `.app-fixed`) These pages now let the body scroll, enabling iOS tap-to-scroll: - **analytics** — removed `overflow-y: auto; height: 100%` - **observers** — removed `overflow-y: auto; height: calc(100vh - 56px)` - **traces** — removed `overflow-y: auto; height: 100%` - **home** — removed `#app:has(.home-hero)` override (no longer needed) - **compare** — removed inline `overflow-y:auto; height:calc(100vh - 56px)` - **perf** — removed inline `height:100%; overflow-y:auto` - **observer-detail** — removed inline `overflow-y:auto; height:calc(100vh - 56px)` - **node-analytics** — removed inline `height:100%; overflow-y:auto` ### Files changed | File | Change | |------|--------| | `public/style.css` | `#app` default → `min-height`; added `.app-fixed` class | | `public/app.js` | Router toggles `.app-fixed` based on page | | `public/home.css` | Removed `#app:has()` workaround | | `public/compare.js` | Removed inline overflow/height | | `public/perf.js` | Removed inline overflow/height | | `public/observer-detail.js` | Removed inline overflow/height | | `public/node-analytics.js` | Removed inline overflow/height | ### What's preserved - Sticky nav (`position: sticky; top: 0`) — works with body scroll - Split-panel resize handles — unchanged, still in fixed containers - Virtual scroll on packets page — unchanged, `.panel-left` still has fixed height - Leaflet maps — unchanged, containers still have fixed dimensions - Mobile responsive overrides — unchanged Co-authored-by: you <you@example.com> |
||
|
|
412a8fdb8f |
feat: live map uses affinity-aware hop resolution (#528) (#550)
## Summary Augments the shared `HopResolver` with neighbor-graph affinity data so that when multiple nodes match a hop prefix, the resolver prefers candidates that are known neighbors of the adjacent hop — instead of relying solely on geo-distance. Fixes #528 ## Changes ### `public/hop-resolver.js` - Added `affinityMap` — stores bidirectional neighbor adjacency with scores - Added `setAffinity(graph)` — ingests `/api/analytics/neighbor-graph` edge data into O(1) Map lookups - Added `getAffinity(pubkeyA, pubkeyB)` — returns affinity score between two nodes (0 if not neighbors) - Added `pickByAffinity(candidates, adjacentPubkey, anchor, ...)` — picks best candidate: affinity-neighbor first (highest score), then geo-distance fallback - Modified forward and backward passes in `resolve()` to track the previously-resolved pubkey and use `pickByAffinity` instead of raw geo-sort ### `public/live.js` - Added `fetchAffinityData()` — fetches `/api/analytics/neighbor-graph` once and calls `HopResolver.setAffinity()` - Added `startAffinityRefresh()` — refreshes affinity data every 60 seconds - Both are called from `loadNodes()` after HopResolver is initialized ### `test-hop-resolver-affinity.js` (new) - Affinity prefers neighbor candidate over geo-closest - Cold start (no affinity data) falls back to geo-closest - Null/undefined affinity doesn't crash - Bidirectional score lookup - Highest affinity score wins among multiple neighbors - Unambiguous hops unaffected by affinity ## Performance - API calls: 1 at load + 1 per 60s (no per-packet calls) - Per-packet resolve: O(1) Map lookups, <0.5ms - Memory: ~50KB for 2K-node graph --------- Co-authored-by: you <you@example.com> |
||
|
|
9a39198d92 |
fix: only count repeaters in hash collision analysis (#441) (#548)
Fixes #441 ## Summary Hash collision analysis was including ALL node types, inflating collision counts with irrelevant data. Per MeshCore firmware analysis, **only repeaters matter for collision analysis** — they're the only role that forwards packets and appears in routing `path[]` arrays. ## Root Causes Fixed 1. **`hash_size==0` nodes counted in all buckets** — nodes with unknown hash size were included via `cn.HashSize == bytes || cn.HashSize == 0`, polluting every bucket 2. **Non-repeater roles included** — companions, rooms, sensors, and observers were counted even though their hash collisions never cause routing ambiguity ## Fix Changed `computeHashCollisions()` filter from: ```go // Before: include everything except companions if cn.HashSize == bytes && cn.Role != "companion" { ``` To: ```go // After: only include repeaters (per firmware analysis) if cn.HashSize == bytes && cn.Role == "repeater" { ``` ## Why only repeaters? From [MeshCore firmware analysis](https://github.com/Kpa-clawbot/CoreScope/issues/441#issuecomment-4185218547): - Only repeaters override `allowPacketForward()` to return `true` - Only repeaters append their hash to `path[]` during relay - Companions, rooms, sensors, observers never forward packets - Cross-role collisions are benign (companion silently drops, real repeater still forwards) ## Tests - `TestHashCollisionsOnlyRepeaters` — verifies companions, rooms, sensors, and hash_size==0 nodes are all excluded --------- Co-authored-by: you <you@example.com> |
||
|
|
526ea8a1fc |
perf(live): chunk VCR replay packet processing to avoid UI freezes (#549)
## Summary VCR replay functions (`vcrReplayFromTs`, `vcrRewind`, `fetchNextReplayPage`) fetch up to 10K packets and process them all synchronously on the main thread via `expandToBufferEntries`, causing multi-second UI freezes — especially on mobile. ## Fix - Added `expandToBufferEntriesAsync()` — processes packets in chunks of 200, yielding to the event loop via `setTimeout(0)` between chunks - Updated all three VCR replay callers to use the async variant - Kept the synchronous `expandToBufferEntries()` for backward compatibility (tests, small datasets) - Exposed `_liveExpandToBufferEntriesAsync` on window for test access ## Perf justification - **Before:** 10K packets × ~2 observations = 20K+ objects created synchronously, blocking the main thread for 1-3 seconds on mobile - **After:** Same work split into chunks of 200 packets (~400 entries) with event loop yields between chunks. Each chunk takes <5ms, keeping the UI responsive (well under the 16ms frame budget) - Chunk size of 200 is tunable via `VCR_CHUNK_SIZE` ## Tests - Added regression test: sync expand correctness at scale (500 packets → 1000 entries) - Added structural test: verifies `VCR_CHUNK_SIZE` exists and async function yields via `setTimeout` - All existing tests pass (`npm test`) Fixes #395 --------- Co-authored-by: you <you@example.com> |
||
|
|
8e42febc9c |
fix: virtual scroll height accounts for expanded group rows (#410) (#547)
## Summary Fixes #410 — virtual scroll height miscalculation for expanded group rows. ## Root Cause When WebSocket messages add children to an already-expanded packet group, `_rowCounts` becomes stale during the 200ms render debounce window. Scroll events during this window call `renderVisibleRows()` with stale row counts, causing wrong total height, spacer heights, and visible range calculations. ## Changes **public/packets.js:** - Added `_rowCountsDirty` flag to track when row counts need recomputation - Added `_invalidateRowCounts()` — marks row counts as stale and clears cumulative cache - Added `_refreshRowCountsIfDirty()` — lazily recomputes `_rowCounts` from `_displayPackets` - Called `_invalidateRowCounts()` when WS handler adds children to expanded groups (line ~402) - Called `_refreshRowCountsIfDirty()` at top of `renderVisibleRows()` before using row counts - Reset `_rowCountsDirty` in all cleanup paths (destroy, empty display) **test-packets.js:** - Added 4 regression tests for `_invalidateRowCounts` / `_refreshRowCountsIfDirty` ## Complexity O(n) recomputation of `_rowCounts` when dirty (same as existing `renderTableRows` path). Only triggers when WS modifies expanded group children, which is infrequent relative to scroll events. Co-authored-by: you <you@example.com> |
||
|
|
59bff5462c |
fix: rate-limit cache invalidation to prevent 0% hit rate (#533) (#546)
## Summary Fixes #533 — server cache hit rate always 0%. ## Root Cause `invalidateCachesFor()` is called at the end of every `IngestNewFromDB()` and `IngestNewObservations()` cycle (~2-5s). Since new data arrives continuously, caches are cleared faster than any analytics request can hit them, resulting in a permanent 0% cache hit rate. The cache TTL (15s/60s) is irrelevant because entries are evicted by invalidation long before they expire. ## Fix Rate-limit cache invalidation with a 10-second cooldown: - First call after cooldown goes through immediately - Subsequent calls during cooldown accumulate dirty flags in `pendingInv` - Next call after cooldown merges pending + current flags and applies them - Eviction bypasses cooldown (data removal requires immediate clearing) Analytics data may be at most ~10s stale, which is acceptable for a dashboard. ## Changes - **`store.go`**: Added `lastInvalidated`, `pendingInv`, `invCooldown` fields. Refactored `invalidateCachesFor()` to rate-limit non-eviction invalidation. Extracted `applyCacheInvalidation()` helper. - **`cache_invalidation_test.go`**: Added 4 new tests: - `TestInvalidationRateLimited` — verifies caches survive during cooldown - `TestInvalidationCooldownAccumulatesFlags` — verifies flag merging - `TestEvictionBypassesCooldown` — verifies eviction always clears immediately - `BenchmarkCacheHitDuringIngestion` — confirms 100% hit rate during rapid ingestion (was 0%) ## Perf Proof ``` BenchmarkCacheHitDuringIngestion-16 3467889 1018 ns/op 100.0 hit% ``` Before: 0% hit rate under continuous ingestion. After: 100% hit rate during cooldown periods. Co-authored-by: you <you@example.com> |
||
|
|
8c1cd8a9fe |
perf: track advert pubkeys incrementally, eliminate per-request JSON parsing (#360) (#544)
## Summary `GetPerfStoreStats()` and `GetPerfStoreStatsTyped()` iterated **all** ADVERT packets and called `json.Unmarshal` on each one — under a read lock — on every `/api/perf` and `/api/health` request. With 5K+ adverts, each health check triggered thousands of JSON parses. ## Fix Added a refcounted `advertPubkeys map[string]int` to `PacketStore` that tracks distinct pubkeys incrementally during `Load()`, `IngestNewFromDB()`, and eviction. The perf/health handlers now just read `len(s.advertPubkeys)` — O(1) with zero allocations. ## Benchmark Results (5K adverts, 200 distinct pubkeys) | Method | ns/op | allocs/op | |--------|-------|-----------| | `GetPerfStoreStatsTyped` | **78** | **0** | | `GetPerfStoreStats` | **2,565** | **9** | Before this change, both methods performed O(N) JSON unmarshals per call. ## Tests Added - `TestAdvertPubkeyTracking` — verifies incremental tracking through add/evict lifecycle - `TestAdvertPubkeyPublicKeyField` — covers the `public_key` JSON field variant - `TestAdvertPubkeyNonAdvert` — ensures non-ADVERT packets don't affect count - `BenchmarkGetPerfStoreStats` — 5K adverts benchmark - `BenchmarkGetPerfStoreStatsTyped` — 5K adverts benchmark Fixes #360 --------- Co-authored-by: you <you@example.com> |
||
|
|
29e8e37114 |
fix: mobile filter dropdown specificity prevents expansion (#534) (#541)
## Summary Fixes #534 — mobile filter dropdown doesn't expand on packets page. ## Root Cause CSS specificity battle in the mobile media query. The hide rule uses `:not()` pseudo-classes which add specificity: ```css /* Higher specificity due to :not() */ .filter-bar > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: none; } /* Lower specificity — loses even with .filters-expanded */ .filter-bar.filters-expanded > * { display: inline-flex; } ``` The JS toggle correctly adds/removes `.filters-expanded`, but the CSS expanded rule could never win. ## Fix Match the `:not()` selectors in the expanded rule so `.filters-expanded` makes it strictly more specific: ```css .filter-bar.filters-expanded > *:not(.filter-toggle-btn):not(.col-toggle-wrap) { display: inline-flex; } ``` Added a comment explaining the specificity dependency so future devs don't repeat this. ## Tests Added Playwright E2E test: mobile viewport (480×800), navigates to packets page, clicks filter toggle, verifies filter inputs become visible. --------- Co-authored-by: you <you@example.com> |
||
|
|
9b9f396af5 |
perf: replace O(n²) observation dedup with map-based O(n) (#355) (#543)
## Summary Fixes #355 — replaces O(n²) observation dedup in `Load()`, `IngestNewFromDB()`, and `IngestNewObservations()` with an O(1) map-based lookup. ## Changes - Added `obsKeys map[string]bool` field to `StoreTx` for O(1) dedup keyed on `observerID + "|" + pathJSON` - Replaced all 3 linear-scan dedup sites in `store.go` with map lookups - Lazy-init `obsKeys` for transmissions created before this change (in `IngestNewFromDB` and `IngestNewObservations`) - Added regression test (`TestObsDedupCorrectness`) verifying dedup correctness - Added nil-map safety test (`TestObsDedupNilMapSafety`) - Added benchmark comparing map vs linear scan ## Benchmark Results (ARM64, 16 cores) | Observations | Map (O(1)) | Linear (O(n)) | Speedup | |---|---|---|---| | 10 | 34 ns/op | 41 ns/op | 1.2x | | 50 | 34 ns/op | 186 ns/op | 5.5x | | 100 | 34 ns/op | 361 ns/op | 10.6x | | 500 | 34 ns/op | 4,903 ns/op | **146x** | Map lookup is constant time regardless of observation count. The linear scan degrades quadratically — at 500 observations per transmission (realistic for popular packets seen by many observers), the old code is 146x slower per dedup check. All existing tests pass. --------- Co-authored-by: you <you@example.com> |
||
|
|
b472c8de30 |
perf: replace O(n²) selection sort with sort.Slice (#354) (#542)
## Summary Fixes #354 Replaces the O(n²) selection sort in `sortedCopy()` with Go's built-in `sort.Float64s()` (O(n log n)). ## Changes - **`cmd/server/routes.go`**: Replaced manual nested-loop selection sort with `sort.Float64s(cp)` - **`cmd/server/helpers_test.go`**: Added regression test with 1000-element random input + benchmark ## Benchmark Results (ARM64) ``` BenchmarkSortedCopy/n=256 ~16μs/op 1 alloc BenchmarkSortedCopy/n=1000 ~95μs/op 1 alloc BenchmarkSortedCopy/n=10000 ~1.3ms/op 1 alloc ``` With the old O(n²) sort, n=10000 would take ~50ms+. The new implementation scales as O(n log n). ## Testing - All existing `TestSortedCopy` tests pass (unchanged behavior) - New `TestSortedCopyLarge` validates correctness on 1000 random elements - `go test ./...` passes in `cmd/server` Co-authored-by: you <you@example.com> |
||
|
|
03e384bbc4 |
fix: null guard on pathHops prevents crash on ADVERT detail (#538) (#540)
## Summary Fixes #538 — `null is not an object (evaluating 'pathHops.length')` crash on ADVERT packet detail. ## Root Cause `getParsedPath` caches its result as `p._parsedPath`. If another code path (e.g., object spread, API response) sets `_parsedPath = null`, the cache check (`!== undefined`) passes and returns `null` — causing `.length` to crash. Same pattern exists for `getParsedDecoded`. ## Changes ### `public/packet-helpers.js` - `getParsedPath`: cached return now uses `|| []` to guard against null cache - `getParsedDecoded`: cached return now uses `|| {}` to guard against null cache ### `public/packets.js` - `renderDetail()` (line ~1440): defensive `|| []` / `|| {}` on getParsedPath/getParsedDecoded calls - `buildFlatRowHtml()` (line ~1103): same defensive guards ### `test-frontend-helpers.js` - Added test: cached `_parsedPath = null` returns `[]` - Added test: cached `_parsedDecoded = null` returns `{}` ## Testing All 428 frontend helper tests pass. All 62 packet filter tests pass. Co-authored-by: you <you@example.com> |
||
|
|
bf8c9e72ec |
fix: observer filter checks all observations in grouped mode (#537) (#539)
Fixes #537 ## Problem Observer filter in grouped mode only checked `p.observer_id` (the primary observer), ignoring child observations. Grouped packets seen by multiple observers would be hidden when filtering for a non-primary observer. ## Fix Two filter paths updated to also check `p._children`: 1. **Client-side display filter** (line ~1293): removed the `!groupByHash` guard and added `_children` check so grouped packets are included when any child observation matches 2. **WS real-time filter** (line ~360): added `_children` fallback check The grouped row rendering (line ~1042) already correctly uses `_observerFilterSet` for child filtering — no changes needed there. ## Tests Added 5 tests in `test-frontend-helpers.js`: - Grouped packet with matching child observer is shown - Grouped packet with no matching observers is hidden - WS filter passes/rejects grouped packets correctly - Source code assertions verifying both filter paths check `_children` Co-authored-by: you <you@example.com> |
||
|
|
48923db3d0 |
Add deep linking rule to AGENTS.md (#535)
Adds a rule to AGENTS.md requiring all new UI states to be URL-addressable (deep-linkable). Part of #536. Co-authored-by: you <you@example.com> |
||
|
|
709e5a4776 |
fix: observer filter drops groups in grouped packets view (#464) (#531)
## Summary - When `groupByHash=true`, each group only carries its representative (best-path) `observer_id`. The client-side filter was checking only that field, silently dropping groups that were seen by the selected observer but had a different representative. - `loadPackets` now passes the `observer` param to the server so `filterPackets`/`buildGroupedWhere` do the correct "any observation matches" check. - Client-side observer filter in `renderTableRows` is skipped for grouped mode (server already filtered correctly). - Both `db.go` and `store.go` observer filtering extended to support comma-separated IDs (multi-select UI). ## Test plan - [ ] Set an observer filter on the Packets screen with grouping enabled — all groups that have **any** observation from the selected observer(s) should appear, not just groups where that observer is the representative - [ ] Multi-select two observers — groups seen by either should appear - [ ] Toggle to flat (ungrouped) mode — per-observation filter still works correctly - [ ] Existing grouped packets tests pass: `cd cmd/server && go test ./...` Fixes #464 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: you <you@example.com> |
||
|
|
9099154514 | docs: add v3.4 release notes v3.4.0 | ||
|
|
924caaa680 |
fix: render both steps AND FAQ on home page (#525) (#529)
Fixes #525 The `checklist()` function in `home.js` treated steps and FAQ/checklist as mutually exclusive — if `homeCfg.checklist` existed, steps were skipped entirely. Adding a single FAQ via the customizer made all intro steps disappear. Now renders steps first, then FAQ below with a '❓ FAQ' header. Falls back to Bay Area hardcoded defaults only when neither exists. --------- Co-authored-by: you <you@example.com> |
||
|
|
ca95fc46aa |
fix: neighbor UI — show neighbors crash, dark mode contrast (#523) (#527)
## Summary Part of #523 — fixes bugs 5 and 7 (bug 6 was a duplicate of bug 7). ### Bug 5: Show Neighbors button throws `window._mapSelectRefNode is not a function` **Root cause:** Map popup HTML used inline `onclick` calling `window._mapSelectRefNode`, which was deleted on SPA page destroy. If a popup persisted after navigation, clicks would throw. **Fix:** Replaced inline `onclick` with event delegation. A document-level click handler catches all `[data-show-neighbors]` clicks and calls `selectReferenceNode` directly. The global `window._mapSelectRefNode` is still exposed for existing Playwright tests but is no longer relied upon by the UI. ### Bug 7: Blue text on dark blue background (dark mode contrast) **Root cause:** Neighbor table cells inside `.node-detail-section` / `.node-full-card` inherited accent/link color instead of using `var(--text)`, making text unreadable in dark mode. **Fix:** Added explicit `color: var(--text)` on `.node-detail-section .data-table td` and `.node-full-card .data-table td`. Only `<a>` tags within those cells retain `color: var(--accent)`. ### Files changed - `public/map.js` — event delegation for Show Neighbors - `public/style.css` — contrast fix for neighbor table cells --------- Co-authored-by: you <you@example.com> |
||
|
|
54fab0551e |
fix: add home defaults to server theme config (#525) (#526)
## Summary Fixes #525 — Customizer v2 home section shows empty fields and adding FAQ kills steps. ## Root Cause Server returned `home: null` from `/api/config/theme` when no home config existed in config.json or theme.json. The customizer had no built-in defaults, so all home fields appeared empty. When a user added a single override (e.g. FAQ), `computeEffective` started from `home: null`, created `home: {}`, and only applied the user's override — wiping steps and everything else. ## Fix ### Server-side (primary) In `handleConfigTheme()`, replaced the conditional `home` assignment with `mergeMap` using built-in defaults matching what `home.js` hardcodes: - `heroTitle`: "CoreScope" - `heroSubtitle`: "Real-time MeshCore LoRa mesh network analyzer" - `steps`: 4 default getting-started steps - `footerLinks`: Packets + Network Map links Config/theme overrides merge on top, so customization still works. ### Client-side (defense-in-depth) Added `DEFAULT_HOME` constant in `customize-v2.js`. `computeEffective()` now falls back to these defaults when server returns `home: null`, ensuring the customizer works even without server defaults. ## Tests - **Go**: `TestConfigThemeHomeDefaults` — verifies `/api/config/theme` returns non-null home with heroTitle, steps, footerLinks when no config is set - **JS**: Two new tests in `test-frontend-helpers.js` — verifies `computeEffective` provides defaults when home is null, and that user overrides merge correctly with defaults Co-authored-by: you <you@example.com> |
||
|
|
0e1beac52f |
fix: neighbor affinity graph empty results + performance + accessibility (#523) (#524)
## Summary Fixes the neighbor affinity graph returning empty results despite abundant ADVERT data in the store. **Root cause:** `extractFromNode()` in `neighbor_graph.go` only checked for `"from_node"` and `"from"` fields in the decoded JSON, but real ADVERT packets store the originator public key as `"pubKey"`. This meant `fromNode` was always empty, so: - Zero-hop edges (originator↔observer) were never created - Originator↔path[0] edges were never created - Only observer↔path[last] edges could be created (and only for non-empty paths) **Fix:** Check `"pubKey"` first in `extractFromNode()`, then fall through to `"from_node"` and `"from"` for other packet types. ## Bugs Fixed | Bug | Issue | Fix | |-----|-------|-----| | Empty graph results | #522 | `extractFromNode()` now reads `pubKey` field from ADVERTs | | 3-4s response time | #523 comment | Graph was rebuilding correctly with 60s TTL cache — the slow response was due to iterating all packets finding zero matches. With edges now being found, the cache works as designed. | | Incomplete visualization | #523 comment | Downstream of bug 1+2 — fixed by fixing the builder | | Accessibility | #523 comment | Added text-based neighbor list, dynamic aria-label, keyboard focus CSS, dashed lines for ambiguous edges, confidence symbols | ## Changes - **`cmd/server/neighbor_graph.go`** — Fixed `extractFromNode()` to check `pubKey` field (real ADVERT format) - **`cmd/server/neighbor_graph_test.go`** — Added 2 new tests: `TestBuildNeighborGraph_AdvertPubKeyField` (real ADVERT format) and `TestBuildNeighborGraph_OneByteHashPrefixes` (1-byte prefix collision scenario) - **`public/analytics.js`** — Added accessible text-based neighbor list, dynamic aria-label, dashed line pattern for ambiguous edges - **`public/style.css`** — Added `:focus-visible` keyboard focus indicator for canvas ## Testing All Go tests pass (`go test ./... -count=1`). New tests verify the fix prevents regression. Fixes #523, Fixes #522 --------- Co-authored-by: you <you@example.com> |
||
|
|
34489e0446 |
fix: customizer v2 — phantom overrides, missing defaults, stale dark mode (#518) (#520)
Fixes #518, Fixes #514, Fixes #515, Fixes #516 ## Summary Fixes all customizer v2 bugs from the consolidated tracker (#518). Both server and client changes. ## Server Changes (`routes.go`) - **typeColors defaults** — added all 10 type color defaults matching `roles.js` `TYPE_COLORS`. Previously returned `{}`, causing all type colors to render as black. - **themeDark defaults** — added 22 dark mode color defaults matching the Default preset. Previously returned `{}`, causing dark mode to have no server-side defaults. ## Client Changes (`customize-v2.js`) - [x] **P0: Phantom override cleanup on init** — new `_cleanPhantomOverrides()` runs on startup, scanning `cs-theme-overrides` and removing any values that match server defaults (arrays via `JSON.stringify`, scalars via `===`). - [x] **P1: `setOverride` auto-prunes matching defaults** — after debounced write, iterates the delta and removes any key whose value matches the server default. Prevents phantom overrides from accumulating. - [x] **P1: `_countOverrides` counts only real diffs** — now iterates keys and calls `_isOverridden()` instead of blindly counting `Object.keys().length`. Badge count reflects actual overrides only. - [x] **P1: `_isOverridden` handles arrays/objects** — uses `JSON.stringify` comparison for non-scalar values (home.steps, home.checklist, etc.). - [x] **P1: Type color fallback** — `_renderNodes()` falls back to `window.TYPE_COLORS` when effective typeColors are empty, preventing black color swatches. - [x] **P1: Dark/light toggle re-renders panel** — MutationObserver on `data-theme` now calls `_refreshPanel()` when panel is open, so switching modes updates the Theme tab immediately. ## Tests 6 new unit tests added to `test-customizer-v2.js`: - Phantom scalar overrides cleaned on init - Phantom array overrides cleaned on init - Real overrides preserved after cleanup - `isOverridden` handles matching arrays (returns false) - `isOverridden` handles differing arrays (returns true) - `setOverride` prunes value matching server default All 48 tests pass. Go tests pass. --------- Co-authored-by: you <you@example.com> |
||
|
|
58f791266d |
feat: affinity debugging tools (#482) — milestone 6 (#521)
## Summary Milestone 6 of #482: Observability & Debugging tools for the neighbor affinity system. These tools exist because someone will need them at 3 AM when "Show Neighbors is showing the wrong node for C0DE" and they have 5 minutes to diagnose it. ## Changes ### 1. Debug API — `GET /api/debug/affinity` - Full graph state dump: all edges with weights, observation counts, last-seen timestamps - Per-prefix resolution log with disambiguation reasoning (Jaccard scores, ratios, thresholds) - Query params: `?prefix=C0DE` filter to specific prefix, `?node=<pubkey>` for specific node's edges - Protected by API key (same auth as `/api/admin/prune`) - Response includes: edge count, node count, cache age, last rebuild time ### 2. Debug Overlay on Map - Toggle-able checkbox "🔍 Affinity Debug" in map controls - Draws lines between nodes showing affinity edges with color coding: - Green = high confidence (score ≥ 0.6) - Yellow = medium (0.3–0.6) - Red = ambiguous (< 0.3) - Line thickness proportional to weight, dashed for ambiguous - Unresolved prefixes shown as ❓ markers - Click edge → popup with observation count, last seen, score, observers - Hidden behind `debugAffinity` config flag or `localStorage.setItem('meshcore-affinity-debug', 'true')` ### 3. Per-Node Debug Panel - Expandable "🔍 Affinity Debug" section in node detail page (collapsed by default) - Shows: neighbor edges table with scores, prefix resolutions with reasoning trace - Candidates table with Jaccard scores, highlighting the chosen candidate - Graph-level stats summary ### 4. Server-Side Structured Logging - Integrated into `disambiguate()` — logs every resolution decision during graph build - Format: `[affinity] resolve C0DE: c0dedad4 score=47 Jaccard=0.82 vs c0dedad9 score=3 Jaccard=0.11 → neighbor_affinity (ratio 15.7×)` - Logs ambiguous decisions: `scores too close (12 vs 9, ratio 1.3×) → ambiguous` - Gated by `debugAffinity` config flag ### 5. Dashboard Stats Widget - Added to analytics overview tab when debug mode is enabled - Metrics: total edges/nodes, resolved/ambiguous counts (%), avg confidence, cold-start coverage, cache age, last rebuild ## Files Changed - `cmd/server/neighbor_debug.go` — new: debug API handler, resolution builder, cold-start coverage - `cmd/server/neighbor_debug_test.go` — new: 7 tests for debug API - `cmd/server/neighbor_graph.go` — added structured logging to disambiguate(), `logFn` field, `BuildFromStoreWithLog` - `cmd/server/neighbor_api.go` — pass debug flag through `BuildFromStoreWithLog` - `cmd/server/config.go` — added `DebugAffinity` config field - `cmd/server/routes.go` — registered `/api/debug/affinity` route, exposed `debugAffinity` in client config - `cmd/server/types.go` — added `DebugAffinity` to `ClientConfigResponse` - `public/map.js` — affinity debug overlay layer with edge visualization - `public/nodes.js` — per-node affinity debug panel - `public/analytics.js` — dashboard stats widget - `test-e2e-playwright.js` — 3 Playwright tests for debug UI ## Tests - ✅ 7 Go unit tests (API shape, prefix/node filters, auth, structured logging, cold-start coverage) - ✅ 3 Playwright E2E tests (overlay checkbox, toggle without crash, panel expansion) - ✅ All existing tests pass (`go test ./cmd/server/... -count=1`) Part of #482 --------- Co-authored-by: you <you@example.com> |
||
|
|
9b1b82f29b |
fix: remove merge conflict marker from test-e2e-playwright.js (#519)
Removes a stale `<<<<<<< HEAD` conflict marker that was accidentally left in during the PR #510 rebase. This breaks Playwright E2E tests in CI. One-line fix — line 1311 deletion. Co-authored-by: you <you@example.com> |
||
|
|
943eb69937 |
feat: neighbors section in node detail page (#482) — milestone 5 (#510)
## Summary Add a "Neighbors" section to the node detail page, showing first-hop neighbor relationships derived from the neighbor affinity graph (M2 API). Part of #482 — Milestone 5 per [spec](https://github.com/Kpa-clawbot/CoreScope/blob/spec/482-neighbor-affinity/docs/specs/neighbor-affinity-graph.md). ## What's Added ### Full-screen detail view (`#/nodes/{pubkey}`) - New `node-full-card` section between "Heard By" and "Paths Through This Node" - Table with columns: **Neighbor** (linked), **Role** (badge), **Score**, **Obs**, **Last Seen**, **Conf** (confidence indicator) - Confidence indicators per spec: - 🟢 HIGH: auto-resolved, ≥3 observations, score ≥ 0.5 - 🟡 MEDIUM: 2+ observations - 🔴 LOW: single observation - ⚠️ AMBIGUOUS: multiple candidates - Click neighbor name → navigate to their detail page - 📍 Map button per resolved neighbor row ### Condensed panel view (right panel) - Shows top 5 neighbors only - "View all N neighbors →" link navigates to full detail page with `?section=node-neighbors` ### Deep linking - `?section=node-neighbors` auto-scrolls to the neighbors section (uses existing scroll mechanism) ### Data fetching - `GET /api/nodes/{pubkey}/neighbors` via existing `api()` helper - Cached per-node for 5 minutes (panel lifetime) - Loading spinner, empty state, error state ### States - **Loading**: spinner with "Loading neighbors…" - **Empty**: "No neighbor data available yet. Neighbor relationships are built from observed packet paths over time." - **Error**: "Could not load neighbor data" ## Tests - 2 new Playwright E2E tests: 1. Section exists with correct table columns (or empty state) 2. Loading spinner visible during fetch ## Files Changed - `public/nodes.js` — neighbor section rendering + data fetching helpers - `test-e2e-playwright.js` — 2 new E2E tests --------- Co-authored-by: you <you@example.com> |
||
|
|
15634362c9 |
feat: neighbor graph visualization in analytics (#482) — milestone 7 (#513)
## Summary Adds a **Neighbor Graph** tab to the Analytics page — an interactive force-directed graph visualization of the mesh network's neighbor affinity data. Part of #482 (Milestone 7 — Analytics Graph Visualization) ## What's New ### Neighbor Graph Tab - New "Neighbor Graph" tab in the analytics tab bar - Force-directed graph layout using HTML5 Canvas (vanilla JS, no external libs) - Nodes rendered as circles, colored by role using existing `ROLE_COLORS` - Edges as lines with thickness proportional to affinity score - Ambiguous edges highlighted in yellow ### Interactions - **Click node** → navigates to node detail page (`#/nodes/{pubkey}`) - **Hover node** → tooltip showing name, role, neighbor count - **Drag nodes** → rearrange layout interactively - **Mouse wheel** → zoom in/out (towards cursor position) - **Drag background** → pan the view ### Filters - **Role checkboxes** — toggle repeater, companion, room, sensor visibility - **Minimum score slider** — filter out weak edges (0.00–1.00) - **Confidence filter** — show all / high confidence only / hide ambiguous ### Stats Summary Displays above the graph: total nodes, total edges, average score, resolved %, ambiguous count ### Data Source Uses `GET /api/analytics/neighbor-graph` endpoint from M2, with region filtering via the shared RegionFilter component. ## Performance - Canvas-based rendering (not SVG) for performance with large graphs - Force simulation uses `requestAnimationFrame` with cooling/dampening — stops iterating when layout stabilizes - O(n²) repulsion is acceptable for typical mesh sizes (~500 nodes); for larger meshes, a Barnes-Hut approximation could be added later - Animation frame is properly cleaned up on page destroy ## Tests - Updated tab count assertion (≥10 tabs) - New Playwright test: tab loads, canvas renders, stats shown (≥3 stat cards) - New Playwright test: filter changes update stats ## Files Changed - `public/analytics.js` — new tab + full graph visualization implementation - `test-e2e-playwright.js` — 2 new tests + updated assertion --------- Co-authored-by: you <you@example.com> |
||
|
|
5151030697 |
feat: affinity-aware hop resolution (#482) — milestone 4 (#511)
## Summary Milestone 4 of #482: adds affinity-aware hop resolution to improve disambiguation accuracy across all hop resolution in the app. ### What changed **Backend — `prefixMap.resolveWithContext()` (store.go)** New method that applies a 4-tier disambiguation priority when multiple nodes match a hop prefix: | Priority | Strategy | When it wins | |----------|----------|-------------| | 1 | **Affinity graph score** | Neighbor graph has data, score ratio ≥ 3× runner-up | | 2 | **Geographic proximity** | Context nodes have GPS, pick closest candidate | | 3 | **GPS preference** | At least one candidate has coordinates | | 4 | **First match** | No signal — current naive fallback | The existing `resolve()` method is unchanged for backward compatibility. New callers that have context (originator, observer, adjacent hops) can use `resolveWithContext()` for better results. **API — `handleResolveHops` (routes.go)** Enhanced `/api/resolve-hops` endpoint: - New query params: `from_node`, `observer` — provide context for affinity scoring - New response fields on `HopCandidate`: `affinityScore` (float, 0.0–1.0) - New response fields on `HopResolution`: `bestCandidate` (pubkey when confident), `confidence` (one of `unique_prefix`, `neighbor_affinity`, `ambiguous`) - Backward compatible: without context params, behavior is identical to before (just adds `confidence` field) **Types (types.go)** - `HopCandidate.AffinityScore *float64` - `HopResolution.BestCandidate *string` - `HopResolution.Confidence string` ### Tests - 7 unit tests for `resolveWithContext` covering all 4 priority tiers + edge cases - 2 unit tests for `geoDistApprox` - 4 API tests for enhanced `/api/resolve-hops` response shape - All existing tests pass (no regressions) ### Impact This improves ALL hop resolution across the app — analytics, route display, subpath analysis, and any future feature that resolves hop prefixes. The affinity graph (from M1/M2) now feeds directly into disambiguation decisions. Part of #482 --------- Co-authored-by: you <you@example.com> |
||
|
|
813b424ca1 |
fix: Show Neighbors uses affinity API for collision disambiguation (#484) — milestone 3 (#512)
## Summary
Replace broken client-side path walking in `selectReferenceNode()` with
server-side `/api/nodes/{pubkey}/neighbors` API call, fixing #484 where
Show Neighbors returned zero results due to hash collision
disambiguation failures.
**Fixes #484** | Part of #482
## What changed
### `public/map.js` — `selectReferenceNode()` function
**Before:** Client-side path walking — fetched
`/api/nodes/{pubkey}/paths`, walked each path to find hops adjacent to
the selected node by comparing full pubkeys. This fails on hash
collisions because path hops only contain short prefixes (1-2 bytes),
and the hop resolver can pick the wrong collision candidate.
**After:** Server-side affinity resolution — fetches
`/api/nodes/{pubkey}/neighbors?min_count=3` which uses the neighbor
affinity graph (built in M1/M2) to return disambiguated neighbors. For
ambiguous edges, all candidates are included in the neighbor set (better
to show extra markers than miss real neighbors).
**Fallback:** When the affinity API returns zero neighbors (cold start,
insufficient data), the function falls back to the original path-walking
approach. This ensures the feature works even before the affinity graph
has accumulated enough observations.
## Tests
4 new Playwright E2E tests (in both `test-show-neighbors.js` and
`test-e2e-playwright.js`):
1. **Happy path** — Verifies the `/neighbors` API is called and the
reference node UI activates
2. **Hash collision disambiguation** — Two nodes sharing prefix "C0" get
different neighbor sets via the affinity API (THE critical test for
#484)
3. **Fallback to path walking** — Empty affinity response triggers
fallback to `/paths` API
4. **Ambiguous candidates** — Ambiguous edge candidates are included in
the neighbor set
All tests use Playwright route interception to mock API responses,
testing the frontend logic independently of server state.
## Spec reference
See [neighbor-affinity-graph.md](docs/specs/neighbor-affinity-graph.md),
sections:
- "Replacing Show Neighbors on the map" (lines ~461-504)
- "Milestone 3: Show Neighbors Fix (#484)" (lines ~1136-1152)
- Test specs a & b (lines ~754-800)
---------
Co-authored-by: you <you@example.com>
|
||
|
|
e66085092e |
feat: neighbor affinity API endpoints (#482) — milestone 2 (#508)
## Summary Milestone 2 of the neighbor affinity graph (#482). Adds two API endpoints that expose the neighbor graph built in M1 (PR #507). ### Endpoints #### `GET /api/nodes/{pubkey}/neighbors` Returns neighbors for a specific node with affinity scores. **Query params:** `min_count` (default 1), `min_score` (default 0.0), `include_ambiguous` (default true) **Response shape:** ```json { "node": "pubkey", "neighbors": [ { "pubkey": "...", "prefix": "BB", "name": "...", "role": "repeater", "count": 847, "score": 0.95, "first_seen": "...", "last_seen": "...", "avg_snr": -8.2, "observers": ["obs1"], "ambiguous": false } ], "total_observations": 847 } ``` Ambiguous entries have `candidates` array; unresolved prefixes have `unresolved: true`. #### `GET /api/analytics/neighbor-graph` Returns full graph summary for analytics/visualization. **Query params:** `min_count` (default 5), `min_score` (default 0.1), `region` (IATA code filter) **Response shape:** ```json { "nodes": [{ "pubkey": "...", "name": "...", "role": "...", "neighbor_count": 5 }], "edges": [{ "source": "...", "target": "...", "weight": 847, "score": 0.95, "ambiguous": false }], "stats": { "total_nodes": 42, "total_edges": 87, "ambiguous_edges": 3, "avg_cluster_size": 4.2 } } ``` ### Wiring - `NeighborGraph` + `neighborMu` added to `Server` struct - Lazy initialization: graph built on first API call, cached with 60s TTL - Node name/role lookups via existing `getCachedNodesAndPM()` - Region filtering via existing `resolveRegionObservers()` ### Tests (15 tests) - Empty graph, single neighbor, multiple neighbors (sorted by score) - Ambiguous candidates with candidate list - Unresolved prefix (orphan) with `unresolved: true` - `min_count` filter, `min_score` filter, `include_ambiguous=false` filter - Unknown node returns 200 with empty neighbors - Graph endpoint: empty, with edges, default min_count, ambiguous count - Region filter (graceful when no store) - Response shape validation (all required keys present) All existing tests continue to pass. Part of #482 --------- Co-authored-by: you <you@example.com> |
||
|
|
4a56be0b48 |
feat: neighbor affinity graph builder (#482) — milestone 1 (#507)
## Summary Milestone 1 of 7 for the neighbor affinity graph feature (#482). Implements the core `NeighborGraph` data structure and `BuildFromStore()` algorithm. **Spec:** `docs/specs/neighbor-affinity-graph.md` on `spec/482-neighbor-affinity` branch. ## What's Built ### `cmd/server/neighbor_graph.go` - **`NeighborGraph` struct** — thread-safe (sync.RWMutex) in-memory graph with edge map and per-node index - **`BuildFromStore(*PacketStore)`** — iterates all packets/observations to extract first-hop edges: - `originator ↔ path[0]` for ADVERT packets only (originator identity known) - `observer ↔ path[last]` for ALL packet types - Zero-hop ADVERTs: `originator ↔ observer` direct edge - **Affinity scoring** — `score = min(1.0, count/100) × exp(-λ × hours)` with 7-day half-life - **Jaccard disambiguation** — resolves ambiguous hash prefixes using mutual-neighbor overlap - **Confidence threshold** — auto-resolve only when best ≥ 3× second-best AND ≥ 3 observations - **Transitivity poisoning guard** — only fully-resolved edges used as evidence - **Orphan prefix handling** — unknown prefixes stored as unresolved markers - **Cache management** — 60s TTL, `IsStale()` check for rebuild triggering ### `cmd/server/neighbor_graph_test.go` 22 unit tests covering all spec requirements: | Test | What it validates | |------|-------------------| | EmptyStore | Empty graph from empty store | | AdvertSingleHopPath | Both edge types from single-hop ADVERT | | AdvertMultiHopPath | originator↔path[0] + observer↔path[last] | | AdvertZeroHop | Direct originator↔observer edge | | NonAdvertEmptyPath | No edges from non-ADVERT empty path | | NonAdvertOnlyObserverEdge | Only observer↔last_hop for non-ADVERTs | | NonAdvertSingleHop | observer↔path[0] only | | HashCollision | Ambiguous edge with candidates | | JaccardScoring | Jaccard coefficient computation | | ConfidenceAutoResolve | Auto-resolve when ratio ≥ 3× | | EqualScoresAmbiguous | Remains ambiguous with equal scores | | ObserverSelfEdgeGuard | No self-edges | | OrphanPrefix | Unresolved prefix handling | | AffinityScore_Fresh | Score ≈ 1.0 for fresh high-count | | AffinityScore_Decayed | Score ≈ 0.5 at 7-day half-life | | AffinityScore_LowCount | Score ≈ 0.05 for count=5 | | AffinityScore_StaleAndLow | Score ≈ 0 for old low-count | | CountAccumulation | 5 observations → count=5 | | MultipleObservers | Observer set tracks all witnesses | | TimeDecayOldObservations | Month-old edge scores very low | | ADVERTOnlyConstraint | Non-ADVERTs don't create originator edges | | CacheTTL | Stale detection works correctly | ## Not in scope (future milestones) - API endpoints (M2) - Frontend integration (M3-M5) - Debug tools (M6) - Analytics visualization (M7) Part of #482 --------- Co-authored-by: you <you@example.com> |
||
|
|
64745f89b1 |
feat: customizer v2 — event-driven state management (#502) (#503)
## Summary Implements the customizer v2 per the [approved spec](docs/specs/customizer-rework.md), replacing the v1 customizer's scattered state management with a clean event-driven architecture. Resolves #502. ## What Changed ### New: `public/customize-v2.js` Complete rewrite of the customizer as a self-contained IIFE with: - **Single localStorage key** (`cs-theme-overrides`) replacing 7 scattered keys - **Three state layers:** server defaults (immutable) → user overrides (delta) → effective config (computed) - **Full data flow pipeline:** `write → read-back → merge → atomic SITE_CONFIG assign → apply CSS → dispatch theme-changed` - **Color picker optimistic CSS** (Decision #12): `input` events update CSS directly for responsiveness; `change` events trigger the full pipeline - **Override indicator dots** (●) on each field — click to reset individual values - **Section-level override count badges** on tabs - **Browser-local banner** in panel header: "These settings are saved in your browser only" - **Auto-save status indicator** in footer: "All changes saved" / "Saving..." / "⚠️ Storage full" - **Export/Import** with full shape validation (`validateShape()`) - **Presets** flow through the standard pipeline (`writeOverrides(presetData) → pipeline`) - **One-time migration** from 7 legacy localStorage keys (exact field mapping per spec) - **Validation** on all writes: color format, opacity range, timestamp enum values - **QuotaExceededError handling** with visible user warning ### Modified: `public/app.js` Replaced ~80 lines of inline theme application code with a 15-line `_customizerV2.init(cfg)` call. The customizer v2 handles all merging, CSS application, and global state updates. ### Modified: `public/index.html` Swapped `customize.js` → `customize-v2.js` script tag. ### Added: `docs/specs/customizer-rework.md` The full approved spec, included in the repo for reference. ## Migration On first page load: 1. Checks if `cs-theme-overrides` already exists → skip if yes 2. Reads all 7 legacy keys (`meshcore-user-theme`, `meshcore-timestamp-*`, `meshcore-heatmap-opacity`, `meshcore-live-heatmap-opacity`) 3. Maps them to the new delta format per the spec's field-by-field mapping 4. Writes to `cs-theme-overrides`, removes all legacy keys 5. Continues with normal init Users with existing customizations will see them preserved automatically. ## Dark/Light Mode - `theme` section stores light mode overrides, `themeDark` stores dark mode overrides - `meshcore-theme` localStorage key remains **separate** (view preference, not customization) - Switching modes re-runs the full pipeline with the correct section ## Testing - All existing tests pass (`test-packet-filter.js`, `test-aging.js`, `test-frontend-helpers.js`) - Old `customize.js` is NOT modified — left in place for reference but no longer loaded ## Not in Scope (per spec) - Undo/redo stack - Cross-tab synchronization - Server-side admin import endpoint - Map config / geo-filter overrides --------- Co-authored-by: you <you@example.com> |
||
|
|
c9c473279e |
fix: add null-guards to rAF callbacks in live page animations (#506)
## Summary Fixes #483 — navigating away from the live page while matrix/hop animations are running throws `TypeError: Cannot read properties of null (reading 'addLayer')`. ## Root Cause `destroy()` sets `animLayer = null` and `pathsLayer = null`, but in-flight `requestAnimationFrame` callbacks continue executing and attempt to call `.addTo(animLayer)` or `.removeLayer()` on the now-null references. The entry guards at the top of `drawMatrixLine()` and `drawAnimatedLine()` only protect the initial call — not the rAF continuation loops inside `tick()`, `fadeOut()`, `animateLine()`, and `animateFade()`. ## Fix Added null-guards (`if (!animLayer || !pathsLayer) return`) at the top of all four rAF callback functions in `live.js`: 1. **`tick()`** (line ~2203) — matrix animation main loop 2. **`fadeOut()`** (line ~2253) — matrix animation fade-out 3. **`animateLine()`** (line ~2302) — standard line animation main loop 4. **`animateFade()`** (line ~2337) — standard line fade-out This pattern is already used elsewhere in the file (e.g., line 1873, 1886) for the same purpose. ## Testing - All unit tests pass (`npm test` — 0 failures) - Go server tests pass (`cmd/server` + `cmd/ingestor`) - Change is defensive only (early return on null) — no behavioral change when layers exist --------- Co-authored-by: you <you@example.com> |
||
|
|
ad97c0fdd1 |
fix: clear stale parsed cache on observation packets (#505)
## Summary Fixes #504 — Expanding a packet in the packets UI showed the same path on every observation instead of each observation's unique path. ## Root Cause PR #400 (fixing #387) added caching of `JSON.parse` results as `_parsedPath` and `_parsedDecoded` properties on packet objects. When observation packets are created via object spread (`{...parentPacket, ...obs}`), these cache properties are copied from the parent. Subsequent calls to `getParsedPath(obsPacket)` hit the stale cache and return the parent's path, ignoring the observation's own `path_json`. ## Fix After every object spread that creates an observation packet from a parent packet, delete the cache properties so they get re-parsed from the observation's own data: ```js delete obsPacket._parsedPath; delete obsPacket._parsedDecoded; ``` Applied to all 5 spread sites in `public/packets.js`: - Line 271: detail pane observation selection - Line 504: flat view observation expansion - Line 840: grouped view observation expansion - Line 1012: child observation selection in grouped view - Line 1982: WebSocket live update observation expansion ## Tests Added 2 new tests in `test-frontend-helpers.js`: 1. Verifies observation packets get their own path after cache invalidation (not the parent's) 2. Verifies observation path differs from parent path after cache invalidation All 431 frontend helper tests pass. All 62 packet filter tests pass. --------- Co-authored-by: you <you@example.com> |
||
|
|
c7f655e419 |
perf(frontend): cache JSON.parse results for packet data (#400)
## Problem As described in #387, `JSON.parse()` is called repeatedly on the same packet data across render cycles. With 30K packets, each render cycle parses 60K+ JSON strings unnecessarily. ## Analysis The server sends `decoded_json` and `path_json` as JSON strings. The frontend parses them on-demand in multiple locations: - `renderTableRows()` — for every row, every render - WebSocket handling — when processing filtered packets - `loadPackets()` — during packet loading - Detail view rendering — when showing packet details This creates O(n×m) parsing overhead where n = packet count and m = render cycles. ## Solution Add cached parse helpers that store parsed results on the packet object: ```javascript function getParsedPath(p) { if (p._parsedPath === undefined) { try { p._parsedPath = JSON.parse(p.path_json || '[]'); } catch { p._parsedPath = []; } } return p._parsedPath; } ``` Same pattern for `getParsedDecoded()`. ## Changes - `public/packets.js`: Add helpers + replace 15+ JSON.parse calls - `public/live.js`: Add helpers + replace 5 JSON.parse calls ## Benchmarks Before: 60K+ JSON.parse calls per render cycle (30K packets) After: ~30K parse calls (one per packet, cached thereafter) Memory impact: Negligible (stores parsed objects that were already created temporarily) ## Notes - Cache uses `undefined` check to distinguish "not cached" from "cached empty result" - Property names `_parsedPath` and `_parsedDecoded` prefixed to avoid collision with server fields - No breaking changes to existing code paths Fixes #387 --------- Co-authored-by: P. Clawmogorov <262173731+Alm0stSurely@users.noreply.github.com> Co-authored-by: you <you@example.com> |
||
|
|
b1d89d7d9f |
fix: apply region filter in GetNodes — was silently ignored (#496) (#497)
## Summary - `db.GetNodes` accepted a `region` param from the HTTP handler but never used it — every region-filter selection was silently ignored and all nodes were always returned - Added a subquery filtering `nodes.public_key` against ADVERT transmissions (payload_type=4) observed by observers with matching IATA codes - Handles both v2 (`observer_id TEXT`) and v3 (`observer_idx INT`) schemas ## Test plan - [x] 4 new subtests added to `TestGetNodesFiltering`: SJC (1 node), SFO (1 node), SJC,SFO multi (1 node deduped), AMS unknown (0 nodes) - [x] All existing Go tests still pass - [x] Deploy to staging, open `/nodes`, select a region in the filter bar — only nodes observed by observers in that region should appear Closes #496 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: you <you@example.com> |
||
|
|
c173ab7e80 |
perf: skip JSON parse in indexByNode when no pubkey fields present (#376) (#499)
## Summary - `indexByNode` was calling `json.Unmarshal` for every packet during `Load()` and `IngestNewFromDB()`, even channel messages and other payloads that can never contain node pubkey fields - All three target fields (`"pubKey"`, `"destPubKey"`, `"srcPubKey"`) share the common substring `"ubKey"` — added a `strings.Contains` pre-check that skips the JSON parse entirely for packets that don't match - At 30K+ packets on startup, this eliminates the majority of `json.Unmarshal` calls in `indexByNode` (channel messages, status packets, etc. all bypass it) ## Test plan - [x] 5 new subtests in `TestIndexByNodePreCheck`: ADVERT with pubKey indexed, destPubKey indexed, channel message skipped, empty JSON skipped, duplicate hash deduped - [x] All existing Go tests pass - [x] Deploy to staging and verify node-filtered packet queries still work correctly Closes #376 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: you <you@example.com> |
||
|
|
4664c90db4 |
fix: skip zero-hop adverts when checking node hash size (#493)
Fixes issue router IDs flapping between 1byte and multi-byte as described in https://github.com/Kpa-clawbot/CoreScope/issues/303 with a minimal patch + test coverage. This fix is critical for regions using multi-byte IDs. Closes https://github.com/Kpa-clawbot/CoreScope/issues/303 --------- Co-authored-by: you <you@example.com> |
||
|
|
2755dc3875 |
test: push ingestor coverage from 70% to 84% (#344) (#492)
## Summary Push Go ingestor test coverage from **70.2% → 84.0%** (92.8% excluding the untestable `main()` and `init()` functions). Part of #344 — ingestor coverage ## What Changed Added `coverage_boost_test.go` with 60+ new test functions covering previously untested code paths: ### Coverage Before → After by Function | Function | Before | After | |----------|--------|-------| | `NodeDaysOrDefault` | 0% | 100% | | `MoveStaleNodes` | 0% | 76.5% | | `NodePassesGeoFilter` | 40% | 100% | | `handleMessage` | 41.4% | 92.1% | | `ResolvedSources` | 71.4% | 100% | | `extractObserverMeta` | 100% | 100% | | `decodeAdvert` | 88.2% | 94.1% | | `decryptChannelMessage` | 88.4% | 93.0% | | **Total** | **70.2%** | **84.0%** | ### Test Categories Added - **Config**: `NodeDaysOrDefault` all branches, broker scheme normalization (`mqtt://` → `tcp://`, `mqtts://` → `ssl://`) - **Database**: `MoveStaleNodes` (stale/fresh/replace), duplicate transmission handling, default timestamps, telemetry updates, schema migration verification - **Decoder**: Sensor telemetry parsing, location + features with truncated data, `countNonPrintable` with invalid UTF-8, `decryptChannelMessage` error paths (invalid key/MAC/ciphertext/alignment), short payload handling - **Geo Filter**: All branches (nil filter, nil coords, inside/outside) - **Message Handler**: Channel messages (with/without sender, empty text), direct messages, geo-filtered adverts, corrupted adverts (all-zero pubkey), non-advert packets, `Score`/`Direction` case-insensitive fallbacks, status messages with full hardware metadata ### Why Not 90%+ The remaining ~16% uncovered statements are: - `main()` function (68 blocks) — program entry point with MQTT client setup, signal handling, goroutines — not unit-testable without major refactoring - `init()` function — `--version` flag + `os.Exit(0)` — kills the test process - `prepareStatements()` error returns — only trigger on corrupted/incompatible SQLite databases - `applySchema()` migration error paths — only trigger on filesystem/SQLite failures Excluding `main()` and `init()`, effective coverage is **92.8%**. ## Test Results All 100+ tests pass (existing + new): ``` ok github.com/corescope/ingestor 25.945s coverage: 84.0% of statements ``` --------- Co-authored-by: you <you@example.com> |
||
|
|
5228e67604 |
fix: use packet timestamp in bufferPacket instead of arrival time (#475) (#491)
## Summary - `bufferPacket()` was overwriting `_ts` with `Date.now()` (receive time) for every live WS packet - Packets arriving in the same batch all got identical timestamps, making the message history show the same "Xs ago" for every entry (e.g., all show "5s ago") - Fix: use `pkt.timestamp || pkt.created_at` (mirroring `dbPacketToLive`) so each packet reflects its actual origination time, falling back to `Date.now()` only when the packet has no timestamp ## Root cause ```js // before pkt._ts = Date.now(); // after pkt._ts = new Date(pkt.timestamp || pkt.created_at || Date.now()).getTime(); ``` The WS broadcast includes `timestamp` (= `tx.FirstSeen`) in the packet map (store.go:1182), so the field is always present for real packets. ## Test plan - [x] Open Live page, observe packets arriving — each should show its own relative time, not all the same value - [x] `node test-frontend-helpers.js` passes (235 tests, 0 failures) Closes #475 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: you <you@example.com> |
||
|
|
698514e5e6 |
test: comprehensive live.js coverage (71 tests) (#489)
## Summary Add comprehensive test coverage for `live.js` — the largest and most complex frontend file (2500+ lines) covering animation modes, VCR playback, WebSocket handling, audio integration, and the live map. Part of #344 — live.js coverage. ## What's Tested (71 tests) ### Pure function tests via `vm.createContext` - **`dbPacketToLive`** — DB packet → live format conversion, null `decoded_json`, `payload_type_name` fallback, `created_at` timestamp fallback - **`expandToBufferEntries`** — observation expansion (1→N entries), empty observations, multi-packet batches - **`SEG_MAP`** — 7-segment LCD digit mapping completeness (all digits, colon, space, VCR mode letters) - **VCR state machine** — mode transitions (`LIVE`→`PAUSED`→`REPLAY`), `frozenNow` lifecycle, speed cycling (1→2→4→8→1), pause idempotency - **`getFavoritePubkeys`** — localStorage merging from `meshcore-favorites` + `meshcore-my-nodes`, corrupt data handling, falsy filtering - **`packetInvolvesFavorite`** — sender pubKey matching, hop prefix matching, missing decoded fields - **`isNodeFavorited`** — basic favorite lookup, empty state - **`formatLiveTimestampHtml`** — timestamp formatting with tooltip, null input, numeric input, future warning icon - **`resolveHopPositions`** — HopResolver integration, ghost hop interpolation between known nodes - **`bufferPacket`** — VCR buffer management, 2000-entry cap with playhead adjustment, missed count in PAUSED mode ### Source-level safety checks (20 tests) - Null guards: `renderPacketTree`, `animatePath`, `pulseNode`, `nextHop` (all verified via source-level checks) - Animation limit enforcement (`MAX_CONCURRENT_ANIMS`) - Tab visibility optimization (skip animations when hidden, clear propagation buffer on restore) - WebSocket auto-reconnect - `addNodeMarker` deduplication - All toggle state persistence to localStorage (matrix, rain, realistic, favorites, ghost hops) - `clearNodeMarkers` resets HopResolver - `startReplay` pre-aggregates by hash - Orientation change retry delays - `vcrRewind` deduplicates buffer entries by ID ## Changes - `public/live.js` — expose 14 additional functions via `window._live*` for testing (following existing pattern) - `test-live.js` — new test file, 841 lines, 71 tests ## Constraints - No new dependencies - Tests run via `vm.createContext` against real code (not copies) - No build step — vanilla JS --------- Co-authored-by: you <you@example.com> |
||
|
|
cf3a383bb2 |
test: comprehensive app.js coverage — 100+ new tests (#490)
## Summary Adds 100+ new tests for previously untested `app.js` functions, significantly improving frontend coverage toward the 90%+ target. ## What's Tested All pure/testable functions from `app.js` that lacked coverage: | Function Group | Tests Added | Description | |---|---|---| | `payloadTypeColor` | 13 | All PAYLOAD_COLORS mappings + unknown/null/undefined fallback | | `pad2` / `pad3` | 10 | Zero-padding for 1-3 digit values, no truncation | | `formatIsoLike` | 5 | UTC/local timezone, with/without milliseconds, zero-padding | | `formatTimestampCustom` | 5 | Token replacement (YYYY/MM/DD/HH/mm/ss/SSS/Z), partial formats, invalid format rejection | | `formatAbsoluteTimestamp` | 3 | Custom format integration, locale+UTC, null/invalid date handling | | `getTimestamp*` getters | 11 | localStorage priority, server config fallback, invalid value rejection for Mode/Timezone/FormatPreset/CustomFormat | | `invalidateApiCache` | 3 | Prefix-based selective invalidation, full clear, cache→invalidate→re-fetch lifecycle | | `formatHex` | 5 | Byte spacing, single byte, null/empty, odd-length hex | | `createColoredHexDump` | 6 | Range-based coloring, override precedence, null/empty hex+ranges | | `buildHexLegend` | 5 | Label deduplication, correct swatch colors per label class, null/empty | | Favorites (`getFavorites`/`isFavorite`/`toggleFavorite`/`favStar`) | 9 | CRUD operations, corrupt JSON resilience, star HTML rendering with custom classes | | `debounce` | 3 | Delay behavior, timer reset on rapid calls, argument forwarding | | `mergeUserHomeConfig` | 5 | Null/missing siteConfig/userTheme, non-object home, missing home creation | | Constants | 2 | Exhaustive ROUTE_TYPES (4) and PAYLOAD_TYPES (13) mapping verification | ## Approach - Tests use the existing `vm.createContext` sandbox pattern from `test-frontend-helpers.js` - Tests the **real code** loaded from `public/app.js` — no copies - No new dependencies - Each `invalidateApiCache` test uses an isolated sandbox to avoid async race conditions ## Test Results ``` Frontend helpers: 343 passed, 0 failed ``` Part of #344 — app.js coverage --------- Co-authored-by: you <you@example.com> |
||
|
|
a45ac71508 |
fix: restore color-coded hex breakdown in packet detail (#329) (#500)
## Summary
- `BuildBreakdown` was never ported from the deleted Node.js
`decoder.js` to Go — the server has returned `breakdown: {}` since the
Go migration (commit `742ed865`), so `createColoredHexDump()` and
`buildHexLegend()` in the frontend always received an empty `ranges`
array and rendered everything as monochrome
- Implemented `BuildBreakdown()` in `decoder.go` — computes labeled byte
ranges matching the frontend's `LABEL_CLASS` map: `Header`, `Transport
Codes`, `Path Length`, `Path`, `Payload`; ADVERT packets get sub-ranges:
`PubKey`, `Timestamp`, `Signature`, `Flags`, `Latitude`, `Longitude`,
`Name`
- Wired into `handlePacketDetail` (was `struct{}{}`)
- Also adds per-section color classes to the field breakdown table
(`section-header`, `section-transport`, `section-path`,
`section-payload`) so the table rows get matching background tints
## Test plan
- [x] Open any packet detail pane — hex dump should show color-coded
sections (red header, orange path length, blue transport codes, green
path hops, yellow/colored payload)
- [x] Legend below action buttons should appear with color swatches
- [x] ADVERT packets: PubKey/Timestamp/Signature/Flags each get their
own distinct color
- [x] Field breakdown table section header rows should be tinted per
section
- [x] 8 new Go tests: all pass
Closes #329
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
016b87b33c |
test: add 64 unit tests for packets.js (Part of #344) (#488)
## Summary Adds 64 unit tests for `packets.js` — the largest untested frontend file (2000+ lines) covering filter engine integration, time window logic, groupByHash rendering, and packet detail display. Part of #344 — packets.js coverage. ## Approach Follows the existing `test-frontend-helpers.js` pattern: loads real source files into a `vm.createContext` sandbox and tests actual code (no copies). Added a `window._packetsTestAPI` export at the end of the packets.js IIFE to expose pure functions for testing without changing any runtime behavior. ## What's Tested | Function | Tests | What it covers | |----------|-------|----------------| | `typeName` | 2 | Type code → name mapping, unknown fallback | | `obsName` | 2 | Observer name lookup, falsy/missing handling | | `kv` | 1 | Key-value HTML helper | | `sectionRow` / `fieldRow` | 3 | Table section/field HTML builders | | `getDetailPreview` | 17 | All packet types: CHAN, ADVERT (repeater/room/sensor/companion), GRP_TXT (no_key/decryption_failed/channelHashHex), TXT_MSG, PATH, REQ, RESPONSE, ANON_REQ, text fallback, public_key fallback, empty | | `getPathHopCount` | 4 | Valid path, empty, null, invalid JSON | | `sortGroupChildren` | 3 | Default observer sort, header update, null safety | | `renderTimestampCell` | 2 | Timestamp HTML output, null handling | | `renderPath` | 3 | Empty/null, multi-hop with arrows, single hop | | `renderDecodedPacket` | 6 | Header/path/payload/nested objects/null skip/raw hex | | `buildFieldTable` | 11 | All payload types (ADVERT with flags/location/name, GRP_TXT, CHAN, ACK, destHash, raw fallback), transport codes, path hops, hash_size calculation, empty hex | | `_getRowCount` | 1 | Virtual scroll row counting | | `buildFlatRowHtml` | 3 | Row rendering, size calculation, missing hex | | `buildGroupRowHtml` | 3 | Single/multi group, observation badge | | Test API exposure | 1 | Verifies window._packetsTestAPI | ## Constraints Met - No new test dependencies - Tests real code via `vm.createContext`, not copies - No build step — vanilla JS - All existing tests still pass (254 frontend-helpers, 62 packet-filter, 29 aging) Co-authored-by: you <you@example.com> |
||
|
|
889107a5e1 |
fix: address PR #487 review feedback (#501)
## Summary Addresses review feedback from PR #487 (nodes.js coverage). ### Changes 1. **Replace fragile `exportInternals` regex source patching with stable test hooks** — `getStatusInfo` and `getStatusTooltip` are now exposed via `window._nodesGetStatusInfo` and `window._nodesGetStatusTooltip`, matching the existing pattern used by all other test-accessible functions. The brittle regex `.replace()` approach that modified source code at runtime has been removed entirely. 2. **Strengthen weak null assertion** — The `renderNodeTimestampHtml handles null` test previously asserted `html.includes('—') || html.length > 0`, which is a near-tautology (any non-empty string passes). Now strictly asserts `html.includes('—')`. ### Files changed - `public/nodes.js` — 2 new test hook lines - `test-frontend-helpers.js` — removed 21-line `exportInternals` branch, updated tests to use hooks ### Testing - All 309 frontend helper tests pass - All 62 packet filter tests pass - All 29 aging tests pass Closes review items from #487. Co-authored-by: you <you@example.com> |