Compare commits

...

41 Commits

Author SHA1 Message Date
you 68c669f4a4 Extract validateAdvertSignature to shared package, accept []byte, add server tests
- Extract signature validation to internal/sigvalidate/ shared package
- Change function signature to accept []byte instead of hex strings,
  eliminating unnecessary hex encode/decode round-trip
- Add signature validation tests to cmd/server/decoder_test.go
- Both cmd/server and cmd/ingestor now import from the shared package

Addresses all review feedback on PR #686.
2026-04-10 00:44:48 +00:00
you a7ada12f6d Add tests for ed25519 signature validation
Test validateAdvertSignature with valid signatures, tampered data,
wrong timestamps, malformed inputs, and wrong-length keys/signatures.
Also test decodeAdvert with validation enabled vs disabled.
2026-04-10 00:36:23 +00:00
you c182e799bf Clean up bool pointer allocation in signature validation
Replace &[]bool{false}[0] idiom with explicit variable for clarity.
2026-04-10 00:36:17 +00:00
you e900502c68 Add missing badge-success and badge-danger CSS classes
The signature validation UI uses badge-success and badge-danger classes
that were not defined in style.css, resulting in unstyled badges.
Add them using the same color patterns as existing badge-hash variants.
2026-04-10 00:36:12 +00:00
Jeff Copeland 8e8ff85f1c for BYOP mode, perform signature validation on advert packets and display status 2026-04-08 12:03:22 -04:00
you 111b03cea1 docs: lead with pre-built Docker image as the headline 2026-04-08 07:22:07 +00:00
you 34c56d203e docs: promote API docs to own section with live analyzer.00id.net links, fix transition section 2026-04-08 07:21:11 +00:00
you cc9f25e5c8 docs: fix release notes — bind mount for caddy-data, no personal paths, add Caddyfile example 2026-04-08 07:20:02 +00:00
you 2e33eb7050 docs: add HTTPS/Caddyfile mount to release notes and upgrade steps 2026-04-08 07:14:15 +00:00
you 6dd0957507 docs: use v3.5.0 tag in release notes, :latest requires git tag 2026-04-08 07:05:58 +00:00
you e22ee3f0ad docs: docker run based upgrade, no compose 2026-04-08 07:03:05 +00:00
you f7f1bb08d0 docs: add cd to compose dir in upgrade steps 2026-04-08 07:01:43 +00:00
you 84da4d962d docs: release notes with juice 2026-04-08 07:00:59 +00:00
you ad0a10c009 docs: fix transition steps — compose-based, not docker run 2026-04-08 06:59:54 +00:00
you c1f268d3b9 docs: add concrete transition steps to release notes 2026-04-08 06:58:39 +00:00
you f5d25f75c6 docs: trim release notes — less book, more changelog 2026-04-08 06:56:50 +00:00
you cde62166cb docs: v3.5.0 release notes + API documentation across README, deployment guide, FAQ
- Release notes for 95 commits since v3.4.1
- OpenAPI/Swagger docs: /api/spec and /api/docs called out everywhere
- Deployment guide: new API Documentation section
- README: API docs link added
- FAQ: 'Where is the API documentation?' entry
- Test plans for v3.4.2 validation
2026-04-08 06:55:25 +00:00
Kpa-clawbot 5606bc639e fix: table sorting broken on all node tables — wrong data attribute (#679) (#680)
## Problem

All table sorting on the Nodes page was broken — clicking column headers
did nothing. Affected:
- Nodes list table
- Node detail → Neighbors table
- Node detail → Observers table

## Root Cause

**Not a race condition** — the actual bug was a **data attribute
mismatch**.

`TableSort.init()` (in `table-sort.js`) queries for `th[data-sort-key]`
to find sortable columns. But all table headers in `nodes.js` used
`data-sort="..."` instead of `data-sort-key="..."`. The selector never
matched any headers, so no click handlers were attached and sorting
silently failed.

Additionally, `data-type="number"` was used but TableSort's built-in
comparator is named `numeric`, causing numeric columns to fall back to
text comparison.

The packets table (`packets.js`) was unaffected because it already used
the correct `data-sort-key` and `data-type="numeric"` attributes.

## Fix

1. **`public/nodes.js`**: Changed all `data-sort="..."` to
`data-sort-key="..."` on `<th>` elements (nodes list, neighbors table,
observers table)
2. **`public/nodes.js`**: Changed `data-type="number"` to
`data-type="numeric"` to match TableSort's comparator names
3. **`public/packets.js`**: Added timestamp tiebreaker to packet sort
for stable ordering when primary column values are equal

## Testing

- All existing tests pass (`npm test`)
- No changes to test infrastructure needed — this was a pure HTML
attribute fix

Fixes #679

---------

Co-authored-by: you <you@example.com>
2026-04-07 23:30:31 -07:00
Kpa-clawbot 1373106b50 Fix panel corner toggle buttons invisible and scrolling away (#678)
## Summary

Panel corner toggle buttons (◫) were invisible due to small size, low
opacity, and `position: absolute` causing them to scroll away with panel
content.

## Changes

### Panel structure — non-scrolling header
All 3 live overlay panels (feed, node detail, legend) now use a flex
layout:
- **`.panel-header`** — non-scrolling row with corner toggle + close
button
- **`.panel-content`** — scrollable content area

### CSS updates
- `.live-overlay`: `display: flex; flex-direction: column`
- `.panel-header`: flex row, `flex-shrink: 0`
- `.panel-content`: `flex: 1; overflow-y: auto`
- `.panel-corner-btn`: removed `position: absolute`, increased to
28×28px, opacity 0.6, hover background

### JS updates
- Feed items now appended to `.panel-content` child instead of panel
root
- `rebuildFeedList` and `addFeedItem` updated to target `.panel-content`
- Resize handle still attaches to panel root (correct behavior)

## Testing
- All 490+ frontend helper tests pass
- All panel-corner tests pass (14/14)
- No test changes needed — tests exercise logic, not DOM structure

Fixes #677

---------

Co-authored-by: you <you@example.com>
2026-04-07 23:17:19 -07:00
Kpa-clawbot 68a4628edf fix: channel color picker — data shape mismatch + redesign for discoverability (#675)
## Fix: Channel Color Picker — Data Shape Mismatch + Redesign (#674)

### Problem

The channel color picker was completely non-functional — dead code.
Three locations in `live.js` attempted to read
`decoded.header.payloadTypeName` and `decoded.payload.channelName`, but:

1. The decoded payload structure is flat
(`decoded.payload.channelHash`), not nested with separate
`header`/`payload` objects within the payload
2. The field is `channelHash` (an integer), not `channelName`
3. `_ccChannel` was **never set** on any DOM element, so all picker
handlers exited early

Additionally, the picker had zero discoverability — hidden behind
right-click/long-press with no visual affordance.

### Changes

**M1 — Fix the data shape bug:**
- Fixed `_ccChannel` assignment in 3 locations in `live.js` to use
`decoded.payload.channelHash` (converted to string)
- Fixed `_getChannelStyle()` to use the same flat structure
- Channel colors now key on the hash string (e.g. `"5"`) matching the
channels API

**M2 — Redesign for discoverability:**
- Reduced palette from 10 to **8 maximally-distinct colors** (removed
teal/rose — too close to cyan/red)
- Removed `<input type="color">` custom picker, "Apply" button, title
bar, close button
- Popover is now just 8 circle swatches + "Clear color" — click outside
to dismiss
- Added **12px clickable color dots** next to channel names on the
channels page (primary configuration surface)
- Unassigned channels show a dashed-border empty circle; assigned show
filled
- Channel list items get `border-left: 3px solid` when colored
- **Removed long-press handler entirely** — dots handle mobile
interaction
- Mobile: bottom-sheet with 36px touch targets via `@media (pointer:
coarse)`

**M3 — Visual encoding:**
- Left border only (3px) — no background tint (per Tufte spec: minimum
effective dose)
- Consistent encoding across live feed items, channel list, packets
table

### Tests

17 new tests in `test-channel-color-picker.js`:
- `_ccChannel` correctly set for GRP_TXT with various `channelHash`
values (including 0)
- `_ccChannel` not set for non-GRP_TXT packets
- `getRowStyle` returns `border-left:3px` only (no background)
- Palette is exactly 8 colors, no teal/rose
- All existing tests pass (62 + 29 + 490)

Fixes #674

---------

Co-authored-by: you <you@example.com>
2026-04-07 23:03:57 -07:00
you 00953207fb ci: remove arm64 build + QEMU — amd64 only
Removes linux/arm64 from multi-platform build and drops QEMU setup.
All infra (prod + staging) is x86. QEMU emulation was adding ~12min
to every CI run for an unused architecture.
2026-04-08 05:23:41 +00:00
you 16a72b66a9 test: fix hash_size test for zero-hop behavior change (#653)
The buildFieldTable test expected hash_size=4 for path byte 0xC0 with
hash_count=0. After #653, zero hash_count shows 'hash_count=0 (direct
advert)' instead. Updated test and added new test verifying hash_size
IS shown when hash_count > 0.
2026-04-08 04:53:10 +00:00
Kpa-clawbot e0e9aaa324 feat: noise floor column chart with color-coded thresholds (#659)
## Noise Floor: Line Chart → Color-Coded Column Chart

Implements M3a from the [RF Health Dashboard
spec](https://github.com/Kpa-clawbot/CoreScope/issues/600#issuecomment-2784399622)
— replacing the noise floor line chart with discrete color-coded
columns.

### What changed

**`public/analytics.js`** — replaced `rfNFLineChart()` with
`rfNFColumnChart()`:

- **Color-coded bars by threshold**: green (`< -100 dBm`), yellow (`-100
to -85 dBm`), red (`≥ -85 dBm`)
- **Instant hover tooltips**: exact dBm value + UTC timestamp via native
SVG `<title>` — no delay
- **Column highlighting on hover**: CSS `:hover` with opacity change +
border stroke
- **Inline legend**: green/yellow/red threshold key in chart header
- **Removed reference lines**: the `-100 warning` and `-85 critical`
dashed lines are eliminated — threshold info is now encoded directly in
bar color (data-ink ratio improvement)
- **No gap detection**: column charts render discrete bars — each data
point is an independent observation, so line-chart-style gap detection
doesn't apply. Every sample gets a bar.
- **Reboot markers**: vertical dashed lines with "reboot" labels at
reboot timestamps (shared `rfRebootMarkers` helper, same as other RF
charts)
- **Division-by-zero guard**: constant values or single data points use
a ±5 dBm window so bars render with visible height
- **Sparklines unchanged**: fleet overview sparklines remain as
polylines (correct at 140×24px scale)

### Why columns instead of lines

A polyline connecting discrete 5-minute noise floor samples creates
false visual continuity — it implies interpolation between measurements
that doesn't exist. When readings jump between -115 and -95 irregularly,
the line becomes a jagged mess. Column bars encode each sample as a
discrete, independent observation: one bar = one measurement.

### Testing

- 12 unit tests in `test-frontend-helpers.js` covering: SVG output,
threshold color coding, tooltips, empty/single/constant data, legend
rendering, reboot markers, shared time axis
- All existing tests pass (packet-filter: 62, aging: 29,
frontend-helpers: 490)

### No backend changes

Pure frontend change — ~150 lines in `analytics.js`.

Fixes #600

---------

Co-authored-by: you <you@example.com>
2026-04-07 21:40:14 -07:00
Kpa-clawbot 22bf33700e Fix: filter path-hop candidates by resolved_path to prevent prefix collisions (#658)
## Problem

The "Paths Through This Node" API endpoint (`/api/nodes/{pubkey}/paths`)
returns unrelated packets when two nodes share a hex prefix. For
example, querying paths for "Kpa Roof Solar" (`c0dedad4...`) returns 316
packets that actually belong to "C0ffee SF" (`C0FFEEC7...`) because both
share the `c0` prefix in the `byPathHop` index.

Fixes #655

## Root Cause

`handleNodePaths()` in `routes.go` collects candidates from the
`byPathHop` index using 2-char and 4-char hex prefixes for speed, but
never verifies that the target node actually appears in each candidate's
resolved path. The broad index lookup is intentional, but the
**post-filter was missing**.

## Fix

Added `nodeInResolvedPath()` helper in `store.go` that checks whether a
transmission's `resolved_path` (from the neighbor affinity graph via
`resolveWithContext`) contains the target node's full pubkey. The
filter:

- **Includes** packets where `resolved_path` contains the target node's
full pubkey
- **Excludes** packets where `resolved_path` resolved to a different
node (prefix collision)
- **Excludes** packets where `resolved_path` is nil/empty (ambiguous —
avoids false positives)

The check examines both the best observation's resolved_path
(`tx.ResolvedPath`) and all individual observations, so packets are
included if *any* observation resolved the target.

## Tests

- `TestNodeInResolvedPath` — unit test for the helper with 5 cases
(match, different node, nil, all-nil elements, match in observation
only)
- `TestNodePathsPrefixCollisionFilter` — integration test: two nodes
sharing `aa` prefix, verifies the collision packet is excluded from one
and included for the other
- Updated test DB schema to include `resolved_path` column and seed data
with resolved pubkeys
- All existing tests pass (165 additions, 8 modifications)

## Performance

No impact on hot paths. The filter runs once per API call on the
already-collected candidate set (typically small). `nodeInResolvedPath`
is O(observations × hops) per candidate — negligible since observations
per transmission are typically 1–5.

---------

Co-authored-by: you <you@example.com>
2026-04-07 21:24:00 -07:00
Kpa-clawbot b8e9b04a97 feat: panel corner-position toggle (M0) (#657)
## Panel Corner-Position Toggle (M0)

Fixes #608

### What

Each overlay panel on the live map page (feed, legend, node detail) gets
a small corner-toggle button that cycles through **TL → TR → BR → BL**
placement. This solves the panel-blocking-map-data problem with minimal
complexity.

### Changes

**`public/live.css`** (~60 lines)
- CSS classes for 4 corner positions via `data-position` attribute
- Smooth transitions with `cubic-bezier` easing
- `prefers-reduced-motion` support
- Direction-aware hide animations for positioned panels
- `.panel-corner-btn` styling (subtle, hover-to-reveal)
- Mobile: corner buttons hidden (`<640px` — panels are hidden or
bottom-sheet)
- `.sr-only` class for screen reader announcements

**`public/live.js`** (~90 lines)
- `PANEL_DEFAULTS`, `CORNER_CYCLE`, `CORNER_ARROWS` constants
- `getPanelPositions()` — reads from localStorage with defaults
- `nextAvailableCorner()` — collision avoidance (skips occupied corners)
- `applyPanelPosition()` — sets `data-position` + updates button
- `onCornerClick()` — cycle logic + persistence + SR announcement
- `resetPanelPositions()` — clears saved positions
- Corner toggle buttons added to feed, legend, and node detail panel
HTML
- `initPanelPositions()` called during page init

**`test-panel-corner.js`** (14 tests)
- `nextAvailableCorner`: available, skip occupied, skip multiple,
self-exclusion
- `getPanelPositions`: defaults, saved values
- `applyPanelPosition`: attribute setting, button update, missing
element
- `onCornerClick`: cycling, collision avoidance
- `resetPanelPositions`: clear + restore defaults
- Cycle order and default position validation

### What this does NOT include

- Drag-and-drop (M1–M4)
- Snap-to-edge
- Z-index management
- Keyboard repositioning
- Any of the full drag system

### Design decisions

- **`data-position` + CSS classes** over inline transforms — avoids
conflict with existing show/hide `transform` animations
- **Cycle (TL→TR→BR→BL)** over toggle-to-opposite — predictable,
learnable
- **3 panels, 4 corners** — collision avoidance is trivial, always a
free corner
- **Header/stats panel excluded** — it's contextual chrome, not
repositionable

---------

Co-authored-by: you <you@example.com>
2026-04-07 21:20:29 -07:00
Kpa-clawbot 7d71dc857b feat: expose hopsCompleted for TRACE packets, show real path on live map (#656)
## Summary

TRACE packets on the live map previously animated the **full intended
route** regardless of how far the trace actually reached. This made it
impossible to distinguish a completed route from a failed one —
undermining the primary diagnostic purpose of trace packets.

## Changes

### Backend — `cmd/server/decoder.go`

- Added `HopsCompleted *int` field to the `Path` struct
- For TRACE packets, the header path contains SNR bytes (one per hop
that actually forwarded). Before overwriting `path.Hops` with the full
intended route from the payload, we now capture the header path's
`HashCount` as `hopsCompleted`
- This field is included in API responses and WebSocket broadcasts via
the existing JSON serialization

### Frontend — `public/live.js`

- For TRACE packets with `hopsCompleted < totalHops`:
  - Animate only the **completed** portion (solid line + pulse)
- Draw the **unreached** remainder as a dashed/ghosted line (25%
opacity, `6,8` dash pattern) with ghost markers
  - Dashed lines and ghost markers auto-remove after 10 seconds
- When `hopsCompleted` is absent or equals total hops, behavior is
unchanged

### Tests — `cmd/server/decoder_test.go`

- `TestDecodePacket_TraceHopsCompleted` — partial completion (2 of 4
hops)
- `TestDecodePacket_TraceNoSNR` — zero completion (trace not forwarded
yet)
- `TestDecodePacket_TraceFullyCompleted` — all hops completed

## How it works

The MeshCore firmware appends an SNR byte to `pkt->path[]` at each hop
that forwards a TRACE packet. The count of these SNR bytes (`path_len`)
indicates how far the trace actually got. CoreScope's decoder already
parsed the header path, but the TRACE-specific code overwrote it with
the payload hops (full intended route) without preserving the progress
information. Now we save that count first.

Fixes #651

---------

Co-authored-by: you <you@example.com>
2026-04-07 21:19:45 -07:00
Kpa-clawbot 088b4381c3 Fix: Hash Stats 'By Repeaters' includes non-repeater nodes (#654)
## Summary

The "By Repeaters" section on the Hash Stats analytics page was counting
**all** node types (companions, room servers, sensors, etc.) instead of
only repeaters. This made the "By Repeaters" distribution identical to
"Multi-Byte Hash Adopters", defeating the purpose of the breakdown.

Fixes #652

## Root Cause

`computeAnalyticsHashSizes()` in `cmd/server/store.go` built its
`byNode` map from advert packet data without cross-referencing node
roles from the node store. Both `distributionByRepeaters` and
`multiByteNodes` consumed this unfiltered map.

## Changes

### `cmd/server/store.go`
- Build a `nodeRoleByPK` lookup map from `getCachedNodesAndPM()` at the
start of the function
- Store `role` in each `byNode` entry when processing advert packets
- **`distributionByRepeaters`**: filter to only count nodes whose role
contains "repeater"
- **`multiByteNodes`**: include `role` field in output so the frontend
can filter/group by node type

### `cmd/server/coverage_test.go`
- Add `TestHashSizesDistributionByRepeatersFiltersRole`: verifies that
companion nodes are excluded from `distributionByRepeaters` but included
in `multiByteNodes` with correct role

### `cmd/server/routes_test.go`
- Fix `TestHashAnalyticsZeroHopAdvert`: invalidate node cache after DB
insert so role lookup works
- Fix `TestAnalyticsHashSizeSameNameDifferentPubkey`: insert node
records as repeaters + invalidate cache

## Testing

All `cmd/server` tests pass (68 insertions, 3 deletions across 3 files).

Co-authored-by: you <you@example.com>
2026-04-07 21:00:03 -07:00
you 1ff094b852 fix: staging compose — standard ports, remove 3GB memory limit
- HTTP: 82→80 (standard)
- MQTT: 1885→1883 (standard)
- Remove 3GB memory limit that was causing OOM on 1.5M observation DB
2026-04-08 03:50:07 +00:00
efiten 144e98bcdf fix: hide hash size for zero-hop direct adverts (#649) (#653)
## Fix: Zero-hop DIRECT packets report bogus hash_size

Closes #649

### Problem
When a DIRECT packet has zero hops (pathByte lower 6 bits = 0), the
generic `hash_size = (pathByte >> 6) + 1` formula produces a bogus value
(1-4) instead of 0/unknown. This causes incorrect hash size displays and
analytics for zero-hop direct adverts.

### Solution

**Frontend (JS):**
- `packets.js` and `nodes.js` now check `(pathByte & 0x3F) === 0` to
detect zero-hop packets and suppress bogus hash_size display.

**Backend (Go):**
- Both `cmd/server/decoder.go` and `cmd/ingestor/decoder.go` reset
`HashSize=0` for DIRECT packets where `pathByte & 0x3F == 0` (hash_count
is zero).
- TRACE packets are excluded since they use hashSize to parse hop data
from the payload.
- The condition uses `pathByte & 0x3F == 0` (not `pathByte == 0x00`) to
correctly handle the case where hash_size bits are non-zero but
hash_count is zero — matching the JS frontend approach.

### Testing

**Backend:**
- Added 4 tests each in `cmd/server/decoder_test.go` and
`cmd/ingestor/decoder_test.go`:
  - DIRECT + pathByte 0x00 → HashSize=0 
- DIRECT + pathByte 0x40 (hash_size bits set, hash_count=0) → HashSize=0

  - Non-DIRECT + pathByte 0x00 → HashSize=1 (unchanged) 
  - DIRECT + pathByte 0x01 (1 hop) → HashSize=1 (unchanged) 
- All existing tests pass (`go test ./...` in both cmd/server and
cmd/ingestor)

**Frontend:**
- Verified hash size display is suppressed for zero-hop direct adverts

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: you <you@example.com>
2026-04-07 19:39:15 -07:00
efiten bd54707987 feat: distance unit preference — km, mi, or auto (#621) (#646)
## Summary

- **`app.js`**: `getDistanceUnit()`, `formatDistance(km)`,
`formatDistanceRound(km)` helpers. Auto mode uses `navigator.language` —
miles for `en-US`, `en-GB`, `my`, `lr`; km everywhere else.
- **`customize-v2.js`**: Distance Unit preference (km / mi / auto) in
Display Settings panel. Stored in
`localStorage['meshcore-distance-unit']` via the existing apply
pipeline. Override dot and reset work. Display tab badge counts it.
- **`nodes.js`**: Neighbor table distance cell uses `formatDistance()`.
- **`analytics.js`**: All rendered km values use `formatDistance()` or
`formatDistanceRound()`. Column headers (`km`/`mi`) respond to the
active unit. Collision classification thresholds (Local < 50 km /
Regional 50–200 km / Distant > 200 km) also adapt.

Default is `auto` — no change for existing users unless their locale
maps to miles.

## Test plan

- [x] `node test-frontend-helpers.js` — 456 passed, 0 failed (10 new
formatDistance tests)
- [ ] Set unit to **mi** in customize → Neighbors table shows `7.6 mi`
instead of `12.3 km`
- [ ] Analytics → Distance tab → stat cards, leaderboard, and column
headers all show miles
- [ ] Collision tool → Local/Regional/Distant thresholds show `31 mi` /
`124 mi`
- [ ] Route patterns popup shows miles per hop and total
- [ ] Reset override dot → unit returns to auto

Closes #621

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: you <you@example.com>
2026-04-07 19:36:25 -07:00
efiten 1033555d00 fix: resolve originLat out-of-scope ReferenceError in resolveHopPositions (#647) (#648)
## Summary

- `originLat` was declared with `const` inside two block-scoped
`if`/`else` branches in `resolveHopPositions` (lines 1914 and 1921) but
referenced at line 1945 outside both blocks → `ReferenceError: originLat
is not defined` thrown on every packet render on the live page.
- Fix: introduce `senderLat` derived directly from
`payload.lat`/`payload.lon` at the point of use, using the same
null/zero guard as the existing declarations.

## Test plan

- [x] Live page no longer shows `ReferenceError: originLat is not
defined` in the console
- [x] Packet path animations still render correctly for packets with GPS
coords
- [x] Packets without GPS coords still handled (senderLat === null,
anchor not added)

Closes #647

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: you <you@example.com>
2026-04-07 19:31:43 -07:00
Kpa-clawbot 37be3dcd1f fix: Prefix Tool text consistency — use 'repeaters' everywhere (#642) (#645)
## Summary

Fixes remaining text inconsistencies in the Prefix Tool after #643 added
the repeater filter.

The Torvalds review on #643 flagged:
1. **Must-fix (already addressed in #643):** "About these numbers" text
— fixed
2. **Out-of-scope:** Empty state says "No nodes" should say "No
repeaters"

This PR fixes ALL remaining "nodes" references in the Prefix Tool to say
"repeaters":

- Empty state: "No nodes in the network yet" → "No repeaters in the
network yet"
- Stat card label: "Total nodes" → "Total repeaters"
- Region note link: "Check all nodes →" → "Check all repeaters →"
- Recommendation text: "With N nodes" → "With N repeaters"

Verified: zero occurrences of stale "all nodes", "Total nodes", or "No
nodes" remain in the Prefix Tool section.

Closes #642

Co-authored-by: you <you@example.com>
2026-04-06 15:43:43 -07:00
efiten 2bff89a546 feat: deep link P1 UI states — nodes tab, packets filters, channels node panel (#536) (#618)
## Summary

- **nodes.js**: `#/nodes?tab=repeater` and `#/nodes?search=foo` — role
tab and search query are now URL-addressable; state resets to defaults
on re-navigation
- **packets.js**: `#/packets?timeWindow=60` and
`#/packets?region=US-SFO` — time window and region filter survive
refresh and are shareable
- **channels.js**: `#/channels/{hash}?node=Name` — node detail panel is
URL-addressable; auto-opens on load, URL updates on open/close
- **region-filter.js**: adds `RegionFilter.setSelected(codesArray)` to
public API (needed for URL-driven init)

All changes use `history.replaceState` (not `pushState`) to avoid
polluting browser history. URL params override localStorage on load;
localStorage remains fallback.

## Implementation notes

- Router strips query string before computing `routeParam`, so all pages
read URL params directly from `location.hash`
- `buildNodesQuery(tab, searchStr)` and `buildPacketsUrl(timeWindowMin,
regionParam)` are pure functions exposed on `window` for testability
- Region URL param is applied after `RegionFilter.init()` via a
`_pendingUrlRegion` module-level var to keep ordering explicit
- `showNodeDetail` captures `selectedHash` before the async `lookupNode`
call to avoid stale URL construction

## Test plan

- [x] `node test-frontend-helpers.js` — 459 passed, 0 failed (includes 6
`buildNodesQuery` + 5 `buildPacketsUrl` unit tests)
- [x] Navigate to `#/nodes?tab=repeater` — Repeaters tab active on load
- [x] Click a tab, verify URL updates to `#/nodes?tab=room`
- [x] Navigate to `#/packets?timeWindow=60` — time window dropdown shows
60 min
- [x] Change time window, verify URL updates
- [x] Navigate to `#/channels/{hash}` and click a sender name — URL
updates to `?node=Name`
- [x] Reload that URL — node panel re-opens

Closes #536

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 15:43:25 -07:00
Kpa-clawbot dc079064f5 fix: clarify Hash Issues vs Prefix Tool collision data discrepancy (#643)
## Summary

Hash Issues and Prefix Tool tabs showed different collision counts
because the Prefix Tool was including all node types (companions, rooms,
sensors) while Hash Issues correctly filtered to repeaters only.

**Only repeaters matter for prefix collisions** — they're the nodes that
relay packets using hash-based addressing. Non-repeater collisions are
harmless noise.

## Changes
1. **Filtered Prefix Tool to repeaters only** — matches Hash Issues'
scope
2. **Updated explanatory text** — both tabs now clearly state they cover
repeaters
3. **Added cross-reference links** between the two tabs
4. **Added hash_size badges** in Prefix Tool results

Both tabs should now agree on collision counts for each byte size.

## Review Status
-  Self-review
-  Torvalds review — caught stale 'regardless of role' text, fixed
-  All tests pass

Fixes #642

---------

Co-authored-by: you <you@example.com>
2026-04-05 19:52:19 -07:00
Kpa-clawbot 43098a0705 refactor: DRY hash matrix rendering in analytics.js (#419) (#640)
## Summary

Fixes #419 — DRY violation in `renderHashMatrixFromServer` in
analytics.js.

The 1-byte and 2-byte branches shared ~80% identical HTML structure
(stat cards, matrix grid, detail panel, legend, tooltip init, click
handlers). This refactor extracts four shared helpers:

### New helpers

| Helper | Purpose |
|--------|---------|
| `classifyHashCell(count, isConfirmed, isPossible)` | Unified cell
classification → `{cls, bg}` |
| `hashCellTd(hex, cellSize, cls, bg, count, tipHtml, fontWeight)` |
Shared `<td>` element generation |
| `hashTooltipHtml(hexLabel, statusText, nodesHtml)` | Tooltip HTML
assembly |
| `renderHashMatrixPanel(el, statCards, cellFn, detailWidth, legend,
clickFn)` | Full matrix assembly pipeline |

### What changed

- Both branches now call `renderHashMatrixPanel()` with branch-specific
callbacks for cell rendering and detail click handling
- Cell classification logic (empty → taken → possible → collision with
heat scaling) is unified in `classifyHashCell()`
- Tooltip and `<td>` generation consolidated — no more duplicated inline
template strings
- Zero behavioral changes — all existing rendering, tooltips, and click
interactions are preserved

### Tests

All existing tests pass (445 frontend helpers, 62 packet filter, 29
aging).

Co-authored-by: you <you@example.com>
2026-04-05 18:31:23 -07:00
Kpa-clawbot 2d260bbfed test: behavioral vscroll tests replacing source-grep (#405, #409) (#641)
## Summary

Replace source-grep virtual scroll tests with behavioral tests that
exercise actual logic. Fixes #405, Fixes #409.

## What changed

### packets.js
- **Extracted `_calcVisibleRange()`** — pure function containing the
binary-search range calculation logic previously inline in
`renderVisibleRows()`. Takes offsets, scroll position, viewport
dimensions, row height, thead offset, and buffer as parameters. Returns
`{ startIdx, endIdx, firstEntry, lastEntry }`.
- `renderVisibleRows()` now calls `_calcVisibleRange()` instead of
inline math — no behavioral change.
- Exported via `_packetsTestAPI` for direct testing.

### test-frontend-helpers.js
- **Removed 8 source-grep tests** that used
`packetsSource.includes(...)` to check strings exist in source code (not
behavior):
  - "renderVisibleRows uses cumulative offsets not flat entry count"
  - "renderVisibleRows skips DOM rebuild when range unchanged"
  - "lazy row generation — HTML built only for visible slice"
  - "observer filter Set is hoisted, not recreated per-packet"
  - "packets.js display filter checks _children for observer match"
  - "packets.js WS filter checks _children for observer match"
  - "buildFlatRowHtml has null-safe decoded_json"
  - "pathHops null guard in buildFlatRowHtml / detail pane"
  - "destroy cleans up virtual scroll state"

- **Added 11 behavioral tests for `_calcVisibleRange()`** loaded from
the actual packets.js via sandbox:
  - Top of list (scroll = 0)
  - Middle of list (scroll to row 50)
  - Bottom of list (scroll past end)
  - Empty array (0 entries)
  - Single item
  - Exact row boundary
  - Large dataset (30K items)
  - Various row heights (24px instead of 36px)
  - Thead offset shifting visible range
  - Expanded groups with variable row counts
  - Buffer clamped at boundaries

- **Kept all existing behavioral tests**: `cumulativeRowOffsets`,
`getRowCount`, observer filter logic (#537).

## Test count
- Removed: 8 source-grep tests
- Added: 11 behavioral tests
- Net: +3 tests (446 total, 0 failures)

## Why
Source-grep tests (`packetsSource.includes('...')`) are brittle — they
break on refactors even when behavior is preserved, and they pass even
when the tested code is buggy. Behavioral tests exercise real
inputs/outputs and catch actual regressions.

Co-authored-by: you <you@example.com>
2026-04-05 18:30:30 -07:00
Kpa-clawbot 1dd763bf44 feat: sortable nodes list + neighbor/observer tables (M2, #620) (#639)
## Summary

Implements M2 of the table sorting spec (#620): sortable nodes list +
neighbor/observer tables.

### Changes

**Shared utility (`public/table-sort.js`)**
- IIFE pattern, no dependencies, no build step
- DOM-reorder sorting (no innerHTML rebuild) — preserves event listeners
- `data-value` attributes for raw sortable values, `data-type` on `<th>`
for type detection
- Built-in comparators: text (`localeCompare`), number, date, dBm
- `aria-sort` attributes, keyboard support (Enter/Space), sort arrows
- localStorage persistence with `storageKey` option
- `onSort` callback for custom re-render triggers

**Nodes list table**
- Wired via `TableSort.init` with `onSort` callback that triggers
`renderRows()`
- Keeps JS-array-level sorting for claimed/favorites pinning (TableSort
can't handle pinned rows)
- Replaces old `sortState`, `toggleSort()`, `sortArrow()` with TableSort
controller
- Test hooks preserved for backward compatibility (fallback state for
non-DOM tests)

**Neighbor table**
- Added `data-sort` and `data-value` attributes to all columns (name,
role, score, count, last_seen, distance)
- Default sort: count descending
- `TableSort.init` called after neighbor data renders

**Observer table (full detail page)**
- Converted from plain `<table>` to sortable table with data attributes
- Sortable columns: observer, region, packets, avg SNR, avg RSSI
- Default sort: packets descending

### Testing
- 18 new unit tests for `table-sort.js` (custom DOM mock, no jsdom
dependency)
- All 445 existing frontend tests pass unchanged
- All packet-filter (62) and aging (29) tests pass

### Note
This branch includes `table-sort.js` since M1 hasn't merged yet. The
utility code is identical to the M1 spec.

---------

Co-authored-by: you <you@example.com>
2026-04-05 18:29:54 -07:00
you 6b9946d9c6 docs: timestamp-based packet filter spec (#289) 2026-04-06 01:22:15 +00:00
Kpa-clawbot 243de9fba1 fix: consolidate CI pipeline — build, publish to GHCR, then deploy staging (#636)
## Consolidate CI Pipeline — Build + Publish to GHCR + Deploy Staging

### What
Merges the separate `publish.yml` workflow into `deploy.yml`, creating a
single CI/CD pipeline:

**`go-test → e2e-test → build-and-publish → deploy → publish-badges`**

### Why
- Two workflows doing overlapping builds was wasteful and error-prone
- `publish.yml` had a bug: `BUILD_TIME=$(date ...)` in a `with:` block
never executed (literal string)
- The old build job had duplicate/conflicting `APP_VERSION` assignments

### Changes
- **`build-and-publish` job** replaces old `build` job — builds locally
for staging, then does multi-arch GHCR push (gated to push events only,
PRs skip)
- **Build metadata** computed in a dedicated step, passed via
`GITHUB_OUTPUT` — no more shell expansion bugs
- **`APP_VERSION`** is `v1.2.3` on tag push, `edge` on master push
- **Deploy** now pulls the `edge` image from GHCR and tags for compose
compatibility, with fallback to local build
- **`publish.yml` deleted** — no duplicate workflow
- **Top-level `permissions`** block with `packages:write` for GHCR auth
- **Triggers** now include `tags: ['v*']` for release publishing

### Status
-  Rebased onto master
-  Self-reviewed (all checklist items pass)
-  Ready for merge

Co-authored-by: you <you@example.com>
2026-04-05 18:09:20 -07:00
Kpa-clawbot 6f3e3535c9 feat: shared table sort utility + packets table sorting (M1, #620) (#638)
## Summary

Implements M1 of the table sorting spec (#620): a shared `TableSort`
utility module and integration with the packets table.

### What's included

**1. `public/table-sort.js` — Shared sort utility (IIFE, no
dependencies)**
- `TableSort.init(tableEl, options)` — attaches click-to-sort on `<th
data-sort-key="...">` elements
- Built-in comparators: text (localeCompare), numeric, date (ISO), dBm
(strips suffix)
- NaN/null values sort last consistently
- Visual: ▲/▼ `<span class="sort-arrow">` appended to active column
header
- Accessibility: `aria-sort="ascending|descending|none"`, keyboard
support (Enter/Space)
- DOM reorder via `appendChild` loop (no innerHTML rebuild)
- `domReorder: false` option for virtual scroll tables (packets)
- `storageKey` option for localStorage persistence
- Custom comparator override per column
- `onSort(column, direction)` callback
- `destroy()` for clean teardown

**2. Packets table integration**
- All columns sortable: region, time, hash, size, HB, type, observer,
path, rpt
- Default sort: time descending (matches existing behavior)
- Uses `domReorder: false` + `onSort` callback to sort the data array,
then re-render via virtual scroll
- Works with both grouped and ungrouped views
- WebSocket updates respect active sort column
- Sort preference persisted in localStorage (`meshcore-packets-sort`)

**3. Tests — 22 unit tests (`test-table-sort.js`)**
- All 4 built-in comparators (text, numeric, date, dBm)
- NaN/null edge cases
- Direction toggle on click
- aria-sort attribute correctness
- Visual indicator (▲/▼) presence and updates
- onSort callback
- domReorder: false behavior
- destroy() cleanup
- Custom comparator override

### Performance

Packets table sorting works at the data array level (single `Array.sort`
call), not DOM level. Virtual scroll then renders only visible rows. No
new DOM nodes are created during sort — it's purely a data reorder +
re-render of the existing visible window. Expected sort time for 30K
packets: ~50-100ms (array sort) + existing virtual scroll render time.

Closes #620 (M1)

Co-authored-by: you <you@example.com>
2026-04-05 15:29:14 -07:00
Kpa-clawbot cae14da05e fix: implement DISABLE_CADDY env var in Docker entrypoint (#629) (#637)
## Summary

Implements the `DISABLE_CADDY` environment variable in the Docker
entrypoint, fixing #629.

## Problem

The `DISABLE_CADDY` env var was documented but had no effect — the
entrypoint only handled `DISABLE_MOSQUITTO`.

## Changes

### New supervisord configs
- **`supervisord-go-no-caddy.conf`** — mosquitto + ingestor + server (no
Caddy)
- **`supervisord-go-no-mosquitto-no-caddy.conf`** — ingestor + server
only

### Updated entrypoint (`docker/entrypoint-go.sh`)
Handles all 4 combinations:
| DISABLE_MOSQUITTO | DISABLE_CADDY | Config used |
|---|---|---|
| false | false | `supervisord.conf` (default) |
| true | false | `supervisord-no-mosquitto.conf` |
| false | true | `supervisord-no-caddy.conf` |
| true | true | `supervisord-no-mosquitto-no-caddy.conf` |

### Dockerfiles
Added COPY lines for the new configs in both `Dockerfile` and
`Dockerfile.go`.

## Testing

```bash
# Verify correct config selection
docker run -e DISABLE_CADDY=true corescope
# Should log: [config] Caddy reverse proxy disabled (DISABLE_CADDY=true)

docker run -e DISABLE_CADDY=true -e DISABLE_MOSQUITTO=true corescope
# Should log both disabled messages
```

Fixes #629

Co-authored-by: you <you@example.com>
2026-04-05 15:26:40 -07:00
52 changed files with 5213 additions and 720 deletions
+80 -18
View File
@@ -3,10 +3,15 @@ name: CI/CD Pipeline
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
@@ -18,8 +23,8 @@ env:
STAGING_CONTAINER: corescope-staging-go
# Pipeline (sequential, fail-fast):
# go-test → e2e-test → build → deploy → publish
# PRs stop after build. Master continues to deploy + publish.
# go-test → e2e-test → build-and-publish → deploy → publish-badges
# PRs stop after build-and-publish (no GHCR push). Master continues to deploy + badges.
jobs:
# ───────────────────────────────────────────────────────────────
@@ -231,51 +236,108 @@ jobs:
include-hidden-files: true
# ───────────────────────────────────────────────────────────────
# 3. Build Docker Image
# 3. Build & Publish Docker Image
# ───────────────────────────────────────────────────────────────
build:
name: "🏗️ Build Docker Image"
build-and-publish:
name: "🏗️ Build & Publish Docker Image"
needs: [e2e-test]
runs-on: [self-hosted, meshcore-runner-2]
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Node.js 22
uses: actions/setup-node@v5
with:
node-version: '22'
- name: Free disk space
run: |
docker system prune -af 2>/dev/null || true
docker builder prune -af 2>/dev/null || true
df -h /
- name: Build Go Docker image
- name: Compute build metadata
id: meta
run: |
echo "${GITHUB_SHA::7}" > .git-commit
APP_VERSION=$(node -p "require('./package.json').version") \
GIT_COMMIT="${GITHUB_SHA::7}" \
APP_VERSION=$(grep -oP 'APP_VERSION:-\K[^}]+' docker-compose.yml | head -1 || echo "3.0.0")
GIT_COMMIT=$(git rev-parse --short HEAD)
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
export APP_VERSION GIT_COMMIT BUILD_TIME
GIT_COMMIT="${GITHUB_SHA::7}"
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
APP_VERSION="${GITHUB_REF#refs/tags/}"
else
APP_VERSION="edge"
fi
echo "build_time=$BUILD_TIME" >> "$GITHUB_OUTPUT"
echo "git_commit=$GIT_COMMIT" >> "$GITHUB_OUTPUT"
echo "app_version=$APP_VERSION" >> "$GITHUB_OUTPUT"
echo "Build: version=$APP_VERSION commit=$GIT_COMMIT time=$BUILD_TIME"
- name: Build Go Docker image (local staging)
run: |
GIT_COMMIT="${{ steps.meta.outputs.git_commit }}" \
APP_VERSION="${{ steps.meta.outputs.app_version }}" \
BUILD_TIME="${{ steps.meta.outputs.build_time }}" \
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging build "$STAGING_SERVICE"
echo "Built Go staging image ✅"
- name: Set up Docker Buildx
if: github.event_name == 'push'
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
if: github.event_name == 'push'
id: docker-meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/kpa-clawbot/corescope
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=edge,branch=master
- name: Build and push to GHCR
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.meta.outputs.app_version }}
GIT_COMMIT=${{ steps.meta.outputs.git_commit }}
BUILD_TIME=${{ steps.meta.outputs.build_time }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ───────────────────────────────────────────────────────────────
# 4. Deploy Staging (master only)
# ───────────────────────────────────────────────────────────────
deploy:
name: "🚀 Deploy Staging"
if: github.event_name == 'push'
needs: [build]
needs: [build-and-publish]
runs-on: [self-hosted, meshcore-runner-2]
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Pull latest image from GHCR
run: |
# Try to pull the edge image from GHCR and tag for docker-compose compatibility
if docker pull ghcr.io/kpa-clawbot/corescope:edge; then
docker tag ghcr.io/kpa-clawbot/corescope:edge corescope-go:latest
echo "Pulled and tagged GHCR edge image ✅"
else
echo "⚠️ GHCR pull failed — falling back to locally built image"
fi
- name: Deploy staging
run: |
# Stop old container and release memory
-54
View File
@@ -1,54 +0,0 @@
name: Publish Docker Image
on:
push:
tags: ['v*']
branches: [master]
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/kpa-clawbot/corescope
tags: |
# On tag push: v1.2.3, v1.2, v1, latest
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
# On master push: edge
type=edge,branch=master
- uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ github.ref_name }}
GIT_COMMIT=${{ github.sha }}
BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
cache-from: type=gha
cache-to: type=gha,mode=max
+2
View File
@@ -42,6 +42,8 @@ RUN echo "unknown" > .git-commit
# Supervisor + Mosquitto + Caddy config
COPY docker/supervisord-go.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/supervisord-go-no-mosquitto.conf /etc/supervisor/conf.d/supervisord-no-mosquitto.conf
COPY docker/supervisord-go-no-caddy.conf /etc/supervisor/conf.d/supervisord-no-caddy.conf
COPY docker/supervisord-go-no-mosquitto-no-caddy.conf /etc/supervisor/conf.d/supervisord-no-mosquitto-no-caddy.conf
COPY docker/mosquitto.conf /etc/mosquitto/mosquitto.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile
+3
View File
@@ -40,6 +40,9 @@ RUN if [ ! -f .git-commit ]; then echo "unknown" > .git-commit; fi
# Supervisor + Mosquitto + Caddy config
COPY docker/supervisord-go.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/supervisord-go-no-mosquitto.conf /etc/supervisor/conf.d/supervisord-no-mosquitto.conf
COPY docker/supervisord-go-no-caddy.conf /etc/supervisor/conf.d/supervisord-no-caddy.conf
COPY docker/supervisord-go-no-mosquitto-no-caddy.conf /etc/supervisor/conf.d/supervisord-no-mosquitto-no-caddy.conf
COPY docker/mosquitto.conf /etc/mosquitto/mosquitto.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile
+2
View File
@@ -254,6 +254,8 @@ Contributions welcome. Please read [AGENTS.md](AGENTS.md) for coding conventions
**Live instance:** [analyzer.00id.net](https://analyzer.00id.net) — all API endpoints are public, no auth required.
**API Documentation:** CoreScope auto-generates an OpenAPI 3.0 spec. Browse the interactive Swagger UI at [`/api/docs`](https://analyzer.00id.net/api/docs) or fetch the machine-readable spec at [`/api/spec`](https://analyzer.00id.net/api/spec).
## License
MIT
+7 -7
View File
@@ -461,7 +461,7 @@ func TestDecodeAdvertLocationTruncated(t *testing.T) {
buf[100] = 0x11
// Only 4 bytes after flags — not enough for full location (needs 8)
p := decodeAdvert(buf[:105])
p := decodeAdvert(buf[:105], false)
if p.Error != "" {
t.Fatalf("error: %s", p.Error)
}
@@ -483,7 +483,7 @@ func TestDecodeAdvertFeat1Truncated(t *testing.T) {
buf[100] = 0x21
// Only 1 byte after flags — not enough for feat1 (needs 2)
p := decodeAdvert(buf[:102])
p := decodeAdvert(buf[:102], false)
if p.Feat1 != nil {
t.Error("feat1 should be nil with truncated data")
}
@@ -504,7 +504,7 @@ func TestDecodeAdvertFeat2Truncated(t *testing.T) {
buf[102] = 0x00
// Only 1 byte left — not enough for feat2
p := decodeAdvert(buf[:104])
p := decodeAdvert(buf[:104], false)
if p.Feat1 == nil {
t.Error("feat1 should be set")
}
@@ -544,7 +544,7 @@ func TestDecodeAdvertSensorBadTelemetry(t *testing.T) {
buf[105] = 0x20
buf[106] = 0x4E
p := decodeAdvert(buf[:107])
p := decodeAdvert(buf[:107], false)
if p.BatteryMv != nil {
t.Error("battery_mv=0 should be nil")
}
@@ -740,7 +740,7 @@ func TestDecodeAdvertSensorNoName(t *testing.T) {
buf[103] = 0xC4
buf[104] = 0x09
p := decodeAdvert(buf[:105])
p := decodeAdvert(buf[:105], false)
if p.Error != "" {
t.Fatalf("error: %s", p.Error)
}
@@ -835,7 +835,7 @@ func TestDecodePacketNoPathByteAfterHeader(t *testing.T) {
// Non-transport route, but only header byte (no path byte)
// Actually 0A alone = 1 byte, but we need >= 2
// Header + exactly at offset boundary
_, err := DecodePacket("0A", nil)
_, err := DecodePacket("0A", nil, false)
if err == nil {
t.Error("should error - too short")
}
@@ -856,7 +856,7 @@ func TestDecodeAdvertNameNoNull(t *testing.T) {
// Name without null terminator — goes to end of buffer
copy(buf[101:], []byte("LongNameNoNull"))
p := decodeAdvert(buf[:115])
p := decodeAdvert(buf[:115], false)
if p.Name != "LongNameNoNull" {
t.Errorf("name=%q, want LongNameNoNull", p.Name)
}
+6 -6
View File
@@ -576,7 +576,7 @@ func TestEndToEndIngest(t *testing.T) {
// Simulate full pipeline: decode + insert
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
decoded, err := DecodePacket(rawHex, nil)
decoded, err := DecodePacket(rawHex, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -764,7 +764,7 @@ func TestInsertTransmissionNilSNRRSSI(t *testing.T) {
func TestBuildPacketData(t *testing.T) {
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
decoded, err := DecodePacket(rawHex, nil)
decoded, err := DecodePacket(rawHex, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -818,7 +818,7 @@ func TestBuildPacketData(t *testing.T) {
func TestBuildPacketDataWithHops(t *testing.T) {
// A packet with actual hops in the path
raw := "0505AABBCCDDEE" + strings.Repeat("00", 10)
decoded, err := DecodePacket(raw, nil)
decoded, err := DecodePacket(raw, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -834,7 +834,7 @@ func TestBuildPacketDataWithHops(t *testing.T) {
}
func TestBuildPacketDataNilSNRRSSI(t *testing.T) {
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil)
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil, false)
msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)}
pkt := BuildPacketData(msg, decoded, "", "")
@@ -1624,7 +1624,7 @@ func TestObsTimestampIndexMigration(t *testing.T) {
func TestBuildPacketDataScoreAndDirection(t *testing.T) {
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
decoded, err := DecodePacket(rawHex, nil)
decoded, err := DecodePacket(rawHex, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -1647,7 +1647,7 @@ func TestBuildPacketDataScoreAndDirection(t *testing.T) {
}
func TestBuildPacketDataNilScoreDirection(t *testing.T) {
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil)
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil, false)
msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)}
pkt := BuildPacketData(msg, decoded, "", "")
+23 -5
View File
@@ -11,6 +11,8 @@ import (
"math"
"strings"
"unicode/utf8"
"github.com/meshcore-analyzer/sigvalidate"
)
// Route type constants (header bits 1-0)
@@ -109,6 +111,7 @@ type Payload struct {
Timestamp uint32 `json:"timestamp,omitempty"`
TimestampISO string `json:"timestampISO,omitempty"`
Signature string `json:"signature,omitempty"`
SignatureValid *bool `json:"signatureValid,omitempty"`
Flags *AdvertFlags `json:"flags,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
@@ -215,7 +218,7 @@ func decodeAck(buf []byte) Payload {
}
}
func decodeAdvert(buf []byte) Payload {
func decodeAdvert(buf []byte, validateSignatures bool) Payload {
if len(buf) < 100 {
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
}
@@ -233,6 +236,11 @@ func decodeAdvert(buf []byte) Payload {
Signature: signature,
}
if validateSignatures {
valid := sigvalidate.ValidateAdvertSignature(buf[0:32], buf[36:100], timestamp, appdata)
p.SignatureValid = &valid
}
if len(appdata) > 0 {
flags := appdata[0]
advType := int(flags & 0x0F)
@@ -506,7 +514,7 @@ func decodeTrace(buf []byte) Payload {
return p
}
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string, validateSignatures bool) Payload {
switch payloadType {
case PayloadREQ:
return decodeEncryptedPayload("REQ", buf)
@@ -517,7 +525,7 @@ func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) P
case PayloadACK:
return decodeAck(buf)
case PayloadADVERT:
return decodeAdvert(buf)
return decodeAdvert(buf, validateSignatures)
case PayloadGRP_TXT:
return decodeGrpTxt(buf, channelKeys)
case PayloadANON_REQ:
@@ -532,7 +540,7 @@ func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) P
}
// DecodePacket decodes a hex-encoded MeshCore packet.
func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) {
func DecodePacket(hexString string, channelKeys map[string]string, validateSignatures bool) (*DecodedPacket, error) {
hexString = strings.ReplaceAll(hexString, " ", "")
hexString = strings.ReplaceAll(hexString, "\n", "")
hexString = strings.ReplaceAll(hexString, "\r", "")
@@ -570,7 +578,7 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
offset += bytesConsumed
payloadBuf := buf[offset:]
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys)
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys, validateSignatures)
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
// path field. The header path byte still encodes hashSize in bits 6-7, which
@@ -587,6 +595,16 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
}
}
// Zero-hop direct packets have hash_count=0 (lower 6 bits of pathByte),
// which makes the generic formula yield a bogus hashSize. Reset to 0
// (unknown) so API consumers get correct data. We mask with 0x3F to check
// only hash_count, matching the JS frontend approach — the upper hash_size
// bits are meaningless when there are no hops. Skip TRACE packets — they
// use hashSize to parse hops from the payload above.
if (header.RouteType == RouteDirect || header.RouteType == RouteTransportDirect) && pathByte&0x3F == 0 && header.PayloadType != PayloadTRACE {
path.HashSize = 0
}
return &DecodedPacket{
Header: header,
TransportCodes: tc,
+190 -37
View File
@@ -2,6 +2,7 @@ package main
import (
"crypto/aes"
"crypto/ed25519"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
@@ -9,6 +10,8 @@ import (
"math"
"strings"
"testing"
"github.com/meshcore-analyzer/sigvalidate"
)
func TestDecodeHeaderRoutTypes(t *testing.T) {
@@ -55,7 +58,7 @@ func TestDecodeHeaderPayloadTypes(t *testing.T) {
func TestDecodePathZeroHops(t *testing.T) {
// 0x00: 0 hops, 1-byte hashes
pkt, err := DecodePacket("0500"+strings.Repeat("00", 10), nil)
pkt, err := DecodePacket("0500"+strings.Repeat("00", 10), nil, false)
if err != nil {
t.Fatal(err)
}
@@ -72,7 +75,7 @@ func TestDecodePathZeroHops(t *testing.T) {
func TestDecodePath1ByteHashes(t *testing.T) {
// 0x05: 5 hops, 1-byte hashes → 5 path bytes
pkt, err := DecodePacket("0505"+"AABBCCDDEE"+strings.Repeat("00", 10), nil)
pkt, err := DecodePacket("0505"+"AABBCCDDEE"+strings.Repeat("00", 10), nil, false)
if err != nil {
t.Fatal(err)
}
@@ -95,7 +98,7 @@ func TestDecodePath1ByteHashes(t *testing.T) {
func TestDecodePath2ByteHashes(t *testing.T) {
// 0x45: 5 hops, 2-byte hashes
pkt, err := DecodePacket("0545"+"AA11BB22CC33DD44EE55"+strings.Repeat("00", 10), nil)
pkt, err := DecodePacket("0545"+"AA11BB22CC33DD44EE55"+strings.Repeat("00", 10), nil, false)
if err != nil {
t.Fatal(err)
}
@@ -112,7 +115,7 @@ func TestDecodePath2ByteHashes(t *testing.T) {
func TestDecodePath3ByteHashes(t *testing.T) {
// 0x8A: 10 hops, 3-byte hashes
pkt, err := DecodePacket("058A"+strings.Repeat("AA11FF", 10)+strings.Repeat("00", 10), nil)
pkt, err := DecodePacket("058A"+strings.Repeat("AA11FF", 10)+strings.Repeat("00", 10), nil, false)
if err != nil {
t.Fatal(err)
}
@@ -131,7 +134,7 @@ func TestTransportCodes(t *testing.T) {
// Route type 0 (TRANSPORT_FLOOD) should have transport codes
// Firmware order: header + transport_codes(4) + path_len + path + payload
hex := "14" + "AABB" + "CCDD" + "00" + strings.Repeat("00", 10)
pkt, err := DecodePacket(hex, nil)
pkt, err := DecodePacket(hex, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -149,7 +152,7 @@ func TestTransportCodes(t *testing.T) {
}
// Route type 1 (FLOOD) should NOT have transport codes
pkt2, err := DecodePacket("0500"+strings.Repeat("00", 10), nil)
pkt2, err := DecodePacket("0500"+strings.Repeat("00", 10), nil, false)
if err != nil {
t.Fatal(err)
}
@@ -169,7 +172,7 @@ func TestDecodeAdvertFull(t *testing.T) {
name := "546573744E6F6465" // "TestNode"
hex := "1200" + pubkey + timestamp + signature + flags + lat + lon + name
pkt, err := DecodePacket(hex, nil)
pkt, err := DecodePacket(hex, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -227,7 +230,7 @@ func TestDecodeAdvertTypeEnums(t *testing.T) {
makeAdvert := func(flagsByte byte) *DecodedPacket {
hex := "1200" + strings.Repeat("AA", 32) + "00000000" + strings.Repeat("BB", 64) +
strings.ToUpper(string([]byte{hexDigit(flagsByte>>4), hexDigit(flagsByte & 0x0f)}))
pkt, err := DecodePacket(hex, nil)
pkt, err := DecodePacket(hex, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -272,7 +275,7 @@ func hexDigit(v byte) byte {
func TestDecodeAdvertNoLocationNoName(t *testing.T) {
hex := "1200" + strings.Repeat("CC", 32) + "00000000" + strings.Repeat("DD", 64) + "02"
pkt, err := DecodePacket(hex, nil)
pkt, err := DecodePacket(hex, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -291,7 +294,7 @@ func TestDecodeAdvertNoLocationNoName(t *testing.T) {
}
func TestGoldenFixtureTxtMsg(t *testing.T) {
pkt, err := DecodePacket("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976", nil)
pkt, err := DecodePacket("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976", nil, false)
if err != nil {
t.Fatal(err)
}
@@ -314,7 +317,7 @@ func TestGoldenFixtureTxtMsg(t *testing.T) {
func TestGoldenFixtureAdvert(t *testing.T) {
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
pkt, err := DecodePacket(rawHex, nil)
pkt, err := DecodePacket(rawHex, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -337,7 +340,7 @@ func TestGoldenFixtureAdvert(t *testing.T) {
func TestGoldenFixtureUnicodeAdvert(t *testing.T) {
rawHex := "120073CFF971E1CB5754A742C152B2D2E0EB108A19B246D663ED8898A72C4A5AD86EA6768E66694B025EDF6939D5C44CFF719C5D5520E5F06B20680A83AD9C2C61C3227BBB977A85EE462F3553445FECF8EDD05C234ECE217272E503F14D6DF2B1B9B133890C923CDF3002F8FDC1F85045414BF09F8CB3"
pkt, err := DecodePacket(rawHex, nil)
pkt, err := DecodePacket(rawHex, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -354,14 +357,14 @@ func TestGoldenFixtureUnicodeAdvert(t *testing.T) {
}
func TestDecodePacketTooShort(t *testing.T) {
_, err := DecodePacket("FF", nil)
_, err := DecodePacket("FF", nil, false)
if err == nil {
t.Error("expected error for 1-byte packet")
}
}
func TestDecodePacketInvalidHex(t *testing.T) {
_, err := DecodePacket("ZZZZ", nil)
_, err := DecodePacket("ZZZZ", nil, false)
if err == nil {
t.Error("expected error for invalid hex")
}
@@ -568,7 +571,7 @@ func TestDecodeTracePathParsing(t *testing.T) {
// Packet from issue #276: 260001807dca00000000007d547d
// Path byte 0x00 → hashSize=1, hops in payload at buf[9:] = 7d 54 7d
// Expected path: ["7D", "54", "7D"]
pkt, err := DecodePacket("260001807dca00000000007d547d", nil)
pkt, err := DecodePacket("260001807dca00000000007d547d", nil, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
@@ -590,7 +593,7 @@ func TestDecodeTracePathParsing(t *testing.T) {
}
func TestDecodeAdvertShort(t *testing.T) {
p := decodeAdvert(make([]byte, 50))
p := decodeAdvert(make([]byte, 50), false)
if p.Error != "too short for advert" {
t.Errorf("expected 'too short for advert' error, got %q", p.Error)
}
@@ -628,7 +631,7 @@ func TestDecodeEncryptedPayloadValid(t *testing.T) {
func TestDecodePayloadGRPData(t *testing.T) {
buf := []byte{0x01, 0x02, 0x03}
p := decodePayload(PayloadGRP_DATA, buf, nil)
p := decodePayload(PayloadGRP_DATA, buf, nil, false)
if p.Type != "UNKNOWN" {
t.Errorf("type=%s, want UNKNOWN", p.Type)
}
@@ -639,7 +642,7 @@ func TestDecodePayloadGRPData(t *testing.T) {
func TestDecodePayloadRAWCustom(t *testing.T) {
buf := []byte{0xFF, 0xFE}
p := decodePayload(PayloadRAW_CUSTOM, buf, nil)
p := decodePayload(PayloadRAW_CUSTOM, buf, nil, false)
if p.Type != "UNKNOWN" {
t.Errorf("type=%s, want UNKNOWN", p.Type)
}
@@ -647,49 +650,49 @@ func TestDecodePayloadRAWCustom(t *testing.T) {
func TestDecodePayloadAllTypes(t *testing.T) {
// REQ
p := decodePayload(PayloadREQ, make([]byte, 10), nil)
p := decodePayload(PayloadREQ, make([]byte, 10), nil, false)
if p.Type != "REQ" {
t.Errorf("REQ: type=%s", p.Type)
}
// RESPONSE
p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil)
p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil, false)
if p.Type != "RESPONSE" {
t.Errorf("RESPONSE: type=%s", p.Type)
}
// TXT_MSG
p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil)
p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil, false)
if p.Type != "TXT_MSG" {
t.Errorf("TXT_MSG: type=%s", p.Type)
}
// ACK
p = decodePayload(PayloadACK, make([]byte, 10), nil)
p = decodePayload(PayloadACK, make([]byte, 10), nil, false)
if p.Type != "ACK" {
t.Errorf("ACK: type=%s", p.Type)
}
// GRP_TXT
p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil)
p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil, false)
if p.Type != "GRP_TXT" {
t.Errorf("GRP_TXT: type=%s", p.Type)
}
// ANON_REQ
p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil)
p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil, false)
if p.Type != "ANON_REQ" {
t.Errorf("ANON_REQ: type=%s", p.Type)
}
// PATH
p = decodePayload(PayloadPATH, make([]byte, 10), nil)
p = decodePayload(PayloadPATH, make([]byte, 10), nil, false)
if p.Type != "PATH" {
t.Errorf("PATH: type=%s", p.Type)
}
// TRACE
p = decodePayload(PayloadTRACE, make([]byte, 20), nil)
p = decodePayload(PayloadTRACE, make([]byte, 20), nil, false)
if p.Type != "TRACE" {
t.Errorf("TRACE: type=%s", p.Type)
}
@@ -925,7 +928,7 @@ func TestComputeContentHashLongFallback(t *testing.T) {
func TestDecodePacketWithWhitespace(t *testing.T) {
raw := "0A 00 D6 9F D7 A5 A7 47 5D B0 73 37 74 9A E6 1F A5 3A 47 88 E9 76"
pkt, err := DecodePacket(raw, nil)
pkt, err := DecodePacket(raw, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -936,7 +939,7 @@ func TestDecodePacketWithWhitespace(t *testing.T) {
func TestDecodePacketWithNewlines(t *testing.T) {
raw := "0A00\nD69F\r\nD7A5A7475DB07337749AE61FA53A4788E976"
pkt, err := DecodePacket(raw, nil)
pkt, err := DecodePacket(raw, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -947,7 +950,7 @@ func TestDecodePacketWithNewlines(t *testing.T) {
func TestDecodePacketTransportRouteTooShort(t *testing.T) {
// TRANSPORT_FLOOD (route=0) but only 2 bytes total → too short for transport codes
_, err := DecodePacket("1400", nil)
_, err := DecodePacket("1400", nil, false)
if err == nil {
t.Error("expected error for transport route with too-short buffer")
}
@@ -1007,7 +1010,7 @@ func TestDecodeHeaderUnknownTypes(t *testing.T) {
func TestDecodePayloadMultipart(t *testing.T) {
// MULTIPART (0x0A) falls through to default → UNKNOWN
p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil)
p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil, false)
if p.Type != "UNKNOWN" {
t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type)
}
@@ -1015,7 +1018,7 @@ func TestDecodePayloadMultipart(t *testing.T) {
func TestDecodePayloadControl(t *testing.T) {
// CONTROL (0x0B) falls through to default → UNKNOWN
p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil)
p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil, false)
if p.Type != "UNKNOWN" {
t.Errorf("CONTROL type=%s, want UNKNOWN", p.Type)
}
@@ -1039,7 +1042,7 @@ func TestDecodePathTruncatedBuffer(t *testing.T) {
func TestDecodeFloodAdvert5Hops(t *testing.T) {
// From test-decoder.js Test 1
raw := "11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172"
pkt, err := DecodePacket(raw, nil)
pkt, err := DecodePacket(raw, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -1410,7 +1413,7 @@ func TestDecodeAdvertWithTelemetry(t *testing.T) {
name + nullTerm +
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
pkt, err := DecodePacket(hexStr, nil)
pkt, err := DecodePacket(hexStr, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -1449,7 +1452,7 @@ func TestDecodeAdvertWithTelemetryNegativeTemp(t *testing.T) {
name + nullTerm +
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
pkt, err := DecodePacket(hexStr, nil)
pkt, err := DecodePacket(hexStr, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -1476,7 +1479,7 @@ func TestDecodeAdvertWithoutTelemetry(t *testing.T) {
name := hex.EncodeToString([]byte("Node1"))
hexStr := "1200" + pubkey + timestamp + signature + flags + name
pkt, err := DecodePacket(hexStr, nil)
pkt, err := DecodePacket(hexStr, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -1503,7 +1506,7 @@ func TestDecodeAdvertNonSensorIgnoresTelemetryBytes(t *testing.T) {
extraBytes := "B40ED403" // battery-like and temp-like bytes
hexStr := "1200" + pubkey + timestamp + signature + flags + name + nullTerm + extraBytes
pkt, err := DecodePacket(hexStr, nil)
pkt, err := DecodePacket(hexStr, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -1531,7 +1534,7 @@ func TestDecodeAdvertTelemetryZeroTemp(t *testing.T) {
name + nullTerm +
hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE)
pkt, err := DecodePacket(hexStr, nil)
pkt, err := DecodePacket(hexStr, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -1542,3 +1545,153 @@ func TestDecodeAdvertTelemetryZeroTemp(t *testing.T) {
t.Errorf("temperature_c=%f, want 0.0", *pkt.Payload.TemperatureC)
}
}
func repeatHex(byteHex string, n int) string {
s := ""
for i := 0; i < n; i++ {
s += byteHex
}
return s
}
func TestZeroHopDirectHashSize(t *testing.T) {
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
// pathByte=0x00 → hash_count=0, hash_size bits=0 → should get HashSize=0
hex := "02" + "00" + repeatHex("AA", 20)
pkt, err := DecodePacket(hex, nil, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 0 {
t.Errorf("DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
}
}
func TestZeroHopDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
// pathByte=0x40 → hash_count=0, hash_size bits=01 → should still get HashSize=0
hex := "02" + "40" + repeatHex("AA", 20)
pkt, err := DecodePacket(hex, nil, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 0 {
t.Errorf("DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
}
}
func TestNonDirectZeroPathByteKeepsHashSize(t *testing.T) {
// FLOOD (RouteType=1) + REQ (PayloadType=0) → header byte = 0x01
// pathByte=0x00 → non-DIRECT should keep HashSize=1
hex := "01" + "00" + repeatHex("AA", 20)
pkt, err := DecodePacket(hex, nil, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 1 {
t.Errorf("FLOOD zero pathByte: want HashSize=1, got %d", pkt.Path.HashSize)
}
}
func TestDirectNonZeroHopKeepsHashSize(t *testing.T) {
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
// pathByte=0x01 → hash_count=1, hash_size=1 → should keep HashSize=1
hex := "02" + "01" + repeatHex("BB", 21)
pkt, err := DecodePacket(hex, nil, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 1 {
t.Errorf("DIRECT with 1 hop: want HashSize=1, got %d", pkt.Path.HashSize)
}
}
func TestValidateAdvertSignature(t *testing.T) {
// Generate a real ed25519 key pair
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
var timestamp uint32 = 1234567890
appdata := []byte{0x02, 0x11, 0x22} // flags + some data
// Build the message the same way ValidateAdvertSignature does
message := make([]byte, 32+4+len(appdata))
copy(message[0:32], pub)
binary.LittleEndian.PutUint32(message[32:36], timestamp)
copy(message[36:], appdata)
sig := ed25519.Sign(priv, message)
// Valid signature
if !sigvalidate.ValidateAdvertSignature(pub, sig, timestamp, appdata) {
t.Error("expected valid signature")
}
// Tampered appdata → invalid
badAppdata := []byte{0x03, 0x11, 0x22}
if sigvalidate.ValidateAdvertSignature(pub, sig, timestamp, badAppdata) {
t.Error("expected invalid signature with tampered appdata")
}
// Wrong timestamp → invalid
if sigvalidate.ValidateAdvertSignature(pub, sig, timestamp+1, appdata) {
t.Error("expected invalid signature with wrong timestamp")
}
// Wrong length pubkey → false
if sigvalidate.ValidateAdvertSignature([]byte{0xAA, 0xBB}, sig, timestamp, appdata) {
t.Error("expected false for short pubkey")
}
// Wrong length signature → false
if sigvalidate.ValidateAdvertSignature(pub, []byte{0xAA, 0xBB}, timestamp, appdata) {
t.Error("expected false for short signature")
}
}
func TestDecodeAdvertWithSignatureValidation(t *testing.T) {
// Generate key pair
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
var timestamp uint32 = 1000000
appdata := []byte{0x02} // repeater type, no location
// Build signed message
message := make([]byte, 32+4+len(appdata))
copy(message[0:32], pub)
binary.LittleEndian.PutUint32(message[32:36], timestamp)
copy(message[36:], appdata)
sig := ed25519.Sign(priv, message)
// Build advert buffer: pubkey(32) + timestamp(4) + signature(64) + appdata
buf := make([]byte, 0, 101)
buf = append(buf, pub...)
ts := make([]byte, 4)
binary.LittleEndian.PutUint32(ts, timestamp)
buf = append(buf, ts...)
buf = append(buf, sig...)
buf = append(buf, appdata...)
// With validation enabled
p := decodeAdvert(buf, true)
if p.Error != "" {
t.Fatalf("decode error: %s", p.Error)
}
if p.SignatureValid == nil {
t.Fatal("SignatureValid should be set when validation enabled")
}
if !*p.SignatureValid {
t.Error("expected valid signature")
}
// Without validation
p2 := decodeAdvert(buf, false)
if p2.SignatureValid != nil {
t.Error("SignatureValid should be nil when validation disabled")
}
}
+3
View File
@@ -5,11 +5,14 @@ go 1.22
require (
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/meshcore-analyzer/geofilter v0.0.0
github.com/meshcore-analyzer/sigvalidate v0.0.0-00010101000000-000000000000
modernc.org/sqlite v1.34.5
)
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
replace github.com/meshcore-analyzer/sigvalidate => ../../internal/sigvalidate
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
+1 -1
View File
@@ -248,7 +248,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
// Format 1: Raw packet (meshcoretomqtt / Cisien format)
rawHex, _ := msg["raw"].(string)
if rawHex != "" {
decoded, err := DecodePacket(rawHex, channelKeys)
decoded, err := DecodePacket(rawHex, channelKeys, false)
if err != nil {
log.Printf("MQTT [%s] decode error: %v", tag, err)
return
+47
View File
@@ -2198,6 +2198,53 @@ func TestStoreGetAnalyticsHashSizes(t *testing.T) {
})
}
func TestHashSizesDistributionByRepeatersFiltersRole(t *testing.T) {
db := setupRichTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
store.Load()
result := store.GetAnalyticsHashSizes("")
// distributionByRepeaters should only count repeater nodes.
// Rich test DB: aabbccdd11223344 = repeater (hash size 2), eeff00112233aabb = companion (hash size 3).
dbr, ok := result["distributionByRepeaters"].(map[string]int)
if !ok {
t.Fatal("expected distributionByRepeaters map")
}
// Only the repeater node should be counted.
if dbr["3"] != 0 {
t.Errorf("distributionByRepeaters[3] = %d, want 0 (companion should be excluded)", dbr["3"])
}
if dbr["2"] != 1 {
t.Errorf("distributionByRepeaters[2] = %d, want 1 (repeater)", dbr["2"])
}
// multiByteNodes should include role field for frontend filtering.
mbn, ok := result["multiByteNodes"].([]map[string]interface{})
if !ok {
t.Fatal("expected multiByteNodes slice")
}
for _, node := range mbn {
if _, hasRole := node["role"]; !hasRole {
t.Errorf("multiByteNodes entry missing 'role' field: %v", node)
}
}
// Verify companion is included in multiByteNodes (it's multi-byte) with correct role.
foundCompanion := false
for _, node := range mbn {
if node["pubkey"] == "eeff00112233aabb" {
foundCompanion = true
if node["role"] != "companion" {
t.Errorf("companion node role = %v, want 'companion'", node["role"])
}
}
}
if !foundCompanion {
t.Error("expected companion node in multiByteNodes (multi-byte adopters should include all roles)")
}
}
func TestStoreGetAnalyticsSubpaths(t *testing.T) {
db := setupRichTestDB(t)
defer db.Close()
+10 -8
View File
@@ -72,7 +72,8 @@ func setupTestDB(t *testing.T) *DB {
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL
timestamp INTEGER NOT NULL,
resolved_path TEXT
);
CREATE TABLE IF NOT EXISTS observer_metrics (
@@ -95,7 +96,7 @@ func setupTestDB(t *testing.T) *DB {
t.Fatal(err)
}
return &DB{conn: conn, isV3: true}
return &DB{conn: conn, isV3: true, hasResolvedPath: true}
}
func seedTestData(t *testing.T, db *DB) {
@@ -132,14 +133,15 @@ func seedTestData(t *testing.T, db *DB) {
VALUES ('AA1F', 'def456abc1230099', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000100,"timestampISO":"2023-11-14T22:14:40.000Z","signature":"fedcba","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}')`, yesterday)
// Seed observations (use unix timestamps)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 12.5, -90, '["aa","bb"]', ?)`, recentEpoch)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 2, 8.0, -95, '["aa"]', ?)`, recentEpoch-100)
// resolved_path contains full pubkeys parallel to path_json hops
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
VALUES (1, 1, 12.5, -90, '["aa","bb"]', ?, '["aabbccdd11223344","eeff00112233aabb"]')`, recentEpoch)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
VALUES (1, 2, 8.0, -95, '["aa"]', ?, '["aabbccdd11223344"]')`, recentEpoch-100)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 1, 15.0, -85, '[]', ?)`, yesterdayEpoch)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (3, 1, 10.0, -92, '["cc"]', ?)`, yesterdayEpoch)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
VALUES (3, 1, 10.0, -92, '["cc"]', ?, '["1122334455667788"]')`, yesterdayEpoch)
}
func TestGetStats(t *testing.T) {
+33 -8
View File
@@ -9,6 +9,8 @@ import (
"math"
"strings"
"time"
"github.com/meshcore-analyzer/sigvalidate"
)
// Route type constants (header bits 1-0)
@@ -60,9 +62,10 @@ type TransportCodes struct {
// Path holds decoded path/hop information.
type Path struct {
HashSize int `json:"hashSize"`
HashCount int `json:"hashCount"`
Hops []string `json:"hops"`
HashSize int `json:"hashSize"`
HashCount int `json:"hashCount"`
Hops []string `json:"hops"`
HopsCompleted *int `json:"hopsCompleted,omitempty"`
}
// AdvertFlags holds decoded advert flag bits.
@@ -91,6 +94,7 @@ type Payload struct {
Timestamp uint32 `json:"timestamp,omitempty"`
TimestampISO string `json:"timestampISO,omitempty"`
Signature string `json:"signature,omitempty"`
SignatureValid *bool `json:"signatureValid,omitempty"`
Flags *AdvertFlags `json:"flags,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
@@ -187,7 +191,7 @@ func decodeAck(buf []byte) Payload {
}
}
func decodeAdvert(buf []byte) Payload {
func decodeAdvert(buf []byte, validateSignatures bool) Payload {
if len(buf) < 100 {
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
}
@@ -205,6 +209,11 @@ func decodeAdvert(buf []byte) Payload {
Signature: signature,
}
if validateSignatures {
valid := sigvalidate.ValidateAdvertSignature(buf[0:32], buf[36:100], timestamp, appdata)
p.SignatureValid = &valid
}
if len(appdata) > 0 {
flags := appdata[0]
advType := int(flags & 0x0F)
@@ -307,7 +316,7 @@ func decodeTrace(buf []byte) Payload {
return p
}
func decodePayload(payloadType int, buf []byte) Payload {
func decodePayload(payloadType int, buf []byte, validateSignatures bool) Payload {
switch payloadType {
case PayloadREQ:
return decodeEncryptedPayload("REQ", buf)
@@ -318,7 +327,7 @@ func decodePayload(payloadType int, buf []byte) Payload {
case PayloadACK:
return decodeAck(buf)
case PayloadADVERT:
return decodeAdvert(buf)
return decodeAdvert(buf, validateSignatures)
case PayloadGRP_TXT:
return decodeGrpTxt(buf)
case PayloadANON_REQ:
@@ -333,7 +342,7 @@ func decodePayload(payloadType int, buf []byte) Payload {
}
// DecodePacket decodes a hex-encoded MeshCore packet.
func DecodePacket(hexString string) (*DecodedPacket, error) {
func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, error) {
hexString = strings.ReplaceAll(hexString, " ", "")
hexString = strings.ReplaceAll(hexString, "\n", "")
hexString = strings.ReplaceAll(hexString, "\r", "")
@@ -371,12 +380,17 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
offset += bytesConsumed
payloadBuf := buf[offset:]
payload := decodePayload(header.PayloadType, payloadBuf)
payload := decodePayload(header.PayloadType, payloadBuf, validateSignatures)
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
// path field. The header path byte still encodes hashSize in bits 6-7, which
// we use to split the payload path data into individual hop prefixes.
// The header path contains SNR bytes — one per hop that actually forwarded.
// We expose hopsCompleted (count of SNR bytes) so consumers can distinguish
// how far the trace got vs the full intended route.
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
// The header path hops count represents SNR entries = completed hops
hopsCompleted := path.HashCount
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && path.HashSize > 0 {
hops := make([]string, 0, len(pathBytes)/path.HashSize)
@@ -385,9 +399,20 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
}
path.Hops = hops
path.HashCount = len(hops)
path.HopsCompleted = &hopsCompleted
}
}
// Zero-hop direct packets have hash_count=0 (lower 6 bits of pathByte),
// which makes the generic formula yield a bogus hashSize. Reset to 0
// (unknown) so API consumers get correct data. We mask with 0x3F to check
// only hash_count, matching the JS frontend approach — the upper hash_size
// bits are meaningless when there are no hops. Skip TRACE packets — they
// use hashSize to parse hops from the payload above.
if (header.RouteType == RouteDirect || header.RouteType == RouteTransportDirect) && pathByte&0x3F == 0 && header.PayloadType != PayloadTRACE {
path.HashSize = 0
}
return &DecodedPacket{
Header: header,
TransportCodes: tc,
+252 -2
View File
@@ -1,7 +1,11 @@
package main
import (
"crypto/ed25519"
"encoding/binary"
"testing"
"github.com/meshcore-analyzer/sigvalidate"
)
func TestDecodeHeader_TransportFlood(t *testing.T) {
@@ -65,7 +69,7 @@ func TestDecodePacket_TransportFloodHasCodes(t *testing.T) {
// Path byte: 0x00 (hashSize=1, hashCount=0)
// Payload: at least some bytes for GRP_TXT
hex := "14AABBCCDD00112233445566778899"
pkt, err := DecodePacket(hex)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -85,7 +89,7 @@ func TestDecodePacket_FloodHasNoCodes(t *testing.T) {
// Path byte: 0x00 (no hops)
// Some payload bytes
hex := "110011223344556677889900AABBCCDD"
pkt, err := DecodePacket(hex)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -235,6 +239,87 @@ func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantE
t.Errorf("range %q not found in %v", label, rangeLabels(ranges))
}
func TestZeroHopDirectHashSize(t *testing.T) {
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
// pathByte=0x00 → hash_count=0, hash_size bits=0 → should get HashSize=0
// Need at least a few payload bytes after pathByte.
hex := "02" + "00" + repeatHex("AA", 20)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 0 {
t.Errorf("DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
}
}
func TestZeroHopDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
// pathByte=0x40 → hash_count=0, hash_size bits=01 → should still get HashSize=0
// because hash_count is zero (lower 6 bits are 0).
hex := "02" + "40" + repeatHex("AA", 20)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 0 {
t.Errorf("DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
}
}
func TestZeroHopTransportDirectHashSize(t *testing.T) {
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
// 4 bytes transport codes + pathByte=0x00 → hash_count=0 → should get HashSize=0
hex := "03" + "11223344" + "00" + repeatHex("AA", 20)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 0 {
t.Errorf("TRANSPORT_DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
}
}
func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
// 4 bytes transport codes + pathByte=0xC0 → hash_count=0, hash_size bits=11 → should still get HashSize=0
hex := "03" + "11223344" + "C0" + repeatHex("AA", 20)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 0 {
t.Errorf("TRANSPORT_DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
}
}
func TestNonDirectZeroPathByteKeepsHashSize(t *testing.T) {
// FLOOD (RouteType=1) + REQ (PayloadType=0) → header byte = 0x01
// pathByte=0x00 → even though hash_count=0, non-DIRECT should keep HashSize=1
hex := "01" + "00" + repeatHex("AA", 20)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 1 {
t.Errorf("FLOOD zero pathByte: want HashSize=1 (unchanged), got %d", pkt.Path.HashSize)
}
}
func TestDirectNonZeroHopKeepsHashSize(t *testing.T) {
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
// pathByte=0x01 → hash_count=1, hash_size=1 → should keep HashSize=1
// Need 1 hop hash byte after pathByte.
hex := "02" + "01" + repeatHex("BB", 21)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 1 {
t.Errorf("DIRECT with 1 hop: want HashSize=1, got %d", pkt.Path.HashSize)
}
}
func repeatHex(byteHex string, n int) string {
s := ""
for i := 0; i < n; i++ {
@@ -242,3 +327,168 @@ func repeatHex(byteHex string, n int) string {
}
return s
}
func TestDecodePacket_TraceHopsCompleted(t *testing.T) {
// Build a TRACE packet:
// header: route=FLOOD(1), payload=TRACE(9), version=0 → (0<<6)|(9<<2)|1 = 0x25
// path_length: hash_size bits=0b00 (1-byte), hash_count=2 (2 SNR bytes) → 0x02
// path: 2 SNR bytes: 0xAA, 0xBB
// payload: tag(4 LE) + authCode(4 LE) + flags(1) + 4 hop hashes (1 byte each)
hex := "2502AABB" + // header + path_length + 2 SNR bytes
"01000000" + // tag = 1
"02000000" + // authCode = 2
"00" + // flags = 0
"DEADBEEF" // 4 hops (1-byte hash each)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if pkt.Payload.Type != "TRACE" {
t.Fatalf("expected TRACE, got %s", pkt.Payload.Type)
}
// Full intended route = 4 hops from payload
if len(pkt.Path.Hops) != 4 {
t.Errorf("expected 4 hops, got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops)
}
// HopsCompleted = 2 (from header path SNR count)
if pkt.Path.HopsCompleted == nil {
t.Fatal("expected HopsCompleted to be set")
}
if *pkt.Path.HopsCompleted != 2 {
t.Errorf("expected HopsCompleted=2, got %d", *pkt.Path.HopsCompleted)
}
}
func TestDecodePacket_TraceNoSNR(t *testing.T) {
// TRACE with 0 SNR bytes (trace hasn't been forwarded yet)
// path_length: hash_size=0b00 (1-byte), hash_count=0 → 0x00
hex := "2500" + // header + path_length (0 hops in header)
"01000000" + // tag
"02000000" + // authCode
"00" + // flags
"AABBCC" // 3 hops intended
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if pkt.Path.HopsCompleted == nil {
t.Fatal("expected HopsCompleted to be set")
}
if *pkt.Path.HopsCompleted != 0 {
t.Errorf("expected HopsCompleted=0, got %d", *pkt.Path.HopsCompleted)
}
if len(pkt.Path.Hops) != 3 {
t.Errorf("expected 3 hops, got %d", len(pkt.Path.Hops))
}
}
func TestDecodePacket_TraceFullyCompleted(t *testing.T) {
// TRACE where all hops completed (SNR count = hop count)
// path_length: hash_size=0b00 (1-byte), hash_count=3 → 0x03
hex := "2503AABBCC" + // header + path_length + 3 SNR bytes
"01000000" + // tag
"02000000" + // authCode
"00" + // flags
"DDEEFF" // 3 hops intended
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if pkt.Path.HopsCompleted == nil {
t.Fatal("expected HopsCompleted to be set")
}
if *pkt.Path.HopsCompleted != 3 {
t.Errorf("expected HopsCompleted=3, got %d", *pkt.Path.HopsCompleted)
}
if len(pkt.Path.Hops) != 3 {
t.Errorf("expected 3 hops, got %d", len(pkt.Path.Hops))
}
}
func TestValidateAdvertSignature(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
var timestamp uint32 = 1234567890
appdata := []byte{0x02, 0x11, 0x22}
message := make([]byte, 32+4+len(appdata))
copy(message[0:32], pub)
binary.LittleEndian.PutUint32(message[32:36], timestamp)
copy(message[36:], appdata)
sig := ed25519.Sign(priv, message)
// Valid signature
if !sigvalidate.ValidateAdvertSignature(pub, sig, timestamp, appdata) {
t.Error("expected valid signature")
}
// Tampered appdata
if sigvalidate.ValidateAdvertSignature(pub, sig, timestamp, []byte{0x03, 0x11, 0x22}) {
t.Error("expected invalid with tampered appdata")
}
// Wrong timestamp
if sigvalidate.ValidateAdvertSignature(pub, sig, timestamp+1, appdata) {
t.Error("expected invalid with wrong timestamp")
}
// Short pubkey
if sigvalidate.ValidateAdvertSignature([]byte{0xAA}, sig, timestamp, appdata) {
t.Error("expected false for short pubkey")
}
// Short signature
if sigvalidate.ValidateAdvertSignature(pub, []byte{0xBB}, timestamp, appdata) {
t.Error("expected false for short signature")
}
}
func TestDecodeAdvertWithSignatureValidation(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
var timestamp uint32 = 1000000
appdata := []byte{0x02} // repeater type
message := make([]byte, 32+4+len(appdata))
copy(message[0:32], pub)
binary.LittleEndian.PutUint32(message[32:36], timestamp)
copy(message[36:], appdata)
sig := ed25519.Sign(priv, message)
// Build advert buffer: pubkey(32) + timestamp(4) + signature(64) + appdata
buf := make([]byte, 0, 101)
buf = append(buf, pub...)
ts := make([]byte, 4)
binary.LittleEndian.PutUint32(ts, timestamp)
buf = append(buf, ts...)
buf = append(buf, sig...)
buf = append(buf, appdata...)
// With validation
p := decodeAdvert(buf, true)
if p.Error != "" {
t.Fatalf("decode error: %s", p.Error)
}
if p.SignatureValid == nil {
t.Fatal("SignatureValid should be set when validation enabled")
}
if !*p.SignatureValid {
t.Error("expected valid signature")
}
// Without validation
p2 := decodeAdvert(buf, false)
if p2.SignatureValid != nil {
t.Error("SignatureValid should be nil when validation disabled")
}
}
+3
View File
@@ -6,11 +6,14 @@ require (
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/meshcore-analyzer/geofilter v0.0.0
github.com/meshcore-analyzer/sigvalidate v0.0.0-00010101000000-000000000000
modernc.org/sqlite v1.34.5
)
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
replace github.com/meshcore-analyzer/sigvalidate => ../../internal/sigvalidate
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
+13 -2
View File
@@ -930,7 +930,7 @@ func (s *Server) handleDecode(w http.ResponseWriter, r *http.Request) {
writeError(w, 400, "hex is required")
return
}
decoded, err := DecodePacket(hexStr)
decoded, err := DecodePacket(hexStr, true)
if err != nil {
writeError(w, 400, err.Error())
return
@@ -962,7 +962,7 @@ func (s *Server) handlePostPacket(w http.ResponseWriter, r *http.Request) {
writeError(w, 400, "hex is required")
return
}
decoded, err := DecodePacket(hexStr)
decoded, err := DecodePacket(hexStr, false)
if err != nil {
writeError(w, 400, err.Error())
return
@@ -1176,6 +1176,17 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
}
}
// Post-filter: verify target node actually appears in each candidate's resolved_path.
// The byPathHop index uses short prefixes which can collide (e.g. "c0" matches multiple nodes).
// We lean on resolved_path (from neighbor affinity graph) to disambiguate.
filtered := candidates[:0] // reuse backing array
for _, tx := range candidates {
if nodeInResolvedPath(tx, lowerPK) {
filtered = append(filtered, tx)
}
}
candidates = filtered
type pathAgg struct {
Hops []PathHopResp
Count int
+123
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -2451,6 +2452,7 @@ func TestHashAnalyticsZeroHopAdvert(t *testing.T) {
pk := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'ZeroHop', 'repeater')", pk)
store.InvalidateNodeCache()
decoded := `{"name":"ZeroHop","pubKey":"` + pk + `"}`
// header 0x05 → routeType=1 (FLOOD), pathByte=0x00 → hashSize=1
@@ -2504,6 +2506,11 @@ func TestAnalyticsHashSizeSameNameDifferentPubkey(t *testing.T) {
pk1 := "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeee1111"
pk2 := "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeee2222"
// Insert both nodes as repeaters so they appear in distributionByRepeaters.
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'SameName', 'repeater')", pk1)
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'SameName', 'repeater')", pk2)
store.InvalidateNodeCache()
decoded1 := `{"name":"SameName","pubKey":"` + pk1 + `"}`
decoded2 := `{"name":"SameName","pubKey":"` + pk2 + `"}`
@@ -3532,6 +3539,122 @@ func TestNodePathsEndpointUsesIndex(t *testing.T) {
}
}
func TestNodePathsPrefixCollisionFilter(t *testing.T) {
// Two nodes share the "aa" prefix: TestRepeater (aabbccdd11223344) and a
// second node (aacafe0000000000). Packets whose resolved_path points to
// the second node must NOT appear when querying TestRepeater's paths.
srv, router := setupTestServer(t)
// Manually inject a transmission whose raw path contains "aa" but whose
// resolved_path points to the other node (aacafe0000000000).
now := time.Now().UTC()
recent := now.Add(-30 * time.Minute).Format(time.RFC3339)
recentEpoch := now.Add(-30 * time.Minute).Unix()
// Insert a second node with the same 2-char prefix
srv.db.conn.Exec(`INSERT OR IGNORE INTO nodes (public_key, name, role, last_seen, first_seen, advert_count)
VALUES ('aacafe0000000000', 'CollisionNode', 'repeater', ?, '2026-01-01T00:00:00Z', 5)`, recent)
// Insert a transmission with path hop "aa" that resolves to the OTHER node
srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('FF01', 'collision_test_hash', ?, 1, 4, '{}')`, recent)
// Get its ID
var collisionTxID int
srv.db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='collision_test_hash'`).Scan(&collisionTxID)
srv.db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
VALUES (?, 1, 10.0, -90, '["aa","bb"]', ?, '["aacafe0000000000","eeff00112233aabb"]')`,
collisionTxID, recentEpoch)
// Reload store to pick up new data
store := NewPacketStore(srv.db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
srv.store = store
// Query paths for TestRepeater — should NOT include the collision packet
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/paths", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Paths []json.RawMessage `json:"paths"`
TotalTransmissions int `json:"totalTransmissions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad JSON: %v", err)
}
// The collision packet should be filtered out. Only transmission 1 (and 3
// if prefix matches) should remain — but transmission 3 has path "cc" and
// resolved_path pointing to TestRoom, so only tx 1 should match.
// Check that collision_test_hash is not in any path group.
bodyStr := w.Body.String()
if strings.Contains(bodyStr, "collision_test_hash") {
t.Error("collision packet should have been filtered out but appeared in response")
}
// Query paths for CollisionNode — should include the collision packet
req2 := httptest.NewRequest("GET", "/api/nodes/aacafe0000000000/paths", nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != 200 {
t.Fatalf("expected 200 for CollisionNode, got %d: %s", w2.Code, w2.Body.String())
}
body2 := w2.Body.String()
if !strings.Contains(body2, "collision_test_hash") {
t.Error("collision packet should appear for CollisionNode but was missing")
}
}
func TestNodeInResolvedPath(t *testing.T) {
target := "aabbccdd11223344"
// Case 1: tx.ResolvedPath contains target
pk := "aabbccdd11223344"
tx1 := &StoreTx{ResolvedPath: []*string{&pk}}
if !nodeInResolvedPath(tx1, target) {
t.Error("should match when ResolvedPath contains target")
}
// Case 2: tx.ResolvedPath contains different node
other := "aacafe0000000000"
tx2 := &StoreTx{ResolvedPath: []*string{&other}}
if nodeInResolvedPath(tx2, target) {
t.Error("should not match when ResolvedPath contains different node")
}
// Case 3: nil ResolvedPath — should match (no data to disambiguate, keep it)
tx3 := &StoreTx{}
if !nodeInResolvedPath(tx3, target) {
t.Error("should match when ResolvedPath is nil (no data to disambiguate)")
}
// Case 4: ResolvedPath with nil elements only — has data but no match
tx4 := &StoreTx{ResolvedPath: []*string{nil, nil}}
if nodeInResolvedPath(tx4, target) {
t.Error("should not match when all ResolvedPath elements are nil")
}
// Case 5: target in observation but not in tx.ResolvedPath
tx5 := &StoreTx{
ResolvedPath: []*string{&other},
Observations: []*StoreObs{
{ResolvedPath: []*string{&pk}},
},
}
if !nodeInResolvedPath(tx5, target) {
t.Error("should match when observation's ResolvedPath contains target")
}
}
func TestPathHopIndexIncrementalUpdate(t *testing.T) {
// Test that addTxToPathHopIndex and removeTxFromPathHopIndex work correctly
idx := make(map[string][]*StoreTx)
+49 -3
View File
@@ -2157,6 +2157,40 @@ func resolvePayloadTypeName(pt *int) string {
return fmt.Sprintf("UNK(%d)", *pt)
}
// nodeInResolvedPath checks whether a transmission's resolved_path contains
// the target node's full pubkey. Returns true if at least one observation's
// resolved_path includes targetPK (lowercased). Excludes transmissions where
// resolved_path is nil/empty or the hop resolved to a different node.
func nodeInResolvedPath(tx *StoreTx, targetPK string) bool {
// If no resolved_path data exists anywhere on this tx, we can't
// disambiguate — return true to keep it (avoid dropping old data).
hasAny := false
// Check the best observation's resolved_path (stored on tx directly).
if tx.ResolvedPath != nil && len(tx.ResolvedPath) > 0 {
hasAny = true
for _, rp := range tx.ResolvedPath {
if rp != nil && strings.ToLower(*rp) == targetPK {
return true
}
}
}
// Also check all observations in case a non-best observation resolved it.
for _, obs := range tx.Observations {
if obs.ResolvedPath == nil || len(obs.ResolvedPath) == 0 {
continue
}
hasAny = true
for _, rp := range obs.ResolvedPath {
if rp != nil && strings.ToLower(*rp) == targetPK {
return true
}
}
}
// No resolved_path data at all — can't disambiguate, keep the candidate.
return !hasAny
}
// txGetParsedPath returns cached parsed path hops, parsing on first call.
func txGetParsedPath(tx *StoreTx) []string {
if tx.pathParsed {
@@ -4731,7 +4765,13 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
regionObs = s.resolveRegionObservers(region)
}
_, pm := s.getCachedNodesAndPM()
allNodes, pm := s.getCachedNodesAndPM()
// Build pubkey→role map for filtering by node type.
nodeRoleByPK := make(map[string]string, len(allNodes))
for _, n := range allNodes {
nodeRoleByPK[n.PublicKey] = n.Role
}
distribution := map[string]int{"1": 0, "2": 0, "3": 0}
byHour := map[string]map[string]int{}
@@ -4808,9 +4848,11 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
}
}
if byNode[pk] == nil {
role := nodeRoleByPK[pk] // empty if unknown
byNode[pk] = map[string]interface{}{
"hashSize": hashSize, "packets": 0,
"lastSeen": tx.FirstSeen, "name": name,
"role": role,
}
}
byNode[pk]["packets"] = byNode[pk]["packets"].(int) + 1
@@ -4902,7 +4944,7 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
multiByteNodes = append(multiByteNodes, map[string]interface{}{
"name": data["name"], "hashSize": data["hashSize"],
"packets": data["packets"], "lastSeen": data["lastSeen"],
"pubkey": pk,
"pubkey": pk, "role": data["role"],
})
}
}
@@ -4910,9 +4952,13 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
return multiByteNodes[i]["packets"].(int) > multiByteNodes[j]["packets"].(int)
})
// Distribution by repeaters: count unique nodes per hash size
// Distribution by repeaters: count unique REPEATER nodes per hash size
distributionByRepeaters := map[string]int{"1": 0, "2": 0, "3": 0}
for _, data := range byNode {
role, _ := data["role"].(string)
if !strings.Contains(strings.ToLower(role), "repeater") {
continue
}
hs := data["hashSize"].(int)
key := strconv.Itoa(hs)
distributionByRepeaters[key]++
+2 -6
View File
@@ -15,15 +15,11 @@ services:
restart: unless-stopped
stop_grace_period: 30s
stop_signal: SIGTERM
deploy:
resources:
limits:
memory: 3g
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${STAGING_GO_HTTP_PORT:-82}:80"
- "${STAGING_GO_MQTT_PORT:-1885}:1883"
- "${STAGING_GO_HTTP_PORT:-80}:80"
- "${STAGING_GO_MQTT_PORT:-1883}:1883"
- "6060:6060" # pprof server
- "6061:6061" # pprof ingestor
volumes:
+8 -1
View File
@@ -15,9 +15,16 @@ if [ -f /app/data/theme.json ]; then
fi
SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord.conf"
if [ "${DISABLE_MOSQUITTO:-false}" = "true" ]; then
if [ "${DISABLE_MOSQUITTO:-false}" = "true" ] && [ "${DISABLE_CADDY:-false}" = "true" ]; then
echo "[config] internal MQTT broker disabled (DISABLE_MOSQUITTO=true)"
echo "[config] Caddy reverse proxy disabled (DISABLE_CADDY=true)"
SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord-no-mosquitto-no-caddy.conf"
elif [ "${DISABLE_MOSQUITTO:-false}" = "true" ]; then
echo "[config] internal MQTT broker disabled (DISABLE_MOSQUITTO=true)"
SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord-no-mosquitto.conf"
elif [ "${DISABLE_CADDY:-false}" = "true" ]; then
echo "[config] Caddy reverse proxy disabled (DISABLE_CADDY=true)"
SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord-no-caddy.conf"
fi
exec /usr/bin/supervisord -c "$SUPERVISORD_CONF"
+43
View File
@@ -0,0 +1,43 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:mosquitto]
command=/usr/sbin/mosquitto -c /etc/mosquitto/mosquitto.conf
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:corescope-ingestor]
command=/app/corescope-ingestor -config /app/config.json
directory=/app
autostart=true
autorestart=true
startretries=10
startsecs=2
stopsignal=TERM
stopwaitsecs=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:corescope-server]
command=/app/corescope-server -config-dir /app -db /app/data/meshcore.db -public /app/public -port 3000
directory=/app
autostart=true
autorestart=true
startretries=10
startsecs=2
stopsignal=TERM
stopwaitsecs=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
@@ -0,0 +1,34 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:corescope-ingestor]
command=/app/corescope-ingestor -config /app/config.json
directory=/app
autostart=true
autorestart=true
startretries=10
startsecs=2
stopsignal=TERM
stopwaitsecs=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:corescope-server]
command=/app/corescope-server -config-dir /app -db /app/data/meshcore.db -public /app/public -port 3000
directory=/app
autostart=true
autorestart=true
startretries=10
startsecs=2
stopsignal=TERM
stopwaitsecs=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+32
View File
@@ -261,6 +261,38 @@ Caddy handles certificate issuance and renewal automatically.
---
## API Documentation
CoreScope auto-generates an OpenAPI 3.0 specification from its route definitions. The spec is always in sync with the running server — no manual maintenance required.
### Endpoints
| URL | Description |
|-----|-------------|
| `/api/spec` | OpenAPI 3.0 JSON schema — machine-readable API definition |
| `/api/docs` | Interactive Swagger UI — browse and test all 40+ endpoints |
### Usage
**Browse the API interactively:**
```
http://your-instance/api/docs
```
**Fetch the spec programmatically:**
```bash
curl http://your-instance/api/spec | jq .
```
**For bot/integration developers:** The spec includes all request parameters, response schemas, and example values. Import it into Postman, Insomnia, or any OpenAPI-compatible tool.
### Public instance
The live instance at [analyzer.00id.net](https://analyzer.00id.net) has all API endpoints publicly accessible:
- Spec: [analyzer.00id.net/api/spec](https://analyzer.00id.net/api/spec)
- Docs: [analyzer.00id.net/api/docs](https://analyzer.00id.net/api/docs)
---
## Monitoring & Health Checks
### Docker health check
+135
View File
@@ -0,0 +1,135 @@
# CoreScope v3.5.0 🚀
The "stop building from source and start analyzing your mesh" release. 95 commits.
---
## 🐳 Pre-built Docker Images
CoreScope now ships as a ready-to-run Docker image on GitHub Container Registry. No cloning, no building, no dependencies — just pull and run.
```bash
docker run -d --name corescope -p 80:80 -p 443:443 -p 1883:1883 \
-v corescope-data:/app/data \
ghcr.io/kpa-clawbot/corescope:v3.5.0
```
**Using HTTPS with a custom domain?** Mount your Caddyfile and certs directory:
```bash
docker run -d --name corescope -p 80:80 -p 443:443 -p 1883:1883 \
-v /your/data:/app/data \
-v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
-v /your/caddy-data:/data/caddy \
ghcr.io/kpa-clawbot/corescope:v3.5.0
```
Caddy auto-provisions Let's Encrypt certs. Your Caddyfile just needs:
```
yourdomain.example.com {
reverse_proxy localhost:3000
}
```
That's it. Zero config required — MQTT broker, Caddy HTTPS, and SQLite are built in.
**Already running CoreScope?**
```bash
# 1. Find your running container name
docker ps --format '{{.Names}}'
# 2. Stop and remove it
docker stop <container-name> && docker rm <container-name>
# 3. Pull the pre-built image
docker pull ghcr.io/kpa-clawbot/corescope:v3.5.0
# 4. Run with your existing data directory
docker run -d --name corescope -p 80:80 -p 443:443 -p 1883:1883 \
-v /your/data:/app/data \
-v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
-v /your/caddy-data:/data/caddy \
ghcr.io/kpa-clawbot/corescope:v3.5.0
```
Your data volume stays. Nothing to migrate.
Tags: `v3.5.0` (this release) · `latest` (latest tagged release) · `edge` (master tip, for testing). Env: `DISABLE_CADDY=true` / `DISABLE_MOSQUITTO=true` if you bring your own.
---
## ⚡ 83% Faster
35 performance commits. Packets endpoint p50 dropped from 16.7ms → 2.7ms. Server now serves HTTP within 2 minutes on *any* DB size — async background backfill means you're never staring at a loading screen. N+1 API calls killed everywhere. Prefix map memory cut 10x. WebSocket renders batched via rAF.
---
## 🔬 RF Health Dashboard
New Analytics tab. Per-observer noise floor as color-coded columns (green/yellow/red), airtime utilization, error rates, battery levels. Click any observer for the full breakdown. Region-filterable. This is the beginning of making CoreScope more than just a packet viewer.
---
## 🗺️ See Where Traces Actually Go
Send a trace → watch it on the live map. Solid animated line shows how far it got. Dashed ghost shows where it didn't reach. Finally know *where* your trace failed, not just *that* it failed.
---
## 📊 Things That Were Lying To You
- "By Repeaters" was counting companions. Fixed.
- Zero-hop adverts claimed "1 byte hash" when the hash size was unknowable. Fixed.
- "Packets through this node" showed packets through a *different* node with the same prefix. Fixed — now uses the neighbor affinity graph.
- Table sorting on nodes/neighbors/observers silently did nothing. Fixed.
---
## 🔗 Deep Links · 🎨 Channel Colors · 📱 Mobile · 🔑 Security
**Deep links** — every page state goes in the URL. Share a link to a specific node, filter, or analytics tab.
**Channel colors** — click the color dot next to any channel, pick from 8 colors, see it highlighted across the feed. Persists in localStorage.
**Distance units** — km, miles, or auto-detect from locale. Customizer → Display.
**Mobile** — 44px touch targets, ARIA labels, responsive breakpoints.
**Security** — weak API keys rejected at startup. License: GPL v3.
---
## 📡 Full API Documentation
Every endpoint is now documented with an auto-generated OpenAPI 3.0 spec — always in sync with the running server.
- **Interactive Swagger UI:** [analyzer.00id.net/api/docs](https://analyzer.00id.net/api/docs) — browse and test all 40+ endpoints
- **Machine-readable spec:** [analyzer.00id.net/api/spec](https://analyzer.00id.net/api/spec) — import into Postman, Insomnia, or use for bot/integration development
On your own instance: `/api/docs` and `/api/spec`.
---
## 🐛 14 Bugs Squashed
Live map crash, zero-hop hash lies, animation freezes, repeater miscounts, prefix collisions, dead channel picker, invisible buttons, broken sorting, memory leak, and more.
---
## Upgrade
```bash
docker stop <container-name> && docker rm <container-name>
docker pull ghcr.io/kpa-clawbot/corescope:v3.5.0
# HTTP only:
docker run -d --name corescope -p 80:80 -p 1883:1883 \
-v /your/data:/app/data \
ghcr.io/kpa-clawbot/corescope:v3.5.0
# With HTTPS (custom domain):
docker run -d --name corescope -p 80:80 -p 443:443 -p 1883:1883 \
-v /your/data:/app/data \
-v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
-v /your/caddy-data:/data/caddy \
ghcr.io/kpa-clawbot/corescope:v3.5.0
```
First start backfills `resolved_path` in the background. No downtime. No breaking changes.
+266
View File
@@ -0,0 +1,266 @@
# Timestamp-Based Packet Filters
**Issue:** #289
**Status:** Draft
**Depends on:** #286 (timestamp display config)
## Summary
Extend the existing filter engine (`packet-filter.js`) with a `time` field type supporting absolute ISO timestamps, relative durations, and range expressions. The filter compiles date expressions to epoch milliseconds at parse time so per-packet evaluation is a single numeric comparison — no date parsing in the hot path.
## Syntax
### Absolute (ISO 8601)
```
time > "2024-01-01T00:00:00Z"
time <= "2024-06-15"
time == "2024-03-01"
```
Quoted strings after `time` are parsed as dates. Partial dates (`"2024-01-01"`) are treated as midnight UTC. All absolute values are interpreted as UTC regardless of the user's display preference.
### Relative
```
time > 2h ago
time > 30m ago
time > 7d ago
```
The lexer recognizes `<number><unit> ago` as a relative time literal. Supported units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days). At compile time, the relative offset is resolved to an absolute epoch ms value (`Date.now() - offset`). This means a compiled filter's relative thresholds are frozen at compile time — recompile to refresh.
### Shorthand
```
time.ago < 30m
time.ago < 2h
```
`time.ago` resolves to `Date.now() - packet.timestamp`. The comparison value is a duration literal (`30m`, `2h`, `7d`). This is syntactic sugar and semantically equivalent to the relative form but reads more naturally for "show me recent packets."
### Range
```
time between "2024-01-01" "2024-01-02"
time between 1h ago 30m ago
```
`between` is a ternary operator: `field between <low> <high>`. Compiles to `low <= field && field <= high`. Both bounds are inclusive.
### Combinable with existing filters
```
type == Advert && time > 1h ago
snr > 5 && time between "2024-01-01" "2024-01-02"
(type == GRP_TXT || type == TXT_MSG) && time.ago < 30m
```
## Grammar Extension
### New token types
| Token | Pattern | Example |
|-------|---------|---------|
| `DURATION` | `/^\d+[smhd]$/` | `30m`, `2h`, `7d` |
| `AGO` | keyword `ago` | `ago` |
| `BETWEEN` | keyword `between` | `between` |
### Lexer changes
1. After reading an identifier that matches `\d+[smhd]`, emit `DURATION` token instead of `FIELD`.
2. Recognize `ago` and `between` as keywords (like `and`/`or`).
### Parser changes
In `parseComparison()`:
1. **Relative time:** If field is `time` and value tokens are `DURATION AGO`, compute `Date.now() - durationToMs(duration)` and store as a numeric epoch ms value in the AST node.
2. **Absolute time:** If field is `time` and value is a `STRING`, attempt `new Date(value).getTime()`. If `NaN`, return parse error. Store epoch ms.
3. **`time.ago` shorthand:** If field is `time.ago`, the value is a `DURATION`. Store the duration in ms. At evaluation, compute `now - packet_ts` and compare against the duration.
4. **`between`:** If operator token is `BETWEEN`, consume two values (same type resolution as above). Emit `{ type: 'between', field, low, high }`.
### AST node shapes
```js
// Absolute/relative (pre-resolved to epoch ms)
{ type: 'comparison', field: 'time', op: '>', value: 1704067200000 }
// time.ago (duration in ms)
{ type: 'comparison', field: 'time.ago', op: '<', value: 1800000 }
// between (both bounds as epoch ms)
{ type: 'between', field: 'time', low: 1704067200000, high: 1704153600000 }
```
## Field Resolution
Add to `resolveField()`:
```js
if (field === 'time') return packet.timestamp; // epoch ms
if (field === 'time.ago') return Date.now() - packet.timestamp;
```
`packet.timestamp` is the packet's capture time in epoch milliseconds. This field already exists in the data model (populated from the DB `created_at` column).
## Time Semantics
- **Filter expressions:** Always UTC. `"2024-01-01"` means `2024-01-01T00:00:00Z`.
- **Display:** Follows the user's timestamp config from #286 (UTC/local/relative).
- **Relative times:** Computed against `Date.now()` at compile time. The compiled filter is a snapshot — if the filter stays active for hours, relative thresholds drift. This is acceptable; filters are typically short-lived or recompiled on interaction.
**No timezone specifiers in the filter syntax.** UTC only. This avoids ambiguity and parsing complexity. Users who think in local time can use the relative syntax (`time > 2h ago`) which is timezone-agnostic.
## Performance
### Compile-time work (once)
- Parse date strings → epoch ms via `new Date().getTime()` (~1μs per date)
- Parse duration strings → ms via multiplication (~0ns, trivial arithmetic)
- Relative `ago``Date.now() - offset` (~0ns)
### Per-packet evaluation (hot path)
- `time` comparison: one numeric read + one numeric compare. Same cost as `snr > 5`.
- `time.ago`: one subtraction + one compare. Two arithmetic ops. **Important:** cache `Date.now()` once per filter pass (e.g., in a closure variable set before iterating packets), not per-packet. 30K `Date.now()` calls are ~1ms but it's a pointless syscall tax.
- `between`: two numeric compares.
**No `Date` objects created per packet. No string parsing per packet. No regex per packet.**
At 30K packets, the time filter adds ~0.1ms total to filter evaluation — dominated by the existing field resolution and AST walk overhead. No measurable regression.
### Implementation note: `between` as sugar
`between` should compile to `{ type: 'and', left: { type: 'comparison', field, op: '>=', value: low }, right: { type: 'comparison', field, op: '<=', value: high } }` — reusing existing comparison evaluation. No new AST node type, no new evaluator branch. The parser desugars it; the evaluator never sees `between`.
### Implementation note: `time.ago` and `Date.now()` caching
The `compile()` function should return a filter that accepts an optional `now` parameter:
```js
var compiled = compile('time.ago < 30m');
var now = Date.now();
packets.filter(function(p) { return compiled.filter(p, now); });
```
If `now` is not passed, `Date.now()` is called once on the first invocation and reused for the entire filter pass. This avoids 30K syscalls and ensures consistent evaluation within a single pass.
## Carmack Review Notes
Reviewed with a performance-first lens (30K+ packets, real-time updates):
1. **✅ No allocations in hot path.** All date parsing happens at compile time. Per-packet evaluation is pure numeric comparison — same cost as existing `snr > 5` filters.
2. **⚠️ `Date.now()` per-packet for `time.ago`.** Fixed above — cache once per filter pass via optional `now` parameter or closure. Without this, 30K packets × `Date.now()` = ~1ms wasted on a monotonic clock syscall that returns the same value.
3. **`between` as sugar, not a new node type.** Desugar in the parser to reuse existing `and` + `comparison` nodes. Zero new code paths in the evaluator = zero new bugs in the evaluator.
4. **✅ Parser complexity is bounded.** Three new token types, one new keyword. The parser remains LL(1) — no backtracking, no ambiguity. `DURATION AGO` is a clear two-token lookahead only when field is `time`.
5. **✅ Memory impact negligible.** Compiled time filters add one or two floats to the AST. At 16 bytes per node, even complex expressions with multiple time clauses are <100 bytes.
6. **⚠️ Compiled filter staleness for relative times.** Spec acknowledges this. Acceptable for a web UI where filters are recompiled on user interaction. If filters persist across long WebSocket sessions, consider recompiling on a timer (every 60s). This is a future concern, not a blocker.
7. **✅ No regex in hot path.** Duration parsing uses a simple char check on the last character + `parseInt`. Cheaper than any regex.
A compiled time filter adds one or two 64-bit float values to the AST. Negligible — roughly 16 bytes per time comparison node.
## URL Integration
Time filters appear in the URL hash query string like any other filter:
```
#/packets?filter=time%20%3E%201h%20ago
#/packets?filter=type%20%3D%3D%20Advert%20%26%26%20time%20%3E%20%222024-01-01%22
```
The filter text is URL-encoded and round-trips through `encodeURIComponent`/`decodeURIComponent`. No special handling needed — the existing filter-in-URL mechanism (#286 or current) works unchanged.
For convenience, a future milestone could add dedicated `timeFrom`/`timeTo` query params that inject into the filter, but this is not required for the initial implementation.
## Wireshark Compatibility
| Wireshark syntax | CoreScope equivalent | Notes |
|------------------|---------------------|-------|
| `frame.time >= "2024-01-01"` | `time >= "2024-01-01"` | We use `time` instead of `frame.time` for brevity. Could alias `frame.time``time` later. |
| `frame.time_relative < 60` | `time.ago < 60s` | Wireshark uses seconds float; we use duration literals |
| `frame.time_delta` | Not supported | Inter-packet delta is a different feature |
We intentionally diverge from Wireshark where their syntax is verbose or requires pcap-specific concepts. CoreScope's filter language prioritizes brevity and readability for a web UI. A `frame.time` alias for `time` can be added trivially in the field resolver if users request it.
## Milestones
### M1: Core time filtering (parser + evaluator)
- Add `DURATION`, `AGO`, `BETWEEN` tokens to lexer
- Extend parser for `time` field special handling
- Add `time` and `time.ago` to `resolveField()`
- Implement `between` AST node evaluation
- Unit tests: absolute, relative, ago, between, combined with existing filters, edge cases (bad dates, invalid units)
- **Test:** filter 30K packets by time in <50ms (assert in test)
### M2: UI integration
- Filter bar autocomplete hints for time syntax
- Help tooltip / cheat sheet update with time examples
- Verify URL round-trip with time filters
- Playwright E2E test: enter time filter, verify packet list updates
### M3: Polish
- `frame.time` alias
- Error messages for common mistakes ("did you mean `time > 1h ago`?")
- Consider dedicated time range picker UI widget (out of scope for this spec)
## Testing
### Unit tests (add to `test-packet-filter.js`)
```js
// Absolute time
c = compile('time > "2024-01-01"');
assert(c.filter({ timestamp: new Date('2024-06-01').getTime() }), 'after 2024-01-01');
assert(!c.filter({ timestamp: new Date('2023-06-01').getTime() }), 'before 2024-01-01');
// Relative time
c = compile('time > 1h ago');
assert(c.filter({ timestamp: Date.now() - 30 * 60000 }), '30m ago passes 1h filter');
assert(!c.filter({ timestamp: Date.now() - 2 * 3600000 }), '2h ago fails 1h filter');
// time.ago shorthand
c = compile('time.ago < 30m');
assert(c.filter({ timestamp: Date.now() - 10 * 60000 }), '10m ago < 30m');
assert(!c.filter({ timestamp: Date.now() - 60 * 60000 }), '60m ago not < 30m');
// between
c = compile('time between "2024-01-01" "2024-01-02"');
assert(c.filter({ timestamp: new Date('2024-01-01T12:00:00Z').getTime() }), 'in range');
assert(!c.filter({ timestamp: new Date('2024-01-03').getTime() }), 'out of range');
// Combined
c = compile('type == Advert && time > 1h ago');
assert(c.filter({ payload_type: 4, timestamp: Date.now() - 1000 }), 'combined pass');
assert(!c.filter({ payload_type: 4, timestamp: Date.now() - 7200000 }), 'combined fail time');
assert(!c.filter({ payload_type: 1, timestamp: Date.now() - 1000 }), 'combined fail type');
// Error cases
c = compile('time > "not-a-date"');
assert(c.error, 'invalid date string');
c = compile('time > 5x ago');
assert(c.error, 'invalid duration unit');
// Performance
var start = Date.now();
c = compile('time > 1h ago && type == Advert');
var packets = [];
for (var i = 0; i < 30000; i++) {
packets.push({ payload_type: i % 5, timestamp: Date.now() - i * 1000 });
}
packets.forEach(function(p) { c.filter(p); });
assert(Date.now() - start < 50, 'filter 30K packets in <50ms');
```
### Playwright tests
- Enter `time > 1h ago` in filter bar → verify packet count decreases
- Enter invalid time filter → verify error message appears
- Reload page with time filter in URL → verify filter is applied
@@ -0,0 +1,674 @@
# Deep Linking P1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make P1 UI states in nodes, packets, and channels URL-addressable so they survive refresh and can be shared.
**Architecture:** Each page reads URL params from `location.hash.split('?')[1]` on init (router strips query string before passing `routeParam`, so pages must read `location.hash` directly). State changes call `history.replaceState` to keep the URL in sync. localStorage remains the fallback default; URL params override when present.
**Tech Stack:** Vanilla JS (ES5/6), browser History API, URLSearchParams
---
## Files Changed
| File | Changes |
|---|---|
| `public/region-filter.js` | Add `setSelected(codesArray)`, track `_container` for re-render |
| `public/nodes.js` | Read `?tab=`/`?search=` on init; `updateNodesUrl()` on tab/search change; expose `buildNodesQuery` on `window` |
| `public/packets.js` | Read `?timeWindow=`/`?region=` on init; `updatePacketsUrl()` on timeWindow/region change; expose `buildPacketsUrl` on `window` |
| `public/channels.js` | Read `?node=` on init; update URL in `showNodeDetail`/`closeNodeDetail` |
| `test-frontend-helpers.js` | Add unit tests for `buildNodesQuery` and `buildPacketsUrl` |
| `test-e2e-playwright.js` | Add Playwright tests: tab URL persistence, timeWindow URL persistence |
---
## Task 1: Add `setSelected` to RegionFilter
**Files:**
- Modify: `public/region-filter.js`
- [ ] **Step 1: Write the failing unit test**
Add to `test-frontend-helpers.js` before the `// ===== SUMMARY =====` line:
```javascript
// ===== REGION-FILTER.JS: setSelected =====
console.log('\n=== region-filter.js: setSelected ===');
{
const ctx = makeSandbox();
ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) });
loadInCtx(ctx, 'public/region-filter.js');
const RF = ctx.RegionFilter;
RF.init(document.createElement('div'));
test('setSelected sets region codes', async () => {
await RF.init(document.createElement('div'));
RF.setSelected(['US-SFO', 'US-LAX']);
assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX');
});
test('setSelected with null clears selection', async () => {
await RF.init(document.createElement('div'));
RF.setSelected(['US-SFO']);
RF.setSelected(null);
assert.strictEqual(RF.getRegionParam(), '');
});
test('setSelected with empty array clears selection', async () => {
await RF.init(document.createElement('div'));
RF.setSelected(['US-SFO']);
RF.setSelected([]);
assert.strictEqual(RF.getRegionParam(), '');
});
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
node test-frontend-helpers.js 2>&1 | grep -A2 "setSelected"
```
Expected: `❌ setSelected sets region codes: RF.setSelected is not a function`
- [ ] **Step 3: Add `_container` tracking and `setSelected` to region-filter.js**
In `region-filter.js`, add `var _container = null;` after the existing module-level vars (after line 9 `var _listeners = [];`):
```javascript
var _listeners = [];
var _container = null; // ← add this line
var _loaded = false;
```
In `initFilter`, save the container:
```javascript
async function initFilter(container, opts) {
_container = container; // ← add this line
if (opts && opts.dropdown) container._forceDropdown = true;
await fetchRegions();
render(container);
}
```
Add `setSelected` function before `// Expose globally`:
```javascript
/** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */
function setSelected(codesArray) {
_selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null;
saveToStorage();
if (_container) render(_container);
}
```
Add `setSelected` to the public API object:
```javascript
window.RegionFilter = {
init: initFilter,
render: render,
getSelected: getSelected,
getRegionParam: getRegionParam,
regionQueryString: regionQueryString,
onChange: onChange,
offChange: offChange,
fetchRegions: fetchRegions,
setSelected: setSelected, // ← add this line
};
```
- [ ] **Step 4: Run test to verify it passes**
```bash
node test-frontend-helpers.js 2>&1 | grep -E "(setSelected|FAIL|passed|failed)"
```
Expected: 3 passing `setSelected` tests, overall pass.
- [ ] **Step 5: Commit**
```bash
git add public/region-filter.js test-frontend-helpers.js
git commit -m "feat: add RegionFilter.setSelected for URL param initialization (#536)"
```
---
## Task 2: nodes.js — tab and search deep linking
**Files:**
- Modify: `public/nodes.js`
- Test: `test-frontend-helpers.js`
- Test: `test-e2e-playwright.js`
- [ ] **Step 1: Write the unit test (add to test-frontend-helpers.js)**
Add before the `// ===== SUMMARY =====` line:
```javascript
// ===== NODES.JS: buildNodesQuery =====
console.log('\n=== nodes.js: buildNodesQuery ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
// Provide required globals for nodes.js IIFE to execute
ctx.registerPage = () => {};
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.debouncedOnWS = () => () => {};
ctx.invalidateApiCache = () => {};
ctx.favStar = () => '';
ctx.bindFavStars = () => {};
ctx.getFavorites = () => [];
ctx.isFavorite = () => false;
ctx.connectWS = () => {};
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
ctx.initTabBar = () => {};
ctx.debounce = (fn) => fn;
ctx.copyToClipboard = () => {};
ctx.api = () => Promise.resolve({});
ctx.escapeHtml = (s) => s;
ctx.timeAgo = () => '';
ctx.formatTimestampWithTooltip = () => '';
ctx.getTimestampMode = () => 'ago';
ctx.CLIENT_TTL = {};
ctx.qrcode = null;
try {
const src = fs.readFileSync('public/nodes.js', 'utf8');
vm.runInContext(src, ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
} catch (e) {
console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120));
}
const buildNodesQuery = ctx.buildNodesQuery;
if (buildNodesQuery) {
test('buildNodesQuery: all tab + no search = empty', () => {
assert.strictEqual(buildNodesQuery('all', ''), '');
});
test('buildNodesQuery: repeater tab only', () => {
assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater');
});
test('buildNodesQuery: search only (all tab)', () => {
assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo');
});
test('buildNodesQuery: tab + search combined', () => {
assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar');
});
test('buildNodesQuery: null search treated as empty', () => {
assert.strictEqual(buildNodesQuery('all', null), '');
});
test('buildNodesQuery: sensor tab', () => {
assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor');
});
} else {
console.log(' ⚠️ buildNodesQuery not exposed — skipping');
}
}
```
- [ ] **Step 2: Run test to verify it fails (or skips)**
```bash
node test-frontend-helpers.js 2>&1 | grep -A3 "buildNodesQuery"
```
Expected: `⚠️ buildNodesQuery not exposed — skipping`
- [ ] **Step 3: Add URL param reading and helpers to nodes.js**
**3a.** Add `buildNodesQuery` and `updateNodesUrl` functions inside the nodes.js IIFE, after the `TABS` definition (around line 86, before `function renderNodeTimestampHtml`):
```javascript
function buildNodesQuery(tab, searchStr) {
var parts = [];
if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
return parts.length ? '?' + parts.join('&') : '';
}
window.buildNodesQuery = buildNodesQuery;
function updateNodesUrl() {
history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search));
}
```
**3b.** In the list-view branch of `init` (after the `return;` that ends the full-screen block at line 317), add URL param reading before `app.innerHTML`:
```javascript
// Read URL params for list view (router strips query string from routeParam)
const _listUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
const _urlTab = _listUrlParams.get('tab');
const _urlSearch = _listUrlParams.get('search');
if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
if (_urlSearch) search = _urlSearch;
app.innerHTML = `<div class="nodes-page">
```
**3c.** After `app.innerHTML = ...` (after the closing backtick at line ~330), populate the search input:
```javascript
if (search) {
var _si = document.getElementById('nodeSearch');
if (_si) _si.value = search;
}
```
**3d.** In the search input event listener (around line 335), add `updateNodesUrl()`:
```javascript
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
search = e.target.value;
updateNodesUrl();
loadNodes();
}, 250));
```
**3e.** In the tab click handler inside `renderLeft` (around line 875), add `updateNodesUrl()`:
```javascript
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); });
```
- [ ] **Step 4: Run unit tests**
```bash
node test-frontend-helpers.js 2>&1 | grep -E "(buildNodesQuery|✅|❌)" | grep -v "helpers"
```
Expected: 6 passing `buildNodesQuery` tests.
- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js)**
Add before the closing `await browser.close()` line:
```javascript
// --- Group: Deep linking (#536) ---
// Test: nodes tab deep link
await test('Nodes tab deep link restores active tab', async () => {
await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.node-tab', { timeout: 8000 });
const activeTab = await page.$('.node-tab.active');
assert(activeTab, 'No active tab found');
const tabText = await activeTab.textContent();
assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`);
const url = page.url();
assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`);
});
// Test: nodes tab click updates URL
await test('Nodes tab click updates URL', async () => {
await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.node-tab', { timeout: 8000 });
const roomTab = await page.$('.node-tab[data-tab="room"]');
if (roomTab) {
await roomTab.click();
await page.waitForTimeout(300);
const url = page.url();
assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`);
}
});
```
- [ ] **Step 6: Run full test suite**
```bash
node test-frontend-helpers.js
```
Expected: all tests pass.
- [ ] **Step 7: Commit**
```bash
git add public/nodes.js test-frontend-helpers.js test-e2e-playwright.js
git commit -m "feat: deep link nodes tab and search query (#536)"
```
---
## Task 3: packets.js — timeWindow and region deep linking
**Files:**
- Modify: `public/packets.js`
- Test: `test-frontend-helpers.js`
- Test: `test-e2e-playwright.js`
> Depends on Task 1 (RegionFilter.setSelected).
- [ ] **Step 1: Write the unit test**
Add to `test-frontend-helpers.js` before `// ===== SUMMARY =====`:
```javascript
// ===== PACKETS.JS: buildPacketsUrl =====
console.log('\n=== packets.js: buildPacketsUrl ===');
{
// Test the pure helper function
// (loaded via packets.js after it exposes window.buildPacketsUrl)
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
ctx.registerPage = () => {};
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.debouncedOnWS = () => () => {};
ctx.invalidateApiCache = () => {};
ctx.api = () => Promise.resolve({});
ctx.observerMap = new Map();
ctx.getParsedPath = () => [];
ctx.getParsedDecoded = () => ({});
ctx.clearParsedCache = () => {};
ctx.escapeHtml = (s) => s;
ctx.timeAgo = () => '';
ctx.formatTimestampWithTooltip = () => '';
ctx.getTimestampMode = () => 'ago';
ctx.copyToClipboard = () => {};
ctx.CLIENT_TTL = {};
ctx.debounce = (fn) => fn;
ctx.initTabBar = () => {};
try {
const src = fs.readFileSync('public/packet-helpers.js', 'utf8');
vm.runInContext(src, ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
const src2 = fs.readFileSync('public/packets.js', 'utf8');
vm.runInContext(src2, ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
} catch (e) {
console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120));
}
const buildPacketsUrl = ctx.buildPacketsUrl;
if (buildPacketsUrl) {
test('buildPacketsUrl: default (15min, no region) = bare #/packets', () => {
assert.strictEqual(buildPacketsUrl(15, ''), '#/packets');
});
test('buildPacketsUrl: non-default timeWindow', () => {
assert.strictEqual(buildPacketsUrl(60, ''), '#/packets?timeWindow=60');
});
test('buildPacketsUrl: region only', () => {
assert.strictEqual(buildPacketsUrl(15, 'US-SFO'), '#/packets?region=US-SFO');
});
test('buildPacketsUrl: timeWindow + region', () => {
assert.strictEqual(buildPacketsUrl(30, 'US-SFO,US-LAX'), '#/packets?timeWindow=30&region=US-SFO%2CUS-LAX');
});
test('buildPacketsUrl: timeWindow=0 treated as default', () => {
assert.strictEqual(buildPacketsUrl(0, ''), '#/packets');
});
} else {
console.log(' ⚠️ buildPacketsUrl not exposed — skipping');
}
}
```
- [ ] **Step 2: Run to verify it skips**
```bash
node test-frontend-helpers.js 2>&1 | grep -A2 "buildPacketsUrl"
```
Expected: `⚠️ buildPacketsUrl not exposed — skipping`
- [ ] **Step 3: Add helpers and URL param reading to packets.js**
**3a.** Add `buildPacketsUrl` and `updatePacketsUrl` inside the packets.js IIFE, after the existing constants at the top (around line 36, after `let showHexHashes`):
```javascript
function buildPacketsUrl(timeWindowMin, regionParam) {
var parts = [];
if (timeWindowMin && timeWindowMin !== 15) parts.push('timeWindow=' + timeWindowMin);
if (regionParam) parts.push('region=' + encodeURIComponent(regionParam));
return '#/packets' + (parts.length ? '?' + parts.join('&') : '');
}
window.buildPacketsUrl = buildPacketsUrl;
function updatePacketsUrl() {
history.replaceState(null, '', buildPacketsUrl(savedTimeWindowMin, RegionFilter.getRegionParam()));
}
```
**3b.** In the `init` function (around line 263), add URL param reading after the existing `routeParam`/`directObsId` parsing and before `app.innerHTML`:
```javascript
// Read URL params for filter state (router strips query from routeParam; read from location.hash)
var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
var _urlTimeWindow = Number(_initUrlParams.get('timeWindow'));
if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) {
savedTimeWindowMin = _urlTimeWindow;
localStorage.setItem('meshcore-time-window', String(_urlTimeWindow));
}
var _urlRegion = _initUrlParams.get('region');
if (_urlRegion) {
RegionFilter.setSelected(_urlRegion.split(',').filter(Boolean));
}
app.innerHTML = `<div class="split-layout detail-collapsed">
```
**3c.** In the time window change handler (around line 865), add `updatePacketsUrl()`:
```javascript
fTimeWindow.addEventListener('change', () => {
savedTimeWindowMin = Number(fTimeWindow.value);
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
updatePacketsUrl();
loadPackets();
});
```
**3d.** In the RegionFilter.onChange callback (around line 719), add `updatePacketsUrl()`:
```javascript
RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
```
- [ ] **Step 4: Run unit tests**
```bash
node test-frontend-helpers.js 2>&1 | grep -E "(buildPacketsUrl|✅|❌)" | grep -v "helpers"
```
Expected: 5 passing `buildPacketsUrl` tests.
- [ ] **Step 5: Write Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)**
```javascript
// Test: packets timeWindow deep link
await test('Packets timeWindow deep link restores dropdown', async () => {
await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
const val = await page.$eval('#fTimeWindow', el => el.value);
assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`);
const url = page.url();
assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`);
});
// Test: timeWindow change updates URL
await test('Packets timeWindow change updates URL', async () => {
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
await page.selectOption('#fTimeWindow', '30');
await page.waitForTimeout(300);
const url = page.url();
assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`);
});
```
- [ ] **Step 6: Run full test suite**
```bash
node test-frontend-helpers.js
```
Expected: all tests pass.
- [ ] **Step 7: Commit**
```bash
git add public/packets.js test-frontend-helpers.js test-e2e-playwright.js
git commit -m "feat: deep link packets timeWindow and region filter (#536)"
```
---
## Task 4: channels.js — node panel deep linking
**Files:**
- Modify: `public/channels.js`
No unit tests needed for this task — the URL manipulation is side-effectful (DOM + History API). Playwright tests cover it.
- [ ] **Step 1: Write the Playwright test (add to test-e2e-playwright.js, inside the deep-linking group)**
```javascript
// Test: channels selected channel survives refresh (already implemented, verify it still works)
await test('Channels channel selection is URL-addressable', async () => {
await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null);
const firstChannel = await page.$('.ch-item');
if (firstChannel) {
await firstChannel.click();
await page.waitForTimeout(500);
const url = page.url();
assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`);
}
});
```
- [ ] **Step 2: Update `showNodeDetail` to write `?node=` to the URL**
In `channels.js`, in `showNodeDetail` (around line 171), add the URL update right after `selectedNode = name;`:
```javascript
async function showNodeDetail(name) {
_nodePanelTrigger = document.activeElement;
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
const node = await lookupNode(name);
selectedNode = name;
var _chBase = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name));
let panel = document.getElementById('chNodePanel');
```
- [ ] **Step 3: Update `closeNodeDetail` to strip `?node=` from the URL**
In `closeNodeDetail` (around line 232), add URL restore right after `selectedNode = null;`:
```javascript
function closeNodeDetail() {
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
const panel = document.getElementById('chNodePanel');
if (panel) panel.classList.remove('open');
selectedNode = null;
var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
history.replaceState(null, '', _chRestoreUrl);
if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') {
```
- [ ] **Step 4: Read `?node=` on init and auto-open panel**
In `channels.js` `init` (line 316), add URL param reading at the very top of the function (before `app.innerHTML`):
```javascript
function init(app, routeParam) {
var _initUrlParams = new URLSearchParams(location.hash.split('?')[1] || '');
var _pendingNode = _initUrlParams.get('node');
app.innerHTML = `<div class="ch-layout">
```
Then update the `loadChannels().then(...)` call (around line 350) to auto-open the node panel:
```javascript
loadChannels().then(async function () {
if (routeParam) await selectChannel(routeParam);
if (_pendingNode) showNodeDetail(_pendingNode);
});
```
- [ ] **Step 5: Run full test suite**
```bash
node test-frontend-helpers.js
```
Expected: all tests pass (no channels unit tests, but regression tests still pass).
- [ ] **Step 6: Commit**
```bash
git add public/channels.js
git commit -m "feat: deep link channels node panel via ?node= (#536)"
```
---
## Task 5: Run E2E Playwright tests
- [ ] **Step 1: Start the local server**
```bash
cd cmd/server && go run . &
```
Wait for it to be ready (check `http://localhost:3000`).
- [ ] **Step 2: Run Playwright tests**
```bash
node test-e2e-playwright.js
```
Expected: all tests pass including the new deep-linking group.
- [ ] **Step 3: If any deep-linking test fails, debug**
Common failures:
- Selector `.node-tab.active` not found: check that nodes.js correctly reads `?tab=` from URL before rendering
- `#fTimeWindow` value wrong: check that `savedTimeWindowMin` is overridden before the DOM is built
- URL doesn't update: check `history.replaceState` calls in the change handlers
- [ ] **Step 4: Final commit (if any fixes needed)**
```bash
git add public/nodes.js public/packets.js public/channels.js
git commit -m "fix: deep linking E2E adjustments (#536)"
```
---
## Self-Review
**Spec coverage check:**
- ✅ P1: Nodes role tab → Task 2
- ✅ P1: Packets time window → Task 3
- ✅ P1: Packets region filter → Task 3 (depends on Task 1)
- ✅ P1: Channels selected channel → Already implemented via `#/channels/{hash}` (verified in channels.js init line 351)
- ✅ P1: Channels node panel → Task 4
- ✅ P2+ items → explicitly out of scope per issue
**Architecture note:** The router in `app.js` strips the query string at line 422 (`const route = hash.split('?')[0]`) before computing `basePage` and `routeParam`. Therefore `#/nodes?tab=repeater` gives `routeParam=null` (not `?tab=repeater`). All pages must read URL params from `location.hash` directly, not from `routeParam`. This is the established pattern in `analytics.js` and `nodes.js` (section scroll).
**Placeholder scan:** No TBDs, no "implement later", all code blocks complete. ✅
**Type consistency:**
- `buildNodesQuery(tab, searchStr)` — used consistently in `updateNodesUrl()` and in tests ✅
- `buildPacketsUrl(timeWindowMin, regionParam)` — used consistently in `updatePacketsUrl()` and in tests ✅
- `RegionFilter.setSelected(codesArray)` — defined in Task 1, used in Task 3 ✅
+162
View File
@@ -0,0 +1,162 @@
# v3.4.2 Manual Validation Checklist
**Tester:** _______________
**Staging:** http://20.109.157.39
**Prod:** https://analyzer.00id.net (READ ONLY — do not deploy until staging passes)
**Browser:** Chrome + Firefox + Safari (mobile for responsive items)
**Time estimate:** ~45 minutes
---
## 🔴 HIGH RISK — Test First
### 1. Zero-hop hash size display (#649, #653)
- [ ] Go to Packets page, find a DIRECT advert (route_type=2, 0 hops)
- [ ] Open packet detail — hash size should say "Unknown (zero-hop)" or be hidden, NOT "1 byte"
- [ ] Check "Path Length" field shows `hash_count=0 (direct advert)`
- [ ] Find a FLOOD advert with 0 hops — it SHOULD show hash size (this is different from DIRECT)
### 2. TRACE packet real path (#651, #656)
- [ ] Send a trace from your companion
- [ ] Watch Live map — the animated dot should only travel along completed hops (solid line)
- [ ] Unreached hops should show as dashed/ghosted line at reduced opacity
- [ ] If trace completes fully, entire path should be solid
- [ ] Ghost line should auto-clean after ~10 seconds
### 3. "Paths through this node" accuracy (#655, #658)
- [ ] Go to: http://20.109.157.39/#/nodes/c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4
- [ ] Check "Packets through this node" — packets should actually have this node in their path
- [ ] Compare with a node that shares a 2-char prefix (e.g. C0ffee SF) — they should show DIFFERENT packets
- [ ] Spot-check 3-4 packets: click through, verify path contains the node
### 4. Hash Stats "By Repeaters" (#652, #654)
- [ ] Go to Analytics → Hash Stats
- [ ] "By Repeaters" section should only show repeater-role nodes
- [ ] Compare count in "Multi-Byte Hash Adopters" vs "By Repeaters" — adopters may include companions, repeaters section should not
- [ ] Check that companions/rooms/sensors are excluded from the repeater distribution
### 5. Noise floor column chart (#600, #659)
- [ ] Go to Analytics → RF Health
- [ ] Noise floor chart should show vertical color-coded bars, NOT a line
- [ ] Green bars (< -100 dBm), yellow (-100 to -85), red (≥ -85)
- [ ] Hover over a bar — tooltip should show exact dBm + timestamp
- [ ] Check with only 1 observer selected — chart should still render (division by zero edge case)
- [ ] Reboot markers (if any) should show as vertical dashed lines
### 6. Async backfill on startup
- [ ] SSH to staging: `ssh -i ~/.ssh/id_ed25519 runner@20.109.157.39`
- [ ] `docker restart corescope-staging-go`
- [ ] Within 30 seconds, hit `curl http://localhost:82/api/stats` — should return data (not hang)
- [ ] Check `backfilling` and `backfillProgress` fields in stats response
- [ ] Server should be serving HTTP while backfill runs in background
---
## 🟡 MEDIUM RISK — Features
### 7. Distance unit preference (#621, #646)
- [ ] Go to Customizer → Display tab
- [ ] Change distance unit to "mi" — all distances should show in miles
- [ ] Change to "km" — all distances should show in km
- [ ] Change to "auto" — should use locale (US = miles, EU = km)
- [ ] Check Analytics page distances update after customizer change (no page reload needed)
- [ ] Check Node detail → Neighbors table distances
- [ ] Very small distances (<0.1 mi) should show in feet, not "0.0 mi"
### 8. Panel corner toggle (#608, #657)
- [ ] Go to Live map page
- [ ] Each panel (feed, legend, node detail) should have a small corner-toggle button
- [ ] Click the button — panel should snap to next corner (TL → TR → BR → BL)
- [ ] Refresh page — panel positions should persist (localStorage)
- [ ] Move two panels to same corner — collision avoidance should skip to next free corner
- [ ] On mobile viewport (<768px?) — toggle buttons should be hidden
### 9. Deep linking (#536, #618)
- [ ] Navigate to Nodes page, click a node → URL should update with pubkey hash
- [ ] Copy URL, open in new tab → should land on same node
- [ ] Apply packet filters → URL hash should include filter params
- [ ] Channels page: select a node → URL should reflect selection
- [ ] Analytics tabs: switch tabs → URL should include tab name
- [ ] Share a deep link with someone — they should see the same view
### 10. Sortable tables (#620, #638, #639)
- [ ] Nodes list: click column headers — should sort ascending/descending
- [ ] Sort indicator (arrow) should be visible on active column
- [ ] Node detail → Neighbors table: sortable
- [ ] Node detail → Observers table: sortable
- [ ] Packets table: sortable by column headers
### 11. Channel color highlighting (#271, #607, #611)
- [ ] Go to Channels page
- [ ] Assign a color to a channel using the color picker
- [ ] Feed rows should highlight with that color
- [ ] Change color — should update immediately
- [ ] Refresh — color assignment should persist
### 12. Collapsible panels (#606)
- [ ] Live map: panels should have collapse/expand toggle
- [ ] Collapsed panel should show just the header
- [ ] State should persist across page navigations
### 13. Mobile accessibility (#630, #633)
- [ ] Open staging on phone (or Chrome DevTools mobile emulation)
- [ ] Touch targets should be at least 44×44px
- [ ] Channel color picker should work on mobile
- [ ] No horizontal scroll on any page
- [ ] ARIA labels present on interactive elements (inspect with accessibility tools)
### 14. Map byte-size filter (#565, #568)
- [ ] Go to Map page
- [ ] Find the byte-size filter control
- [ ] Filter by packet size — map should update to show only matching packets
- [ ] Clear filter — all packets should return
### 15. API key security (#532, #628)
- [ ] Try accessing a write endpoint without API key — should be blocked
- [ ] Try with a weak key (e.g., "test", "admin") — should be rejected at startup
- [ ] Check staging logs for API key warning: `docker logs corescope-staging-go 2>&1 | grep -i "apiKey\|api_key\|security"`
### 16. OpenAPI/Swagger (#530, #632)
- [ ] Hit http://20.109.157.39/api/spec — should return valid OpenAPI 3.0 spec
- [ ] Hit http://20.109.157.39/api/docs — should show Swagger UI
- [ ] Try an endpoint from Swagger UI — should work
---
## 🟢 LOW RISK — Verify Quickly
### 17. View Route on Map button
- [ ] Go to any packet detail page
- [ ] Click "View Route on Map" — should navigate to map with route highlighted
### 18. og-image compression
- [ ] Check page source or network tab — og-image.png should be < 300KB (was 1.1MB)
### 19. Prefix Tool
- [ ] Analytics → Prefix Tool tab should load
- [ ] Should show collision data
### 20. License
- [ ] Check repo footer/LICENSE — should be GPL v3
### 21. Docker DISABLE_CADDY
- [ ] (If testable) Set DISABLE_CADDY=true — Caddy should not start
### 22. Region filter on RF Health
- [ ] RF Health tab: change region filter — charts should update
---
## 🏁 Sign-off
| Section | Status | Notes |
|---------|--------|-------|
| High risk (1-6) | ☐ | |
| Medium risk (7-16) | ☐ | |
| Low risk (17-22) | ☐ | |
| **Overall** | ☐ | |
**Tested by:** _______________
**Date:** _______________
**Staging version:** `curl -s http://20.109.157.39/api/stats | jq .version`
**Ready for release:** ☐ Yes / ☐ No — blockers: _______________
+309
View File
@@ -0,0 +1,309 @@
# v3.4.2 Release Test Plan
**Scope:** 90 commits since v3.4.1 (84 files, +14,931 / -1,005)
**Categories:** 19 perf, 19 feat, 18 fix, 15 docs, 3 chore, 1 test, 1 refactor, 1 ci
**Date:** 2026-04-08
---
## A. Automated Tests — Verify All Pass
### Go Backend
```bash
cd cmd/server && go test -race -count=1 ./...
cd cmd/ingestor && go test -race -count=1 ./...
```
**Test files (27 total):**
| File | Tests For |
|------|-----------|
| `cmd/server/decoder_test.go` | Hash size zero-hop, TRACE hopsCompleted, transport direct |
| `cmd/server/backfill_async_test.go` | **NEW** — Async chunked backfill |
| `cmd/server/eviction_test.go` | Memory eviction with runtime heap stats |
| `cmd/server/apikey_security_test.go` | **NEW** — Weak/default API key rejection |
| `cmd/server/openapi_test.go` | **NEW** — OpenAPI spec generation |
| `cmd/server/routes_test.go` | Batch observations endpoint, subpaths-bulk, expand=observations |
| `cmd/server/cache_invalidation_test.go` | cacheTTL config wiring |
| `cmd/server/config_knobs_test.go` | cacheTTLSec helper |
| `cmd/server/helpers_test.go` | constantTimeEqual, IsWeakAPIKey |
| `cmd/server/obs_dedup_test.go` | UniqueObserverCount tracking |
| `cmd/server/neighbor_*.go` (4 files) | Neighbor graph, affinity, persistence |
| `cmd/server/perfstats_race_test.go` | Perf stats concurrency |
| `cmd/server/resolve_context_test.go` | Resolved path filtering |
| `cmd/server/advert_pubkey_test.go` | Advert pubkey tracking |
| `cmd/server/db_test.go` | SQLite operations |
| `cmd/server/config_test.go` | Config loading |
| `cmd/server/coverage_test.go` | Coverage helpers |
| `cmd/server/parity_test.go` | Go/JS decoder parity |
| `cmd/server/websocket_test.go` | WebSocket broadcast |
| `cmd/ingestor/decoder_test.go` | Ingestor decoder (hash size zero-hop) |
| `cmd/ingestor/db_test.go` | Ingestor DB writes |
| `cmd/ingestor/config_test.go` | Ingestor config |
| `cmd/ingestor/main_test.go` | Ingestor entry |
| `cmd/ingestor/coverage_boost_test.go` | Coverage helpers |
### Frontend Unit Tests
```bash
node test-packet-filter.js
node test-aging.js
node test-frontend-helpers.js
node test-table-sort.js # NEW — shared table sort utility
node test-channel-colors.js # NEW — channel color model
node test-panel-corner.js # NEW — panel corner toggle
node test-packets.js # NEW — packets page logic
node test-hop-resolver-affinity.js
node test-customizer-v2.js
node test-live.js
node test-live-dedup.js
```
### E2E / Playwright
```bash
BASE_URL=http://localhost:13581 node test-e2e-playwright.js
```
**Expected:** All existing tests pass + new tests added for sortable tables, deep linking, collapsible panels.
---
## B. Manual Browser Verification
### B1. HIGH RISK — Data Correctness
| # | Feature | Page | What to Check |
|---|---------|------|---------------|
| 1 | Hash size zero-hop | Packets detail | Find a direct (route_type=0) packet → hash_size should show 0, not a bogus computed value |
| 2 | TRACE hopsCompleted | Packets detail / Live map | Find a TRACE packet → verify `hopsCompleted` shows in decoded JSON, live map shows real path length vs intended |
| 3 | Transport direct hash size | Packets detail | Find route_type=RouteTransportDirect packet → hash_size=0 |
| 4 | resolved_path filtering | Node detail → Paths tab | Verify path-hop candidates use resolved_path, no prefix collision false positives |
| 5 | Hash stats repeater filter | Analytics → Hash Issues | "By Repeaters" should only show nodes with repeater role, not companions/sensors |
| 6 | Async chunked backfill | Server startup | Start server with large DB → verify HTTP serves within 2 minutes, `X-CoreScope-Status: backfilling` header present, then transitions to `ready` |
| 7 | Memory eviction (heap stats) | Admin/stats | Verify `/api/stats` shows realistic memory numbers from runtime heap, not the old estimation |
| 8 | Distance/subpath/path-hop indexes | Analytics → Distances, Subpaths | Verify analytics data matches v3.4.1 output (no missing or extra entries) |
| 9 | cacheTTL config wiring | Config | Set `cacheTTL.analyticsHashSizes: 300` in config → verify collision cache respects it |
### B2. MEDIUM RISK — User-Facing Features
| # | Feature | Page | What to Check |
|---|---------|------|---------------|
| 10 | Distance unit preference | Nodes detail, Map | Toggle km/mi/auto in settings → distances update throughout UI |
| 11 | Panel corner toggle | Live page | Click corner toggle → panel moves to opposite corner, persists on reload |
| 12 | Noise floor column chart | Analytics → RF | Verify column chart renders with color-coded thresholds, hover shows values |
| 13 | Deep linking UI states | All pages | Navigate to `#/nodes?tab=neighbors`, `#/packets?observer=X`, `#/channels?node=Y` → correct state loads. Copy URL, open in new tab → same state |
| 14 | Sortable tables | Nodes list, Neighbors, Observers | Click column headers → sort asc/desc, indicator arrow shows, persists correctly |
| 15 | Channel color highlighting | Channels, Live feed | Assign color to channel → feed rows show that color, persists on reload |
| 16 | Mobile accessibility | All pages (phone viewport) | Touch targets ≥44px, ARIA labels present, small viewport doesn't overflow |
| 17 | Collapsible panels | Live map | Collapse/expand panels, medium breakpoint auto-collapses, state persists |
| 18 | Byte-size map filter | Map page | Filter by byte size → markers update correctly |
| 19 | OpenAPI/Swagger | `/api/spec`, `/api/docs` | Spec loads valid JSON, Swagger UI renders and all endpoints are documented |
| 20 | API key rejection | Protected endpoints | Send weak key (e.g. "changeme", "test123") → 403 forbidden |
| 21 | Channel color picker mobile | Channels (phone viewport) | Color picker usable on touch, doesn't overflow |
| 22 | RF Health dashboard | Analytics → RF Health | Observer metrics grid, airtime charts, battery charts, error rate, region filter |
| 23 | Prefix Tool tab | Analytics → Prefix Tool | Renders correctly, collision data consistent with Hash Issues |
| 24 | View Route on Map | Packet detail page | Button works and shows route on map |
### B3. LOWER RISK — Performance (Verify No Regressions)
| # | Feature | Page | What to Check |
|---|---------|------|---------------|
| 25 | Incremental DOM diff | Packets (30K+) | Virtual scroll renders smoothly, no visible flicker |
| 26 | Coalesced WS renders | Live page | Rapid packets don't cause frame drops (rAF coalescing) |
| 27 | Marker reposition on zoom | Map | Zoom/resize → markers move smoothly, no full rebuild flash |
| 28 | Parallel replay fetches | Live → VCR | Replay loads quickly (parallel observation fetches) |
| 29 | Batch observations API | Packets page (sort change) | Changing sort fetches observations in batch (network tab: 1 POST not N GETs) |
| 30 | Client-side network status | Analytics | No separate API call for network status |
| 31 | og-image compression | `/og-image.png` | Verify loads, ~235KB not ~1.1MB |
---
## C. API Regression Tests
Run against a local server with test-fixture DB:
```bash
BASE=http://localhost:13581
# Core endpoints — verify response shape
curl -s "$BASE/api/stats" | jq '.totalPackets, .backfilling, .backfillProgress'
curl -s "$BASE/api/packets?limit=5" | jq '.packets[0] | keys'
curl -s "$BASE/api/packets?limit=5&expand=observations" | jq '.packets[0].observations | length'
curl -s "$BASE/api/nodes?limit=5" | jq '.[0] | keys'
# New endpoints
curl -s -X POST "$BASE/api/packets/observations" \
-H 'Content-Type: application/json' \
-d '{"hashes":["test123"]}' | jq '.results | keys'
curl -s "$BASE/api/analytics/subpaths-bulk?hops=A,B&hops=B,C" | jq 'keys'
curl -s "$BASE/api/observers/metrics/summary" | jq 'type'
curl -s "$BASE/api/spec" | jq '.openapi'
curl -s "$BASE/api/docs" | head -5 # Should return HTML
# Backfill status header
curl -sI "$BASE/api/stats" | grep X-CoreScope-Status
# API key rejection
curl -s -H 'X-API-Key: changeme' "$BASE/api/debug/vars" | jq '.error'
curl -s -H 'X-API-Key: test' "$BASE/api/debug/vars" | jq '.error'
# Existing endpoints — verify not broken
curl -s "$BASE/api/analytics/rf?timeRange=24h" | jq 'keys'
curl -s "$BASE/api/analytics/hash-sizes" | jq 'type'
curl -s "$BASE/api/analytics/distances" | jq 'type'
curl -s "$BASE/api/analytics/subpaths" | jq 'type'
curl -s "$BASE/api/channels" | jq 'type'
curl -s "$BASE/api/config/client" | jq 'keys'
```
### Expected response shape changes from v3.4.1:
- `/api/stats` now includes `backfilling` (bool) and `backfillProgress` (float 0-1)
- `/api/packets` no longer strips observations by default (lazy via `ExpandObservations` flag) — verify `observations` key absent without `expand=observations`
- Decoded packets with route_type=direct now have `hashSize: 0`
- TRACE packets now have `path.hopsCompleted` field
---
## D. Performance Regression Tests
### D1. Server Startup Time
```bash
# Start server with production-size DB (~30K packets)
# Measure time from process start to first successful HTTP response
time curl -s http://localhost:13581/api/stats > /dev/null
# Target: < 2 minutes (async backfill requirement)
```
### D2. Go Benchmarks
```bash
cd cmd/server && go test -bench=. -benchmem -count=3
```
Key benchmarks to compare with v3.4.1 baseline:
- `BenchmarkQueryPackets` — should not regress with new indexes
- `BenchmarkEvictStale` — batch removal from secondary indexes
- `BenchmarkGetStoreStats` — 2 concurrent queries vs 5 sequential
- `BenchmarkIngestNew` — additional index maintenance overhead
### D3. Frontend Performance
- Open Packets page with 30K+ packets → measure initial render time (DevTools Performance tab)
- Scroll rapidly through virtual scroll → should maintain 60fps
- Switch sort column on packets → single batch POST, not N+1 GETs
- Open Analytics page → no redundant API calls in network tab
### D4. Memory Usage
- After loading 30K packets, check `/api/stats` memory figure
- Compare with v3.4.1 baseline (prefix map cap at 8 chars should reduce ~10x)
- Verify eviction triggers at correct memory threshold using runtime heap stats
---
## E. Infrastructure / Deployment Tests
### E1. Docker Build
```bash
docker build -t corescope:test .
docker run --rm -p 13581:13581 corescope:test
# Verify: container starts, HTTP responds, WebSocket connects
```
### E2. GHCR Publish (CI)
- Verify CI publishes to `ghcr.io/kpa-clawbot/corescope`
- Verify tags: `edge` (master), `vX.Y.Z` (release)
### E3. Staging Deploy
```bash
# Verify staging compose works with standard ports
docker compose -f docker-compose.staging.yml up -d
# Check: no 3GB memory limit, standard port binding
```
### E4. DISABLE_CADDY
```bash
docker run --rm -e DISABLE_CADDY=true corescope:test
# Verify: Caddy not started, Go server serves directly
```
### E5. CI Pipeline
- Verify consolidated pipeline: build → publish GHCR → deploy staging
- Verify runs on `meshcore-runner-2`
---
## F. Edge Cases & Integration Tests
### F1. Cross-Feature Interactions
| Scenario | Risk |
|----------|------|
| Deep link to sorted table → sort state matches URL params | Medium |
| Channel color + deep link → color persists in linked URL | Medium |
| Panel corner toggle + collapsible panels → both states persist independently | Low |
| Distance unit pref + neighbor table sort by distance → sort uses correct unit | Medium |
| Noise floor chart + region filter → chart respects filter | Medium |
| Byte-size map filter + channel color highlighting → both active simultaneously | Low |
### F2. Data Correctness Edge Cases
| Scenario | Risk |
|----------|------|
| Zero-hop TRACE packet (should NOT reset hashSize — TRACE exemption) | **High** |
| Packet with all hops having same 2-char prefix → resolved_path filtering prevents false match | **High** |
| Node that switches role (repeater → companion) → hash stats updates | Medium |
| Backfill interrupted mid-chunk (server restart) → resumes or completes on next start | Medium |
| Empty DB startup → no errors, backfill completes instantly | Low |
| DB with 100K+ packets → async backfill doesn't OOM, progress reported | **High** |
### F3. Concurrency / Race Conditions
| Scenario | Risk |
|----------|------|
| Concurrent API requests during backfill → no deadlock (lock ordering documented) | **High** |
| Eviction running while analytics query in progress → no stale pointer panic | **High** |
| Multiple WebSocket clients during high ingest rate → coalesced broadcasts don't drop | Medium |
| `time.NewTicker` cleanup on graceful shutdown (replaced `time.Tick`) | Low |
### F4. API Key Security
| Scenario | Expected |
|----------|----------|
| No API key configured → write endpoints disabled | 403 "write endpoints disabled" |
| Weak key "changeme" → rejected even if configured | 403 "forbidden" |
| Timing-safe comparison → no timing oracle | Constant-time via `crypto/subtle` |
| Empty string key → rejected | 401 "unauthorized" |
### F5. Browser Compatibility
- Test on Chrome, Firefox, Safari (latest)
- Test on iOS Safari, Android Chrome
- Verify touch targets on mobile (44px minimum)
- Verify ARIA labels with screen reader
---
## G. Test Coverage Gaps — Action Items
| Gap | Priority | Action |
|-----|----------|--------|
| No automated test for distance unit preference rendering | Medium | Add Playwright test |
| No automated test for noise floor column chart | Medium | Add Playwright test |
| No automated test for deep link state restoration | **High** | Add Playwright tests for each deep-linkable state |
| No automated test for channel color persistence | Medium | `test-channel-colors.js` covers model; need Playwright for UI |
| No automated test for mobile viewport behavior | Medium | Add Playwright test with mobile viewport |
| No automated test for backfill progress header | Low | Add to `routes_test.go` |
| No automated test for `time.NewTicker` cleanup | Low | Add to graceful shutdown test |
| Observer metrics endpoints not covered in route tests | Medium | Add to `routes_test.go` |
| Subpaths-bulk endpoint needs test | Medium | Add to `routes_test.go` |
| No load test for batch observations endpoint (200 hash limit) | Low | Add boundary test |
---
## H. Release Checklist
- [ ] All Go tests pass with `-race` flag
- [ ] All frontend unit tests pass
- [ ] Playwright E2E tests pass
- [ ] Manual browser verification (Section B) complete
- [ ] API regression tests (Section C) pass
- [ ] Docker build succeeds
- [ ] Staging deploy verified
- [ ] No console errors on any page
- [ ] Performance spot-checks (Section D) — no regressions
- [ ] Coverage badges updated (backend ≥85%, frontend ≥42%)
- [ ] CHANGELOG updated
- [ ] Tag `v3.4.2` created
+11
View File
@@ -52,3 +52,14 @@ CoreScope uses URL hashes for deep linking. Copy the URL from your browser — i
- `#/packets/abc123` — a specific packet
- `#/analytics?tab=collisions` — the hash issues tab
- `#/nodes/pubkey123` — a specific node's detail page
### Where is the API documentation?
CoreScope auto-generates an OpenAPI 3.0 specification from its route definitions:
- **Interactive docs (Swagger UI):** `/api/docs` — browse and test all 40+ endpoints from your browser
- **Machine-readable spec:** `/api/spec` — import into Postman, Insomnia, or any OpenAPI tool
The spec is always in sync with the running server. No manual maintenance needed.
On the public instance: [analyzer.00id.net/api/docs](https://analyzer.00id.net/api/docs)
+3
View File
@@ -0,0 +1,3 @@
module github.com/meshcore-analyzer/sigvalidate
go 1.22
+23
View File
@@ -0,0 +1,23 @@
// Package sigvalidate provides Ed25519 signature validation for MeshCore adverts.
package sigvalidate
import (
"crypto/ed25519"
"encoding/binary"
)
// ValidateAdvertSignature verifies an Ed25519 signature over a MeshCore advert.
// The signed message is: pubKey (32 bytes) || timestamp (4 bytes LE) || appdata.
// Returns false if pubKey is not 32 bytes or signature is not 64 bytes.
func ValidateAdvertSignature(pubKey, signature []byte, timestamp uint32, appdata []byte) bool {
if len(pubKey) != 32 || len(signature) != 64 {
return false
}
message := make([]byte, 32+4+len(appdata))
copy(message[0:32], pubKey)
binary.LittleEndian.PutUint32(message[32:36], timestamp)
copy(message[36:], appdata)
return ed25519.Verify(ed25519.PublicKey(pubKey), message, signature)
}
+182 -127
View File
@@ -136,9 +136,14 @@
analyticsContent.addEventListener('keydown', handler);
}
// Re-render when distance unit or theme changes
_themeRefreshHandler = function () { renderTab(_currentTab); };
window.addEventListener('theme-refresh', _themeRefreshHandler);
loadAnalytics();
}
var _themeRefreshHandler = null;
let _currentTab = 'overview';
async function loadAnalytics() {
@@ -992,6 +997,7 @@
<span style="color:var(--border)">|</span>
<a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">🔎 Check a prefix </a>
</nav>
<p class="text-muted" style="margin:0 0 12px;font-size:0.78em">This tab shows operational collisions among <strong>repeaters</strong> grouped by their configured hash size. The <a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">Prefix Tool</a> checks all repeaters regardless of their configured hash size.</p>
<div class="analytics-card" id="inconsistentHashSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0"> Inconsistent Hash Sizes</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)"> top</a></div>
@@ -1197,6 +1203,45 @@
</div>`;
}
// --- Shared cell classification for hash matrix ---
function classifyHashCell(count, isConfirmedCollision, isPossibleConflict) {
if (count === 0) return { cls: 'hash-cell-empty', bg: '' };
if (!isConfirmedCollision && !isPossibleConflict) return { cls: 'hash-cell-taken', bg: '' };
if (isPossibleConflict) return { cls: 'hash-cell-possible', bg: '' };
const t = Math.min((count - 2) / 4, 1);
return { cls: 'hash-cell-collision', bg: `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);` };
}
function hashCellTd(hex, cellSize, cls, bg, count, tipHtml, fontWeight) {
return `<td class="hash-cell ${cls}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tipHtml.replace(/"/g,'&quot;')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bg}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${fontWeight}">${hex}</td>`;
}
function hashTooltipHtml(hexLabel, statusText, nodesHtml) {
let html = `<div class="hash-matrix-tooltip-hex">${hexLabel}</div><div class="hash-matrix-tooltip-status">${statusText}</div>`;
if (nodesHtml) html += `<div class="hash-matrix-tooltip-nodes">${nodesHtml}</div>`;
return html;
}
function renderHashMatrixPanel(el, statCardsHtml, cellRendererFn, detailMaxWidth, legendLabels, clickHandlerFn) {
const nibbles = '0123456789ABCDEF'.split('');
const cellSize = 36;
const headerSize = 24;
let html = statCardsHtml;
html += hashMatrixGridHtml(nibbles, cellSize, headerSize, cellRendererFn);
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:${detailMaxWidth}px;font-size:0.85em"></div></div>`;
html += hashMatrixLegendHtml(legendLabels);
el.innerHTML = html;
initMatrixTooltip(el);
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
clickHandlerFn(td);
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
});
});
}
function renderHashMatrixFromServer(sizeData, bytes) {
const el = document.getElementById('hashMatrix');
if (!sizeData) { el.innerHTML = '<div class="text-muted">No data</div>'; return; }
@@ -1206,55 +1251,42 @@
// 3-byte: show a summary panel instead of a matrix
if (bytes === 3) {
el.innerHTML = hashStatCardsHtml(totalNodes, stats.using_this_size || 0, '3-byte', 16777216, stats.unique_prefixes || 0, stats.collision_count || 0) +
`<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>`;
`<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>` +
`<p class="text-muted" style="margin:8px 0 0;font-size:0.8em">️ This tab only counts collisions among repeaters configured for this hash size. The <a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">Prefix Tool</a> checks all repeaters regardless of configured hash size.</p>`;
return;
}
const nibbles = '0123456789ABCDEF'.split('');
const cellSize = 36;
const headerSize = 24;
if (bytes === 1) {
const oneByteCells = sizeData.one_byte_cells || {};
const oneByteCount = stats.using_this_size || 0;
const oneUsed = Object.values(oneByteCells).filter(v => v.length > 0).length;
const oneCollisions = Object.values(oneByteCells).filter(v => v.length > 1).length;
let html = hashStatCardsHtml(totalNodes, oneByteCount, '1-byte', 256, oneUsed, oneCollisions);
html += hashMatrixGridHtml(nibbles, cellSize, headerSize, (hex, cs) => {
renderHashMatrixPanel(el,
hashStatCardsHtml(totalNodes, oneByteCount, '1-byte', 256, oneUsed, oneCollisions),
(hex, cs) => {
const nodes = oneByteCells[hex] || [];
const count = nodes.length;
const repeaterCount = nodes.filter(n => n.role === 'repeater').length;
const isCollision = count >= 2 && repeaterCount >= 2;
const isPossible = count >= 2 && !isCollision;
let cellClass, bgStyle;
if (count === 0) { cellClass = 'hash-cell-empty'; bgStyle = ''; }
else if (count === 1) { cellClass = 'hash-cell-taken'; bgStyle = ''; }
else if (isPossible) { cellClass = 'hash-cell-possible'; bgStyle = ''; }
else { const t = Math.min((count - 2) / 4, 1); bgStyle = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass = 'hash-cell-collision'; }
const { cls, bg } = classifyHashCell(count, isCollision, isPossible);
const nodeLabel = m => `<div style="font-size:11px">${esc(m.name||m.public_key.slice(0,12))}${!m.role ? ' <span style="opacity:0.7">(unknown role)</span>' : ''}</div>`;
const tip1 = count === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">Available</div>`
: count === 1
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">One node — no collision</div><div class="hash-matrix-tooltip-nodes">${nodeLabel(nodes[0])}</div>`
: isPossible
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — POSSIBLE CONFLICT</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`
: `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — COLLISION</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`;
return `<td class="hash-cell ${cellClass}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip1.replace(/"/g,'&quot;')}" style="width:${cs}px;height:${cs}px;text-align:center;${bgStyle}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${count >= 2 ? '700' : '400'}">${hex}</td>`;
});
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>`;
html += hashMatrixLegendHtml([
{cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'Available'},
{cls: 'hash-cell-taken', text: 'One node'},
{cls: 'hash-cell-possible', text: 'Possible conflict'},
{cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'}
]);
el.innerHTML = html;
initMatrixTooltip(el);
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
const nodesPreview = nodes.slice(0,5).map(nodeLabel).join('') + (nodes.length > 5 ? `<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>` : '');
const tip = count === 0 ? hashTooltipHtml(`0x${hex}`, 'Available')
: count === 1 ? hashTooltipHtml(`0x${hex}`, 'One node — no collision', nodeLabel(nodes[0]))
: isPossible ? hashTooltipHtml(`0x${hex}`, `${count} nodesPOSSIBLE CONFLICT`, nodesPreview)
: hashTooltipHtml(`0x${hex}`, `${count} nodes — COLLISION`, nodesPreview);
return hashCellTd(hex, cs, cls, bg, count, tip, count >= 2 ? '700' : '400');
},
400,
[
{cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'Available'},
{cls: 'hash-cell-taken', text: 'One node'},
{cls: 'hash-cell-possible', text: 'Possible conflict'},
{cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'}
],
(td) => {
const hex = td.dataset.hex.toUpperCase();
const matches = oneByteCells[hex] || [];
const detail = document.getElementById('hashDetail');
@@ -1265,10 +1297,8 @@
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
}).join('')}</div>`;
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
});
});
}
);
} else if (bytes === 2) {
const twoByteCells = sizeData.two_byte_cells || {};
@@ -1276,38 +1306,34 @@
const uniqueTwoBytePrefixes = stats.unique_prefixes || 0;
const twoCollisions = Object.values(twoByteCells).filter(v => v.collision_count > 0).length;
let html = hashStatCardsHtml(totalNodes, twoByteCount, '2-byte', 65536, uniqueTwoBytePrefixes, twoCollisions);
html += hashMatrixGridHtml(nibbles, cellSize, headerSize, (hex, cs) => {
renderHashMatrixPanel(el,
hashStatCardsHtml(totalNodes, twoByteCount, '2-byte', 65536, uniqueTwoBytePrefixes, twoCollisions),
(hex, cs) => {
const info = twoByteCells[hex] || { group_nodes: [], max_collision: 0, collision_count: 0, two_byte_map: {} };
const nodeCount = (info.group_nodes || []).length;
const maxCol = info.max_collision || 0;
const overlapping = Object.values(info.two_byte_map || {}).filter(v => v.length > 1);
const hasConfirmed = overlapping.some(ns => ns.filter(n => n.role === 'repeater').length >= 2);
const hasPossible = !hasConfirmed && overlapping.some(ns => ns.length >= 2);
let cellClass2, bgStyle2;
if (nodeCount === 0) { cellClass2 = 'hash-cell-empty'; bgStyle2 = ''; }
else if (maxCol === 0) { cellClass2 = 'hash-cell-taken'; bgStyle2 = ''; }
else if (hasPossible) { cellClass2 = 'hash-cell-possible'; bgStyle2 = ''; }
else { const t = Math.min((maxCol - 2) / 4, 1); bgStyle2 = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass2 = 'hash-cell-collision'; }
const { cls, bg } = classifyHashCell(maxCol > 0 ? maxCol : nodeCount === 0 ? 0 : 1, hasConfirmed, hasPossible);
const nodeLabel2 = m => esc(m.name||m.public_key.slice(0,8)) + (!m.role ? ' (?)' : '');
const tip2 = nodeCount === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">No nodes in this group</div>`
const tip = nodeCount === 0
? hashTooltipHtml(`0x${hex}__`, 'No nodes in this group')
: (info.collision_count || 0) === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions</div>`
: `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${hasConfirmed ? (info.collision_count||0) + ' collision' + ((info.collision_count||0)>1?'s':'') : 'Possible conflict'}</div><div class="hash-matrix-tooltip-nodes">${Object.entries(info.two_byte_map||{}).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> ${ns.map(nodeLabel2).join(', ')}</div>`).join('')}</div>`;
return `<td class="hash-cell ${cellClass2}${nodeCount ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip2.replace(/"/g,'&quot;')}" style="width:${cs}px;height:${cs}px;text-align:center;${bgStyle2}border:1px solid var(--border);cursor:${nodeCount ? 'pointer' : 'default'};font-size:11px;font-weight:${maxCol > 0 ? '700' : '400'}">${hex}</td>`;
});
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:420px;font-size:0.85em"></div></div>`;
html += hashMatrixLegendHtml([
{cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'No nodes in group'},
{cls: 'hash-cell-taken', text: 'Nodes present, no collision'},
{cls: 'hash-cell-possible', text: 'Possible conflict'},
{cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'}
]);
el.innerHTML = html;
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
? hashTooltipHtml(`0x${hex}__`, `${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions`)
: hashTooltipHtml(`0x${hex}__`,
hasConfirmed ? (info.collision_count||0) + ' collision' + ((info.collision_count||0)>1?'s':'') : 'Possible conflict',
Object.entries(info.two_byte_map||{}).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> — ${ns.map(nodeLabel2).join(', ')}</div>`).join(''));
return hashCellTd(hex, cs, cls, bg, nodeCount, tip, maxCol > 0 ? '700' : '400');
},
420,
[
{cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'No nodes in group'},
{cls: 'hash-cell-taken', text: 'Nodes present, no collision'},
{cls: 'hash-cell-possible', text: 'Possible conflict'},
{cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'}
],
(td) => {
const hex = td.dataset.hex.toUpperCase();
const info = twoByteCells[hex];
const detail = document.getElementById('hashDetail');
@@ -1332,12 +1358,8 @@
dhtml += '</div>';
}
detail.innerHTML = dhtml;
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
});
});
initMatrixTooltip(el);
}
);
}
}
@@ -1348,13 +1370,15 @@
if (!collisions.length) {
const cleanMsg = bytes === 3
? '✅ No 3-byte prefix collisions detected — all nodes have unique 3-byte prefixes.'
? '✅ No 3-byte prefix collisions detected — all repeaters have unique 3-byte prefixes.'
: `✅ No ${bytes}-byte collisions detected`;
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
return;
}
const showAppearances = bytes < 3;
const t50 = formatDistanceRound(50);
const t200 = formatDistanceRound(200);
el.innerHTML = `<table class="analytics-table">
<thead><tr>
<th scope="col">Prefix</th>
@@ -1366,20 +1390,20 @@
<tbody>${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
badge = `<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within ${t50} — likely true collision, same RF neighborhood">🏘️ Local</span>`;
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
} else if (c.classification === 'regional') {
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
badge = `<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes ${t50}${t200} apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>`;
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
} else if (c.classification === 'distant') {
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
badge = `<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >${t200} apart — beyond typical 915MHz range">🌐 Distant</span>`;
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
} else {
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
tooltip = 'Not enough coordinate data to classify';
}
const nodes = c.nodes || [];
const distStr = c.with_coords >= 2 ? `${Math.round(c.max_dist_km)} km` : '<span class="text-muted">—</span>';
const distStr = c.with_coords >= 2 ? formatDistanceRound(c.max_dist_km) : '<span class="text-muted">—</span>';
return `<tr>
<td class="mono">${c.prefix}</td>
${showAppearances ? `<td>${(c.appearances || 0).toLocaleString()}</td>` : ''}
@@ -1395,9 +1419,9 @@
}).join('')}</tbody>
</table>
<div class="text-muted" style="padding:8px;font-size:0.8em">
<strong>🏘 Local</strong> &lt;50km: true prefix collision, same mesh area &nbsp;
<strong> Regional</strong> 50200km: edge of LoRa range, possible atmospheric propagation &nbsp;
<strong>🌐 Distant</strong> &gt;200km: beyond 915MHz range internet bridge, MQTT gateway, or separate networks
<strong>🏘 Local</strong> &lt;${t50}: true prefix collision, same mesh area &nbsp;
<strong> Regional</strong> ${t50}${t200}: edge of LoRa range, possible atmospheric propagation &nbsp;
<strong>🌐 Distant</strong> &gt;${t200}: beyond 915MHz range internet bridge, MQTT gateway, or separate networks
</div>`;
}
async function renderSubpaths(el) {
@@ -1528,12 +1552,12 @@
: (() => { const R=6371, dLat=(b.lat-a.lat)*Math.PI/180, dLon=(b.lon-a.lon)*Math.PI/180, h=Math.sin(dLat/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2; return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h)); })();
total += km;
const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)}${esc(b.name)}</span></div>`);
dists.push(`<div style="padding:2px 0"><span style="${cls}">${formatDistance(km)}</span> <span class="text-muted">${esc(a.name)}${esc(b.name)}</span></div>`);
} else {
dists.push(`<div style="padding:2px 0"><span class="text-muted">? ${esc(a.name)}${esc(b.name)} (no coords)</span></div>`);
}
}
if (dists.length > 1) dists.push(`<div style="padding:4px 0;border-top:1px solid var(--border);margin-top:4px"><strong>Total: ${total < 1 ? (total*1000).toFixed(0)+'m' : total.toFixed(1)+'km'}</strong></div>`);
if (dists.length > 1) dists.push(`<div style="padding:4px 0;border-top:1px solid var(--border);margin-top:4px"><strong>Total: ${formatDistance(total)}</strong></div>`);
return dists.join('');
})()}
</div>` : ''}
@@ -1770,16 +1794,17 @@
let html = `<div class="analytics-grid">
<div class="stat-card"><div class="stat-value">${s.totalHops.toLocaleString()}</div><div class="stat-label">Total Hops Analyzed</div></div>
<div class="stat-card"><div class="stat-value">${s.totalPaths.toLocaleString()}</div><div class="stat-label">Paths Analyzed</div></div>
<div class="stat-card"><div class="stat-value">${s.avgDist} km</div><div class="stat-label">Avg Hop Distance</div></div>
<div class="stat-card"><div class="stat-value">${s.maxDist} km</div><div class="stat-label">Max Hop Distance</div></div>
<div class="stat-card"><div class="stat-value">${formatDistance(s.avgDist)}</div><div class="stat-label">Avg Hop Distance</div></div>
<div class="stat-card"><div class="stat-value">${formatDistance(s.maxDist)}</div><div class="stat-label">Max Hop Distance</div></div>
</div>`;
// Category stats
const cats = data.catStats;
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th scope="col">Type</th><th scope="col">Count</th><th scope="col">Avg (km)</th><th scope="col">Median (km)</th><th scope="col">Min (km)</th><th scope="col">Max (km)</th></tr></thead><tbody>`;
const distUnitLabel = getDistanceUnit() === 'mi' ? 'mi' : 'km';
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th scope="col">Type</th><th scope="col">Count</th><th scope="col">Avg (${distUnitLabel})</th><th scope="col">Median (${distUnitLabel})</th><th scope="col">Min (${distUnitLabel})</th><th scope="col">Max (${distUnitLabel})</th></tr></thead><tbody>`;
for (const [cat, st] of Object.entries(cats)) {
if (!st.count) continue;
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${st.avg}</td><td>${st.median}</td><td>${st.min}</td><td>${st.max}</td></tr>`;
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${formatDistance(st.avg)}</td><td>${formatDistance(st.median)}</td><td>${formatDistance(st.min)}</td><td>${formatDistance(st.max)}</td></tr>`;
}
html += `</tbody></table></div>`;
@@ -1796,7 +1821,7 @@
}
// Top hops leaderboard
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (km)</th><th scope="col">Type</th><th scope="col">SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (${distUnitLabel})</th><th scope="col">Type</th><th scope="col">SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
const top20 = data.topHops.slice(0, 20);
top20.forEach((h, i) => {
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
@@ -1804,13 +1829,13 @@
const snr = h.snr != null ? h.snr + ' dB' : '<span class="text-muted">—</span>';
const pktLink = h.hash ? `<a href="#/packet/${encodeURIComponent(h.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(h.hash.slice(0, 12))}…</a>` : '—';
const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map">🗺️</button>` : '';
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${h.dist}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${formatDistance(h.dist)}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
});
html += `</tbody></table></div>`;
// Top paths
if (data.topPaths.length) {
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (km)</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">Total Distance (${distUnitLabel})</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
data.topPaths.slice(0, 10).forEach((p, i) => {
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → ');
const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—';
@@ -1819,7 +1844,7 @@
p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); });
if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); }
const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="View on map">🗺️</button>` : '';
html += `<tr><td>${i+1}</td><td><strong>${p.totalDist}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
html += `<tr><td>${i+1}</td><td><strong>${formatDistance(p.totalDist)}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
});
html += `</tbody></table></div>`;
}
@@ -1847,7 +1872,7 @@
}
}
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; }
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
// Expose for testing
if (typeof window !== 'undefined') {
@@ -1856,6 +1881,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
window._analyticsSaveChannelSort = saveChannelSort;
window._analyticsChannelTbodyHtml = channelTbodyHtml;
window._analyticsChannelTheadHtml = channelTheadHtml;
window._analyticsRfNFColumnChart = rfNFColumnChart;
}
// ─── Neighbor Graph Tab ─────────────────────────────────────────────────────
@@ -2330,10 +2356,13 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
nodeMap.set(n.public_key, n);
}
});
const nodes = [...nodeMap.values()];
const allNodes = [...nodeMap.values()];
// Only repeaters matter for prefix collisions — they relay packets using hash prefixes.
// Companions, rooms, and sensors don't route, so their prefix collisions are harmless.
const nodes = allNodes.filter(n => n.role === 'repeater');
if (nodes.length === 0) {
el.innerHTML = `<div class="analytics-card"><p class="text-muted">No nodes in the network yet. Any prefix is available!</p></div>`;
el.innerHTML = `<div class="analytics-card"><p class="text-muted">No repeaters in the network yet. Any prefix is available!</p></div>`;
return;
}
@@ -2362,11 +2391,11 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
const totalNodes = nodes.length;
let rec, recDetail;
if (totalNodes < 20) {
rec = '1-byte'; recDetail = `With only ${totalNodes} nodes, 1-byte prefixes have low collision risk.`;
rec = '1-byte'; recDetail = `With only ${totalNodes} repeaters, 1-byte prefixes have low collision risk.`;
} else if (totalNodes < 500) {
rec = '2-byte'; recDetail = `With ${totalNodes} nodes, 2-byte prefixes are recommended to avoid collisions.`;
rec = '2-byte'; recDetail = `With ${totalNodes} repeaters, 2-byte prefixes are recommended to avoid collisions.`;
} else {
rec = '2-byte'; recDetail = `With ${totalNodes} nodes, 2-byte prefixes are strongly recommended.`;
rec = '2-byte'; recDetail = `With ${totalNodes} repeaters, 2-byte prefixes are strongly recommended.`;
}
// URL params for pre-fill / auto-run
@@ -2375,7 +2404,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
const initGenerate = hashParams.get('generate') || '';
const regionNote = regionLabel
? `<p class="text-muted" style="font-size:0.85em;margin:4px 0 0">Showing data for region: <strong>${esc(regionLabel)}</strong>. <a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">Check all nodes →</a></p>`
? `<p class="text-muted" style="font-size:0.85em;margin:4px 0 0">Showing data for region: <strong>${esc(regionLabel)}</strong>. <a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">Check all repeaters →</a></p>`
: '';
el.innerHTML = `
@@ -2388,7 +2417,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
${regionNote}
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:12px 0 16px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Total nodes</div>
<div class="analytics-stat-label">Total repeaters</div>
<div class="analytics-stat-value">${totalNodes.toLocaleString()}</div>
</div>
${[1, 2, 3].map(b => `
@@ -2405,10 +2434,15 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
</div>
</div>`).join('')}
</div>
<div style="background:var(--bg-secondary,var(--bg));border:1px solid var(--border);border-radius:6px;padding:10px 14px">
<div style="background:var(--bg-secondary,var(--bg));border:1px solid var(--border);border-radius:6px;padding:10px 14px;margin-bottom:12px">
<strong>Recommendation: ${rec} prefixes</strong> ${recDetail}
<span class="text-muted" style="font-size:0.8em;display:block;margin-top:4px">Hash size is configured per-node in firmware. Changing requires reflashing.</span>
</div>
<div style="background:var(--bg-secondary,var(--bg));border:1px solid var(--border);border-radius:6px;padding:10px 14px;font-size:0.85em">
<strong> About these numbers:</strong> This tool checks <em>repeater</em> public key prefixes regardless of their configured hash size. Only repeaters are included because they are the nodes that relay packets using hash-based addressing.
The <a href="#/analytics?tab=collisions" style="color:var(--accent)">Hash Issues</a> tab shows only <em>operational</em> collisions nodes that actually use the same hash size and are repeaters.
A collision shown here may not appear in Hash Issues if the nodes use a different hash size.
</div>
</div>
</div>
@@ -2454,8 +2488,9 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
function nodeEntry(n) {
const name = esc(n.name || n.public_key.slice(0, 12));
const role = n.role ? `<span class="text-muted" style="font-size:0.82em">${esc(n.role)}</span>` : '';
const hs = n.hash_size ? ` <span class="text-muted" style="font-size:0.78em;opacity:0.7">${n.hash_size}B hash</span>` : '';
const when = n.last_seen ? ` <span class="text-muted" style="font-size:0.8em">${new Date(n.last_seen).toLocaleDateString()}</span>` : '';
return `<div style="padding:3px 0"><a href="#/nodes/${encodeURIComponent(n.public_key)}" class="analytics-link">${name}</a> ${role}${when}</div>`;
return `<div style="padding:3px 0"><a href="#/nodes/${encodeURIComponent(n.public_key)}" class="analytics-link">${name}</a> ${role}${hs}${when}</div>`;
}
function severityBadge(count) {
@@ -2898,7 +2933,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
// Render noise floor chart
const nfEl = document.getElementById('rfDetailNFChart');
if (nfEl && nfData.length > 1) {
nfEl.innerHTML = rfNFLineChart(nfData, nfEl.clientWidth || 700, 180, reboots, minT, maxT);
nfEl.innerHTML = rfNFColumnChart(nfData, nfEl.clientWidth || 700, 180, reboots, minT, maxT);
} else if (nfEl) {
nfEl.innerHTML = '<span class="text-muted">Not enough noise floor data</span>';
}
@@ -3162,7 +3197,13 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
return svg;
}
function rfNFLineChart(data, w, h, reboots, sharedMinT, sharedMaxT) {
/**
* Noise floor column chart color-coded bars (green/yellow/red) by threshold.
* Replaces the old line chart for better discrete-sample readability.
* Thresholds: green (< -100 dBm), yellow (-100 to -85 dBm), red ( -85 dBm).
*/
function rfNFColumnChart(data, w, h, reboots, sharedMinT, sharedMaxT) {
if (!data || !data.length) return '<svg viewBox="0 0 1 1"></svg>';
reboots = reboots || [];
const pad = { top: 20, right: 40, bottom: 30, left: 55 };
const cw = w - pad.left - pad.right;
@@ -3173,34 +3214,33 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
const maxT = sharedMaxT != null ? sharedMaxT : Math.max(...data.map(d => new Date(d.t).getTime()));
const minV = Math.min(...values);
const maxV = Math.max(...values);
const rangeV = maxV - minV || 1;
// Guard against zero range (single data point or constant values):
// use a ±5 dBm window so bars are visible and centered in the chart
const rawRangeV = maxV - minV;
const rangeV = rawRangeV || 10;
const adjMinV = rawRangeV ? minV : minV - 5;
const rangeT = maxT - minT || 1;
const sx = t => pad.left + ((t - minT) / rangeT) * cw;
const sy = v => pad.top + ch - ((v - minV) / rangeV) * ch;
const sy = v => pad.top + ch - ((v - adjMinV) / rangeV) * ch;
const pts = data.map(d => `${sx(new Date(d.t).getTime()).toFixed(1)},${sy(d.v).toFixed(1)}`).join(' ');
// Column width: proportional to chart width / data points, min 2px, gap of 1px
const colW = Math.max(2, Math.floor(cw / data.length) - 1);
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px" role="img" aria-label="Noise floor line chart"><title>Noise floor over time</title>`;
const times = data.map(d => new Date(d.t).getTime());
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px" role="img" aria-label="Noise floor column chart"><title>Noise floor over time</title>`;
// Inline style for hover highlighting
svg += `<style>.nf-bar{transition:opacity 0.05s}.nf-bar:hover{opacity:0.75;stroke:var(--text);stroke-width:1}</style>`;
// Chart title
svg += `<text x="${pad.left}" y="12" font-size="10" fill="var(--text-muted)" font-weight="600">Noise Floor dBm</text>`;
// Reference lines
const refLines = [-100, -85];
const refLabels = ['-100 warning', '-85 critical'];
refLines.forEach((ref, i) => {
if (ref >= minV && ref <= maxV) {
const y = sy(ref);
svg += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="var(--text-muted)" stroke-width="0.5" stroke-dasharray="4,2"/>`;
svg += `<text x="${w - pad.right + 2}" y="${(y + 3).toFixed(1)}" font-size="9" fill="var(--text-muted)">${refLabels[i]}</text>`;
}
});
// Y-axis labels
// Y-axis labels + grid lines
const yTicks = 5;
for (let i = 0; i <= yTicks; i++) {
const v = minV + (rangeV * i / yTicks);
const v = adjMinV + (rangeV * i / yTicks);
const y = sy(v);
svg += `<text x="${pad.left - 4}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="9" fill="var(--text-muted)">${v.toFixed(0)}</text>`;
svg += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="var(--border)" stroke-width="0.3"/>`;
@@ -3212,24 +3252,39 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
// X-axis labels
svg += rfXAxisLabels(data, sx, h, pad);
// Data polyline
svg += `<polyline points="${pts}" fill="none" stroke="var(--accent)" stroke-width="1.5"/>`;
// Color-coded columns
for (let i = 0; i < data.length; i++) {
const t = times[i];
const v = data[i].v;
const x = sx(t) - colW / 2;
const y = sy(v);
const barH = pad.top + ch - y;
// Hover tooltips
svg += rfTooltipCircles(data, sx, sy, 'NF', ' dBm');
// Threshold color: green < -100, yellow -100 to -85, red >= -85
let color;
if (v < -100) color = 'var(--success, #22c55e)';
else if (v < -85) color = 'var(--warning, #eab308)';
else color = 'var(--danger, #ef4444)';
// Direct labels: min and max points
const times = data.map(d => new Date(d.t).getTime());
const maxIdx = values.indexOf(maxV);
const minIdx = values.indexOf(minV);
svg += `<circle cx="${sx(times[maxIdx]).toFixed(1)}" cy="${sy(maxV).toFixed(1)}" r="3" fill="var(--danger, red)"/>`;
svg += `<text x="${sx(times[maxIdx]).toFixed(1)}" y="${(sy(maxV) - 6).toFixed(1)}" text-anchor="middle" font-size="9" fill="var(--danger, red)">${maxV.toFixed(1)}</text>`;
svg += `<circle cx="${sx(times[minIdx]).toFixed(1)}" cy="${sy(minV).toFixed(1)}" r="3" fill="var(--success, green)"/>`;
svg += `<text x="${sx(times[minIdx]).toFixed(1)}" y="${(sy(minV) + 14).toFixed(1)}" text-anchor="middle" font-size="9" fill="var(--success, green)">${minV.toFixed(1)}</text>`;
const ts = new Date(data[i].t).toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC');
const tip = `NF: ${v.toFixed(1)} dBm\n${ts}`;
svg += `<rect class="nf-bar" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${colW}" height="${Math.max(0, barH).toFixed(1)}" fill="${color}" rx="0.5"><title>${tip}</title></rect>`;
}
// Y-axis label
svg += `<text x="12" y="${(h / 2)}" text-anchor="middle" font-size="10" fill="var(--text-muted)" transform="rotate(-90,12,${h/2})">dBm</text>`;
// Legend
const legendY = pad.top + 2;
const legendX = w - pad.right - 140;
svg += `<rect x="${legendX}" y="${legendY}" width="8" height="8" fill="var(--success, #22c55e)" rx="1"/>`;
svg += `<text x="${legendX + 11}" y="${legendY + 7}" font-size="8" fill="var(--text-muted)">&lt; -100</text>`;
svg += `<rect x="${legendX + 48}" y="${legendY}" width="8" height="8" fill="var(--warning, #eab308)" rx="1"/>`;
svg += `<text x="${legendX + 59}" y="${legendY + 7}" font-size="8" fill="var(--text-muted)">-100…-85</text>`;
svg += `<rect x="${legendX + 105}" y="${legendY}" width="8" height="8" fill="var(--danger, #ef4444)" rx="1"/>`;
svg += `<text x="${legendX + 116}" y="${legendY + 7}" font-size="8" fill="var(--text-muted)">≥ -85</text>`;
svg += '</svg>';
return svg;
}
+40
View File
@@ -104,6 +104,46 @@ function timeAgo(iso) {
return value + suffix + ' ago';
}
function getHashParams() {
return new URLSearchParams(location.hash.split('?')[1] || '');
}
function getDistanceUnit() {
var stored = localStorage.getItem('meshcore-distance-unit');
if (stored === 'km') return 'km';
if (stored === 'mi') return 'mi';
// 'auto' or no value — locale detection
var milesLocales = ['en-us', 'en-gb'];
var lang = (typeof navigator !== 'undefined' && navigator.language || '').toLowerCase();
for (var i = 0; i < milesLocales.length; i++) {
if (lang === milesLocales[i] || lang.startsWith(milesLocales[i] + '-')) return 'mi';
}
return 'km';
}
window.getDistanceUnit = getDistanceUnit;
function formatDistance(km) {
if (km == null || isNaN(+km)) return '—';
var d = +km;
var unit = getDistanceUnit();
if (unit === 'mi') {
var mi = d / 1.60934;
if (mi < 0.1) return Math.round(mi * 5280) + ' ft';
return mi.toFixed(1) + ' mi';
}
if (d < 1) return Math.round(d * 1000) + ' m';
return d.toFixed(1) + ' km';
}
window.formatDistance = formatDistance;
function formatDistanceRound(km) {
if (km == null || isNaN(+km)) return '—';
var unit = getDistanceUnit();
if (unit === 'mi') return Math.round(+km / 1.60934) + ' mi';
return Math.round(+km) + ' km';
}
window.formatDistanceRound = formatDistanceRound;
function getTimestampMode() {
const saved = localStorage.getItem('meshcore-timestamp-mode');
if (saved === 'ago' || saved === 'absolute') return saved;
+62 -133
View File
@@ -1,16 +1,17 @@
/**
* Channel Color Quick-Assign Popover (M2, #271)
* Channel Color Picker Simplified popover with 8-color constrained palette (#674)
*
* Right-click (or long-press on mobile) a channel name in the live feed
* or packets table to open a color picker popover.
* Click a color dot next to channel names (channels page, live feed) to open picker.
* Right-click on live feed items retained as power-user shortcut (desktop only).
* No long-press. No custom color input. 8 preset colors.
*
* Uses ChannelColors.set/get/remove from channel-colors.js (M1).
* Uses ChannelColors.set/get/remove from channel-colors.js.
*/
(function() {
'use strict';
// Curated maximally-distinct palette (10 swatches, ColorBrewer-inspired)
var PRESET_COLORS = [
// 8 maximally-distinct colors on dark backgrounds (#674 Tufte spec)
var CHANNEL_PALETTE = [
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
@@ -18,14 +19,11 @@
'#06b6d4', // cyan
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#14b8a6', // teal
'#f43f5e' // rose
'#ec4899' // pink
];
var popoverEl = null;
var currentChannel = null;
var longPressTimer = null;
function createPopover() {
if (popoverEl) return popoverEl;
@@ -35,27 +33,19 @@
el.setAttribute('aria-label', 'Channel color picker');
el.style.display = 'none';
el.innerHTML =
'<div class="cc-picker-header">' +
'<span class="cc-picker-title" id="cc-picker-title"></span>' +
'<button class="cc-picker-close" title="Close" aria-label="Close">✕</button>' +
'</div>' +
'<div class="cc-picker-swatches" role="group" aria-label="Color swatches"></div>' +
'<div class="cc-picker-custom">' +
'<label>Custom: <input type="color" class="cc-picker-input" value="#3b82f6" aria-label="Custom color"></label>' +
'<button class="cc-picker-apply">Apply</button>' +
'</div>' +
'<button class="cc-picker-clear">Clear color</button>';
el.setAttribute('aria-labelledby', 'cc-picker-title');
// Build swatches
var swatchContainer = el.querySelector('.cc-picker-swatches');
for (var i = 0; i < PRESET_COLORS.length; i++) {
for (var i = 0; i < CHANNEL_PALETTE.length; i++) {
var sw = document.createElement('button');
sw.className = 'cc-swatch';
sw.style.background = PRESET_COLORS[i];
sw.setAttribute('data-color', PRESET_COLORS[i]);
sw.setAttribute('aria-label', PRESET_COLORS[i]);
sw.title = PRESET_COLORS[i];
sw.style.background = CHANNEL_PALETTE[i];
sw.setAttribute('data-color', CHANNEL_PALETTE[i]);
sw.setAttribute('aria-label', CHANNEL_PALETTE[i]);
sw.title = CHANNEL_PALETTE[i];
sw.setAttribute('tabindex', '0');
swatchContainer.appendChild(sw);
}
@@ -66,7 +56,7 @@
assignColor(btn.getAttribute('data-color'));
});
// Keyboard navigation for swatches (arrow keys)
// Keyboard navigation for swatches
swatchContainer.addEventListener('keydown', function(e) {
var btn = e.target.closest('.cc-swatch');
if (!btn) return;
@@ -80,12 +70,6 @@
if (next >= 0) { swatches[next].focus(); e.preventDefault(); }
});
// Event: custom apply
el.querySelector('.cc-picker-apply').addEventListener('click', function() {
var input = el.querySelector('.cc-picker-input');
assignColor(input.value);
});
// Event: clear
el.querySelector('.cc-picker-clear').addEventListener('click', function() {
if (currentChannel && window.ChannelColors) {
@@ -95,11 +79,6 @@
hidePopover();
});
// Event: close button
el.querySelector('.cc-picker-close').addEventListener('click', function() {
hidePopover();
});
// Prevent right-click on the popover itself
el.addEventListener('contextmenu', function(e) { e.preventDefault(); });
@@ -120,23 +99,17 @@
var el = createPopover();
currentChannel = channel;
// Update title
el.querySelector('.cc-picker-title').textContent = channel;
// Highlight current color
var current = window.ChannelColors ? window.ChannelColors.get(channel) : null;
var swatches = el.querySelectorAll('.cc-swatch');
for (var i = 0; i < swatches.length; i++) {
swatches[i].classList.toggle('cc-swatch-active', swatches[i].getAttribute('data-color') === current);
}
if (current) {
el.querySelector('.cc-picker-input').value = current;
}
// Show/hide clear button
el.querySelector('.cc-picker-clear').style.display = current ? '' : 'none';
// Position — on touch devices, CSS handles bottom-sheet via @media(pointer:coarse)
// Position
el.style.display = '';
var isTouch = window.matchMedia('(pointer: coarse)').matches;
if (!isTouch) {
@@ -188,7 +161,7 @@
}
// Trap Tab within the popover
if (e.key === 'Tab' && popoverEl && popoverEl.style.display !== 'none') {
var focusable = popoverEl.querySelectorAll('button, input, [tabindex]');
var focusable = popoverEl.querySelectorAll('button, [tabindex]');
if (focusable.length === 0) return;
var first = focusable[0];
var last = focusable[focusable.length - 1];
@@ -200,7 +173,7 @@
}
}
/** Refresh channel color styles on all visible feed items and packet rows. */
/** Refresh channel color styles on all visible feed items, channel list, and packet rows. */
function refreshVisibleRows() {
if (!window.ChannelColors) return;
@@ -210,11 +183,28 @@
var item = feedItems[i];
var ch = item._ccChannel;
if (!ch) continue;
var style = window.ChannelColors.getRowStyle('GRP_TXT', ch);
// Remove old channel color styles, reapply
item.style.borderLeft = '';
item.style.background = '';
if (style) item.style.cssText += style;
var color = window.ChannelColors.get(ch);
item.style.borderLeft = color ? '3px solid ' + color : '';
}
// Update color dots everywhere
var dots = document.querySelectorAll('.ch-color-dot');
for (var j = 0; j < dots.length; j++) {
var dot = dots[j];
var dotCh = dot.getAttribute('data-channel');
if (!dotCh) continue;
var dotColor = window.ChannelColors.get(dotCh);
dot.style.background = dotColor || '';
}
// Channel list items — update border
var chItems = document.querySelectorAll('.ch-item[data-hash]');
for (var k = 0; k < chItems.length; k++) {
var chItem = chItems[k];
var hash = chItem.getAttribute('data-hash');
if (!hash) continue;
var chColor = window.ChannelColors.get(hash);
chItem.style.borderLeft = chColor ? '3px solid ' + chColor : '';
}
// Packets table — trigger re-render via custom event
@@ -222,75 +212,27 @@
}
/**
* Extract channel name from a packet object.
* Returns null if no channel found or not a GRP_TXT/CHAN type.
*/
function extractChannel(pkt) {
if (!pkt) return null;
var d = pkt.decoded || {};
var h = d.header || {};
var p = d.payload || {};
var type = h.payloadTypeName || '';
if (type !== 'GRP_TXT' && type !== 'CHAN') return null;
return p.channelName || null;
}
/**
* Extract channel from a packets-table decoded_json.
*/
function extractChannelFromDecoded(decoded) {
if (!decoded) return null;
var type = decoded.type || '';
if (type !== 'GRP_TXT' && type !== 'CHAN') return null;
return decoded.channel || null;
}
/**
* Install context-menu (right-click) and long-press handlers on the live feed.
* Install context-menu (right-click) handler on the live feed.
* No long-press color dots handle mobile interaction.
*/
function installLiveFeedHandlers() {
var feed = document.getElementById('liveFeed');
if (!feed) return;
// Click on color dot opens picker (#674)
feed.addEventListener('click', function(e) {
var dot = e.target.closest('.feed-color-dot');
if (!dot) return;
e.stopPropagation();
var ch = dot.getAttribute('data-channel');
if (ch) showPopover(ch, e.clientX, e.clientY);
});
feed.addEventListener('contextmenu', function(e) {
var item = e.target.closest('.live-feed-item');
if (!item || !item._ccChannel) return;
var ch = item._ccChannel;
e.preventDefault();
showPopover(ch, e.clientX, e.clientY);
});
// Long-press for mobile
var longPressTriggered = false;
feed.addEventListener('touchstart', function(e) {
var item = e.target.closest('.live-feed-item');
if (!item || !item._ccChannel) return;
var ch = item._ccChannel;
if (!ch) return;
var touch = e.touches[0];
var tx = touch.clientX;
var ty = touch.clientY;
longPressTriggered = false;
// Don't preventDefault here — it blocks scroll initiation on feed items.
// CSS -webkit-touch-callout:none + user-select:none (on .live-feed-item)
// already suppress native context menu and text selection.
longPressTimer = setTimeout(function() {
longPressTimer = null;
longPressTriggered = true;
showPopover(ch, tx, ty);
}, 500);
}, { passive: true });
feed.addEventListener('touchend', function(e) {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
if (longPressTriggered) { e.preventDefault(); longPressTriggered = false; }
});
feed.addEventListener('touchmove', function() {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
});
// Prevent context menu on long-press (some browsers fire contextmenu after touch)
feed.addEventListener('contextmenu', function(e) {
if (longPressTriggered) e.preventDefault();
showPopover(item._ccChannel, e.clientX, e.clientY);
});
}
@@ -304,33 +246,19 @@
table.addEventListener('contextmenu', function(e) {
var row = e.target.closest('tr');
if (!row) return;
// Try to get decoded data from the row's data attribute
var decodedStr = row.getAttribute('data-decoded');
var decoded = null;
if (decodedStr) {
try { decoded = JSON.parse(decodedStr); } catch(ex) {}
}
// Fallback: check if the row has a chan-tag
if (!decoded) {
var chanTag = row.querySelector('.chan-tag');
if (chanTag) {
var ch = chanTag.textContent.trim();
if (ch) {
e.preventDefault();
showPopover(ch, e.clientX, e.clientY);
return;
}
var chanTag = row.querySelector('.chan-tag');
if (chanTag) {
var ch = chanTag.textContent.trim();
if (ch) {
e.preventDefault();
showPopover(ch, e.clientX, e.clientY);
return;
}
return;
}
var ch = extractChannelFromDecoded(decoded);
if (!ch) return;
e.preventDefault();
showPopover(ch, e.clientX, e.clientY);
});
}
// Export for use by live.js feed item creation
// Export
window.ChannelColorPicker = {
install: function() {
installLiveFeedHandlers();
@@ -339,6 +267,7 @@
installLiveFeed: installLiveFeedHandlers,
installPacketsTable: installPacketsTableHandlers,
show: showPopover,
hide: hidePopover
hide: hidePopover,
PALETTE: CHANNEL_PALETTE
};
})();
+2 -2
View File
@@ -94,8 +94,8 @@
if (!channel) return '';
var color = getChannelColor(channel);
if (!color) return '';
// 4px left border + 10% opacity background tint
return 'border-left:4px solid ' + color + ';background:' + color + '1a;';
// 3px left border only — minimal Tufte-style encoding (#674)
return 'border-left:3px solid ' + color + ';';
}
// Export to window for use by live.js and packets.js
+26 -3
View File
@@ -171,8 +171,11 @@
async function showNodeDetail(name) {
_nodePanelTrigger = document.activeElement;
if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; }
var _capturedHash = selectedHash;
const node = await lookupNode(name);
selectedNode = name;
var _chBase = _capturedHash ? '#/channels/' + encodeURIComponent(_capturedHash) : '#/channels';
history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name));
let panel = document.getElementById('chNodePanel');
if (!panel) {
@@ -234,6 +237,8 @@
const panel = document.getElementById('chNodePanel');
if (panel) panel.classList.remove('open');
selectedNode = null;
var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels';
history.replaceState(null, '', _chRestoreUrl);
if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') {
_nodePanelTrigger.focus();
_nodePanelTrigger = null;
@@ -314,6 +319,9 @@
let regionChangeHandler = null;
function init(app, routeParam) {
var _initUrlParams = getHashParams();
var _pendingNode = _initUrlParams.get('node');
app.innerHTML = `<div class="ch-layout">
<div class="ch-sidebar" aria-label="Channel list">
<div class="ch-sidebar-header">
@@ -347,8 +355,9 @@
});
loadObserverRegions();
loadChannels().then(() => {
if (routeParam) selectChannel(routeParam);
loadChannels().then(async function () {
if (routeParam) await selectChannel(routeParam);
if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode);
});
// #89: Sidebar resize handle
@@ -394,6 +403,14 @@
// Event delegation for channel selection (touch-friendly)
document.getElementById('chList').addEventListener('click', (e) => {
// Color dot click — open picker, don't select channel
const dot = e.target.closest('.ch-color-dot');
if (dot && window.ChannelColorPicker) {
e.stopPropagation();
var ch = dot.getAttribute('data-channel');
if (ch) ChannelColorPicker.show(ch, e.clientX, e.clientY);
return;
}
const item = e.target.closest('.ch-item[data-hash]');
if (item) selectChannel(item.dataset.hash);
});
@@ -670,12 +687,18 @@
: `${ch.messageCount} messages`;
const sel = selectedHash === ch.hash ? ' selected' : '';
const abbr = name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase();
// Channel color dot for color picker (#674)
const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null;
const dotStyle = chColor ? ` style="background:${chColor}"` : '';
// Left border for assigned color
const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : '';
return `<button class="ch-item${sel}" data-hash="${ch.hash}" type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
return `<button class="ch-item${sel}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
<div class="ch-badge" style="background:${color}" aria-hidden="true">${escapeHtml(abbr)}</div>
<div class="ch-item-body">
<div class="ch-item-top">
<span class="ch-item-name">${escapeHtml(name)}</span>
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>
</div>
<div class="ch-item-preview">${escapeHtml(preview)}</div>
+30 -8
View File
@@ -33,9 +33,10 @@
'meshcore-live-heatmap-opacity'
];
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity'];
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit'];
var OBJECT_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps'];
var SCALAR_SECTIONS = ['heatmapOpacity', 'liveHeatmapOpacity'];
var DISTANCE_UNIT_VALUES = ['km', 'mi', 'auto'];
// CSS variable mapping (theme key → CSS custom property)
var THEME_CSS_MAP = {
@@ -503,6 +504,11 @@
localStorage.setItem('meshcore-live-heatmap-opacity', effectiveConfig.liveHeatmapOpacity);
}
// Distance unit → sync to localStorage for all pages
if (typeof effectiveConfig.distanceUnit === 'string' && DISTANCE_UNIT_VALUES.indexOf(effectiveConfig.distanceUnit) >= 0) {
localStorage.setItem('meshcore-distance-unit', effectiveConfig.distanceUnit);
}
// Nav gradient
if (themeSection.navBg) {
var nav = document.querySelector('.top-nav');
@@ -744,6 +750,10 @@
}
}
}
// Validate distanceUnit
if (key === 'distanceUnit' && DISTANCE_UNIT_VALUES.indexOf(obj[key]) === -1) {
errors.push('Invalid distanceUnit: "' + obj[key] + '" — must be km, mi, or auto');
}
}
return { valid: errors.length === 0, errors: errors };
}
@@ -895,7 +905,7 @@
{ id: 'theme', label: '🎨', title: 'Theme', badge: _tabBadge(isDarkMode() ? 'themeDark' : 'theme') },
{ id: 'nodes', label: '🎯', title: 'Colors', badge: (function () { var n = _countOverrides('nodeColors') + _countOverrides('typeColors'); return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : ''; })() },
{ id: 'home', label: '🏠', title: 'Home', badge: _tabBadge('home') },
{ id: 'display', label: '🖥️', title: 'Display', badge: _tabBadge('timestamps') },
{ id: 'display', label: '🖥️', title: 'Display', badge: (function () { var n = _countOverrides('timestamps') + (_isOverridden(null, 'distanceUnit') ? 1 : 0); return n ? ' <span class="cv2-tab-badge">' + n + '</span>' : ''; })() },
{ id: 'export', label: '📤', title: 'Export' }
];
return '<div class="cust-tabs">' + tabs.map(function (t) {
@@ -1059,6 +1069,7 @@
function _renderDisplay() {
var eff = _getEffective();
var distUnit = typeof eff.distanceUnit === 'string' && DISTANCE_UNIT_VALUES.indexOf(eff.distanceUnit) >= 0 ? eff.distanceUnit : 'auto';
var ts = (eff.timestamps) || {};
var tsMode = ts.defaultMode === 'absolute' ? 'absolute' : 'ago';
var tsTz = ts.timezone === 'utc' ? 'utc' : 'local';
@@ -1086,6 +1097,13 @@
'<option value="locale"' + (tsFmt === 'locale' ? ' selected' : '') + '>Locale (browser)</option></select></div>' +
(canCustom ? '<div class="cust-field" data-ts-abs="custom"' + showAbs + '><label>Custom Format' + _overrideDot('timestamps', 'customFormat') + '</label>' +
'<input type="text" data-cv2-field="timestamps.customFormat" value="' + escAttr(customFmt) + '" placeholder="YYYY-MM-DD HH:mm:ss"></div>' : '') +
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Distances</p>' +
'<div class="cust-field"><label>Distance Unit' + _overrideDot(null, 'distanceUnit') + '</label>' +
'<select data-cv2-select="distanceUnit" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
'<option value="auto"' + (distUnit === 'auto' ? ' selected' : '') + '>Auto (browser locale)</option>' +
'<option value="km"' + (distUnit === 'km' ? ' selected' : '') + '>Kilometers (km)</option>' +
'<option value="mi"' + (distUnit === 'mi' ? ' selected' : '') + '>Miles (mi)</option>' +
'</select></div>' +
'</div>';
}
@@ -1324,12 +1342,16 @@
container.querySelectorAll('[data-cv2-select]').forEach(function (sel) {
sel.addEventListener('change', function () {
var parts = sel.dataset.cv2Select.split('.');
setOverride(parts[0], parts[1], sel.value);
// Show/hide absolute-only fields
if (parts[1] === 'defaultMode') {
container.querySelectorAll('[data-ts-abs]').forEach(function (el) {
el.style.display = sel.value === 'absolute' ? '' : 'none';
});
if (parts.length === 1) {
setOverride(null, parts[0], sel.value);
} else {
setOverride(parts[0], parts[1], sel.value);
// Show/hide absolute-only fields
if (parts[1] === 'defaultMode') {
container.querySelectorAll('[data-ts-abs]').forEach(function (el) {
el.style.display = sel.value === 'absolute' ? '' : 'none';
});
}
}
window.dispatchEvent(new CustomEvent('timestamp-mode-changed'));
});
+2
View File
@@ -92,6 +92,7 @@
<script src="hop-display.js?v=__BUST__"></script>
<script src="app.js?v=__BUST__"></script>
<script src="home.js?v=__BUST__"></script>
<script src="table-sort.js?v=__BUST__"></script>
<script src="packet-filter.js?v=__BUST__"></script>
<script src="packet-helpers.js?v=__BUST__"></script>
<script src="channel-colors.js?v=__BUST__"></script>
@@ -100,6 +101,7 @@
<script src="geo-filter-overlay.js?v=__BUST__"></script>
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="table-sort.js?v=__BUST__"></script>
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
+79 -8
View File
@@ -19,6 +19,36 @@
position: absolute;
z-index: 1000;
pointer-events: auto;
display: flex;
flex-direction: column;
}
/* ---- Panel header (non-scrolling) ---- */
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
padding: 4px 6px;
}
/* ---- Panel content (scrollable) ---- */
.panel-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.live-feed .panel-content {
display: flex;
flex-direction: column;
gap: 1px;
}
.live-legend .panel-content {
display: flex;
flex-direction: column;
gap: 3px;
}
/* ---- Header / Stats ---- */
@@ -106,7 +136,6 @@
right: 12px;
width: 320px;
max-height: calc(100vh - 140px);
overflow-y: auto;
background: color-mix(in srgb, var(--surface-1) 95%, transparent);
backdrop-filter: blur(12px);
border-radius: 10px;
@@ -126,16 +155,12 @@
left: 12px;
width: 360px;
max-height: 340px;
overflow-y: auto;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(12px);
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
padding: 6px;
display: flex;
flex-direction: column;
gap: 1px;
}
.live-feed-item {
@@ -198,9 +223,6 @@
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
color: var(--text-muted);
font-size: 11px;
display: flex;
flex-direction: column;
gap: 3px;
transition: opacity 0.3s, transform 0.3s;
}
@@ -778,3 +800,52 @@
}
.nav-pin-btn:hover { opacity: 0.8; }
.nav-pin-btn.pinned { opacity: 1; filter: drop-shadow(0 0 4px rgba(59,130,246,0.5)); }
/* ========== Panel Corner Positioning (#608 M0) ========== */
/* Corner positions — applied via data-position attribute on .live-overlay panels */
.live-overlay[data-position="tl"] { top: 64px; left: 12px; bottom: auto; right: auto; }
.live-overlay[data-position="tr"] { top: 64px; right: 12px; bottom: auto; left: auto; }
.live-overlay[data-position="bl"] { bottom: 12px; left: 12px; top: auto; right: auto; }
.live-overlay[data-position="br"] { bottom: 12px; right: 12px; top: auto; left: auto; }
/* Override hide animations for positioned panels — slide toward nearest edge */
.live-overlay[data-position="tl"].hidden,
.live-overlay[data-position="bl"].hidden { transform: translateX(-100%); }
.live-overlay[data-position="tr"].hidden,
.live-overlay[data-position="br"].hidden { transform: translateX(100%); }
.live-overlay[data-position].hidden { opacity: 0; pointer-events: none; visibility: hidden; }
/* Corner toggle button */
.panel-corner-btn {
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
font-size: 14px;
line-height: 28px;
text-align: center;
flex-shrink: 0;
border-radius: 4px;
}
.panel-corner-btn:hover { opacity: 1; background: color-mix(in srgb, var(--text) 12%, transparent); }
.panel-corner-btn:focus-visible {
opacity: 1;
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 3px;
}
/* On mobile, corner toggle is not useful (panels are hidden or bottom-sheet) */
@media (max-width: 640px) {
.panel-corner-btn { display: none !important; }
.live-overlay[data-position] {
top: unset !important; bottom: unset !important;
left: unset !important; right: unset !important;
}
}
+201 -24
View File
@@ -58,6 +58,92 @@
REQUEST: '❓', RESPONSE: '📨', TRACE: '🔍', PATH: '🛤️'
};
/* ---- Panel Corner Positioning (#608 M0) ---- */
var PANEL_DEFAULTS = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
var CORNER_CYCLE = ['tl', 'tr', 'br', 'bl'];
var CORNER_ARROWS = { tl: '↘', tr: '↙', bl: '↗', br: '↖' };
var CORNER_LABELS = { tl: 'top-left', tr: 'top-right', bl: 'bottom-left', br: 'bottom-right' };
var PANEL_NAMES = { liveFeed: 'Feed', liveLegend: 'Legend', liveNodeDetail: 'Node detail' };
function getPanelPositions() {
var pos = {};
for (var id in PANEL_DEFAULTS) {
try { pos[id] = localStorage.getItem('panel-corner-' + id) || PANEL_DEFAULTS[id]; }
catch (_) { pos[id] = PANEL_DEFAULTS[id]; }
}
return pos;
}
function nextAvailableCorner(panelId, desired, allPositions) {
var idx = CORNER_CYCLE.indexOf(desired);
for (var i = 0; i < 4; i++) {
var candidate = CORNER_CYCLE[(idx + i) % 4];
var occupied = false;
for (var otherId in allPositions) {
if (otherId !== panelId && allPositions[otherId] === candidate) { occupied = true; break; }
}
if (!occupied) return candidate;
}
return desired; // all occupied (impossible with 3 panels, 4 corners)
}
function applyPanelPosition(id, corner) {
var el = document.getElementById(id);
if (!el) return;
el.setAttribute('data-position', corner);
var btn = el.querySelector('.panel-corner-btn');
if (btn) {
btn.textContent = CORNER_ARROWS[corner];
btn.setAttribute('aria-label',
'Move ' + (PANEL_NAMES[id] || 'panel') + ' to next corner (currently ' + CORNER_LABELS[corner] + ')');
}
}
function initPanelPositions() {
var positions = getPanelPositions();
for (var id in positions) {
applyPanelPosition(id, positions[id]);
}
// Wire up click handlers on corner buttons
var btns = document.querySelectorAll('.panel-corner-btn[data-panel]');
for (var i = 0; i < btns.length; i++) {
btns[i].addEventListener('click', function(e) {
e.stopPropagation();
var panelId = this.getAttribute('data-panel');
onCornerClick(panelId);
});
}
}
function onCornerClick(panelId) {
var positions = getPanelPositions();
var current = positions[panelId];
var nextIdx = (CORNER_CYCLE.indexOf(current) + 1) % 4;
var next = nextAvailableCorner(panelId, CORNER_CYCLE[nextIdx], positions);
try { localStorage.setItem('panel-corner-' + panelId, next); } catch (_) { /* quota */ }
applyPanelPosition(panelId, next);
// Announce for screen readers
var announce = document.getElementById('panelPositionAnnounce');
if (announce) announce.textContent = (PANEL_NAMES[panelId] || 'Panel') + ' moved to ' + CORNER_LABELS[next];
}
function resetPanelPositions() {
for (var id in PANEL_DEFAULTS) {
try { localStorage.removeItem('panel-corner-' + id); } catch (_) { /* ignore */ }
applyPanelPosition(id, PANEL_DEFAULTS[id]);
}
}
// Export for testing
if (typeof window !== 'undefined') {
window._panelCorner = {
PANEL_DEFAULTS: PANEL_DEFAULTS, CORNER_CYCLE: CORNER_CYCLE,
getPanelPositions: getPanelPositions, nextAvailableCorner: nextAvailableCorner,
applyPanelPosition: applyPanelPosition, onCornerClick: onCornerClick,
resetPanelPositions: resetPanelPositions
};
}
function formatLiveTimestampHtml(isoLike) {
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoLike) : '—');
@@ -754,16 +840,27 @@
<label class="audio-slider-label">Vol <input type="range" id="audioVolSlider" min="0" max="100" value="30" class="audio-slider"><span id="audioVolVal">30</span></label>
</div>
</div>
<div class="live-overlay live-feed" id="liveFeed" aria-live="polite" aria-relevant="additions" role="log">
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed"></button>
<div class="live-overlay live-feed" id="liveFeed">
<div class="panel-header">
<button class="panel-corner-btn" data-panel="liveFeed" title="Move panel to next corner" aria-label="Move panel to next corner"></button>
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed"></button>
</div>
<div class="panel-content" aria-live="polite" aria-relevant="additions" role="log"></div>
</div>
<button class="feed-show-btn hidden" id="feedShowBtn" title="Show feed">📋</button>
<div class="live-overlay live-node-detail hidden" id="liveNodeDetail">
<button class="feed-hide-btn" id="nodeDetailClose" title="Close"></button>
<div id="nodeDetailContent"></div>
<div class="panel-header">
<button class="panel-corner-btn" data-panel="liveNodeDetail" title="Move panel to next corner" aria-label="Move panel to next corner"></button>
<button class="feed-hide-btn" id="nodeDetailClose" title="Close"></button>
</div>
<div class="panel-content" id="nodeDetailContent"></div>
</div>
<button class="legend-toggle-btn" id="legendToggleBtn" aria-label="Show legend" title="Show legend">🎨</button>
<div class="live-overlay live-legend" id="liveLegend" role="region" aria-label="Map legend">
<div class="panel-header">
<button class="panel-corner-btn" data-panel="liveLegend" title="Move panel to next corner" aria-label="Move panel to next corner"></button>
</div>
<div class="panel-content">
<h3 class="legend-title">PACKET TYPES</h3>
<ul class="legend-list">
<li><span class="live-dot" style="background:${TYPE_COLORS.ADVERT}" aria-hidden="true"></span> Advert Node advertisement</li>
@@ -774,9 +871,11 @@
</ul>
<h3 class="legend-title" style="margin-top:8px">NODE ROLES</h3>
<ul class="legend-list" id="roleLegendList"></ul>
</div>
</div>
<!-- VCR Bar -->
<div class="sr-only" id="panelPositionAnnounce" aria-live="polite"></div>
<div class="vcr-bar" id="vcrBar">
<div class="vcr-controls">
<button id="vcrRewindBtn" class="vcr-btn" title="Rewind" aria-label="Rewind"></button>
@@ -1060,6 +1159,8 @@
}
// Populate role legend from shared roles.js
// Initialize panel corner positions (#608 M0)
initPanelPositions();
const roleLegendList = document.getElementById('roleLegendList');
if (roleLegendList) {
for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) {
@@ -1502,7 +1603,9 @@
function rebuildFeedList() {
const feed = document.getElementById('liveFeed');
if (!feed) return;
feed.querySelectorAll('.live-feed-item').forEach(el => el.remove());
const feedContent = feed.querySelector('.panel-content');
if (!feedContent) return;
feedContent.querySelectorAll('.live-feed-item').forEach(el => el.remove());
feedDedup.clear();
// Aggregate VCR buffer by hash, then create one feed item per unique hash
@@ -1550,6 +1653,10 @@
const hopStr = longestHops.length ? `<span class="feed-hops">${longestHops.length}⇢</span>` : '';
const obsBadge = group.count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${group.count}</span>` : '';
var _ccPayload = (pkt.decoded || {}).payload || {};
var _ccChan1 = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload.channel || null) : null;
var dotHtml1 = _ccChan1 ? _feedColorDot(_ccChan1) : '';
const item = document.createElement('div');
item.className = 'live-feed-item';
item.setAttribute('tabindex', '0');
@@ -1559,13 +1666,13 @@
item.innerHTML = `
<span class="feed-icon" style="color:${color}">${icon}</span>
<span class="feed-type" style="color:${color}">${typeName}</span>
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
${dotHtml1}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${formatLiveTimestampHtml(group.latestTs || Date.now())}</span>
`;
var _ccD = (pkt.decoded || {}), _ccH = (_ccD.header || {}), _ccP = (_ccD.payload || {}); if (_ccH.payloadTypeName === 'GRP_TXT' || _ccH.payloadTypeName === 'CHAN') item._ccChannel = _ccP.channelName || null; // channel color picker (#271 M2)
if (_ccChan1) item._ccChannel = _ccChan1; // channel color picker (#674)
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.appendChild(item);
feedContent.appendChild(item);
// Register in dedup map so replay and live updates work
if (group.hash) {
@@ -1899,11 +2006,66 @@
}
}
firstPathDone = true;
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop);
// For TRACE packets, split at hopsCompleted: solid for completed, dashed for remaining
var hopsCompleted = decoded.path && decoded.path.hopsCompleted;
if (typeName === 'TRACE' && hopsCompleted != null && hopsCompleted < allPaths[ai].hopPositions.length) {
var completedPositions = allPaths[ai].hopPositions.slice(0, hopsCompleted + 1);
var remainingPositions = allPaths[ai].hopPositions.slice(hopsCompleted);
if (completedPositions.length >= 2) {
animatePath(completedPositions, typeName, color, allPaths[ai].raw, onHop);
} else if (completedPositions.length === 1) {
pulseNode(completedPositions[0].key, completedPositions[0].pos, typeName);
}
if (remainingPositions.length >= 2) {
drawDashedPath(remainingPositions, color);
}
} else {
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop);
}
}
}
// Draw a static dashed/ghosted line for unreached TRACE hops
function drawDashedPath(hopPositions, color) {
var GHOST_TIMEOUT_MS = 10000;
var ghostColor = getComputedStyle(document.documentElement).getPropertyValue('--trace-ghost-color').trim() || '#94a3b8';
if (!pathsLayer) return;
for (var i = 0; i < hopPositions.length - 1; i++) {
var from = hopPositions[i].pos;
var to = hopPositions[i + 1].pos;
var line = L.polyline([from, to], {
color: color, weight: 2, opacity: 0.25, dashArray: '6, 8'
}).addTo(pathsLayer);
// Pulse the unreached hop nodes as ghost markers
if (i > 0) {
var hp = hopPositions[i];
if (!nodeMarkers[hp.key]) {
var ghost = L.circleMarker(hp.pos, {
radius: 3, fillColor: ghostColor, fillOpacity: 0.2, color: color, weight: 1, opacity: 0.3
}).addTo(pathsLayer);
setTimeout((function(g) { return function() { if (pathsLayer.hasLayer(g)) pathsLayer.removeLayer(g); }; })(ghost), GHOST_TIMEOUT_MS);
}
}
// Remove dashed line after timeout
setTimeout((function(l) { return function() { if (pathsLayer.hasLayer(l)) pathsLayer.removeLayer(l); }; })(line), GHOST_TIMEOUT_MS);
}
// Ghost marker for the final unreached hop
var last = hopPositions[hopPositions.length - 1];
if (!nodeMarkers[last.key]) {
var ghostEnd = L.circleMarker(last.pos, {
radius: 4, fillColor: ghostColor, fillOpacity: 0.25, color: color, weight: 1, opacity: 0.35
}).addTo(pathsLayer);
setTimeout(function() { if (pathsLayer.hasLayer(ghostEnd)) pathsLayer.removeLayer(ghostEnd); }, GHOST_TIMEOUT_MS);
}
}
function resolveHopPositions(hops, payload, resolvedPath) {
// Hoist sender GPS guard once — reject (0,0) as "no GPS"
const hasValidGps = payload.lat != null && payload.lon != null
&& !(payload.lat === 0 && payload.lon === 0);
const senderLat = hasValidGps ? payload.lat : null;
const senderLon = hasValidGps ? payload.lon : null;
// Prefer server-side resolved_path when available
var resolvedMap;
if (resolvedPath && resolvedPath.length === hops.length && window.HopResolver && HopResolver.ready()) {
@@ -1911,19 +2073,14 @@
// Fill in any null entries from client-side fallback, preserving sender GPS context
var nullHops = hops.filter(function(h, i) { return !resolvedPath[i] && !resolvedMap[h]; });
if (nullHops.length) {
const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null;
const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null;
var fallback = HopResolver.resolve(nullHops, originLat, originLon, null, null, null);
var fallback = HopResolver.resolve(nullHops, senderLat, senderLon, null, null, null);
for (var k in fallback) resolvedMap[k] = fallback[k];
}
} else {
// Delegate to shared HopResolver (from hop-resolver.js) instead of reimplementing
const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null;
const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null;
// Use HopResolver if available and initialized, otherwise fall back to simple lookup
resolvedMap = (window.HopResolver && HopResolver.ready())
? HopResolver.resolve(hops, originLat, originLon, null, null, null)
? HopResolver.resolve(hops, senderLat, senderLon, null, null, null)
: {};
}
@@ -1942,7 +2099,7 @@
});
// Add sender position as anchor if available
if (payload.pubKey && originLat != null) {
if (payload.pubKey && senderLat != null) {
const existing = raw.find(p => p.key === payload.pubKey);
if (!existing) {
raw.unshift({ key: payload.pubKey, pos: [payload.lat, payload.lon], name: payload.name || payload.pubKey.slice(0, 8), known: true });
@@ -2500,9 +2657,22 @@
function _getChannelStyle(pkt) {
if (!window.ChannelColors) return '';
var d = pkt.decoded || {};
var h = d.header || {};
var p = d.payload || {};
return window.ChannelColors.getRowStyle(h.payloadTypeName || '', p.channelName || null);
var typeName = p.type || (d.header || {}).payloadTypeName || '';
var ch = p.channel || null;
return window.ChannelColors.getRowStyle(typeName, ch);
}
/** Build a clickable 12×12 color dot for a channel feed item (#674). */
function _feedColorDot(channel) {
if (!channel || !window.ChannelColors) return '';
var c = window.ChannelColors.get(channel);
var bg = c || 'transparent';
var border = c ? c : 'var(--border-color, #555)';
var style = c
? 'background:' + bg + ';border:1px solid ' + border
: 'background:transparent;border:1px dashed ' + border;
return '<span class="feed-color-dot" data-channel="' + escapeHtml(channel) + '" style="display:inline-block;width:12px;height:12px;border-radius:50%;' + style + ';cursor:pointer;vertical-align:middle;margin-left:4px;flex-shrink:0" title="Set color for ' + escapeHtml(channel) + '"></span>';
}
function addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed) {
@@ -2510,6 +2680,9 @@
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
const obsBadge = pkt.observation_count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${pkt.observation_count}</span>` : '';
var _ccPayload2 = (pkt.decoded || {}).payload || {};
var _ccChan = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload2.channel || null) : null;
var dotHtml = _ccChan ? _feedColorDot(_ccChan) : '';
const item = document.createElement('div');
item.className = 'live-feed-item';
item.setAttribute('tabindex', '0');
@@ -2521,11 +2694,11 @@
item.innerHTML = `
<span class="feed-icon" style="color:${color}">${icon}</span>
<span class="feed-type" style="color:${color}">${typeName}</span>
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
${dotHtml}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
`;
var _ccD = (pkt.decoded || {}), _ccH = (_ccD.header || {}), _ccP = (_ccD.payload || {}); if (_ccH.payloadTypeName === 'GRP_TXT' || _ccH.payloadTypeName === 'CHAN') item._ccChannel = _ccP.channelName || null; // channel color picker (#271 M2)
if (_ccChan) item._ccChannel = _ccChan; // channel color picker (#674)
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.appendChild(item);
}
@@ -2538,7 +2711,8 @@
const DEDUP_WINDOW = 30000;
function addFeedItem(icon, typeName, payload, hops, color, pkt) {
const feed = document.getElementById('liveFeed');
const feedPanel = document.getElementById('liveFeed');
const feed = feedPanel ? feedPanel.querySelector('.panel-content') : null;
if (!feed) return;
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return;
@@ -2580,6 +2754,9 @@
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
const obsBadge = incomingObs > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${incomingObs}</span>` : '';
var _ccPayload3 = (pkt.decoded || {}).payload || {};
var _ccChan3 = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload3.channel || null) : null;
var dotHtml3 = _ccChan3 ? _feedColorDot(_ccChan3) : '';
const item = document.createElement('div');
item.className = 'live-feed-item live-feed-enter';
@@ -2593,11 +2770,11 @@
item.innerHTML = `
<span class="feed-icon" style="color:${color}">${icon}</span>
<span class="feed-type" style="color:${color}">${typeName}</span>
${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
${dotHtml3}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
`;
var _ccD = (pkt.decoded || {}), _ccH = (_ccD.header || {}), _ccP = (_ccD.payload || {}); if (_ccH.payloadTypeName === 'GRP_TXT' || _ccH.payloadTypeName === 'CHAN') item._ccChannel = _ccP.channelName || null; // channel color picker (#271 M2)
if (_ccChan3) item._ccChannel = _ccChan3; // channel color picker (#674)
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.prepend(item);
requestAnimationFrame(() => requestAnimationFrame(() => item.classList.remove('live-feed-enter')));
+138 -56
View File
@@ -20,26 +20,24 @@
let activeTab = 'all';
let search = '';
// Sort state: column + direction, persisted to localStorage
let sortState = (function () {
try {
const saved = JSON.parse(localStorage.getItem('meshcore-nodes-sort'));
if (saved && saved.column && saved.direction) return saved;
} catch {}
return { column: 'last_seen', direction: 'desc' };
})();
// Managed by TableSort utility (public/table-sort.js) when DOM is available,
// falls back to simple object for unit testing
var _nodesTableSortCtrl = null;
// TODO(M5): remove fallback when tests use DOM sandbox
var _fallbackSortState = null; // used when TableSort controller not initialized (tests)
function toggleSort(column) {
if (sortState.column === column) {
sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc';
} else {
// Default direction per column type
const descDefault = ['last_seen', 'advert_count'];
sortState = { column, direction: descDefault.includes(column) ? 'desc' : 'asc' };
}
localStorage.setItem('meshcore-nodes-sort', JSON.stringify(sortState));
function _getSortState() {
if (_nodesTableSortCtrl) return _nodesTableSortCtrl.getState();
if (_fallbackSortState) return _fallbackSortState;
try {
var saved = JSON.parse(localStorage.getItem('meshcore-nodes-sort'));
if (saved && saved.column && saved.direction) return saved;
} catch (e) { /* ignore */ }
return { column: 'last_seen', direction: 'desc' };
}
function sortNodes(arr) {
var sortState = _getSortState();
const col = sortState.column;
const dir = sortState.direction === 'asc' ? 1 : -1;
return arr.sort(function (a, b) {
@@ -66,11 +64,6 @@
return 0;
});
}
function sortArrow(col) {
if (sortState.column !== col) return '';
return '<span class="sort-arrow">' + (sortState.direction === 'asc' ? '▲' : '▼') + '</span>';
}
let lastHeard = localStorage.getItem('meshcore-nodes-last-heard') || '';
let statusFilter = localStorage.getItem('meshcore-nodes-status-filter') || 'all';
let wsHandler = null;
@@ -85,6 +78,18 @@
{ key: 'sensor', label: 'Sensors' },
];
function buildNodesQuery(tab, searchStr) {
var parts = [];
if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
return parts.length ? '?' + parts.join('&') : '';
}
window.buildNodesQuery = buildNodesQuery;
function updateNodesUrl() {
history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search));
}
function renderNodeTimestampHtml(isoString) {
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
@@ -189,7 +194,7 @@
function renderNeighborRows(neighbors, limit) {
var sorted = neighbors.slice().sort(function(a, b) {
return (b.score || b.affinity || 0) - (a.score || a.affinity || 0);
return (b.count || 0) - (a.count || 0);
});
var items = limit ? sorted.slice(0, limit) : sorted;
return items.map(function(nb) {
@@ -205,18 +210,20 @@
var scoreTitle = 'Observations: ' + nb.count;
if (nb.avg_snr != null) scoreTitle += ' · Avg SNR: ' + Number(nb.avg_snr).toFixed(1) + ' dB';
var distanceCell = nb.distance_km != null
? Number(nb.distance_km).toFixed(1) + ' km'
? formatDistance(Number(nb.distance_km))
: '<span class="text-muted">—</span>';
var showOnMap = nb.pubkey
? ' <button class="btn-link neighbor-show-map" data-pubkey="' + escapeHtml(nb.pubkey) + '" style="font-size:11px;padding:1px 6px;white-space:nowrap">📍 Map</button>'
: '';
var lastSeenVal = nb.last_seen ? new Date(nb.last_seen).getTime() : 0;
var distanceVal = nb.distance_km != null ? Number(nb.distance_km) : '';
return '<tr>' +
'<td style="font-weight:600">' + nameHtml + '</td>' +
'<td>' + roleBadge + '</td>' +
'<td title="' + escapeHtml(scoreTitle) + '">' + Number(nb.score).toFixed(2) + '</td>' +
'<td>' + nb.count + '</td>' +
'<td>' + renderNodeTimestampHtml(nb.last_seen) + '</td>' +
'<td>' + distanceCell + '</td>' +
'<td data-value="' + escapeHtml(name.toLowerCase()) + '" style="font-weight:600">' + nameHtml + '</td>' +
'<td data-value="' + escapeHtml(role.toLowerCase()) + '">' + roleBadge + '</td>' +
'<td data-value="' + Number(nb.score || 0) + '" title="' + escapeHtml(scoreTitle) + '">' + Number(nb.score).toFixed(2) + '</td>' +
'<td data-value="' + (nb.count || 0) + '">' + nb.count + '</td>' +
'<td data-value="' + lastSeenVal + '">' + renderNodeTimestampHtml(nb.last_seen) + '</td>' +
'<td data-value="' + distanceVal + '">' + distanceCell + '</td>' +
'<td><span title="' + conf.label + '">' + conf.icon + '</span></td>' +
'<td style="text-align:right">' + showOnMap + '</td>' +
'</tr>';
@@ -224,8 +231,16 @@
}
function renderNeighborTable(neighbors, limit) {
return '<table class="data-table" style="font-size:12px">' +
'<thead><tr><th>Neighbor</th><th>Role</th><th>Score</th><th>Obs</th><th>Last Seen</th><th>Distance</th><th>Conf</th><th></th></tr></thead>' +
return '<table class="data-table neighbor-sort-table" style="font-size:12px">' +
'<thead><tr>' +
'<th scope="col" data-sort-key="name">Neighbor</th>' +
'<th scope="col" data-sort-key="role">Role</th>' +
'<th scope="col" data-sort-key="score" data-type="numeric" data-sort-default="desc">Score</th>' +
'<th scope="col" data-sort-key="count" data-type="numeric" data-sort-default="desc">Obs</th>' +
'<th scope="col" data-sort-key="last_seen" data-type="numeric" data-sort-default="desc">Last Seen</th>' +
'<th scope="col" data-sort-key="distance" data-type="numeric">Distance</th>' +
'<th scope="col">Conf</th><th scope="col"></th>' +
'</tr></thead>' +
'<tbody>' + renderNeighborRows(neighbors, limit) + '</tbody></table>';
}
@@ -276,6 +291,15 @@
}
el.innerHTML = html;
// Initialize TableSort on neighbor table
var neighborTable = el.querySelector('.neighbor-sort-table');
if (neighborTable && window.TableSort) {
TableSort.init(neighborTable, {
defaultColumn: 'count',
defaultDirection: 'desc'
});
}
// Wire up "Show on Map" buttons via event delegation
el.addEventListener('click', function(e) {
var btn = e.target.closest('.neighbor-show-map');
@@ -317,6 +341,15 @@
return;
}
// Reset list-view state to defaults, then override from URL params
activeTab = 'all';
search = '';
const _listUrlParams = getHashParams();
const _urlTab = _listUrlParams.get('tab');
const _urlSearch = _listUrlParams.get('search');
if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
if (_urlSearch) search = _urlSearch;
app.innerHTML = `<div class="nodes-page">
<div class="nodes-topbar">
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…" aria-label="Search nodes by name">
@@ -332,8 +365,14 @@
RegionFilter.init(document.getElementById('nodesRegionFilter'));
regionChangeHandler = RegionFilter.onChange(function () { _allNodes = null; loadNodes(); });
if (search) {
var _si = document.getElementById('nodeSearch');
if (_si) _si.value = search;
}
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
search = e.target.value;
updateNodesUrl();
loadNodes();
}, 250));
@@ -457,15 +496,21 @@
${observers.length ? `<div class="node-full-card" id="node-observers">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:8px"><strong>Regions:</strong> ${regions.map(r => '<span class="badge" style="margin:0 2px">' + escapeHtml(r) + '</span>').join(' ')}</div>` : ''; })()}
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
<table class="data-table" style="font-size:12px">
<thead><tr><th scope="col">Observer</th><th scope="col">Region</th><th scope="col">Packets</th><th scope="col">Avg SNR</th><th scope="col">Avg RSSI</th></tr></thead>
<table class="data-table observer-sort-table" style="font-size:12px">
<thead><tr>
<th scope="col" data-sort-key="observer">Observer</th>
<th scope="col" data-sort-key="region">Region</th>
<th scope="col" data-sort-key="packets" data-type="numeric" data-sort-default="desc">Packets</th>
<th scope="col" data-sort-key="snr" data-type="numeric" data-sort-default="desc">Avg SNR</th>
<th scope="col" data-sort-key="rssi" data-type="numeric" data-sort-default="desc">Avg RSSI</th>
</tr></thead>
<tbody>
${observers.map(o => `<tr>
<td style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</td>
<td>${o.iata ? escapeHtml(o.iata) : '—'}</td>
<td>${o.packetCount}</td>
<td>${o.avgSnr != null ? Number(o.avgSnr).toFixed(1) + ' dB' : '—'}</td>
<td>${o.avgRssi != null ? Number(o.avgRssi).toFixed(0) + ' dBm' : '—'}</td>
<td data-value="${escapeHtml((o.observer_name || o.observer_id || '').toLowerCase())}" style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</td>
<td data-value="${escapeHtml((o.iata || '').toLowerCase())}">${o.iata ? escapeHtml(o.iata) : '—'}</td>
<td data-value="${o.packetCount || 0}">${o.packetCount}</td>
<td data-value="${o.avgSnr != null ? Number(o.avgSnr) : ''}">${o.avgSnr != null ? Number(o.avgSnr).toFixed(1) + ' dB' : '—'}</td>
<td data-value="${o.avgRssi != null ? Number(o.avgRssi) : ''}">${o.avgRssi != null ? Number(o.avgRssi).toFixed(0) + ' dBm' : '—'}</td>
</tr>`).join('')}
</tbody>
</table>
@@ -503,10 +548,12 @@
let hashSizeBadge = '';
if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
const pb = parseInt(p.raw_hex.slice(2, 4), 16);
const hs = ((pb >> 6) & 0x3) + 1;
const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
const hsFg = hs === 2 ? '#064e3b' : '#fff';
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
if ((pb & 0x3F) !== 0) {
const hs = ((pb >> 6) & 0x3) + 1;
const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
const hsFg = hs === 2 ? '#064e3b' : '#fff';
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
}
}
return `<div class="node-activity-item">
<span class="node-activity-time">${renderNodeTimestampHtml(p.timestamp)}</span>
@@ -563,6 +610,15 @@
} catch {}
}
// Initialize TableSort on observer table (full detail page)
var observerTable = document.querySelector('#node-observers .observer-sort-table');
if (observerTable && window.TableSort) {
TableSort.init(observerTable, {
defaultColumn: 'packets',
defaultDirection: 'desc'
});
}
// Fetch neighbors for this node (full-screen view)
fetchAndRenderNeighbors(n.public_key, 'fullNeighborsContent', {
headerSelector: '#fullNeighborsHeader'
@@ -859,11 +915,11 @@
</div>
<table class="data-table" id="nodesTable">
<thead><tr>
<th scope="col" class="sortable${sortState.column==='name'?' sort-active':''}" data-sort="name">Name${sortArrow('name')}</th>
<th scope="col" class="col-pubkey sortable${sortState.column==='public_key'?' sort-active':''}" data-sort="public_key">Public Key${sortArrow('public_key')}</th>
<th scope="col" class="sortable${sortState.column==='role'?' sort-active':''}" data-sort="role">Role${sortArrow('role')}</th>
<th scope="col" class="sortable${sortState.column==='last_seen'?' sort-active':''}" data-sort="last_seen">Last Seen${sortArrow('last_seen')}</th>
<th scope="col" class="sortable${sortState.column==='advert_count'?' sort-active':''}" data-sort="advert_count">Adverts${sortArrow('advert_count')}</th>
<th scope="col" data-sort-key="name">Name</th>
<th scope="col" class="col-pubkey" data-sort-key="public_key">Public Key</th>
<th scope="col" data-sort-key="role">Role</th>
<th scope="col" data-sort-key="last_seen" data-sort-default="desc">Last Seen</th>
<th scope="col" data-sort-key="advert_count" data-sort-default="desc">Adverts</th>
</tr></thead>
<tbody id="nodesBody"></tbody>
</table>`;
@@ -872,7 +928,7 @@
const nodeTabs = document.getElementById('nodeTabs');
initTabBar(nodeTabs);
el.querySelectorAll('.node-tab').forEach(btn => {
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; loadNodes(); });
btn.addEventListener('click', () => { activeTab = btn.dataset.tab; updateNodesUrl(); loadNodes(); });
});
// Filter changes
@@ -888,10 +944,18 @@
});
});
// Sortable column headers
el.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => { toggleSort(th.dataset.sort); renderLeft(); });
});
// Initialize TableSort on nodes table (handles header clicks, indicators, persistence)
// We use onSort callback to re-render rows (sorting is done at JS-array level in renderRows
// because of claimed/favorites pinning logic that TableSort can't handle)
var nodesTableEl = document.getElementById('nodesTable');
if (nodesTableEl && window.TableSort) {
_nodesTableSortCtrl = TableSort.init(nodesTableEl, {
defaultColumn: 'last_seen',
defaultDirection: 'desc',
storageKey: 'meshcore-nodes-sort',
onSort: function () { renderRows(); }
});
}
// Delegated click/keyboard handler for table rows
const tbody = document.getElementById('nodesBody');
@@ -1212,11 +1276,29 @@
window._nodesIsAdvertMessage = isAdvertMessage;
window._nodesGetAllNodes = function() { return _allNodes; };
window._nodesSetAllNodes = function(n) { _allNodes = n; };
window._nodesToggleSort = toggleSort;
window._nodesToggleSort = function(col) {
if (_nodesTableSortCtrl) { _nodesTableSortCtrl.sort(col); return; }
// Fallback for tests without DOM
var st = _getSortState();
var descDefault = ['last_seen', 'advert_count'];
if (st.column === col) {
_fallbackSortState = { column: col, direction: st.direction === 'asc' ? 'desc' : 'asc' };
} else {
_fallbackSortState = { column: col, direction: descDefault.indexOf(col) >= 0 ? 'desc' : 'asc' };
}
localStorage.setItem('meshcore-nodes-sort', JSON.stringify(_fallbackSortState));
};
window._nodesSortNodes = sortNodes;
window._nodesSortArrow = sortArrow;
window._nodesGetSortState = function() { return sortState; };
window._nodesSetSortState = function(s) { sortState = s; };
window._nodesSortArrow = function(col) {
var st = _getSortState();
if (st.column !== col) return '';
return '<span class="sort-arrow">' + (st.direction === 'asc' ? '▲' : '▼') + '</span>';
};
window._nodesGetSortState = _getSortState;
window._nodesSetSortState = function(s) {
_fallbackSortState = s;
if (_nodesTableSortCtrl) _nodesTableSortCtrl.sort(s.column, s.direction);
};
window._nodesSyncClaimedToFavorites = syncClaimedToFavorites;
window._nodesRenderNodeTimestampHtml = renderNodeTimestampHtml;
window._nodesRenderNodeTimestampText = renderNodeTimestampText;
+168 -36
View File
@@ -33,7 +33,26 @@
let totalCount = 0;
let expandedHashes = new Set();
let hopNameCache = {};
let _tableSortInstance = null;
let _packetSortColumn = null;
let _packetSortDirection = 'desc';
let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true';
var _pendingUrlRegion = null;
var DEFAULT_TIME_WINDOW = 15;
function buildPacketsQuery(timeWindowMin, regionParam) {
var parts = [];
if (timeWindowMin && timeWindowMin !== DEFAULT_TIME_WINDOW) parts.push('timeWindow=' + timeWindowMin);
if (regionParam) parts.push('region=' + encodeURIComponent(regionParam));
return parts.length ? '?' + parts.join('&') : '';
}
window.buildPacketsQuery = buildPacketsQuery;
function updatePacketsUrl() {
history.replaceState(null, '', '#/packets' + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam()));
}
let filtersBuilt = false;
let _renderTimer = null;
function scheduleRender() {
@@ -80,6 +99,37 @@
let _wsRenderDirty = false; // dirty flag for rAF render coalescing (#396)
let _observerFilterSet = null; // cached Set from filters.observer, hoisted above loops (#427)
// Pure function: calculate visible entry range from scroll state.
// Extracted for testability (#405, #409).
function _calcVisibleRange(offsets, entryCount, scrollTop, viewportHeight, rowHeight, theadHeight, buffer) {
const adjustedScrollTop = Math.max(0, scrollTop - theadHeight);
const firstDomRow = Math.floor(adjustedScrollTop / rowHeight);
const visibleDomCount = Math.ceil(viewportHeight / rowHeight);
// Binary search for first entry whose cumulative offset covers firstDomRow
let lo = 0, hi = entryCount;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (offsets[mid + 1] <= firstDomRow) lo = mid + 1;
else hi = mid;
}
const firstEntry = lo;
// Binary search for last visible entry
const lastDomRow = firstDomRow + visibleDomCount;
lo = firstEntry; hi = entryCount;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (offsets[mid + 1] <= lastDomRow) lo = mid + 1;
else hi = mid;
}
const lastEntry = Math.min(lo + 1, entryCount);
const startIdx = Math.max(0, firstEntry - buffer);
const endIdx = Math.min(entryCount, lastEntry + buffer);
return { startIdx, endIdx, firstEntry, lastEntry };
}
function closeDetailPanel() {
var panel = document.getElementById('pktRight');
if (panel) {
@@ -282,6 +332,17 @@
filters.node = routeParam;
}
}
// Read URL params (router strips query from routeParam; read from location.hash)
var _initUrlParams = getHashParams();
var _urlTimeWindow = Number(_initUrlParams.get('timeWindow'));
if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) {
savedTimeWindowMin = _urlTimeWindow;
localStorage.setItem('meshcore-time-window', String(_urlTimeWindow));
}
var _urlRegion = _initUrlParams.get('region');
if (_urlRegion) _pendingUrlRegion = _urlRegion;
app.innerHTML = `<div class="split-layout detail-collapsed">
<div class="panel-left" id="pktLeft" aria-live="polite" aria-relevant="additions removals"></div>
<div class="panel-right empty" id="pktRight" aria-live="polite">
@@ -468,8 +529,12 @@
if (h) hashIndex.set(h, newGroup);
}
}
// Re-sort by latest DESC, then evict oldest beyond the limit
packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || ''));
// Re-sort by active sort column (or latest DESC as default), then evict oldest beyond the limit
if (_packetSortColumn) {
sortPacketsArray();
} else {
packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || ''));
}
if (packets.length > PACKET_LIMIT) {
const evicted = packets.splice(PACKET_LIMIT);
for (const p of evicted) { if (p.hash) hashIndex.delete(p.hash); }
@@ -490,6 +555,7 @@
clearTimeout(_renderTimer);
if (wsHandler) offWS(wsHandler);
wsHandler = null;
if (_tableSortInstance) { _tableSortInstance.destroy(); _tableSortInstance = null; }
detachVScrollListener();
clearTimeout(_wsRenderTimer);
if (_wsRafId) { cancelAnimationFrame(_wsRafId); _wsRafId = null; }
@@ -618,6 +684,7 @@
}
}
sortPacketsArray();
renderLeft();
} catch (e) {
console.error('Failed to load packets:', e);
@@ -708,9 +775,9 @@
</div>
<table class="data-table" id="pktTable">
<thead><tr>
<th scope="col"></th><th scope="col" class="col-region">Region</th><th scope="col" class="col-time">Time</th><th scope="col" class="col-hash">Hash</th><th scope="col" class="col-size">Size</th>
<th scope="col" class="col-hashsize">HB</th>
<th scope="col" class="col-type">Type</th><th scope="col" class="col-observer">Observer</th><th scope="col" class="col-path">Path</th><th scope="col" class="col-rpt">Rpt</th><th scope="col" class="col-details">Details</th>
<th scope="col"></th><th scope="col" class="col-region" data-sort-key="region">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date">Time</th><th scope="col" class="col-hash" data-sort-key="hash">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric">Size</th>
<th scope="col" class="col-hashsize" data-sort-key="hb" data-type="numeric">HB</th>
<th scope="col" class="col-type" data-sort-key="type">Type</th><th scope="col" class="col-observer" data-sort-key="observer">Observer</th><th scope="col" class="col-path" data-sort-key="path">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric">Rpt</th><th scope="col" class="col-details">Details</th>
</tr></thead>
<tbody id="pktBody"></tbody>
</table>
@@ -718,7 +785,11 @@
// Init shared RegionFilter component
RegionFilter.init(document.getElementById('packetsRegionFilter'), { dropdown: true });
RegionFilter.onChange(function() { loadPackets(); });
if (_pendingUrlRegion) {
RegionFilter.setSelected(_pendingUrlRegion.split(',').filter(Boolean));
_pendingUrlRegion = null;
}
RegionFilter.onChange(function() { updatePacketsUrl(); loadPackets(); });
// --- Packet Filter Language ---
(function() {
@@ -868,6 +939,7 @@
savedTimeWindowMin = Number(fTimeWindow.value);
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
updatePacketsUrl();
loadPackets();
});
@@ -1103,6 +1175,33 @@
renderTableRows();
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
// Initialize table sorting (virtual scroll — sort data array, not DOM)
if (window.TableSort) {
var pktTableEl = document.getElementById('pktTable');
if (pktTableEl) {
if (_tableSortInstance) _tableSortInstance.destroy();
_tableSortInstance = TableSort.init(pktTableEl, {
defaultColumn: 'time',
defaultDirection: 'desc',
storageKey: 'meshcore-packets-sort',
domReorder: false,
onSort: function(column, direction) {
_packetSortColumn = column;
_packetSortDirection = direction;
sortPacketsArray();
renderTableRows();
}
});
// Apply initial sort state from TableSort
if (_tableSortInstance) {
var st = _tableSortInstance.getState();
_packetSortColumn = st.column;
_packetSortDirection = st.direction;
sortPacketsArray();
}
}
}
}
// Build HTML for a single grouped packet row
@@ -1282,34 +1381,11 @@
// Account for thead height (measured dynamically)
const theadEl = scrollContainer.querySelector('thead');
if (theadEl) _vscrollTheadHeight = theadEl.offsetHeight || _vscrollTheadHeight;
const theadHeight = _vscrollTheadHeight;
const adjustedScrollTop = Math.max(0, scrollTop - theadHeight);
// Find the first entry whose cumulative row offset covers the scroll position
const firstDomRow = Math.floor(adjustedScrollTop / VSCROLL_ROW_HEIGHT);
const visibleDomCount = Math.ceil(viewportHeight / VSCROLL_ROW_HEIGHT);
// Binary search for entry index containing firstDomRow
let lo = 0, hi = _displayPackets.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (offsets[mid + 1] <= firstDomRow) lo = mid + 1;
else hi = mid;
}
const firstEntry = lo;
// Find entry index covering last visible DOM row
const lastDomRow = firstDomRow + visibleDomCount;
lo = firstEntry; hi = _displayPackets.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (offsets[mid + 1] <= lastDomRow) lo = mid + 1;
else hi = mid;
}
const lastEntry = Math.min(lo + 1, _displayPackets.length);
const startIdx = Math.max(0, firstEntry - VSCROLL_BUFFER);
const endIdx = Math.min(_displayPackets.length, lastEntry + VSCROLL_BUFFER);
const { startIdx, endIdx } = _calcVisibleRange(
offsets, _displayPackets.length, scrollTop, viewportHeight,
VSCROLL_ROW_HEIGHT, _vscrollTheadHeight, VSCROLL_BUFFER
);
// Skip DOM rebuild if visible range hasn't changed
if (startIdx === _lastVisibleStart && endIdx === _lastVisibleEnd) {
@@ -1407,6 +1483,55 @@
_vsScrollHandler = null;
}
/** Sort the packets array by the current sort column. Called before renderTableRows. */
function sortPacketsArray() {
if (!_packetSortColumn || !packets.length) return;
var col = _packetSortColumn;
var dir = _packetSortDirection === 'asc' ? 1 : -1;
var accessor;
switch (col) {
case 'time': accessor = function(p) { return p.latest || p.timestamp || ''; }; break;
case 'type': accessor = function(p) { return typeName(p.payload_type); }; break;
case 'hash': accessor = function(p) { return p.hash || ''; }; break;
case 'observer': accessor = function(p) { return obsName(p.observer_id); }; break;
case 'size': accessor = function(p) { return p.packet_size || 0; }; break;
case 'hb': accessor = function(p) { return p.hash_byte_count != null ? p.hash_byte_count : (p.hash_size || 0); }; break;
case 'rpt': accessor = function(p) {
try { var pj = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : p.path_json; return Array.isArray(pj) ? pj.length : 0; } catch(e) { return 0; }
}; break;
case 'region': accessor = function(p) { return (regionMap && regionMap[p.observer_id]) || ''; }; break;
case 'path': accessor = function(p) {
try { var pj = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : p.path_json; return Array.isArray(pj) ? pj.join(',') : ''; } catch(e) { return ''; }
}; break;
default: return; // unsortable column
}
// Choose comparator based on column type
var isNumeric = (col === 'size' || col === 'hb' || col === 'rpt');
var isDate = (col === 'time');
packets.sort(function(a, b) {
var va = accessor(a), vb = accessor(b);
var result;
if (isDate) {
result = TableSort.comparators.date(va, vb);
} else if (isNumeric) {
result = TableSort.comparators.numeric(va, vb);
} else {
result = TableSort.comparators.text(va, vb);
}
// Stable tiebreaker: sort by timestamp (desc) when primary values are equal
if (result === 0 && !isDate) {
result = TableSort.comparators.date(
a.timestamp || a.first_seen || '',
b.timestamp || b.first_seen || ''
) * -1; // desc (newest first)
}
return dir * result;
});
}
async function renderTableRows() {
const tbody = document.getElementById('pktBody');
if (!tbody) return;
@@ -1637,7 +1762,7 @@
// Parse hash size from path byte
const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(2, 4), 16) : NaN;
const hashSize = isNaN(rawPathByte) ? null : ((rawPathByte >> 6) + 1);
const hashSize = (isNaN(rawPathByte) || (rawPathByte & 0x3F) === 0) ? null : ((rawPathByte >> 6) + 1);
const size = pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0;
const typeName = payloadTypeName(pkt.payload_type);
@@ -1859,7 +1984,7 @@
const pathByte0 = parseInt(buf.slice(2, 4), 16);
const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1);
const hashCountVal = isNaN(pathByte0) ? '?' : (pathByte0 & 0x3F);
rows += fieldRow(1, 'Path Length', '0x' + (buf.slice(2, 4) || '??'), `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}`);
rows += fieldRow(1, 'Path Length', '0x' + (buf.slice(2, 4) || '??'), hashCountVal === 0 ? `hash_count=0 (direct advert)` : `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}`);
// Transport codes
let off = 2;
@@ -1887,7 +2012,7 @@
rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type), 'section-payload');
if (decoded.type === 'ADVERT') {
rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1));
if (hashCountVal !== 0) rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1));
rows += fieldRow(off, 'Public Key (32B)', truncate(decoded.pubKey || '', 24), '');
rows += fieldRow(off + 32, 'Timestamp (4B)', decoded.timestampISO || '', 'Unix: ' + (decoded.timestamp || ''));
rows += fieldRow(off + 36, 'Signature (64B)', truncate(decoded.signature || '', 24), '');
@@ -2049,6 +2174,12 @@
html += kv(k, String(v));
}
}
// Special handling for advert signature validation
if (h.payloadType === 4 && p.signatureValid !== undefined) {
const status = p.signatureValid ? 'Valid' : 'Invalid';
const badgeClass = p.signatureValid ? 'badge-success' : 'badge-danger';
html += kv('Signature', `<span class="badge ${badgeClass}">${status}</span>`);
}
html += '</div></div>';
// Raw hex
@@ -2213,6 +2344,7 @@
_refreshRowCountsIfDirty,
buildGroupRowHtml,
buildFlatRowHtml,
_calcVisibleRange,
};
}
+11 -1
View File
@@ -6,6 +6,7 @@
var _regions = {}; // { code: label }
var _selected = null; // Set of selected region codes, null = all
var _listeners = [];
var _container = null;
var _loaded = false;
function loadFromStorage() {
@@ -199,11 +200,19 @@
/** Initialize filter in a container, fetch regions, render, return promise.
* Options: { dropdown: true } to force dropdown mode regardless of region count */
async function initFilter(container, opts) {
_container = container;
if (opts && opts.dropdown) container._forceDropdown = true;
await fetchRegions();
render(container);
}
/** Override selected regions (e.g. from URL param). Persists to localStorage and re-renders. */
function setSelected(codesArray) {
_selected = (codesArray && codesArray.length > 0) ? new Set(codesArray) : null;
saveToStorage();
if (_container) render(_container);
}
// Expose globally
window.RegionFilter = {
init: initFilter,
@@ -213,6 +222,7 @@
regionQueryString: regionQueryString,
onChange: onChange,
offChange: offChange,
fetchRegions: fetchRegions
fetchRegions: fetchRegions,
setSelected: setSelected
};
})();
+63 -91
View File
@@ -30,6 +30,7 @@
--content-bg: var(--surface-0);
--card-bg: var(--surface-1);
--hover-bg: rgba(0,0,0, 0.04);
--trace-ghost-color: #94a3b8;
}
/* DARK THEME VARIABLES KEEP BOTH BLOCKS IN SYNC
@@ -55,6 +56,7 @@
--input-bg: #1e1e34;
--selected-bg: #1e3a5f;
--hover-bg: rgba(255,255,255, 0.06);
--trace-ghost-color: #94a3b8;
--section-bg: #1e1e34;
}
}
@@ -78,6 +80,7 @@
--input-bg: #1e1e34;
--selected-bg: #1e3a5f;
--hover-bg: rgba(255,255,255, 0.06);
--trace-ghost-color: #94a3b8;
--section-bg: #1e1e34;
}
@@ -1184,6 +1187,8 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.hash-bar-value { min-width: 120px; text-align: right; font-size: 13px; font-weight: 600; }
.badge-hash-1 { background: #ef444420; color: var(--status-red); }
.badge-hash-2 { background: #22c55e20; color: var(--status-green); }
.badge-success { background: #22c55e20; color: var(--status-green); }
.badge-danger { background: #ef444420; color: var(--status-red); }
.badge-hash-3 { background: #3b82f620; color: var(--accent); }
.timeline-legend { display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 12px; }
.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
@@ -2039,132 +2044,93 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
}
/* Channel Color Picker Popover (M2, #271) */
/* === Channel Color Picker (#674) === */
.cc-picker-popover {
position: fixed;
z-index: 10000;
background: var(--surface-1, #1e1e2e);
border: 1px solid var(--border, #444);
z-index: 9999;
background: var(--bg-secondary, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
padding: 10px;
min-width: 200px;
max-width: 260px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
font-size: 13px;
padding: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.cc-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.cc-picker-title {
font-weight: 600;
color: var(--text-primary, #e0e0e0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cc-picker-close {
background: none;
border: none;
color: var(--muted, #888);
cursor: pointer;
font-size: 14px;
padding: 2px 4px;
}
.cc-picker-close:hover { color: var(--text-primary, #e0e0e0); }
.cc-picker-swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.cc-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: border-color 0.15s;
}
.cc-swatch:hover { border-color: var(--text-primary, #e0e0e0); }
.cc-swatch:hover { border-color: rgba(255,255,255,0.6); }
.cc-swatch:focus-visible { border-color: #fff; outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
.cc-swatch-active { border-color: #fff; box-shadow: 0 0 0 1px rgba(255,255,255,0.5); }
.cc-swatch-active { border-color: #fff; }
.cc-picker-clear {
display: block;
width: 100%;
margin-top: 6px;
padding: 4px 0;
font-size: 11px;
color: var(--text-muted, #888);
background: none;
border: none;
cursor: pointer;
text-align: center;
}
.cc-picker-clear:hover { color: var(--text-primary, #e0e0e0); }
/* Mobile: larger touch targets, hide native color picker, safe areas */
/* Color dot affordance (#674) */
.ch-color-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
border: 1.5px solid rgba(255,255,255,0.3);
cursor: pointer;
vertical-align: middle;
margin-left: 6px;
flex-shrink: 0;
}
.ch-color-dot:not([style*="background"]) {
background: transparent;
border-style: dashed;
border-color: var(--text-muted, #888);
}
/* Mobile bottom-sheet + larger touch targets (#674) */
@media (pointer: coarse) {
.ch-color-dot {
width: 20px;
height: 20px;
margin-left: 8px;
}
.cc-swatch {
width: 40px;
height: 40px;
border-radius: 6px;
width: 36px;
height: 36px;
}
.cc-picker-swatches {
gap: 8px;
}
.cc-picker-custom {
display: none !important;
justify-content: center;
gap: 10px;
}
.cc-picker-popover {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
top: auto !important;
width: 100% !important;
max-width: 100% !important;
border-radius: 16px 16px 0 0;
border-radius: 12px 12px 0 0;
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.live-feed-item {
-webkit-touch-callout: none;
user-select: none;
}
}
.cc-picker-custom {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.cc-picker-custom label {
color: var(--muted, #888);
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
}
.cc-picker-input {
width: 32px;
height: 24px;
border: none;
padding: 0;
cursor: pointer;
background: none;
}
.cc-picker-apply {
background: var(--accent, #3b82f6);
color: #fff;
border: none;
border-radius: 4px;
padding: 3px 8px;
cursor: pointer;
font-size: 12px;
}
.cc-picker-apply:hover { opacity: 0.85; }
.cc-picker-clear {
background: none;
border: 1px solid var(--border, #444);
color: var(--muted, #888);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
width: 100%;
}
.cc-picker-clear:hover { color: var(--text-primary, #e0e0e0); border-color: var(--text-primary, #e0e0e0); }
/* === #630 — Mobile Accessibility Fixes === */
@@ -2239,3 +2205,9 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
@media (max-width: 640px) {
.data-table { min-width: 480px; }
}
/* Table sorting indicators */
th[data-sort-key] { cursor: pointer; user-select: none; }
th[data-sort-key]:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); }
th.sort-active { color: var(--accent, #60a5fa); }
.sort-arrow { font-size: 0.75em; opacity: 0.8; }
+224
View File
@@ -0,0 +1,224 @@
/* === CoreScope — table-sort.js === */
/* Shared table sorting utility. IIFE, no dependencies. */
'use strict';
window.TableSort = (function() {
/**
* Built-in comparators. Each takes two raw string values (from data-value or textContent)
* and returns a number for Array.sort.
*/
var comparators = {
text: function(a, b) {
if (a == null) a = '';
if (b == null) b = '';
return String(a).localeCompare(String(b));
},
numeric: function(a, b) {
var na = Number(a), nb = Number(b);
var aIsNaN = isNaN(na), bIsNaN = isNaN(nb);
if (aIsNaN && bIsNaN) return 0;
if (aIsNaN) return 1; // NaN sorts last
if (bIsNaN) return -1;
return na - nb;
},
date: function(a, b) {
var ta = a ? new Date(a).getTime() : NaN;
var tb = b ? new Date(b).getTime() : NaN;
var aIsNaN = isNaN(ta), bIsNaN = isNaN(tb);
if (aIsNaN && bIsNaN) return 0;
if (aIsNaN) return 1;
if (bIsNaN) return -1;
return ta - tb;
},
dbm: function(a, b) {
var na = parseFloat(String(a).replace(/\s*dBm\s*/i, ''));
var nb = parseFloat(String(b).replace(/\s*dBm\s*/i, ''));
var aIsNaN = isNaN(na), bIsNaN = isNaN(nb);
if (aIsNaN && bIsNaN) return 0;
if (aIsNaN) return 1;
if (bIsNaN) return -1;
return na - nb;
}
};
/**
* Resolve the comparator for a <th> element.
* Priority: custom comparator from options > data-type attribute > text default.
*/
function resolveComparator(key, thEl, customComparators) {
if (customComparators && customComparators[key]) return customComparators[key];
var type = thEl.getAttribute('data-type');
if (type && comparators[type]) return comparators[type];
return comparators.text;
}
/**
* Get the sort value for a <td>. Prefers data-value attribute, falls back to textContent.
*/
function getCellValue(td) {
if (!td) return '';
var dv = td.getAttribute('data-value');
return dv != null ? dv : td.textContent.trim();
}
/**
* Initialize sorting on a table element.
*
* @param {HTMLTableElement} tableEl - The table to make sortable
* @param {Object} [options]
* @param {string} [options.defaultColumn] - data-sort-key of initial sort column
* @param {string} [options.defaultDirection='asc'] - 'asc' or 'desc'
* @param {string} [options.storageKey] - localStorage key for persistence
* @param {Object} [options.comparators] - custom comparator functions keyed by column key
* @param {Function} [options.onSort] - callback(column, direction) after sort
* @param {boolean} [options.domReorder=true] - if false, skip DOM reorder (for virtual scroll tables)
* @returns {Object} instance with sort(), destroy(), getState() methods
*/
function init(tableEl, options) {
if (!tableEl) return null;
options = options || {};
var thead = tableEl.querySelector('thead');
if (!thead) return null;
var state = { column: options.defaultColumn || null, direction: options.defaultDirection || 'asc' };
var domReorder = options.domReorder !== false;
// Restore from localStorage
if (options.storageKey) {
try {
var saved = JSON.parse(localStorage.getItem(options.storageKey));
if (saved && saved.column) {
state.column = saved.column;
state.direction = saved.direction || 'asc';
}
} catch(e) { /* ignore */ }
}
var ths = thead.querySelectorAll('th[data-sort-key]');
var thMap = {}; // key → th element
var handlers = []; // for cleanup
for (var i = 0; i < ths.length; i++) {
(function(th) {
var key = th.getAttribute('data-sort-key');
thMap[key] = th;
th.style.cursor = 'pointer';
th.setAttribute('tabindex', '0');
th.setAttribute('aria-sort', 'none');
var handler = function(e) {
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
if (state.column === key) {
state.direction = state.direction === 'asc' ? 'desc' : 'asc';
} else {
state.column = key;
state.direction = options.defaultDirection || 'asc';
}
doSort();
};
th.addEventListener('click', handler);
th.addEventListener('keydown', handler);
handlers.push({ el: th, click: handler, keydown: handler });
})(ths[i]);
}
// Apply initial sort if defaultColumn is set
if (state.column && thMap[state.column]) {
updateArrows();
if (domReorder) sortDOM();
}
function doSort() {
updateArrows();
if (options.storageKey) {
try { localStorage.setItem(options.storageKey, JSON.stringify(state)); } catch(e) { /* ignore */ }
}
if (domReorder) sortDOM();
if (options.onSort) options.onSort(state.column, state.direction);
}
function updateArrows() {
for (var k in thMap) {
var th = thMap[k];
// Remove existing arrow
var arrow = th.querySelector('.sort-arrow');
if (arrow) arrow.remove();
if (k === state.column) {
th.classList.add('sort-active');
th.setAttribute('aria-sort', state.direction === 'asc' ? 'ascending' : 'descending');
var span = document.createElement('span');
span.className = 'sort-arrow';
span.textContent = state.direction === 'asc' ? ' ▲' : ' ▼';
th.appendChild(span);
} else {
th.classList.remove('sort-active');
th.setAttribute('aria-sort', 'none');
}
}
}
function sortDOM() {
var tbody = tableEl.querySelector('tbody');
if (!tbody) return;
var th = thMap[state.column];
if (!th) return;
var cmp = resolveComparator(state.column, th, options.comparators);
var colIndex = -1;
var allThs = thead.querySelectorAll('th');
for (var j = 0; j < allThs.length; j++) {
if (allThs[j] === th) { colIndex = j; break; }
}
if (colIndex < 0) return;
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
var dir = state.direction === 'asc' ? 1 : -1;
rows.sort(function(rowA, rowB) {
var a = getCellValue(rowA.cells[colIndex]);
var b = getCellValue(rowB.cells[colIndex]);
return dir * cmp(a, b);
});
// DOM reorder via appendChild (no innerHTML rebuild)
for (var r = 0; r < rows.length; r++) {
tbody.appendChild(rows[r]);
}
}
function destroy() {
for (var h = 0; h < handlers.length; h++) {
handlers[h].el.removeEventListener('click', handlers[h].click);
handlers[h].el.removeEventListener('keydown', handlers[h].keydown);
// Clean up aria/classes
handlers[h].el.removeAttribute('aria-sort');
handlers[h].el.classList.remove('sort-active');
var arrow = handlers[h].el.querySelector('.sort-arrow');
if (arrow) arrow.remove();
}
handlers = [];
}
function sort(column, direction) {
if (column) state.column = column;
if (direction) state.direction = direction;
doSort();
}
function getState() {
return { column: state.column, direction: state.direction };
}
return { sort: sort, destroy: destroy, getState: getState };
}
return {
init: init,
comparators: comparators
};
})();
+177
View File
@@ -0,0 +1,177 @@
/**
* Tests for channel color picker fix (#674)
*
* Verifies:
* 1. _ccChannel is set correctly for GRP_TXT packets (flat decoded structure)
* 2. _ccChannel is NOT set for non-GRP_TXT packets
* 3. Channel color picker palette is 8 colors
* 4. getRowStyle uses border-left only (no background tint)
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
let passed = 0;
let failed = 0;
function assert(condition, msg) {
if (condition) {
passed++;
console.log(`${msg}`);
} else {
failed++;
console.error(`${msg}`);
}
}
// --- Test 1: _ccChannel extraction logic (simulates live.js behavior) ---
console.log('\n=== _ccChannel assignment from flat decoded structure ===');
// Simulate the fixed logic from live.js — uses payload.channel (name string),
// NOT payload.channelHash (numeric byte). Channel colors are keyed by channel
// name (e.g. "public", "#test") matching the channels API hash field.
function extractCcChannel(typeName, pkt) {
var _ccPayload = (pkt.decoded || {}).payload || {};
if (typeName === 'GRP_TXT' || typeName === 'CHAN') {
return _ccPayload.channel || null;
}
return undefined; // not set
}
// CHAN with channel name (normal case — ingestor-decrypted WS broadcast)
var chanPkt = {
decoded: {
header: { payloadTypeName: 'CHAN' },
payload: { type: 'CHAN', channel: '#test', channelHash: 217, text: 'hello' }
}
};
assert(extractCcChannel('CHAN', chanPkt) === '#test', 'CHAN with channel="#test" → _ccChannel="#test"');
// CHAN with "public" channel
var publicPkt = {
decoded: {
header: { payloadTypeName: 'CHAN' },
payload: { type: 'CHAN', channel: 'public', text: 'hi' }
}
};
assert(extractCcChannel('CHAN', publicPkt) === 'public', 'CHAN with channel="public" → _ccChannel="public"');
// GRP_TXT without channel (encrypted, no decryption)
var encryptedPkt = {
decoded: {
header: { payloadTypeName: 'GRP_TXT' },
payload: { type: 'GRP_TXT', channelHash: 5, mac: 'ab12', encryptedData: 'ff' }
}
};
assert(extractCcChannel('GRP_TXT', encryptedPkt) === null, 'GRP_TXT without channel field → null');
// Non-GRP_TXT packet — should not set _ccChannel
var advertPkt = {
decoded: {
header: { payloadTypeName: 'ADVERT' },
payload: { type: 'ADVERT', name: 'Node1' }
}
};
assert(extractCcChannel('ADVERT', advertPkt) === undefined, 'ADVERT → _ccChannel not set');
// Empty decoded
var emptyPkt = { decoded: {} };
assert(extractCcChannel('GRP_TXT', emptyPkt) === null, 'GRP_TXT with empty payload → null');
// --- Test 2: _getChannelStyle fix (simulates fixed logic) ---
console.log('\n=== _getChannelStyle with flat structure ===');
function simulateGetChannelStyle(pkt, channelColors) {
var d = pkt.decoded || {};
var h = d.header || {};
var p = d.payload || {};
var ch = p.channel || null;
var typeName = h.payloadTypeName || '';
if (typeName !== 'GRP_TXT' && typeName !== 'CHAN') return '';
if (!ch) return '';
var color = channelColors[ch] || null;
if (!color) return '';
return 'border-left:3px solid ' + color + ';';
}
var colors = { '#test': '#ef4444' };
assert(
simulateGetChannelStyle(chanPkt, colors) === 'border-left:3px solid #ef4444;',
'getChannelStyle returns border-left for assigned color'
);
assert(
simulateGetChannelStyle(chanPkt, {}) === '',
'getChannelStyle returns empty for unassigned channel'
);
assert(
simulateGetChannelStyle(advertPkt, colors) === '',
'getChannelStyle returns empty for non-GRP_TXT'
);
// --- Test 3: channel-colors.js getRowStyle uses border-left only ---
console.log('\n=== channel-colors.js getRowStyle ===');
const ccSource = fs.readFileSync(path.join(__dirname, 'public', 'channel-colors.js'), 'utf8');
const ccCtx = {
window: {},
localStorage: {
_data: {},
getItem(k) { return this._data[k] || null; },
setItem(k, v) { this._data[k] = v; }
}
};
vm.createContext(ccCtx);
vm.runInContext(ccSource, ccCtx);
// Set a color
ccCtx.window.ChannelColors.set('5', '#3b82f6');
var style = ccCtx.window.ChannelColors.getRowStyle('GRP_TXT', '5');
assert(style === 'border-left:3px solid #3b82f6;', 'getRowStyle returns border-left:3px (no background tint)');
assert(!style.includes('background'), 'getRowStyle has no background property');
var noStyle = ccCtx.window.ChannelColors.getRowStyle('GRP_TXT', '99');
assert(noStyle === '', 'getRowStyle returns empty for unassigned channel');
var advertStyle = ccCtx.window.ChannelColors.getRowStyle('ADVERT', '5');
assert(advertStyle === '', 'getRowStyle returns empty for non-GRP_TXT type');
// --- Test 4: channel-color-picker.js palette ---
console.log('\n=== channel-color-picker.js palette ===');
const pickerSource = fs.readFileSync(path.join(__dirname, 'public', 'channel-color-picker.js'), 'utf8');
const pickerCtx = {
window: { ChannelColors: ccCtx.window.ChannelColors, matchMedia: () => ({ matches: false }) },
document: {
createElement: () => ({
className: '', style: {}, innerHTML: '',
setAttribute: () => {},
querySelector: () => ({ textContent: '', style: {}, addEventListener: () => {} }),
querySelectorAll: () => [],
appendChild: () => {},
addEventListener: () => {}
}),
body: { appendChild: () => {}, style: {} },
addEventListener: () => {},
removeEventListener: () => {},
activeElement: null
},
setTimeout: (fn) => fn(),
Array: Array
};
vm.createContext(pickerCtx);
vm.runInContext(pickerSource, pickerCtx);
assert(pickerCtx.window.ChannelColorPicker != null, 'ChannelColorPicker exported');
assert(Array.isArray(pickerCtx.window.ChannelColorPicker.PALETTE), 'PALETTE is exported');
assert(pickerCtx.window.ChannelColorPicker.PALETTE.length === 8, 'PALETTE has exactly 8 colors');
// Verify no teal/rose in palette
var palette = pickerCtx.window.ChannelColorPicker.PALETTE;
assert(!palette.includes('#14b8a6'), 'No teal in palette');
assert(!palette.includes('#f43f5e'), 'No rose in palette');
// --- Summary ---
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
+60 -1
View File
@@ -1488,7 +1488,7 @@ async function run() {
const hasTable = await page.$('#fullNeighborsContent .data-table');
if (hasTable) {
// Check columns
const headers = await page.$$eval('#fullNeighborsContent thead th', ths => ths.map(t => t.textContent));
const headers = await page.$$eval('#fullNeighborsContent thead th', ths => ths.map(t => t.textContent.trim().replace(/\s*[▲▼]\s*$/, '')));
assert(headers.includes('Neighbor'), 'Should have Neighbor column');
assert(headers.includes('Role'), 'Should have Role column');
assert(headers.includes('Score'), 'Should have Score column');
@@ -1627,6 +1627,65 @@ async function run() {
}
} catch {}
// --- Group: Deep linking (#536) ---
// Test: nodes tab deep link
await test('Nodes tab deep link restores active tab', async () => {
await page.goto(BASE + '#/nodes?tab=repeater', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.node-tab', { timeout: 8000 });
const activeTab = await page.$('.node-tab.active');
assert(activeTab, 'No active tab found');
const tabText = await activeTab.textContent();
assert(tabText.includes('Repeater'), `Expected Repeater tab active, got: ${tabText}`);
const url = page.url();
assert(url.includes('tab=repeater'), `URL should contain tab=repeater, got: ${url}`);
});
// Test: nodes tab click updates URL
await test('Nodes tab click updates URL', async () => {
await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.node-tab', { timeout: 8000 });
const roomTab = await page.$('.node-tab[data-tab="room"]');
assert(roomTab, 'Room tab (data-tab="room") not found — nodes page may not have rendered or tab selector changed');
await roomTab.click();
await page.waitForTimeout(300);
const url = page.url();
assert(url.includes('tab=room'), `URL should contain tab=room after click, got: ${url}`);
});
// Test: packets timeWindow deep link
await test('Packets timeWindow deep link restores dropdown', async () => {
await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
const val = await page.$eval('#fTimeWindow', el => el.value);
assert(val === '60', `Expected timeWindow dropdown = 60, got: ${val}`);
const url = page.url();
assert(url.includes('timeWindow=60'), `URL should still contain timeWindow=60, got: ${url}`);
});
// Test: timeWindow change updates URL
await test('Packets timeWindow change updates URL', async () => {
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#fTimeWindow', { timeout: 8000 });
await page.selectOption('#fTimeWindow', '30');
await page.waitForTimeout(300);
const url = page.url();
assert(url.includes('timeWindow=30'), `URL should contain timeWindow=30 after change, got: ${url}`);
});
// Test: channels selected channel survives refresh (already implemented, verify it still works)
await test('Channels channel selection is URL-addressable', async () => {
await page.goto(BASE + '#/channels', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.ch-item', { timeout: 8000 }).catch(() => null);
const firstChannel = await page.$('.ch-item');
if (firstChannel) {
await firstChannel.click();
await page.waitForTimeout(500);
const url = page.url();
assert(url.includes('#/channels/') || url.includes('#/channels'), `URL should reflect channel selection, got: ${url}`);
}
});
await browser.close();
// Summary
+499 -71
View File
@@ -75,6 +75,7 @@ function makeSandbox() {
};
})(),
location: { hash: '' },
getHashParams: function() { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); },
CustomEvent: class CustomEvent {},
Map,
Promise,
@@ -2078,6 +2079,151 @@ console.log('\n=== analytics.js: sortChannels ===');
});
}
// ===== analytics.js: rfNFColumnChart =====
console.log('\n=== analytics.js: rfNFColumnChart ===');
{
function makeAnalyticsSandbox2() {
const ctx = makeSandbox();
ctx.getComputedStyle = () => ({ getPropertyValue: () => '' });
ctx.registerPage = () => {};
ctx.api = () => Promise.resolve({});
ctx.timeAgo = (iso) => iso ? 'x ago' : '—';
ctx.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.connectWS = () => {};
ctx.invalidateApiCache = () => {};
ctx.makeColumnsResizable = () => {};
ctx.initTabBar = () => {};
ctx.IATA_COORDS_GEO = {};
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) {
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
return ctx;
}
const ctx2 = makeAnalyticsSandbox2();
const rfNFColumnChart = ctx2.window._analyticsRfNFColumnChart;
test('rfNFColumnChart is exposed', () => assert.ok(rfNFColumnChart, '_analyticsRfNFColumnChart must be exposed'));
test('returns SVG string with column bars', () => {
const data = [
{ t: '2024-01-01T00:00:00Z', v: -110 },
{ t: '2024-01-01T00:05:00Z', v: -95 },
{ t: '2024-01-01T00:10:00Z', v: -80 },
];
const svg = rfNFColumnChart(data, 700, 180, []);
assert.ok(svg.includes('<svg'), 'should produce SVG');
assert.ok(svg.includes('class="nf-bar"'), 'should have column bars');
assert.ok(svg.includes('Noise floor column chart'), 'should have aria label');
});
test('color-codes bars by threshold', () => {
const data = [
{ t: '2024-01-01T00:00:00Z', v: -110 }, // green (< -100)
{ t: '2024-01-01T00:05:00Z', v: -95 }, // yellow (-100 to -85)
{ t: '2024-01-01T00:10:00Z', v: -80 }, // red (>= -85)
];
const svg = rfNFColumnChart(data, 700, 180, []);
assert.ok(svg.includes('var(--success'), 'green bar for < -100');
assert.ok(svg.includes('var(--warning'), 'yellow bar for -100 to -85');
assert.ok(svg.includes('var(--danger'), 'red bar for >= -85');
});
test('includes hover tooltips in bars', () => {
const data = [
{ t: '2024-01-01T00:00:00Z', v: -105 },
];
const svg = rfNFColumnChart(data, 700, 180, []);
assert.ok(svg.includes('<title>NF: -105.0 dBm'), 'tooltip with dBm value');
});
test('handles empty data gracefully', () => {
const svg = rfNFColumnChart([], 700, 180, []);
assert.ok(svg.includes('<svg'), 'should return empty SVG');
});
test('handles single data point with visible bar', () => {
const data = [{ t: '2024-01-01T00:00:00Z', v: -100 }];
const svg = rfNFColumnChart(data, 700, 180, []);
assert.ok(svg.includes('class="nf-bar"'), 'should render single bar');
// Bar must have non-zero height (division-by-zero guard)
const m = svg.match(/height="([\d.]+)"/);
assert.ok(m && parseFloat(m[1]) > 0, 'single data point bar must have non-zero height');
assert.ok(!svg.includes('NaN'), 'must not contain NaN');
});
test('handles constant values with visible bars', () => {
const data = [
{ t: '2024-01-01T00:00:00Z', v: -95 },
{ t: '2024-01-01T00:05:00Z', v: -95 },
{ t: '2024-01-01T00:10:00Z', v: -95 },
];
const svg = rfNFColumnChart(data, 700, 180, []);
const heights = [...svg.matchAll(/class="nf-bar"[^>]*height="([\d.]+)"/g)].map(m => parseFloat(m[1]));
assert.strictEqual(heights.length, 3, 'should render 3 bars');
assert.ok(heights.every(h => h > 0), 'all bars must have non-zero height');
assert.ok(!svg.includes('NaN'), 'must not contain NaN');
});
test('includes legend', () => {
const data = [
{ t: '2024-01-01T00:00:00Z', v: -110 },
{ t: '2024-01-01T00:05:00Z', v: -90 },
];
const svg = rfNFColumnChart(data, 700, 180, []);
assert.ok(svg.includes('&lt; -100'), 'legend has green label');
assert.ok(svg.includes('-100…-85'), 'legend has yellow label');
assert.ok(svg.includes('≥ -85'), 'legend has red label');
});
test('no reference lines (removed per spec)', () => {
const data = [
{ t: '2024-01-01T00:00:00Z', v: -110 },
{ t: '2024-01-01T00:05:00Z', v: -80 },
];
const svg = rfNFColumnChart(data, 700, 180, []);
assert.ok(!svg.includes('-100 warning'), 'no -100 warning reference line');
assert.ok(!svg.includes('-85 critical'), 'no -85 critical reference line');
assert.ok(!svg.includes('stroke-dasharray="4,2"'), 'no dashed reference lines');
});
test('renders all bars even with time gaps', () => {
const data = [
{ t: '2024-01-01T00:00:00Z', v: -110 },
{ t: '2024-01-01T06:00:00Z', v: -95 }, // 6h gap
{ t: '2024-01-01T06:05:00Z', v: -80 },
];
const svg = rfNFColumnChart(data, 700, 180, []);
const barCount = (svg.match(/class="nf-bar"/g) || []).length;
assert.strictEqual(barCount, 3, 'all 3 bars rendered despite time gap');
});
test('respects shared time axis', () => {
const data = [
{ t: '2024-01-01T00:00:00Z', v: -100 },
{ t: '2024-01-01T00:05:00Z', v: -95 },
];
const minT = new Date('2023-12-31T00:00:00Z').getTime();
const maxT = new Date('2024-01-02T00:00:00Z').getTime();
const svg = rfNFColumnChart(data, 700, 180, [], minT, maxT);
assert.ok(svg.includes('class="nf-bar"'), 'renders with shared time axis');
});
test('renders reboot markers when reboots provided', () => {
const data = [
{ t: '2024-01-01T00:00:00Z', v: -105 },
{ t: '2024-01-01T01:00:00Z', v: -95 },
];
const reboots = [new Date('2024-01-01T00:30:00Z').getTime()];
const svg = rfNFColumnChart(data, 700, 180, reboots);
assert.ok(svg.includes('reboot'), 'should render reboot marker');
});
}
// ===== CUSTOMIZE-V2.JS: core behavior =====
console.log('\n=== customize-v2.js: core behavior ===');
@@ -2718,7 +2864,6 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
// ===== Packets page: virtual scroll infrastructure =====
{
console.log('\nPackets page — virtual scroll:');
const packetsSource = fs.readFileSync('public/packets.js', 'utf8');
// --- Behavioral tests using extracted logic ---
@@ -2743,6 +2888,17 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
return 1 + childCount;
}
// Load _calcVisibleRange from the actual packets.js via sandbox
const pktCtx = makeSandbox();
pktCtx.registerPage = (name, handlers) => {};
pktCtx.onWS = () => {};
pktCtx.offWS = () => {};
pktCtx.api = () => Promise.resolve({});
pktCtx.window.getParsedPath = () => [];
pktCtx.window.getParsedDecoded = () => ({});
loadInCtx(pktCtx, 'public/packets.js');
const _calcVisibleRange = pktCtx.window._packetsTestAPI._calcVisibleRange;
test('cumulativeRowOffsets computes correct offsets for flat rows', () => {
const counts = [1, 1, 1, 1, 1];
const offsets = cumulativeRowOffsets(counts);
@@ -2804,38 +2960,101 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
assert.strictEqual(getRowCount(p, true, expanded, null), 1);
});
test('renderVisibleRows uses cumulative offsets not flat entry count', () => {
assert.ok(packetsSource.includes('_cumulativeRowOffsets'),
'renderVisibleRows should use cumulative row offsets');
assert.ok(!packetsSource.includes('const totalRows = _displayPackets.length'),
'should NOT use flat array length for total row count');
// --- Behavioral tests for _calcVisibleRange (#405, #409) ---
test('_calcVisibleRange: top of list (scrollTop = 0)', () => {
const offsets = cumulativeRowOffsets([1,1,1,1,1,1,1,1,1,1]); // 10 flat items
const r = _calcVisibleRange(offsets, 10, 0, 360, 36, 0, 2);
assert.strictEqual(r.startIdx, 0, 'start should be 0');
assert.ok(r.endIdx <= 10, 'end should not exceed entry count');
assert.ok(r.endIdx >= 10, 'with buffer=2, should cover visible + buffer');
});
test('renderVisibleRows skips DOM rebuild when range unchanged', () => {
assert.ok(packetsSource.includes('startIdx === _lastVisibleStart && endIdx === _lastVisibleEnd'),
'should skip rebuild when range is unchanged');
test('_calcVisibleRange: middle of list', () => {
// 100 flat items, viewport shows ~10 rows, scroll to row 50
const offsets = cumulativeRowOffsets(new Array(100).fill(1));
const r = _calcVisibleRange(offsets, 100, 50 * 36, 360, 36, 0, 5);
assert.strictEqual(r.firstEntry, 50, 'firstEntry should be 50');
assert.strictEqual(r.startIdx, 45, 'startIdx = firstEntry - buffer');
assert.ok(r.endIdx <= 100);
assert.ok(r.endIdx >= 60, 'endIdx should cover visible + buffer');
});
test('lazy row generation — HTML built only for visible slice', () => {
assert.ok(!packetsSource.includes('_lastRenderedRows'),
'should NOT have pre-built row HTML cache');
assert.ok(packetsSource.includes('_displayPackets.slice(startIdx, endIdx)'),
'should slice display packets for visible range on full rebuild');
// Incremental path uses builder() per-item in loops; full rebuild uses .map()
assert.ok(packetsSource.includes('builder(p, startIdx + i)') || packetsSource.includes('builder(_displayPackets[i], i)'),
'should build HTML lazily per visible packet');
test('_calcVisibleRange: bottom of list', () => {
const offsets = cumulativeRowOffsets(new Array(100).fill(1));
// Scroll past the end
const r = _calcVisibleRange(offsets, 100, 99 * 36, 360, 36, 0, 5);
assert.strictEqual(r.endIdx, 100, 'endIdx clamped to entry count');
assert.ok(r.startIdx >= 84, 'startIdx should be near end minus buffer');
});
test('observer filter Set is hoisted, not recreated per-packet', () => {
assert.ok(packetsSource.includes('_observerFilterSet = filters.observer ? new Set(filters.observer.split'),
'observer filter Set should be created once in renderTableRows');
assert.ok(packetsSource.includes('_observerFilterSet.has(String(c.observer_id))'),
'buildGroupRowHtml should use hoisted _observerFilterSet');
test('_calcVisibleRange: empty array', () => {
const offsets = cumulativeRowOffsets([]);
const r = _calcVisibleRange(offsets, 0, 0, 360, 36, 0, 5);
assert.strictEqual(r.startIdx, 0);
assert.strictEqual(r.endIdx, 0);
});
test('_calcVisibleRange: single item', () => {
const offsets = cumulativeRowOffsets([1]);
const r = _calcVisibleRange(offsets, 1, 0, 360, 36, 0, 5);
assert.strictEqual(r.startIdx, 0);
assert.strictEqual(r.endIdx, 1);
});
test('_calcVisibleRange: exact row boundary', () => {
const offsets = cumulativeRowOffsets(new Array(20).fill(1));
// scrollTop exactly at row 5 boundary
const r = _calcVisibleRange(offsets, 20, 5 * 36, 360, 36, 0, 2);
assert.strictEqual(r.firstEntry, 5, 'firstEntry at exact boundary');
assert.strictEqual(r.startIdx, 3, 'startIdx = firstEntry - buffer');
});
test('_calcVisibleRange: large dataset (30K items)', () => {
const offsets = cumulativeRowOffsets(new Array(30000).fill(1));
const r = _calcVisibleRange(offsets, 30000, 15000 * 36, 360, 36, 30, 30);
// theadHeight=30 means adjustedScrollTop = 15000*36 - 30, so firstDomRow = floor((540000-30)/36) = 14999
assert.strictEqual(r.firstEntry, 14999);
assert.strictEqual(r.startIdx, 14969);
assert.ok(r.endIdx <= 30000);
assert.ok(r.endIdx >= 15040);
});
test('_calcVisibleRange: various row heights', () => {
const offsets = cumulativeRowOffsets(new Array(50).fill(1));
// rowHeight = 24 instead of 36
const r = _calcVisibleRange(offsets, 50, 10 * 24, 240, 24, 0, 3);
assert.strictEqual(r.firstEntry, 10);
assert.strictEqual(r.startIdx, 7);
});
test('_calcVisibleRange: thead offset shifts visible range', () => {
const offsets = cumulativeRowOffsets(new Array(20).fill(1));
// scrollTop = 40 but theadHeight = 40, so adjustedScrollTop = 0
const r = _calcVisibleRange(offsets, 20, 40, 360, 36, 40, 2);
assert.strictEqual(r.firstEntry, 0, 'thead offset should be subtracted');
});
test('_calcVisibleRange: expanded groups with variable row counts', () => {
// Simulate: item0=1row, item1=5rows(expanded group), item2=1row, item3=3rows, item4=1row
const offsets = cumulativeRowOffsets([1, 5, 1, 3, 1]);
// Scroll to DOM row 6 (in item2), viewport shows 3 DOM rows
const r = _calcVisibleRange(offsets, 5, 6 * 36, 108, 36, 0, 0);
assert.strictEqual(r.firstEntry, 2, 'should land in item2 (offsets[2]=6)');
assert.strictEqual(r.startIdx, 2);
});
test('_calcVisibleRange: buffer clamped at boundaries', () => {
const offsets = cumulativeRowOffsets(new Array(10).fill(1));
// At top with buffer=20 (larger than dataset)
const r = _calcVisibleRange(offsets, 10, 0, 360, 36, 0, 20);
assert.strictEqual(r.startIdx, 0, 'start clamped to 0');
assert.strictEqual(r.endIdx, 10, 'end clamped to entry count');
});
// --- Behavioral tests for observer filter logic (#537) ---
test('observer filter in grouped mode includes packet when child matches (#537)', () => {
// The display filter should keep a grouped packet whose primary observer_id
// does NOT match, but one of its _children does.
const obsIds = new Set(['OBS_B']);
const packets = [
{ observer_id: 'OBS_A', _children: [{ observer_id: 'OBS_A' }, { observer_id: 'OBS_B' }] },
@@ -2874,53 +3093,6 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
const passes2 = obsSet.has(p2.observer_id) || (p2._children && p2._children.some(c => obsSet.has(String(c.observer_id))));
assert.ok(!passes2, 'WS filter should reject grouped packet with no matching observers');
});
test('packets.js display filter checks _children for observer match (#537)', () => {
// Verify the actual source code has the children check
assert.ok(
packetsSource.includes('p._children) return p._children.some(c => obsIds.has(String(c.observer_id))'),
'display filter should check _children for observer match'
);
});
test('packets.js WS filter checks _children for observer match (#537)', () => {
assert.ok(
packetsSource.includes('p._children && p._children.some(c => obsSet.has(String(c.observer_id)))'),
'WS filter should check _children for observer match'
);
});
test('buildFlatRowHtml has null-safe decoded_json', () => {
const flatBuilderMatch = packetsSource.match(/function buildFlatRowHtml[\s\S]*?(?=\n function )/);
assert.ok(flatBuilderMatch, 'buildFlatRowHtml should exist');
assert.ok(flatBuilderMatch[0].includes('getParsedDecoded(p)'),
'buildFlatRowHtml should use getParsedDecoded for null-safe decoded_json fallback');
});
test('pathHops null guard in buildFlatRowHtml (issue #451)', () => {
const flatBuilderMatch = packetsSource.match(/function buildFlatRowHtml[\s\S]*?(?=\n function )/);
assert.ok(flatBuilderMatch, 'buildFlatRowHtml should exist');
assert.ok(flatBuilderMatch[0].includes('getParsedPath(p)'),
'buildFlatRowHtml should use getParsedPath which guards against null');
});
test('pathHops null guard in detail pane (issue #451)', () => {
assert.ok(packetsSource.includes('getParsedPath(pkt)'),
'detail pane should use getParsedPath for null-safe path parsing');
assert.ok(packetsSource.includes('getParsedDecoded(pkt)'),
'detail pane should use getParsedDecoded for null-safe decoded parsing');
});
test('destroy cleans up virtual scroll state', () => {
assert.ok(packetsSource.includes('detachVScrollListener'),
'destroy should detach virtual scroll listener');
assert.ok(packetsSource.includes("_displayPackets = []"),
'destroy should reset display packets');
assert.ok(packetsSource.includes("_rowCounts = []"),
'destroy should reset row counts');
assert.ok(packetsSource.includes("_lastVisibleStart = -1"),
'destroy should reset visible start');
});
}
// ===== live.js: packetTimestamp =====
@@ -4490,6 +4662,262 @@ console.log('\n=== app.js: routeTypeName/payloadTypeName edge cases ===');
});
}
// ===== REGION-FILTER.JS: setSelected =====
console.log('\n=== region-filter.js: setSelected ===');
{
const ctx = makeSandbox();
ctx.fetch = () => Promise.resolve({ json: () => Promise.resolve({ 'US-SFO': 'San Jose', 'US-LAX': 'Los Angeles' }) });
// Patch createElement to return an object with style property
const origCreate = ctx.document.createElement;
ctx.document.createElement = () => ({
id: '', textContent: '', innerHTML: '',
style: {},
querySelector: () => null,
querySelectorAll: () => [],
onclick: null,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
});
loadInCtx(ctx, 'public/region-filter.js');
const RF = ctx.RegionFilter;
test('setSelected sets region codes', async () => {
await RF.init(ctx.document.createElement('div'));
RF.setSelected(['US-SFO', 'US-LAX']);
assert.strictEqual(RF.getRegionParam(), 'US-SFO,US-LAX');
});
test('setSelected with null clears selection', async () => {
await RF.init(ctx.document.createElement('div'));
RF.setSelected(['US-SFO']);
RF.setSelected(null);
assert.strictEqual(RF.getRegionParam(), '');
});
test('setSelected with empty array clears selection', async () => {
await RF.init(ctx.document.createElement('div'));
RF.setSelected(['US-SFO']);
RF.setSelected([]);
assert.strictEqual(RF.getRegionParam(), '');
});
}
// ===== NODES.JS: buildNodesQuery =====
console.log('\n=== nodes.js: buildNodesQuery ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
// Provide required globals for nodes.js IIFE to execute
ctx.registerPage = () => {};
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '' };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.debouncedOnWS = () => () => {};
ctx.invalidateApiCache = () => {};
ctx.favStar = () => '';
ctx.bindFavStars = () => {};
ctx.getFavorites = () => [];
ctx.isFavorite = () => false;
ctx.connectWS = () => {};
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
ctx.initTabBar = () => {};
ctx.debounce = (fn) => fn;
ctx.copyToClipboard = () => {};
ctx.api = () => Promise.resolve({});
ctx.escapeHtml = (s) => s;
ctx.timeAgo = () => '';
ctx.formatTimestampWithTooltip = () => '';
ctx.getTimestampMode = () => 'ago';
ctx.CLIENT_TTL = {};
ctx.qrcode = null;
try {
const src = fs.readFileSync('public/nodes.js', 'utf8');
vm.runInContext(src, ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
} catch (e) {
console.log(' ⚠️ nodes.js sandbox load failed:', e.message.slice(0, 120));
}
const buildNodesQuery = ctx.buildNodesQuery;
if (buildNodesQuery) {
test('buildNodesQuery: all tab + no search = empty', () => {
assert.strictEqual(buildNodesQuery('all', ''), '');
});
test('buildNodesQuery: repeater tab only', () => {
assert.strictEqual(buildNodesQuery('repeater', ''), '?tab=repeater');
});
test('buildNodesQuery: search only (all tab)', () => {
assert.strictEqual(buildNodesQuery('all', 'foo'), '?search=foo');
});
test('buildNodesQuery: tab + search combined', () => {
assert.strictEqual(buildNodesQuery('companion', 'bar'), '?tab=companion&search=bar');
});
test('buildNodesQuery: null search treated as empty', () => {
assert.strictEqual(buildNodesQuery('all', null), '');
});
test('buildNodesQuery: sensor tab', () => {
assert.strictEqual(buildNodesQuery('sensor', ''), '?tab=sensor');
});
} else {
console.log(' ⚠️ buildNodesQuery not exposed — skipping');
}
}
// ===== PACKETS.JS: buildPacketsQuery =====
console.log('\n=== packets.js: buildPacketsQuery ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
ctx.registerPage = () => {};
ctx.RegionFilter = { init: () => Promise.resolve(), onChange: () => () => {}, offChange: () => {}, getSelected: () => null, getRegionParam: () => '', setSelected: () => {} };
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.debouncedOnWS = () => () => {};
ctx.invalidateApiCache = () => {};
ctx.api = () => Promise.resolve({});
ctx.observerMap = new Map();
ctx.getParsedPath = () => [];
ctx.getParsedDecoded = () => ({});
ctx.clearParsedCache = () => {};
ctx.escapeHtml = (s) => s;
ctx.timeAgo = () => '';
ctx.formatTimestampWithTooltip = () => '';
ctx.getTimestampMode = () => 'ago';
ctx.copyToClipboard = () => {};
ctx.CLIENT_TTL = {};
ctx.debounce = (fn) => fn;
ctx.initTabBar = () => {};
try {
const src = fs.readFileSync('public/packet-helpers.js', 'utf8');
vm.runInContext(src, ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
const src2 = fs.readFileSync('public/packets.js', 'utf8');
vm.runInContext(src2, ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
} catch (e) {
console.log(' ⚠️ packets.js sandbox load failed:', e.message.slice(0, 120));
}
const buildPacketsQuery = ctx.buildPacketsQuery;
if (buildPacketsQuery) {
test('buildPacketsQuery: default (15min, no region) = empty string', () => {
assert.strictEqual(buildPacketsQuery(15, ''), '');
});
test('buildPacketsQuery: non-default timeWindow', () => {
assert.strictEqual(buildPacketsQuery(60, ''), '?timeWindow=60');
});
test('buildPacketsQuery: region only', () => {
assert.strictEqual(buildPacketsQuery(15, 'US-SFO'), '?region=US-SFO');
});
test('buildPacketsQuery: timeWindow + region', () => {
assert.strictEqual(buildPacketsQuery(30, 'US-SFO,US-LAX'), '?timeWindow=30&region=US-SFO%2CUS-LAX');
});
test('buildPacketsQuery: timeWindow=0 treated as default', () => {
assert.strictEqual(buildPacketsQuery(0, ''), '');
});
} else {
console.log(' ⚠️ buildPacketsQuery not exposed — skipping');
}
}
// ===== APP.JS: formatDistance / getDistanceUnit =====
console.log('\n=== app.js: formatDistance ===');
{
function makeDistCtx(localeLang, storageUnit) {
const ctx = makeSandbox();
if (storageUnit !== undefined) ctx.localStorage.setItem('meshcore-distance-unit', storageUnit);
ctx.navigator = { language: localeLang || 'en-BE' };
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
return ctx;
}
test('formatDistance: km mode, 12.3 km', () => {
const ctx = makeDistCtx('en-BE', 'km');
assert.strictEqual(ctx.formatDistance(12.3), '12.3 km');
});
test('formatDistance: km mode, sub-1km shows meters', () => {
const ctx = makeDistCtx('en-BE', 'km');
assert.strictEqual(ctx.formatDistance(0.45), '450 m');
});
test('formatDistance: mi mode, 12.3 km → 7.6 mi', () => {
const ctx = makeDistCtx('en-BE', 'mi');
assert.strictEqual(ctx.formatDistance(12.3), '7.6 mi');
});
test('formatDistance: auto + en-US locale → mi', () => {
const ctx = makeDistCtx('en-US', 'auto');
assert.strictEqual(ctx.getDistanceUnit(), 'mi');
});
test('formatDistance: auto + en-GB locale → mi', () => {
const ctx = makeDistCtx('en-GB', 'auto');
assert.strictEqual(ctx.getDistanceUnit(), 'mi');
});
test('formatDistance: auto + fr-BE locale → km', () => {
const ctx = makeDistCtx('fr-BE', 'auto');
assert.strictEqual(ctx.getDistanceUnit(), 'km');
});
test('formatDistance: null input returns —', () => {
const ctx = makeDistCtx('en-BE', 'km');
assert.strictEqual(ctx.formatDistance(null), '—');
});
test('formatDistanceRound: 50 km → "50 km"', () => {
const ctx = makeDistCtx('en-BE', 'km');
assert.strictEqual(ctx.formatDistanceRound(50), '50 km');
});
test('formatDistanceRound: 50 km in mi mode → "31 mi"', () => {
const ctx = makeDistCtx('en-BE', 'mi');
assert.strictEqual(ctx.formatDistanceRound(50), '31 mi');
});
test('formatDistanceRound: 200 km in mi mode → "124 mi"', () => {
const ctx = makeDistCtx('en-BE', 'mi');
assert.strictEqual(ctx.formatDistanceRound(200), '124 mi');
});
test('formatDistance: 0 in km mode → "0 m"', () => {
const ctx = makeDistCtx('en-BE', 'km');
assert.strictEqual(ctx.formatDistance(0), '0 m');
});
test('formatDistance: 0 in mi mode → "0 ft"', () => {
const ctx = makeDistCtx('en-BE', 'mi');
assert.strictEqual(ctx.formatDistance(0), '0 ft');
});
test('formatDistance: NaN input returns —', () => {
const ctx = makeDistCtx('en-BE', 'km');
assert.strictEqual(ctx.formatDistance(NaN), '—');
});
test('formatDistance: "abc" input returns —', () => {
const ctx = makeDistCtx('en-BE', 'km');
assert.strictEqual(ctx.formatDistance('abc'), '—');
});
test('formatDistanceRound: null input returns —', () => {
const ctx = makeDistCtx('en-BE', 'km');
assert.strictEqual(ctx.formatDistanceRound(null), '—');
});
test('formatDistanceRound: NaN input returns —', () => {
const ctx = makeDistCtx('en-BE', 'km');
assert.strictEqual(ctx.formatDistanceRound(NaN), '—');
});
test('formatDistanceRound: 0 in km mode → "0 km"', () => {
const ctx = makeDistCtx('en-BE', 'km');
assert.strictEqual(ctx.formatDistanceRound(0), '0 km');
});
test('formatDistance: mi mode sub-0.1mi shows feet', () => {
const ctx = makeDistCtx('en-BE', 'mi');
assert.strictEqual(ctx.formatDistance(0.01), '33 ft');
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);
+10 -1
View File
@@ -629,10 +629,19 @@ console.log('\n=== packets.js: buildFieldTable ===');
});
test('buildFieldTable hash_size calculation', () => {
// Path byte 0xC0 → bits 7-6 = 3 → hash_size = 4
// Path byte 0xC0 → bits 7-6 = 3 → hash_size = 4, but hash_count = 0
// Since #653: when hashCount == 0, shows "hash_count=0 (direct advert)" instead of hash_size
const pkt = { raw_hex: '00C0', route_type: 1, payload_type: 0 };
const decoded = {};
const result = api.buildFieldTable(pkt, decoded, [], []);
assert(result.includes('hash_count=0 (direct advert)'));
});
test('buildFieldTable hash_size shown when hash_count > 0', () => {
// Path byte 0xC1 → bits 7-6 = 3 → hash_size = 4, hash_count = 1
const pkt = { raw_hex: '00C1aabbccdd', route_type: 1, payload_type: 0 };
const decoded = {};
const result = api.buildFieldTable(pkt, decoded, [], []);
assert(result.includes('hash_size=4'));
});
+334
View File
@@ -0,0 +1,334 @@
/**
* Tests for panel corner positioning (#608 M0)
* Tests the pure logic functions extracted from live.js
*/
'use strict';
const assert = require('assert');
const vm = require('vm');
const fs = require('fs');
const path = require('path');
// Minimal DOM/browser stubs
function createContext() {
const storage = {};
const elements = {};
const listeners = {};
const mockEl = () => ({
style: {}, textContent: '', innerHTML: '',
classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
appendChild(c){ return c; }, removeChild(){ }, insertBefore(c){ return c; },
setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){},
addEventListener(){}, removeEventListener(){},
querySelector(){ return null; }, querySelectorAll(){ return []; },
getBoundingClientRect(){ return {top:0,left:0,right:0,bottom:0,width:0,height:0}; },
closest(){ return null; }, matches(){ return false; },
children: [], childNodes: [], parentNode: null, parentElement: null,
focus(){}, blur(){}, click(){}, scrollTo(){},
dataset: {}, offsetWidth: 0, offsetHeight: 0,
getContext(){ return { clearRect(){}, fillRect(){}, beginPath(){}, moveTo(){}, lineTo(){}, stroke(){}, fill(){}, arc(){}, save(){}, restore(){}, translate(){}, rotate(){}, scale(){}, drawImage(){}, measureText(){ return {width:0}; }, createLinearGradient(){ return {addColorStop(){}}; }, canvas: {width:0,height:0} }; },
width: 0, height: 0,
});
const ctx = {
window: {},
document: {
getElementById: (id) => elements[id] || null,
querySelectorAll: (sel) => {
const results = [];
for (const id in elements) {
const el = elements[id];
if (el._btns) results.push(...el._btns);
}
return results;
},
querySelector: () => null,
documentElement: { getAttribute: () => null, style: {} },
addEventListener: () => {},
createElement: () => mockEl(),
createElementNS: () => mockEl(),
createTextNode: (t) => ({ textContent: t }),
createDocumentFragment: () => ({ appendChild(){}, children: [] }),
body: { appendChild(){}, removeChild(){}, style: {}, classList: { add(){}, remove(){} } },
head: { appendChild(){} },
},
localStorage: {
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; }
},
_storage: storage,
_elements: elements,
_addElement: function(id) {
const attrs = {};
const btns = [];
elements[id] = {
setAttribute: (k, v) => { attrs[k] = v; },
getAttribute: (k) => attrs[k] || null,
querySelector: (sel) => {
if (sel === '.panel-corner-btn') return btns[0] || null;
return null;
},
_attrs: attrs,
_btns: btns,
_addBtn: function(panelId) {
const btnAttrs = { 'data-panel': panelId };
const btn = {
textContent: '',
setAttribute: (k, v) => { btnAttrs[k] = v; },
getAttribute: (k) => btnAttrs[k] || null,
addEventListener: () => {},
_attrs: btnAttrs
};
btns.push(btn);
return btn;
}
};
return elements[id];
}
};
// Self-references
ctx.window = ctx;
ctx.self = ctx;
return ctx;
}
function loadLiveModule(ctx) {
// Load the REAL live.js in a VM context and return window._panelCorner.
// This tests the actual code, not a copy (per AGENTS.md "test the real code, not copies").
const src = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
// Minimal stubs for live.js dependencies (only what's needed to avoid errors)
ctx.registerPage = () => {};
ctx.escapeHtml = (s) => String(s || '');
ctx.timeAgo = () => '—';
ctx.getParsedPath = () => [];
ctx.getParsedDecoded = () => ({});
ctx.TYPE_COLORS = { ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280', REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6' };
ctx.ROLE_COLORS = {};
ctx.ROLE_LABELS = {};
ctx.ROLE_STYLE = {};
ctx.ROLE_SORT = [];
ctx.formatTimestampWithTooltip = () => '';
ctx.getTimestampMode = () => 'relative';
ctx.console = console;
ctx.setTimeout = setTimeout;
ctx.clearTimeout = clearTimeout;
ctx.setInterval = setInterval;
ctx.clearInterval = clearInterval;
ctx.requestAnimationFrame = (cb) => setTimeout(cb, 0);
ctx.cancelAnimationFrame = clearTimeout;
ctx.matchMedia = () => ({ matches: false, addEventListener: () => {} });
ctx.navigator = { userAgent: '' };
ctx.performance = { now: () => Date.now() };
ctx.L = undefined;
ctx.MutationObserver = class { observe() {} disconnect() {} };
ctx.ResizeObserver = class { observe() {} disconnect() {} };
ctx.IntersectionObserver = class { observe() {} disconnect() {} };
ctx.Image = class {};
ctx.AudioContext = undefined;
ctx.HTMLElement = class {};
ctx.Event = class {};
ctx.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
ctx.Number = Number; ctx.String = String; ctx.Array = Array; ctx.Object = Object;
ctx.JSON = JSON; ctx.Math = Math; ctx.Date = Date; ctx.RegExp = RegExp;
ctx.Error = Error; ctx.Map = Map; ctx.Set = Set; ctx.WeakMap = WeakMap;
ctx.parseInt = parseInt; ctx.parseFloat = parseFloat;
ctx.isNaN = isNaN; ctx.isFinite = isFinite;
ctx.encodeURIComponent = encodeURIComponent;
ctx.decodeURIComponent = decodeURIComponent;
ctx.Promise = Promise; ctx.Symbol = Symbol;
ctx.queueMicrotask = queueMicrotask;
// Self-references needed for the IIFE
ctx.self = ctx;
ctx.globalThis = ctx;
vm.createContext(ctx);
vm.runInContext(src, ctx, { timeout: 3000 });
return ctx.window._panelCorner;
}
// ---- Tests ----
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(' ✓ ' + name);
} catch (e) {
failed++;
console.log(' ✗ ' + name);
console.log(' ' + e.message);
}
}
console.log('\nPanel Corner Positioning Tests (#608 M0)\n');
// --- nextAvailableCorner ---
console.log('nextAvailableCorner:');
test('returns desired corner when available', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const positions = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'tl', positions), 'tl');
});
test('skips occupied corner', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const positions = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
// liveFeed wants 'tr' but liveNodeDetail is there → should get 'br'? No, liveLegend is at br → skip to bl? No liveFeed is at bl → skip to tl
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'tr', positions), 'bl');
// Wait — liveFeed IS liveFeed, so bl is not occupied by "another" panel
// Actually liveFeed wants tr → tr occupied by nodeDetail → try br → occupied by legend → try bl → that's liveFeed itself (excluded from "occupied") → bl is free
});
test('skips multiple occupied corners', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const positions = { liveFeed: 'tl', liveLegend: 'tr', liveNodeDetail: 'br' };
// liveFeed wants 'tr' → occupied by legend → try 'br' → occupied by nodeDetail → try 'bl' → free
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'tr', positions), 'bl');
});
test('returns desired when only self occupies it', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const positions = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
// liveFeed wants bl — it's "occupied" by liveFeed itself, which is excluded
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'bl', positions), 'bl');
});
// --- getPanelPositions ---
console.log('\ngetPanelPositions:');
test('returns defaults when nothing in localStorage', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const pos = pc.getPanelPositions();
assert.strictEqual(pos.liveFeed, 'bl');
assert.strictEqual(pos.liveLegend, 'br');
assert.strictEqual(pos.liveNodeDetail, 'tr');
});
test('returns saved positions from localStorage', () => {
const ctx = createContext();
ctx.localStorage.setItem('panel-corner-liveFeed', 'tl');
ctx.localStorage.setItem('panel-corner-liveLegend', 'bl');
const pc = loadLiveModule(ctx);
const pos = pc.getPanelPositions();
assert.strictEqual(pos.liveFeed, 'tl');
assert.strictEqual(pos.liveLegend, 'bl');
assert.strictEqual(pos.liveNodeDetail, 'tr'); // still default
});
// --- applyPanelPosition ---
console.log('\napplyPanelPosition:');
test('sets data-position attribute on element', () => {
const ctx = createContext();
const el = ctx._addElement('liveFeed');
el._addBtn('liveFeed');
const pc = loadLiveModule(ctx);
pc.applyPanelPosition('liveFeed', 'tr');
assert.strictEqual(el._attrs['data-position'], 'tr');
});
test('updates button text and aria-label', () => {
const ctx = createContext();
const el = ctx._addElement('liveFeed');
const btn = el._addBtn('liveFeed');
const pc = loadLiveModule(ctx);
pc.applyPanelPosition('liveFeed', 'tr');
assert.strictEqual(btn.textContent, '↙');
assert.ok(btn._attrs['aria-label'].includes('top-right'));
});
test('handles missing element gracefully', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
// Should not throw
pc.applyPanelPosition('nonexistent', 'tl');
});
// --- onCornerClick ---
console.log('\nonCornerClick:');
test('cycles from default bl to tl for feed', () => {
const ctx = createContext();
const el = ctx._addElement('liveFeed');
el._addBtn('liveFeed');
ctx._addElement('liveLegend');
ctx._addElement('liveNodeDetail');
ctx._addElement('panelPositionAnnounce');
ctx._elements.panelPositionAnnounce.textContent = '';
const pc = loadLiveModule(ctx);
// Feed defaults to bl, cycle: bl → tl (next in cycle after bl is tl)
pc.onCornerClick('liveFeed');
assert.strictEqual(ctx._storage['panel-corner-liveFeed'], 'tl');
assert.strictEqual(el._attrs['data-position'], 'tl');
});
test('collision avoidance: skips occupied corner', () => {
const ctx = createContext();
ctx._addElement('liveFeed');
const legendEl = ctx._addElement('liveLegend');
legendEl._addBtn('liveLegend');
ctx._addElement('liveNodeDetail');
ctx._addElement('panelPositionAnnounce');
ctx._elements.panelPositionAnnounce.textContent = '';
const pc = loadLiveModule(ctx);
// Legend defaults to br. Click → next is bl. But bl is occupied by feed → skip to tl
pc.onCornerClick('liveLegend');
assert.strictEqual(ctx._storage['panel-corner-liveLegend'], 'tl');
});
// --- resetPanelPositions ---
console.log('\nresetPanelPositions:');
test('clears localStorage and restores defaults', () => {
const ctx = createContext();
ctx.localStorage.setItem('panel-corner-liveFeed', 'tr');
ctx.localStorage.setItem('panel-corner-liveLegend', 'tl');
const feedEl = ctx._addElement('liveFeed');
feedEl._addBtn('liveFeed');
const legendEl = ctx._addElement('liveLegend');
legendEl._addBtn('liveLegend');
const detailEl = ctx._addElement('liveNodeDetail');
detailEl._addBtn('liveNodeDetail');
const pc = loadLiveModule(ctx);
pc.resetPanelPositions();
assert.strictEqual(ctx._storage['panel-corner-liveFeed'], undefined);
assert.strictEqual(feedEl._attrs['data-position'], 'bl');
assert.strictEqual(legendEl._attrs['data-position'], 'br');
assert.strictEqual(detailEl._attrs['data-position'], 'tr');
});
// --- Corner cycle order ---
console.log('\nCorner cycle order:');
test('full cycle: tl → tr → br → bl → tl', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const cycle = pc.CORNER_CYCLE;
assert.strictEqual(cycle.join(','), 'tl,tr,br,bl');
});
test('defaults match expected panel positions', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
assert.strictEqual(pc.PANEL_DEFAULTS.liveFeed, 'bl');
assert.strictEqual(pc.PANEL_DEFAULTS.liveLegend, 'br');
assert.strictEqual(pc.PANEL_DEFAULTS.liveNodeDetail, 'tr');
});
// Summary
console.log('\n' + (passed + failed) + ' tests, ' + passed + ' passed, ' + failed + ' failed\n');
process.exit(failed > 0 ? 1 : 0);
+356
View File
@@ -0,0 +1,356 @@
/* test-table-sort.js — Unit tests for TableSort utility */
'use strict';
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(`${name}`);
} catch (e) {
failed++;
console.log(`${name}`);
console.log(` ${e.message}`);
}
}
function createDOM(html) {
const dom = new JSDOM(`<!DOCTYPE html><html><body>${html}</body></html>`, {
url: 'http://localhost',
runScripts: 'dangerously'
});
// Load TableSort into this DOM
const script = fs.readFileSync(path.join(__dirname, 'public', 'table-sort.js'), 'utf8');
const el = dom.window.document.createElement('script');
el.textContent = script;
dom.window.document.head.appendChild(el);
return dom;
}
function makeTable(headers, rows) {
// headers: [{key, type?, label}], rows: [[value, ...]]
let html = '<table id="t"><thead><tr>';
for (const h of headers) {
html += `<th data-sort-key="${h.key}"${h.type ? ` data-type="${h.type}"` : ''}>${h.label || h.key}</th>`;
}
html += '</tr></thead><tbody>';
for (const row of rows) {
html += '<tr>';
for (let i = 0; i < row.length; i++) {
const val = row[i];
if (typeof val === 'object' && val !== null) {
html += `<td data-value="${val.dataValue}">${val.text || ''}</td>`;
} else {
html += `<td data-value="${val}">${val}</td>`;
}
}
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
function getColumnValues(dom, colIndex) {
const rows = dom.window.document.querySelectorAll('tbody tr');
return Array.from(rows).map(r => r.cells[colIndex].getAttribute('data-value'));
}
console.log('\nTableSort — comparators');
test('text comparator: basic alphabetical', () => {
const cmp = (() => {
const dom = createDOM('<div></div>');
return dom.window.TableSort.comparators.text;
})();
assert.ok(cmp('apple', 'banana') < 0);
assert.ok(cmp('banana', 'apple') > 0);
assert.strictEqual(cmp('same', 'same'), 0);
});
test('text comparator: null/undefined handling', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.text;
assert.strictEqual(cmp(null, null), 0);
assert.strictEqual(cmp(undefined, undefined), 0);
});
test('numeric comparator: basic numbers', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.numeric;
assert.ok(cmp('1', '2') < 0);
assert.ok(cmp('10', '2') > 0);
assert.strictEqual(cmp('5', '5'), 0);
});
test('numeric comparator: NaN sorts last', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.numeric;
assert.ok(cmp('abc', '5') > 0); // NaN > number (sorts last)
assert.ok(cmp('5', 'abc') < 0);
assert.strictEqual(cmp('abc', 'xyz'), 0); // both NaN
});
test('numeric comparator: negative numbers', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.numeric;
assert.ok(cmp('-10', '-5') < 0);
assert.ok(cmp('-5', '-10') > 0);
});
test('date comparator: ISO dates', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.date;
assert.ok(cmp('2024-01-01T00:00:00Z', '2024-06-01T00:00:00Z') < 0);
assert.ok(cmp('2024-06-01T00:00:00Z', '2024-01-01T00:00:00Z') > 0);
assert.strictEqual(cmp('2024-01-01', '2024-01-01'), 0);
});
test('date comparator: invalid dates sort last', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.date;
assert.ok(cmp('invalid', '2024-01-01') > 0);
assert.ok(cmp('2024-01-01', 'invalid') < 0);
});
test('dBm comparator: strips suffix', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.dbm;
assert.ok(cmp('-120 dBm', '-80 dBm') < 0);
assert.ok(cmp('-80 dBm', '-120 dBm') > 0);
assert.strictEqual(cmp('-95 dBm', '-95 dBm'), 0);
});
test('dBm comparator: works without suffix', () => {
const dom = createDOM('<div></div>');
const cmp = dom.window.TableSort.comparators.dbm;
assert.ok(cmp('-120', '-80') < 0);
});
console.log('\nTableSort — DOM sorting');
test('sort ascending by text column', () => {
const html = makeTable(
[{key: 'name'}],
[['Charlie'], ['Alice'], ['Bob']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
const inst = dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc' });
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['Alice', 'Bob', 'Charlie']);
});
test('sort descending by numeric column', () => {
const html = makeTable(
[{key: 'val', type: 'numeric'}],
[['3'], ['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'val', defaultDirection: 'desc' });
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['3', '2', '1']);
});
test('click toggles direction', () => {
const html = makeTable(
[{key: 'name'}],
[['B'], ['A'], ['C']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
const inst = dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc' });
// Initially ascending
assert.deepStrictEqual(getColumnValues(dom, 0), ['A', 'B', 'C']);
// Click same header → descending
const th = dom.window.document.querySelector('th[data-sort-key="name"]');
th.click();
assert.deepStrictEqual(getColumnValues(dom, 0), ['C', 'B', 'A']);
// Click again → ascending
th.click();
assert.deepStrictEqual(getColumnValues(dom, 0), ['A', 'B', 'C']);
});
console.log('\nTableSort — aria-sort attributes');
test('aria-sort set correctly on active column', () => {
const html = makeTable(
[{key: 'a'}, {key: 'b'}],
[['1', 'x'], ['2', 'y']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const thA = dom.window.document.querySelector('th[data-sort-key="a"]');
const thB = dom.window.document.querySelector('th[data-sort-key="b"]');
assert.strictEqual(thA.getAttribute('aria-sort'), 'ascending');
assert.strictEqual(thB.getAttribute('aria-sort'), 'none');
});
test('aria-sort updates on direction change', () => {
const html = makeTable(
[{key: 'a'}],
[['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
assert.strictEqual(th.getAttribute('aria-sort'), 'ascending');
th.click(); // toggle to desc
assert.strictEqual(th.getAttribute('aria-sort'), 'descending');
});
test('aria-sort updates when switching columns', () => {
const html = makeTable(
[{key: 'a'}, {key: 'b'}],
[['1', 'x'], ['2', 'y']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const thB = dom.window.document.querySelector('th[data-sort-key="b"]');
thB.click(); // switch to column b
const thA = dom.window.document.querySelector('th[data-sort-key="a"]');
assert.strictEqual(thA.getAttribute('aria-sort'), 'none');
assert.strictEqual(thB.getAttribute('aria-sort'), 'ascending');
});
console.log('\nTableSort — visual indicator');
test('sort arrow shows on active column', () => {
const html = makeTable(
[{key: 'a'}],
[['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const arrow = dom.window.document.querySelector('.sort-arrow');
assert.ok(arrow, 'sort arrow should exist');
assert.ok(arrow.textContent.includes('▲'), 'ascending should show ▲');
});
test('sort arrow changes on direction toggle', () => {
const html = makeTable(
[{key: 'a'}],
[['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
th.click(); // desc
const arrow = dom.window.document.querySelector('.sort-arrow');
assert.ok(arrow.textContent.includes('▼'), 'descending should show ▼');
});
console.log('\nTableSort — onSort callback');
test('onSort fires with column and direction', () => {
const html = makeTable(
[{key: 'a'}, {key: 'b'}],
[['1', 'x'], ['2', 'y']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
let called = null;
dom.window.TableSort.init(table, {
domReorder: false,
onSort: function(col, dir) { called = { col, dir }; }
});
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
th.click();
assert.ok(called, 'onSort should fire');
assert.strictEqual(called.col, 'a');
assert.strictEqual(called.dir, 'asc');
});
console.log('\nTableSort — domReorder: false');
test('domReorder: false skips DOM sorting', () => {
const html = makeTable(
[{key: 'name'}],
[['C'], ['A'], ['B']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc', domReorder: false });
// DOM order should NOT change
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['C', 'A', 'B']);
});
console.log('\nTableSort — destroy');
test('destroy removes event handlers and cleans up', () => {
const html = makeTable(
[{key: 'a'}],
[['2'], ['1']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
const inst = dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' });
inst.destroy();
const th = dom.window.document.querySelector('th[data-sort-key="a"]');
assert.strictEqual(th.getAttribute('aria-sort'), null, 'aria-sort should be removed');
assert.ok(!th.classList.contains('sort-active'), 'sort-active should be removed');
assert.strictEqual(th.querySelector('.sort-arrow'), null, 'arrow should be removed');
});
console.log('\nTableSort — custom comparators');
test('custom comparator overrides built-in', () => {
const html = makeTable(
[{key: 'val', type: 'numeric'}],
[['3'], ['1'], ['2']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
// Custom: reverse numeric
dom.window.TableSort.init(table, {
defaultColumn: 'val', defaultDirection: 'asc',
comparators: { val: function(a, b) { return Number(b) - Number(a); } }
});
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['3', '2', '1']); // reversed
});
console.log('\nTableSort — date sort with data-type="date"');
test('date column sorts correctly', () => {
const html = makeTable(
[{key: 'ts', type: 'date'}],
[['2024-06-15T10:00:00Z'], ['2024-01-01T00:00:00Z'], ['2024-12-25T23:59:59Z']]
);
const dom = createDOM(html);
const table = dom.window.document.getElementById('t');
dom.window.TableSort.init(table, { defaultColumn: 'ts', defaultDirection: 'asc' });
const vals = getColumnValues(dom, 0);
assert.deepStrictEqual(vals, ['2024-01-01T00:00:00Z', '2024-06-15T10:00:00Z', '2024-12-25T23:59:59Z']);
});
// Summary
console.log(`\n${passed + failed} tests, ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);