Compare commits

..

80 Commits

Author SHA1 Message Date
clawbot 35e1f46b36 test(#1085): E2E for Roles fold-in into Analytics tab
Adds three failing assertions covering the acceptance criteria:
1. Top nav must NOT contain a 'Roles' link
2. Analytics page must include a [data-tab=roles] tab that renders Roles content
3. Old #/roles URL must redirect to #/analytics?tab=roles

Replaces the legacy 'Roles page renders distribution table' E2E (issue #818)
which assumed a standalone /#/roles SPA page.

Red commit — production code in a follow-up.
2026-05-05 09:13:23 +00:00
Kpa-clawbot 282074b19d feat(#1034): wire QR generate + scan into channel modal (PR 3/3) (#1081)
## Summary

**PR 3/3 of #1034** — wires the existing `window.ChannelQR` module (PR2
#1035) into the existing channel modal placeholders (PR1 #1037).

### Changes

**`public/channels.js`**
- **Generate handler** (`#chGenerateBtn`): replaced the "QR coming in
next update" placeholder text with a real call to
`window.ChannelQR.generate(label || channelName, keyHex, qrOut)`.
Renders QR canvas + `meshcore://channel/add?...` URL + Copy Key inline
into `#qr-output`.
- **Scan handler** (`#scan-qr-btn`): removed `disabled` attribute,
refreshed title, and added a click handler that calls
`window.ChannelQR.scan()`. On success it populates `#chPskKey` (from
`result.secret`) and `#chPskName` (from `result.name`); on cancel it's a
no-op; on error it surfaces the message via `#chPskError`.

The Share button on sidebar entries was already wired to
`ChannelQR.generate` in PR1 (no change needed).

### TDD

1. **Red commit** (`178020b`): `test-channel-qr-wiring.js` — 12
assertions, 7 failed against the placeholder code (Generate handler
still printed "coming in next update", scan button still disabled).
2. **Green commit** (`e708f3f`): wiring added → all 12 assertions pass.

### E2E (rule 18)

`test-e2e-playwright.js` gains 3 Playwright tests (run against the live
Go server with fixture DB in CI):

- Generate → asserts `#qr-output canvas` and the
`meshcore://channel/add` URL appear after the click.
- Scan button is enabled (no `disabled` attribute).
- Stubs `ChannelQR.scan` to return `{name, secret}`, clicks the button,
asserts `#chPskKey` + `#chPskName` are populated.

### CI registration

Added `node test-channel-qr-wiring.js` and `node
test-channel-modal-ux.js` to the JS unit-test step in
`.github/workflows/deploy.yml` (and `test-all.sh`).

### Closes

Closes #1034 (final PR in the redesign series).

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-05 01:59:17 -07:00
Kpa-clawbot 136e1d23c8 feat(#730): foreign-advert detection — flag instead of silent drop (#1084)
## Summary

**Partial fix for #730 (M1 only — M2 frontend and M3 alerting
deferred).**

Today the ingestor **silently drops** ADVERTs whose GPS lies outside the
configured `geo_filter` polygon. That's the wrong default for an
analytics tool — operators get zero visibility into bridged or leaked
meshes.

This PR makes the new default **flag, don't drop**: foreign adverts are
stored, the node row is tagged `foreign_advert=1`, and the API surfaces
`"foreign": true` so dashboards / map overlays can be built on top.

## Behavior

| Mode | What happens to an ADVERT outside `geo_filter` |
|---|---|
| (default) flag | Stored, marked `foreign_advert=1`, exposed via API |
| drop (legacy) | Silently dropped (preserves old behavior for ops who
want it) |

## What's done (M1 — Backend)
- ingestor stores foreign adverts instead of dropping
- `nodes.foreign_advert` column added (migration)
- `/api/nodes` and `/api/nodes/{pk}` expose `foreign: true` field
- Config: `geofilter.action: "flag"|"drop"` (default `flag`)
- Tests + config docs

## What's NOT done (deferred to M2 + M3)

- **M2 — Frontend:** Map overlay showing foreign adverts as distinct
markers, foreign-advert filter on packets/nodes pages, dedicated
foreign-advert dashboard
- **M3 — Alerting:** Time-series detection of bridging events, alert
when foreign advert rate spikes, identify bridge entry-point nodes

Issue #730 remains open for M2 and M3.

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-05 01:58:52 -07:00
Kpa-clawbot f7d8a7cb8f feat(packets): filter UX — in-UI docs + autocomplete + right-click + saved filters (#966) (#1083)
## Summary

Implements the full filter-input UX upgrade from #966 — Wireshark-style
help, autocomplete, right-click-to-filter, and saved filters.

Closes #966.

## Surfaces

### A. Help popover (ⓘ button next to filter input)
Auto-generated from `PacketFilter.FIELDS` / `OPERATORS` so it stays in
sync with the parser. Includes:
- Syntax overview (boolean ops, parens, case-insensitivity,
URL-shareable filters)
- Full field reference (27 entries: top-level + `payload.*`)
- Full operator reference with one example per op
- 10 ready-to-paste examples
- Tips (right-click, autocomplete, save)

### B. Autocomplete dropdown
- Type partial field name → field suggestions (top-level + dynamic
`payload.*` keys discovered from visible packets)
- Type `field` → operator suggestions
- Type `type ==` → list of canonical type values (`ADVERT`, `GRP_TXT`,
…)
- Type `route ==` → list of route values (`FLOOD`, `DIRECT`,
`TRANSPORT_FLOOD`, …)
- Keyboard nav: ↑/↓, Tab/Enter to accept, Esc to dismiss

### C. Right-click → filter by this value
Right-click any of these cells in the packet table:
- `hash`, `size`, `type`, `observer`

Context menu offers `==`, `!=`, `contains`. Click → clause appended to
filter input (with `&&` if expression already present).

### D. Saved filters
- ★ Saved ▾ dropdown next to the input
- 7 starter defaults (Adverts only, Channel traffic, Direct messages,
Strong signal SNR > 5, Multi-hop, Repeater adverts, Recent < 5m)
- "+ Save current expression" prompts for a name and persists to
`localStorage` under `corescope_saved_filters_v1`
- User filters can be deleted (✕); defaults cannot
- User filters with the same name as a default override it

## Implementation

**`public/packet-filter.js`** — exposes `FIELDS`, `OPERATORS`,
`TYPE_VALUES`, `ROUTE_VALUES`, and a new `suggest(input, cursor, opts)`
function that returns ranked autocomplete suggestions with
replace-range. Pure function — no DOM, fully unit-tested.

**NEW `public/filter-ux.js`** — `window.FilterUX` IIFE owning the help
popover, autocomplete dropdown, context menu, and saved-filters store.
`init()` is idempotent, called once after the filter input renders.

**`public/packets.js`** — calls `FilterUX.init()` after the filter input
IIFE; row builders gain `data-filter-field` / `data-filter-value` attrs
on hash/size/type/observer cells. `filter-group` wrapper now `position:
relative` so dropdowns anchor correctly.

**`public/style.css`** — scoped `.fux-*` styles using existing CSS
variables (no new theme tokens).

## Tests

- `test-packet-filter-ux.js` (19 unit tests, wired into `test-all.sh`):
  - Metadata exposure (FIELDS / OPERATORS / TYPE_VALUES / ROUTE_VALUES)
- `suggest()` for empty input, prefix match, after `==`, dynamic
`payload.*` keys
- `SavedFilters.list/save/delete` — defaults, persistence, override,
dedup
- `buildCellFilterClause()` and `appendClauseToExpr()` quoting +
appending
- `test-filter-ux-e2e.js` (Playwright, wired into `deploy.yml`):
  - Navigate /packets → metadata exposed
  - Help popover opens with field reference, operators, examples
  - Autocomplete shows on focus, filters by prefix, accepts on Enter
  - Saved-filter dropdown lists defaults, click populates input
  - Right-click on TYPE cell → context menu → click appends clause
  - Save current expression persists to localStorage

TDD red commit (`bddf1c1`) — assertion failures only, no import errors.
Green commit (`0d3f381`) — all 19 unit tests pass.

## Browser validation

Spawned local server on :39966 against the e2e fixture DB and exercised
every UX surface via the openclaw browser tool. Confirmed:
- `window.PacketFilter.FIELDS.length === 27`, `suggest()` available
- `FilterUX.SavedFilters.list().length === 7` (defaults seeded)
- Help popover renders with `payload.name`, `contains`, `ADVERT` text
content
- Right-click on a `data-filter-field="type"` /
`data-filter-value="Response"` cell → context menu showed three options
→ clicking == populated the input with `type == "Response"` (and the
existing alias resolver matched it to `payload_type === 1`)
- Autocomplete on `pay` returned `payload_bytes`, `payload_hex`,
`payload.name`, `payload.lat`, `payload.lon`, `payload.text`

## Out of scope (deferred per the issue)

- Server-synced saved filters (cross-device)
- Visual filter builder
- Custom field expressions

## Acceptance criteria

- [x] Help icon (ⓘ) next to filter input opens documentation popover
- [x] Field reference table + operator reference + 6+ examples in
popover
- [x] Autocomplete dropdown on field names (top-level + `payload.*`)
- [x] Autocomplete dropdown on values for `type` / `route` operators
- [x] Right-click on packet cell → "Filter ==" / "Filter !=" / "Filter
contains"
- [x] Right-click context menu hides when clicking elsewhere / Esc
- [x] Saved-filters dropdown with at least 5 default examples (7
shipped)
- [x] User-saved filters persist in localStorage
- [x] Real-time match count next to filter input (already shipped
pre-PR; preserved)
- [ ] Improved error messages with token + position — partial: existing
parse errors already cite position; not a regression
- [x] No regression in existing filter behavior
(`test-packet-filter.js`: 69/69 pass)

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 01:50:12 -07:00
Kpa-clawbot e9c801b41a feat(live): filter incoming packets by IATA region (#1045) (#1080)
Closes #1045.

## What
Adds an optional region dropdown to the **Live** page that filters
incoming packets by observer IATA. When a user selects one or more
regions, only packets observed by repeaters in those regions render in
the feed/animation/audio.

## How
- New `liveRegionFilter` container in the live header toggles row,
initialised via the shared `RegionFilter` component in `dropdown` mode
(matches packets/nodes/observers pages).
- On page init, fetches `/api/observers` once and builds an `observer_id
→ IATA` map.
- `packetMatchesRegion(packets, obsMap, selected)` (pure helper, OR
across observations, case-insensitive) gates `renderPacketTree` next to
the existing favorite + node filters.
- Selection persists in localStorage via the existing `RegionFilter`
machinery — no per-page key needed.
- Listener cleanup hooked into the existing live-page teardown.

## TDD
- Red commit `55097ce`: `test-live-region-filter.js` asserts
`_livePacketMatchesRegion` exists and behaves correctly across 9 cases
(no-selection passthrough, single match, no-match, OR across
observations, multi-region selection, unknown observer, missing
observer_id, case-insensitivity, observer-map override). Fails with
`_livePacketMatchesRegion must be exposed` against master.
- Green commit `fdec7bf`: implements helper + UI wiring + CSS; test
passes.

Test wired into `.github/workflows/deploy.yml` JS unit-test step.

## Notes
- Server-side WS broadcast is unchanged — filtering is purely
client-side, as the issue requests ("something a user can activate
themselves, and not something that would be server wide").
- Pre-existing `test-live.js` / `test-live-dedup.js` failures on master
are not introduced or affected by this PR (verified by running both on
master HEAD).

---------

Co-authored-by: meshcore-bot <bot@openclaw.local>
2026-05-05 01:43:05 -07:00
Kpa-clawbot 3ab404b545 feat(node-battery): voltage trend chart + /api/nodes/{pubkey}/battery (#663) (#1082)
## Summary

Closes #663 (Phase 2 + 3 partial — time-series tracking + thresholds for
nodes that are also observers).

Adds a per-node battery voltage trend chart and
`/api/nodes/{pubkey}/battery` endpoint, sourced from the existing
`observer_metrics.battery_mv` samples populated by observer status
messages. No new ingest or schema changes — purely surfaces data we were
already collecting.

## Scope (TDD red→green)

**RED commit:** test(node-battery) — DB query, endpoint shape
(200/404/no-data), and config getters all asserted.
**GREEN commit:** feat(node-battery) — implementation only.

## Changes

### Backend
- `cmd/server/node_battery.go` (new):
- `DB.GetNodeBatteryHistory(pubkey, since)` — pulls `(timestamp,
battery_mv)` rows from `observer_metrics WHERE LOWER(observer_id) =
LOWER(public_key) AND battery_mv IS NOT NULL`. Case-insensitive join
tolerates historical pubkey casing variation (observers persist
uppercase, nodes lowercase in this DB).
- `Server.handleNodeBattery` — `GET /api/nodes/{pubkey}/battery?days=N`
(default 7, max 365). Returns `{public_key, days, samples[], latest_mv,
latest_ts, status, thresholds}`.
- `Config.LowBatteryMv()` / `CriticalBatteryMv()` — defaults 3300 / 3000
mV.
- `cmd/server/config.go` — `BatteryThresholds *BatteryThresholdsConfig`
field.
- `cmd/server/routes.go` — route registration alongside existing
`/health`, `/analytics`.

### Frontend
- `public/node-analytics.js` — new "Battery Voltage" chart card with
status badge (🔋 OK / ⚠️ Low / 🪫 Critical / No data). Renders dashed
threshold lines at `lowMv` and `criticalMv`. Empty-state message when no
samples in window.

### Config
- `config.example.json` — `batteryThresholds: { lowMv: 3300, criticalMv:
3000 }` with `_comment` per Config Documentation Rule.

## Status semantics

| latest_mv             | status     |
|-----------------------|------------|
| no samples in window  | `unknown`  |
| `>= lowMv`            | `ok`       |
| `< lowMv`, `>= critMv`| `low`      |
| `< criticalMv`        | `critical` |

## What this PR does NOT do (deferred)

The issue's full Phase 1 (writing decoded sensor advert telemetry into
`nodes.battery_mv` / `temperature_c` from server-side decoder) and Phase
4 (firmware/active polling for repeaters without observers) are out of
scope here. This PR delivers the requested Phase 2/3 surfacing for the
data path that already lands rows: `observer_metrics`. Repeaters that
are also observers (i.e. publish status to MQTT) will get a voltage
trend immediately; pure passive nodes won't until Phase 1 lands.

## Tests

- `TestGetNodeBatteryHistory_FromObserverMetrics` — case-insensitive
join, NULL skipping, ordering.
- `TestNodeBatteryEndpoint` — full happy path with thresholds + status.
- `TestNodeBatteryEndpoint_NoData` — 200 + status=unknown.
- `TestNodeBatteryEndpoint_404` — unknown node.
- `TestBatteryThresholds_ConfigOverride` — config getters + defaults.

`cd cmd/server && go test ./...` — green.

## Performance

Endpoint is per-pubkey (called once on analytics page open), indexed by
`(observer_id, timestamp)` PK on `observer_metrics`. No hot-path impact.

---------

Co-authored-by: bot <bot@corescope>
2026-05-05 01:41:00 -07:00
Kpa-clawbot aa3d26f314 fix(nav): stop nav bar from jumping when Live is selected (#1046) (#1078)
## Summary

The `🔴 Live` nav link could wrap onto two lines at certain viewport
widths once it became the `.active` link, which grew `.nav-link`'s
height and made the whole `.top-nav` "hop" the instant Live was selected
(issue #1046).

Adding `white-space: nowrap` to the base `.nav-link` rule keeps every
nav label on a single line at every breakpoint (default desktop + the
768–1279px and <768px responsive overrides), eliminating the jump.

## Changes

- `public/style.css` — `white-space: nowrap` on `.nav-link`.
- `test-e2e-playwright.js` — new assertion at viewport 1115px (the width
in the issue screenshots) that:
  - computed `white-space` prevents wrapping
  - the Live link renders on a single line in both states
  - `.top-nav` height does not change when `.active` is toggled

## TDD

- Red commit `ba906a5` — test added, fails because base `.nav-link` has
no `white-space` rule (default `normal`).
- Green commit `51906cb` — single-line CSS fix makes the test pass.

Fixes #1046

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-05 01:36:08 -07:00
Kpa-clawbot 5f6c5af0cf fix(observers): correct column headings after Last Packet (#1039) (#1075)
## Summary

Fixes #1039 — the Observers page table had 10 `<td>` cells per row but
only 9 `<th>` headings, so labels drifted starting at the Packet Health
badge cell. The headings `Packets`, `Packets/Hour`, `Clock Offset`,
`Uptime` were each one column to the left of their data.

## Changes

- `public/observers.js`: added missing `Packet Health` heading (over the
`packetBadge()` cell) and renamed the count column header from `Packets`
to `Total Packets` to disambiguate from `Packets/Hour`.

## TDD

- **Red commit** (`7cae61c`): `test-observers-headings.js` asserts
`<th>` count equals `<td>` count and verifies the expected header order.
Both assertions fail on master (9 vs 10; `Packets` vs `Packet
Health`/`Total Packets`).
- **Green commit** (`8ed7f7c`): heading row updated; both assertions
pass.

## Test

```
$ node test-observers-headings.js
── Observers table headings (#1039) ──
  ✓ thead column count equals tbody row column count
  ✓ expected headings present and ordered
2 passed, 0 failed
```

Wired into `test-all.sh`.

## Risk

Frontend-only, static template change. No data flow / perf impact.

Fixes #1039

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-05 01:35:09 -07:00
Kpa-clawbot f33801ecb4 feat(repeater): usefulness score — traffic axis (#672) (#1079)
## Summary

Implements the **Traffic axis** of the repeater usefulness score (#672).
Does NOT close #672 — Bridge, Coverage, and Redundancy axes are deferred
to follow-up PRs.

Adds `usefulness_score` (0..1) to repeater/room node API responses
representing what fraction of non-advert traffic passes through this
repeater as a relay hop.

## Why traffic-axis-first

The issue proposes a 4-axis composite (Bridge, Coverage, Traffic,
Redundancy). Bridge/Coverage/Redundancy require betweenness centrality
and neighbor graph infrastructure (#773 Neighbor Graph V2). Traffic axis
can ship independently using existing path-hop data.

## Remaining work for #672

- Bridge axis (betweenness centrality — depends on #773)
- Coverage axis (observer reach comparison)
- Redundancy axis (node-removal simulation — depends on #687)
- Composite score combining all 4 axes

Partial fix for #672.

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 01:34:08 -07:00
Kpa-clawbot d05e468598 feat(memlimit): GOMEMLIMIT support, derive from packetStore.maxMemoryMB (#836) (#1077)
## Summary

Implements **part 1** of #836 — `GOMEMLIMIT` support so the Go runtime
self-throttles GC under cgroup memory pressure instead of getting
SIGKILLed.

(Parts 2 & 3 — bounded cold-load batching + README ops docs — land in
follow-up PRs.)

## Behavior

On startup `cmd/server/main.go` now calls `applyMemoryLimit(maxMemoryMB,
envSet)`:

| Condition | Action | Log |
|---|---|---|
| `GOMEMLIMIT` env set | Honor the runtime's parse, do nothing |
`[memlimit] using GOMEMLIMIT from environment (...)` |
| env unset, `packetStore.maxMemoryMB > 0` | `debug.SetMemoryLimit(maxMB
* 1.5 MiB)` | `[memlimit] derived from packetStore.maxMemoryMB=512 → 768
MiB (1.5x headroom)` |
| env unset, `maxMemoryMB == 0` | No-op | `[memlimit] no soft memory
limit set ... recommend setting one to avoid container OOM-kill` |

The 1.5x headroom covers Go's NextGC trigger at ~2× live heap (per #836
heap profile: 680 MB live → 1.38 GB NextGC).

## Tests (TDD red→green visible in commit history)

- `TestApplyMemoryLimit_FromEnv` — env wins, function does not override
- `TestApplyMemoryLimit_DerivedFromMaxMemoryMB` — verifies bytes
computation + `debug.SetMemoryLimit` actually applied at runtime
- `TestApplyMemoryLimit_None` — no env, no config → reports `"none"`, no
side effect

Red commit: `7de3c62` (assertion failures, builds clean)
Green commit: `454516d`

## Config docs

`config.example.json` `packetStore._comment_gomemlimit` documents
env/derived/override behavior.

## Out of scope

- Cold-load transient bounding (item 2 in #836)
- README container-size table (item 3)
- QA §1.1 rewrite

Closes part 1 of #836.

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-05 01:33:23 -07:00
Kpa-clawbot d192330bdc feat(compare): asymmetric overlap stats for reference observer comparison (#671) (#1076)
## Summary

Adds asymmetric overlap percentages to the existing observer compare
page so it can be used as a **reference observer comparison** tool
(Uncle Lit's request, #671).

## What changed

`public/compare.js` (frontend only — no backend changes)

- New `computeOverlapStats(cmp)` helper that turns a
`comparePacketSets()` result into two-way coverage:
  - `aSeesOfB` — % of B's packets that A also saw
  - `bSeesOfA` — % of A's packets that B also saw
  - plus shared / onlyA / onlyB / totalA / totalB
- Two callout cards on the compare summary view:
  - `<A> saw N of <B>'s X packets` (Y%)
  - `<B> saw N of <A>'s X packets` (Y%)
- Existing "Only A / Only B / Both" tabs already identify unique
packets; that's the second half of the issue and is left intact.

## Operator workflow

Pick a known-good observer (LOS to key nodes) as the reference. Pair it
with a candidate. If the candidate's overlap with the reference is high
→ healthy. If low → investigate antenna, obstruction, or RF deafness.

## Out of scope (future work)

Issue lists several follow-on milestones — full Analytics sub-tab with
reference-vs-many table, SNR delta, geographic proximity filter,
server-side `/api/analytics/observer-comparison` endpoint. Those are
larger and tracked by the issue's M1-M4 milestones; this PR closes the
core ask (asymmetric overlap on the existing compare page) and leaves
the rest for follow-ups.

## Tests

`test-compare-overlap.js` — 6 unit tests via vm sandbox:

- exposes `computeOverlapStats` on `window`
- basic asymmetric scenario (8/10 vs 8/12)
- zero packets — no division by zero
- one observer empty — both percentages 0
- perfect overlap — 100% both ways
- disjoint observers — 0% both ways

TDD: red commit landed first with stub returning zeros (assertions
failed), green commit added the math.

Closes #671

---------

Co-authored-by: bot <bot@corescope.local>
2026-05-05 01:33:04 -07:00
Kpa-clawbot 2b01ecd051 ci: update go-server-coverage.json [skip ci] 2026-05-05 08:28:49 +00:00
Kpa-clawbot 34b418894a ci: update go-ingestor-coverage.json [skip ci] 2026-05-05 08:28:48 +00:00
Kpa-clawbot 1860cb4c54 ci: update frontend-tests.json [skip ci] 2026-05-05 08:28:47 +00:00
Kpa-clawbot 6a715e6af7 ci: update frontend-coverage.json [skip ci] 2026-05-05 08:28:46 +00:00
Kpa-clawbot fc16b4e069 ci: update e2e-tests.json [skip ci] 2026-05-05 08:28:45 +00:00
Kpa-clawbot 45f30fcadc feat(repeater): liveness detection — distinguish actively relaying from advert-only (#662) (#1073)
## Summary

Implements repeater liveness detection per #662 — distinguishes a
repeater that is **actively relaying traffic** from one that is **alive
but idle** (only sending its own adverts).

## Approach

The backend already maintains a `byPathHop` index keyed by lowercase
hop/pubkey for every transmission. Decode-window writes also key it by
**resolved pubkey** for relay hops. We just weren't surfacing it.

`GetRepeaterRelayInfo(pubkey, windowHours)`:
- Reads `byPathHop[pubkey]`.
- Skips packets whose `payload_type == 4` (advert) — a self-advert
proves liveness, not relaying.
- Returns the most recent `FirstSeen` as `lastRelayed`, plus
`relayActive` (within window) and the `windowHours` actually used.

## Three states (per issue)

| State | Indicator | Condition |
|---|---|---|
| 🟢 Relaying | green | `last_relayed` within `relayActiveHours` |
| 🟡 Alive (idle) | yellow | repeater is in the DB but
`relay_active=false` (no recent path-hop appearance, or none ever) |
|  Stale | existing | falls out of the existing `getNodeStatus` logic |

## API

- `GET /api/nodes` — repeater/room rows now include `last_relayed`
(omitted if never observed) and `relay_active`.
- `GET /api/nodes/{pubkey}` — same fields plus `relay_window_hours`.

## Config

New optional field under `healthThresholds`:

```json
"healthThresholds": {
  ...,
  "relayActiveHours": 24
}
```

Default 24h. Documented in `config.example.json`.

## Frontend

Node detail page gains a **Last Relayed** row for repeaters/rooms with
the 🟢/🟡 state badge. Tooltip explains the distinction from "Last Heard".

## TDD

- **Red commit** `4445f91`: `repeater_liveness_test.go` + stub
`GetRepeaterRelayInfo` returning zero. Active and Stale tests fail on
assertion (LastRelayed empty / mismatched). Idle and IgnoresAdverts
already match the desired behavior under the stub. Compiles, runs, fails
on assertions — not on imports.
- **Green commit** `5fcfb57`: Implementation. All four tests pass. Full
`cmd/server` suite green (~22s).

## Performance

`O(N)` over `byPathHop[pubkey]` per call. The index is bounded by store
eviction; a single repeater has at most a few hundred entries on real
data. The `/api/nodes` loop adds one map read + scan per repeater row —
negligible against the existing enrichment work.

## Limitations (per issue body)

1. Observer coverage gaps — if no observer hears a repeater's relay,
it'll show as idle even when actively relaying. This is inherent to
passive observation.
2. Low-traffic networks — a repeater in a quiet area legitimately shows
idle. The 🟡 indicator copy makes that explicit ("alive (idle)").
3. Hash collisions are mitigated by the existing `resolveWithContext`
path before pubkeys land in `byPathHop`.

Fixes #662

---------

Co-authored-by: clawbot <bot@corescope.local>
2026-05-05 01:17:52 -07:00
Kpa-clawbot 8b924cd217 feat(ui): encode view & filter state in URL hash (#749) (#1072)
## Summary

Encodes view + filter state in the URL hash so deep links restore the
exact page state (issue #749).

## Changes

New shared helper `public/url-state.js` exposing `URLState`:
- `parseSort('col:asc')` → `{column, direction}` (defaults to `desc`)
- `serializeSort('col', 'desc')` → `'col'` (omits default direction)
- `parseHash('#/nodes/abc?tab=x')` → `{route: 'nodes/abc', params:
{tab:'x'}}`
- `buildHash(route, params)` and `updateHashParams(updates,
currentHash)` for round-tripping while preserving subpaths.

Wired into:

- **packets.js** — sort column/direction now in
`#/packets?sort=col[:asc]`, restored on init (overrides localStorage).
Subpath `#/packets/<hash>` preserved.
- **nodes.js** — sort encoded as `#/nodes?sort=col[:asc]`, restored on
init. Subpath `#/nodes/<pubkey>` preserved.
- **analytics.js** — both selected tab (`tab=topology`) AND time-window
picker value (`window=7d`) now round-trip via URL. Subview keys used by
rf-health (`range/observer/from/to`) cleared when switching tabs to keep
URLs clean.

Existing deep links (`#/nodes/<pubkey>`, `#/packets/<hash>`,
`?filter=…`, `?node=…`, `?observer=…`, `?channel=…`, `?timeWindow=…`,
`?region=…`) all keep working — additive change only.

## Tests

TDD red→green:
- Red: `5e1482e` (stub throws "not implemented"; 18/18 tests fail on
assertions)
- Green: `512940e` (helper implemented; 18/18 pass)

Wired `test-url-state.js` into `test-all.sh`.

Fixes #749

---------

Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-05-05 01:17:22 -07:00
Kpa-clawbot 83881e6b71 fix(#688): auto-discover hashtag channels from message text (#1071)
## Summary

Auto-discovers previously-unknown hashtag channels by scanning decoded
channel message text for `#name` mentions and surfacing them via
`GetChannels`.

Workflow (per the issue):
1. New channel message arrives on a known channel
2. Decoded text is scanned for `#hashtag` mentions
3. Any mention that doesn't match an existing channel is surfaced as a
discovered channel (`discovered: true`, `messageCount: 0`)
4. Future traffic on that channel will populate the entry once it has
its own packets

## Changes

- `cmd/server/discovered_channels.go` — new file.
`extractHashtagsFromText` parses `#name` mentions from free text,
deduped, order-preserving. Trailing punctuation is excluded by the
character class.
- `cmd/server/store.go` — `GetChannels` now scans CHAN packet text for
hashtags after building the primary channel map, and appends any unseen
hashtag mentions as discovered entries.
- `cmd/server/discovered_channels_test.go` — new tests covering parser
edge cases (single, multi, dedup, punctuation, none, bare `#`) and
end-to-end discovery via `GetChannels`.

## TDD

- Red: `34f1817` — stub returns `nil`, both new tests fail on assertion
(verified).
- Green: `d27b3ed` — real implementation, full `cmd/server` test suite
passes (21.7s).

## Notes

- Discovered channels carry `messageCount: 0` and `lastActivity` set to
the most recent mention's `firstSeen`, so they sort naturally alongside
real channels.
- Names are matched against existing entries by both `#name` and bare
`name` so a channel that already has decoded traffic isn't
double-listed.
- The existing `channelsCache` (15s) covers the new code path; no
separate invalidation needed since the source data (`byPayloadType[5]`)
drives both maps.

Fixes #688

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-05 01:16:57 -07:00
Kpa-clawbot 417b460fa0 feat(css): fluid scaffolding — clamp() spacing/type/container tokens (#1054) (#1066)
## Summary

Lands the **fluid CSS foundation** for the responsive scaffolding effort
(parent #1050). Pure additive change to the top of `public/style.css` —
no component CSS touched.

## What changed

### New tokens in `:root`
- **Spacing scale** — `--space-xs … --space-2xl` via `clamp()`. 1440px
targets match the prior hardcoded `4 / 8 / 16 / 24 / 32 / 48` px values
to within ~1px.
- **Type scale** — `--fs-sm … --fs-2xl` via `clamp(min, vw-based, max)`.
Floors keep text readable at 768px; caps prevent runaway growth at
2560px+.
- **Radii** — `--radius-sm/md/lg` via `clamp()`.
- **Container layout** — `--gutter` (`clamp()`) and `--content-max`
(`min(100% - 2*gutter, 1600px)`) for fluid horizontal layout without
media queries.

### Base consumption
- `html, body` now sets `font-size: var(--fs-md)`.

### Parallel-work safety
- Added `FLUID SCAFFOLDING` section header at the top.
- Added `COMPONENT STYLES` section header marking where the rest of the
file (nav, tables, charts, map, packets, analytics …) begins. Sibling
tasks 1050-3..6 / 1052-* edit inside that region and won't conflict with
this PR.

## TDD

- **Red:** `2d6f90a` — `test-fluid-scaffolding.js` asserts the new
tokens exist with `clamp()`/`min()`, that `html, body` consumes
`--fs-md`, and that the section marker is present. Fails on assertions
(15 failed, 0 passed).
- **Green:** `7b4d59b` — implementation in `public/style.css`. All 15
assertions pass.

## Acceptance criteria

- [x] Fluid spacing scale `--space-xs..--space-2xl` via `clamp()`
- [x] Fluid type scale `--fs-sm..--fs-2xl` via `clamp()`
- [x] Replace base body font-size with the new token
- [x] Container layout vars `--content-max`, `--gutter` via
`min()`/`clamp()`
- [x] No component CSS edits (only `:root`, `html`, `body`)
- [x] No visual regression at 1440px (token targets numerically match
prior px values)

## Notes for reviewers

- Pre-existing `test-frontend-helpers.js` failure on master is unrelated
(`nodesContainer.setAttribute is not a function`) and not introduced
here.
- `--content-max` uses `min(100% - 2*gutter, 1600px)` — the `100% - …`
arm wins on small viewports and guarantees a gutter always remains.

Fixes #1054

---------

Co-authored-by: clawbot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 01:14:39 -07:00
Kpa-clawbot 78dabd5bda feat(filter): timestamp predicates (after/before/between/age) — #289 (#1070)
Fixes #289.

Adds Wireshark-style timestamp predicates to the client-side packet
filter
engine (`public/packet-filter.js`).

## New syntax

| Form | Meaning |
| --- | --- |
| `time after "2024-01-01"` | packets with timestamp strictly after the
given datetime |
| `time before "2024-12-31T23:59:59Z"` | packets strictly before |
| `time between "2024-01-01" "2024-02-01"` | inclusive range
(order-insensitive) |
| `age < 1h` | packets newer than 1 hour |
| `age > 24h` | packets older than 24 hours |
| `age < 7d && type == ADVERT` | composes with existing predicates |

Duration units: `s` / `m` / `h` / `d` / `w`. Datetime values use
`Date.parse`
(ISO 8601 + bare `YYYY-MM-DD`). `time` is also accepted as `timestamp`.

## Implementation

- `OP_WORDS` extended with `after`, `before`, `between`.
- New `TK.DURATION` token: lexer recognises `<number><unit>` and
pre-converts
  to seconds at lex time (no per-evaluation parsing cost).
- `between` is a two-value op handled in `parseComparison`.
- Field resolver:
- `time` / `timestamp` → epoch-ms; falls back to `first_seen` then
`latest`
    so grouped rows from `/api/packets?groupByHash=true` work.
  - `age` → seconds since `Date.now()`.
- Parse-time validation rejects invalid datetimes and unknown duration
units
(silent-fail would have been a footgun — every packet would just
disappear).
- Null/missing timestamps → predicate returns `false`, consistent with
the
  existing null-field behaviour for `snr` / `rssi`.

## Open questions from the issue

- **UTC vs local**: defaults to whatever `Date.parse` returns. Bare
dates like
`"2024-01-01"` are interpreted as UTC midnight by the spec. Tying this
to
  the #286 timestamp display setting can be a follow-up.
- **URL query string**: out of scope for this PR.

## Tests

- New `test-packet-filter-time.js`: 20 tests covering
`after`/`before`/`between`,
ISO datetimes, all duration units, composition with `&&`, null-timestamp
safety,
  invalid-datetime / invalid-unit errors, and `first_seen` fallback.
- Wired into `.github/workflows/deploy.yml` JS unit-test step.
- Existing `test-packet-filter.js` (69 tests) and inline self-tests
still pass.

## Commits

- Red: `5ccfad3` — failing tests + lexer-only stub (compiles, asserts
fail)
- Green: `976d50f` — implementation

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-05 01:13:48 -07:00
Kpa-clawbot e2050f8ec8 fix(docker): default BUILDPLATFORM so plain docker build works (fixes #884) (#1069)
## Summary

Plain `docker build .` (no buildx) fails immediately:

```
Step 1/45 : FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS builder
failed to parse platform : "" is an invalid component of "": platform specifier
component must match "^[A-Za-z0-9_-]+$"
```

`$BUILDPLATFORM` is only auto-populated by buildx; under plain
BuildKit/`docker build` it's empty.

## Fix

Add `ARG BUILDPLATFORM=linux/amd64` before the `FROM` so the variable
always resolves.

## Multi-arch preserved

`docker buildx build --platform=linux/arm64,linux/amd64 .` still
overrides `BUILDPLATFORM` at invocation time — the ARG default only
applies when the caller doesn't set one. The existing CI multi-arch
workflow is unaffected.

Fixes #884

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 01:12:40 -07:00
Kpa-clawbot cbfd159f8e feat(ws): pull-to-reconnect on touch devices (Fixes #1063) (#1068)
## Summary

Reframes the browser's native pull-to-refresh on touch devices as a
**WebSocket reconnect** instead of a full page reload. On data pages
(Packets, Nodes, Channels — and globally, since the WS is shared) a
downward pull at `scrollTop=0` cycles the WS, which is what users
actually want when they reach for that gesture.

Fixes #1063.

## Behavior

- **Touch-only**: gated by `('ontouchstart' in window) ||
navigator.maxTouchPoints > 0`. Desktop is untouched.
- **Scroll-safe**: every handler re-checks `scrollTop > 0` and bails out
— never hijacks normal scroll.
- **Visual affordance**: a fixed chip slides down from the top with a
rotating ⟳ icon; opacity and rotation scale with pull progress (0 →
`PULL_THRESHOLD_PX = 80px`).
- **`preventDefault` is conservative**: only after `dy > 16px` and only
on `touchmove`, so taps and short swipes are not affected.
- **Result feedback**: a brief toast — green `Connected ✓` if WS was
already OPEN, `Reconnecting…` otherwise. Both auto-dismiss after ~1.8s.
- **Reconnect path**: closes the existing WS so the existing `onclose`
auto-reconnect fires immediately; an explicit `connectWS()` is also
called as a safety net when `ws` is null.
- **No regression** to existing WS auto-reconnect — same `connectWS` /
`setTimeout(connectWS, 3000)` chain, just kicked manually.

## TDD

- **Red commit** `f90f5e9` — adds `test-pull-to-reconnect.js` with 6
assertions; stub functions added to `app.js` so tests reach assertion
failures (not ReferenceError). 3/6 fail on behavior.
- **Green commit** `53adbd9` — full implementation; 6/6 pass.

## Files

- `public/app.js` — `pullReconnect()`, `setupPullToReconnect()`,
`_ensurePullIndicator()`, `_showPullToast()`, `_isTouchDevice()`. Wired
into `DOMContentLoaded` next to `connectWS()`. Touched the WS section
only.
- `test-pull-to-reconnect.js` — vm sandbox suite covering exposure,
WS-close, listener wiring, threshold trigger, scroll-position gate.

## Acceptance criteria check

-  Pull-down at scroll-top triggers WS reconnect + data refetch
(debounced cache invalidate fires on next WS message)
-  Visible affordance during pull (rotating chip)
-  Resolves on success (toast), shows status toast on disconnect path
-  Disabled when not at `scrollTop=0`
-  No regression to existing WS auto-reconnect

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
2026-05-05 01:11:59 -07:00
Kpa-clawbot eaf14a61f5 fix(css): 48px touch targets, :active states, hover→tap (#1060) (#1067)
## Summary

Fixes #1060 — free-win CSS pass for touch usability.

- All major interactive controls (`.btn`, `.btn-icon`, `.nav-btn`,
`.nav-link`, `.ch-icon-btn`, `.ch-remove-btn`, `.ch-share-btn`,
`.ch-gear-btn`, `.panel-close-btn`, `.mc-jump-btn`, `button.ch-item`)
now declare `min-height: 48px` / `min-width: 48px`. Hit-area grows;
visual padding/icon size unchanged on desktop because the rules use
`inline-flex` centering.
- Added visible `:active` feedback (background shift + `transform:
scale(0.92–0.97)` + opacity) on every button class — touch devices have
no hover, so `:active` is the only press signal.
- Hover-only `.sort-help` tooltip rule is now wrapped in `@media (hover:
hover)`; added a CSS-only `:focus` / `:focus-within` tap-to-reveal path
with a visible focus ring so the same content is reachable on touch (and
via keyboard).
- All changes scoped to the `=== Touch Targets ===` section. No other
CSS section modified, no JS touched, no markup edits.

## Acceptance criteria

- [x] All interactive controls reach 48×48 CSS-px touch target (verified
by `test-touch-targets.js`).
- [x] Every button has a visible `:active` state (no hover-only
feedback).
- [x] Hover tooltip rule is gated behind `@media (hover: hover)`, with
`:focus-within` tap-to-reveal fallback.
- [x] Desktop visuals preserved (padding-based, not visual-size-based).

## TDD

- Red commit `327473b` — `test-touch-targets.js` asserts every required
selector/property; it compiles and fails on assertion against pre-change
CSS.
- Green commit `e319a8f` — Touch Targets section rewrite; test passes.

```
$ node test-touch-targets.js
test-touch-targets.js: OK
```

Fixes #1060

---------

Co-authored-by: bot <bot@corescope>
2026-05-05 01:11:08 -07:00
Kpa-clawbot b71c290783 ci: update go-server-coverage.json [skip ci] 2026-05-05 07:24:14 +00:00
Kpa-clawbot d7fbd4755e ci: update go-ingestor-coverage.json [skip ci] 2026-05-05 07:24:13 +00:00
Kpa-clawbot 13b6eecc82 ci: update frontend-tests.json [skip ci] 2026-05-05 07:24:12 +00:00
Kpa-clawbot b18ebe1a26 ci: update frontend-coverage.json [skip ci] 2026-05-05 07:24:11 +00:00
Kpa-clawbot 9aa94166df ci: update e2e-tests.json [skip ci] 2026-05-05 07:24:09 +00:00
Kpa-clawbot 38703c75e6 fix(e2e): make Nodes WS auto-update test deterministic (#1051)
## Problem

The Playwright E2E test `Nodes page has WebSocket auto-update`
(`test-e2e-playwright.js:259`) has flaked 7+ times this session,
blocking CI. Failure mode:

```
page.waitForSelector: Timeout 10000ms exceeded
waiting for locator('table tbody tr') to be visible
```

## Root cause

The test navigates to `/#/nodes`, waits for `[data-loaded="true"]`
(passes), then waits for `table tbody tr` (10s, fails intermittently).
Rows in this code path only appear via WebSocket push — which is
timing-dependent in CI (no guaranteed live MQTT feed within the 10s
window).

## Fix

Drop the `table tbody tr` wait. This test's contract is **WS
infrastructure existence**, not data delivery:

- `#liveDot` element present
- `onWS` / `offWS` globals defined
- Best-effort connected-state check (already tolerant of failure)

All those assertions are deterministic post-DOMContentLoaded. Initial
table population is already covered by the preceding `Nodes page loads
with data` test.

## Coverage

No coverage loss — the WS infra assertions are unchanged. Only the
timing-dependent row-presence wait is removed.

## TDD note

This is a test-fix, not a behavior change. The "red" is the existing
intermittent CI failure; the "green" is this commit removing the flaky
wait. No production code touched.

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 00:11:18 -07:00
Kpa-clawbot f9cd43f06f fix(analytics): integrate channels list with PSK decrypt UX + add link from Channels page (#1042)
## What

Integrates the Analytics → Channels section with the PSK decrypt UX (PRs
#1021–#1040). Replaces nonsense `chNNN` placeholders with useful display
names and groups the table the same way the Channels sidebar does.

## Before
- Encrypted channels showed raw `ch185`, `ch64`, `ch?` placeholders.
- Locally-decrypted PSK channels (with stored keys + labels) were not
surfaced — every encrypted row looked identical and useless.
- Single flat list, sorted by last activity by default.

## After
- **My Channels** 🔑 — any analytics row whose hash byte matches a stored
PSK key (via `ChannelDecrypt.getStoredKeys()` + `computeChannelHash`).
Display name uses the user's label if set, otherwise the key name.
- **Network** 📻 — known cleartext channels (server-provided names) and
rainbow-table-decoded encrypted channels.
- **Encrypted** 🔒 — unknown encrypted, rendered as `🔒 Encrypted (0xNN)`
instead of `chNNN`.
- Within each group: messages descending (most active first).
- New `📊 Channel Analytics →` link in the Channels page sidebar header →
`#/analytics`.

## How
- Pure `decorateAnalyticsChannels(channels, hashByteToKeyName, labels)`
— testable in isolation, sets `displayName` + `group` per row.
- `buildHashKeyMap()` — async helper that resolves stored PSK keys to
their channel hash bytes via `computeChannelHash`. Used at render time;
first paint uses an empty map (best-effort) and re-renders once keys
resolve. Graceful fallback when `ChannelDecrypt` is missing or there are
no stored keys.
- `channelTbodyHtml` gains an `opts.grouped` flag — opt-in so the
existing flat sort still works for any other caller.
- The analytics API endpoint is **unchanged** — this is purely frontend
rendering.

## Tests
`test-analytics-channels-integration.js` — 19 assertions covering
decoration, grouping, sort order, and the channels-page link. Added to
`test-all.sh`.

Red commit: `5081b12` (12 assertion failures + stub).  
Green commit: `6be16d9` (all 19 pass).

---------

Co-authored-by: bot <bot@corescope.local>
Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 00:05:09 -07:00
Kpa-clawbot 26a914274f ci: update go-server-coverage.json [skip ci] 2026-05-05 06:52:56 +00:00
Kpa-clawbot e4f358f562 ci: update go-ingestor-coverage.json [skip ci] 2026-05-05 06:52:55 +00:00
Kpa-clawbot 5ff9d4f31d ci: update frontend-tests.json [skip ci] 2026-05-05 06:52:54 +00:00
Kpa-clawbot db75dbee44 ci: update frontend-coverage.json [skip ci] 2026-05-05 06:52:52 +00:00
Kpa-clawbot 16e1ff9e6c ci: update e2e-tests.json [skip ci] 2026-05-05 06:52:51 +00:00
Kpa-clawbot d144764d38 fix(analytics): multiByteCapability missing under region filter → all rows 'unknown' (#1049)
## Bug

`https://meshcore.meshat.se/#/analytics`:

- Unfiltered → 0 adopter rows show "unknown" (correct).
- Region filter `JKG` → 14 rows show "unknown" (wrong — same nodes, all
confirmed when unfiltered).

Multi-byte capability is a property of the NODE, derived from its own
adverts (the full pubkey is in the advert payload, no prefix collision
risk). The observing region should only control which nodes appear in
the analytics list — it must not change a node's cap evidence.

## Root cause

`PacketStore.GetAnalyticsHashSizes(region)` only attached
`result["multiByteCapability"]` when `region == ""`. Under any region
filter the field was absent. The frontend (`public/analytics.js:1011`)
does `data.multiByteCapability || []`, so every adopter row falls
through the merge with no cap status and renders as "unknown".

## Fix

Always populate `multiByteCapability`. When a region filter is active,
source the global adopter hash-size set from a no-region compute pass so
out-of-region observers' adverts still count as evidence.

## TDD

Red commit (`0968137`): adds
`cmd/server/multibyte_region_filter_test.go`, asserts that
`GetAnalyticsHashSizes("JKG")` returns a populated `multiByteCapability`
with Node A as `confirmed`. Fails on the assertion (field missing)
before the fix.

Green commit (`6616730`): always compute capability against the global
advert dataset.

## Files changed

- `cmd/server/store.go` — `GetAnalyticsHashSizes`: drop the `region ==
""` gate, always populate `multiByteCapability`.
- `cmd/server/multibyte_region_filter_test.go` — new red→green test.

## Verification

```
go test ./... -count=1   # all server tests pass (21s)
```

---------

Co-authored-by: clawbot <bot@corescope.local>
2026-05-05 06:42:58 +00:00
Kpa-clawbot c4fac7fe2e ci: update go-server-coverage.json [skip ci] 2026-05-05 06:29:28 +00:00
Kpa-clawbot 13587584d2 ci: update go-ingestor-coverage.json [skip ci] 2026-05-05 06:29:27 +00:00
Kpa-clawbot 68cd9d77c6 ci: update frontend-tests.json [skip ci] 2026-05-05 06:29:26 +00:00
Kpa-clawbot f2ee74c8f3 ci: update frontend-coverage.json [skip ci] 2026-05-05 06:29:24 +00:00
Kpa-clawbot f676c146ae ci: update e2e-tests.json [skip ci] 2026-05-05 06:29:23 +00:00
Kpa-clawbot 227f375b4a test(ingestor): regression test for observer metadata persistence (#1044) (#1047)
Adds end-to-end test proving that `extractObserverMeta` +
`UpsertObserver` correctly stores model, firmware, battery_mv,
noise_floor, uptime_secs from a real MQTT status payload.

Test passes — confirms the code path works. #1044 was caused by upstream
observers not including metadata fields in their status payloads (older
`meshcoretomqtt` client versions), not a code bug.

Closes #1044

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 06:18:47 +00:00
Kpa-clawbot 2e959145aa ci: update go-server-coverage.json [skip ci] 2026-05-05 04:06:12 +00:00
Kpa-clawbot 72dd377ba1 ci: update go-ingestor-coverage.json [skip ci] 2026-05-05 04:06:12 +00:00
Kpa-clawbot 8a536c5899 ci: update frontend-tests.json [skip ci] 2026-05-05 04:06:11 +00:00
Kpa-clawbot f3a7d0d435 ci: update frontend-coverage.json [skip ci] 2026-05-05 04:06:10 +00:00
Kpa-clawbot ccc7cf5a77 ci: update e2e-tests.json [skip ci] 2026-05-05 04:06:09 +00:00
Kpa-clawbot 67da696a42 fix(channels): hide raw psk:* in header, label share button, red delete button (#1041)
## Channel UX round 2 (follow-up to #1040)

Three UX issues reported after #1040 landed:

### 1. Header shows raw `psk:372a9c93` for PSK channels
The selected-channel title rendered `ch.name` directly, which for
user-added PSK channels is the synthetic `psk:<hex8>` string. Users see
opaque key fragments where they expected the friendly name they typed.

**Fix:** new `channelDisplayName(ch)` helper. Returns `ch.userLabel`
when set, falls back to `"Private Channel"` for any `psk:*` name, then
to the original name, then to `Channel <hash>`. Used in both
`selectChannel` (header) and `renderChannelRow` (sidebar).

### 2. Share button `⤴` is unrecognizable
Up-arrow glyph carried no meaning — users didn't know it opened the
QR/key reshare modal.

**Fix:** swap `⤴` for `📤 Share` text label. Same hook, same handler.

### 3. ✕ delete button is a subtle span, not a destructive button
Looked like decorative text, not a real action.

**Fix:** `.ch-remove-btn` gets `background: var(--statusRed, #b54a4a)`,
`color: white`, `border-radius: 4px`, `padding: 4px 8px`, `font-weight:
bold`. Now reads as a destructive action.

### TDD
- Red commit `2d05bbf`: 9 failing assertions (helper missing, ⤴ still
present, CSS rules absent), test compiles + runs to assertion failure.
- Green commit `938f3fc`: all 12 assertions pass. Existing
`test-channel-ux-followup.js` still 28/28.

### Files
- `public/channels.js` — `channelDisplayName` helper, header + row
rendering, share button label
- `public/style.css` — `.ch-remove-btn` destructive styling
- `test-channel-ux-round2.js` — new test (helper behavior + source/CSS
assertions)

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-04 20:56:01 -07:00
Kpa-clawbot 5829d2328d ci: update go-server-coverage.json [skip ci] 2026-05-05 03:19:29 +00:00
Kpa-clawbot df60f324e9 ci: update go-ingestor-coverage.json [skip ci] 2026-05-05 03:19:28 +00:00
Kpa-clawbot 0aeb33f757 ci: update frontend-tests.json [skip ci] 2026-05-05 03:19:26 +00:00
Kpa-clawbot e334f8611e ci: update frontend-coverage.json [skip ci] 2026-05-05 03:19:25 +00:00
Kpa-clawbot 32ba77eaf8 ci: update e2e-tests.json [skip ci] 2026-05-05 03:19:24 +00:00
Kpa-clawbot 724a96f35b ci: update go-server-coverage.json [skip ci] 2026-05-05 03:12:40 +00:00
Kpa-clawbot 849bf1c335 ci: update go-ingestor-coverage.json [skip ci] 2026-05-05 03:12:39 +00:00
Kpa-clawbot a0b791254c ci: update frontend-tests.json [skip ci] 2026-05-05 03:12:38 +00:00
Kpa-clawbot 62a2a13251 ci: update frontend-coverage.json [skip ci] 2026-05-05 03:12:38 +00:00
Kpa-clawbot c94ba05c01 ci: update e2e-tests.json [skip ci] 2026-05-05 03:12:37 +00:00
Kpa-clawbot c00b585ee5 fix(channels): UX follow-ups to #1037 (touch target, '0 messages', share, locality, #meshcore) (#1040)
## Summary

Seven UX follow-ups to the channel modal/sidebar redesign in #1037.

## Fixes

1. **✕ touch target** — was 13px font + 0×4 padding, far below WCAG
2.5.5 / Apple HIG 44×44px. Bumped `.ch-remove-btn` to a 44×44 hit area
without disturbing desktop layout.
2. **"0 messages" preview** — user-added (PSK) channel rows showed `0
messages` even when dozens were decrypted. `messageCount` only tracks
server-known activity, not PSK decrypts. Drop the misleading fallback:
when no last message is known and the count is zero/absent, render
nothing.
3. **Privacy footer wording** — old copy "Clear browser data to remove
stored keys" was misleading after #1037 added per-channel ✕. Reworded to
point users at the ✕ button.
4. **Reshare affordance** — each user-added row now exposes a `⤴` Share
button that re-opens the QR + key for that channel via
`ChannelQR.generate` (with a plain-hex + `meshcore://channel/add?...`
URL fallback when the QR vendor lib isn't loaded). Reuses the Add
Channel modal; cleared on close.
5. **Drop "(your key)" suffix** from the row preview. The 🔑 badge
already conveys ownership; the suffix was noise. The key hex itself is
now only revealed on explicit Share, not in the sidebar.
6. **Make browser-local nature obvious** — the prior framing made
local-only sound like a feature when it's actually a constraint users
need to plan around. Adds:
- Prominent `.ch-modal-callout` in the Add Channel modal: *"Channels are
saved to **THIS browser only**. They won't appear on other devices or
browsers, and clearing browser data will remove them."*
   - `🖥️ (this browser)` marker in the **My Channels** section header
- Remove-confirm prompt now explicitly says *"permanently remove the key
from this browser"*
7. **#meshcore, not #LongFast** — `#LongFast` is Meshtastic's default
channel name. The meshcore network's analogous default is `#meshcore`.
Updated placeholder + case-sensitivity example in the modal.

## TDD

- Red commit `878d872` — failing assertions for fixes 1–6.
- Green commit `444cf81` — implementation.
- Red commit `6cab596` — failing assertions for fix 7.
- Green commit `9adc1a3` — `#meshcore` swap.

`test-channel-ux-followup.js` (18 assertions) passes. Existing
`test-channel-modal-ux.js` (33) and `test-channel-sidebar-layout.js` (8)
remain green.

## Files
- `public/channels.js` — row template, share handler, modal
callout/footer, sidebar header, confirm copy, placeholder swap
- `public/style.css` — `.ch-remove-btn` / `.ch-share-btn` 44×44,
`.ch-modal-callout`, `.ch-section-locality`
- `test-channel-ux-followup.js` — new test file

---------

Co-authored-by: clawbot <clawbot@local>
2026-05-04 19:57:53 -07:00
Kpa-clawbot e2bd9a8fa2 ci: update go-server-coverage.json [skip ci] 2026-05-05 02:07:56 +00:00
Kpa-clawbot 1f3c8130ef ci: update go-ingestor-coverage.json [skip ci] 2026-05-05 02:07:55 +00:00
Kpa-clawbot e5606058c1 ci: update frontend-tests.json [skip ci] 2026-05-05 02:07:54 +00:00
Kpa-clawbot 47b4021346 ci: update frontend-coverage.json [skip ci] 2026-05-05 02:07:53 +00:00
Kpa-clawbot c93c008867 ci: update e2e-tests.json [skip ci] 2026-05-05 02:07:52 +00:00
Kpa-clawbot cea2c70d12 feat(#1034): channel UX redesign PR1 — Add Channel modal + sectioned sidebar (#1037)
## Summary

PR 1 of 3 for #1034 — channel UX redesign. Replaces the cramped inline
"type a name or 32-hex blob" form with a clear modal dialog, and
reorganizes the sidebar into three labeled sections.

**Scope of this PR:** Modal UI + sectioned sidebar. QR generation/scan
is deferred to PR #2 (placeholders are wired and ready).
`channel-decrypt.js` crypto is untouched.

## What changed

### New modal: `[+ Add Channel]`

Triggered by the new sidebar button. Three sections:

1. **Generate PSK Channel** — name + `[Generate & Show QR]` →
`crypto.getRandomValues(16)` → hex → `ChannelDecrypt.storeKey`. QR
rendering ships in PR #2; for now `#qr-output` surfaces the hex key as
text.
2. **Add Private Channel (PSK)** — 32-hex input (regex-validated),
optional display name, `[Add]`. `[📷 Scan QR]` placeholder is present but
`disabled` (PR #2 wires it).
3. **Monitor Hashtag Channel** — non-editable `#` prefix + free text +
case-sensitivity warning + `[Monitor]`. Reuses
`ChannelDecrypt.deriveKey`.

Privacy footer: _"🔒 Keys stay in your browser. CoreScope is a passive
observer..."_

Close ✕, backdrop click, and Escape all dismiss.

### Sectioned sidebar

`renderChannelList()` rewritten to render three sections:

- **My Channels** — `userAdded` channels. ✕ always visible. Last sender
+ relative time.
- **Network** — server-known cleartext channels.
- **Encrypted (N)** — collapsed by default (toggle persists in
`localStorage`). Shows hash byte + packet count.

The legacy "🔒 No key" checkbox and `#chShowEncrypted` toggle are removed
entirely. Encrypted channels are always fetched; the renderer groups
them.

## Tests

- **Unit** — `test-channel-modal-ux.js` (33 assertions): added to
`test-all.sh`. Covers sidebar button, modal markup, three sections, QR
placeholders, privacy footer, sectioned sidebar, modal handlers (incl.
`crypto.getRandomValues(16)`).
- **E2E** — `test-channel-modal-e2e.js` (Playwright, 14 steps). Covers
modal open/close, section rendering, invalid-hex error, valid-hex
storage, encrypted-section toggle. Run with:
  ```
CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:38201
node test-channel-modal-e2e.js
  ```
- `test-channel-psk-ux.js` — updated to reference `#chPskName` (was
`#chKeyLabelInput`).

### Red→green proof

- Red commit (`7ee421b`): test added with 31 expected assertion
failures, no source change.
- Green commit (`897be8f`): implementation lands, test passes 33/33.

## Browser-validated

Built `cmd/server/`, ran against `test-fixtures/e2e-fixture.db`,
exercised modal open → invalid hex → valid hex → key persisted → modal
closes → sectioned sidebar renders + Encrypted toggle expands. All 14
E2E steps pass.

## What's NOT in this PR

- QR code rendering (PR #2)
- Camera/QR scanning (PR #2)
- Migration of legacy localStorage format (PR #3, if needed — current
key format is unchanged)
- `channel-decrypt.js` changes (none — UI-only PR)

## Acceptance criteria from #1034

- [x] Modal opens on `[+ Add Channel]` click
- [x] Three sections clearly separated with labels
- [x] Add PSK: accepts 32-hex (QR scan = PR #2)
- [x] Monitor Hashtag: derives key, case-sensitivity warning shown
- [x] Privacy footer present
- [x] Sidebar: three sections (My Channels / Network / Encrypted)
- [x] ✕ button visible and functional on My Channels entries
- [x] "No key" checkbox removed
- [ ] Generate PSK QR display — text fallback only; QR is PR #2
- [ ] Old stored keys migrate seamlessly — no migration needed (storage
format unchanged)

Refs #1034

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-04 18:40:46 -07:00
Kpa-clawbot 71f82d5d25 ci: update go-server-coverage.json [skip ci] 2026-05-05 01:39:54 +00:00
Kpa-clawbot 81430cf4c4 ci: update go-ingestor-coverage.json [skip ci] 2026-05-05 01:39:53 +00:00
Kpa-clawbot 1178bae18f ci: update frontend-tests.json [skip ci] 2026-05-05 01:39:52 +00:00
Kpa-clawbot 27c8514d70 ci: update frontend-coverage.json [skip ci] 2026-05-05 01:39:51 +00:00
Kpa-clawbot a24ec6e767 ci: update e2e-tests.json [skip ci] 2026-05-05 01:39:50 +00:00
Kpa-clawbot c1d0daf200 feat(#1034): channel QR generate + scan module (PR 2/3) (#1035)
## PR #2 of channel UX redesign (#1034) — QR generation + scanning

Self-contained QR module for MeshCore channel sharing. Wirable but **not
wired** — PR #3 wires this into the modal placeholders shipped by PR #1.

### What's in
- **`public/channel-qr.js`** — new module exporting `window.ChannelQR`:
- `buildUrl(name, secretHex)` →
`meshcore://channel/add?name=<urlencoded>&secret=<32hex>`
- `parseChannelUrl(url)` → `{name, secret}` or `null` (strict: scheme,
path, hex32 secret)
- `generate(name, secretHex, target)` — renders QR (via vendored
qrcode.js) + the URL string + a "Copy Key" button into `target`
- `scan()` → `Promise<{name, secret} | null>` — opens a camera overlay,
decodes with jsQR, parses, auto-closes on first valid match. Graceful
no-camera/permission-denied fallback ("Camera not available — paste key
manually").
- **`public/vendor/jsqr.min.js`** — vendored jsQR 1.4.0
- **`public/index.html`** — loads `vendor/jsqr.min.js` + `channel-qr.js`
after `channel-decrypt.js`
- **`test-channel-qr.js`** + wired into `test-all.sh` — 16 assertions on
`buildUrl` / `parseChannelUrl` (DOM/camera paths covered by Playwright
in #3)

### TDD
- Red commit `d6ba89e` — stub module + failing assertions on `buildUrl`
/ `parseChannelUrl` (compiles, runs, fails on assertion)
- Green commit `25328ac` — real impl, 16/16 pass

### License note
Brief specified jsQR as MIT — it's actually **Apache-2.0**
(https://github.com/cozmo/jsQR/blob/master/package.json). Apache-2.0 is
permissive and compatible with the repo's ISC license; flagging here so
reviewers can confirm. Cited in the file header.

### Independence guarantees
- Does **not** touch `channels.js` or `channel-decrypt.js`
- Does not call any UI from `channels.js`; PR #3 will call
`ChannelQR.generate(...)` into `#qr-output` and wire `#scan-qr-btn` to
`ChannelQR.scan()`

Refs #1034

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-04 18:29:48 -07:00
Kpa-clawbot d967170dd3 fix(channels): sidebar layout for user-added (PSK) rows — nested <button> bug (#1033)
## Problem

Channel sidebar layout broke for user-added (PSK) channels. Visible
symptoms in the screenshot:

- No ✕ (delete) button on user-added rows
- 🔑 emoji floating in the wrong position
- Message preview text (e.g. `KpaPocket: Тест`) orphaned **between**
channel entries instead of inside the row
- Spinner/loading dots misaligned

## Root cause

**HTML5 forbids nested `<button>` elements.** The `.ch-item` row is a
`<button>`, and #1024 added a `<button class="ch-remove-btn">` inside
it. The HTML parser implicitly closes the outer `.ch-item` the moment it
sees the inner `<button>`, then re-parents everything after it (✕ and
the `.ch-item-preview` line) outside the row.

Resulting DOM tree (parser-corrected, simplified):

```
<button class="ch-item">[icon] Levski 🔑</button>   <-- closes early
<button class="ch-remove-btn">✕</button>            <-- orphaned, "floating"
<div class="ch-item-preview">KpaPocket: Тест</div>  <-- orphaned
<button class="ch-item">[icon] #bookclub …</button>
```

Compounded by `.ch-remove-btn { opacity: 0 }` (only visible on row
hover), which made the ✕ undiscoverable on touch devices even before the
parser bug.

## Fix

`public/channels.js`
- Replace the inner `<button class="ch-remove-btn">` with `<span
class="ch-remove-btn" role="button" tabindex="0">`. Click delegation
already keys off `[data-remove-channel]` so behavior is unchanged.
- Add `keydown` (Enter / Space) handler on `#chList` so the role=button
span stays keyboard-accessible.
- Relabel the ambiguous `🔒 No key` toggle to `🔒 Show encrypted (no
key)`, with an explanatory `title` ("Show encrypted channels you don't
have a key for (locked, can't decrypt)") so users understand it controls
visibility of channels they haven't added a PSK for.

`public/style.css`
- `.ch-remove-btn`: drop `opacity: 0` default. Now `0.55` idle, `0.9` on
row hover, `1` on direct hover/focus. Added `:focus` outline removal +
`display: inline-flex` so the ✕ centers cleanly.
- Add `.ch-user-badge` rule (was unstyled — contributed to the
misalignment of the 🔑).

## TDD

- Red commit `eeb94ad` — `test-channel-sidebar-layout.js` (7 assertions,
3 failing on master).
- Green commit `2959c3d` — fix; all 7 pass.
- Wire commit `4d6100d` — added to `test-all.sh`.

Existing channel test files still pass (`test-channel-psk-ux.js`,
`test-channel-live-decrypt.js`,
`test-channel-live-decrypt-userprefix.js`,
`test-channel-decrypt-m345.js`,
`test-channel-decrypt-insecure-context.js`).

## Files changed
- `public/channels.js`
- `public/style.css`
- `test-channel-sidebar-layout.js` (new)
- `test-all.sh`
2026-05-04 18:29:45 -07:00
Kpa-clawbot 2f0c97604b feat(map): cluster markers with Leaflet.markercluster (#1036) (#1038)
## Summary
Implements map marker clustering for large meshes (500+ nodes) using
vendored `Leaflet.markercluster@1.5.3`. Closes the long-standing no-op
`Show clusters` checkbox.

## What changed
**Vendored library** — `public/vendor/leaflet.markercluster.js` +
`MarkerCluster.css` + `MarkerCluster.Default.css`. No CDN: this runs
offline on mesh-operator deployments.

**`map.js`**
- `createClusterGroup()` instantiates `L.markerClusterGroup` with:
  - `chunkedLoading: true` (no frame drops on initial render)
- `removeOutsideVisibleBounds: true` (viewport culling — key win at 2k+
nodes)
  - `disableClusteringAtZoom: 16` (fully expanded at high zoom)
  - `spiderfyOnMaxZoom: true` (fan out at max zoom)
  - `showCoverageOnHover: false`
  - `animate` disabled on mobile UA for perf
- `makeClusterIcon(cluster)` produces a CoreScope-themed `L.divIcon`:
  - Bold total count, centered
- Up to 4 role-color mini-pills (repeater / companion / room / sensor /
observer) using `ROLE_COLORS`
- Bucketed `mc-sm` / `mc-md` / `mc-lg` background (info / warning /
accent CSS vars)
- `#mcClusters` checkbox repurposed from no-op `Show clusters` →
`Cluster markers`, default **ON**, persisted to
`localStorage['meshcore-map-clustering']`
- Render branches at the marker-add step: clustering ON → `addLayers()`
to `clusterGroup`, skip `deconflictLabels` + `_updateOffsetIndicator`
polylines + `_repositionMarkers` on zoom/resize. Clustering OFF →
original flow unchanged.
- Route polylines (`drawPacketRoute`) already removed both layers — no
change needed beyond actually instantiating `clusterGroup`.
- `?node=PUBKEY` deep-link lookup now searches both `markerLayer` and
`clusterGroup` so it works in either mode.

**`style.css`** — cluster bubble + role-pill styles using `--info` /
`--warning` / `--accent` CSS variables; hover scale.

**`index.html`** — vendor CSS + JS tags after the Leaflet bundle
(cache-busted via `__BUST__`).

## TDD
- **Red commit** `e10af23` — `test-map-clustering.js` + stub
`createClusterGroup`/`makeClusterIcon` returning null/empty divIcon.
Compiles, runs, fails 4/5 on assertions.
- **Green commit** `482ea2e` — real implementation. 5/5 pass.

```
=== map.js: clustering ===
   exposes test hooks (__meshcoreMapInternals)
   createClusterGroup returns an L.MarkerClusterGroup with required options
   cluster group accepts markers via addLayer
   makeClusterIcon: includes total count and role-pill counts
   makeClusterIcon: bucket sm/md/lg by total
```

## Behavior preserved
- Clustering OFF (existing checkbox unchecked) → all original behavior
intact: deconfliction spiral, offset-indicator polylines, per-zoom
reposition.
- Default ON. Operators with small meshes can disable via the checkbox;
choice persists.
- Spiderfying enabled at max zoom (built-in markercluster behavior).

## Performance target
Smooth pan/zoom at 2000 nodes — `chunkedLoading` keeps the main thread
responsive during initial add, `removeOutsideVisibleBounds` keeps DOM
bounded to the viewport. Per AGENTS.md rule 0: complexity is O(n) for
the initial add (chunked across frames), per-zoom re-cluster is internal
to markercluster (well-tested at 10k+ scale).

## Out of scope (filed as follow-ups in spec)
- Canvas marker renderer — only if 5k+ nodes per viewport materializes
- Server-side viewport culling (`/api/nodes?bbox=`)
- Cluster-by-role split groups
- 2k-node fixture + Playwright DOM assertions — repo doesn't currently
ship a `fixture=` query param; the unit test exercises the integration
deterministically.

Fixes #1036

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-04 18:29:42 -07:00
Kpa-clawbot 0b0fda5bb2 ci: update go-server-coverage.json [skip ci] 2026-05-04 23:54:17 +00:00
Kpa-clawbot e966ecc71a ci: update go-ingestor-coverage.json [skip ci] 2026-05-04 23:54:16 +00:00
Kpa-clawbot e7aa8eded8 ci: update frontend-tests.json [skip ci] 2026-05-04 23:54:16 +00:00
Kpa-clawbot d652b7c39d ci: update frontend-coverage.json [skip ci] 2026-05-04 23:54:15 +00:00
Kpa-clawbot 6a8ed98d8f ci: update e2e-tests.json [skip ci] 2026-05-04 23:54:14 +00:00
Kpa-clawbot 26daa760cd fix(channels): live PSK decrypt for user-added channels (#1029 follow-up) (#1031)
## Problem

PR #1030 added live PSK decrypt for GRP_TXT WS packets, but in
production it still didn't work for **user-added** PSK channels. New
messages never appeared in real time on a channel added via the sidebar
key form — users had to refresh the page to see them via the REST fetch
path (regression #1029).

## Root cause

`decryptLivePSKBatch` rewrites the payload with the raw channel name:

```js
payload.channel = dec.channelName;   // e.g. "medusa"
```

But user-added channels live in `channels[]` under the key produced by
`addUserChannel`:

```js
hash: 'user:' + name,                // e.g. "user:medusa"
```

`selectedHash` also uses the `user:`-prefixed key while a user-added
channel is open. Downstream in `processWSBatch`:

| Line | Check | Result |
|---|---|---|
| 962 | `c.hash === channelName` | `"medusa" !== "user:medusa"` → user
channel never matched |
| 982 | `channelName === selectedHash` | `"medusa" !== "user:medusa"` →
message never appended to open chat |
| 974 | `channels.push({ hash: channelName, ... })` | duplicate plain
`"medusa"` entry pushed into sidebar |

The unread bumper (`channels.js:1086`) compared `chName === prior` with
the same mismatch, so it bumped an unread badge on the channel currently
being viewed.

Verified end to end against staging WS traffic (live `decryption_status:
"decrypted"` packets observed; user-added channel never updated,
duplicate entry created).

## Fix

`decryptLivePSKBatch` now also stamps a canonical sidebar key on the
payload:

```js
payload.channelKey = hasUserCh ? ('user:' + dec.channelName) : dec.channelName;
```

`processWSBatch` and the unread bumper route on `payload.channelKey`
(falling back to `payload.channel` for server-known CHAN packets — no
behavior change there).

After the fix:
-  live message appends to the open user-added chat
-  sidebar row's `lastMessage` / `messageCount` / `lastActivityMs`
update
-  no duplicate non-prefixed sidebar entry
-  unread bumped only on channels NOT being viewed

## TDD

Red commit `f1719a8` — `test-channel-live-decrypt-userprefix.js`, fails
6/9 on assertions (NOT build error) on pristine `channels.js`.
Green commit `da87018` — minimal fix in `channels.js`, all 9/9 pass.

Verified red gates the change: stashed `public/channels.js`, re-ran test
on red commit alone → 6 assertion failures (open channel got 0 messages,
duplicate sidebar entry, unread bumped on viewed channel).

## Files changed

- `public/channels.js` — stamp/route on `channelKey`
- `test-channel-live-decrypt-userprefix.js` (new) — red-then-green
regression test

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-04 16:44:35 -07:00
74 changed files with 17548 additions and 253 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"93 passed","color":"brightgreen"}
{"schemaVersion":1,"label":"e2e tests","message":"104 passed","color":"brightgreen"}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"40.01%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"38.41%","color":"red"}
+6
View File
@@ -83,7 +83,12 @@ jobs:
run: |
set -e
node test-packet-filter.js
node test-packet-filter-time.js
node test-channel-decrypt-insecure-context.js
node test-live-region-filter.js
node test-channel-qr.js
node test-channel-qr-wiring.js
node test-channel-modal-ux.js
- name: Verify proto syntax
run: |
@@ -206,6 +211,7 @@ jobs:
- name: Run Playwright E2E tests (fail-fast)
run: |
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
BASE_URL=http://localhost:13581 node test-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
+3
View File
@@ -1,5 +1,8 @@
# Build stage always runs natively on the builder's arch ($BUILDPLATFORM)
# and cross-compiles to $TARGETOS/$TARGETARCH via Go toolchain. No QEMU.
# BUILDPLATFORM is auto-set by buildx; default to linux/amd64 so plain
# `docker build` (without buildx) doesn't fail on an empty platform string.
ARG BUILDPLATFORM=linux/amd64
FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS builder
ARG APP_VERSION=unknown
+19 -1
View File
@@ -52,7 +52,8 @@ type Config struct {
HashChannels []string `json:"hashChannels,omitempty"`
Retention *RetentionConfig `json:"retention,omitempty"`
Metrics *MetricsConfig `json:"metrics,omitempty"`
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
ForeignAdverts *ForeignAdvertConfig `json:"foreignAdverts,omitempty"`
ValidateSignatures *bool `json:"validateSignatures,omitempty"`
DB *DBConfig `json:"db,omitempty"`
@@ -79,6 +80,23 @@ type Config struct {
// GeoFilterConfig is an alias for the shared geofilter.Config type.
type GeoFilterConfig = geofilter.Config
// ForeignAdvertConfig controls how the ingestor handles ADVERTs whose GPS lies
// outside the configured geofilter polygon (#730). Modes:
// - "flag" (default): store the advert/node and tag it foreign for visibility.
// - "drop": silently discard the advert (legacy behavior).
type ForeignAdvertConfig struct {
Mode string `json:"mode,omitempty"`
}
// IsDropMode reports whether the foreign-advert config is set to "drop".
// Defaults to false ("flag" mode) when nil or unset.
func (f *ForeignAdvertConfig) IsDropMode() bool {
if f == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(f.Mode), "drop")
}
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`
+7 -2
View File
@@ -428,7 +428,12 @@ func TestHandleMessageAdvertGeoFiltered(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, &Config{GeoFilter: gf})
// Legacy silent-drop behavior is now opt-in via ForeignAdverts.Mode="drop"
// (#730). The new default — flag — is covered by foreign_advert_test.go.
handleMessage(store, "test", source, msg, nil, &Config{
GeoFilter: gf,
ForeignAdverts: &ForeignAdvertConfig{Mode: "drop"},
})
// Geo-filtered adverts should not create nodes
var nodeCount int
@@ -436,7 +441,7 @@ func TestHandleMessageAdvertGeoFiltered(t *testing.T) {
t.Fatal(err)
}
if nodeCount != 0 {
t.Errorf("nodes=%d, want 0 (geo-filtered advert should not create node)", nodeCount)
t.Errorf("nodes=%d, want 0 (geo-filtered advert in drop mode should not create node)", nodeCount)
}
}
+39 -2
View File
@@ -101,7 +101,8 @@ func applySchema(db *sql.DB) error {
first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL
temperature_c REAL,
foreign_advert INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS observers (
@@ -135,7 +136,8 @@ func applySchema(db *sql.DB) error {
first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL
temperature_c REAL,
foreign_advert INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_inactive_nodes_last_seen ON inactive_nodes(last_seen);
@@ -463,6 +465,25 @@ func applySchema(db *sql.DB) error {
db.Exec(`INSERT INTO _migrations (name) VALUES ('cleanup_legacy_null_hash_ts')`)
}
// Migration: foreign_advert column on nodes/inactive_nodes (#730)
// Marks nodes whose ADVERT GPS lies outside the configured geofilter polygon.
// Default 0; set to 1 by the ingestor when GeoFilter is configured and
// PassesFilter() returns false. Allows operators to surface bridged/leaked
// adverts without silently dropping them.
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'foreign_advert_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Adding foreign_advert column to nodes/inactive_nodes...")
if _, err := db.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
log.Printf("[migration] nodes.foreign_advert: %v (may already exist)", err)
}
if _, err := db.Exec(`ALTER TABLE inactive_nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
log.Printf("[migration] inactive_nodes.foreign_advert: %v (may already exist)", err)
}
db.Exec(`CREATE INDEX IF NOT EXISTS idx_nodes_foreign_advert ON nodes(foreign_advert) WHERE foreign_advert = 1`)
db.Exec(`INSERT INTO _migrations (name) VALUES ('foreign_advert_v1')`)
log.Println("[migration] foreign_advert column added")
}
return nil
}
@@ -676,6 +697,21 @@ func (s *Store) IncrementAdvertCount(pubKey string) error {
return err
}
// MarkNodeForeign sets foreign_advert=1 on the node row identified by pubKey.
// Used when an ADVERT arrives whose GPS lies outside the configured geofilter
// polygon (#730). Idempotent — safe to call repeatedly. No-op if pubKey is
// empty.
func (s *Store) MarkNodeForeign(pubKey string) error {
if pubKey == "" {
return nil
}
_, err := s.db.Exec(`UPDATE nodes SET foreign_advert = 1 WHERE public_key = ?`, pubKey)
if err != nil {
s.Stats.WriteErrors.Add(1)
}
return err
}
// UpdateNodeTelemetry updates battery and temperature for a node.
func (s *Store) UpdateNodeTelemetry(pubKey string, batteryMv *int, temperatureC *float64) error {
var bv, tc interface{}
@@ -1106,6 +1142,7 @@ type PacketData struct {
DecodedJSON string
ChannelHash string // grouping key for channel queries (#762)
Region string // observer region: payload > topic > source config (#788)
Foreign bool // true when ADVERT GPS lies outside configured geofilter (#730)
}
// nilIfEmpty returns nil for empty strings (for nullable DB columns).
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"testing"
)
// TestHandleMessageAdvertForeign_FlagModeStoresWithFlag asserts that when an
// ADVERT comes from a node whose GPS is OUTSIDE the configured geofilter,
// the ingestor (in default "flag" mode) stores the node and marks it foreign,
// instead of silently dropping it (#730).
func TestHandleMessageAdvertForeign_FlagModeStoresWithFlag(t *testing.T) {
store, source := newTestContext(t)
// Real ADVERT raw hex from existing TestHandleMessageAdvertGeoFiltered.
// Decoder will produce a node with a known GPS — the test below just
// asserts that with a tight geofilter that EXCLUDES that GPS, the node
// is still stored AND tagged as foreign.
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
latMin, latMax := -1.0, 1.0
lonMin, lonMax := -1.0, 1.0
gf := &GeoFilterConfig{
LatMin: &latMin, LatMax: &latMax,
LonMin: &lonMin, LonMax: &lonMax,
}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
// Default mode (no ForeignAdverts.Mode set) MUST be "flag", per #730 design.
handleMessage(store, "test", source, msg, nil, &Config{GeoFilter: gf})
var nodeCount int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil {
t.Fatal(err)
}
if nodeCount != 1 {
t.Fatalf("nodes=%d, want 1 (foreign advert should be stored, not dropped, in flag mode)", nodeCount)
}
var foreign int
if err := store.db.QueryRow("SELECT foreign_advert FROM nodes").Scan(&foreign); err != nil {
t.Fatalf("foreign_advert column missing or unreadable: %v", err)
}
if foreign != 1 {
t.Errorf("foreign_advert=%d, want 1", foreign)
}
}
// TestHandleMessageAdvertForeign_DropModeStillDrops asserts the legacy
// drop-on-foreign behavior is preserved when ForeignAdverts.Mode = "drop".
func TestHandleMessageAdvertForeign_DropModeStillDrops(t *testing.T) {
store, source := newTestContext(t)
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
latMin, latMax := -1.0, 1.0
lonMin, lonMax := -1.0, 1.0
gf := &GeoFilterConfig{
LatMin: &latMin, LatMax: &latMax,
LonMin: &lonMin, LonMax: &lonMax,
}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
cfg := &Config{
GeoFilter: gf,
ForeignAdverts: &ForeignAdvertConfig{Mode: "drop"},
}
handleMessage(store, "test", source, msg, nil, cfg)
var nodeCount int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil {
t.Fatal(err)
}
if nodeCount != 0 {
t.Errorf("nodes=%d, want 0 (drop mode preserves legacy silent-drop behavior)", nodeCount)
}
}
// TestHandleMessageAdvertInRegion_NotFlaggedForeign asserts in-region
// adverts are NOT marked foreign.
func TestHandleMessageAdvertInRegion_NotFlaggedForeign(t *testing.T) {
store, source := newTestContext(t)
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
// Wide-open geofilter: every coord passes.
latMin, latMax := -90.0, 90.0
lonMin, lonMax := -180.0, 180.0
gf := &GeoFilterConfig{
LatMin: &latMin, LatMax: &latMax,
LonMin: &lonMin, LonMax: &lonMax,
}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, &Config{GeoFilter: gf})
var foreign int
err := store.db.QueryRow("SELECT foreign_advert FROM nodes").Scan(&foreign)
if err != nil {
t.Fatalf("query foreign_advert: %v", err)
}
if foreign != 0 {
t.Errorf("foreign_advert=%d, want 0 (in-region node)", foreign)
}
}
+24 -1
View File
@@ -422,10 +422,28 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
})
return
}
foreign := false
if !NodePassesGeoFilter(decoded.Payload.Lat, decoded.Payload.Lon, cfg.GeoFilter) {
return
if cfg.ForeignAdverts.IsDropMode() {
return
}
foreign = true
lat, lon := 0.0, 0.0
if decoded.Payload.Lat != nil {
lat = *decoded.Payload.Lat
}
if decoded.Payload.Lon != nil {
lon = *decoded.Payload.Lon
}
truncPK := decoded.Payload.PubKey
if len(truncPK) > 16 {
truncPK = truncPK[:16]
}
log.Printf("MQTT [%s] foreign advert: node=%s name=%s lat=%.4f lon=%.4f observer=%s",
tag, truncPK, decoded.Payload.Name, lat, lon, firstNonEmpty(mqttMsg.Origin, observerID))
}
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
pktData.Foreign = foreign
isNew, err := store.InsertTransmission(pktData)
if err != nil {
log.Printf("MQTT [%s] db insert error: %v", tag, err)
@@ -434,6 +452,11 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
}
if foreign {
if err := store.MarkNodeForeign(decoded.Payload.PubKey); err != nil {
log.Printf("MQTT [%s] mark foreign error: %v", tag, err)
}
}
if isNew {
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
log.Printf("MQTT [%s] advert count error: %v", tag, err)
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"encoding/json"
"testing"
)
// Regression test for #1044: observer metadata (model, firmware, battery_mv,
// noise_floor) is silently dropped when an MQTT status payload arrives, even
// though the same payload's `radio` and `client_version` fields ARE persisted.
//
// Real-world payload captured from the production MQTT bridge:
//
// {"status":"online","origin":"TestObserver","origin_id":"AABBCCDD",
// "radio":"910.5250244,62.5,7,5",
// "model":"Heltec V3",
// "firmware_version":"1.12.0-test",
// "client_version":"meshcoretomqtt/1.0.8.0",
// "stats":{"battery_mv":4209,"uptime_secs":75821,"noise_floor":-109,
// "tx_air_secs":80,"rx_air_secs":1903,"recv_errors":934}}
func TestStatusMessageMetadataPersisted_Issue1044(t *testing.T) {
const payload = `{"status":"online","origin":"TestObserver","origin_id":"AABBCCDD","radio":"910.5250244,62.5,7,5","model":"Heltec V3","firmware_version":"1.12.0-test","client_version":"meshcoretomqtt/1.0.8.0","stats":{"battery_mv":4209,"uptime_secs":75821,"noise_floor":-109,"tx_air_secs":80,"rx_air_secs":1903,"recv_errors":934}}`
var msg map[string]interface{}
if err := json.Unmarshal([]byte(payload), &msg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
meta := extractObserverMeta(msg)
if meta == nil {
t.Fatal("extractObserverMeta returned nil for a payload that contains model/firmware/battery_mv")
}
if meta.Model == nil || *meta.Model != "Heltec V3" {
t.Errorf("meta.Model = %v, want \"Heltec V3\"", meta.Model)
}
if meta.Firmware == nil || *meta.Firmware != "1.12.0-test" {
t.Errorf("meta.Firmware = %v, want \"1.12.0-test\"", meta.Firmware)
}
if meta.ClientVersion == nil || *meta.ClientVersion != "meshcoretomqtt/1.0.8.0" {
t.Errorf("meta.ClientVersion = %v, want \"meshcoretomqtt/1.0.8.0\"", meta.ClientVersion)
}
if meta.Radio == nil || *meta.Radio != "910.5250244,62.5,7,5" {
t.Errorf("meta.Radio = %v, want radio string", meta.Radio)
}
if meta.BatteryMv == nil || *meta.BatteryMv != 4209 {
t.Errorf("meta.BatteryMv = %v, want 4209", meta.BatteryMv)
}
if meta.NoiseFloor == nil || *meta.NoiseFloor != -109 {
t.Errorf("meta.NoiseFloor = %v, want -109", meta.NoiseFloor)
}
if meta.UptimeSecs == nil || *meta.UptimeSecs != 75821 {
t.Errorf("meta.UptimeSecs = %v, want 75821", meta.UptimeSecs)
}
// Now drive the meta through UpsertObserver and verify the row.
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.UpsertObserver("AABBCCDD", "TestObserver", "SJC", meta); err != nil {
t.Fatalf("UpsertObserver: %v", err)
}
var (
gotModel, gotFirmware, gotClientVersion, gotRadio string
gotBattery int
gotUptime int64
gotNoise float64
)
err = s.db.QueryRow(`SELECT model, firmware, client_version, radio,
battery_mv, uptime_secs, noise_floor
FROM observers WHERE id = 'AABBCCDD'`).Scan(
&gotModel, &gotFirmware, &gotClientVersion, &gotRadio,
&gotBattery, &gotUptime, &gotNoise,
)
if err != nil {
t.Fatalf("scan observer row: %v", err)
}
if gotModel != "Heltec V3" {
t.Errorf("DB model = %q, want \"Heltec V3\"", gotModel)
}
if gotFirmware != "1.12.0-test" {
t.Errorf("DB firmware = %q, want \"1.12.0-test\"", gotFirmware)
}
if gotBattery != 4209 {
t.Errorf("DB battery_mv = %d, want 4209", gotBattery)
}
if gotUptime != 75821 {
t.Errorf("DB uptime_secs = %d, want 75821", gotUptime)
}
if gotNoise != -109 {
t.Errorf("DB noise_floor = %f, want -109", gotNoise)
}
}
+11
View File
@@ -89,6 +89,9 @@ type Config struct {
ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"`
NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"`
// BatteryThresholds: voltage cutoffs for low/critical alerts (#663).
BatteryThresholds *BatteryThresholdsConfig `json:"batteryThresholds,omitempty"`
}
// weakAPIKeys is the blocklist of known default/example API keys that must be rejected.
@@ -221,6 +224,10 @@ type HealthThresholds struct {
InfraSilentHours float64 `json:"infraSilentHours"`
NodeDegradedHours float64 `json:"nodeDegradedHours"`
NodeSilentHours float64 `json:"nodeSilentHours"`
// RelayActiveHours: how recent a path-hop appearance must be for a
// repeater to be considered "actively relaying" vs only "alive
// (advert-only)". See issue #662. Defaults to 24h.
RelayActiveHours float64 `json:"relayActiveHours"`
}
// ThemeFile mirrors theme.json overlay.
@@ -289,6 +296,7 @@ func (c *Config) GetHealthThresholds() HealthThresholds {
InfraSilentHours: 72,
NodeDegradedHours: 1,
NodeSilentHours: 24,
RelayActiveHours: 24,
}
if c.HealthThresholds != nil {
if c.HealthThresholds.InfraDegradedHours > 0 {
@@ -303,6 +311,9 @@ func (c *Config) GetHealthThresholds() HealthThresholds {
if c.HealthThresholds.NodeSilentHours > 0 {
h.NodeSilentHours = c.HealthThresholds.NodeSilentHours
}
if c.HealthThresholds.RelayActiveHours > 0 {
h.RelayActiveHours = c.HealthThresholds.RelayActiveHours
}
}
return h
}
+7 -5
View File
@@ -787,7 +787,7 @@ func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortB
var total int
db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM nodes %s", w), args...).Scan(&total)
querySQL := fmt.Sprintf("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c FROM nodes %s ORDER BY %s LIMIT ? OFFSET ?", w, order)
querySQL := fmt.Sprintf("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert FROM nodes %s ORDER BY %s LIMIT ? OFFSET ?", w, order)
qArgs := append(args, limit, offset)
rows, err := db.conn.Query(querySQL, qArgs...)
@@ -813,7 +813,7 @@ func (db *DB) SearchNodes(query string, limit int) ([]map[string]interface{}, er
if limit <= 0 {
limit = 10
}
rows, err := db.conn.Query(`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c
rows, err := db.conn.Query(`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert
FROM nodes WHERE name LIKE ? OR public_key LIKE ? ORDER BY last_seen DESC LIMIT ?`,
"%"+query+"%", query+"%", limit)
if err != nil {
@@ -852,7 +852,7 @@ func (db *DB) GetNodeByPrefix(prefix string) (map[string]interface{}, bool, erro
}
}
rows, err := db.conn.Query(
`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c
`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert
FROM nodes WHERE public_key LIKE ? LIMIT 2`,
prefix+"%",
)
@@ -882,7 +882,7 @@ func (db *DB) GetNodeByPrefix(prefix string) (map[string]interface{}, bool, erro
// GetNodeByPubkey returns a single node.
func (db *DB) GetNodeByPubkey(pubkey string) (map[string]interface{}, error) {
rows, err := db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c FROM nodes WHERE public_key = ?", pubkey)
rows, err := db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert FROM nodes WHERE public_key = ?", pubkey)
if err != nil {
return nil, err
}
@@ -1867,8 +1867,9 @@ func scanNodeRow(rows *sql.Rows) map[string]interface{} {
var advertCount int
var batteryMv sql.NullInt64
var temperatureC sql.NullFloat64
var foreign sql.NullInt64
if err := rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC); err != nil {
if err := rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC, &foreign); err != nil {
return nil
}
m := map[string]interface{}{
@@ -1883,6 +1884,7 @@ func scanNodeRow(rows *sql.Rows) map[string]interface{} {
"last_heard": nullStr(lastSeen),
"hash_size": nil,
"hash_size_inconsistent": false,
"foreign": foreign.Valid && foreign.Int64 != 0,
}
if batteryMv.Valid {
m["battery_mv"] = int(batteryMv.Int64)
+4 -2
View File
@@ -32,7 +32,8 @@ func setupTestDB(t *testing.T) *DB {
first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL
temperature_c REAL,
foreign_advert INTEGER DEFAULT 0
);
CREATE TABLE observers (
@@ -1173,7 +1174,8 @@ func setupTestDBV2(t *testing.T) *DB {
first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL
temperature_c REAL,
foreign_advert INTEGER DEFAULT 0
);
CREATE TABLE observers (
+50
View File
@@ -0,0 +1,50 @@
// Package main — discovered channels (#688).
//
// When a decoded channel message text mentions a previously-unknown hashtag
// channel (e.g. "Hey, I created new channel called #mesh, please join"), we
// auto-register that hashtag so future traffic can be displayed. This file
// owns the parsing helper plus the integration glue exposed via GetChannels.
package main
import "regexp"
// hashtagRE matches MeshCore-style hashtag channel mentions inside free text.
// A valid channel name starts with '#', followed by one or more letters,
// digits, underscore, or dash. Trailing punctuation (.,!?:;) is excluded by
// the character class.
var hashtagRE = regexp.MustCompile(`#[A-Za-z0-9_\-]+`)
// extractHashtagsFromText scans a decoded message text and returns the unique
// hashtag channel mentions found, in first-seen order. The leading '#' is
// preserved so callers can match against canonical channel names directly.
//
// Examples:
// extractHashtagsFromText("hi #mesh and #fun") => []string{"#mesh", "#fun"}
// extractHashtagsFromText("nothing here") => nil
// extractHashtagsFromText("dup #x and #x again") => []string{"#x"}
//
func extractHashtagsFromText(text string) []string {
if text == "" {
return nil
}
matches := hashtagRE.FindAllString(text, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]struct{}, len(matches))
out := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) < 2 { // bare '#' guard (regex requires 1+ chars but be defensive)
continue
}
if _, ok := seen[m]; ok {
continue
}
seen[m] = struct{}{}
out = append(out, m)
}
if len(out) == 0 {
return nil
}
return out
}
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"reflect"
"testing"
)
// TestExtractHashtagsFromText covers the parsing helper used to discover new
// hashtag channels from decoded message text (issue #688).
func TestExtractHashtagsFromText(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{
name: "single mention from issue body",
in: "Hey, I created new channel called #mesh, please join",
want: []string{"#mesh"},
},
{
name: "multiple mentions preserve order",
in: "join #mesh and #wardriving today",
want: []string{"#mesh", "#wardriving"},
},
{
name: "dedup repeated mentions",
in: "#x then #x again",
want: []string{"#x"},
},
{
name: "ignores trailing punctuation",
in: "check #fun!",
want: []string{"#fun"},
},
{
name: "no hashtag returns nil",
in: "nothing to see here",
want: nil,
},
{
name: "bare # is not a channel",
in: "issue #",
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractHashtagsFromText(tc.in)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("extractHashtagsFromText(%q): got %v, want %v", tc.in, got, tc.want)
}
})
}
}
// TestGetChannels_DiscoversHashtagsFromMessages verifies that when a decoded
// CHAN message body mentions a previously-unknown hashtag channel, that
// channel is auto-registered in the GetChannels output (#688).
func TestGetChannels_DiscoversHashtagsFromMessages(t *testing.T) {
// One known channel (#general) where someone announces a new channel #mesh.
pkt := makeGrpTx(198, "general", "Alice: Hey, I created new channel called #mesh, please join", "Alice")
ps := newChannelTestStore([]*StoreTx{pkt})
channels := ps.GetChannels("")
var sawGeneral, sawMesh bool
for _, ch := range channels {
switch ch["name"] {
case "general":
sawGeneral = true
case "#mesh":
sawMesh = true
if d, _ := ch["discovered"].(bool); !d {
t.Errorf("expected discovered=true on #mesh, got %v", ch["discovered"])
}
}
}
if !sawGeneral {
t.Error("expected the source channel 'general' in GetChannels output")
}
if !sawMesh {
t.Errorf("expected discovered hashtag channel '#mesh' in GetChannels output; got %d channels: %+v", len(channels), channels)
}
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestHandleNodes_ExposesForeignAdvertField asserts the /api/nodes response
// surfaces the foreign_advert column as a boolean `foreign` field on each
// node, so operators can see bridged/leaked nodes (#730).
func TestHandleNodes_ExposesForeignAdvertField(t *testing.T) {
srv, router := setupTestServer(t)
conn := srv.db.conn
if _, err := conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count, foreign_advert)
VALUES
('PK_LOCAL', 'local-node', 'companion', 37.0, -122.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 1, 0),
('PK_FOREIGN', 'foreign-node', 'companion', 50.0, 10.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 1, 1)`,
); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/api/nodes?limit=100", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Nodes []map[string]interface{} `json:"nodes"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, n := range resp.Nodes {
pk, _ := n["public_key"].(string)
f, ok := n["foreign"].(bool)
if !ok {
t.Errorf("node %s: missing/non-bool 'foreign' field, got %T %v", pk, n["foreign"], n["foreign"])
continue
}
got[pk] = f
}
if !got["PK_LOCAL"] == false || got["PK_LOCAL"] != false {
t.Errorf("PK_LOCAL foreign=%v, want false", got["PK_LOCAL"])
}
if got["PK_FOREIGN"] != true {
t.Errorf("PK_FOREIGN foreign=%v, want true", got["PK_FOREIGN"])
}
}
+26
View File
@@ -108,6 +108,25 @@ func main() {
log.Printf("[security] WARNING: API key is weak or a known default — write endpoints are vulnerable")
}
// Apply Go runtime soft memory limit (#836).
// Honors GOMEMLIMIT if set; otherwise derives from packetStore.maxMemoryMB.
{
_, envSet := os.LookupEnv("GOMEMLIMIT")
maxMB := 0
if cfg.PacketStore != nil {
maxMB = cfg.PacketStore.MaxMemoryMB
}
limit, source := applyMemoryLimit(maxMB, envSet)
switch source {
case "env":
log.Printf("[memlimit] using GOMEMLIMIT from environment (%s)", os.Getenv("GOMEMLIMIT"))
case "derived":
log.Printf("[memlimit] derived from packetStore.maxMemoryMB=%d → %d MiB (1.5x headroom)", maxMB, limit/(1024*1024))
default:
log.Printf("[memlimit] no soft memory limit set (GOMEMLIMIT unset, packetStore.maxMemoryMB=0); recommend setting one to avoid container OOM-kill")
}
}
// Resolve DB path
resolvedDB := cfg.ResolveDBPath(configDir)
log.Printf("[config] port=%d db=%s public=%s", cfg.Port, resolvedDB, publicDir)
@@ -186,6 +205,13 @@ func main() {
log.Printf("[store] warning: could not add observers.last_packet_at column: %v", err)
}
// Ensure nodes.foreign_advert column exists (#730 reads it on every /api/nodes
// scan; ingestor migration foreign_advert_v1 adds it but server may run against
// DBs ingestor never touched, e.g. e2e fixture).
if err := ensureForeignAdvertColumn(dbPath); err != nil {
log.Printf("[store] warning: could not add nodes.foreign_advert column: %v", err)
}
// Soft-delete observers that are in the blacklist (mark inactive=1) so
// historical data from a prior unblocked window is hidden too.
if len(cfg.ObserverBlacklist) > 0 {
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"runtime/debug"
)
// applyMemoryLimit configures Go's soft memory limit (GOMEMLIMIT).
//
// Behavior:
// - If envSet is true (GOMEMLIMIT env var present), the runtime has already
// parsed it; we leave it alone and report source="env" with limit=0.
// - Otherwise, if maxMemoryMB > 0, we derive a limit of maxMemoryMB * 1.5 MiB
// and set it via debug.SetMemoryLimit. This forces aggressive GC under
// cgroup pressure so the process self-throttles before SIGKILL. See #836.
// - Otherwise, no limit is applied; source="none".
//
// Returns the limit (in bytes) we actually set, or 0 if we did not set one,
// plus a short source identifier ("env" | "derived" | "none") for logging.
func applyMemoryLimit(maxMemoryMB int, envSet bool) (int64, string) {
if envSet {
return 0, "env"
}
if maxMemoryMB <= 0 {
return 0, "none"
}
// 1.5x headroom over the steady-state packet store budget covers
// transient peaks (cold-load row-scan / decode pipeline, Go's NextGC
// trigger at ~2x live heap). See issue #836 heap profile.
limit := int64(maxMemoryMB) * 1024 * 1024 * 3 / 2
debug.SetMemoryLimit(limit)
return limit, "derived"
}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"runtime/debug"
"testing"
)
func TestApplyMemoryLimit_FromEnv(t *testing.T) {
t.Setenv("GOMEMLIMIT", "850MiB")
// reset to a known state after test
defer debug.SetMemoryLimit(-1)
limit, source := applyMemoryLimit(512, true /* envSet */)
if source != "env" {
t.Fatalf("expected source=env, got %q", source)
}
// When env is set, our function must NOT override it; reported limit is 0.
if limit != 0 {
t.Fatalf("expected limit=0 (not set by us), got %d", limit)
}
}
func TestApplyMemoryLimit_DerivedFromMaxMemoryMB(t *testing.T) {
defer debug.SetMemoryLimit(-1)
// maxMemoryMB=512 → 512 * 1.5 = 768 MiB = 768 * 1024 * 1024 bytes
limit, source := applyMemoryLimit(512, false /* envSet */)
if source != "derived" {
t.Fatalf("expected source=derived, got %q", source)
}
want := int64(768) * 1024 * 1024
if limit != want {
t.Fatalf("expected limit=%d, got %d", want, limit)
}
// Verify it was actually set on the runtime
cur := debug.SetMemoryLimit(-1)
if cur != want {
t.Fatalf("runtime memory limit not set: want=%d got=%d", want, cur)
}
}
func TestApplyMemoryLimit_None(t *testing.T) {
defer debug.SetMemoryLimit(-1)
// Reset to "no limit" (math.MaxInt64) before test
debug.SetMemoryLimit(int64(1<<63 - 1))
limit, source := applyMemoryLimit(0, false)
if source != "none" {
t.Fatalf("expected source=none, got %q", source)
}
if limit != 0 {
t.Fatalf("expected limit=0, got %d", limit)
}
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"testing"
"time"
)
// TestMultiByteCapability_RegionFiltered_PreservesConfirmedStatus verifies
// that GetAnalyticsHashSizes returns a populated multiByteCapability list
// even when a region filter is applied. The frontend (analytics.js) merges
// this into the adopter table to render per-node "confirmed/suspected/unknown"
// badges. When the field is missing or empty under a region filter, every
// row falls back to "unknown" — see meshcore.meshat.se/#/analytics filtered
// by JKG showing 14 "unknown" while the unfiltered view shows 0.
//
// Multi-byte capability is a property of the NODE (advertised hash_size from
// its own adverts), not the observing region. Region filter should affect
// which nodes appear in the result list (multiByteNodes), not their cap status.
//
// Pre-fix behavior: multiByteCapability is only populated when region == "".
// This test fails because result["multiByteCapability"] is absent under
// region="JKG", so the lookup returns nil/false.
func TestMultiByteCapability_RegionFiltered_PreservesConfirmedStatus(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC()
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
recentEpoch := now.Add(-1 * time.Hour).Unix()
// Two observers in different regions.
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs-sjc', 'Obs SJC', 'SJC', ?, '2026-01-01T00:00:00Z', 100)`, recent)
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs-jkg', 'Obs JKG', 'JKG', ?, '2026-01-01T00:00:00Z', 100)`, recent)
// Node A: a JKG-region repeater that advertises multi-byte (hash_size=2).
// Its zero-hop direct advert is only heard by obs-SJC (e.g. an out-of-region
// listener that happens to pick it up). Under the JKG region filter, the
// computeAnalyticsHashSizes() pass will see a smaller advert dataset, but
// the node's multi-byte capability is intrinsic and should still resolve
// to "confirmed" via the global advert evidence.
pkA := "aaa0000000000001"
db.conn.Exec(`INSERT INTO nodes (public_key, name, role)
VALUES (?, 'Node-A', 'repeater')`, pkA)
decodedA := `{"pubKey":"` + pkA + `","name":"Node-A","type":"ADVERT","flags":{"isRepeater":true}}`
// Zero-hop direct advert (route_type=2, payload_type=4),
// pathByte 0x40 → hash_size bits 01 → 2 bytes.
// Heard by obs-SJC ONLY.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1240aabbccdd', 'a_zh_direct', ?, 2, 4, ?)`, recent, decodedA)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 12.0, -85, '[]', ?)`, recentEpoch)
// Node A also appears as a path hop in a JKG-observed packet, so it
// shows up in the JKG region's node list.
// route_type=1 (flood), payload_type=4, pathByte 0x41 (hs=2, hops=1)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1141aabbccdd', 'a_jkg_relay', ?, 1, 4, ?)`, recent, decodedA)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 2, 8.0, -95, '["aa"]', ?)`, recentEpoch)
store := NewPacketStore(db, nil)
store.Load()
// Sanity: unfiltered view exposes the field.
unfiltered := store.GetAnalyticsHashSizes("")
if _, ok := unfiltered["multiByteCapability"]; !ok {
t.Fatal("unfiltered result missing multiByteCapability — test setup is wrong")
}
// The actual assertion: region-filtered view MUST also expose the field
// AND must report Node A as "confirmed", not "unknown".
result := store.GetAnalyticsHashSizes("JKG")
capsRaw, ok := result["multiByteCapability"]
if !ok {
t.Fatalf("expected multiByteCapability in region=JKG result, got keys: %v", keysOf(result))
}
caps, ok := capsRaw.([]MultiByteCapEntry)
if !ok {
t.Fatalf("expected []MultiByteCapEntry, got %T", capsRaw)
}
var foundA *MultiByteCapEntry
for i := range caps {
if caps[i].PublicKey == pkA {
foundA = &caps[i]
break
}
}
if foundA == nil {
t.Fatalf("Node A missing from region=JKG multiByteCapability (have %d entries)", len(caps))
}
if foundA.Status != "confirmed" {
t.Errorf("Node A status under region=JKG = %q, want %q (region filter wrongly downgraded multi-byte capability evidence)", foundA.Status, "confirmed")
}
}
func keysOf(m map[string]interface{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+46
View File
@@ -353,6 +353,52 @@ func ensureLastPacketAtColumn(dbPath string) error {
return nil
}
// ensureForeignAdvertColumn adds the foreign_advert column to nodes/inactive_nodes
// if missing (#730). The column is added by the ingestor migration foreign_advert_v1
// — but the server may run against a DB the ingestor has never touched (e2e fixture,
// fresh installs where the server boots first), in which case scanNodeRow fails
// with "no such column: foreign_advert" and /api/nodes silently returns nothing.
func ensureForeignAdvertColumn(dbPath string) error {
rw, err := cachedRW(dbPath)
if err != nil {
return err
}
for _, table := range []string{"nodes", "inactive_nodes"} {
has, err := tableHasColumn(rw, table, "foreign_advert")
if err != nil {
return fmt.Errorf("inspect %s: %w", table, err)
}
if has {
continue
}
if _, err := rw.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN foreign_advert INTEGER DEFAULT 0", table)); err != nil {
return fmt.Errorf("add foreign_advert to %s: %w", table, err)
}
log.Printf("[store] Added foreign_advert column to %s", table)
}
return nil
}
// tableHasColumn reports whether the named table has the named column.
func tableHasColumn(rw *sql.DB, table, column string) (bool, error) {
rows, err := rw.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cid int
var colName string
var colType sql.NullString
var notNull, pk int
var dflt sql.NullString
if rows.Scan(&cid, &colName, &colType, &notNull, &dflt, &pk) == nil && colName == column {
return true, nil
}
}
return false, nil
}
// softDeleteBlacklistedObservers marks observers matching the blacklist as
// inactive=1 so they are hidden from API responses. Runs once at startup.
func softDeleteBlacklistedObservers(dbPath string, blacklist []string) {
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
)
// BatteryThresholdsConfig: voltage cutoffs for low-battery alerts (#663).
// All values in millivolts. When a node's most-recent battery sample falls
// below LowMv it is flagged "low"; below CriticalMv it is flagged "critical".
type BatteryThresholdsConfig struct {
LowMv int `json:"lowMv"`
CriticalMv int `json:"criticalMv"`
}
// LowBatteryMv returns the configured low-battery threshold or the default 3300mV.
func (c *Config) LowBatteryMv() int {
if c.BatteryThresholds != nil && c.BatteryThresholds.LowMv > 0 {
return c.BatteryThresholds.LowMv
}
return 3300
}
// CriticalBatteryMv returns the configured critical-battery threshold or the default 3000mV.
func (c *Config) CriticalBatteryMv() int {
if c.BatteryThresholds != nil && c.BatteryThresholds.CriticalMv > 0 {
return c.BatteryThresholds.CriticalMv
}
return 3000
}
// NodeBatterySample is a single (timestamp, battery_mv) point.
type NodeBatterySample struct {
Timestamp string `json:"timestamp"`
BatteryMv int `json:"battery_mv"`
}
// GetNodeBatteryHistory returns time-ordered battery_mv samples for a node,
// pulled from observer_metrics by joining observers.id (uppercase pubkey)
// against the node's public_key (lowercase). Rows with NULL battery are skipped.
//
// The match is case-insensitive on observer_id to tolerate historical
// variation in pubkey casing.
func (db *DB) GetNodeBatteryHistory(pubkey, since string) ([]NodeBatterySample, error) {
if pubkey == "" {
return nil, nil
}
pk := strings.ToLower(pubkey)
rows, err := db.conn.Query(`
SELECT timestamp, battery_mv
FROM observer_metrics
WHERE LOWER(observer_id) = ?
AND battery_mv IS NOT NULL
AND timestamp >= ?
ORDER BY timestamp ASC`, pk, since)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NodeBatterySample
for rows.Next() {
var ts string
var mv int
if err := rows.Scan(&ts, &mv); err != nil {
return nil, err
}
out = append(out, NodeBatterySample{Timestamp: ts, BatteryMv: mv})
}
return out, rows.Err()
}
// handleNodeBattery serves GET /api/nodes/{pubkey}/battery?days=N (#663).
//
// Returns voltage time-series for a node and a status flag based on the most
// recent sample evaluated against configured thresholds:
// - "critical" : latest_mv < CriticalBatteryMv
// - "low" : latest_mv < LowBatteryMv
// - "ok" : latest_mv >= LowBatteryMv
// - "unknown" : no samples in window
func (s *Server) handleNodeBattery(w http.ResponseWriter, r *http.Request) {
pubkey := mux.Vars(r)["pubkey"]
if pubkey == "" {
writeError(w, 400, "missing pubkey")
return
}
// 404 if node unknown — keeps URL space tidy and matches /health behavior.
node, err := s.db.GetNodeByPubkey(pubkey)
if err != nil {
writeError(w, 500, err.Error())
return
}
if node == nil {
writeError(w, 404, "node not found")
return
}
days := 7
if d, _ := strconv.Atoi(r.URL.Query().Get("days")); d > 0 && d <= 365 {
days = d
}
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
samples, err := s.db.GetNodeBatteryHistory(pubkey, since)
if err != nil {
writeError(w, 500, err.Error())
return
}
if samples == nil {
samples = []NodeBatterySample{}
}
low := s.cfg.LowBatteryMv()
crit := s.cfg.CriticalBatteryMv()
status := "unknown"
var latestMv interface{}
var latestTs interface{}
if n := len(samples); n > 0 {
mv := samples[n-1].BatteryMv
latestMv = mv
latestTs = samples[n-1].Timestamp
switch {
case mv < crit:
status = "critical"
case mv < low:
status = "low"
default:
status = "ok"
}
}
writeJSON(w, map[string]interface{}{
"public_key": strings.ToLower(pubkey),
"days": days,
"samples": samples,
"latest_mv": latestMv,
"latest_ts": latestTs,
"status": status,
"thresholds": map[string]interface{}{
"low_mv": low,
"critical_mv": crit,
},
})
}
+161
View File
@@ -0,0 +1,161 @@
package main
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
)
// TestGetNodeBatteryHistory_FromObserverMetrics validates that the DB layer
// can pull a node's battery_mv time-series from observer_metrics, joining
// observers.id (uppercase hex pubkey) to nodes.public_key (lowercase hex).
func TestGetNodeBatteryHistory_FromObserverMetrics(t *testing.T) {
db := setupTestDB(t)
now := time.Now().UTC()
// node + observer with matching pubkey (cases differ on purpose)
pkLower := "deadbeefcafef00d11223344"
idUpper := strings.ToUpper(pkLower)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, first_seen) VALUES (?, 'BatNode', 'repeater', ?, ?)`,
pkLower, now.Format(time.RFC3339), now.Add(-72*time.Hour).Format(time.RFC3339))
db.conn.Exec(`INSERT INTO observers (id, name, last_seen, first_seen) VALUES (?, 'BatNode', ?, ?)`,
idUpper, now.Format(time.RFC3339), now.Add(-72*time.Hour).Format(time.RFC3339))
// 3 metrics samples: 3700, 3500, 3200 mV
for i, mv := range []int{3700, 3500, 3200} {
ts := now.Add(time.Duration(-2+i) * time.Hour).Format(time.RFC3339)
db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp, battery_mv) VALUES (?, ?, ?)`,
idUpper, ts, mv)
}
// One sample with NULL battery should be skipped
db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp) VALUES (?, ?)`,
idUpper, now.Add(-3*time.Hour).Format(time.RFC3339))
since := now.Add(-24 * time.Hour).Format(time.RFC3339)
samples, err := db.GetNodeBatteryHistory(pkLower, since)
if err != nil {
t.Fatalf("GetNodeBatteryHistory: %v", err)
}
if len(samples) != 3 {
t.Fatalf("expected 3 samples, got %d", len(samples))
}
if samples[0].BatteryMv != 3700 || samples[2].BatteryMv != 3200 {
t.Errorf("samples=%+v", samples)
}
}
// TestNodeBatteryEndpoint validates the /api/nodes/{pubkey}/battery endpoint
// returns time-series data plus configured thresholds and a status flag.
func TestNodeBatteryEndpoint(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
now := time.Now().UTC()
pkLower := "aabbccdd11223344"
idUpper := strings.ToUpper(pkLower)
db.conn.Exec(`INSERT INTO observers (id, name, last_seen, first_seen) VALUES (?, 'TestRepeater', ?, ?)`,
idUpper, now.Format(time.RFC3339), now.Add(-72*time.Hour).Format(time.RFC3339))
for i, mv := range []int{3800, 3600, 3200} {
ts := now.Add(time.Duration(-2+i) * time.Hour).Format(time.RFC3339)
db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp, battery_mv) VALUES (?, ?, ?)`,
idUpper, ts, mv)
}
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load: %v", err)
}
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/nodes/"+pkLower+"/battery?days=7", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
samples, ok := body["samples"].([]interface{})
if !ok {
t.Fatalf("samples missing: %+v", body)
}
if len(samples) != 3 {
t.Errorf("expected 3 samples, got %d", len(samples))
}
thr, ok := body["thresholds"].(map[string]interface{})
if !ok {
t.Fatalf("thresholds missing: %+v", body)
}
if int(thr["low_mv"].(float64)) != 3300 {
t.Errorf("default low_mv expected 3300, got %v", thr["low_mv"])
}
if int(thr["critical_mv"].(float64)) != 3000 {
t.Errorf("default critical_mv expected 3000, got %v", thr["critical_mv"])
}
// latest 3200 -> "low" (below 3300, above 3000)
if body["status"] != "low" {
t.Errorf("expected status=low, got %v", body["status"])
}
if int(body["latest_mv"].(float64)) != 3200 {
t.Errorf("latest_mv expected 3200, got %v", body["latest_mv"])
}
}
// TestNodeBatteryEndpoint_NoData returns 200 with empty samples and status="unknown".
func TestNodeBatteryEndpoint_NoData(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/battery", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "unknown" {
t.Errorf("expected unknown when no samples, got %v", body["status"])
}
}
// TestNodeBatteryEndpoint_404 returns 404 for unknown node.
func TestNodeBatteryEndpoint_404(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/nodes/notarealnode00000000/battery", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 404 {
t.Errorf("expected 404, got %d", w.Code)
}
}
// TestBatteryThresholds_ConfigOverride confirms config overrides take effect.
func TestBatteryThresholds_ConfigOverride(t *testing.T) {
cfg := &Config{
BatteryThresholds: &BatteryThresholdsConfig{LowMv: 3500, CriticalMv: 3100},
}
if cfg.LowBatteryMv() != 3500 {
t.Errorf("LowBatteryMv override failed: %d", cfg.LowBatteryMv())
}
if cfg.CriticalBatteryMv() != 3100 {
t.Errorf("CriticalBatteryMv override failed: %d", cfg.CriticalBatteryMv())
}
empty := &Config{}
if empty.LowBatteryMv() != 3300 {
t.Errorf("default LowBatteryMv expected 3300, got %d", empty.LowBatteryMv())
}
if empty.CriticalBatteryMv() != 3000 {
t.Errorf("default CriticalBatteryMv expected 3000, got %d", empty.CriticalBatteryMv())
}
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"strings"
"time"
)
// RepeaterRelayInfo describes whether a repeater has been observed
// relaying traffic (appearing as a path hop in non-advert packets) and
// when. This is distinct from advert-based liveness (last_seen / last_heard),
// which only proves the repeater can transmit its own adverts.
//
// See issue #662.
type RepeaterRelayInfo struct {
// LastRelayed is the ISO-8601 timestamp of the most recent non-advert
// packet where this pubkey appeared as a relay hop. Empty if never.
LastRelayed string `json:"lastRelayed,omitempty"`
// RelayActive is true if LastRelayed falls within the configured
// activity window (default 24h).
RelayActive bool `json:"relayActive"`
// WindowHours is the active-window threshold actually used.
WindowHours float64 `json:"windowHours"`
// RelayCount1h is the count of distinct non-advert packets where this
// pubkey appeared as a relay hop in the last 1 hour.
RelayCount1h int `json:"relayCount1h"`
// RelayCount24h is the count of distinct non-advert packets where this
// pubkey appeared as a relay hop in the last 24 hours.
RelayCount24h int `json:"relayCount24h"`
}
// payloadTypeAdvert is the MeshCore payload type for ADVERT packets.
// See firmware/src/Mesh.h. Adverts are NOT considered relay activity:
// a repeater that only sends adverts proves it is alive, not that it
// is forwarding traffic for other nodes.
const payloadTypeAdvert = 4
// parseRelayTS attempts to parse a packet first-seen timestamp using the
// formats CoreScope writes in practice. Returns zero time and false on
// failure. Accepted (in order):
// - RFC3339Nano — Go's default UTC marshal output
// - RFC3339 — second-precision ISO-8601 with offset
// - "2006-01-02T15:04:05.000Z" — millisecond-precision Z form used by ingest
func parseRelayTS(ts string) (time.Time, bool) {
if ts == "" {
return time.Time{}, false
}
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
return t, true
}
if t, err := time.Parse(time.RFC3339, ts); err == nil {
return t, true
}
if t, err := time.Parse("2006-01-02T15:04:05.000Z", ts); err == nil {
return t, true
}
return time.Time{}, false
}
// GetRepeaterRelayInfo returns relay-activity information for a node by
// scanning the byPathHop index for non-advert packets that name the
// pubkey as a hop. It computes the most recent appearance timestamp,
// 1h/24h hop counts, and whether the latest appearance falls within
// windowHours.
//
// Cost: O(N) over the indexed entries for `pubkey`. The byPathHop index
// is bounded by store eviction; on real data this is small per-node.
//
// Note on self-as-source: byPathHop is keyed by every hop in a packet's
// resolved path, including the originator. For ADVERT packets that's the
// node itself, which is filtered above by the payloadTypeAdvert check.
// For non-advert packets a node "originates" rather than "relays" only
// when it is the source; we don't currently have a clean signal for that
// distinction, so the count here is *path-hop appearances in non-advert
// packets*. In practice for a repeater nearly all such appearances are
// relay hops (the firmware doesn't originate user traffic), so this is
// the right approximation for issue #662.
func (s *PacketStore) GetRepeaterRelayInfo(pubkey string, windowHours float64) RepeaterRelayInfo {
info := RepeaterRelayInfo{WindowHours: windowHours}
if pubkey == "" {
return info
}
key := strings.ToLower(pubkey)
s.mu.RLock()
txList := s.byPathHop[key]
// Copy only the timestamps + payload types we need so we can release
// the read lock before doing parsing/compare work below.
type entry struct {
ts string
pt int
}
scratch := make([]entry, 0, len(txList))
for _, tx := range txList {
if tx == nil {
continue
}
pt := -1
if tx.PayloadType != nil {
pt = *tx.PayloadType
}
scratch = append(scratch, entry{ts: tx.FirstSeen, pt: pt})
}
s.mu.RUnlock()
now := time.Now().UTC()
cutoff1h := now.Add(-1 * time.Hour)
cutoff24h := now.Add(-24 * time.Hour)
var latest time.Time
var latestRaw string
for _, e := range scratch {
// Self-originated adverts are not relay activity (see header comment).
if e.pt == payloadTypeAdvert {
continue
}
t, ok := parseRelayTS(e.ts)
if !ok {
continue
}
if t.After(latest) {
latest = t
latestRaw = e.ts
}
if t.After(cutoff24h) {
info.RelayCount24h++
if t.After(cutoff1h) {
info.RelayCount1h++
}
}
}
if latestRaw == "" {
return info
}
info.LastRelayed = latestRaw
if windowHours > 0 {
cutoff := now.Add(-time.Duration(windowHours * float64(time.Hour)))
if latest.After(cutoff) {
info.RelayActive = true
}
}
return info
}
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"testing"
"time"
)
// TestRepeaterRelayActivity_Active verifies that a repeater whose pubkey
// appears as a relay hop in a recent (non-advert) packet is reported with
// a non-zero lastRelayed timestamp and relayActive=true.
func TestRepeaterRelayActivity_Active(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
pubkey := "aabbccdd11223344"
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
pubkey, "RepActive", "repeater", recentTS(1))
store := NewPacketStore(db, nil)
// A non-advert packet (payload_type=1, TXT_MSG) with the repeater pubkey
// indexed as a path hop. Index by lowercase pubkey directly to mirror
// the resolved-path entries that decode-window writes.
pt := 1
relayed := &StoreTx{
RawHex: "0100",
PayloadType: &pt,
PathJSON: `["aa"]`,
FirstSeen: recentTS(2),
}
store.mu.Lock()
relayed.ID = len(store.packets) + 1
relayed.Hash = "test-relay-1"
store.packets = append(store.packets, relayed)
store.byHash[relayed.Hash] = relayed
store.byTxID[relayed.ID] = relayed
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], relayed)
store.mu.Unlock()
info := store.GetRepeaterRelayInfo(pubkey, 24)
if info.LastRelayed == "" {
t.Fatalf("expected non-empty LastRelayed for active relayer, got empty (RelayActive=%v)", info.RelayActive)
}
if !info.RelayActive {
t.Errorf("expected RelayActive=true within 24h window, got false (LastRelayed=%s)", info.LastRelayed)
}
if info.RelayCount1h != 0 {
t.Errorf("expected RelayCount1h=0 (relay was 2h ago, outside 1h window), got %d", info.RelayCount1h)
}
if info.RelayCount24h != 1 {
t.Errorf("expected RelayCount24h=1 (relay was 2h ago, inside 24h window), got %d", info.RelayCount24h)
}
}
// TestRepeaterRelayActivity_Idle verifies that a repeater whose pubkey
// has not appeared as a relay hop reports an empty LastRelayed and
// relayActive=false.
func TestRepeaterRelayActivity_Idle(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
pubkey := "ccddeeff55667788"
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
pubkey, "RepIdle", "repeater", recentTS(1))
store := NewPacketStore(db, nil)
info := store.GetRepeaterRelayInfo(pubkey, 24)
if info.LastRelayed != "" {
t.Errorf("expected empty LastRelayed for idle repeater, got %q", info.LastRelayed)
}
if info.RelayActive {
t.Errorf("expected RelayActive=false for idle repeater, got true")
}
if info.RelayCount1h != 0 || info.RelayCount24h != 0 {
t.Errorf("expected zero relay counts for idle repeater, got 1h=%d 24h=%d", info.RelayCount1h, info.RelayCount24h)
}
}
// TestRepeaterRelayActivity_Stale verifies that a repeater whose only
// relay-hop appearances are older than the configured window reports
// a non-empty LastRelayed but relayActive=false.
func TestRepeaterRelayActivity_Stale(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
pubkey := "1122334455667788"
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
pubkey, "RepStale", "repeater", recentTS(1))
store := NewPacketStore(db, nil)
pt := 1
staleTS := time.Now().UTC().Add(-48 * time.Hour).Format("2006-01-02T15:04:05.000Z")
old := &StoreTx{
RawHex: "0100",
PayloadType: &pt,
PathJSON: `["11"]`,
FirstSeen: staleTS,
}
store.mu.Lock()
old.ID = len(store.packets) + 1
old.Hash = "test-relay-stale"
store.packets = append(store.packets, old)
store.byHash[old.Hash] = old
store.byTxID[old.ID] = old
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], old)
store.mu.Unlock()
info := store.GetRepeaterRelayInfo(pubkey, 24)
if info.LastRelayed != staleTS {
t.Errorf("expected LastRelayed=%q (stale ts), got %q", staleTS, info.LastRelayed)
}
if info.RelayActive {
t.Errorf("expected RelayActive=false for relay older than window, got true")
}
if info.RelayCount1h != 0 || info.RelayCount24h != 0 {
t.Errorf("expected zero relay counts for stale (>24h) repeater, got 1h=%d 24h=%d", info.RelayCount1h, info.RelayCount24h)
}
}
// TestRepeaterRelayActivity_IgnoresAdverts verifies that adverts originated
// by the repeater itself (payload_type=4) are NOT counted as relay activity —
// adverts demonstrate liveness, not relaying.
func TestRepeaterRelayActivity_IgnoresAdverts(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
pubkey := "deadbeef00000001"
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
pubkey, "RepAdvertOnly", "repeater", recentTS(1))
store := NewPacketStore(db, nil)
// Self-advert with the repeater as its own first hop. Should NOT count.
pt := 4
adv := &StoreTx{
RawHex: "0140de",
PayloadType: &pt,
PathJSON: `["de"]`,
FirstSeen: recentTS(2),
}
store.mu.Lock()
adv.ID = len(store.packets) + 1
adv.Hash = "test-advert-1"
store.packets = append(store.packets, adv)
store.byHash[adv.Hash] = adv
store.byTxID[adv.ID] = adv
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], adv)
store.mu.Unlock()
info := store.GetRepeaterRelayInfo(pubkey, 24)
if info.LastRelayed != "" {
t.Errorf("expected empty LastRelayed (adverts ignored), got %q", info.LastRelayed)
}
if info.RelayActive {
t.Errorf("expected RelayActive=false (adverts ignored), got true")
}
if info.RelayCount1h != 0 || info.RelayCount24h != 0 {
t.Errorf("expected zero relay counts (adverts ignored), got 1h=%d 24h=%d", info.RelayCount1h, info.RelayCount24h)
}
}
+64
View File
@@ -0,0 +1,64 @@
package main
import "strings"
// GetRepeaterUsefulnessScore returns a 0..1 score representing what
// fraction of non-advert traffic in the store passes through this
// repeater as a relay hop. Issue #672 (Traffic axis only — bridge,
// coverage, and redundancy axes are deferred to follow-up work).
//
// Numerator: count of non-advert StoreTx entries indexed under
// pubkey in byPathHop.
// Denominator: total non-advert StoreTx entries in the store
// (sum of byPayloadType for all keys != payloadTypeAdvert).
//
// Returns 0 when there is no non-advert traffic, the pubkey is empty,
// or the repeater never appears as a relay hop. Scores are clamped to
// [0,1] for defensive bounds.
//
// Cost: O(N) over byPayloadType keys (typically <20) plus the per-hop
// slice for pubkey. Cheap relative to the per-request enrichment loop
// in handleNodes; if it ever shows up in profiles, denominator can be
// memoized off store invalidation.
func (s *PacketStore) GetRepeaterUsefulnessScore(pubkey string) float64 {
if pubkey == "" {
return 0
}
key := strings.ToLower(pubkey)
s.mu.RLock()
defer s.mu.RUnlock()
// Denominator: total non-advert packets.
totalNonAdvert := 0
for pt, list := range s.byPayloadType {
if pt == payloadTypeAdvert {
continue
}
totalNonAdvert += len(list)
}
if totalNonAdvert == 0 {
return 0
}
// Numerator: this repeater's non-advert hop appearances.
relayed := 0
for _, tx := range s.byPathHop[key] {
if tx == nil {
continue
}
if tx.PayloadType != nil && *tx.PayloadType == payloadTypeAdvert {
continue
}
relayed++
}
score := float64(relayed) / float64(totalNonAdvert)
if score < 0 {
return 0
}
if score > 1 {
return 1
}
return score
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"testing"
)
// TestRepeaterUsefulness_BasicShare verifies that usefulness_score is
// relay_count_24h / total_non_advert_traffic_24h. With 1 of 4 relayed
// packets going through the repeater, score should be 0.25.
//
// Issue #672. We are intentionally implementing the *traffic share*
// dimension of the composite score from the issue body — bridge,
// coverage, redundancy are deferred to follow-up work. This is the
// "Traffic" axis of the table in #672.
func TestRepeaterUsefulness_BasicShare(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
pubkey := "aabbccdd11223344"
store := NewPacketStore(db, nil)
// 4 non-advert packets total in last hour. The repeater appears in
// the resolved path of exactly one of them.
pt := 1
for i := 0; i < 4; i++ {
tx := &StoreTx{RawHex: "0100", PayloadType: &pt, FirstSeen: recentTS(0)}
// Only first packet has our repeater in its path.
if i == 0 {
store.mu.Lock()
tx.ID = len(store.packets) + 1
tx.Hash = "uf-hit"
store.packets = append(store.packets, tx)
store.byHash[tx.Hash] = tx
store.byTxID[tx.ID] = tx
store.byPayloadType[pt] = append(store.byPayloadType[pt], tx)
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], tx)
store.mu.Unlock()
} else {
addTestPacket(store, tx)
}
}
score := store.GetRepeaterUsefulnessScore(pubkey)
// 1 relay / 4 total = 0.25
if score < 0.24 || score > 0.26 {
t.Errorf("expected usefulness ~0.25, got %f", score)
}
}
// TestRepeaterUsefulness_NoTraffic verifies score is 0 when there is
// no non-advert traffic to share.
func TestRepeaterUsefulness_NoTraffic(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
store := NewPacketStore(db, nil)
score := store.GetRepeaterUsefulnessScore("deadbeefcafebabe")
if score != 0 {
t.Errorf("expected 0 for empty store, got %f", score)
}
}
// TestRepeaterUsefulness_AdvertsExcluded verifies that ADVERT packets
// (payload_type=4) are excluded from both numerator and denominator —
// adverts don't count as forwarded traffic.
func TestRepeaterUsefulness_AdvertsExcluded(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
pubkey := "11aa22bb33cc44dd"
store := NewPacketStore(db, nil)
// 2 non-advert packets, both with our repeater in path → score = 1.0
pt := 1
for i := 0; i < 2; i++ {
tx := &StoreTx{RawHex: "0100", PayloadType: &pt, FirstSeen: recentTS(0)}
store.mu.Lock()
tx.ID = len(store.packets) + 1
tx.Hash = "uf-non-advert"
if i == 1 {
tx.Hash = "uf-non-advert-2"
}
store.packets = append(store.packets, tx)
store.byHash[tx.Hash] = tx
store.byTxID[tx.ID] = tx
store.byPayloadType[pt] = append(store.byPayloadType[pt], tx)
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], tx)
store.mu.Unlock()
}
// Add 100 adverts — these must be ignored.
advertPT := payloadTypeAdvert
for i := 0; i < 100; i++ {
tx := &StoreTx{RawHex: "0400", PayloadType: &advertPT, FirstSeen: recentTS(0)}
addTestPacket(store, tx)
}
score := store.GetRepeaterUsefulnessScore(pubkey)
if score < 0.99 || score > 1.01 {
t.Errorf("expected usefulness ~1.0 (adverts excluded), got %f", score)
}
}
+34
View File
@@ -151,6 +151,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/nodes/{pubkey}/health", s.handleNodeHealth).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}/paths", s.handleNodePaths).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}/analytics", s.handleNodeAnalytics).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}/battery", s.handleNodeBattery).Methods("GET")
r.HandleFunc("/api/nodes/clock-skew", s.handleFleetClockSkew).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}/clock-skew", s.handleNodeClockSkew).Methods("GET")
r.HandleFunc("/api/observers/clock-skew", s.handleObserverClockSkew).Methods("GET")
@@ -1097,16 +1098,37 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
if s.store != nil {
hashInfo := s.store.GetNodeHashSizeInfo()
mbCap := s.store.GetMultiByteCapMap()
relayWindow := s.cfg.GetHealthThresholds().RelayActiveHours
for _, node := range nodes {
if pk, ok := node["public_key"].(string); ok {
EnrichNodeWithHashSize(node, hashInfo[pk])
EnrichNodeWithMultiByte(node, mbCap[pk])
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
info := s.store.GetRepeaterRelayInfo(pk, relayWindow)
if info.LastRelayed != "" {
node["last_relayed"] = info.LastRelayed
}
node["relay_active"] = info.RelayActive
node["relay_count_1h"] = info.RelayCount1h
node["relay_count_24h"] = info.RelayCount24h
node["usefulness_score"] = s.store.GetRepeaterUsefulnessScore(pk)
}
}
}
}
if s.cfg.GeoFilter != nil {
filtered := nodes[:0]
for _, node := range nodes {
// Foreign-flagged nodes (#730) are kept even when their GPS lies
// outside the geofilter polygon — that's the whole point of the
// flag: operators need to SEE bridged/leaked nodes, not have them
// filtered away. The ingestor sets foreign_advert=1 when its
// configured geo_filter rejected the advert; the server must
// surface those.
if isForeign, _ := node["foreign"].(bool); isForeign {
filtered = append(filtered, node)
continue
}
if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
filtered = append(filtered, node)
}
@@ -1197,6 +1219,18 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
EnrichNodeWithHashSize(node, hashInfo[pubkey])
mbCap := s.store.GetMultiByteCapMap()
EnrichNodeWithMultiByte(node, mbCap[pubkey])
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
ht := s.cfg.GetHealthThresholds()
info := s.store.GetRepeaterRelayInfo(pubkey, ht.RelayActiveHours)
if info.LastRelayed != "" {
node["last_relayed"] = info.LastRelayed
}
node["relay_active"] = info.RelayActive
node["relay_window_hours"] = info.WindowHours
node["relay_count_1h"] = info.RelayCount1h
node["relay_count_24h"] = info.RelayCount24h
node["usefulness_score"] = s.store.GetRepeaterUsefulnessScore(pubkey)
}
}
name := ""
+70 -5
View File
@@ -3672,6 +3672,51 @@ func (s *PacketStore) GetChannels(region string) []map[string]interface{} {
})
}
// #688: scan decoded message text for #hashtag mentions and surface any
// previously-unseen channel names as discovered channels. We dedup against
// channelMap (matched by name) so a channel that already has traffic does
// NOT also appear as discovered.
discovered := map[string]string{} // name -> lastActivity
for _, snap := range snapshots {
if !snap.hasRegion {
continue
}
var decoded decodedGrp
if json.Unmarshal([]byte(snap.decodedJSON), &decoded) != nil {
continue
}
if decoded.Type != "CHAN" || decoded.Text == "" {
continue
}
if hasGarbageChars(decoded.Text) {
continue
}
for _, tag := range extractHashtagsFromText(decoded.Text) {
// Skip if already a known/decoded channel (by name with or without '#').
bare := tag[1:]
if _, ok := channelMap[tag]; ok {
continue
}
if _, ok := channelMap[bare]; ok {
continue
}
if existing, ok := discovered[tag]; !ok || snap.firstSeen > existing {
discovered[tag] = snap.firstSeen
}
}
}
for name, lastActivity := range discovered {
channels = append(channels, map[string]interface{}{
"hash": name,
"name": name,
"lastMessage": nil,
"lastSender": nil,
"messageCount": 0,
"lastActivity": lastActivity,
"discovered": true,
})
}
s.channelsCacheMu.Lock()
s.channelsCacheRes = channels
s.channelsCacheKey = cacheKey
@@ -5773,21 +5818,41 @@ func (s *PacketStore) GetAnalyticsHashSizes(region string) map[string]interface{
result := s.computeAnalyticsHashSizes(region)
// Add multi-byte capability data (only for unfiltered/global view)
// Multi-byte capability is a NODE property (derived from each node's own
// adverts), not a function of the observing region. The region filter
// should only control which nodes appear in the analytics list, not the
// evidence used to classify their capability. Always compute capability
// against the GLOBAL advert dataset so a region-filtered view doesn't
// downgrade every adopter to "unknown" just because the confirming
// advert was heard by an out-of-region observer (#bug: meshat.se/JKG
// showed 14 unknown vs 0 unknown unfiltered).
globalAdopterHS := make(map[string]int)
if region == "" {
// Pass adopter hash sizes so capability can cross-reference
adopterHS := make(map[string]int)
if mbNodes, ok := result["multiByteNodes"].([]map[string]interface{}); ok {
for _, n := range mbNodes {
pk, _ := n["pubkey"].(string)
hs, _ := n["hashSize"].(int)
if pk != "" && hs >= 2 {
adopterHS[pk] = hs
globalAdopterHS[pk] = hs
}
}
}
} else {
// Pull the global multiByteNodes set without the region filter.
// Use a separate compute call (not the cached path) to avoid
// recursive locking on hashCache and to keep this side-effect free.
globalRes := s.computeAnalyticsHashSizes("")
if mbNodes, ok := globalRes["multiByteNodes"].([]map[string]interface{}); ok {
for _, n := range mbNodes {
pk, _ := n["pubkey"].(string)
hs, _ := n["hashSize"].(int)
if pk != "" && hs >= 2 {
globalAdopterHS[pk] = hs
}
}
}
result["multiByteCapability"] = s.computeMultiByteCapability(adopterHS)
}
result["multiByteCapability"] = s.computeMultiByteCapability(globalAdopterHS)
s.cacheMu.Lock()
s.hashCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
+13 -2
View File
@@ -155,7 +155,8 @@
"infraSilentHours": 72,
"nodeDegradedHours": 1,
"nodeSilentHours": 24,
"_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others."
"relayActiveHours": 24,
"_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others. relayActiveHours: a repeater is shown as 'actively relaying' if its pubkey appeared as a path hop in a non-advert packet within this window (issue #662)."
},
"defaultRegion": "SJC",
"mapDefaults": {
@@ -175,6 +176,10 @@
"bufferKm": 20,
"_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon, save drafts to localStorage with Save Draft, and export a config snippet with Download — paste the snippet here as the `geo_filter` block. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through."
},
"foreignAdverts": {
"mode": "flag",
"_comment": "Controls how the ingestor handles ADVERTs whose GPS is OUTSIDE the geo_filter polygon (#730). 'flag' (default): store the advert/node and tag it foreign_advert=1 so operators can see bridged/leaked nodes via the API ('foreign': true on /api/nodes). 'drop': legacy behavior — silently discard the advert (no log, no node row). Only applies when geo_filter is configured; otherwise has no effect."
},
"regions": {
"SJC": "San Jose, US",
"SFO": "San Francisco, US",
@@ -218,7 +223,8 @@
"maxMemoryMB": 1024,
"estimatedPacketBytes": 450,
"retentionHours": 168,
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. retentionHours: only packets younger than this are loaded on startup and kept in memory (0 = unlimited, not recommended for large DBs — causes OOM on cold start). 168 = 7 days. Must be ≤ retention.packetDays * 24."
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. retentionHours: only packets younger than this are loaded on startup and kept in memory (0 = unlimited, not recommended for large DBs — causes OOM on cold start). 168 = 7 days. Must be ≤ retention.packetDays * 24.",
"_comment_gomemlimit": "On startup the server reads GOMEMLIMIT from the environment if set; otherwise it derives a Go runtime soft memory limit of maxMemoryMB * 1.5 and applies it via debug.SetMemoryLimit. This forces aggressive GC under cgroup pressure so the process self-throttles before the kernel SIGKILLs it. To override, set GOMEMLIMIT explicitly (e.g. GOMEMLIMIT=850MiB). See issue #836."
},
"resolvedPath": {
"backfillHours": 24,
@@ -228,6 +234,11 @@
"maxAgeDays": 5,
"_comment": "Neighbor edges older than this many days are pruned on startup and daily. Default: 5."
},
"batteryThresholds": {
"lowMv": 3300,
"criticalMv": 3000,
"_comment": "Voltage cutoffs (millivolts) for the per-node battery trend chart on /node-analytics. Latest sample below lowMv shows the node as ⚠️ Low; below criticalMv shows 🪫 Critical. Both default to 3300 / 3000 if omitted. Source data: observer_metrics.battery_mv populated from observer status messages; only nodes that are themselves observers (matching pubkey ↔ observer id) yield a series. Issue #663."
},
"_comment_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional). region: default IATA region for this source — used when packet/topic doesn't specify one (optional, priority: payload > topic > this field).",
"_comment_channelKeys": "Hex keys for decrypting channel messages. Key name = channel display name. public channel key is well-known.",
"_comment_hashChannels": "Channel names whose keys are derived via SHA256. Key = SHA256(name)[:16]. Listed here so the ingestor can auto-derive keys.",
+164 -11
View File
@@ -109,18 +109,38 @@
// Tab handling
const analyticsTabs = document.getElementById('analyticsTabs');
initTabBar(analyticsTabs);
// #749 — keep analytics tab + window in URL for deep-linking.
function _updateAnalyticsUrl() {
if (!window.URLState) return;
var twElNow = document.getElementById('analyticsTimeWindow');
var updates = {
tab: _currentTab && _currentTab !== 'overview' ? _currentTab : '',
window: twElNow && twElNow.value ? twElNow.value : ''
};
// Drop any subview-specific keys that don't belong to the active tab
// so switching tabs gives a clean URL. (rf-health uses 'range', 'observer', 'from', 'to')
if (_currentTab !== 'rf-health') {
var cleared = ['range', 'observer', 'from', 'to'];
for (var i = 0; i < cleared.length; i++) updates[cleared[i]] = '';
}
var newHash = URLState.updateHashParams(updates, location.hash);
if (newHash !== location.hash) history.replaceState(null, '', newHash);
}
analyticsTabs.addEventListener('click', e => {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_currentTab = btn.dataset.tab;
_updateAnalyticsUrl();
renderTab(_currentTab);
});
// Deep-link: #/analytics?tab=collisions
// Deep-link: #/analytics?tab=collisions&window=7d
const hashParams = location.hash.split('?')[1] || '';
const urlTab = new URLSearchParams(hashParams).get('tab');
const _ap = new URLSearchParams(hashParams);
const urlTab = _ap.get('tab');
if (urlTab) {
const tabBtn = analyticsTabs.querySelector(`[data-tab="${urlTab}"]`);
if (tabBtn) {
@@ -129,6 +149,12 @@
_currentTab = urlTab;
}
}
// #749 — restore time window from URL.
const urlWindow = _ap.get('window');
if (urlWindow) {
const twInit = document.getElementById('analyticsTimeWindow');
if (twInit) twInit.value = urlWindow;
}
RegionFilter.init(document.getElementById('analyticsRegionFilter'));
RegionFilter.onChange(function () { loadAnalytics(); });
@@ -136,7 +162,7 @@
// Time-window picker (#842) — refresh analytics on change.
const tw = document.getElementById('analyticsTimeWindow');
if (tw) {
tw.addEventListener('change', function () { loadAnalytics(); });
tw.addEventListener('change', function () { _updateAnalyticsUrl(); loadAnalytics(); });
}
// Delegated click/keyboard handler for clickable table rows
@@ -737,6 +763,7 @@
// ===================== CHANNELS =====================
var _channelSortState = null;
var _channelData = null;
var _channelRenderGen = 0;
var CHANNEL_SORT_KEY = 'meshcore-channel-sort';
function loadChannelSort() {
@@ -747,6 +774,18 @@
return { col: 'lastActivity', dir: 'desc' };
}
// True when the user has explicitly chosen a sort (saved in localStorage).
// Used by the grouped analytics view to decide whether to apply its own
// default ("messages desc") instead of the global flat-list default.
function hasSavedChannelSort() {
try {
var s = localStorage.getItem(CHANNEL_SORT_KEY);
if (!s) return false;
var p = JSON.parse(s);
return !!(p && p.col && p.dir);
} catch (e) { return false; }
}
function saveChannelSort(state) {
try { localStorage.setItem(CHANNEL_SORT_KEY, JSON.stringify(state)); } catch (e) {}
}
@@ -781,20 +820,107 @@
}
function channelRowHtml(c) {
var name = c.displayName || c.name || 'Unknown';
return '<tr class="clickable-row" data-action="navigate" data-value="#/channels?ch=' + c.hash + '" tabindex="0" role="row">' +
'<td><strong>' + esc(c.name || 'Unknown') + '</strong></td>' +
'<td><strong>' + esc(name) + '</strong></td>' +
'<td class="mono">' + (typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash) + '</td>' +
'<td>' + c.messages + '</td>' +
'<td>' + c.senders + '</td>' +
'<td>' + timeAgo(c.lastActivity) + '</td>' +
'<td>' + (c.encrypted ? '🔒' : '✅') + '</td>' +
'<td>' + (c.encrypted ? (c.group === 'mine' ? '🔑' : '🔒') : '✅') + '</td>' +
'</tr>';
}
function channelTbodyHtml(channels, col, dir) {
// ── PSK-aware decoration ──────────────────────────────────────────────────
// Server returns raw "chNNN" placeholder names for encrypted channels it
// doesn't know. Decorate so the UI shows a useful display name and a
// group bucket: mine / network / encrypted. Pure function for testability.
function decorateAnalyticsChannels(channels, hashByteToKeyName, labels) {
var keyMap = hashByteToKeyName || {};
var lab = labels || {};
var out = [];
for (var i = 0; i < (channels || []).length; i++) {
var c = channels[i];
var copy = Object.assign({}, c);
var hashNum = typeof c.hash === 'number' ? c.hash : parseInt(c.hash, 10);
var rawName = String(c.name || '');
var isPlaceholder = /^ch(\d+|\?)$/.test(rawName);
if (c.encrypted) {
var keyName = !isNaN(hashNum) ? keyMap[hashNum] : null;
if (keyName) {
copy.displayName = lab[keyName] || keyName;
copy.group = 'mine';
} else if (isPlaceholder || !rawName) {
// Placeholder ("chNNN") or empty name → render as opaque encrypted.
// Empty-name encrypted rows would otherwise leak through with an
// empty <strong> in the row; force the placeholder rendering.
copy.displayName = !isNaN(hashNum)
? '🔒 Encrypted (0x' + hashNum.toString(16).toUpperCase().padStart(2, '0') + ')'
: '🔒 Encrypted';
copy.group = 'encrypted';
} else {
// Server gave us a real name (rainbow table hit) for an encrypted ch.
copy.displayName = rawName;
copy.group = 'network';
}
} else {
copy.displayName = rawName || 'Unknown';
copy.group = 'network';
}
out.push(copy);
}
return out;
}
// Build the (hash byte → key name) map from ChannelDecrypt's stored keys.
// Async because computeChannelHash uses subtle.digest. Returns {} if the
// module or its keys are unavailable (graceful fallback).
async function buildHashKeyMap() {
if (typeof ChannelDecrypt === 'undefined' || !ChannelDecrypt.getStoredKeys) return {};
var keys = ChannelDecrypt.getStoredKeys();
var map = {};
var names = Object.keys(keys || {});
for (var ni = 0; ni < names.length; ni++) {
var name = names[ni];
try {
var bytes = ChannelDecrypt.hexToBytes(keys[name]);
var hb = await ChannelDecrypt.computeChannelHash(bytes);
if (typeof hb === 'number') map[hb] = name;
} catch (e) { /* skip bad key */ }
}
return map;
}
function channelTbodyHtml(channels, col, dir, opts) {
var sorted = sortChannels(channels, col, dir);
var parts = [];
for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i]));
if (opts && opts.grouped) {
// Group by .group: mine → network → encrypted. Inside each group keep
// the active sort (caller passes col/dir; for the integration we sort
// by messages desc by default).
var groups = { mine: [], network: [], encrypted: [] };
for (var gi = 0; gi < sorted.length; gi++) {
var g = sorted[gi].group || (sorted[gi].encrypted ? 'encrypted' : 'network');
(groups[g] || (groups[g] = [])).push(sorted[gi]);
}
var sections = [
{ key: 'mine', label: '🔑 My Channels' },
{ key: 'network', label: '📻 Network' },
{ key: 'encrypted', label: '🔒 Encrypted' },
];
for (var si = 0; si < sections.length; si++) {
var rows = groups[sections[si].key] || [];
if (!rows.length) continue;
parts.push(
'<tr class="ch-section-row"><td colspan="6" class="ch-section-header">' +
esc(sections[si].label) + ' <span class="text-muted">(' + rows.length + ')</span>' +
'</td></tr>'
);
for (var ri = 0; ri < rows.length; ri++) parts.push(channelRowHtml(rows[ri]));
}
} else {
for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i]));
}
return parts.join('');
}
@@ -825,13 +951,39 @@
var tbody = document.getElementById('channelsTbody');
var thead = document.querySelector('#channelsTable thead');
if (!tbody || !_channelData) return;
tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir);
tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true });
if (thead) thead.outerHTML = channelTheadHtml(_channelSortState.col, _channelSortState.dir);
}
function renderChannels(el, ch) {
_channelData = ch.channels;
if (!_channelSortState) _channelSortState = loadChannelSort();
// Decorate first so grouping/display name reflect locally-stored PSK keys.
// buildHashKeyMap is async; render once with a sync best-effort empty map,
// then upgrade once keys resolve. That keeps first paint fast and avoids
// blocking on subtle.digest in environments where it's slow.
var rawChannels = ch.channels || [];
// Resolve the persisted sort first so the default-fallback below doesn't
// shadow what the user previously chose. Default for the grouped view is
// messages desc (matches the PR description); only used when nothing saved.
if (!_channelSortState) {
_channelSortState = hasSavedChannelSort()
? loadChannelSort()
: { col: 'messages', dir: 'desc' };
}
var ranOnce = false;
// Generation token: if renderChannels is called again before
// buildHashKeyMap() resolves, the older promise must not clobber the
// newer rawChannels / decoration with stale-key data.
var myGen = ++_channelRenderGen;
function applyDecorate(map) {
if (myGen !== _channelRenderGen) return; // superseded
var labels = (typeof ChannelDecrypt !== 'undefined' && ChannelDecrypt.getLabels)
? ChannelDecrypt.getLabels() : {};
_channelData = decorateAnalyticsChannels(rawChannels, map, labels);
if (ranOnce) updateChannelTable();
}
applyDecorate({});
ranOnce = true;
buildHashKeyMap().then(applyDecorate).catch(function () { /* graceful */ });
var timelineHtml = renderChannelTimeline(ch.channelTimeline);
var topSendersHtml = renderTopSenders(ch.topSenders);
@@ -844,7 +996,7 @@
'<table class="analytics-table" id="channelsTable">' +
channelTheadHtml(_channelSortState.col, _channelSortState.dir) +
'<tbody id="channelsTbody">' +
channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir) +
channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true }) +
'</tbody>' +
'</table>' +
'</div>' +
@@ -2055,6 +2207,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
// Expose for testing
if (typeof window !== 'undefined') {
window._analyticsDecorateChannels = decorateAnalyticsChannels;
window._analyticsSortChannels = sortChannels;
window._analyticsLoadChannelSort = loadChannelSort;
window._analyticsSaveChannelSort = saveChannelSort;
+143
View File
@@ -501,6 +501,148 @@ function connectWS() {
function onWS(fn) { wsListeners.push(fn); }
function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); }
// --- Pull-to-reconnect (#1063) ---
// Touch-device pull-down at scrollTop=0 reconnects the WebSocket
// (instead of triggering native pull-to-refresh full-page reload).
// Visual indicator pulses during pull; toast confirms result.
const PULL_THRESHOLD_PX = 80;
let _pullToast = null;
let _pullToastTimer = null;
let _pullIndicator = null;
function _ensurePullIndicator() {
if (_pullIndicator && document.body && typeof document.body.contains === 'function' && document.body.contains(_pullIndicator)) return _pullIndicator;
if (_pullIndicator) return _pullIndicator;
const el = document.createElement('div');
el.id = 'pullReconnectIndicator';
el.setAttribute('aria-hidden', 'true');
el.innerHTML = '<span class="prr-icon">⟳</span>';
el.style.cssText = [
'position:fixed', 'top:0', 'left:50%', 'transform:translate(-50%,-100%)',
'z-index:99999', 'padding:8px 14px', 'border-radius:0 0 12px 12px',
'background:var(--accent,#2563eb)', 'color:#fff', 'font:14px/1 var(--font,system-ui)',
'box-shadow:0 2px 8px rgba(0,0,0,.2)', 'pointer-events:none',
'transition:transform .15s ease, opacity .15s ease', 'opacity:0',
].join(';');
document.body.appendChild(el);
_pullIndicator = el;
return el;
}
function _showPullToast(msg, ok) {
try {
if (_pullToast && _pullToast.remove) _pullToast.remove();
} catch (e) {}
if (_pullToastTimer) { try { clearTimeout(_pullToastTimer); } catch (e) {} _pullToastTimer = null; }
const el = document.createElement('div');
el.className = 'pull-reconnect-toast';
el.textContent = msg;
el.style.cssText = [
'position:fixed', 'top:12px', 'left:50%', 'transform:translateX(-50%)',
'z-index:99999', 'padding:8px 16px', 'border-radius:8px',
'background:' + (ok ? 'var(--status-green,#16a34a)' : 'var(--status-red,#dc2626)'),
'color:#fff', 'font:14px/1.2 var(--font,system-ui)',
'box-shadow:0 2px 8px rgba(0,0,0,.2)', 'pointer-events:none',
].join(';');
document.body.appendChild(el);
_pullToast = el;
_pullToastTimer = setTimeout(function () {
_pullToastTimer = null;
try { el.remove(); } catch (e) {}
}, 1800);
}
function pullReconnect() {
// If WS is connected (readyState OPEN), give a brief "Connected ✓"
// confirmation but still cycle so the user sees fresh data.
const wasOpen = ws && ws.readyState === 1;
if (wasOpen) {
_showPullToast('Connected ✓', true);
// Fast cycle: close and let onclose reconnect immediately
try { ws.close(); } catch (e) {}
} else {
_showPullToast('Reconnecting…', true);
try { if (ws) ws.close(); } catch (e) {}
// onclose handler schedules reconnect; force one now in case ws was null
try { connectWS(); } catch (e) {}
}
}
function _isTouchDevice() {
try {
return ('ontouchstart' in window) ||
(navigator && (navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0));
} catch (e) { return false; }
}
function setupPullToReconnect() {
// Always attach listeners (tests + future-proof). Inside the handler we
// gate on _isTouchDevice() AND scrollTop=0 so desktop/scrolled pages are
// unaffected.
let startY = null;
let pulling = false;
let dist = 0;
function getScrollTop() {
return (document.documentElement && document.documentElement.scrollTop) ||
(document.body && document.body.scrollTop) || 0;
}
function onStart(e) {
if (!_isTouchDevice()) return;
if (getScrollTop() > 0) { startY = null; pulling = false; return; }
const t = e.touches && e.touches[0];
startY = t ? t.clientY : null;
pulling = false;
dist = 0;
}
function onMove(e) {
if (startY == null) return;
if (getScrollTop() > 0) { startY = null; pulling = false; return; }
const t = e.touches && e.touches[0];
if (!t) return;
const dy = t.clientY - startY;
if (dy <= 0) return; // upward swipe — ignore
dist = dy;
if (dy > 8) {
pulling = true;
const ind = _ensurePullIndicator();
const pct = Math.min(1, dy / PULL_THRESHOLD_PX);
ind.style.opacity = String(pct);
ind.style.transform = 'translate(-50%, ' + (-100 + pct * 100) + '%)';
const icon = ind.querySelector && ind.querySelector('.prr-icon');
if (icon) icon.style.transform = 'rotate(' + Math.round(pct * 360) + 'deg)';
// Prevent native pull-to-refresh ONLY once we've committed to the gesture
if (dy > 16 && typeof e.preventDefault === 'function' && e.cancelable !== false) {
try { e.preventDefault(); } catch (_) {}
}
}
}
function onEnd() {
const wasPulling = pulling;
const finalDist = dist;
startY = null; pulling = false; dist = 0;
if (_pullIndicator) {
_pullIndicator.style.opacity = '0';
_pullIndicator.style.transform = 'translate(-50%, -100%)';
}
if (wasPulling && finalDist >= PULL_THRESHOLD_PX) {
try { (window.pullReconnect || pullReconnect)(); } catch (e) {}
}
}
document.addEventListener('touchstart', onStart, { passive: true });
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onEnd, { passive: true });
document.addEventListener('touchcancel', onEnd, { passive: true });
}
window.pullReconnect = pullReconnect;
window.setupPullToReconnect = setupPullToReconnect;
window.connectWS = connectWS;
/* Global escapeHtml — used by multiple pages */
function escapeHtml(s) {
if (s == null) return '';
@@ -676,6 +818,7 @@ window.addEventListener('timestamp-mode-changed', () => {
});
window.addEventListener('DOMContentLoaded', () => {
connectWS();
setupPullToReconnect();
// --- Dark Mode ---
const darkToggle = document.getElementById('darkModeToggle');
+256
View File
@@ -0,0 +1,256 @@
/**
* channel-qr.js QR code generation + scanning for MeshCore channels.
*
* URL format (per firmware spec):
* meshcore://channel/add?name=<urlencoded>&secret=<32hex>
*
* Public API (window.ChannelQR):
* buildUrl(name, secretHex) string
* parseChannelUrl(url) {name, secret} | null
* generate(name, secretHex, target) renders QR + URL + Copy Key into `target`
* scan() Promise<{name, secret} | null>
*
* Self-contained: does NOT touch channels.js / channel-decrypt.js.
* The PR that wires the modal into this module is #3.
*
* Vendored deps (loaded by index.html):
* - public/vendor/qrcode.js (davidshimjs/qrcodejs, MIT) QR rendering
* - public/vendor/jsqr.min.js (cozmo/jsQR, Apache-2.0) QR decoding from camera
*/
(function (root) {
'use strict';
const SCHEME_PREFIX = 'meshcore://channel/add';
const HEX32_RE = /^[0-9a-fA-F]{32}$/;
function buildUrl(name, secretHex) {
return SCHEME_PREFIX + '?name=' + encodeURIComponent(String(name)) +
'&secret=' + String(secretHex);
}
/**
* parseChannelUrl(url) { name, secret } | null
* Strict: scheme must be `meshcore:`, host+path `//channel/add`,
* both `name` and `secret` query params present, secret must be 32 hex chars.
*/
function parseChannelUrl(url) {
if (!url || typeof url !== 'string') return null;
if (url.indexOf(SCHEME_PREFIX) !== 0) return null;
// Strip prefix → query string
const rest = url.slice(SCHEME_PREFIX.length);
if (rest[0] !== '?' && rest !== '') return null;
const qs = rest.slice(1);
if (!qs) return null;
const params = {};
const pairs = qs.split('&');
for (let i = 0; i < pairs.length; i++) {
const eq = pairs[i].indexOf('=');
if (eq < 0) continue;
const k = pairs[i].slice(0, eq);
const v = pairs[i].slice(eq + 1);
try { params[k] = decodeURIComponent(v); }
catch (_e) { return null; }
}
if (!params.name || !params.secret) return null;
if (!HEX32_RE.test(params.secret)) return null;
return { name: params.name, secret: params.secret.toLowerCase() };
}
// ---------- DOM helpers (browser-only) ----------
function _hasDom() {
return typeof document !== 'undefined' && document.createElement;
}
/**
* Render QR + URL + Copy Key button into `target`.
* Requires window.QRCode (vendor/qrcode.js) loaded.
*/
function generate(name, secretHex, target) {
if (!_hasDom() || !target) return;
target.innerHTML = '';
const url = buildUrl(name, secretHex);
const qrBox = document.createElement('div');
qrBox.className = 'channel-qr-canvas';
qrBox.style.display = 'inline-block';
target.appendChild(qrBox);
if (typeof root.QRCode === 'function') {
try {
// davidshimjs/qrcodejs API: new QRCode(el, {text, width, height, ...})
new root.QRCode(qrBox, {
text: url,
width: 192,
height: 192,
correctLevel: root.QRCode.CorrectLevel ? root.QRCode.CorrectLevel.M : 0,
});
} catch (e) {
qrBox.textContent = '[QR render failed: ' + (e && e.message || e) + ']';
}
} else {
qrBox.textContent = '[QR library not loaded]';
}
const urlLine = document.createElement('div');
urlLine.className = 'channel-qr-url';
urlLine.style.cssText = 'font-family:monospace;font-size:11px;word-break:break-all;margin-top:6px;';
urlLine.textContent = url;
target.appendChild(urlLine);
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'channel-qr-copy';
copyBtn.textContent = '📋 Copy Key';
copyBtn.style.cssText = 'margin-top:6px;';
copyBtn.addEventListener('click', function () {
const text = secretHex;
const done = function () {
const orig = copyBtn.textContent;
copyBtn.textContent = '✓ Copied';
setTimeout(function () { copyBtn.textContent = orig; }, 1200);
};
if (root.navigator && root.navigator.clipboard && root.navigator.clipboard.writeText) {
root.navigator.clipboard.writeText(text).then(done, function () {
// Fallback: select text in a temp input
_fallbackCopy(text); done();
});
} else {
_fallbackCopy(text); done();
}
});
target.appendChild(copyBtn);
}
function _fallbackCopy(text) {
if (!_hasDom()) return;
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0;';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (_e) {}
document.body.removeChild(ta);
}
// ---------- Camera scan ----------
/**
* scan() Promise<{name, secret} | null>
*
* Opens a small modal with a live camera preview, decodes via jsQR,
* resolves with the parsed channel info on first valid match. Closes
* camera on resolve/reject. Resolves with `null` if user cancels or
* camera permission is denied (graceful fallback path).
*/
function scan() {
if (!_hasDom()) return Promise.resolve(null);
const nav = root.navigator;
if (!nav || !nav.mediaDevices || !nav.mediaDevices.getUserMedia ||
typeof root.jsQR !== 'function') {
_showCameraFallback();
return Promise.resolve(null);
}
return new Promise(function (resolve) {
const overlay = document.createElement('div');
overlay.className = 'channel-qr-scan-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);' +
'display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:99999;';
const video = document.createElement('video');
video.setAttribute('playsinline', 'true');
video.style.cssText = 'max-width:90vw;max-height:60vh;background:#000;';
overlay.appendChild(video);
const status = document.createElement('div');
status.style.cssText = 'color:#fff;margin-top:12px;font-family:sans-serif;';
status.textContent = 'Point camera at a MeshCore channel QR…';
overlay.appendChild(status);
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = 'margin-top:12px;';
overlay.appendChild(cancelBtn);
document.body.appendChild(overlay);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let stream = null;
let rafId = 0;
let done = false;
function cleanup(result) {
if (done) return;
done = true;
if (rafId) cancelAnimationFrame(rafId);
if (stream) {
stream.getTracks().forEach(function (t) { try { t.stop(); } catch (_e) {} });
}
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
resolve(result);
}
cancelBtn.addEventListener('click', function () { cleanup(null); });
function tick() {
if (done) return;
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
let imgData;
try { imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); }
catch (_e) { rafId = requestAnimationFrame(tick); return; }
const code = root.jsQR(imgData.data, imgData.width, imgData.height, {
inversionAttempts: 'dontInvert',
});
if (code && code.data) {
const parsed = parseChannelUrl(code.data);
if (parsed) { cleanup(parsed); return; }
status.textContent = 'QR found but not a MeshCore channel — keep trying…';
}
}
rafId = requestAnimationFrame(tick);
}
nav.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
.then(function (s) {
stream = s;
video.srcObject = s;
video.play().then(function () { tick(); }, function () { tick(); });
})
.catch(function () {
status.textContent = 'Camera not available — paste key manually.';
setTimeout(function () { cleanup(null); }, 1800);
});
});
}
function _showCameraFallback() {
if (!_hasDom()) return;
const note = document.createElement('div');
note.className = 'channel-qr-fallback';
note.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);' +
'background:#222;color:#fff;padding:10px 14px;border-radius:6px;z-index:99999;';
note.textContent = 'Camera not available — paste key manually.';
document.body.appendChild(note);
setTimeout(function () {
if (note.parentNode) note.parentNode.removeChild(note);
}, 2500);
}
root.ChannelQR = {
buildUrl: buildUrl,
parseChannelUrl: parseChannelUrl,
generate: generate,
scan: scan,
};
})(typeof window !== 'undefined' ? window : globalThis);
+423 -123
View File
@@ -631,33 +631,77 @@
<div class="ch-sidebar" aria-label="Channel list">
<div class="ch-sidebar-header">
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
<label class="ch-encrypted-toggle" title="Show encrypted channels (no key configured)">
<input type="checkbox" id="chShowEncrypted"> <span class="ch-toggle-label">🔒 No key</span>
</label>
</div>
<div class="ch-key-input-wrap" style="padding:4px 8px">
<form id="chKeyForm" autocomplete="off" class="ch-add-form">
<div class="ch-add-row">
<input type="text" id="chKeyInput" class="ch-key-input"
placeholder="#channelname"
aria-label="Channel name or hex key" spellcheck="false">
<button type="submit" class="ch-add-btn" title="Add channel">+</button>
</div>
<div class="ch-add-row">
<input type="text" id="chKeyLabelInput" class="ch-key-label-input"
placeholder="optional name (e.g. My Crew)"
aria-label="Optional display name for this channel" spellcheck="false">
</div>
<div class="ch-add-hint">e.g. #LongFast or 32-char hex key decrypted in your browser.</div>
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
</form>
<button type="button" id="chAddChannelBtn" class="ch-add-channel-btn"
aria-label="Add channel" title="Add a channel — generate, paste a key, or monitor a hashtag">+ Add Channel</button>
</div>
<a href="#/analytics" class="ch-analytics-link"
title="Open the Analytics page to see channel activity stats">📊 Channel Analytics </a>
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
<div id="chRegionFilter" class="region-filter-container" style="padding:0 8px"></div>
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
<div class="ch-loading">Loading channels</div>
</div>
<div class="ch-sidebar-resize" aria-hidden="true"></div>
</div>
<!-- #1034 PR1: Add Channel modal -->
<div id="chAddChannelModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chModalTitle" hidden>
<div class="modal ch-modal" role="document">
<button type="button" class="modal-close ch-modal-close" id="chModalClose" data-action="ch-modal-close" aria-label="Close"></button>
<h3 id="chModalTitle">Add Channel</h3>
<div class="ch-modal-callout" role="note">
Channels are saved to <strong>THIS browser only</strong>. They won't appear on other devices or browsers, and clearing browser data will remove them.
</div>
<section class="ch-modal-section" aria-labelledby="chSecGenTitle">
<h4 id="chSecGenTitle" class="ch-modal-section-title">Generate PSK Channel</h4>
<p class="ch-modal-section-hint">Create a new private channel with a random key. Share the QR code with others to add it.</p>
<div class="ch-modal-row">
<input type="text" id="chGenerateName" class="ch-modal-input" placeholder="Channel name (e.g. My Crew)" aria-label="Channel name" spellcheck="false">
<button type="button" id="chGenerateBtn" class="btn-primary">Generate &amp; Show QR</button>
</div>
<div id="qr-output" class="ch-qr-output" aria-live="polite"></div>
</section>
<section class="ch-modal-section" aria-labelledby="chSecPskTitle">
<h4 id="chSecPskTitle" class="ch-modal-section-title">Add Private Channel (PSK)</h4>
<p class="ch-modal-section-hint">Paste a 32-character hex key someone shared with you, or scan their QR code.</p>
<div class="ch-modal-row">
<input type="text" id="chPskKey" class="ch-modal-input ch-modal-input--mono"
placeholder="32-char hex key (0-9, a-f)"
pattern="[0-9a-fA-F]{32}"
maxlength="32"
aria-label="32-character hex PSK key" spellcheck="false" autocomplete="off">
<button type="button" id="scan-qr-btn" class="ch-modal-btn-secondary" title="Scan a meshcore:// channel QR with your camera">📷 Scan QR</button>
</div>
<div class="ch-modal-row">
<input type="text" id="chPskName" class="ch-modal-input" placeholder="Display name (optional)" aria-label="Optional display name" spellcheck="false">
<button type="button" id="chPskAddBtn" class="btn-primary">Add</button>
</div>
<div id="chPskError" class="ch-modal-error" style="display:none" role="alert"></div>
</section>
<section class="ch-modal-section" aria-labelledby="chSecTagTitle">
<h4 id="chSecTagTitle" class="ch-modal-section-title">Monitor Hashtag Channel</h4>
<p class="ch-modal-section-hint">Decrypt traffic on a public hashtag channel by deriving the key from its name.</p>
<div class="ch-modal-row ch-hashtag-row">
<span class="ch-hashtag-prefix" aria-hidden="true">#</span>
<input type="text" id="chHashtagName" class="ch-modal-input"
placeholder="meshcore"
aria-label="Hashtag channel name (without #)" spellcheck="false" autocomplete="off">
<button type="button" id="chHashtagBtn" class="btn-primary">Monitor</button>
</div>
<div class="ch-modal-warn"> Case-sensitive <code>#meshcore</code> <code>#MeshCore</code></div>
</section>
<section id="chShareSection" class="ch-modal-section" hidden aria-labelledby="chShareHeading">
<h4 id="chShareHeading" class="ch-modal-section-title">Share Channel</h4>
<div id="chShareOutput" class="ch-share-output" aria-live="polite"></div>
</section>
<div class="ch-modal-footer">
🔒 Keys stay in your browser CoreScope is a passive observer that monitors and decrypts traffic but cannot transmit over RF. Use to remove individual channels.
</div>
</div>
</div>
<div class="ch-main" role="region" aria-label="Channel messages">
<div class="ch-main-header" id="chHeader">
<button class="ch-back-btn" id="chBackBtn" aria-label="Back to channels" data-action="ch-back"></button>
@@ -673,15 +717,10 @@
RegionFilter.init(document.getElementById('chRegionFilter'));
// Encrypted channels toggle (#727)
var showEncryptedCb = document.getElementById('chShowEncrypted');
var showEncrypted = localStorage.getItem('channels-show-encrypted') === 'true';
showEncryptedCb.checked = showEncrypted;
showEncryptedCb.addEventListener('change', function () {
showEncrypted = showEncryptedCb.checked;
localStorage.setItem('channels-show-encrypted', showEncrypted ? 'true' : 'false');
loadChannels(true);
});
// #1034 PR1: encrypted-channels visibility now driven by sectioned sidebar.
// Always include encrypted channels in the API call; the renderer groups them.
var showEncrypted = true;
try { localStorage.setItem('channels-show-encrypted', 'true'); } catch (e) { /* quota */ }
regionChangeHandler = RegionFilter.onChange(function () {
loadChannels(true).then(async function () {
@@ -690,36 +729,135 @@
});
});
// Channel key input handler (#725 M2, improved UX #759)
var chKeyForm = document.getElementById('chKeyForm');
if (chKeyForm) {
var submitHandler = async function (e) {
e.preventDefault();
var input = document.getElementById('chKeyInput');
var labelInput = document.getElementById('chKeyLabelInput');
var val = (input.value || '').trim();
var label = labelInput ? (labelInput.value || '').trim() : '';
if (!val) return;
input.value = '';
if (labelInput) labelInput.value = '';
await addUserChannel(val, label);
};
chKeyForm.addEventListener('submit', submitHandler);
var chKeyInput = document.getElementById('chKeyInput');
if (chKeyInput) {
chKeyInput.addEventListener('focus', function () {
var st = document.getElementById('chAddStatus');
if (st) { st.style.display = 'none'; clearTimeout(statusTimer); statusTimer = null; }
});
}
// #1034 PR1: Add Channel modal wiring (replaces inline form)
var modalEl = document.getElementById('chAddChannelModal');
function openAddModal() {
if (!modalEl) return;
modalEl.classList.remove('hidden');
modalEl.removeAttribute('hidden');
var first = document.getElementById('chGenerateName');
if (first) try { first.focus(); } catch (e) { /* noop */ }
}
function closeAddModal() {
if (!modalEl) return;
modalEl.classList.add('hidden');
modalEl.setAttribute('hidden', '');
var err = document.getElementById('chPskError');
if (err) { err.style.display = 'none'; err.textContent = ''; }
var shareOut = document.getElementById('chShareOutput');
if (shareOut) { shareOut.innerHTML = ''; }
var shareSec = document.getElementById('chShareSection');
if (shareSec) { shareSec.hidden = true; }
}
var addBtn = document.getElementById('chAddChannelBtn');
if (addBtn) addBtn.addEventListener('click', openAddModal);
if (modalEl) {
modalEl.addEventListener('click', function (e) {
// Close on overlay backdrop click or any [data-action=ch-modal-close]
var closeEl = e.target.closest('[data-action="ch-modal-close"]');
if (closeEl || e.target === modalEl) {
e.preventDefault();
closeAddModal();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modalEl.classList.contains('hidden')) {
closeAddModal();
}
});
}
// Auto-enable encrypted toggle if deep-linking to an encrypted channel
if (routeParam && routeParam.startsWith('enc_') && !showEncrypted) {
showEncrypted = true;
showEncryptedCb.checked = true;
localStorage.setItem('channels-show-encrypted', 'true');
}
// Section 1: Generate PSK
var genBtn = document.getElementById('chGenerateBtn');
if (genBtn) genBtn.addEventListener('click', async function () {
var nameEl = document.getElementById('chGenerateName');
var label = nameEl ? (nameEl.value || '').trim() : '';
// 16 random bytes -> 32-char hex
var bytes = crypto.getRandomValues(new Uint8Array(16));
var keyHex = ChannelDecrypt.bytesToHex(bytes);
var channelName = 'psk:' + keyHex.substring(0, 8);
ChannelDecrypt.storeKey(channelName, keyHex, label);
var qrOut = document.getElementById('qr-output');
if (qrOut) {
qrOut.innerHTML = '';
// Render the QR + meshcore:// URL + Copy Key inline. The QR
// helper handles canvas rendering + accessible copy controls.
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
// Use the user-supplied label when provided so the scanned
// recipient sees a meaningful name; fall back to the
// psk:<prefix> auto-name otherwise.
window.ChannelQR.generate(label || channelName, keyHex, qrOut);
} else {
// Fallback when channel-qr.js failed to load.
qrOut.textContent = 'Key generated: ' + keyHex;
}
}
mergeUserChannels();
renderChannelList();
showAddStatus('Generated channel ' + (label || channelName), 'success');
});
// Section 2: Add PSK
var pskBtn = document.getElementById('chPskAddBtn');
if (pskBtn) pskBtn.addEventListener('click', async function () {
var keyEl = document.getElementById('chPskKey');
var nameEl = document.getElementById('chPskName');
var errEl = document.getElementById('chPskError');
var raw = keyEl ? (keyEl.value || '').trim() : '';
var label = nameEl ? (nameEl.value || '').trim() : '';
if (!isHexKey(raw)) {
if (errEl) { errEl.textContent = 'Key must be 32 hex characters (09, af).'; errEl.style.display = ''; }
return;
}
if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; }
closeAddModal();
if (keyEl) keyEl.value = '';
if (nameEl) nameEl.value = '';
await addUserChannel(raw.toLowerCase(), label);
});
// Section 2 (cont.): Scan QR — populates #chPskKey + #chPskName
// from a scanned meshcore://channel/add?... URL. Wiring added in
// PR #1034/PR3 against window.ChannelQR (public/channel-qr.js).
var scanBtn = document.getElementById('scan-qr-btn');
if (scanBtn) scanBtn.addEventListener('click', async function () {
var errEl = document.getElementById('chPskError');
if (!window.ChannelQR || typeof window.ChannelQR.scan !== 'function') {
if (errEl) {
errEl.textContent = 'QR scanning is unavailable in this browser.';
errEl.style.display = '';
}
return;
}
try {
var result = await window.ChannelQR.scan();
if (!result) return; // user cancelled
var keyEl = document.getElementById('chPskKey');
var nameEl = document.getElementById('chPskName');
if (keyEl && result.secret) keyEl.value = result.secret;
if (nameEl && result.name) nameEl.value = result.name;
if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; }
} catch (err) {
if (errEl) {
errEl.textContent = 'Scan failed: ' + (err && err.message ? err.message : 'unknown error');
errEl.style.display = '';
}
}
});
// Section 3: Monitor Hashtag
var tagBtn = document.getElementById('chHashtagBtn');
if (tagBtn) tagBtn.addEventListener('click', async function () {
var tagEl = document.getElementById('chHashtagName');
var raw = tagEl ? (tagEl.value || '').trim() : '';
if (!raw) return;
// Strip a leading '#' if the user typed one — the prefix is implicit.
if (raw.charAt(0) === '#') raw = raw.substring(1);
if (!raw) return;
closeAddModal();
if (tagEl) tagEl.value = '';
await addUserChannel('#' + raw, '');
});
loadObserverRegions();
loadChannels().then(async function () {
@@ -771,7 +909,60 @@
});
// Event delegation for channel selection (touch-friendly)
document.getElementById('chList').addEventListener('click', (e) => {
var chListEl = document.getElementById('chList');
// Keyboard accessibility for the role="button" remove/share spans
// (Enter/Space). Single .closest() call with a combined selector.
chListEl.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
var rb = e.target.closest && e.target.closest('[data-remove-channel],[data-share-channel]');
if (!rb) return;
e.preventDefault();
e.stopPropagation();
// Re-dispatch as a click so the existing click handler runs.
rb.click();
});
chListEl.addEventListener('click', (e) => {
// Share/reshare: open the Add Channel modal and render QR + URL
// for the existing key (no re-generation).
const shareBtn = e.target.closest('[data-share-channel]');
if (shareBtn) {
e.stopPropagation();
var shareHash = shareBtn.getAttribute('data-share-channel');
if (!shareHash) return;
var sCh = channels.find(function (c) { return c.hash === shareHash; });
var sName = shareHash.startsWith('user:')
? shareHash.substring(5)
: (sCh && sCh.name) || shareHash;
var keys = ChannelDecrypt.getStoredKeys();
var keyHex = keys[sName];
if (typeof openAddModal === 'function') openAddModal();
var sec = document.getElementById('chShareSection');
var out = document.getElementById('chShareOutput');
if (!sec || !out) return;
sec.hidden = false;
out.innerHTML = '';
if (!keyHex) {
out.textContent = 'No stored key found for "' + sName + '" — cannot share.';
return;
}
var heading = document.createElement('div');
heading.className = 'ch-share-heading';
heading.textContent = 'Share "' + sName + '"';
out.appendChild(heading);
var holder = document.createElement('div');
out.appendChild(holder);
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
window.ChannelQR.generate(sName, keyHex, holder);
} else {
// Fallback: copyable hex + meshcore:// URL.
var url = 'meshcore://channel/add?name=' + encodeURIComponent(sName) +
'&secret=' + keyHex;
holder.innerHTML =
'<div>Key: <code>' + escapeHtml(keyHex) + '</code></div>' +
'<div>URL: <code>' + escapeHtml(url) + '</code></div>';
}
return;
}
// M4: Remove channel button
const removeBtn = e.target.closest('[data-remove-channel]');
if (removeBtn) {
@@ -785,7 +976,7 @@
var chName = channelHash.startsWith('user:')
? channelHash.substring(5)
: (ch && ch.name) || channelHash;
if (!confirm('Remove channel "' + chName + '"? This will clear saved keys and cached messages.')) return;
if (!confirm('Remove channel "' + chName + '"?\n\nThis will permanently remove the key from this browser and clear cached messages. You will need to re-enter the key to decrypt this channel again.')) return;
ChannelDecrypt.removeKey(chName);
if (channelHash.startsWith('user:')) {
// Pure user-added channel — drop from the list entirely.
@@ -929,6 +1120,11 @@
if (!payload) continue;
var channelName = payload.channel || 'unknown';
// For live-decrypted user-added (PSK) channels, decryptLivePSKBatch
// also stamps payload.channelKey ("user:<name>") so we route the
// message to the correct sidebar row and to the open chat view.
// Falls back to channelName for server-known CHAN packets.
var channelKey = payload.channelKey || channelName;
var rawText = payload.text || '';
var sender = payload.sender || null;
var displayText = rawText;
@@ -955,10 +1151,10 @@
var observer = m.data?.packet?.observer_name || m.data?.observer || null;
// Update channel list entry — only once per unique packet hash
var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelName);
if (pktHash) seenHashes.add(pktHash + ':' + channelName);
var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelKey);
if (pktHash) seenHashes.add(pktHash + ':' + channelKey);
var ch = channels.find(function (c) { return c.hash === channelName; });
var ch = channels.find(function (c) { return c.hash === channelKey; });
if (ch) {
if (isFirstObservation) ch.messageCount = (ch.messageCount || 0) + 1;
ch.lastActivityMs = Date.now();
@@ -968,7 +1164,7 @@
} else if (isFirstObservation) {
// New channel we haven't seen
channels.push({
hash: channelName,
hash: channelKey,
name: channelName,
messageCount: 1,
lastActivityMs: Date.now(),
@@ -979,7 +1175,7 @@
}
// If this message is for the selected channel, append to messages
if (selectedHash && channelName === selectedHash) {
if (selectedHash && channelKey === selectedHash) {
// Deduplicate by packet hash — same message seen by multiple observers
var existing = pktHash ? messages.find(function (msg) { return msg.packetHash === pktHash; }) : null;
if (existing) {
@@ -1062,6 +1258,18 @@
// up as a real message instead of an encrypted blob. Keep the original
// hash byte for any downstream consumer that wants it.
payload.channel = dec.channelName;
// For user-added PSK channels the sidebar entry & selectedHash use a
// "user:<name>" key (see addUserChannel). Stamp the canonical key on
// the payload so processWSBatch routes the live message to the
// correct sidebar row and to the open chat view instead of dropping
// it / creating a duplicate plain entry. Falls back to the raw name
// for non-user channels (server-known CHAN paths still work).
var userKey = 'user:' + dec.channelName;
var hasUserCh = false;
for (var ck = 0; ck < channels.length; ck++) {
if (channels[ck].hash === userKey) { hasUserCh = true; break; }
}
payload.channelKey = hasUserCh ? userKey : dec.channelName;
payload.sender = dec.sender;
payload.text = dec.sender ? (dec.sender + ': ' + dec.text) : dec.text;
payload.decryptedLocally = true;
@@ -1083,9 +1291,12 @@
for (var i = 0; i < msgs.length; i++) {
var p = msgs[i] && msgs[i].data && msgs[i].data.decoded && msgs[i].data.decoded.payload;
if (!p || !p.decryptedLocally) continue;
var chName = p.channel;
if (!chName || chName === prior) continue;
var ch = channels.find(function (c) { return c.hash === chName || c.name === chName || c.hash === ('user:' + chName); });
// Use the canonical sidebar key stamped by decryptLivePSKBatch so
// the comparison against `prior` (= selectedHash) actually matches
// for user-added (user:*-prefixed) channels.
var chKey = p.channelKey || p.channel;
if (!chKey || chKey === prior) continue;
var ch = channels.find(function (c) { return c.hash === chKey || c.name === chKey || c.hash === ('user:' + chKey); });
if (ch) {
ch.unread = (ch.unread || 0) + 1;
bumped = true;
@@ -1151,69 +1362,156 @@
}
}
// #1041: single source of truth for the user-facing placeholder shown
// when a PSK channel has no user-supplied label. Hoisted so the helper
// and any future call sites stay in sync (i18n / branding-friendly).
const PRIVATE_CHANNEL_LABEL = 'Private Channel';
// Display name for a channel — handles PSK channels where the raw
// "psk:<hex8>" key prefix shouldn't be shown to users. Falls back to
// userLabel, then a friendly placeholder, then a caller-supplied
// fallback, then `Channel <hash>`.
//
// `fallback` lets row rendering preserve its existing "Unknown" /
// "Channel <hash>" semantics for encrypted-but-not-user-added channels
// without duplicating the psk:* check.
function channelDisplayName(ch, fallback) {
if (!ch) return '';
const name = ch.name || '';
if (ch.userLabel) return ch.userLabel;
if (name.indexOf('psk:') === 0) return PRIVATE_CHANNEL_LABEL;
if (name) return name;
if (fallback) return fallback;
return 'Channel ' + (typeof formatHashHex === 'function' ? formatHashHex(ch.hash) : ch.hash);
}
// #1034 PR1: render a single channel row (used by all sidebar sections).
function renderChannelRow(ch) {
const isEncrypted = ch.encrypted === true;
const isUserAdded = ch.userAdded === true;
// #1041: route through channelDisplayName so the psk:* → "Private
// Channel" rule lives in one place. Pass an `encryptedFallback` so
// rows for non-user-added encrypted channels keep showing "Unknown"
// (their existing behavior) when there's no name at all.
const encryptedFallback = isEncrypted ? 'Unknown' : '';
const name = channelDisplayName(ch, encryptedFallback);
const color = isEncrypted && !isUserAdded ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash);
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
// Preview: show last sender+message when we have one. Otherwise show
// nothing rather than "0 messages" — the count is misleading for
// user-added (PSK) channels where messageCount only reflects
// server-known activity, not actually-decrypted messages.
let preview;
if (ch.lastSender && ch.lastMessage) {
preview = `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`;
} else if (isEncrypted && !isUserAdded) {
preview = `0x${formatHashHex(ch.hash)}`;
} else if (typeof ch.messageCount === 'number' && ch.messageCount > 0) {
preview = `${ch.messageCount} messages`;
} else {
preview = '';
}
const sel = selectedHash === ch.hash ? ' selected' : '';
const encClass = isUserAdded
? ' ch-user-added'
: (isEncrypted ? ' ch-encrypted' : '');
const badgeIcon = isUserAdded ? '🔓' : (isEncrypted ? '🔒' : null);
const abbr = badgeIcon || (name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase());
const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null;
const dotStyle = chColor ? ` style="background:${chColor}"` : '';
const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : '';
// #1033: must NOT be a <button> — outer .ch-item is itself a <button>;
// nested <button> is invalid HTML5 and the parser orphans everything
// after it. Use <span role="button">; keydown handler on #chList
// (Enter/Space) keeps it keyboard-accessible.
// Icon button factory — used for the per-row remove/share controls.
// Both share the .ch-icon-btn base class (touch target, opacity); a
// modifier class (.ch-remove-btn / .ch-share-btn) supplies size + color.
function iconBtn(modClass, dataAttr, hash, name, glyph, title, ariaVerb, extraAttrs) {
return ' <span class="ch-icon-btn ' + modClass + '" role="button" tabindex="0"'
+ ' ' + dataAttr + '="' + escapeHtml(hash) + '"'
+ (extraAttrs || '')
+ ' title="' + title + '"'
+ ' aria-label="' + ariaVerb + ' ' + escapeHtml(name) + '">' + glyph + '</span>';
}
const removeBtn = isUserAdded
? iconBtn('ch-remove-btn', 'data-remove-channel', ch.hash, name, '✕',
'Remove channel and clear saved key', 'Remove', '')
: '';
const shareBtn = isUserAdded
? iconBtn('ch-share-btn', 'data-share-channel', ch.hash, name, '📤 Share',
'Share channel key (QR + URL)', 'Share', ' aria-haspopup="dialog"')
: '';
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key">🔑</span>' : '';
const unreadBadge = (ch.unread && ch.unread > 0)
? ' <span class="ch-unread-badge" data-unread-channel="' + escapeHtml(ch.hash) + '" title="' + ch.unread + ' new" aria-label="' + ch.unread + ' unread">' + (ch.unread > 99 ? '99+' : ch.unread) + '</span>'
: '';
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}"${isEncrypted ? ' data-encrypted="true"' : ''}${isUserAdded ? ' data-user-added="true"' : ''}>
<div class="ch-badge" style="background:${color}" aria-hidden="true">${badgeIcon ? badgeIcon : escapeHtml(abbr)}</div>
<div class="ch-item-body">
<div class="ch-item-top">
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}${unreadBadge}
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '"></span>' : ''}
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>${shareBtn}${removeBtn}
</div>
<div class="ch-item-preview">${escapeHtml(preview)}</div>
</div>
</button>`;
}
// #1034 PR1: sectioned sidebar — My Channels / Network / Encrypted (N).
function renderChannelList() {
const el = document.getElementById('chList');
if (!el) return;
if (channels.length === 0) { el.innerHTML = '<div class="ch-empty">No channels found</div>'; return; }
// Sort by message count desc
const sorted = [...channels].sort((a, b) => {
return (b.messageCount || 0) - (a.messageCount || 0);
});
const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0);
const sortByCount = (a, b) => (b.messageCount || 0) - (a.messageCount || 0);
el.innerHTML = sorted.map(ch => {
const isEncrypted = ch.encrypted === true;
const isUserAdded = ch.userAdded === true;
// #1020: prefer user-supplied label over psk:<hex>
const baseName = isEncrypted ? (ch.name || 'Unknown') : (ch.name || `Channel ${formatHashHex(ch.hash)}`);
const name = (isUserAdded && ch.userLabel) ? ch.userLabel : baseName;
const color = isEncrypted ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash);
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
const preview = isUserAdded
? (ch.lastSender && ch.lastMessage
? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`
: `${ch.messageCount || 0} messages (your key)`)
: isEncrypted
? `${ch.messageCount} encrypted messages (no key configured)`
: ch.lastSender && ch.lastMessage
? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`
: `${ch.messageCount} messages`;
const sel = selectedHash === ch.hash ? ' selected' : '';
// #1020: distinct class so styling/tests can tell user-added apart
// from server-known encrypted channels.
const encClass = isUserAdded
? ' ch-user-added'
: (isEncrypted ? ' ch-encrypted' : '');
// #1020: 🔓 marks "I have the key" vs 🔒 "encrypted, no key"
const badgeIcon = isUserAdded ? '🔓' : (isEncrypted ? '🔒' : null);
const abbr = badgeIcon || (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}"` : '';
// M4 / #1020: Remove button for user-added channels
const removeBtn = isUserAdded ? ' <button class="ch-remove-btn" data-remove-channel="' + escapeHtml(ch.hash) + '" title="Remove channel and clear saved key" aria-label="Remove ' + escapeHtml(name) + '">✕</button>' : '';
// #1020: explicit badge marker for "your key" so it's distinguishable
// from server-known encrypted rows at a glance and for screen readers.
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key">🔑</span>' : '';
// #1029 Unread badge — bumped by live PSK decrypt for channels not currently selected.
const unreadBadge = (ch.unread && ch.unread > 0)
? ' <span class="ch-unread-badge" data-unread-channel="' + escapeHtml(ch.hash) + '" title="' + ch.unread + ' new" aria-label="' + ch.unread + ' unread">' + (ch.unread > 99 ? '99+' : ch.unread) + '</span>'
: '';
const mine = channels.filter(c => c.userAdded === true).sort(sortByActivity);
const network = channels.filter(c => c.userAdded !== true && c.encrypted !== true).sort(sortByActivity);
const encrypted = channels.filter(c => c.userAdded !== true && c.encrypted === true).sort(sortByCount);
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}"${isEncrypted ? ' data-encrypted="true"' : ''}${isUserAdded ? ' data-user-added="true"' : ''}>
<div class="ch-badge" style="background:${color}" aria-hidden="true">${badgeIcon ? badgeIcon : escapeHtml(abbr)}</div>
<div class="ch-item-body">
<div class="ch-item-top">
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}${unreadBadge}
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '"></span>' : ''}
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>${removeBtn}
</div>
<div class="ch-item-preview">${escapeHtml(preview)}</div>
// Encrypted section collapsed by default; user toggle persisted in localStorage.
const collapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false';
const sections = [];
sections.push(
`<div class="ch-section ch-section-mychannels" data-section="mychannels">
<div class="ch-section-header">My Channels <span class="ch-section-locality" title="Saved only in this browser on this device">🖥 (this browser)</span></div>
${mine.length ? mine.map(renderChannelRow).join('') : '<div class="ch-section-empty">No channels yet — click [+ Add Channel] to add one.</div>'}
</div>`
);
sections.push(
`<div class="ch-section ch-section-network" data-section="network">
<div class="ch-section-header">Network</div>
${network.length ? network.map(renderChannelRow).join('') : '<div class="ch-section-empty">No public channels reported by the server.</div>'}
</div>`
);
sections.push(
`<div class="ch-section ch-section-encrypted" data-section="encrypted" data-encrypted-collapsed="${collapsed ? 'true' : 'false'}">
<button type="button" class="ch-section-header ch-section-toggle" id="chEncryptedToggle" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="chEncryptedBody">
<span class="ch-section-caret" aria-hidden="true">${collapsed ? '▸' : '▾'}</span>
Encrypted (${encrypted.length})
</button>
<div class="ch-section-body" id="chEncryptedBody"${collapsed ? ' hidden' : ''}>
${encrypted.length ? encrypted.map(renderChannelRow).join('') : '<div class="ch-section-empty">No unkeyed encrypted channels seen.</div>'}
</div>
</button>`;
}).join('');
</div>`
);
el.innerHTML = sections.join('');
// Toggle expand/collapse for the Encrypted section.
const toggle = document.getElementById('chEncryptedToggle');
if (toggle) {
toggle.addEventListener('click', function () {
const wasCollapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false';
const next = wasCollapsed ? 'false' : 'true';
try { localStorage.setItem('ch-encrypted-collapsed', next); } catch (e) { /* quota */ }
renderChannelList();
});
}
}
async function selectChannel(hash, decryptOpts) {
@@ -1226,7 +1524,9 @@
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
renderChannelList();
const ch = channels.find(c => c.hash === hash);
const name = ch?.name || `Channel ${formatHashHex(hash)}`;
// #1041: never show raw "psk:<hex>" prefixes in the header — use the
// user-supplied label or "Private Channel".
const name = ch ? channelDisplayName(ch) : `Channel ${formatHashHex(hash)}`;
const header = document.getElementById('chHeader');
header.querySelector('.ch-header-text').textContent = `${name}${ch?.messageCount || 0} messages`;
+44 -2
View File
@@ -40,10 +40,40 @@ function filterPacketsByRoute(packets, mode) {
return packets;
}
/**
* Compute asymmetric overlap statistics between two observer packet sets.
* Given a comparePacketSets() result, returns:
* - totalA / totalB: unique packet count for each observer
* - shared: packets seen by both
* - onlyA / onlyB: exclusive packet counts
* - aSeesOfB: percentage of B's packets that A also saw (rounded to 0.1%)
* - bSeesOfA: percentage of A's packets that B also saw (rounded to 0.1%)
* Returns 0% (not NaN) when a denominator is zero.
*/
function computeOverlapStats(cmp) {
var onlyA = (cmp && cmp.onlyA && cmp.onlyA.length) || 0;
var onlyB = (cmp && cmp.onlyB && cmp.onlyB.length) || 0;
var shared = (cmp && cmp.both && cmp.both.length) || 0;
var totalA = onlyA + shared;
var totalB = onlyB + shared;
var aSeesOfB = totalB > 0 ? Math.round((shared / totalB) * 1000) / 10 : 0;
var bSeesOfA = totalA > 0 ? Math.round((shared / totalA) * 1000) / 10 : 0;
return {
totalA: totalA,
totalB: totalB,
shared: shared,
onlyA: onlyA,
onlyB: onlyB,
aSeesOfB: aSeesOfB,
bSeesOfA: bSeesOfA,
};
}
// Expose for testing
if (typeof window !== 'undefined') {
window.comparePacketSets = comparePacketSets;
window.filterPacketsByRoute = filterPacketsByRoute;
window.computeOverlapStats = computeOverlapStats;
}
(function () {
@@ -338,12 +368,24 @@ if (typeof window !== 'undefined') {
if (currentView === 'summary') {
// Textual summary
var stats = computeOverlapStats(r);
var total = r.onlyA.length + r.onlyB.length + r.both.length;
var overlap = total > 0 ? (r.both.length / total * 100).toFixed(1) : '0.0';
el.innerHTML =
'<div class="compare-summary-text">' +
'<p>In the last 24 hours, <strong>' + nameA + '</strong> saw <strong>' + (r.onlyA.length + r.both.length).toLocaleString() + '</strong> unique packets ' +
'and <strong>' + nameB + '</strong> saw <strong>' + (r.onlyB.length + r.both.length).toLocaleString() + '</strong> unique packets.</p>' +
'<p>In the last 24 hours, <strong>' + nameA + '</strong> saw <strong>' + stats.totalA.toLocaleString() + '</strong> unique packets ' +
'and <strong>' + nameB + '</strong> saw <strong>' + stats.totalB.toLocaleString() + '</strong> unique packets.</p>' +
// #671 — asymmetric reference-observer comparison
'<div class="compare-asymmetric" style="display:flex;gap:12px;flex-wrap:wrap;margin:12px 0">' +
'<div class="compare-asym-card" style="flex:1;min-width:240px;padding:12px;border:1px solid var(--border, #333);border-radius:6px">' +
'<div style="font-size:1.6em;font-weight:bold">' + stats.aSeesOfB.toFixed(1) + '%</div>' +
'<div class="text-muted">' + nameA + ' saw <strong>' + stats.shared.toLocaleString() + '</strong> of ' + nameB + '\u2019s ' + stats.totalB.toLocaleString() + ' packets</div>' +
'</div>' +
'<div class="compare-asym-card" style="flex:1;min-width:240px;padding:12px;border:1px solid var(--border, #333);border-radius:6px">' +
'<div style="font-size:1.6em;font-weight:bold">' + stats.bSeesOfA.toFixed(1) + '%</div>' +
'<div class="text-muted">' + nameB + ' saw <strong>' + stats.shared.toLocaleString() + '</strong> of ' + nameA + '\u2019s ' + stats.totalA.toLocaleString() + ' packets</div>' +
'</div>' +
'</div>' +
'<p><strong>' + r.both.length.toLocaleString() + '</strong> packets (' + overlap + '%) were seen by both observers. ' +
'<strong>' + r.onlyA.length.toLocaleString() + '</strong> were exclusive to ' + nameA + ' and ' +
'<strong>' + r.onlyB.length.toLocaleString() + '</strong> were exclusive to ' + nameB + '.</p>' +
+405
View File
@@ -0,0 +1,405 @@
/* filter-ux.js Wireshark-style filter UX (issue #966)
*
* Owns:
* - Help popover (filter syntax, fields, operators, examples)
* - Autocomplete dropdown (field names, operators, type/route values, payload.*)
* - Right-click context menu on packet table cells "Filter by this value"
* - Saved-filter dropdown (localStorage, with starter defaults)
*
* Pure-logic helpers (SavedFilters, buildCellFilterClause, appendClauseToExpr)
* are unit-tested in test-packet-filter-ux.js. DOM glue is exercised by
* test-filter-ux-e2e.js (Playwright).
*/
(function() {
'use strict';
var LS_KEY = 'corescope_saved_filters_v1';
// ── Saved filters store ────────────────────────────────────────────────
var DEFAULT_FILTERS = [
{ name: 'Adverts only', expr: 'type == ADVERT', builtin: true },
{ name: 'Channel traffic', expr: 'type == GRP_TXT', builtin: true },
{ name: 'Direct messages', expr: 'type == TXT_MSG', builtin: true },
{ name: 'Strong signal (SNR > 5)', expr: 'snr > 5', builtin: true },
{ name: 'Multi-hop (hops > 1)', expr: 'hops > 1', builtin: true },
{ name: 'Repeater adverts', expr: 'type == ADVERT && payload.flags.repeater == true', builtin: true },
{ name: 'Recent (last 5 min)', expr: 'age < 5m', builtin: true },
];
function _getStore() {
try {
var raw = window.localStorage.getItem(LS_KEY);
if (!raw) return [];
var parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) { return []; }
}
function _setStore(arr) {
try { window.localStorage.setItem(LS_KEY, JSON.stringify(arr)); } catch (e) {}
}
var SavedFilters = {
defaults: function() { return DEFAULT_FILTERS.slice(); },
list: function() {
// Defaults first, then user filters (deduped by name — user wins on collision)
var user = _getStore();
var userNames = {};
for (var i = 0; i < user.length; i++) userNames[user[i].name] = true;
var defaults = DEFAULT_FILTERS.filter(function(d) { return !userNames[d.name]; });
return defaults.concat(user);
},
save: function(name, expr) {
if (!name || !expr) return;
var user = _getStore();
var idx = -1;
for (var i = 0; i < user.length; i++) { if (user[i].name === name) { idx = i; break; } }
var entry = { name: name, expr: expr, ts: Date.now() };
if (idx >= 0) user[idx] = entry; else user.push(entry);
_setStore(user);
},
delete: function(name) {
var user = _getStore();
_setStore(user.filter(function(f) { return f.name !== name; }));
},
};
// ── Right-click filter clause builders ─────────────────────────────────
// Numeric strings stay unquoted; identifiers from TYPE_VALUES/ROUTE_VALUES
// stay unquoted; everything else gets double-quoted.
function _isNumericString(s) {
if (typeof s !== 'string') return false;
return /^-?\d+(\.\d+)?$/.test(s.trim());
}
function _isBareIdentifier(s) {
return typeof s === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(s);
}
function buildCellFilterClause(field, value, op) {
op = op || '==';
if (value == null) value = '';
var v = String(value);
var rendered;
if (op === 'contains' || op === 'starts_with' || op === 'ends_with') {
// String-only ops: always quote
rendered = '"' + v.replace(/"/g, '\\"') + '"';
} else if (_isNumericString(v)) {
rendered = v;
} else if (_isBareIdentifier(v)) {
rendered = v;
} else {
rendered = '"' + v.replace(/"/g, '\\"') + '"';
}
return field + ' ' + op + ' ' + rendered;
}
function appendClauseToExpr(expr, clause) {
if (!expr || !expr.trim()) return clause;
return expr.trim() + ' && ' + clause;
}
// ── DOM glue (only runs in browser, after init()) ──────────────────────
var _ctxMenu = null;
function _h(tag, attrs, html) {
var el = document.createElement(tag);
if (attrs) for (var k in attrs) {
if (k === 'class') el.className = attrs[k];
else if (k === 'style') el.setAttribute('style', attrs[k]);
else if (k.indexOf('data-') === 0) el.setAttribute(k, attrs[k]);
else el[k] = attrs[k];
}
if (html != null) el.innerHTML = html;
return el;
}
function _esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function _buildHelpHtml() {
var PF = window.PacketFilter;
var rows = (PF.FIELDS || []).map(function(f) {
return '<tr><td class="fux-mono">' + _esc(f.name) + '</td><td>' + _esc(f.desc) + '</td></tr>';
}).join('');
var ops = (PF.OPERATORS || []).map(function(o) {
return '<tr><td class="fux-mono">' + _esc(o.op) + '</td><td>' + _esc(o.desc) +
'</td><td class="fux-mono">' + _esc(o.example) + '</td></tr>';
}).join('');
var examples = [
'type == ADVERT',
'type == GRP_TXT && size > 50',
'payload.name contains "Gilroy"',
'payload.flags.repeater == true',
'snr > 5 && rssi > -90',
'hops < 2',
'observer == "Dorrington" && type == ADVERT',
'(type == ADVERT || type == ACK) && snr > 0',
'age < 1h',
'time after "2025-01-01"',
].map(function(e) { return '<li class="fux-mono">' + _esc(e) + '</li>'; }).join('');
return [
'<h3>Filter syntax</h3>',
'<p>Wireshark-style boolean expressions over packet fields. Combine with <code>&amp;&amp;</code>, <code>||</code>, <code>!</code>, and parentheses. Strings are case-insensitive. Tip: append <code>?filter=…</code> to the URL to share a filter.</p>',
'<h4>Fields</h4>',
'<table class="fux-table"><thead><tr><th>Name</th><th>Description</th></tr></thead><tbody>' + rows + '</tbody></table>',
'<h4>Operators</h4>',
'<table class="fux-table"><thead><tr><th>Op</th><th>Meaning</th><th>Example</th></tr></thead><tbody>' + ops + '</tbody></table>',
'<h4>Examples</h4>',
'<ul class="fux-examples">' + examples + '</ul>',
'<h4>Tips</h4>',
'<ul>',
'<li>Right-click any cell in the packet table to add a clause for that value.</li>',
'<li>Type a partial field name to autocomplete; Tab/Enter accepts, Esc dismisses.</li>',
'<li>Save commonly-used expressions via the ★ Save button — they appear in the Saved dropdown.</li>',
'</ul>',
].join('');
}
function _showHelp() {
var existing = document.getElementById('filterHelpPopover');
if (existing) { existing.remove(); return; }
var pop = _h('div', { id: 'filterHelpPopover', class: 'fux-popover', role: 'dialog', 'aria-label': 'Filter syntax help' });
pop.innerHTML =
'<div class="fux-popover-header"><strong>Filter syntax</strong>' +
'<button type="button" class="fux-popover-close" aria-label="Close">✕</button></div>' +
'<div class="fux-popover-body">' + _buildHelpHtml() + '</div>';
document.body.appendChild(pop);
pop.querySelector('.fux-popover-close').addEventListener('click', function() { pop.remove(); });
document.addEventListener('keydown', function _esc(ev) {
if (ev.key === 'Escape') { pop.remove(); document.removeEventListener('keydown', _esc); }
});
}
// ── Autocomplete ───────────────────────────────────────────────────────
function _wireAutocomplete(input) {
var dd = _h('div', { id: 'filterAcDropdown', class: 'fux-ac-dropdown', role: 'listbox' });
dd.style.display = 'none';
input.parentNode.appendChild(dd);
var sel = -1, items = [];
function _gatherPayloadKeys() {
// Best-effort: scan the first ~50 visible packets for decoded_json keys
var keys = {};
try {
var rows = document.querySelectorAll('#pktTable tbody tr');
for (var r = 0; r < rows.length && r < 50; r++) {
var dj = rows[r].getAttribute('data-decoded');
if (!dj) continue;
var obj = JSON.parse(dj);
for (var k in obj) keys[k] = true;
}
} catch (e) {}
return Object.keys(keys);
}
function close() { dd.style.display = 'none'; sel = -1; items = []; input.removeAttribute('aria-activedescendant'); }
function render() {
if (!items.length) { close(); return; }
dd.innerHTML = items.map(function(it, i) {
return '<div class="fux-ac-item' + (i === sel ? ' active' : '') + '" id="fux-ac-' + i +
'" role="option" data-idx="' + i + '">' +
'<span class="fux-ac-val">' + _esc(it.value) + '</span>' +
(it.desc ? '<span class="fux-ac-desc">' + _esc(it.desc) + '</span>' : '') +
'</div>';
}).join('');
dd.style.display = 'block';
if (sel >= 0) input.setAttribute('aria-activedescendant', 'fux-ac-' + sel);
}
function accept(idx) {
if (!items[idx]) return;
var rs = items._replaceStart, re = items._replaceEnd;
var val = items[idx].value;
var v = input.value;
var newVal = v.slice(0, rs) + val + v.slice(re);
var caret = rs + val.length;
// Append space + helpful next char for fields (so user can type op)
if (items[idx].kind === 'field') { newVal = newVal.slice(0, caret) + ' ' + newVal.slice(caret); caret++; }
input.value = newVal;
input.setSelectionRange(caret, caret);
close();
// Trigger filter recompile
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function refresh() {
var PF = window.PacketFilter;
if (!PF || !PF.suggest) return close();
var r = PF.suggest(input.value, input.selectionStart || 0, { payloadKeys: _gatherPayloadKeys() });
items = (r && r.suggestions) ? r.suggestions.slice(0, 12) : [];
items._replaceStart = r ? r.replaceStart : 0;
items._replaceEnd = r ? r.replaceEnd : 0;
sel = items.length ? 0 : -1;
render();
}
input.addEventListener('input', refresh);
input.addEventListener('focus', refresh);
input.addEventListener('blur', function() { setTimeout(close, 150); });
input.addEventListener('keydown', function(ev) {
if (dd.style.display === 'none') return;
if (ev.key === 'ArrowDown') { sel = (sel + 1) % items.length; render(); ev.preventDefault(); }
else if (ev.key === 'ArrowUp') { sel = (sel - 1 + items.length) % items.length; render(); ev.preventDefault(); }
else if (ev.key === 'Tab' || ev.key === 'Enter') {
if (sel >= 0) { accept(sel); ev.preventDefault(); }
} else if (ev.key === 'Escape') { close(); ev.preventDefault(); }
});
dd.addEventListener('mousedown', function(ev) {
var target = ev.target.closest('.fux-ac-item');
if (!target) return;
ev.preventDefault();
accept(parseInt(target.getAttribute('data-idx'), 10));
});
}
// ── Right-click context menu ───────────────────────────────────────────
function _showContextMenu(x, y, field, value) {
if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
var input = document.getElementById('packetFilterInput');
if (!input) return;
var menu = _h('div', { id: 'filterContextMenu', class: 'fux-ctx-menu', role: 'menu' });
var ops = [
{ label: 'Filter ' + field + ' == "' + value + '"', op: '==' },
{ label: 'Filter ' + field + ' != "' + value + '"', op: '!=' },
{ label: 'Filter ' + field + ' contains "' + value + '"', op: 'contains' },
];
menu.innerHTML = ops.map(function(o, i) {
return '<button type="button" class="fux-ctx-item" data-idx="' + i + '" role="menuitem">' + _esc(o.label) + '</button>';
}).join('');
menu.style.left = x + 'px';
menu.style.top = y + 'px';
document.body.appendChild(menu);
_ctxMenu = menu;
menu.addEventListener('click', function(ev) {
var btn = ev.target.closest('.fux-ctx-item');
if (!btn) return;
var op = ops[parseInt(btn.getAttribute('data-idx'), 10)].op;
var clause = buildCellFilterClause(field, value, op);
input.value = appendClauseToExpr(input.value, clause);
input.dispatchEvent(new Event('input', { bubbles: true }));
menu.remove(); _ctxMenu = null;
});
function dismiss(ev) {
if (_ctxMenu && !_ctxMenu.contains(ev.target)) { _ctxMenu.remove(); _ctxMenu = null;
document.removeEventListener('mousedown', dismiss);
document.removeEventListener('keydown', escDismiss);
}
}
function escDismiss(ev) { if (ev.key === 'Escape') dismiss({ target: document.body }); }
setTimeout(function() {
document.addEventListener('mousedown', dismiss);
document.addEventListener('keydown', escDismiss);
}, 0);
}
function _wireContextMenu() {
// Delegated listener on the table — extracts field+value from data-* attrs.
var tbl = document.getElementById('pktTable');
if (!tbl) return;
tbl.addEventListener('contextmenu', function(ev) {
var cell = ev.target.closest('td[data-filter-field]');
if (!cell) return;
var field = cell.getAttribute('data-filter-field');
var value = cell.getAttribute('data-filter-value');
if (!field || value == null || value === '') return;
ev.preventDefault();
_showContextMenu(ev.pageX, ev.pageY, field, value);
});
}
// ── Saved filters dropdown ─────────────────────────────────────────────
function _renderSavedDropdown(container, input) {
var btn = _h('button', { type: 'button', class: 'fux-saved-trigger', id: 'filterSavedTrigger', title: 'Saved filters' }, '★ Saved ▾');
var menu = _h('div', { class: 'fux-saved-menu hidden', id: 'filterSavedMenu', role: 'menu' });
container.appendChild(btn);
container.appendChild(menu);
function build() {
var list = SavedFilters.list();
var rows = list.map(function(f, i) {
var del = f.builtin ? '' :
'<button type="button" class="fux-saved-del" data-name="' + _esc(f.name) + '" title="Delete">✕</button>';
return '<div class="fux-saved-item" data-idx="' + i + '">' +
'<span class="fux-saved-name">' + _esc(f.name) + '</span>' +
'<span class="fux-saved-expr fux-mono">' + _esc(f.expr) + '</span>' +
del + '</div>';
}).join('');
menu.innerHTML =
'<div class="fux-saved-header">Saved filters</div>' +
rows +
'<div class="fux-saved-footer">' +
'<button type="button" id="filterSaveCurrent" class="fux-saved-save"> Save current expression</button>' +
'</div>';
}
btn.addEventListener('click', function(ev) {
ev.stopPropagation();
build();
menu.classList.toggle('hidden');
});
document.addEventListener('click', function(ev) {
if (!menu.contains(ev.target) && ev.target !== btn) menu.classList.add('hidden');
});
menu.addEventListener('click', function(ev) {
var del = ev.target.closest('.fux-saved-del');
if (del) {
SavedFilters.delete(del.getAttribute('data-name'));
build();
ev.stopPropagation();
return;
}
if (ev.target.id === 'filterSaveCurrent') {
var expr = (input.value || '').trim();
if (!expr) { alert('Type a filter expression first.'); return; }
var name = prompt('Name this filter:', '');
if (name && name.trim()) {
SavedFilters.save(name.trim(), expr);
build();
}
return;
}
var item = ev.target.closest('.fux-saved-item');
if (item) {
var list = SavedFilters.list();
var f = list[parseInt(item.getAttribute('data-idx'), 10)];
if (f) {
input.value = f.expr;
input.dispatchEvent(new Event('input', { bubbles: true }));
menu.classList.add('hidden');
}
}
});
}
// ── Init: idempotent, called by packets.js after filter input renders ──
function init() {
var input = document.getElementById('packetFilterInput');
if (!input || input.dataset.fuxInit === '1') return;
input.dataset.fuxInit = '1';
// Help icon + saved-filters dropdown — injected next to the input
var wrap = input.parentNode;
if (wrap) {
var bar = document.getElementById('filterUxBar');
if (!bar) {
bar = _h('div', { id: 'filterUxBar', class: 'fux-bar' });
var helpBtn = _h('button', { type: 'button', class: 'fux-help-btn', id: 'filterHelpBtn',
'aria-label': 'Filter syntax help', title: 'Filter syntax help' }, 'ⓘ Help');
helpBtn.addEventListener('click', _showHelp);
bar.appendChild(helpBtn);
_renderSavedDropdown(bar, input);
wrap.appendChild(bar);
}
}
_wireAutocomplete(input);
_wireContextMenu();
}
var _exports = {
SavedFilters: SavedFilters,
buildCellFilterClause: buildCellFilterClause,
appendClauseToExpr: appendClauseToExpr,
init: init,
_showHelp: _showHelp, // exposed for E2E
};
if (typeof window !== 'undefined') window.FilterUX = _exports;
if (typeof module !== 'undefined' && module.exports) module.exports = _exports;
})();
+7
View File
@@ -28,9 +28,12 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
<link rel="stylesheet" href="vendor/MarkerCluster.css?v=__BUST__">
<link rel="stylesheet" href="vendor/MarkerCluster.Default.css?v=__BUST__">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin="anonymous"></script>
<script src="vendor/leaflet.markercluster.js?v=__BUST__"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script src="https://unpkg.com/chart.js@4/dist/chart.umd.min.js"></script>
</head>
@@ -92,14 +95,18 @@
<script src="hop-resolver.js?v=__BUST__"></script>
<script src="hop-display.js?v=__BUST__"></script>
<script src="app.js?v=__BUST__"></script>
<script src="url-state.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="filter-ux.js?v=__BUST__"></script>
<script src="hash-color.js?v=__BUST__"></script>
<script src="packet-helpers.js?v=__BUST__"></script>
<script src="vendor/aes-ecb.js?v=__BUST__"></script>
<script src="vendor/sha256-hmac.js?v=__BUST__"></script>
<script src="channel-decrypt.js?v=__BUST__"></script>
<script src="vendor/jsqr.min.js"></script>
<script src="channel-qr.js?v=__BUST__"></script>
<script src="channel-colors.js?v=__BUST__"></script>
<script src="channel-color-picker.js?v=__BUST__"></script>
<script src="packets.js?v=__BUST__"></script>
+4
View File
@@ -292,6 +292,10 @@
.live-toggles label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
.live-toggles input { margin: 0; }
/* Region filter (#1045) inline in live header toggles */
.live-toggles .live-region-filter-container { display: inline-flex; align-items: center; }
.live-toggles .live-region-filter-container .region-dropdown-trigger { font-size: inherit; padding: 2px 6px; }
/* ---- Leaflet overrides for dark theme ---- */
.live-page .leaflet-control-zoom a {
background: color-mix(in srgb, var(--surface-1) 92%, transparent) !important;
+56
View File
@@ -28,6 +28,30 @@
let nodeFilterKeys = (localStorage.getItem('live-node-filter') || '').split(',').map(s => s.trim()).filter(Boolean);
let nodeFilterTotal = 0;
let nodeFilterShown = 0;
// Region filter (#1045): observer_id → IATA code, populated from /api/observers
let observerIataMap = {};
let regionFilterChangeHandler = null;
/**
* Returns true if the packet group matches the selected regions.
* - selected null/empty no filter active, always true.
* - Match if ANY observation's observer maps to an IATA in selected (case-insensitive).
* Pure helper exposed for unit tests.
*/
function packetMatchesRegion(packets, obsMap, selected) {
if (!selected || !selected.length) return true;
if (!packets || !packets.length) return false;
const sel = selected.map(function(s) { return String(s).toUpperCase(); });
for (var i = 0; i < packets.length; i++) {
var oid = packets[i] && packets[i].observer_id;
if (oid == null) continue;
var iata = obsMap && obsMap[oid];
if (!iata) continue;
if (sel.indexOf(String(iata).toUpperCase()) !== -1) return true;
}
return false;
}
function setObserverIataMap(m) { observerIataMap = m || {}; }
let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null;
const propagationBuffer = new Map(); // hash -> {timer, packets[]}
let _onResize = null;
@@ -848,6 +872,7 @@
</div>
<div id="liveNodeFilterCount" class="live-filter-count hidden"></div>
<label id="liveGeoFilterLabel" style="display:none"><input type="checkbox" id="liveGeoFilterToggle"> Mesh live area</label>
<div id="liveRegionFilter" class="region-filter-container live-region-filter-container" aria-label="Filter live packets by IATA region"></div>
</div>
<div class="audio-controls hidden" id="audioControls">
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
@@ -1013,6 +1038,25 @@
applyFavoritesFilter();
});
// Region filter (#1045): dropdown of observer IATA regions
(function initLiveRegionFilter() {
var rfEl = document.getElementById('liveRegionFilter');
if (!rfEl || !window.RegionFilter) return;
// Fetch observer roster to build observer_id → IATA map
fetch('/api/observers').then(function(r) { return r.json(); }).then(function(list) {
var m = {};
if (Array.isArray(list)) {
for (var i = 0; i < list.length; i++) {
var o = list[i];
if (o && o.id != null && o.iata) m[o.id] = o.iata;
}
}
setObserverIataMap(m);
}).catch(function() { /* leave map empty; filter will hide all when active */ });
RegionFilter.init(rfEl, { dropdown: true });
regionFilterChangeHandler = RegionFilter.onChange(function() { /* selection persisted by RegionFilter; future packets reflect it */ });
})();
// Node filter input
const nodeFilterInput = document.getElementById('liveNodeFilterInput');
const nodeFilterClear = document.getElementById('liveNodeFilterClear');
@@ -1956,6 +2000,8 @@
window._liveIsNodeFavorited = isNodeFavorited;
window._livePacketInvolvesFilterNode = packetInvolvesFilterNode;
window._liveGetNodeFilterKeys = function() { return nodeFilterKeys; };
window._livePacketMatchesRegion = packetMatchesRegion;
window._liveSetObserverIataMap = setObserverIataMap;
window._liveSetNodeFilter = setNodeFilter;
window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml;
window._liveResolveHopPositions = resolveHopPositions;
@@ -2055,6 +2101,12 @@
updateNodeFilterUI();
}
// --- Region filter (#1045): drop packet if no observation matches selected IATA ---
if (window.RegionFilter && typeof RegionFilter.getSelected === 'function') {
var _regionSel = RegionFilter.getSelected();
if (_regionSel && _regionSel.length && !packetMatchesRegion(packets, observerIataMap, _regionSel)) return;
}
// --- Ensure ADVERT nodes appear on map ---
for (var pi = 0; pi < packets.length; pi++) {
var pkt = packets[pi];
@@ -3040,6 +3092,10 @@
if (_feedTimestampInterval) { clearInterval(_feedTimestampInterval); _feedTimestampInterval = null; }
if (_affinityInterval) { clearInterval(_affinityInterval); _affinityInterval = null; }
if (ws) { ws.onclose = null; ws.close(); ws = null; }
if (regionFilterChangeHandler && window.RegionFilter && typeof RegionFilter.offChange === 'function') {
RegionFilter.offChange(regionFilterChangeHandler);
regionFilterChangeHandler = null;
}
if (map) { map.remove(); map = null; }
if (_onResize) {
window.removeEventListener('resize', _onResize);
+128 -15
View File
@@ -9,7 +9,7 @@
let nodes = [];
let targetNodeKey = null;
let observers = [];
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all', byteSize: localStorage.getItem('meshcore-map-byte-filter') || 'all', multiByteOverlay: localStorage.getItem('meshcore-map-multibyte-overlay') === 'true' };
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clustering: localStorage.getItem('meshcore-map-clustering') !== 'false', hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all', byteSize: localStorage.getItem('meshcore-map-byte-filter') || 'all', multiByteOverlay: localStorage.getItem('meshcore-map-multibyte-overlay') === 'true' };
let selectedReferenceNode = null; // pubkey of the reference node for neighbor filtering
let neighborPubkeys = null; // Set of pubkeys that are direct neighbors of selected node
let wsHandler = null;
@@ -139,7 +139,7 @@
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Display</legend>
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Cluster markers</label>
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
<label for="mcHashLabels"><input type="checkbox" id="mcHashLabels"> Hash prefix labels</label>
<label for="mcMultiByte"><input type="checkbox" id="mcMultiByte"> Multi-byte support</label>
@@ -239,6 +239,8 @@
});
markerLayer = L.layerGroup().addTo(map);
clusterGroup = createClusterGroup();
if (filters.clustering && clusterGroup) clusterGroup.addTo(map);
routeLayer = L.layerGroup().addTo(map);
// Fix map size on SPA load
@@ -260,7 +262,20 @@
});
// Bind controls
document.getElementById('mcClusters').addEventListener('change', e => { filters.clusters = e.target.checked; renderMarkers(); });
var clustersEl = document.getElementById('mcClusters');
if (clustersEl) {
clustersEl.checked = filters.clustering;
clustersEl.addEventListener('change', function (e) {
filters.clustering = e.target.checked;
localStorage.setItem('meshcore-map-clustering', filters.clustering);
if (filters.clustering) {
if (clusterGroup && !map.hasLayer(clusterGroup)) clusterGroup.addTo(map);
} else {
if (clusterGroup && map.hasLayer(clusterGroup)) map.removeLayer(clusterGroup);
}
renderMarkers();
});
}
const heatEl = document.getElementById('mcHeatmap');
if (localStorage.getItem('meshcore-map-heatmap') === 'true') { heatEl.checked = true; }
heatEl.addEventListener('change', e => { localStorage.setItem('meshcore-map-heatmap', e.target.checked); toggleHeatmap(e.target.checked); });
@@ -572,13 +587,18 @@
// Delay popup open slightly — Leaflet needs the map to settle after setView
setTimeout(() => {
let found = false;
markerLayer.eachLayer(m => {
if (found) return;
if (m._nodeKey === targetNodeKey && m.openPopup) {
m.openPopup();
found = true;
}
});
const findIn = function (layer) {
if (found || !layer || !layer.eachLayer) return;
layer.eachLayer(m => {
if (found) return;
if (m._nodeKey === targetNodeKey && m.openPopup) {
m.openPopup();
found = true;
}
});
};
findIn(markerLayer);
if (!found) findIn(clusterGroup);
if (!found) console.warn('[map] Target node marker not found:', targetNodeKey);
}, 500);
}
@@ -801,6 +821,9 @@
*/
function _repositionMarkers() {
if (!map || _currentMarkerData.length === 0) return;
// Markercluster handles its own re-layout on zoom/move — skip our deconfliction
// dance when clustering is on.
if (filters.clustering) return;
map.invalidateSize({ animate: false });
// Re-run deconfliction with current zoom pixel coordinates
@@ -825,6 +848,7 @@
function _renderMarkersInner() {
markerLayer.clearLayers();
if (clusterGroup) clusterGroup.clearLayers();
_currentMarkerData = [];
const filtered = nodes.filter(n => {
@@ -892,25 +916,37 @@
// (SPA navigation may render markers before container is fully sized)
map.invalidateSize({ animate: false });
// Deconflict ALL markers
if (allMarkers.length > 0) {
// Deconflict ALL markers — but only when clustering is OFF.
// When clustering is ON, markercluster handles overlap collapse and
// deconfliction would just waste CPU + draw offset polylines we don't want.
if (allMarkers.length > 0 && !filters.clustering) {
deconflictLabels(allMarkers, map);
}
// Store marker data for zoom/resize repositioning (avoids full rebuild)
_currentMarkerData = allMarkers;
var useCluster = filters.clustering && clusterGroup;
var clusterMarkers = [];
for (const m of allMarkers) {
const pos = m.adjustedLatLng || m.latLng;
const pos = (useCluster ? m.latLng : (m.adjustedLatLng || m.latLng));
const marker = L.marker(pos, { icon: m.icon, alt: m.alt });
marker._nodeKey = m.node.public_key || m.node.id || null;
marker._role = (m.node && m.node.role) || 'companion';
marker.bindPopup(m.popupFn(), { maxWidth: 280 });
markerLayer.addLayer(marker);
m._leafletMarker = marker;
m._leafletLine = null;
m._leafletDot = null;
_updateOffsetIndicator(m, markerLayer);
if (useCluster) {
clusterMarkers.push(marker);
} else {
markerLayer.addLayer(marker);
_updateOffsetIndicator(m, markerLayer);
}
}
if (useCluster && clusterMarkers.length > 0) {
clusterGroup.addLayers(clusterMarkers);
}
}
@@ -1172,6 +1208,7 @@
map = null;
}
markerLayer = null;
clusterGroup = null;
_currentMarkerData = [];
routeLayer = null;
if (heatLayer) { heatLayer = null; }
@@ -1316,4 +1353,80 @@
return destroy();
}
});
// ── Marker clustering (issue #1036) ──
// Wraps Leaflet.markercluster with CoreScope-themed cluster icons + sane perf
// defaults for large meshes (target: smooth pan/zoom @ 2k nodes on mid mobile).
function isMobileForClustering() {
try {
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || '');
} catch (_) { return false; }
}
function createClusterGroup() {
if (typeof L === 'undefined' || typeof L.markerClusterGroup !== 'function') {
console.warn('[map] L.markerClusterGroup not loaded — clustering disabled');
return null;
}
return L.markerClusterGroup({
chunkedLoading: true,
chunkInterval: 100,
chunkDelay: 25,
removeOutsideVisibleBounds: true,
maxClusterRadius: 60,
spiderfyOnMaxZoom: true,
spiderfyDistanceMultiplier: 1.5,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
disableClusteringAtZoom: 16,
animate: !isMobileForClustering(),
animateAddingMarkers: false,
iconCreateFunction: makeClusterIcon,
});
}
function makeClusterIcon(cluster) {
var markers = cluster.getAllChildMarkers();
var counts = { repeater: 0, companion: 0, room: 0, sensor: 0, observer: 0 };
for (var i = 0; i < markers.length; i++) {
var r = markers[i]._role || 'companion';
if (counts[r] == null) counts[r] = 0;
counts[r] += 1;
}
var total = (typeof cluster.getChildCount === 'function') ? cluster.getChildCount() : markers.length;
var bucket = total >= 100 ? 'lg' : total >= 30 ? 'md' : 'sm';
var roleOrder = ['repeater', 'companion', 'room', 'sensor', 'observer'];
var pillsHtml = '';
var tooltipParts = [];
var pillsShown = 0;
var palette = (typeof ROLE_COLORS !== 'undefined') ? ROLE_COLORS : {};
for (var j = 0; j < roleOrder.length; j++) {
var role = roleOrder[j];
var n = counts[role] || 0;
if (n <= 0) continue;
tooltipParts.push(n + ' ' + role + (n === 1 ? '' : 's'));
if (pillsShown < 4) {
var bg = palette[role] || '#6b7280';
pillsHtml += '<span class="mc-pill" style="background:' + bg + '">' + n + '</span>';
pillsShown += 1;
}
}
var html = '<div class="mc-cluster mc-' + bucket + '">' +
'<b class="mc-count">' + total + '</b>' +
'<div class="mc-pills">' + pillsHtml + '</div>' +
'</div>';
var icon = L.divIcon({
html: html,
className: 'mc-cluster-wrap mc-' + bucket,
iconSize: L.point(48, 48),
});
// Stash a tooltip string for callers that want to bindTooltip (markercluster
// does not natively pipe this through, but it's available via cluster icon
// for E2E inspection).
icon._tooltip = total + ' nodes — ' + tooltipParts.join(', ');
return icon;
}
if (typeof window !== 'undefined') {
window.__meshcoreMapInternals = { createClusterGroup: createClusterGroup, makeClusterIcon: makeClusterIcon };
}
})();
+69
View File
@@ -124,6 +124,12 @@
<div class="analytics-chart-desc">How many repeater hops packets take 0 means direct</div>
<canvas id="hopChart" role="img" aria-label="Hop distribution chart"></canvas>
</div>
<div class="analytics-chart-card full">
<h4>Battery Voltage <span id="batteryStatusBadge" style="font-size:11px;font-weight:normal;margin-left:8px"></span></h4>
<div class="analytics-chart-desc">Battery voltage over time from observer status reports flat line means full, downward slope means draining</div>
<canvas id="batteryChart" role="img" aria-label="Battery voltage trend chart"></canvas>
<div id="batteryEmpty" style="display:none;padding:20px;text-align:center;color:var(--text-muted);font-size:12px">No battery telemetry recorded for this node in this window.</div>
</div>
<div class="analytics-chart-card full">
<h4>Uptime Heatmap</h4>
<div class="analytics-chart-desc">Hour-by-hour activity grid darker = more packets in that slot</div>
@@ -159,6 +165,7 @@
buildObserverChart(data);
buildHopChart(data);
buildHeatmap(data);
loadBatteryChart(pubkey, currentDays);
}
function buildActivityChart(data) {
@@ -289,6 +296,68 @@
}
}
async function loadBatteryChart(pubkey, days) {
let data;
try {
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/battery?days=' + days);
} catch (e) {
const empty = document.getElementById('batteryEmpty');
if (empty) { empty.style.display = 'block'; empty.textContent = 'Battery data unavailable: ' + e.message; }
return;
}
const ctx = document.getElementById('batteryChart');
const empty = document.getElementById('batteryEmpty');
const badge = document.getElementById('batteryStatusBadge');
const samples = (data && data.samples) || [];
const thr = (data && data.thresholds) || { low_mv: 3300, critical_mv: 3000 };
if (badge) {
const STATUS_COLOR = { ok: '#51cf66', low: '#fcc419', critical: '#ff6b6b', unknown: 'var(--text-muted)' };
const label = data && data.status === 'ok' ? '🔋 OK'
: data && data.status === 'low' ? '⚠️ Low'
: data && data.status === 'critical' ? '🪫 Critical'
: 'No data';
const mv = data && data.latest_mv ? ' · ' + data.latest_mv + ' mV' : '';
badge.textContent = label + mv;
badge.style.color = STATUS_COLOR[(data && data.status) || 'unknown'];
}
if (!ctx || samples.length === 0) {
if (ctx) ctx.style.display = 'none';
if (empty) empty.style.display = 'block';
return;
}
if (empty) empty.style.display = 'none';
ctx.style.display = '';
const labels = samples.map(p => {
const d = new Date(p.timestamp);
return (typeof formatChartAxisLabel === 'function')
? formatChartAxisLabel(d, days <= 3)
: (days <= 3 ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: 'short', day: 'numeric' }));
});
const values = samples.map(p => p.battery_mv);
const c = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{ label: 'Battery (mV)', data: values, borderColor: '#4a9eff', backgroundColor: 'rgba(74,158,255,0.15)', tension: 0.25, pointRadius: 2, fill: true },
{ label: 'Low threshold', data: values.map(() => thr.low_mv), borderColor: '#fcc419', borderDash: [6, 4], pointRadius: 0, fill: false },
{ label: 'Critical', data: values.map(() => thr.critical_mv), borderColor: '#ff6b6b', borderDash: [6, 4], pointRadius: 0, fill: false }
]
},
options: {
responsive: true,
plugins: { legend: { display: true, position: 'bottom' } },
scales: { x: { ticks: { maxTicksAutoSkip: true, maxRotation: 45 } }, y: { title: { display: true, text: 'mV' } } }
}
});
charts.push(c);
}
function init(container, routeParam) {
// routeParam is "PUBKEY/analytics"
if (!routeParam || !routeParam.endsWith('/analytics')) {
+41 -2
View File
@@ -82,12 +82,26 @@
var parts = [];
if (tab && tab !== 'all') parts.push('tab=' + encodeURIComponent(tab));
if (searchStr) parts.push('search=' + encodeURIComponent(searchStr));
// #749 — encode current sort state (default 'last_seen:desc' is omitted).
if (window.URLState) {
var st = _getSortState();
var isDefault = st.column === 'last_seen' && st.direction === 'desc';
if (!isDefault) {
var token = URLState.serializeSort(st.column, st.direction);
if (token) parts.push('sort=' + encodeURIComponent(token));
}
}
return parts.length ? '?' + parts.join('&') : '';
}
window.buildNodesQuery = buildNodesQuery;
function updateNodesUrl() {
history.replaceState(null, '', '#/nodes' + buildNodesQuery(activeTab, search));
// Preserve subpath (e.g. #/nodes/<pubkey>) so this doesn't break detail deep-links.
var cur = String(location.hash || '');
var subpath = '';
var m = cur.match(/^#\/nodes(\/[^?]*)?/);
if (m && m[1]) subpath = m[1];
history.replaceState(null, '', '#/nodes' + subpath + buildNodesQuery(activeTab, search));
}
function renderNodeTimestampHtml(isoString) {
@@ -370,6 +384,15 @@
const _urlSearch = _listUrlParams.get('search');
if (_urlTab && TABS.some(function(t) { return t.key === _urlTab; })) activeTab = _urlTab;
if (_urlSearch) search = _urlSearch;
// #749 — restore sort from URL (overrides localStorage persistence).
var _urlSort = _listUrlParams.get('sort');
if (_urlSort && window.URLState) {
var _parsedSort = URLState.parseSort(_urlSort);
if (_parsedSort && _parsedSort.column) {
try { localStorage.setItem('meshcore-nodes-sort', JSON.stringify(_parsedSort)); } catch {}
_fallbackSortState = _parsedSort;
}
}
app.innerHTML = `<div class="nodes-page">
<div class="nodes-topbar">
@@ -508,6 +531,22 @@
<table class="node-stats-table" id="node-stats">
<tr><td>Status</td><td><span title="${si.statusTooltip}">${statusLabel}</span> <span style="font-size:11px;color:var(--text-muted);margin-left:4px">${statusExplanation}</span></td></tr>
<tr><td>Last Heard</td><td>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</td></tr>
${(n.role === 'repeater' || n.role === 'room') ? `<tr><td title="Last time this repeater appeared as a relay hop in a non-advert packet observed by the network. Distinct from 'Last Heard' (which counts the repeater's own adverts). See issue #662.">Last Relayed</td><td>${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? '<span style="color:var(--status-green);font-size:11px">🟢 actively relaying</span>' : '<span style="color:var(--status-yellow);font-size:11px">🟡 alive (idle)</span>') : '<span style="color:var(--text-muted)">never observed as relay hop</span> <span style="color:var(--status-yellow);font-size:11px">🟡 alive (idle)</span>'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` <span style="color:var(--text-muted);font-size:11px;margin-left:4px">(${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)</span>` : ''}</td></tr>` : ''}
${(n.role === 'repeater' || n.role === 'room') && n.usefulness_score != null ? (() => {
const s = Number(n.usefulness_score) || 0;
const pct = (s * 100).toFixed(1);
// Visual indicator: width % bar with green→yellow→red color by score.
// Per issue #672 classification table: 0.8+ Critical, 0.6+ Valuable,
// 0.3+ Moderate, 0.1+ Marginal, else Redundant.
let label, color;
if (s >= 0.8) { label = 'Critical'; color = 'var(--status-green, #2ecc71)'; }
else if (s >= 0.6) { label = 'Valuable'; color = 'var(--status-green, #2ecc71)'; }
else if (s >= 0.3) { label = 'Moderate'; color = 'var(--status-yellow, #f1c40f)'; }
else if (s >= 0.1) { label = 'Marginal'; color = 'var(--status-orange, #e67e22)'; }
else { label = 'Redundant'; color = 'var(--status-red, #e74c3c)'; }
const barWidth = Math.max(2, Math.round(s * 100));
return `<tr id="row-usefulness-score" data-usefulness-score="${s.toFixed(4)}"><td title="Fraction of non-advert traffic in the network observed by CoreScope that this repeater carries as a relay hop (Traffic axis of issue #672). Range 01; higher = forwards more of the mesh's actual traffic.">Usefulness</td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${barWidth}%;height:100%;background:${color}"></span></span><span style="color:${color};font-weight:600">${pct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${label}</span></td></tr>`;
})() : ''}
<tr><td>First Seen</td><td>${renderNodeTimestampHtml(n.first_seen)}</td></tr>
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
<tr><td>Packets Today</td><td>${stats.packetsToday || 0}</td></tr>
@@ -1091,7 +1130,7 @@
defaultColumn: 'last_seen',
defaultDirection: 'desc',
storageKey: 'meshcore-nodes-sort',
onSort: function () { renderRows(); }
onSort: function () { renderRows(); updateNodesUrl(); }
});
}
+1 -1
View File
@@ -144,7 +144,7 @@
<caption class="sr-only">Observer status and statistics</caption>
<thead><tr>
<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Region</th><th scope="col">Last Status</th><th scope="col">Last Packet</th>
<th scope="col">Packets</th><th scope="col">Packets/Hour</th><th scope="col">Clock Offset</th><th scope="col">Uptime</th>
<th scope="col">Packet Health</th><th scope="col">Total Packets</th><th scope="col">Packets/Hour</th><th scope="col">Clock Offset</th><th scope="col">Uptime</th>
</tr></thead>
<tbody>${filtered.map(o => {
const h = healthStatus(o.last_seen);
+229 -14
View File
@@ -22,10 +22,14 @@
// ── Lexer ──────────────────────────────────────────────────────────────────
var TK = {
FIELD: 'FIELD', OP: 'OP', STRING: 'STRING', NUMBER: 'NUMBER', BOOL: 'BOOL',
DURATION: 'DURATION',
AND: 'AND', OR: 'OR', NOT: 'NOT', LPAREN: 'LPAREN', RPAREN: 'RPAREN'
};
var OP_WORDS = { contains: true, starts_with: true, ends_with: true };
var OP_WORDS = { contains: true, starts_with: true, ends_with: true, after: true, before: true, between: true };
// Duration unit → seconds. Used for `age < 1h`-style filters.
var DURATION_UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
function lex(input) {
var tokens = [], i = 0, len = input.length;
@@ -66,7 +70,19 @@
if (input[i] === '-') i++;
while (i < len && /[0-9]/.test(input[i])) i++;
if (i < len && input[i] === '.') { i++; while (i < len && /[0-9]/.test(input[i])) i++; }
tokens.push({ type: TK.NUMBER, value: parseFloat(input.slice(start, i)) });
var numStr = input.slice(start, i);
// Duration suffix: 1h, 15m, 7d, 30s, 2w. Rejects bare letters/multi-letter units.
if (i < len && /[a-zA-Z]/.test(input[i])) {
var unitStart = i;
while (i < len && /[a-zA-Z]/.test(input[i])) i++;
var unit = input.slice(unitStart, i);
if (!DURATION_UNITS[unit]) {
return { tokens: null, error: "Invalid duration unit '" + unit + "' at position " + unitStart + " (expected s/m/h/d/w)" };
}
tokens.push({ type: TK.DURATION, value: parseFloat(numStr) * DURATION_UNITS[unit], raw: numStr + unit });
continue;
}
tokens.push({ type: TK.NUMBER, value: parseFloat(numStr) });
continue;
}
// identifier / keyword / bare value
@@ -154,20 +170,41 @@
}
var op = advance().value;
// Parse value
// `between` takes two values: `field between <a> <b>`
if (op === 'between') {
var lo = parseValue(field, op);
var hi = parseValue(field, op);
validateTimeValue(field, op, lo);
validateTimeValue(field, op, hi);
return { type: 'comparison', field: field, op: op, value: lo, value2: hi };
}
var value = parseValue(field, op);
if (op === 'after' || op === 'before') validateTimeValue(field, op, value);
return { type: 'comparison', field: field, op: op, value: value };
}
// Validates that a value supplied to a temporal op parses as a date.
function validateTimeValue(field, op, v) {
if (typeof v !== 'string') return; // numeric epochs are accepted as-is
var ms = Date.parse(v);
if (isNaN(ms)) {
throw new Error("Invalid datetime '" + v + "' for '" + field + ' ' + op + "'");
}
}
function parseValue(field, op) {
var valTok = peek();
if (!valTok) throw new Error("Expected value after '" + field + ' ' + op + "'");
var value;
if (valTok.type === TK.STRING) { value = advance().value; }
else if (valTok.type === TK.NUMBER) { value = advance().value; }
else if (valTok.type === TK.BOOL) { value = advance().value; }
else if (valTok.type === TK.FIELD) {
if (valTok.type === TK.STRING) { return advance().value; }
if (valTok.type === TK.NUMBER) { return advance().value; }
if (valTok.type === TK.BOOL) { return advance().value; }
if (valTok.type === TK.DURATION) { return { __duration: true, seconds: advance().value }; }
if (valTok.type === TK.FIELD) {
// Bare word as string value (e.g., ADVERT, FLOOD)
value = advance().value;
return advance().value;
}
else { throw new Error("Expected value after '" + field + ' ' + op + "'"); }
return { type: 'comparison', field: field, op: op, value: value };
throw new Error("Expected value after '" + field + ' ' + op + "'");
}
try {
@@ -197,6 +234,22 @@
if (field === 'observer') return packet.observer_name || '';
if (field === 'observer_id') return packet.observer_id || '';
if (field === 'observations') return packet.observation_count || 0;
if (field === 'time' || field === 'timestamp') {
// Returns ms-since-epoch or null. Falls back to first_seen when timestamp absent
// (group rows from /api/packets?groupByHash=true expose first_seen instead).
var ts = packet.timestamp || packet.first_seen || packet.latest;
if (!ts) return null;
var ms = typeof ts === 'number' ? ts : Date.parse(ts);
return isNaN(ms) ? null : ms;
}
if (field === 'age') {
// Age in seconds since the packet timestamp (NOW - ts).
var ts2 = packet.timestamp || packet.first_seen || packet.latest;
if (!ts2) return null;
var ms2 = typeof ts2 === 'number' ? ts2 : Date.parse(ts2);
if (isNaN(ms2)) return null;
return Math.max(0, (Date.now() - ms2) / 1000);
}
if (field === 'path') {
try { return JSON.parse(packet.path_json || '[]').join(' → '); } catch(e) { return ''; }
}
@@ -224,6 +277,16 @@
}
// ── Evaluator ──────────────────────────────────────────────────────────────
function parseDateValue(v) {
if (v == null) return null;
if (typeof v === 'number') return v;
if (typeof v === 'string') {
var ms = Date.parse(v);
return isNaN(ms) ? null : ms;
}
return null;
}
function evaluate(ast, packet) {
if (!ast) return true;
switch (ast.type) {
@@ -241,10 +304,27 @@
if (fieldVal == null || fieldVal === undefined) return false;
// Temporal ops: after / before / between operate on epoch-ms.
if (op === 'after' || op === 'before' || op === 'between') {
var lhsMs = typeof fieldVal === 'number' ? fieldVal : Date.parse(fieldVal);
if (isNaN(lhsMs)) return false;
var rhs1 = parseDateValue(target);
if (rhs1 == null) return false;
if (op === 'after') return lhsMs > rhs1;
if (op === 'before') return lhsMs < rhs1;
var rhs2 = parseDateValue(ast.value2);
if (rhs2 == null) return false;
var lo = Math.min(rhs1, rhs2), hi = Math.max(rhs1, rhs2);
return lhsMs >= lo && lhsMs <= hi;
}
// Numeric operators
if (op === '>' || op === '<' || op === '>=' || op === '<=') {
var a = typeof fieldVal === 'number' ? fieldVal : parseFloat(fieldVal);
var b = typeof target === 'number' ? target : parseFloat(target);
// Duration values are pre-converted to seconds at lex time
var b = (target && typeof target === 'object' && target.__duration)
? target.seconds
: (typeof target === 'number' ? target : parseFloat(target));
if (isNaN(a) || isNaN(b)) return false;
if (op === '>') return a > b;
if (op === '<') return a < b;
@@ -304,7 +384,142 @@
};
}
var _exports = { parse: parse, evaluate: evaluate, compile: compile };
// ── Metadata for autocomplete + in-UI documentation (#966) ────────────────
var FIELDS = [
{ name: 'type', desc: 'Packet payload type (ADVERT, GRP_TXT, TXT_MSG, ACK, …)' },
{ name: 'route', desc: 'Route type (FLOOD, DIRECT, TRANSPORT_FLOOD, TRANSPORT_DIRECT)' },
{ name: 'transport', desc: 'true if route is TRANSPORT_FLOOD or TRANSPORT_DIRECT' },
{ name: 'hash', desc: 'Packet hash (hex)' },
{ name: 'raw', desc: 'Full raw hex of the packet' },
{ name: 'size', desc: 'Total packet size in bytes' },
{ name: 'snr', desc: 'Signal-to-noise ratio (dB)' },
{ name: 'rssi', desc: 'Received signal strength (dBm)' },
{ name: 'hops', desc: 'Number of hops in the path' },
{ name: 'observer', desc: 'Observer station name' },
{ name: 'observer_id', desc: 'Observer pubkey/id' },
{ name: 'observations', desc: 'Number of observations of this packet' },
{ name: 'path', desc: 'Hop path (joined with arrows)' },
{ name: 'payload_bytes', desc: 'Payload size in bytes (size - 2 header bytes)' },
{ name: 'payload_hex', desc: 'Payload bytes as hex (raw without header)' },
{ name: 'time', desc: 'Packet timestamp (epoch ms)' },
{ name: 'age', desc: 'Seconds since the packet was observed (use with durations: age < 1h)' },
{ name: 'payload.name', desc: 'Decoded payload: node name (adverts)' },
{ name: 'payload.lat', desc: 'Decoded payload: latitude' },
{ name: 'payload.lon', desc: 'Decoded payload: longitude' },
{ name: 'payload.text', desc: 'Decoded payload: message text (channel/DM)' },
{ name: 'payload.channel', desc: 'Decoded payload: channel name' },
{ name: 'payload.channelHash', desc: 'Decoded payload: channel hash' },
{ name: 'payload.sender', desc: 'Decoded payload: sender name' },
{ name: 'payload.flags.repeater', desc: 'Decoded payload: advert flag (repeater role)' },
{ name: 'payload.flags.room', desc: 'Decoded payload: advert flag (room server)' },
{ name: 'payload.flags.hasLocation', desc: 'Decoded payload: advert has location' },
];
var OPERATORS = [
{ op: '==', desc: 'Equal (case-insensitive for strings, alias-aware for type/route)', example: 'type == ADVERT' },
{ op: '!=', desc: 'Not equal', example: 'type != ACK' },
{ op: '>', desc: 'Greater than (numeric)', example: 'snr > 5' },
{ op: '<', desc: 'Less than (numeric)', example: 'rssi < -90' },
{ op: '>=', desc: 'Greater or equal', example: 'hops >= 2' },
{ op: '<=', desc: 'Less or equal', example: 'size <= 100' },
{ op: 'contains', desc: 'Substring match (case-insensitive)', example: 'payload.name contains "Gilroy"' },
{ op: 'starts_with', desc: 'String prefix match', example: 'hash starts_with "8a91"' },
{ op: 'ends_with', desc: 'String suffix match', example: 'hash ends_with "ff"' },
{ op: 'after', desc: 'Datetime after (ISO or epoch)', example: 'time after "2025-01-01"' },
{ op: 'before', desc: 'Datetime before', example: 'time before "2025-12-31"' },
{ op: 'between', desc: 'Datetime between two values', example: 'time between "2025-01-01" "2025-02-01"' },
];
// Canonical type names (firmware payload types)
var TYPE_VALUES = ['REQ', 'RESPONSE', 'TXT_MSG', 'ACK', 'ADVERT', 'GRP_TXT', 'GRP_DATA', 'ANON_REQ', 'PATH', 'TRACE', 'MULTIPART', 'CONTROL', 'RAW_CUSTOM'];
var ROUTE_VALUES = ['TRANSPORT_FLOOD', 'FLOOD', 'DIRECT', 'TRANSPORT_DIRECT'];
// suggest(input, cursor, opts?) → { suggestions: [{value, kind, desc?}], replaceStart, replaceEnd }
// Token-aware autocomplete:
// - Empty / partial-word at cursor → field names
// - Right after `field` → operators
// - Right after `type ==` → TYPE_VALUES (filtered by partial)
// - Right after `route ==` → ROUTE_VALUES
// - Partial `payload.<x>` → payload.* fields (incl. dynamic opts.payloadKeys)
function suggest(input, cursor, opts) {
opts = opts || {};
input = input || '';
if (cursor == null) cursor = input.length;
var before = input.slice(0, cursor);
// Determine the current word being typed (the replaceable span).
// Treat alphanumerics, '_', and '.' as word chars (so "payload.na" is one word).
var i = cursor;
while (i > 0 && /[A-Za-z0-9_.]/.test(input.charAt(i - 1))) i--;
var replaceStart = i;
var replaceEnd = cursor;
while (replaceEnd < input.length && /[A-Za-z0-9_.]/.test(input.charAt(replaceEnd))) replaceEnd++;
var partial = input.slice(replaceStart, cursor);
// Look at preceding non-space tokens (very small recogniser)
var preceding = before.slice(0, replaceStart).replace(/\s+$/, '');
var lastTokMatch = preceding.match(/(==|!=|>=|<=|>|<|contains|starts_with|ends_with|after|before|between|&&|\|\||\(|!)$/);
var lastTok = lastTokMatch ? lastTokMatch[1] : null;
// The token before lastTok (the field, if any)
var fieldBefore = null;
if (lastTok) {
var beforeOp = preceding.slice(0, preceding.length - lastTok.length).replace(/\s+$/, '');
var fm = beforeOp.match(/([A-Za-z_][A-Za-z0-9_.]*)$/);
if (fm) fieldBefore = fm[1];
}
function makePrefixSuggestions(items, kind) {
var p = partial.toLowerCase();
var out = [];
for (var k = 0; k < items.length; k++) {
var it = items[k];
var val = typeof it === 'string' ? it : it.value;
if (!p || val.toLowerCase().indexOf(p) === 0) {
out.push({ value: val, kind: kind, desc: typeof it === 'string' ? '' : (it.desc || '') });
}
}
return out;
}
// Case A: just typed `field ==` (or other comparison op) → value suggestions
if (lastTok && fieldBefore) {
if (fieldBefore === 'type' && (lastTok === '==' || lastTok === '!=')) {
return { suggestions: makePrefixSuggestions(TYPE_VALUES, 'value'), replaceStart: replaceStart, replaceEnd: replaceEnd };
}
if (fieldBefore === 'route' && (lastTok === '==' || lastTok === '!=')) {
return { suggestions: makePrefixSuggestions(ROUTE_VALUES, 'value'), replaceStart: replaceStart, replaceEnd: replaceEnd };
}
}
// Case B: a field is just typed (no operator yet) → operator suggestions
// Detect: preceding ends with a known field-like identifier and there's no partial word at cursor
if (!partial && preceding.length) {
var afterField = preceding.match(/([A-Za-z_][A-Za-z0-9_.]*)$/);
if (afterField && !lastTok) {
var ops = OPERATORS.map(function(o) { return { value: o.op, kind: 'op', desc: o.desc }; });
return { suggestions: ops, replaceStart: replaceStart, replaceEnd: replaceEnd };
}
}
// Case C: default → field name suggestions (incl. dynamic payload.* keys)
var fieldItems = FIELDS.map(function(f) { return { value: f.name, desc: f.desc }; });
if (Array.isArray(opts.payloadKeys)) {
var have = {};
for (var z = 0; z < fieldItems.length; z++) have[fieldItems[z].value] = true;
for (var y = 0; y < opts.payloadKeys.length; y++) {
var pkey = 'payload.' + opts.payloadKeys[y];
if (!have[pkey]) fieldItems.push({ value: pkey, desc: 'Decoded payload field (dynamic)' });
}
}
return { suggestions: makePrefixSuggestions(fieldItems, 'field'), replaceStart: replaceStart, replaceEnd: replaceEnd };
}
var _exports = {
parse: parse, evaluate: evaluate, compile: compile,
FIELDS: FIELDS, OPERATORS: OPERATORS,
TYPE_VALUES: TYPE_VALUES, ROUTE_VALUES: ROUTE_VALUES,
suggest: suggest,
};
if (typeof window !== 'undefined') window.PacketFilter = _exports;
// ── Self-tests (Node.js only) ─────────────────────────────────────────────
+46 -15
View File
@@ -53,12 +53,25 @@
if (filters.observer) parts.push('observer=' + encodeURIComponent(filters.observer));
if (filters.channel) parts.push('channel=' + encodeURIComponent(filters.channel));
if (filters._filterExpr) parts.push('filter=' + encodeURIComponent(filters._filterExpr));
// Sort state (#749) — encode as 'col[:asc]'; default 'time:desc' is omitted.
if (_packetSortColumn) {
var sortDefault = _packetSortColumn === 'time' && _packetSortDirection === 'desc';
if (!sortDefault && window.URLState) {
var sortToken = URLState.serializeSort(_packetSortColumn, _packetSortDirection);
if (sortToken) parts.push('sort=' + encodeURIComponent(sortToken));
}
}
return parts.length ? '?' + parts.join('&') : '';
}
window.buildPacketsQuery = buildPacketsQuery;
function updatePacketsUrl() {
history.replaceState(null, '', '#/packets' + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam()));
// Preserve any subpath after /packets (e.g. #/packets/<hash>).
var cur = String(location.hash || '');
var subpath = '';
var m = cur.match(/^#\/packets(\/[^?]*)?/);
if (m && m[1]) subpath = m[1];
history.replaceState(null, '', '#/packets' + subpath + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam()));
// Update clear-filters button visibility
var cb = document.getElementById('clearFiltersBtn');
if (cb) {
@@ -366,6 +379,17 @@
if (_urlChannel) filters.channel = _urlChannel;
var _urlFilterExpr = _initUrlParams.get('filter');
if (_urlFilterExpr) filters._filterExpr = _urlFilterExpr;
// #749 — restore sort state from URL (overrides localStorage).
var _urlSort = _initUrlParams.get('sort');
if (_urlSort && window.URLState) {
var _parsed = URLState.parseSort(_urlSort);
if (_parsed) {
_packetSortColumn = _parsed.column;
_packetSortDirection = _parsed.direction;
// Persist so TableSort init picks it up.
try { localStorage.setItem('meshcore-packets-sort', JSON.stringify({ column: _parsed.column, direction: _parsed.direction })); } catch {}
}
}
app.innerHTML = `<div class="split-layout detail-collapsed">
<div class="panel-left" id="pktLeft" aria-live="polite" aria-relevant="additions removals"></div>
@@ -781,7 +805,7 @@
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet" aria-label="Bring Your Own Packet - paste raw packet hex for analysis" aria-haspopup="dialog">📦 BYOP</button>
</div>
</div>
<div class="filter-group" style="flex:1;margin-bottom:8px">
<div class="filter-group" style="flex:1;margin-bottom:8px;position:relative">
<input type="text" id="packetFilterInput" class="packet-filter-input"
placeholder='Filter: type == Advert && snr > 5 · payload.name contains "Gilroy"'
aria-label="Packet filter expression"
@@ -837,7 +861,7 @@
<option value="chrono-asc">Sort: Time (earliest)</option>
<option value="chrono-desc">Sort: Time (latest)</option>
</select>
<span class="sort-help" id="sortHelpIcon"></span>
<span class="sort-help" id="sortHelpIcon" tabindex="0" role="button" aria-label="Sort help"></span>
</div>
<div class="filter-group">
<div class="col-toggle-wrap">
@@ -916,6 +940,12 @@
});
})();
// Wireshark-style filter UX (#966): help popover, autocomplete, right-click
// context menu, saved-filter dropdown. Idempotent — safe to re-call.
if (window.FilterUX && typeof window.FilterUX.init === 'function') {
window.FilterUX.init();
}
// --- Observer multi-select ---
const obsMenu = document.getElementById('observerMenu');
const obsTrigger = document.getElementById('observerTrigger');
@@ -1393,6 +1423,7 @@
_packetSortDirection = direction;
sortPacketsArray();
renderTableRows();
updatePacketsUrl();
}
});
// Apply initial sort state from TableSort
@@ -1436,11 +1467,11 @@
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
<td class="col-time">${renderTimestampCell(p.latest)}</td>
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || '—', 8)}</td>
<td class="col-size" data-filter-field="size" data-filter-value="${groupSize || ''}">${groupSize ? groupSize + 'B' : '—'}</td>
<td class="col-hashsize mono">${groupHashBytes}</td>
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(groupTypeName || '')}">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(headerObserverId) || '')}">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
<td class="col-details">${getDetailPreview(getParsedDecoded(p))}</td>
@@ -1462,11 +1493,11 @@
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_childHashStripe ? ' style="' + _childHashStripe + '"' : ''}>
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : ''}</td>
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
<td class="col-size">${size}B</td>
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(c.hash || '')}">${truncate(c.hash || '', 8)}</td>
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
<td class="col-hashsize mono">${childHashBytes}</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(c.observer_id) || '')}">${truncate(obsName(c.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
<td class="col-rpt"></td>
<td class="col-details">${getDetailPreview(getParsedDecoded(c))}</td>
@@ -1494,11 +1525,11 @@
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" data-entry-idx="${entryIdx}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}"${_flatStyle ? ' style="' + _flatStyle + '"' : ''}>
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : ''}</td>
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
<td class="col-size">${size}B</td>
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || String(p.id), 8)}</td>
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
<td class="col-hashsize mono">${hashBytes}</td>
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(p.observer_id) || '')}">${truncate(obsName(p.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
<td class="col-rpt"></td>
<td class="col-details">${detail}</td>
+454 -9
View File
@@ -1,6 +1,57 @@
/* === CoreScope — style.css === */
/* ============================================================
* FLUID SCAFFOLDING (issue #1054)
* ------------------------------------------------------------
* Global design tokens for spacing, typography, and container
* layout. All values use clamp()/min() so the layout scales
* smoothly between ~768px and ~2560px viewports without media
* queries. Targets at the historic 1440px design width match
* the previous hardcoded px values to within ~1px so existing
* pages render identically there.
*
* Component-specific spacing/typography (nav, tables, charts,
* map, packets, analytics, ) lives in its own marked region
* further below DO NOT add component CSS in this region.
* ============================================================ */
:root {
/* --- Fluid spacing scale ---------------------------------
* Targets at 1440px viewport: 4 / 8 / 16 / 24 / 32 / 48 px.
* Min/max clamps keep small viewports usable and prevent
* runaway growth on ultra-wide displays.
*/
--space-xs: clamp(3px, 0.15vw + 2px, 6px);
--space-sm: clamp(6px, 0.30vw + 4px, 12px);
--space-md: clamp(10px, 0.50vw + 8px, 20px);
--space-lg: clamp(16px, 0.75vw + 12px, 32px);
--space-xl: clamp(24px, 1.00vw + 16px, 48px);
--space-2xl: clamp(32px, 2.00vw + 20px, 64px);
/* --- Fluid type scale ------------------------------------
* Targets at 1440px viewport: 13 / 16 / 18 / 24 / 32 px.
* Floors ensure readability at 768px; caps prevent giant
* text at 2560px+.
*/
--fs-sm: clamp(12px, 0.15vw + 11px, 14px);
--fs-md: clamp(14px, 0.20vw + 13px, 17px);
--fs-lg: clamp(15px, 0.30vw + 14px, 20px);
--fs-xl: clamp(18px, 0.50vw + 16px, 28px);
--fs-2xl: clamp(22px, 0.75vw + 20px, 36px);
/* --- Fluid radii ----------------------------------------- */
--radius-sm: clamp(3px, 0.1vw + 2px, 6px);
--radius-md: clamp(6px, 0.2vw + 5px, 12px);
--radius-lg: clamp(10px, 0.3vw + 8px, 18px);
/* --- Container layout ------------------------------------
* --gutter scales the side padding; --content-max caps the
* usable content width but always leaves a gutter on each
* side at small viewports.
*/
--gutter: clamp(12px, 2vw, 32px);
--content-max: min(100% - (2 * var(--gutter)), 1600px);
--nav-bg: #0f0f23;
--nav-bg2: #1a1a2e;
--nav-text: #ffffff;
@@ -103,7 +154,13 @@
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; font-family: var(--font); background: var(--content-bg); color: var(--text); }
html, body { height: 100%; font-family: var(--font); font-size: var(--fs-md); background: var(--content-bg); color: var(--text); }
/* ============================================================
* COMPONENT STYLES page-specific rules below.
* (Nav, tables, charts, map, packets, analytics, etc.)
* Tasks 1050-3..6 / 1052-* edit sections inside this region.
* ============================================================ */
/* === Skip Link === */
.skip-link { position: absolute; top: -100%; left: 16px; padding: 8px 16px; background: var(--accent); color: #fff; border-radius: 6px; z-index: 999; font-weight: 600; text-decoration: none; }
@@ -116,7 +173,202 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
}
/* === Touch Targets === */
.nav-link { min-height: 44px; display: inline-flex; align-items: center; }
/* WCAG 2.5.5 / Apple HIG / Material: 48x48 CSS px minimum touch target for
all interactive controls. Targets are achieved with min-height/min-width
plus inline-flex centering so existing visual styling (font-size, padding,
icon size) is preserved on desktop while the *hit area* grows for touch.
Issue #1060. */
.nav-link { min-height: 48px; display: inline-flex; align-items: center; }
/* Generic button surfaces filter bar, modal buttons, inline .btn usages.
inline-flex keeps text/icons centered without changing visible padding much. */
.btn,
.filter-bar .btn,
.filter-group .btn {
min-height: 48px;
min-width: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.btn-icon {
min-height: 48px;
min-width: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
touch-action: manipulation;
}
.nav-btn {
min-height: 48px;
min-width: 48px;
touch-action: manipulation;
}
.ch-icon-btn,
.ch-remove-btn,
.ch-share-btn {
min-height: 48px;
min-width: 48px;
touch-action: manipulation;
}
.ch-gear-btn {
min-height: 48px;
min-width: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
touch-action: manipulation;
}
.panel-close-btn {
min-height: 48px;
min-width: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
touch-action: manipulation;
}
.mc-jump-btn {
min-height: 48px;
min-width: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
touch-action: manipulation;
}
button.ch-item {
min-height: 48px;
touch-action: manipulation;
}
/* Additional button-like controls discovered during PR #1067 review (Issue
#1060 follow-up). Same 48x48 minimums + touch-action so all interactive
surfaces meet WCAG 2.5.5. */
.btn-link,
.col-toggle-btn,
.filter-toggle-btn,
.ch-add-channel-btn,
.ch-back-btn,
.ch-modal-btn-secondary,
.ch-scroll-btn,
.chooser-btn,
.clock-filter-btn,
.compare-btn,
.copy-link-btn,
.alab-btn {
min-height: 48px;
min-width: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.btn-link:active,
.col-toggle-btn:active,
.filter-toggle-btn:active,
.ch-add-channel-btn:active,
.ch-back-btn:active,
.ch-modal-btn-secondary:active,
.ch-scroll-btn:active,
.chooser-btn:active,
.clock-filter-btn:active,
.compare-btn:active,
.copy-link-btn:active,
.alab-btn:active {
background: var(--row-hover);
transform: scale(0.97);
opacity: 0.9;
}
/* Form controls: native <select> and text-like <input> need 48px tap targets
too. Scoped to interactive input types checkbox/radio/range keep their
own visible size and rely on a wrapping label/parent for hit area. */
select,
input[type="text"],
input[type="search"],
input[type="number"],
input[type="email"],
input[type="password"],
input[type="tel"],
input[type="url"],
input[type="date"],
input[type="time"],
input[type="datetime-local"],
input[type="month"],
input[type="week"] {
min-height: 48px;
box-sizing: border-box;
touch-action: manipulation;
}
/* Visible :active states touch devices have no hover, so :active is the
primary feedback channel. Use opacity + slight scale + background shift so
the press is felt even when the user's finger covers the control. */
.btn:active,
.filter-bar .btn:active,
.filter-group .btn:active {
background: var(--row-hover);
transform: scale(0.97);
opacity: 0.9;
}
.btn-icon:active {
background: var(--row-hover);
transform: scale(0.97);
}
.nav-btn:active {
background: var(--nav-bg2);
transform: scale(0.97);
opacity: 0.9;
}
.ch-icon-btn:active,
.ch-remove-btn:active,
.ch-share-btn:active {
opacity: 1;
transform: scale(0.92);
}
.ch-gear-btn:active,
.panel-close-btn:active,
.mc-jump-btn:active {
background: var(--row-hover);
transform: scale(0.95);
}
/* Hovertap conversion. Hover-only feedback (e.g., tooltip reveal) is gated
behind @media (hover: hover) so touch devices don't get stuck in a hover
state after a tap. The same content is exposed on tap via :focus-within
below. */
@media (hover: hover) {
.sort-help:hover .sort-help-tip { display: block; }
}
/* Tap-to-reveal tooltip: .sort-help becomes keyboard/tap focusable (set
tabindex="0" in markup). On focus or focus-within, the tip is shown so a
tap on touch devices reveals it without requiring hover. */
.sort-help { outline: none; }
.sort-help:focus,
.sort-help:focus-within {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
.sort-help:focus .sort-help-tip,
.sort-help:focus-within .sort-help-tip { display: block; }
/* === Nav === */
.top-nav {
@@ -124,8 +376,9 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
background: linear-gradient(135deg, var(--nav-bg) 0%, var(--nav-bg2) 100%); color: var(--nav-text); padding: 0 20px; height: 52px;
position: sticky; top: 0; z-index: 1100;
box-shadow: 0 2px 8px rgba(0,0,0,.3);
flex-wrap: nowrap; overflow: hidden; min-width: 0;
}
.nav-left { display: flex; align-items: center; gap: 24px; }
.nav-left { display: flex; align-items: center; gap: 24px; min-width: 0; flex-shrink: 1; overflow: hidden; }
.nav-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--nav-text); font-weight: 700; font-size: 16px; }
.brand-icon { font-size: 20px; }
.live-dot {
@@ -145,6 +398,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
border-bottom: 2px solid transparent; transition: all .15s;
background: none; border-top: none; border-left: none; border-right: none;
cursor: pointer; font-family: var(--font);
white-space: nowrap; /* #1046: never wrap labels — wrapping makes the nav bar grow taller */
}
.nav-link:hover { color: var(--nav-text); }
.nav-link.active {
@@ -168,7 +422,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
}
.dropdown-item:hover { background: var(--accent); color: #fff; }
.nav-right { display: flex; align-items: center; gap: 8px; }
.nav-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.nav-btn {
background: none; border: 1px solid var(--border); color: var(--nav-text-muted); padding: 6px 12px;
border-radius: 6px; cursor: pointer; font-size: 14px; transition: all .15s;
@@ -537,9 +791,31 @@ button.ch-item.selected { background: var(--selected-bg); }
text-align: center;
line-height: 1.4;
}
.ch-remove-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 13px; padding: 0 2px; margin-left: 4px; opacity: 0; transition: opacity 0.15s; line-height: 1; }
button.ch-item:hover .ch-remove-btn { opacity: 0.6; }
.ch-remove-btn:hover { opacity: 1 !important; color: var(--danger, #dc2626); }
/* Shared icon button base for sidebar row controls (remove , share ).
WCAG 2.5.5 / Apple HIG: 44x44 CSS px minimum touch target. */
.ch-icon-btn {
display: inline-flex; align-items: center; justify-content: center;
background: none; border: none; color: var(--text-muted);
cursor: pointer; padding: 4px; margin-left: 2px;
min-width: 44px; min-height: 44px; box-sizing: border-box;
opacity: 0.55; transition: opacity 0.15s, color 0.15s;
line-height: 1; user-select: none;
}
.ch-remove-btn {
font-size: 14px;
background: var(--statusRed, #b54a4a);
color: white;
border-radius: 4px;
padding: 4px 8px;
font-weight: bold;
opacity: 0.9;
}
.ch-share-btn { font-size: 13px; padding: 4px 8px; }
button.ch-item:hover .ch-icon-btn { opacity: 1; }
.ch-icon-btn:hover, .ch-icon-btn:focus { opacity: 1 !important; outline: none; }
.ch-remove-btn:hover, .ch-remove-btn:focus { background: #8b3838; color: white; }
.ch-share-btn:hover, .ch-share-btn:focus { color: var(--accent, #3b82f6); }
.ch-user-badge { font-size: 12px; margin-left: 2px; opacity: 0.85; flex-shrink: 0; }
.ch-item-preview { font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ch-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; }
@@ -936,8 +1212,11 @@ button.ch-item:hover .ch-remove-btn { opacity: 0.6; }
.map-controls { width: 180px; font-size: 12px; }
}
/* === Responsive — Tablet Priority+ nav (7681023px) === */
@media (min-width: 768px) and (max-width: 1023px) {
/* === Responsive Tablet/Mid Priority+ nav (7681279px) ===
At <1280px the full nav-links + nav-stats + nav-right buttons can't fit
on one row (they total ~1540px on the home page). Collapse non-priority
links into the "More ▾" menu so .nav-right stays inside the viewport. */
@media (min-width: 768px) and (max-width: 1279px) {
.nav-links { display: flex !important; flex-direction: row; gap: 2px; }
.nav-links a:not([data-priority="high"]) { display: none; }
.nav-more-wrap { display: flex; align-items: center; }
@@ -1182,6 +1461,15 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.ch-add-btn:hover { opacity: 0.85; }
.ch-add-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; line-height: 1.3; }
.ch-add-status { font-size: 12px; margin-top: 4px; padding: 4px 6px; border-radius: 4px; }
.ch-analytics-link {
display: block;
padding: 6px 8px;
font-size: 12px;
text-decoration: none;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.ch-analytics-link:hover { color: var(--accent); }
.ch-add-status--loading { color: var(--text-muted); }
.ch-add-status--success { color: var(--success, #22c55e); }
.ch-add-status--warn { color: var(--warning, #eab308); }
@@ -1251,6 +1539,18 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.analytics-table th.sort-active { color: var(--accent); }
.analytics-table th .sort-arrow { font-size: 10px; margin-left: 4px; opacity: 0.7; }
.analytics-table td { padding: 8px; border-bottom: 1px solid var(--border); }
.analytics-table .ch-section-row { background: var(--card-bg); }
.analytics-table .ch-section-row td.ch-section-header {
font-weight: 600;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--text-muted);
text-transform: uppercase;
padding: 10px 8px 6px;
border-bottom: 1px solid var(--border);
background: var(--card-bg);
}
.analytics-table .ch-section-row:hover { background: var(--card-bg); cursor: default; }
.hash-bars { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.hash-bar-row { display: flex; align-items: center; gap: 12px; }
.hash-bar-label { min-width: 160px; font-size: 13px; }
@@ -2369,3 +2669,148 @@ th.sort-active { color: var(--accent, #60a5fa); }
.tools-card:hover { border-color: var(--primary); }
.tools-card h3 { margin: 0 0 4px 0; font-size: 16px; }
.tools-card p { margin: 0; font-size: 13px; color: var(--text-muted); }
/* ── Map marker clustering (issue #1036) ── */
.mc-cluster-wrap { background: transparent !important; border: 0 !important; }
.mc-cluster {
width: 48px; height: 48px; border-radius: 50%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
font-family: var(--font, system-ui, sans-serif);
color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.5);
border: 2px solid rgba(255,255,255,0.85);
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
cursor: pointer;
transition: transform 120ms ease;
}
.mc-cluster:hover { transform: scale(1.06); }
.mc-cluster.mc-sm { background: var(--info, #2563eb); width: 40px; height: 40px; }
.mc-cluster.mc-md { background: var(--warning, #d97706); width: 48px; height: 48px; }
.mc-cluster.mc-lg { background: var(--accent, #dc2626); width: 56px; height: 56px; }
.mc-cluster .mc-count { font-size: 14px; font-weight: 700; line-height: 1; }
.mc-cluster.mc-lg .mc-count { font-size: 16px; }
.mc-cluster .mc-pills {
display: flex; gap: 2px; margin-top: 3px;
}
.mc-cluster .mc-pill {
display: inline-block; min-width: 12px; padding: 0 3px;
border-radius: 6px; font-size: 9px; font-weight: 600; line-height: 12px;
color: #fff; text-align: center; text-shadow: none;
border: 1px solid rgba(255,255,255,0.4);
}
/* === #1034 PR1: Channel Add modal + sectioned sidebar === */
.ch-add-channel-btn {
background: var(--accent, #2563eb); color: #fff; border: none;
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;
}
.ch-add-channel-btn:hover { background: var(--accent-hover, #1d4ed8); }
.ch-modal-overlay { z-index: 1100; }
.ch-modal-overlay.hidden { display: none; }
.ch-modal { width: 560px; max-width: 92vw; padding: 24px 24px 16px; position: relative; }
.ch-modal h3 { margin: 0 0 16px; font-size: 18px; }
.ch-modal-close {
position: absolute; top: 10px; right: 10px;
background: transparent; border: none; cursor: pointer;
font-size: 18px; color: var(--text-muted); padding: 4px 8px; border-radius: 6px;
}
.ch-modal-close:hover { background: var(--row-hover, rgba(0,0,0,0.05)); color: var(--text); }
.ch-modal-section { padding: 12px 0; border-top: 1px solid var(--border); }
.ch-modal-section:first-of-type { border-top: none; padding-top: 0; }
.ch-modal-section-title { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
.ch-modal-section-hint { margin: 0 0 10px; font-size: 12px; color: var(--text-muted); }
.ch-modal-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.ch-modal-input {
flex: 1; min-width: 0; padding: 7px 10px; font-size: 13px;
border: 1px solid var(--border); border-radius: 6px;
background: var(--input-bg, var(--card-bg)); color: var(--text);
}
.ch-modal-input--mono { font-family: var(--mono, monospace); }
.ch-modal-btn-secondary {
background: var(--card-bg); color: var(--text);
border: 1px solid var(--border); padding: 7px 12px;
border-radius: 6px; cursor: pointer; font-size: 13px;
}
.ch-modal-btn-secondary[disabled] { opacity: .5; cursor: not-allowed; }
.ch-hashtag-row .ch-hashtag-prefix {
font-family: var(--mono, monospace); font-size: 14px; color: var(--text-muted); padding: 0 2px;
}
.ch-modal-warn { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.ch-modal-warn code { background: var(--row-hover, rgba(0,0,0,0.05)); padding: 1px 4px; border-radius: 3px; font-size: 11px; }
.ch-modal-error { color: var(--status-red, #dc2626); font-size: 12px; margin-top: 4px; }
.ch-modal-footer {
margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border);
font-size: 12px; color: var(--text-muted); line-height: 1.4;
}
.ch-modal-callout {
margin: 10px 0 14px; padding: 10px 12px; border-radius: 6px;
background: var(--warn-bg, #fef3c7); color: var(--warn-text, #92400e);
border: 1px solid var(--warn-border, #fcd34d);
font-size: 13px; line-height: 1.4;
}
.ch-section-locality {
font-size: 12px; font-weight: 500; text-transform: none;
letter-spacing: 0; color: var(--text-muted); opacity: 0.85;
margin-left: 4px;
}
.ch-qr-output { font-size: 11px; font-family: var(--mono, monospace); color: var(--text-muted); word-break: break-all; min-height: 14px; padding: 4px 0; }
.ch-section { margin-bottom: 8px; }
.ch-section-header {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px; font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .5px; color: var(--text-muted);
background: transparent; border: none; width: 100%; text-align: left; cursor: default;
}
.ch-section-toggle { cursor: pointer; }
.ch-section-toggle:hover { color: var(--text); }
.ch-section-empty { padding: 8px 12px; font-size: 12px; color: var(--text-muted); font-style: italic; }
.ch-section-caret { display: inline-block; width: 10px; }
/* ── Filter UX (issue #966) ────────────────────────────────────────────── */
.fux-bar { display: flex; gap: 6px; margin-top: 4px; align-items: center; flex-wrap: wrap; position: relative; }
.fux-help-btn,
.fux-saved-trigger { background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 12px; cursor: pointer; }
.fux-help-btn:hover,
.fux-saved-trigger:hover { background: var(--bg-hover, var(--surface)); }
.fux-popover { position: fixed; top: 60px; right: 24px; width: min(720px, 92vw); max-height: 80vh; overflow: auto; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.35); z-index: 10000; padding: 0; }
.fux-popover-header { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); }
.fux-popover-close { background: transparent; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; padding: 0 4px; }
.fux-popover-close:hover { color: var(--text); }
.fux-popover-body { padding: 12px 16px; font-size: 13px; }
.fux-popover-body h3,
.fux-popover-body h4 { margin: 12px 0 6px; }
.fux-table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 4px 0 12px; }
.fux-table th,
.fux-table td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
.fux-table th { color: var(--text-muted); font-weight: 600; }
.fux-mono { font-family: var(--mono); font-size: 12px; }
.fux-examples { margin: 4px 0; padding-left: 20px; }
.fux-examples li { margin: 2px 0; }
.fux-ac-dropdown { position: absolute; left: 0; right: 0; top: 100%; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; max-height: 280px; overflow-y: auto; z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.25); margin-top: 2px; }
.fux-ac-item { padding: 4px 10px; display: flex; justify-content: space-between; gap: 12px; cursor: pointer; font-size: 12px; }
.fux-ac-item:hover,
.fux-ac-item.active { background: var(--bg-hover, rgba(120,160,255,0.12)); }
.fux-ac-val { font-family: var(--mono); color: var(--text); }
.fux-ac-desc { color: var(--text-muted); font-size: 11px; max-width: 60%; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fux-ctx-menu { position: absolute; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 4px 14px rgba(0,0,0,0.35); z-index: 10001; min-width: 200px; padding: 4px 0; }
.fux-ctx-item { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 5px 12px; font-size: 12px; cursor: pointer; font-family: var(--mono); }
.fux-ctx-item:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); }
.fux-saved-menu { position: absolute; top: 100%; left: 0; min-width: 320px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; z-index: 9999; box-shadow: 0 4px 14px rgba(0,0,0,0.3); margin-top: 4px; padding: 4px 0; }
.fux-saved-menu.hidden { display: none; }
.fux-saved-header { padding: 6px 10px; font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--border); }
.fux-saved-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px; cursor: pointer; font-size: 12px; }
.fux-saved-item:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); }
.fux-saved-name { font-weight: 600; min-width: 120px; }
.fux-saved-expr { color: var(--text-muted); font-size: 11px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fux-saved-del { background: transparent; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 0 4px; }
.fux-saved-del:hover { color: var(--status-red, #ef4444); }
.fux-saved-footer { border-top: 1px solid var(--border); padding: 4px 0; }
.fux-saved-save { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 6px 10px; font-size: 12px; cursor: pointer; }
.fux-saved-save:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); }
td[data-filter-field] { cursor: context-menu; }
+123
View File
@@ -0,0 +1,123 @@
/* === CoreScope url-state.js ===
*
* Shared helpers for encoding/decoding view & filter state in the URL hash.
* Pages use these so deep links restore the exact view (issue #749).
*
* Hash format: "#/<route>?key1=val1&key2=val2"
*
* Existing deep links remain intact:
* #/nodes/<pubkey> (path segment after route)
* #/packets/<hash> (path segment after route)
* #/packets?filter=... (query after route)
*
* This module ONLY parses/serializes it never mutates location.
*/
'use strict';
(function (root) {
/**
* Parse a sort token "column[:direction]" into { column, direction }.
* Direction defaults to 'desc'. Anything other than 'asc'/'desc' falls back to 'desc'.
* Empty/null input returns null.
*/
function parseSort(s) {
if (s == null || s === '') return null;
var str = String(s);
var idx = str.indexOf(':');
var column = idx >= 0 ? str.slice(0, idx) : str;
var dir = idx >= 0 ? str.slice(idx + 1) : 'desc';
if (dir !== 'asc' && dir !== 'desc') dir = 'desc';
return { column: column, direction: dir };
}
/**
* Serialize a sort state to a token. 'desc' is the default and omitted.
* Empty/null column returns ''.
*/
function serializeSort(column, direction) {
if (!column) return '';
if (direction === 'asc') return column + ':asc';
return String(column);
}
/**
* Parse a location.hash string into { route, params }.
* - Strips leading '#' and '/'.
* - Splits on first '?'; left = route (may include subpath like 'nodes/abc'),
* right = querystring parsed via URLSearchParams.
*/
function parseHash(hash) {
var h = String(hash || '');
if (h.charAt(0) === '#') h = h.slice(1);
if (h.charAt(0) === '/') h = h.slice(1);
if (h === '') return { route: '', params: {} };
var qi = h.indexOf('?');
var route = qi >= 0 ? h.slice(0, qi) : h;
var qs = qi >= 0 ? h.slice(qi + 1) : '';
var params = {};
if (qs) {
var sp = new URLSearchParams(qs);
sp.forEach(function (v, k) { params[k] = v; });
}
return { route: route, params: params };
}
/**
* Build a hash string '#/<route>?k=v&...'. Skips keys with null/undefined/'' values.
* 'route' may be passed as '#/foo', '/foo' or 'foo'.
*/
function buildHash(route, params) {
var r = String(route || '');
if (r.charAt(0) === '#') r = r.slice(1);
if (r.charAt(0) === '/') r = r.slice(1);
var sp = new URLSearchParams();
if (params && typeof params === 'object') {
for (var k in params) {
if (!Object.prototype.hasOwnProperty.call(params, k)) continue;
var v = params[k];
if (v == null || v === '') continue;
sp.set(k, String(v));
}
}
var qs = sp.toString();
return '#/' + r + (qs ? '?' + qs : '');
}
/**
* Apply a partial-update to the params of an existing hash, preserving the route
* (including any subpath like 'nodes/<pubkey>'). Returns the new hash string
* caller decides whether to history.replaceState() it.
*
* Setting a key to '' / null / undefined removes it.
*/
function updateHashParams(updates, currentHash) {
var src = currentHash != null ? currentHash :
(typeof location !== 'undefined' ? location.hash : '');
var parsed = parseHash(src);
var merged = {};
var k;
for (k in parsed.params) {
if (Object.prototype.hasOwnProperty.call(parsed.params, k)) merged[k] = parsed.params[k];
}
if (updates && typeof updates === 'object') {
for (k in updates) {
if (!Object.prototype.hasOwnProperty.call(updates, k)) continue;
var v = updates[k];
if (v == null || v === '') delete merged[k];
else merged[k] = v;
}
}
return buildHash(parsed.route, merged);
}
var api = {
parseSort: parseSort,
serializeSort: serializeSort,
parseHash: parseHash,
buildHash: buildHash,
updateHashParams: updateHashParams,
};
if (typeof module !== 'undefined' && module.exports) module.exports = api;
root.URLState = api;
})(typeof window !== 'undefined' ? window : globalThis);
+60
View File
@@ -0,0 +1,60 @@
.marker-cluster-small {
background-color: rgba(181, 226, 140, 0.6);
}
.marker-cluster-small div {
background-color: rgba(110, 204, 57, 0.6);
}
.marker-cluster-medium {
background-color: rgba(241, 211, 87, 0.6);
}
.marker-cluster-medium div {
background-color: rgba(240, 194, 12, 0.6);
}
.marker-cluster-large {
background-color: rgba(253, 156, 115, 0.6);
}
.marker-cluster-large div {
background-color: rgba(241, 128, 23, 0.6);
}
/* IE 6-8 fallback colors */
.leaflet-oldie .marker-cluster-small {
background-color: rgb(181, 226, 140);
}
.leaflet-oldie .marker-cluster-small div {
background-color: rgb(110, 204, 57);
}
.leaflet-oldie .marker-cluster-medium {
background-color: rgb(241, 211, 87);
}
.leaflet-oldie .marker-cluster-medium div {
background-color: rgb(240, 194, 12);
}
.leaflet-oldie .marker-cluster-large {
background-color: rgb(253, 156, 115);
}
.leaflet-oldie .marker-cluster-large div {
background-color: rgb(241, 128, 23);
}
.marker-cluster {
background-clip: padding-box;
border-radius: 20px;
}
.marker-cluster div {
width: 30px;
height: 30px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
border-radius: 15px;
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
}
.marker-cluster span {
line-height: 30px;
}
+14
View File
@@ -0,0 +1,14 @@
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
}
.leaflet-cluster-spider-leg {
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
}
+10108
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+8
View File
@@ -10,11 +10,19 @@ echo ""
# Unit tests (deterministic, fast)
echo "── Unit Tests ──"
node test-packet-filter.js
node test-packet-filter-ux.js
node test-aging.js
node test-frontend-helpers.js
node test-url-state.js
node test-perf-go-runtime.js
node test-channel-psk-ux.js
node test-channel-sidebar-layout.js
node test-channel-modal-ux.js
node test-channel-decrypt-insecure-context.js
node test-channel-qr.js
node test-channel-qr-wiring.js
node test-analytics-channels-integration.js
node test-observers-headings.js
echo ""
echo "═══════════════════════════════════════"
+188
View File
@@ -0,0 +1,188 @@
/**
* Analytics Channels section integration with PSK decrypt UX.
*
* Bug: the analytics channels list shows nonsense names like "ch185" for
* every encrypted channel and ignores the user's locally-decrypted PSK
* channels (from ChannelDecrypt.getStoredKeys() + label store).
*
* Fix:
* 1. Replace "chNNN" raw names with "🔒 Encrypted (0xNN)" when the channel
* is encrypted and the server only knows its hash byte.
* 2. For channels matching a locally-stored PSK key, show the user's
* label / key-name instead of the hash-byte placeholder.
* 3. Group rendering: My Channels Network Encrypted, each sorted by
* message count descending.
* 4. Add a link from the Channels page to the Analytics page so users can
* jump to channel activity stats.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
// ── Set up a tiny browser-ish global so analytics.js loads cleanly ──────────
global.window = global;
global.document = {
documentElement: {},
createElement: () => ({ style: {}, addEventListener() {} }),
addEventListener() {},
removeEventListener() {},
querySelector: () => null,
querySelectorAll: () => [],
getElementById: () => null,
};
global.localStorage = {
_s: {},
getItem(k) { return this._s[k] || null; },
setItem(k, v) { this._s[k] = String(v); },
removeItem(k) { delete this._s[k]; },
};
global.getComputedStyle = () => ({ getPropertyValue: () => '' });
global.registerPage = () => {};
global.api = async () => ({});
global.fetch = async () => ({ ok: true, json: async () => ({}) });
global.CLIENT_TTL = {};
global.RegionFilter = { getRegionParam: () => '' };
global.Storage = function () {};
global.timeAgo = () => '';
global.histogram = () => ({ svg: '' });
// Load analytics.js — it self-registers global helpers we test.
const analyticsSrc = fs.readFileSync(
path.join(__dirname, 'public/analytics.js'),
'utf8'
);
// Strip top-level `await` / module syntax — analytics.js is plain IIFE so it's
// fine to eval as-is.
// eslint-disable-next-line no-eval
eval(analyticsSrc); // sets window._analyticsDecorateChannels etc.
console.log('\n=== Analytics channels: decorate with PSK keys ===');
const decorate = global._analyticsDecorateChannels;
assert(typeof decorate === 'function',
'_analyticsDecorateChannels exposed for testing');
// Server response sample — mix of cleartext, rainbow-known encrypted, raw "chNNN".
const sampleChannels = [
{ hash: 17, name: 'public', messages: 100, senders: 5, encrypted: false },
{ hash: 217, name: '#test', messages: 200, senders: 8, encrypted: false },
{ hash: 185, name: 'ch185', messages: 50, senders: 0, encrypted: true },
{ hash: 64, name: 'ch64', messages: 300, senders: 0, encrypted: true },
{ hash: 30, name: 'ch30', messages: 75, senders: 0, encrypted: true },
{ hash: 99, name: '#earthquake', messages: 10, senders: 1, encrypted: false },
// Rainbow-table hit on an ENCRYPTED channel: server resolved a real name.
{ hash: 12, name: 'public-meshcore', messages: 40, senders: 2, encrypted: true },
// Encrypted channel with empty name — must not render an empty <strong>.
{ hash: 200, name: '', messages: 5, senders: 0, encrypted: true },
];
// User has two PSK keys locally: one matches hash=185 (named "Levski"),
// one matches hash=30 (named "secret-room", with label "Garage").
const myKeyHashToName = { 185: 'Levski', 30: 'secret-room' };
const labels = { 'secret-room': 'Garage' };
const out = decorate(sampleChannels, myKeyHashToName, labels);
assert(Array.isArray(out), 'decorate returns an array');
assert(out.length === sampleChannels.length, 'decorate keeps every channel');
// Find by original hash (and optionally original name) for assertions.
// Decoration preserves c.name as-is and writes the user-facing string to
// c.displayName, so matching on c.name is unambiguous.
function find(hash, name) {
return out.find(c => c.hash === hash && (name == null || c.name === name));
}
const mine185 = find(185, 'ch185');
assert(mine185 && mine185.displayName === 'Levski',
'hash 185 + stored key → displayName = "Levski" (not "ch185")');
assert(mine185 && mine185.group === 'mine',
'hash 185 grouped as "mine"');
const mine30 = find(30, 'ch30');
assert(mine30 && mine30.displayName === 'Garage',
'hash 30 with stored key + label → displayName = "Garage" (label wins)');
assert(mine30 && mine30.group === 'mine', 'hash 30 grouped as "mine"');
const ch64 = find(64, 'ch64');
assert(ch64 && ch64.displayName === '🔒 Encrypted (0x40)',
'unknown encrypted ch64 → "🔒 Encrypted (0x40)" (no nonsense "ch64")');
assert(ch64 && ch64.group === 'encrypted', 'unknown encrypted grouped as "encrypted"');
const pub = find(17, 'public');
assert(pub && pub.displayName === 'public', 'cleartext public name preserved');
assert(pub && pub.group === 'network', 'cleartext public grouped as "network"');
const test = find(217, '#test');
assert(test && test.group === 'network', 'rainbow-known #test grouped as "network"');
// Rainbow-table hit on an ENCRYPTED channel — actually exercises the
// "encrypted but server has the real name" branch (was previously dead-untested).
const rainbow = find(12, 'public-meshcore');
assert(rainbow && rainbow.encrypted === true,
'rainbow row preserves encrypted=true');
assert(rainbow && rainbow.displayName === 'public-meshcore',
'rainbow-decoded encrypted row → displayName = real name');
assert(rainbow && rainbow.group === 'network',
'rainbow-decoded encrypted row → group = "network"');
// Empty-name encrypted: must NOT leak through with displayName = ''.
const empty = find(200, '');
assert(empty && empty.displayName === '🔒 Encrypted (0xC8)',
'encrypted with empty name → render as opaque encrypted placeholder');
assert(empty && empty.group === 'encrypted',
'encrypted with empty name → group = "encrypted"');
// No "chNNN" leaks into displayName for any row.
const leak = out.find(c => /^ch(\d+|\?)$/.test(c.displayName));
assert(!leak, 'no displayName matches the raw chNNN placeholder');
console.log('\n=== Grouped table render: order + sort ===');
const tbody = global._analyticsChannelTbodyHtml(out, 'messages', 'desc', {
grouped: true,
});
assert(typeof tbody === 'string' && tbody.length > 0,
'channelTbodyHtml accepts grouped option and returns html');
// Group headers must appear in order: My Channels, Network, Encrypted.
const iMine = tbody.indexOf('My Channels');
const iNet = tbody.indexOf('Network');
const iEnc = tbody.indexOf('Encrypted');
assert(iMine >= 0 && iNet > iMine && iEnc > iNet,
'group headers render in order: My Channels → Network → Encrypted');
// Within "mine" section, hash=30 (75 msgs) > hash=185 (50 msgs).
const i30 = tbody.indexOf('Garage');
const i185 = tbody.indexOf('Levski');
assert(i30 > 0 && i185 > i30,
'within "My Channels" sort by messages desc (Garage 75 before Levski 50)');
// Within "network" section, #test (200) > public (100) > #earthquake (10).
const iT = tbody.indexOf('#test');
const iP = tbody.indexOf('public');
const iE = tbody.indexOf('#earthquake');
assert(iT > 0 && iP > iT && iE > iP,
'within "Network" sort by messages desc (#test → public → #earthquake)');
// Within "encrypted" section, ch64 (300 msgs) appears (only one entry).
assert(tbody.indexOf('0x40') > iEnc, 'encrypted section contains 0x40');
console.log('\n=== Channels page links to Analytics ===');
const channelsSrc = fs.readFileSync(
path.join(__dirname, 'public/channels.js'),
'utf8'
);
assert(/#\/analytics/.test(channelsSrc) &&
/Channel Analytics|channel analytics/i.test(channelsSrc),
'channels.js sidebar links to #/analytics with "Channel Analytics" text');
console.log('\n' + (failed ? '✗ ' + failed + ' failed, ' : '') + passed + ' passed');
process.exit(failed ? 1 : 0);
+286
View File
@@ -0,0 +1,286 @@
/**
* Regression test: live PSK decrypt for user-added channels (#1029 follow-up).
*
* PR #1030 added decryptLivePSKBatch() which rewrites encrypted GRP_TXT
* WS packets in place when a stored PSK key matches. It sets
* payload.channel = dec.channelName (e.g. "medusa")
* but user-added channels are stored in channels[] with hash:
* "user:medusa"
* (and selectedHash is also "user:medusa" when viewing).
*
* Symptoms in production:
* - selectedHash === "user:medusa" but processWSBatch compares
* `channelName === selectedHash` ("medusa" !== "user:medusa") so a live
* packet for the open channel is NEVER appended to the message list.
* - channels.find(c => c.hash === channelName) misses the user channel and
* a duplicate plain entry "medusa" is pushed into the sidebar; the real
* user-added channel's lastMessage / messageCount / lastActivityMs never
* update.
* - The unread bumper guards with `chName === prior` (raw name vs prefixed
* selectedHash), so an unread badge is added even when the user IS
* actively viewing that channel.
*
* Fix: have the live decrypt rewrite annotate the payload with the
* canonical channel hash that channels[] / selectedHash use. A simple,
* non-breaking shape: keep payload.channel = name (so the rest of
* processWSBatch keeps working for non-user channels), AND also set
* payload.channelKey = "user:" + name when a user-added channel exists for
* that name. processWSBatch then uses channelKey when present for the
* lookup + selectedHash comparison.
*
* This test loads the real channels.js in a vm sandbox, primes a
* user-added channel, drives an encrypted GRP_TXT through the WS handler
* and asserts:
* 1. the open channel's message list grows by 1 (text is decrypted-locally
* and visible in the messages array)
* 2. the user-added channel's messageCount / lastMessage update
* 3. NO duplicate plain "medusa" entry is added to channels[]
* 4. unread is NOT bumped on the channel currently being viewed
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const { createCipheriv, createHmac, createHash, webcrypto } = require('crypto');
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
function buildEncryptedGrpTxt(channelName, sender, message) {
const key = createHash('sha256').update(channelName).digest().slice(0, 16);
const channelHash = createHash('sha256').update(key).digest()[0];
const text = `${sender}: ${message}`;
const inner = 5 + Buffer.byteLength(text, 'utf8') + 1;
const padded = Math.ceil(inner / 16) * 16;
const pt = Buffer.alloc(padded);
pt.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
pt[4] = 0;
pt.write(text, 5, 'utf8');
const cipher = createCipheriv('aes-128-ecb', key, null);
cipher.setAutoPadding(false);
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
const secret = Buffer.concat([key, Buffer.alloc(16)]);
const mac = createHmac('sha256', secret).update(ct).digest().slice(0, 2);
return {
payload: {
type: 'GRP_TXT',
channelHash,
channelHashHex: channelHash.toString(16).padStart(2, '0'),
mac: mac.toString('hex'),
encryptedData: ct.toString('hex'),
decryptionStatus: 'no_key',
},
keyHex: key.toString('hex'),
};
}
function makeBrowserLikeSandbox() {
const storage = {};
const elements = {};
function makeFakeEl(id) {
return {
id: id || '', innerHTML: '', textContent: '', value: '', scrollTop: 0,
scrollHeight: 0,
style: {}, dataset: {},
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
addEventListener() {}, removeEventListener() {},
querySelector() { return makeFakeEl(); },
querySelectorAll() { return []; },
getAttribute() { return null; }, setAttribute() {},
getBoundingClientRect() { return { width: 240, height: 0, top: 0, left: 0, right: 0, bottom: 0 }; },
appendChild() {}, removeChild() {},
focus() {}, blur() {},
checked: false,
};
}
function el(id) {
if (!elements[id]) elements[id] = makeFakeEl(id);
return elements[id];
}
const ctx = {
window: {},
document: {
readyState: 'complete',
documentElement: { getAttribute: () => null, setAttribute() {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } } },
createElement: () => ({ id: '', textContent: '', innerHTML: '', style: {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, addEventListener() {}, appendChild() {}, querySelector() { return null; }, querySelectorAll() { return []; } }),
head: { appendChild() {} },
body: { appendChild() {} },
getElementById: el,
addEventListener() {}, removeEventListener() {},
querySelector: () => null,
querySelectorAll: () => [],
},
console,
Date, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError, Set, Map, Promise,
parseInt, parseFloat, isNaN, isFinite,
encodeURIComponent, decodeURIComponent,
setTimeout: (fn) => { Promise.resolve().then(fn); return 0; },
clearTimeout: () => {},
setInterval: () => 0,
clearInterval: () => {},
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
localStorage: {
getItem: (k) => Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; },
},
location: { hash: '' },
history: { replaceState() {}, pushState() {} },
crypto: webcrypto,
TextEncoder, TextDecoder,
Uint8Array, Uint16Array, Uint32Array, Int8Array, Int16Array, Int32Array, ArrayBuffer,
URLSearchParams,
CustomEvent: class CustomEvent {},
MutationObserver: class MutationObserver { observe() {} disconnect() {} },
requestAnimationFrame: (cb) => setTimeout(cb, 0),
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
addEventListener() {}, dispatchEvent() {},
getHashParams: () => new URLSearchParams(),
};
ctx.self = ctx;
ctx.globalThis = ctx;
vm.createContext(ctx);
return ctx;
}
function loadInCtx(ctx, file) {
const src = fs.readFileSync(path.join(__dirname, file), 'utf8');
vm.runInContext(src, ctx, { filename: file });
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
async function run() {
console.log('\n=== Live PSK decrypt: user-added channel (user:* prefix) routing ===');
const ctx = makeBrowserLikeSandbox();
ctx.window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} });
ctx.window.addEventListener = () => {};
ctx.btoa = (s) => Buffer.from(String(s), 'binary').toString('base64');
ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('binary');
// App.js stubs: provide debouncedOnWS / onWS / offWS / api / debounce /
// invalidateApiCache / registerPage so channels.js loads cleanly.
let wsListeners = [];
ctx.onWS = (fn) => { wsListeners.push(fn); };
ctx.offWS = (fn) => { wsListeners = wsListeners.filter(f => f !== fn); };
ctx.debouncedOnWS = function (fn) {
function handler(msg) { fn([msg]); }
wsListeners.push(handler);
return handler;
};
ctx.debounce = (fn) => fn;
ctx.api = () => Promise.resolve({ channels: [], observers: [] });
ctx.invalidateApiCache = () => {};
ctx.CLIENT_TTL = { channels: 60000, observers: 600000 };
ctx.escapeHtml = (s) => String(s == null ? '' : s);
ctx.truncate = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n) : s; };
ctx.formatHashHex = (h) => String(h);
ctx.formatSecondsAgo = () => '';
ctx.payloadTypeName = () => 'GRP_TXT';
ctx.RegionFilter = {
init() {},
onChange(fn) { return () => {}; },
offChange() {},
getRegionParam() { return ''; },
getSelected() { return null; },
};
ctx.ChannelColors = { get() { return null; }, remove() {} };
ctx.ChannelColorPicker = { open() {} };
ctx.normalizeObserverNameKey = (s) => String(s || '').toLowerCase();
let pageMod = null;
ctx.registerPage = (name, mod) => { if (name === 'channels') pageMod = mod; };
// Load AES + ChannelDecrypt + channels.js
loadInCtx(ctx, 'public/vendor/aes-ecb.js');
loadInCtx(ctx, 'public/channel-decrypt.js');
loadInCtx(ctx, 'public/channels.js');
const CD = ctx.window.ChannelDecrypt;
assert(typeof CD.tryDecryptLive === 'function', 'ChannelDecrypt.tryDecryptLive available');
const channelName = 'medusa';
const fixture = buildEncryptedGrpTxt(channelName, 'Alice', 'hello darkness');
CD.storeKey(channelName, fixture.keyHex);
// Initialize the channels page so wsHandler is wired up
const appEl = ctx.document.getElementById('page');
appEl.innerHTML = '';
await pageMod.init(appEl, null);
// pump microtasks
await new Promise((r) => setTimeout(r, 0));
ctx.window._channelsSetStateForTest({
channels: [{
hash: 'user:' + channelName,
name: channelName,
messageCount: 0,
lastActivityMs: 0,
lastSender: '',
lastMessage: 'Encrypted — click to decrypt',
encrypted: true,
userAdded: true,
}],
messages: [],
selectedHash: 'user:' + channelName,
});
// Drive the WS path — same shape the Go server broadcasts
const wsMsg = {
type: 'packet',
data: {
id: 12345,
hash: 'deadbeef',
observer_name: 'TestObserver',
packet: { observer_name: 'TestObserver' },
decoded: {
header: { payloadTypeName: 'GRP_TXT' },
payload: fixture.payload,
},
},
};
for (const fn of wsListeners) fn(wsMsg);
// Allow async decryptLivePSKBatch + setTimeout chain to settle
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
const state = ctx.window._channelsGetStateForTest();
// (1) Message list for the open channel grew
assert(state.messages.length === 1,
'open user-added channel receives the live-decrypted message (got ' + state.messages.length + ')');
if (state.messages[0]) {
assert(state.messages[0].text === 'hello darkness',
'decrypted text is rendered (got ' + JSON.stringify(state.messages[0].text) + ')');
assert(state.messages[0].sender === 'Alice',
'decrypted sender is rendered (got ' + JSON.stringify(state.messages[0].sender) + ')');
}
// (2) The user-added channel's metadata updated
const userCh = state.channels.find((c) => c.hash === 'user:' + channelName);
assert(userCh && userCh.messageCount === 1,
'user-added channel messageCount incremented (got ' + (userCh && userCh.messageCount) + ')');
assert(userCh && userCh.lastMessage && userCh.lastMessage.indexOf('hello') !== -1,
'user-added channel lastMessage updated (got ' + (userCh && userCh.lastMessage) + ')');
// (3) No duplicate plain "medusa" entry was created in the sidebar
const dupes = state.channels.filter((c) => c.hash === channelName);
assert(dupes.length === 0,
'no duplicate non-prefixed channel entry created (got ' + dupes.length + ')');
assert(state.channels.length === 1,
'sidebar still has exactly the one user-added channel (got ' + state.channels.length + ')');
// (4) Unread NOT bumped on the channel actively being viewed
assert(!userCh || !userCh.unread,
'unread NOT bumped on the actively-viewed channel (got ' + (userCh && userCh.unread) + ')');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
}
run().catch((e) => { console.error(e); process.exit(1); });
+161
View File
@@ -0,0 +1,161 @@
/**
* E2E (#1034 PR1): Channel Add modal + sectioned sidebar.
*
* Boots a headless Chromium against a locally running corescope-server and
* exercises:
* - sidebar [+ Add Channel] opens modal
* - modal renders three labeled sections + privacy footer + QR placeholders
* - close () hides modal
* - sectioned sidebar renders My Channels / Network / Encrypted sections
* - PSK add flow: invalid hex error; valid hex modal closes
*
* Usage: BASE_URL=http://localhost:38201 node test-channel-modal-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:38201';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1034 PR1 E2E against ${BASE} ===`);
await step('navigate to /channels', async () => {
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#chAddChannelBtn', { timeout: 8000 });
});
await step('Add Channel button is visible', async () => {
const text = await page.textContent('#chAddChannelBtn');
assert(/Add Channel/.test(text), 'button text: ' + text);
});
await step('modal hidden on load', async () => {
const isHidden = await page.evaluate(() => {
const m = document.getElementById('chAddChannelModal');
return !!m && (m.classList.contains('hidden') || m.hasAttribute('hidden'));
});
assert(isHidden, 'modal should start hidden');
});
await step('clicking [+ Add Channel] opens modal', async () => {
await page.click('#chAddChannelBtn');
await page.waitForSelector('#chAddChannelModal:not(.hidden)', { timeout: 3000 });
const visible = await page.isVisible('#chAddChannelModal');
assert(visible, 'modal should be visible after click');
});
await step('modal renders all three section titles', async () => {
const html = await page.innerHTML('#chAddChannelModal');
assert(html.includes('Generate PSK Channel'), 'section 1 missing');
assert(html.includes('Add Private Channel (PSK)'), 'section 2 missing');
assert(html.includes('Monitor Hashtag Channel'), 'section 3 missing');
});
await step('modal renders QR placeholders', async () => {
assert(await page.isVisible('#qr-output'), '#qr-output missing');
const scanBtn = await page.$('#scan-qr-btn');
assert(scanBtn, '#scan-qr-btn missing');
const disabled = await scanBtn.getAttribute('disabled');
assert(disabled === null, '#scan-qr-btn must be enabled (wired in #1034 PR3)');
});
await step('modal renders privacy footer', async () => {
const footer = await page.textContent('#chAddChannelModal .ch-modal-footer');
assert(/Keys stay in your browser/.test(footer), 'footer text missing: ' + footer);
assert(/passive observer/.test(footer), 'passive observer text missing');
});
await step('modal renders case-sensitivity warning', async () => {
const warn = await page.textContent('#chAddChannelModal .ch-modal-warn');
assert(/[Cc]ase-sensitive/.test(warn), 'warning missing: ' + warn);
});
await step('PSK add: invalid hex shows inline error', async () => {
await page.fill('#chPskKey', 'not-hex');
await page.click('#chPskAddBtn');
await page.waitForFunction(() => {
const e = document.getElementById('chPskError');
return e && e.style.display !== 'none' && /hex/i.test(e.textContent);
}, { timeout: 3000 });
});
await step('close button (✕) hides modal', async () => {
await page.click('#chModalClose');
await page.waitForFunction(() => {
const m = document.getElementById('chAddChannelModal');
return m && m.classList.contains('hidden');
}, { timeout: 3000 });
});
await step('sidebar renders three sections (My Channels / Network / Encrypted)', async () => {
// Wait for channel list to populate from API (or render empty-state).
await page.waitForFunction(() => {
const el = document.getElementById('chList');
if (!el) return false;
return el.querySelector('.ch-section-mychannels') &&
el.querySelector('.ch-section-network') &&
el.querySelector('.ch-section-encrypted');
}, { timeout: 8000 });
const headers = await page.$$eval('.ch-section-header', els => els.map(e => e.textContent.trim()));
const joined = headers.join(' | ');
assert(/My Channels/.test(joined), 'My Channels header missing: ' + joined);
assert(/Network/.test(joined), 'Network header missing');
assert(/Encrypted/.test(joined), 'Encrypted header missing');
});
await step('Encrypted section is collapsed by default', async () => {
const collapsed = await page.getAttribute('.ch-section-encrypted', 'data-encrypted-collapsed');
assert(collapsed === 'true', 'expected data-encrypted-collapsed=true, got ' + collapsed);
const bodyHidden = await page.evaluate(() => {
const b = document.getElementById('chEncryptedBody');
return b ? b.hasAttribute('hidden') : null;
});
assert(bodyHidden === true, 'encrypted body should be hidden initially');
});
await step('clicking Encrypted toggle expands it', async () => {
await page.click('#chEncryptedToggle');
const bodyHidden = await page.evaluate(() => {
const b = document.getElementById('chEncryptedBody');
return b ? b.hasAttribute('hidden') : null;
});
assert(bodyHidden === false, 'encrypted body should be visible after toggle');
});
await step('PSK add: valid hex closes modal and persists key', async () => {
await page.click('#chAddChannelBtn');
await page.waitForSelector('#chAddChannelModal:not(.hidden)');
const validHex = 'cafebabe' + '00112233' + '44556677' + '8899aabb';
await page.fill('#chPskKey', validHex);
await page.fill('#chPskName', 'E2E Test Channel');
await page.click('#chPskAddBtn');
await page.waitForFunction(() => {
const m = document.getElementById('chAddChannelModal');
return m && m.classList.contains('hidden');
}, { timeout: 5000 });
const stored = await page.evaluate(() => localStorage.getItem('corescope_channel_keys') || '');
assert(/cafebabe/i.test(stored), 'expected stored key in localStorage corescope_channel_keys, got: ' + stored);
});
await browser.close();
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
process.exit(failed > 0 ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+123
View File
@@ -0,0 +1,123 @@
/**
* Tests for #1034 Channel UX redesign PR1: Modal + sectioned sidebar.
*
* Pattern follows test-channel-psk-ux.js: string-contract assertions over
* public/channels.js + DOM render harness via vm sandbox.
*
* - [+ Add Channel] button in sidebar (replaces inline form)
* - Modal overlay with three labeled sections:
* Generate PSK Channel | Add Private Channel (PSK) | Monitor Hashtag Channel
* - QR placeholders (#qr-output, #scan-qr-btn[disabled])
* - Privacy footer text
* - Sectioned sidebar render: My Channels / Network / Encrypted (N)
* - "No key" checkbox is gone
* - Three modal action handlers wired
*
* Runs in Node.js no browser.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
const cssSrc = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
console.log('\n=== #1034 PR1: [+ Add Channel] sidebar button ===');
assert(/id="chAddChannelBtn"/.test(chSrc),
'sidebar exposes #chAddChannelBtn (replaces inline form)');
assert(/\+ Add Channel/.test(chSrc) || /Add Channel/.test(chSrc),
'[+ Add Channel] button label present');
// Old "No key" toggle must be GONE.
assert(!/No key/.test(chSrc),
'old "No key" checkbox removed from sidebar');
assert(!/id="chShowEncrypted"/.test(chSrc),
'old #chShowEncrypted toggle removed');
console.log('\n=== #1034 PR1: Modal markup ===');
assert(/id="chAddChannelModal"/.test(chSrc),
'modal element #chAddChannelModal exists');
assert(/modal-overlay|ch-modal-overlay/.test(chSrc),
'modal uses overlay pattern (matches existing modal-overlay class)');
assert(/data-action="ch-modal-close"/.test(chSrc) || /id="chModalClose"/.test(chSrc),
'modal has close affordance (data-action ch-modal-close or #chModalClose)');
console.log('\n=== #1034 PR1: Three sections by label ===');
assert(/Generate PSK Channel/.test(chSrc),
'section 1 label: "Generate PSK Channel"');
assert(/Add Private Channel \(PSK\)/.test(chSrc),
'section 2 label: "Add Private Channel (PSK)"');
assert(/Monitor Hashtag Channel/.test(chSrc),
'section 3 label: "Monitor Hashtag Channel"');
console.log('\n=== #1034 PR1: Section 1 — Generate PSK ===');
assert(/id="chGenerateName"/.test(chSrc),
'generate section has #chGenerateName input');
assert(/id="chGenerateBtn"/.test(chSrc),
'generate section has #chGenerateBtn');
assert(/Generate &amp; Show QR|Generate & Show QR/.test(chSrc),
'[Generate & Show QR] button label present');
assert(/id="qr-output"/.test(chSrc),
'#qr-output placeholder div present (QR code render is PR #2)');
console.log('\n=== #1034 PR1: Section 2 — Add PSK ===');
assert(/id="chPskKey"/.test(chSrc),
'PSK section has #chPskKey input (32-hex)');
assert(/id="chPskName"/.test(chSrc),
'PSK section has optional #chPskName input');
assert(/id="chPskAddBtn"/.test(chSrc),
'PSK section has #chPskAddBtn');
assert(/id="scan-qr-btn"/.test(chSrc),
'#scan-qr-btn present (wired in PR3 — see test-channel-qr-wiring.js)');
assert(/\[0-9a-fA-F\]\{32\}|isHexKey/.test(chSrc),
'PSK section validates 32-hex format');
console.log('\n=== #1034 PR1: Section 3 — Monitor Hashtag ===');
assert(/id="chHashtagName"/.test(chSrc),
'hashtag section has #chHashtagName input');
assert(/id="chHashtagBtn"/.test(chSrc),
'hashtag section has #chHashtagBtn');
assert(/Case-sensitive|case-sensitive/.test(chSrc),
'hashtag section shows case-sensitivity warning');
console.log('\n=== #1034 PR1: Privacy footer ===');
assert(/Keys stay in your browser/.test(chSrc),
'privacy footer "Keys stay in your browser" present');
assert(/passive observer/.test(chSrc),
'privacy footer mentions "passive observer"');
console.log('\n=== #1034 PR1: Sectioned sidebar ===');
assert(/ch-section-mychannels|My Channels/.test(chSrc),
'sidebar renders "My Channels" section');
assert(/ch-section-network|>Network</.test(chSrc),
'sidebar renders "Network" section');
assert(/ch-section-encrypted|Encrypted \(/.test(chSrc),
'sidebar renders "Encrypted (N)" section');
assert(/data-encrypted-collapsed|chEncryptedCollapsed|encrypted-collapsed/.test(chSrc),
'Encrypted section is collapsible (collapsed by default)');
console.log('\n=== #1034 PR1: Modal action wiring ===');
assert(/chGenerateBtn[\s\S]{0,400}addEventListener|onGenerate|generatePsk/.test(chSrc),
'#chGenerateBtn has a click handler wired');
assert(/chPskAddBtn[\s\S]{0,400}addEventListener|onPskAdd/.test(chSrc),
'#chPskAddBtn has a click handler wired');
assert(/chHashtagBtn[\s\S]{0,400}addEventListener|onHashtag/.test(chSrc),
'#chHashtagBtn has a click handler wired');
// Generate uses crypto.getRandomValues(16)
assert(/getRandomValues\(\s*new Uint8Array\(\s*16\s*\)|getRandomValues\([^)]*16/.test(chSrc),
'generate handler uses crypto.getRandomValues(16) for the key');
console.log('\n=== #1034 PR1: CSS for modal ===');
assert(/ch-modal|ch-add-modal|chAddChannelModal/.test(cssSrc) || /\.modal-overlay/.test(cssSrc),
'modal CSS present (ch-modal-* or reuses .modal-overlay)');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
+4 -4
View File
@@ -80,10 +80,10 @@ async function run() {
console.log('\n=== #1020 PSK UX: channels.js DOM/contract ===');
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
// E2E DOM: optional label input in add form
assert(chSrc.includes('id="chKeyLabelInput"'),
'add form contains chKeyLabelInput element');
assert(/placeholder="[^"]*name[^"]*"/i.test(chSrc) || chSrc.includes('chKeyLabelInput'),
// E2E DOM: optional label input in add form (now in #1034 modal as #chPskName)
assert(chSrc.includes('id="chPskName"') || chSrc.includes('id="chKeyLabelInput"'),
'add form contains optional label input (#chPskName in modal, was #chKeyLabelInput)');
assert(/placeholder="[^"]*name[^"]*"/i.test(chSrc) || chSrc.includes('chPskName') || chSrc.includes('chKeyLabelInput'),
'label input has a name-related placeholder');
// E2E DOM: distinct badge class/marker for user-added channels
+84
View File
@@ -0,0 +1,84 @@
/**
* #1034 PR3: Wiring tests verify public/channels.js calls into
* window.ChannelQR.generate() from the Generate handler, and that the
* Scan button is enabled + wired to ChannelQR.scan() that populates
* the PSK fields.
*
* Pure source-string + targeted-snippet assertions (no browser).
* E2E behavior is covered by test-channel-modal-e2e.js extensions.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const src = fs.readFileSync(
path.join(__dirname, 'public/channels.js'),
'utf8'
);
console.log('\n=== #1034 PR3: Generate handler renders QR via ChannelQR.generate ===');
// Locate the chGenerateBtn handler block.
var genIdx = src.indexOf("var genBtn = document.getElementById('chGenerateBtn')");
assert(genIdx > 0, 'found chGenerateBtn handler block');
var genBlock = src.substring(genIdx, genIdx + 1200);
assert(/ChannelQR\s*\.\s*generate\s*\(/.test(genBlock) ||
/window\.ChannelQR\.generate\s*\(/.test(genBlock),
'Generate handler calls ChannelQR.generate(...)');
// Old placeholder text must be gone (it forced "QR coming in next update").
assert(!/QR code coming in next update/.test(genBlock),
'Generate handler no longer prints "QR coming in next update" placeholder');
// The generate call should pass the qr-output element as the render target.
assert(/ChannelQR\.generate\([^)]*qrOut|generate\([^)]*qr-output/.test(genBlock),
'Generate handler passes #qr-output as the QR render target');
console.log('\n=== #1034 PR3: Scan button enabled + wired ===');
// Scan button must be enabled (no `disabled` attribute) — or the wiring
// must remove it at init.
var scanBtnRender = src.match(/id="scan-qr-btn"[^>]*>/);
assert(scanBtnRender, '#scan-qr-btn render present');
var hasDisabledAttr = scanBtnRender && /\bdisabled\b/.test(scanBtnRender[0]);
var removesDisabled = /scan-qr-btn[\s\S]{0,400}\.removeAttribute\(\s*['"]disabled/.test(src) ||
/scanBtn[\s\S]{0,200}\.disabled\s*=\s*false/.test(src);
assert(!hasDisabledAttr || removesDisabled,
'scan button is enabled (no disabled attr OR runtime removes it)');
// Click handler wired to ChannelQR.scan
assert(/scan-qr-btn[\s\S]{0,800}addEventListener\(\s*['"]click/.test(src) ||
/scanBtn[\s\S]{0,400}addEventListener\(\s*['"]click/.test(src),
'scan-qr-btn has a click handler attached');
assert(/ChannelQR\s*\.\s*scan\s*\(/.test(src),
'click handler calls ChannelQR.scan()');
console.log('\n=== #1034 PR3: Scan result populates PSK fields ===');
// The scan result is {name, secret}. Wiring must populate #chPskKey
// and #chPskName from the parsed result.
var scanWiring = src.match(/ChannelQR\.scan\([\s\S]{0,1500}/);
assert(scanWiring, 'found ChannelQR.scan(...) call site');
if (scanWiring) {
var sw = scanWiring[0];
assert(/chPskKey/.test(sw),
'scan success path writes to #chPskKey');
assert(/chPskName/.test(sw),
'scan success path writes to #chPskName');
assert(/\.secret\b|result\.secret|\.value\s*=\s*[^;]*secret/.test(sw),
'scan result.secret populates the key field');
}
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
+86
View File
@@ -0,0 +1,86 @@
/**
* Tests for public/channel-qr.js the QR generation/scanning module
* for the channel UX redesign (#1034, PR #2 of 3).
*
* Pure-JS assertions only: covers buildUrl, parseChannelUrl. The DOM
* (generate) and camera (scan) paths are exercised by Playwright E2E
* elsewhere in the redesign series.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
function loadChannelQR() {
const sandbox = {
window: {}, console, Date, JSON, parseInt, Math, String, Number,
Object, Array, RegExp, Error, Promise, setTimeout, encodeURIComponent,
decodeURIComponent, URL, URLSearchParams,
};
sandbox.window = sandbox;
sandbox.self = sandbox;
vm.createContext(sandbox);
const src = fs.readFileSync(path.join(__dirname, 'public/channel-qr.js'), 'utf8');
vm.runInContext(src, sandbox);
return sandbox.window.ChannelQR;
}
console.log('── ChannelQR — URL helpers ──');
const ChannelQR = loadChannelQR();
assert(ChannelQR && typeof ChannelQR.buildUrl === 'function',
'ChannelQR.buildUrl is exported');
assert(typeof ChannelQR.parseChannelUrl === 'function',
'ChannelQR.parseChannelUrl is exported');
assert(typeof ChannelQR.generate === 'function',
'ChannelQR.generate is exported');
assert(typeof ChannelQR.scan === 'function',
'ChannelQR.scan is exported');
// --- buildUrl ---
const SECRET = '8b3387e1c4be1bbf09c1a4cd5c0fa5a3';
const url1 = ChannelQR.buildUrl('Public', SECRET);
assert(url1 === 'meshcore://channel/add?name=Public&secret=' + SECRET,
'buildUrl produces canonical URL for plain name');
const url2 = ChannelQR.buildUrl('My Channel & Stuff', SECRET);
assert(url2 === 'meshcore://channel/add?name=My%20Channel%20%26%20Stuff&secret=' + SECRET,
'buildUrl URL-encodes spaces and ampersands in name');
// --- parseChannelUrl ---
const p1 = ChannelQR.parseChannelUrl(url1);
assert(p1 && p1.name === 'Public' && p1.secret === SECRET,
'parseChannelUrl extracts name + secret from canonical URL');
const p2 = ChannelQR.parseChannelUrl(url2);
assert(p2 && p2.name === 'My Channel & Stuff' && p2.secret === SECRET,
'parseChannelUrl URL-decodes name correctly');
assert(ChannelQR.parseChannelUrl(null) === null, 'parseChannelUrl(null) → null');
assert(ChannelQR.parseChannelUrl('') === null, 'parseChannelUrl("") → null');
assert(ChannelQR.parseChannelUrl('https://example.com') === null,
'parseChannelUrl rejects non-meshcore scheme');
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?name=Foo') === null,
'parseChannelUrl rejects URL missing secret');
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?secret=' + SECRET) === null,
'parseChannelUrl rejects URL missing name');
assert(ChannelQR.parseChannelUrl('meshcore://other/add?name=Foo&secret=' + SECRET) === null,
'parseChannelUrl rejects wrong host/path');
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?name=Foo&secret=zz') === null,
'parseChannelUrl rejects non-hex secret');
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?name=Foo&secret=' + SECRET.slice(0, 30)) === null,
'parseChannelUrl rejects short secret (must be 32 hex chars)');
console.log('');
console.log(` ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
+80
View File
@@ -0,0 +1,80 @@
/**
* Regression: channel sidebar layout for user-added (PSK) channels was
* broken by #1024 ( remove + 🔑 badge) interacting with the outer
* `.ch-item` <button> wrapper.
*
* Root cause: HTML5 disallows nesting <button> inside <button>. The parser
* implicitly closes the outer `.ch-item` button as soon as it hits the
* inner `<button class="ch-remove-btn">`. This re-parents the remove
* button + everything after it (the `.ch-item-preview` "X: msg" line)
* outside the channel entry, producing the visible bug:
*
* [icon] Levski 🔑 <-- outer button closes early here
* <-- orphaned, "floats"
* KpaPocket: Тест <-- preview text orphaned
* [icon] #bookclub ...
*
* This test asserts the rendered template does NOT contain a nested
* `<button>` inside the `.ch-item` button. Plus the "No key" toggle gets
* clearer copy and stays grouped with the channel controls.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
const cssSrc = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
console.log('\n=== Sidebar layout: no nested <button> inside .ch-item ===');
// The bug: a literal `<button class="ch-remove-btn"` inside the
// `.ch-item` template. After fix, the remove affordance must be a
// non-<button> element (e.g. <span role="button">) so HTML parsing
// keeps it inside the channel entry.
assert(!/<button[^>]*class="ch-remove-btn"/.test(chSrc),
'remove (✕) affordance must NOT be a <button> element (would close outer .ch-item button)');
// Remove control must still be discoverable (data attribute keeps the
// existing click handler in `addEventListener('click', ...)`).
// PR #1040 refactored to an iconBtn() helper, so the literal
// `data-remove-channel="..."` no longer appears verbatim in source —
// check that the helper is wired with the right data attribute instead.
assert(/data-remove-channel/.test(chSrc),
'remove affordance still carries data-remove-channel for click delegation');
console.log('\n=== Sidebar layout: ✕ visible on user-added rows (not opacity:0) ===');
// Bug compounded: even if the button rendered correctly, opacity:0
// hide-until-hover made it impossible to discover on touch devices.
// The user-added (PSK) row should expose ✕ at full visibility.
// PR #1040: shared base class .ch-icon-btn carries the opacity rule.
const baseRule = cssSrc.match(/\.ch-icon-btn\s*\{[^}]*\}/);
const removeRule = cssSrc.match(/\.ch-remove-btn\s*\{[^}]*\}/);
assert(baseRule || removeRule, 'found .ch-icon-btn or .ch-remove-btn CSS rule');
if (baseRule) {
assert(!/opacity:\s*0\s*[;}]/.test(baseRule[0]),
'.ch-icon-btn (base for ✕) must not be opacity:0 by default (was invisible on touch)');
}
console.log('\n=== Encrypted section: header exists and is collapsible (#1037 redesign) ===');
// #1037 replaced the binary "No key" visibility toggle with a sectioned
// sidebar — encrypted (no-key) channels live in their own collapsible
// section grouped with the rest. The old toggle is intentionally gone.
assert(/ch-section-encrypted/.test(chSrc),
'sidebar renders a dedicated Encrypted section');
assert(/id="chEncryptedToggle"/.test(chSrc),
'Encrypted section header is a toggle (button#chEncryptedToggle)');
assert(/aria-expanded=/.test(chSrc) && /aria-controls="chEncryptedBody"/.test(chSrc),
'toggle exposes ARIA collapsible state (aria-expanded + aria-controls)');
assert(/Encrypted \(\$\{encrypted\.length\}\)/.test(chSrc),
'Encrypted header shows live count');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
+185
View File
@@ -0,0 +1,185 @@
/**
* Follow-up UX fixes to #1037 channel modal/sidebar redesign:
*
* 1. remove button must hit a 44×44px touch target (WCAG 2.5.5).
* 2. Channel rows must NOT display "0 messages" when no messages
* have been decrypted yet, omit the count entirely.
* 3. Modal footer wording: keys removed via button, not by
* clearing browser data.
* 4. Each user-added (PSK) row must expose a Share affordance that
* re-opens the QR/key for that channel without re-generating it.
* 5. "(your key)" preview suffix on user-added rows is noise; drop it.
* Likewise no key hex in the default row rendering.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
const cssSrc = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
console.log('\n=== Fix 1: ✕ touch target ≥ 44×44px (on shared .ch-icon-btn base) ===');
const iconBtnRule = (cssSrc.match(/\.ch-icon-btn\s*\{[^}]*\}/) || [''])[0];
assert(/min-width:\s*44px/.test(iconBtnRule),
'.ch-icon-btn declares min-width: 44px');
assert(/min-height:\s*44px/.test(iconBtnRule),
'.ch-icon-btn declares min-height: 44px');
console.log('\n=== Fix 2: no "0 messages" in default row ===');
// renderChannelRow must not emit a literal "0 messages" preview when
// messageCount is missing/zero. Look for the offending fallback pattern.
assert(!/\$\{ch\.messageCount\s*\|\|\s*0\}\s*messages/.test(chSrc),
'preview no longer falls back to "${ch.messageCount || 0} messages"');
assert(!/\$\{ch\.messageCount\s*\|\|\s*0\}\s*packets/.test(chSrc),
'encrypted preview no longer falls back to "${ch.messageCount || 0} packets"');
console.log('\n=== Fix 3: privacy footer wording ===');
assert(!/Clear browser data to remove stored keys/.test(chSrc),
'old "Clear browser data to remove stored keys" copy is gone');
assert(/Use\s+✕\s+to remove individual channels/.test(chSrc),
'new copy points at the ✕ button for individual key removal');
console.log('\n=== Fix 4: Share/reshare affordance on user-added rows ===');
// Source-level: data attribute and helper exist. Behavior-level checks
// against rendered output are below in the renderChannelRow section.
assert(/data-share-channel/.test(chSrc),
'channels.js wires the data-share-channel hook somewhere in render');
// Click handler must wire the share button to ChannelQR.generate (or a
// QR-display fallback). The handler lives in the chListEl click delegation.
assert(/data-share-channel/.test(chSrc) && /ChannelQR/.test(chSrc),
'share handler references ChannelQR for QR rendering');
// Modal must have a target container for the reshare QR output.
assert(/id="chShareOutput"/.test(chSrc) || /id="chReshareOutput"/.test(chSrc),
'modal has a reshare QR output container');
console.log('\n=== Fix 5: "(your key)" suffix removed from preview ===');
assert(!/\(your key\)/.test(chSrc),
'user-added preview no longer says "(your key)"');
console.log('\n=== Fix 6: browser-local warning is obvious ===');
// A visible callout in the modal — separate from the small privacy footer.
assert(/class="ch-modal-callout"/.test(chSrc),
'modal has a dedicated .ch-modal-callout for the locality warning');
assert(/THIS browser only/.test(chSrc),
'callout uses emphatic copy: "Channels are saved to THIS browser only"');
assert(/won't appear on other devices or browsers|won.t appear on other devices/.test(chSrc),
'callout warns that channels won\u2019t appear on other devices/browsers');
// Sidebar "My Channels" section header gets a locality marker.
assert(/My Channels[\s\S]{0,200}\(this browser\)|🖥️[\s\S]{0,200}My Channels|My Channels[\s\S]{0,200}🖥️/.test(chSrc),
'My Channels section header reinforces locality (🖥️ or "(this browser)")');
// Remove confirm prompt explicitly mentions "this browser".
assert(/permanently remove the key from this browser/.test(chSrc),
'remove confirm says key is permanently removed from this browser');
console.log('\n=== Fix 7: default channel reference is #meshcore, not #LongFast ===');
// Channels UI must not reference Meshtastic's LongFast as the example
// channel — meshcore network's analogous default is #meshcore.
assert(!/LongFast/.test(chSrc),
'public/channels.js has no "LongFast" references');
assert(/#meshcore/.test(chSrc),
'public/channels.js uses #meshcore as the example/placeholder');
console.log('\n=== Behavior: renderChannelRow output structure ===');
// Extract renderChannelRow and exercise it against synthetic ch records
// to assert behavior (not just source substring presence).
const vm = require('vm');
// Locate renderChannelRow source by walking braces from the function header.
function extractFn(src, header) {
const start = src.indexOf(header);
if (start < 0) return null;
let depth = 0, i = src.indexOf('{', start);
if (i < 0) return null;
for (let j = i; j < src.length; j++) {
const c = src[j];
if (c === '{') depth++;
else if (c === '}') { depth--; if (depth === 0) return src.substring(start, j + 1); }
}
return null;
}
const renderRowSrc = extractFn(chSrc, 'function renderChannelRow(ch)');
assert(renderRowSrc, 'extracted renderChannelRow source for behavior testing');
if (renderRowSrc) {
// Stub the helpers renderChannelRow depends on, evaluate it in a sandbox.
const sandbox = {
escapeHtml: s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c])),
truncate: (s, n) => (s && s.length > n ? s.substring(0, n) + '…' : s || ''),
formatSecondsAgo: () => '5m',
formatHashHex: h => h,
getChannelColor: () => '#fff',
selectedHash: null,
customColors: {},
window: {},
renderChannelRow: null,
};
// #1041 follow-up: renderChannelRow now delegates to channelDisplayName.
// Eval the REAL helper (and its module-local PRIVATE_CHANNEL_LABEL)
// into the sandbox so this test stays in sync with production behavior
// automatically — no hand-rolled duplicate of the psk:* rule.
const helperSrc = extractFn(chSrc, 'function channelDisplayName(ch');
assert(helperSrc, 'extracted channelDisplayName source for behavior sandbox');
vm.createContext(sandbox);
vm.runInContext('const PRIVATE_CHANNEL_LABEL = "Private Channel";\n' + helperSrc, sandbox);
vm.runInContext(renderRowSrc, sandbox);
const userRow = sandbox.renderChannelRow({
hash: 'user:Crew',
name: 'Crew',
userAdded: true,
encrypted: true,
messageCount: 0,
lastActivityMs: Date.now(),
});
assert(/data-share-channel="user:Crew"/.test(userRow),
'renderChannelRow emits a share button for user-added channels');
assert(/aria-haspopup="dialog"/.test(userRow),
'share button announces it opens a dialog (aria-haspopup="dialog")');
assert(/data-remove-channel="user:Crew"/.test(userRow),
'renderChannelRow emits a remove button for user-added channels');
assert(!/0 messages/.test(userRow) && !/your key/.test(userRow),
'user-added preview omits "0 messages" and "your key" when no activity');
// Non-user-added encrypted row should NOT carry share/remove.
const encRow = sandbox.renderChannelRow({
hash: 'abc123', name: 'Net', userAdded: false, encrypted: true,
messageCount: 0, lastActivityMs: 0,
});
assert(!/data-share-channel/.test(encRow),
'encrypted (non-user) rows do NOT expose a share button');
assert(!/0 packets/.test(encRow),
'encrypted preview omits "0 packets" when count is zero');
}
console.log('\n=== Behavior: share output is a labeled section, not a footer trailer ===');
// The share output must live inside a labeled section (a11y), not as a
// dangling div after .ch-modal-footer.
assert(/id="chShareSection"[\s\S]{0,200}aria-labelledby="chShareHeading"/.test(chSrc),
'share output is wrapped in a labeled section (chShareSection / chShareHeading)');
const footerIdx = chSrc.indexOf('class="ch-modal-footer"');
const sectionIdx = chSrc.indexOf('id="chShareSection"');
assert(footerIdx > 0 && sectionIdx > 0 && sectionIdx < footerIdx,
'share section is rendered BEFORE .ch-modal-footer (footer stays last)');
console.log('\n=== A11y: locality marker font-size ≥ 11px ===');
const localityRule = (cssSrc.match(/\.ch-section-locality\s*\{[^}]*\}/) || [''])[0];
const sizeMatch = localityRule.match(/font-size:\s*(\d+)px/);
assert(sizeMatch && parseInt(sizeMatch[1], 10) >= 11,
'.ch-section-locality font-size is ≥ 11px (got: ' + (sizeMatch ? sizeMatch[1] : 'none') + ')');
console.log('\n=== Share handler: no native alert(), uses inline output ===');
// Walk the share-handler region and verify it doesn't drop to alert().
const shareHandlerMatch = chSrc.match(/data-share-channel[\s\S]{0,2000}?return;\n \}/);
assert(shareHandlerMatch && !/alert\(/.test(shareHandlerMatch[0]),
'share handler does not use native alert() for missing-key error');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
+87
View File
@@ -0,0 +1,87 @@
/**
* Follow-up UX round 2 to channels (post #1040):
*
* 1. Channel header (selected-channel title) must NOT display the raw
* "psk:<hex8>" key prefix. Use the user-supplied label when present,
* otherwise fall back to "Private Channel".
* 2. Sidebar share button uses a recognizable label ("📤 Share" or
* similar), not the bare glyph.
* 3. remove button has a red background, white text, proper button
* styling looks like a destructive action.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
const cssSrc = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
console.log('\n=== Fix 1: header display name for PSK channels ===');
// Behavior test: extract channelDisplayName helper and exercise it.
const vm = require('vm');
function extractFn(src, header) {
const start = src.indexOf(header);
if (start < 0) return null;
let depth = 0, i = src.indexOf('{', start);
if (i < 0) return null;
for (let j = i; j < src.length; j++) {
const c = src[j];
if (c === '{') depth++;
else if (c === '}') { depth--; if (depth === 0) return src.substring(start, j + 1); }
}
return null;
}
const helperSrc = extractFn(chSrc, 'function channelDisplayName(ch');
assert(helperSrc, 'channelDisplayName helper exists');
if (helperSrc) {
const sandbox = { formatHashHex: h => h, PRIVATE_CHANNEL_LABEL: 'Private Channel' };
vm.createContext(sandbox);
vm.runInContext('const PRIVATE_CHANNEL_LABEL = "Private Channel";\n' + helperSrc, sandbox);
assert(sandbox.channelDisplayName({ name: 'psk:372a9c93', userLabel: 'My Crew' }) === 'My Crew',
'psk:* with userLabel returns the userLabel');
assert(sandbox.channelDisplayName({ name: 'psk:372a9c93' }) === 'Private Channel',
'psk:* without label returns "Private Channel"');
assert(sandbox.channelDisplayName({ name: '#meshcore' }) === '#meshcore',
'non-PSK names pass through unchanged');
assert(sandbox.channelDisplayName({ hash: 'abc', name: '' }) === 'Channel abc',
'falls back to "Channel <hash>" when name missing');
assert(sandbox.channelDisplayName({ hash: 'abc', name: '' }, 'Unknown') === 'Unknown',
'caller-supplied fallback overrides "Channel <hash>" default');
assert(sandbox.channelDisplayName({ name: 'psk:abc' }, 'Unknown') === 'Private Channel',
'fallback does NOT override the psk:* → "Private Channel" rule');
}
// Source-level: header rendering must call channelDisplayName, not raw ch.name.
assert(/channelDisplayName\(ch\)/.test(chSrc),
'selectChannel header rendering uses channelDisplayName(ch)');
console.log('\n=== Fix 2: share button has recognizable label ===');
assert(!/'⤴'/.test(chSrc) && !/"⤴"/.test(chSrc),
'bare ⤴ glyph no longer used as the share button content');
// Tighten: assert the literal '📤 Share' string is the glyph argument
// passed into the iconBtn(...) call for ch-share-btn — this catches the
// case where someone removes the icon from the button content but leaves
// "Share" in an aria-label or title.
assert(/iconBtn\(\s*'ch-share-btn'[^)]*'📤 Share'/.test(chSrc),
"iconBtn('ch-share-btn', ...) is called with '📤 Share' as the glyph");
console.log('\n=== Fix 3: ✕ delete button is a visibly red destructive button ===');
const removeRule = (cssSrc.match(/\.ch-remove-btn\s*\{[^}]*\}/) || [''])[0];
assert(/background:\s*var\(--statusRed/.test(removeRule) || /background:\s*#b54a4a/.test(removeRule),
'.ch-remove-btn has red background (var(--statusRed,...) or #b54a4a)');
assert(/color:\s*white/.test(removeRule) || /color:\s*#fff/.test(removeRule),
'.ch-remove-btn has white text');
assert(/border-radius:/.test(removeRule),
'.ch-remove-btn has border-radius (button shape)');
assert(/font-weight:\s*bold|font-weight:\s*700/.test(removeRule),
'.ch-remove-btn has bold font-weight');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
+111
View File
@@ -0,0 +1,111 @@
/* Unit tests for compare.js asymmetric overlap stats — Fixes #671 */
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {} },
document: {
readyState: 'complete',
createElement: () => ({ id: '', textContent: '', innerHTML: '', addEventListener: () => {} }),
head: { appendChild: () => {} },
getElementById: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
},
console,
setTimeout, clearTimeout,
location: { hash: '#/compare', href: '' },
history: { replaceState: () => {} },
URLSearchParams,
Map, Set, Date, Promise,
escapeHtml: (s) => s,
api: () => Promise.resolve({ observers: [] }),
CLIENT_TTL: { observers: 0 },
registerPage: () => {},
timeAgo: () => '',
payloadTypeColor: () => '',
};
ctx.self = ctx.window;
return ctx;
}
const ctx = makeSandbox();
const sandbox = vm.createContext(ctx);
const compareSrc = fs.readFileSync(__dirname + '/public/compare.js', 'utf8');
vm.runInContext(compareSrc, sandbox);
console.log('\ncompare.js asymmetric overlap stats (#671):');
test('computeOverlapStats is exposed on window', () => {
assert.strictEqual(typeof sandbox.window.computeOverlapStats, 'function',
'computeOverlapStats should be exposed on window');
});
test('basic asymmetric overlap — A sees 8/10 of B\'s, B sees 8/12 of A\'s', () => {
// A: 12 unique packets total (10 shared with B + 2 unique)
// B: 10 unique packets total (10 shared with A... wait: 8 shared + 2 unique to B)
// Let's do: A has packets 1..10 + extras 11,12; B has packets 1..8 + extras 13,14
// shared = {1..8} = 8
// onlyA = {9,10,11,12} = 4
// onlyB = {13,14} = 2
// A total = 12, B total = 10
// A sees 8/10 = 80% of B's packets
// B sees 8/12 = 66.7% of A's packets
const setA = new Set(['1','2','3','4','5','6','7','8','9','10','11','12']);
const setB = new Set(['1','2','3','4','5','6','7','8','13','14']);
const cmp = sandbox.window.comparePacketSets(setA, setB);
const stats = sandbox.window.computeOverlapStats(cmp);
assert.strictEqual(stats.totalA, 12, 'totalA');
assert.strictEqual(stats.totalB, 10, 'totalB');
assert.strictEqual(stats.shared, 8, 'shared');
assert.strictEqual(stats.onlyA, 4, 'onlyA');
assert.strictEqual(stats.onlyB, 2, 'onlyB');
assert.strictEqual(stats.aSeesOfB, 80.0, 'A sees 80% of B\'s');
assert.strictEqual(stats.bSeesOfA, Math.round(8/12*1000)/10, 'B sees 66.7% of A\'s');
});
test('zero packets — no division by zero', () => {
const cmp = sandbox.window.comparePacketSets(new Set(), new Set());
const stats = sandbox.window.computeOverlapStats(cmp);
assert.strictEqual(stats.aSeesOfB, 0);
assert.strictEqual(stats.bSeesOfA, 0);
assert.strictEqual(stats.shared, 0);
});
test('one observer empty — other gets 0% mutual coverage', () => {
const cmp = sandbox.window.comparePacketSets(new Set(['x','y']), new Set());
const stats = sandbox.window.computeOverlapStats(cmp);
assert.strictEqual(stats.totalA, 2);
assert.strictEqual(stats.totalB, 0);
assert.strictEqual(stats.aSeesOfB, 0, 'no B packets to see');
assert.strictEqual(stats.bSeesOfA, 0, 'B saw 0 of A\'s');
});
test('perfect overlap — 100% both ways', () => {
const s = new Set(['a','b','c']);
const cmp = sandbox.window.comparePacketSets(s, new Set(s));
const stats = sandbox.window.computeOverlapStats(cmp);
assert.strictEqual(stats.aSeesOfB, 100);
assert.strictEqual(stats.bSeesOfA, 100);
assert.strictEqual(stats.shared, 3);
});
test('disjoint observers — 0% both ways', () => {
const cmp = sandbox.window.comparePacketSets(new Set(['a','b']), new Set(['c','d']));
const stats = sandbox.window.computeOverlapStats(cmp);
assert.strictEqual(stats.aSeesOfB, 0);
assert.strictEqual(stats.bSeesOfA, 0);
assert.strictEqual(stats.shared, 0);
});
console.log(`\n ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
+221 -35
View File
@@ -257,10 +257,13 @@ async function run() {
});
// Test: Nodes page has WebSocket auto-update listener (#131)
// NOTE: This test verifies the WS *infrastructure* exists on the Nodes page.
// It deliberately does NOT wait for `table tbody tr` — that creates a flake
// because rows arriving via WS push are timing-dependent in CI. The preceding
// "Nodes page loads with data" test already covers initial table population.
await test('Nodes page has WebSocket auto-update', async () => {
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 });
await page.waitForSelector('table tbody tr');
// The live dot in navbar indicates WS connection status
const liveDot = await page.$('#liveDot');
assert(liveDot, 'Live dot WebSocket indicator (#liveDot) not found');
@@ -269,7 +272,8 @@ async function run() {
return typeof onWS === 'function' && typeof offWS === 'function';
});
assert(hasWsInfra, 'WebSocket listener infrastructure (onWS/offWS) should be available');
// Wait for WS connection and verify liveDot shows connected state
// Best-effort: if WS connects within 5s, verify connected state. Don't fail otherwise —
// CI may not have a live MQTT feed. Infra-existence assertions above are the contract.
try {
await page.waitForFunction(() => {
const dot = document.getElementById('liveDot');
@@ -828,6 +832,57 @@ async function run() {
// --- Group: Live page ---
// Test (issue #1046): Activating the Live nav link MUST NOT cause the
// "🔴 Live" label to wrap onto two lines, which makes the whole top
// nav bar grow taller and "hop". The label has to stay on one line in
// every state, and the nav bar height must be identical with/without
// the .active class.
await test('Live nav-link does not wrap or change nav height when active (#1046)', async () => {
// Use the exact viewport width from the issue screenshots.
await page.setViewportSize({ width: 1115, height: 800 });
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('a.nav-link[data-route="live"]');
const measure = await page.evaluate(() => {
const link = document.querySelector('a.nav-link[data-route="live"]');
const nav = document.querySelector('.top-nav');
const ws = getComputedStyle(link).whiteSpace;
// Force inactive state.
const wasActive = link.classList.contains('active');
link.classList.remove('active');
const inactive = {
navH: nav.getBoundingClientRect().height,
lines: link.getClientRects().length,
};
// Force active state.
link.classList.add('active');
const active = {
navH: nav.getBoundingClientRect().height,
lines: link.getClientRects().length,
};
// Restore.
link.classList.toggle('active', wasActive);
return { ws, inactive, active };
});
assert(
['nowrap', 'pre', 'pre-wrap'].includes(measure.ws),
`Live nav-link must not wrap; computed white-space=${measure.ws}`,
);
assert(
measure.inactive.lines === 1,
`Live nav-link must render on one line when inactive (got ${measure.inactive.lines})`,
);
assert(
measure.active.lines === 1,
`Live nav-link must render on one line when active (got ${measure.active.lines})`,
);
assert(
measure.active.navH === measure.inactive.navH,
`Top nav height must not change when Live becomes active (inactive=${measure.inactive.navH}, active=${measure.active.navH})`,
);
});
// Test: Live page loads with map and stats
await test('Live page loads with map and stats', async () => {
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
@@ -2327,40 +2382,51 @@ async function run() {
assert(hasHslPolyline, 'At least one live-packet-trace polyline should have hsl() stroke color from hash');
});
// --- Roles page (issue #818): renders distribution + per-role skew ---
await test('Roles page renders distribution table from /api/analytics/roles', async () => {
await page.goto(BASE + '/#/roles', { waitUntil: 'domcontentloaded' });
// Wait for roles-page.js to mount and the table to render.
await page.waitForSelector('.roles-page[data-page="roles"]', { timeout: 10000 });
await page.waitForFunction(() => {
var el = document.querySelector('#rolesContent');
if (!el) return false;
// Either the table renders, or the empty-state message appears.
return !!el.querySelector('#rolesTable') || /No roles to show|Failed to load/.test(el.textContent);
}, { timeout: 10000 });
var hasTable = await page.$('#rolesTable');
if (!hasTable) {
// Empty fixture is acceptable; at least the page must NOT show the
// generic "Page not yet implemented" placeholder (the bug we fixed).
var bodyText = await page.evaluate(() => document.body.innerText);
assert(!/Page not yet implemented/i.test(bodyText), 'Roles page must not show "Page not yet implemented" placeholder');
return;
}
// With data: header columns and at least one body row must be present.
var headers = await page.$$eval('#rolesTable thead th', ths => ths.map(t => t.textContent.trim()));
assert(headers.includes('Role'), 'Roles table must have a Role column, got ' + JSON.stringify(headers));
assert(headers.some(h => /Median/.test(h)), 'Roles table must have a Median |skew| column, got ' + JSON.stringify(headers));
var rowCount = await page.$$eval('#rolesTable tbody tr', rs => rs.length);
assert(rowCount > 0, 'Roles table should have at least one row when API returns data');
// API contract sanity check: shape matches the page's expectations.
var apiOk = await page.evaluate(async () => {
var r = await fetch('/api/analytics/roles');
if (!r.ok) return { ok: false, status: r.status };
var j = await r.json();
return { ok: true, hasRoles: Array.isArray(j.roles), hasTotal: typeof j.totalNodes === 'number' };
// --- Roles folded into Analytics (issue #1085) ---
// Acceptance criteria:
// 1. "Roles" link does NOT exist in top nav
// 2. Analytics page has a "Roles" tab with the same content
// 3. Old #/roles URL redirects to #/analytics?tab=roles
await test('Roles fold-in (#1085): no "Roles" link in top nav', async () => {
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav.top-nav .nav-links', { timeout: 10000 });
var hasRolesLink = await page.evaluate(() => {
var links = document.querySelectorAll('nav.top-nav .nav-links a.nav-link[data-route="roles"]');
return links.length > 0;
});
assert(apiOk.ok, '/api/analytics/roles must return 200, got ' + JSON.stringify(apiOk));
assert(apiOk.hasRoles && apiOk.hasTotal, '/api/analytics/roles response must have {roles:[], totalNodes:n}, got ' + JSON.stringify(apiOk));
assert(!hasRolesLink, 'Top nav must NOT contain a "Roles" link (data-route="roles")');
});
await test('Roles fold-in (#1085): Analytics page has a "Roles" tab', async () => {
await page.goto(BASE + '/#/analytics', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#analyticsTabs', { timeout: 10000 });
var rolesTab = await page.$('#analyticsTabs .tab-btn[data-tab="roles"]');
assert(rolesTab, 'Analytics tabs must include a [data-tab="roles"] button');
var label = await page.evaluate(el => el.textContent.trim(), rolesTab);
assert(/roles/i.test(label), 'Roles tab label must say "Roles", got ' + JSON.stringify(label));
// Click the tab and verify the same Roles content renders.
await page.click('#analyticsTabs [data-tab="roles"]');
await page.waitForFunction(() => {
var el = document.getElementById('analyticsContent');
if (!el) return false;
return !!el.querySelector('#rolesTable') || /No roles to show|Failed to load|Loading/i.test(el.textContent);
}, { timeout: 10000 });
// After settle, must show table or empty-state — never the SPA placeholder.
await page.waitForFunction(() => {
var el = document.getElementById('analyticsContent');
return el && !/Loading…/.test(el.textContent);
}, { timeout: 10000 });
var bodyText = await page.evaluate(() => document.getElementById('analyticsContent').innerText);
assert(!/Page not yet implemented/i.test(bodyText), 'Roles tab must not show SPA placeholder');
});
await test('Roles fold-in (#1085): old #/roles URL redirects to #/analytics?tab=roles', async () => {
await page.goto(BASE + '/#/roles', { waitUntil: 'domcontentloaded' });
// Allow router to process the redirect.
await page.waitForFunction(() => /^#\/analytics(\?|$)/.test(location.hash), { timeout: 5000 });
var hash = await page.evaluate(() => location.hash);
assert(/^#\/analytics\?/.test(hash), 'After visiting #/roles, hash must redirect to #/analytics?…, got ' + hash);
assert(/[?&]tab=roles(&|$)/.test(hash), 'Redirect must carry tab=roles, got ' + hash);
});
// --- Geofilter draft: save/load/download buttons (issue #819, rule 18) ---
@@ -2443,6 +2509,126 @@ async function run() {
await page.evaluate(() => localStorage.removeItem('geofilter-draft'));
});
// --- Group: Fluid scaffolding (#1054) — no horizontal overflow at any viewport ---
// Asserts document.documentElement.scrollWidth <= clientWidth across breakpoints.
// Deterministic: pure layout assertion, no timing/network dependencies beyond domcontentloaded.
{
const viewports = [768, 1080, 1440, 1920, 2560];
const HEIGHT = 900;
async function assertNoHOverflow(page, label) {
// Wait for layout to settle: ensure body is rendered and any web fonts/CSS applied.
await page.waitForSelector('body', { timeout: 10000 });
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
const m = await page.evaluate(() => ({
sw: document.documentElement.scrollWidth,
cw: document.documentElement.clientWidth,
bsw: document.body.scrollWidth,
bcw: document.body.clientWidth,
}));
assert(m.sw <= m.cw,
`${label}: documentElement horizontal overflow — scrollWidth=${m.sw} > clientWidth=${m.cw}`);
assert(m.bsw <= m.cw,
`${label}: body horizontal overflow — body.scrollWidth=${m.bsw} > documentElement.clientWidth=${m.cw}`);
}
for (const w of viewports) {
await test(`No horizontal overflow at ${w}px (home)`, async () => {
await page.setViewportSize({ width: w, height: HEIGHT });
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await assertNoHOverflow(page, `home @ ${w}x${HEIGHT}`);
});
}
// ── #1034 PR3: QR generate + scan wiring (channel modal) ──
await test('#1034 PR3: Generate & Show QR renders QR + Copy Key into #qr-output', async () => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#chAddChannelBtn', { timeout: 8000 });
await page.click('#chAddChannelBtn');
await page.waitForSelector('#chAddChannelModal:not(.hidden)', { timeout: 3000 });
await page.fill('#chGenerateName', 'wiring-e2e');
// Sanity: pre-click, qr-output should be empty.
const before = await page.evaluate(() =>
(document.getElementById('qr-output').innerHTML || '').trim()
);
assert(before === '', `#qr-output should start empty, got: ${before.slice(0,60)}`);
await page.click('#chGenerateBtn');
// ChannelQR.generate writes the meshcore:// URL line + a Copy Key
// button regardless of whether QRCode renders as <canvas> or <img>.
// Wait for the URL line which is always populated.
await page.waitForFunction(() => {
const el = document.getElementById('qr-output');
return el && /meshcore:\/\/channel\/add/.test(el.textContent || '');
}, { timeout: 4000 });
const html = await page.innerHTML('#qr-output');
assert(/meshcore:\/\/channel\/add/.test(html),
'#qr-output must contain meshcore://channel/add URL');
assert(/canvas|<img|qr/i.test(html),
'#qr-output must contain a QR rendering (canvas/img/QR table)');
assert(/Copy Key/.test(html),
'#qr-output must contain a Copy Key button');
// Close modal for next test.
const close = await page.$('[data-action="ch-modal-close"], #chModalClose');
if (close) await close.click().catch(() => {});
});
await test('#1034 PR3: scan-qr-btn is enabled (no longer placeholder)', async () => {
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#chAddChannelBtn', { timeout: 8000 });
await page.click('#chAddChannelBtn');
await page.waitForSelector('#scan-qr-btn', { timeout: 3000 });
const disabled = await page.$eval('#scan-qr-btn', (b) => b.hasAttribute('disabled'));
assert(!disabled, '#scan-qr-btn must be enabled (wired to ChannelQR.scan)');
await page.keyboard.press('Escape').catch(() => {});
});
await test('#1034 PR3: scan handler populates #chPskKey + #chPskName from result', async () => {
// Stub ChannelQR.scan to return a deterministic result, then click
// the scan button and assert the form fields are populated.
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#chAddChannelBtn', { timeout: 8000 });
await page.click('#chAddChannelBtn');
await page.waitForSelector('#scan-qr-btn', { timeout: 3000 });
await page.evaluate(() => {
window.ChannelQR = window.ChannelQR || {};
window.ChannelQR.scan = function () {
return Promise.resolve({
name: 'scanned-e2e',
secret: 'a'.repeat(32),
});
};
});
await page.click('#scan-qr-btn');
// Give the async handler a tick.
await page.waitForFunction(() => {
const k = document.getElementById('chPskKey');
return k && k.value && k.value.length === 32;
}, { timeout: 3000 });
const key = await page.$eval('#chPskKey', (el) => el.value);
const name = await page.$eval('#chPskName', (el) => el.value);
assert(key === 'a'.repeat(32), `#chPskKey populated, got: ${key}`);
assert(name === 'scanned-e2e', `#chPskName populated, got: ${name}`);
});
// Spot-check a couple other pages at the smallest and largest viewports.
const otherPages = [
{ name: 'packets', hash: '#/packets' },
{ name: 'nodes', hash: '#/nodes' },
{ name: 'analytics', hash: '#/analytics' },
];
for (const w of [768, 2560]) {
for (const p of otherPages) {
await test(`No horizontal overflow at ${w}px (${p.name})`, async () => {
await page.setViewportSize({ width: w, height: HEIGHT });
await page.goto(BASE + '/' + p.hash, { waitUntil: 'domcontentloaded' });
await assertNoHOverflow(page, `${p.name} @ ${w}x${HEIGHT}`);
});
}
}
}
await browser.close();
// Summary
+180
View File
@@ -0,0 +1,180 @@
/**
* E2E (#966): Wireshark-style filter UX.
*
* Boots Chromium against a local corescope-server (defaults to fixture instance
* on :39966) and exercises:
* - Help button opens popover with field/operator reference
* - Autocomplete dropdown appears as user types and accepts on Enter
* - Right-click on a packet table cell opens "Filter by this value" menu
* and clicking populates the filter input
* - Saved-filter dropdown lists default starter filters
*
* Usage: BASE_URL=http://localhost:39966 node test-filter-ux-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:39966';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #966 filter UX E2E against ${BASE} ===`);
await step('navigate to /packets', async () => {
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#packetFilterInput', { timeout: 8000 });
await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 });
});
await step('PacketFilter metadata is exposed in window', async () => {
const meta = await page.evaluate(() => ({
fields: window.PacketFilter && Array.isArray(window.PacketFilter.FIELDS) && window.PacketFilter.FIELDS.length,
ops: window.PacketFilter && Array.isArray(window.PacketFilter.OPERATORS) && window.PacketFilter.OPERATORS.length,
types: window.PacketFilter && Array.isArray(window.PacketFilter.TYPE_VALUES) && window.PacketFilter.TYPE_VALUES.length,
hasSuggest: typeof window.PacketFilter.suggest === 'function',
}));
assert(meta.fields >= 10, 'FIELDS not populated: ' + JSON.stringify(meta));
assert(meta.ops >= 8, 'OPERATORS not populated');
assert(meta.types >= 5, 'TYPE_VALUES not populated');
assert(meta.hasSuggest, 'suggest() missing');
});
await step('Help button opens popover with field reference', async () => {
await page.click('#filterHelpBtn');
await page.waitForSelector('#filterHelpPopover', { timeout: 3000 });
const txt = await page.textContent('#filterHelpPopover');
assert(/Filter syntax/i.test(txt), 'header missing');
assert(/payload\.name/.test(txt), 'fields table missing payload.name');
assert(/contains/.test(txt), 'operators missing');
assert(/ADVERT/.test(txt), 'examples missing');
// Close it
await page.click('#filterHelpPopover .fux-popover-close');
await page.waitForFunction(() => !document.getElementById('filterHelpPopover'), { timeout: 3000 });
});
await step('Autocomplete dropdown appears on focus and filters by prefix', async () => {
await page.click('#packetFilterInput');
await page.fill('#packetFilterInput', '');
await page.keyboard.type('pay');
await page.waitForSelector('#filterAcDropdown .fux-ac-item', { timeout: 3000 });
const items = await page.$$eval('#filterAcDropdown .fux-ac-item .fux-ac-val', els => els.map(e => e.textContent));
assert(items.some(v => v.startsWith('payload')), 'no payload* in dropdown: ' + items.join(','));
});
await step('Autocomplete accepts on Enter and updates input', async () => {
await page.fill('#packetFilterInput', '');
await page.click('#packetFilterInput');
await page.keyboard.type('typ');
await page.waitForSelector('#filterAcDropdown .fux-ac-item.active', { timeout: 3000 });
await page.keyboard.press('Enter');
const val = await page.inputValue('#packetFilterInput');
assert(/^type/.test(val), 'expected `type` after accept, got: ' + val);
});
await step('Saved-filter dropdown lists default starters', async () => {
// Reset LS so defaults are unmodified
await page.evaluate(() => { try { localStorage.removeItem('corescope_saved_filters_v1'); } catch (e) {} });
await page.click('#filterSavedTrigger');
await page.waitForSelector('#filterSavedMenu:not(.hidden)', { timeout: 3000 });
const names = await page.$$eval('#filterSavedMenu .fux-saved-name', els => els.map(e => e.textContent));
assert(names.length >= 5, 'expected ≥ 5 default filters, got: ' + names.length);
assert(names.some(n => /Adverts only/i.test(n)), 'Adverts only missing: ' + names.join('|'));
assert(names.some(n => /Strong signal/i.test(n)), 'Strong signal missing: ' + names.join('|'));
});
await step('Clicking a saved filter populates the input and applies it', async () => {
// Click the "Adverts only" entry
await page.evaluate(() => {
const items = document.querySelectorAll('#filterSavedMenu .fux-saved-item');
for (const it of items) { if (/Adverts only/i.test(it.textContent)) { it.click(); break; } }
});
await page.waitForFunction(() => /type\s*==\s*ADVERT/i.test(document.getElementById('packetFilterInput').value), { timeout: 3000 });
const val = await page.inputValue('#packetFilterInput');
assert(/type\s*==\s*ADVERT/i.test(val), 'expected Adverts expr, got: ' + val);
});
await step('Right-click on a type cell opens context menu and appends a clause', async () => {
// Reset filter
await page.fill('#packetFilterInput', '');
await page.evaluate(() => document.getElementById('packetFilterInput').dispatchEvent(new Event('input', { bubbles: true })));
// Widen time window so fixture rows render
await page.evaluate(() => {
const sel = document.getElementById('fTimeWindow');
if (sel) {
sel.value = '0';
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
});
// Wait for the table to populate with cells that have a real value
await page.waitForFunction(() => {
const cells = document.querySelectorAll('#pktBody td[data-filter-field="type"]');
for (const c of cells) {
const v = c.getAttribute('data-filter-value');
if (v && v !== '—' && v !== '') return true;
}
return false;
}, { timeout: 8000 });
// Dispatch contextmenu event programmatically (Playwright headless mouse
// right-click does not reliably trigger 'contextmenu' DOM events).
const result = await page.evaluate(() => {
const cell = Array.from(document.querySelectorAll('#pktBody td[data-filter-field="type"]'))
.find(c => {
const v = c.getAttribute('data-filter-value');
return v && v !== '—' && v !== '';
});
if (!cell) return { error: 'no type cell with value' };
const rect = cell.getBoundingClientRect();
const ev = new MouseEvent('contextmenu', {
bubbles: true, cancelable: true, button: 2,
clientX: rect.left + 5, clientY: rect.top + 5,
});
cell.dispatchEvent(ev);
const menu = document.getElementById('filterContextMenu');
if (!menu) return { error: 'context menu not opened' };
const items = Array.from(menu.querySelectorAll('.fux-ctx-item')).map(i => i.textContent);
// Click the first item (== filter)
menu.querySelector('.fux-ctx-item').click();
return { items, inputAfter: document.getElementById('packetFilterInput').value };
});
assert(!result.error, 'menu open failed: ' + (result.error || ''));
assert(result.items.length === 3, 'expected 3 menu items, got: ' + result.items.length);
assert(/type\s*==\s*/.test(result.inputAfter), 'expected type clause appended, got: ' + result.inputAfter);
});
await step('Save current expression persists to localStorage', async () => {
await page.fill('#packetFilterInput', 'snr > 7');
await page.evaluate(() => document.getElementById('packetFilterInput').dispatchEvent(new Event('input', { bubbles: true })));
await page.click('#filterSavedTrigger');
await page.waitForSelector('#filterSavedMenu:not(.hidden)');
// Stub prompt
await page.evaluate(() => { window.prompt = () => 'E2E test filter'; });
await page.click('#filterSaveCurrent');
await page.waitForFunction(() => {
const raw = localStorage.getItem('corescope_saved_filters_v1') || '';
return /E2E test filter/.test(raw) && /snr > 7/.test(raw);
}, { timeout: 3000 });
// Cleanup
await page.evaluate(() => localStorage.removeItem('corescope_saved_filters_v1'));
});
await browser.close();
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
process.exit(failed > 0 ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+90
View File
@@ -0,0 +1,90 @@
/* Tests for fluid CSS scaffolding (issue #1054).
* Ensures `public/style.css` declares fluid spacing/type/container tokens
* via clamp() and that base selectors consume them instead of hardcoded px.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const css = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
// --- Helpers ---------------------------------------------------------------
// Extract the :root { ... } block (first occurrence — the light/default one).
function rootBlock() {
const m = css.match(/:root\s*\{([\s\S]*?)\}/);
if (!m) throw new Error(':root block not found in style.css');
return m[1];
}
// Find the value of a custom property declared in :root.
function rootVar(name) {
const re = new RegExp(`${name}\\s*:\\s*([^;]+);`);
const m = rootBlock().match(re);
return m ? m[1].trim() : null;
}
function assertClamp(name) {
const v = rootVar(name);
assert.ok(v, `expected :root to declare ${name}`);
assert.ok(/clamp\s*\(/.test(v), `${name} should use clamp(); got: ${v}`);
}
// --- Fluid spacing tokens --------------------------------------------------
const SPACE_TOKENS = ['--space-xs', '--space-sm', '--space-md',
'--space-lg', '--space-xl', '--space-2xl'];
SPACE_TOKENS.forEach(t => {
test(`spacing token ${t} declared with clamp()`, () => assertClamp(t));
});
// --- Fluid type scale ------------------------------------------------------
const TYPE_TOKENS = ['--fs-sm', '--fs-md', '--fs-lg', '--fs-xl', '--fs-2xl'];
TYPE_TOKENS.forEach(t => {
test(`type token ${t} declared with clamp()`, () => assertClamp(t));
});
// --- Container tokens ------------------------------------------------------
test('container token --content-max declared', () => {
const v = rootVar('--content-max');
assert.ok(v, 'expected --content-max in :root');
assert.ok(/min\s*\(|clamp\s*\(/.test(v),
`--content-max should use min()/clamp(); got: ${v}`);
});
test('container token --gutter declared with clamp()', () => assertClamp('--gutter'));
// --- Base selectors must consume fluid tokens ------------------------------
test('html/body rule references fluid font-size token', () => {
// Look at the html, body { ... } rule (first one).
const m = css.match(/html\s*,\s*body\s*\{([^}]*)\}/);
assert.ok(m, 'html, body rule not found');
const block = m[1];
assert.ok(/font-size\s*:\s*var\(--fs-/.test(block),
`html/body should set font-size via var(--fs-*); block was: ${block.trim()}`);
});
// --- Section markers -------------------------------------------------------
test('style.css contains FLUID SCAFFOLDING section marker', () => {
assert.ok(/FLUID SCAFFOLDING/i.test(css),
'expected a "FLUID SCAFFOLDING" section marker comment');
});
// --- Summary ---------------------------------------------------------------
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
+140
View File
@@ -0,0 +1,140 @@
/* Unit tests for live.js region filter (#1045)
* Tests packetMatchesRegion helper using observer_id IATA mapping.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 },
document: {
readyState: 'complete',
createElement: () => ({ style:{}, classList:{add(){},remove(){},contains(){return false;}}, setAttribute(){}, addEventListener(){}, getContext: () => ({clearRect(){},fillRect(){},beginPath(){},arc(){},fill(){},scale(){},fillText(){}}) }),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [], querySelector: () => null,
createElementNS: () => ({ setAttribute(){} }),
documentElement: { getAttribute: () => null, setAttribute: () => {}, dataset: {} },
body: { appendChild: () => {}, removeChild: () => {}, contains: () => false },
hidden: false,
},
console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
Error, TypeError, Map, Set, Promise, URLSearchParams,
parseInt, parseFloat, isNaN, isFinite,
encodeURIComponent, decodeURIComponent,
setTimeout: () => 0, clearTimeout: () => {},
setInterval: () => 0, clearInterval: () => {},
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
requestAnimationFrame: (cb) => 0,
cancelAnimationFrame: () => {},
localStorage: (() => {
const s = {};
return {
getItem: k => s[k] !== undefined ? s[k] : null,
setItem: (k, v) => { s[k] = String(v); },
removeItem: k => { delete s[k]; },
};
})(),
location: { hash: '', protocol: 'https:', host: 'localhost' },
CustomEvent: class CustomEvent {},
addEventListener: () => {}, dispatchEvent: () => {},
getComputedStyle: () => ({ getPropertyValue: () => '' }),
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
navigator: {}, visualViewport: null,
MutationObserver: function() { this.observe=()=>{}; this.disconnect=()=>{}; },
WebSocket: function() { this.close=()=>{}; },
IATA_COORDS_GEO: {},
L: {
circleMarker: () => ({addTo(){return this;},bindTooltip(){return this;},on(){return this;},setRadius(){},setStyle(){},setLatLng(){},getLatLng(){return{lat:0,lng:0};},remove(){}}),
polyline: () => ({addTo(){return this;},setStyle(){},remove(){}}),
polygon: () => ({addTo(){return this;},remove(){}}),
map: () => ({setView(){return this;},addLayer(){return this;},on(){return this;},getZoom(){return 11;},getCenter(){return{lat:0,lng:0};},getBounds(){return{contains:()=>true};},fitBounds(){return this;},invalidateSize(){},remove(){},hasLayer(){return false;},removeLayer(){}}),
layerGroup: () => ({addTo(){return this;},addLayer(){},removeLayer(){},clearLayers(){},hasLayer(){return true;},eachLayer(){}}),
tileLayer: () => ({addTo(){return this;}}),
control: { attribution: () => ({addTo(){}}) },
DomUtil: { addClass(){}, removeClass(){} },
},
registerPage: () => {}, onWS: () => {}, offWS: () => {}, connectWS: () => {},
api: () => Promise.resolve([]), invalidateApiCache: () => {},
favStar: () => '', bindFavStars: () => {},
getFavorites: () => [], isFavorite: () => false,
HopResolver: { init(){}, resolve: () => ({}), ready: () => false },
MeshAudio: null,
RegionFilter: { init(){}, getSelected: () => null, onChange: () => {}, offChange: () => {}, regionQueryString: () => '', getRegionParam: () => '' },
};
vm.createContext(ctx);
return ctx;
}
function load(ctx, file) {
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
console.log('\n=== live.js: region filter (#1045) ===');
const ctx = makeSandbox();
load(ctx, 'public/roles.js');
load(ctx, 'public/live.js');
const fn = ctx.window._livePacketMatchesRegion;
assert.ok(fn, '_livePacketMatchesRegion must be exposed');
const obsMap = { 'obs1': 'SJC', 'obs2': 'SFO', 'obs3': 'PDX' };
test('returns true when no regions selected (filter inactive)', () => {
assert.strictEqual(fn([{observer_id:'obs1'}], obsMap, null), true);
assert.strictEqual(fn([{observer_id:'obs1'}], obsMap, []), true);
});
test('matches when single observation observer is in selected region', () => {
assert.strictEqual(fn([{observer_id:'obs1'}], obsMap, ['SJC']), true);
});
test('does not match when observer is in different region', () => {
assert.strictEqual(fn([{observer_id:'obs1'}], obsMap, ['PDX']), false);
});
test('matches if ANY observation is in selected region (OR across observations)', () => {
assert.strictEqual(fn([{observer_id:'obs1'}, {observer_id:'obs3'}], obsMap, ['PDX']), true);
});
test('matches if observer iata is in any of multiple selected regions', () => {
assert.strictEqual(fn([{observer_id:'obs2'}], obsMap, ['SJC','SFO']), true);
});
test('does not match when observer_id is unknown', () => {
assert.strictEqual(fn([{observer_id:'ghost'}], obsMap, ['SJC']), false);
});
test('does not match when packet has no observer_id', () => {
assert.strictEqual(fn([{}], obsMap, ['SJC']), false);
});
test('region match is case-insensitive', () => {
assert.strictEqual(fn([{observer_id:'obs1'}], obsMap, ['sjc']), true);
});
const setMap = ctx.window._liveSetObserverIataMap;
assert.ok(setMap, '_liveSetObserverIataMap must be exposed');
test('observer iata map can be updated and used by filter', () => {
setMap({ 'newobs': 'LAX' });
// Now the live module should consult the new map. The helper accepts the map
// explicitly (pure), so we test the same function with the new mapping:
assert.strictEqual(fn([{observer_id:'newobs'}], { 'newobs': 'LAX' }, ['LAX']), true);
});
console.log(`\n${'═'.repeat(40)}`);
console.log(` live region filter tests: ${passed} passed, ${failed} failed`);
console.log(`${'═'.repeat(40)}\n`);
if (failed > 0) process.exit(1);
+142
View File
@@ -0,0 +1,142 @@
/* Unit tests for map.js clustering integration (issue #1036)
*
* Verifies:
* - makeClusterIcon produces a divIcon HTML containing the total + per-role pills
* - createClusterGroup instantiates an L.MarkerClusterGroup with the required options
* - The cluster group accepts markers via addLayer
*
* Tests run in a jsdom-free vm sandbox with a tiny Leaflet/Leaflet.markercluster
* shim so we exercise our integration code (not the library itself).
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
// ---- Tiny Leaflet shim ----
function makeLeafletShim() {
const L = {};
L.point = (x, y) => ({ x, y });
L.latLng = (a, b) => ({ lat: a, lng: b });
L.divIcon = (opts) => ({ _isDivIcon: true, options: opts, html: opts.html, className: opts.className });
L.layerGroup = () => {
const g = { _layers: [], addLayer(m){ this._layers.push(m); return this; }, removeLayer(m){ const i=this._layers.indexOf(m); if(i>=0) this._layers.splice(i,1); return this; }, clearLayers(){ this._layers=[]; return this; }, eachLayer(fn){ this._layers.forEach(fn); }, addTo(){ return this; }, hasLayer(m){ return this._layers.includes(m); } };
return g;
};
L.marker = (latlng, opts) => ({ _isMarker: true, _latlng: latlng, options: opts || {}, getLatLng(){ return this._latlng; }, bindPopup(){ return this; }, bindTooltip(){ return this; } });
// markercluster shim
function MarkerClusterGroup(opts) {
this.options = opts || {};
this._layers = [];
this._isClusterGroup = true;
}
MarkerClusterGroup.prototype.addLayer = function (m) { this._layers.push(m); return this; };
MarkerClusterGroup.prototype.addLayers = function (ms) { ms.forEach(m => this._layers.push(m)); return this; };
MarkerClusterGroup.prototype.removeLayer = function (m) { const i=this._layers.indexOf(m); if(i>=0) this._layers.splice(i,1); return this; };
MarkerClusterGroup.prototype.clearLayers = function () { this._layers = []; return this; };
MarkerClusterGroup.prototype.eachLayer = function (fn) { this._layers.forEach(fn); };
MarkerClusterGroup.prototype.hasLayer = function (m) { return this._layers.includes(m); };
MarkerClusterGroup.prototype.addTo = function () { return this; };
MarkerClusterGroup.prototype.getLayers = function () { return this._layers.slice(); };
L.MarkerClusterGroup = MarkerClusterGroup;
L.markerClusterGroup = (opts) => new MarkerClusterGroup(opts);
return L;
}
function makeSandbox() {
const ctx = {
window: {},
document: { addEventListener(){}, getElementById(){ return null; }, querySelector(){ return null; }, querySelectorAll(){ return []; }, createElement(){ return { id:'', textContent:'', innerHTML:'', appendChild(){}, addEventListener(){}, setAttribute(){}, classList:{add(){},remove(){},toggle(){}} }; }, head: { appendChild(){} }, body: { appendChild(){} } },
console, Date, Math, Array, Object, String, Number, JSON, RegExp, Error,
parseInt, parseFloat, isFinite, isNaN, Map, Set, Promise,
setTimeout: ()=>{}, clearTimeout: ()=>{}, setInterval: ()=>{}, clearInterval: ()=>{},
registerPage: () => {}, esc: (s) => s, onWS: () => {}, offWS: () => {},
localStorage: (() => { const s={}; return { getItem:k=>s[k]||null, setItem:(k,v)=>{s[k]=String(v);}, removeItem:k=>{delete s[k];} }; })(),
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
addEventListener(){}, dispatchEvent(){},
L: makeLeafletShim(),
};
ctx.window.L = ctx.L;
vm.createContext(ctx);
// Load roles for ROLE_COLORS palette
vm.runInContext(fs.readFileSync('public/roles.js','utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
// Load map.js (IIFE — exposes test hooks via window.__meshcoreMapInternals)
vm.runInContext(fs.readFileSync('public/map.js','utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
return ctx;
}
console.log('\n=== map.js: clustering ===');
{
const ctx = makeSandbox();
const internals = ctx.window.__meshcoreMapInternals;
test('exposes test hooks (__meshcoreMapInternals)', () => {
assert.ok(internals, 'window.__meshcoreMapInternals not exposed by map.js');
assert.ok(typeof internals.makeClusterIcon === 'function', 'makeClusterIcon not exported');
assert.ok(typeof internals.createClusterGroup === 'function', 'createClusterGroup not exported');
});
test('createClusterGroup returns an L.MarkerClusterGroup with required options', () => {
const g = internals.createClusterGroup();
assert.ok(g, 'createClusterGroup returned falsy');
assert.ok(g instanceof ctx.L.MarkerClusterGroup, 'expected L.MarkerClusterGroup instance');
assert.strictEqual(g.options.chunkedLoading, true, 'chunkedLoading should be true');
assert.strictEqual(g.options.removeOutsideVisibleBounds, true, 'removeOutsideVisibleBounds should be true');
assert.strictEqual(g.options.disableClusteringAtZoom, 16, 'disableClusteringAtZoom should be 16');
assert.strictEqual(g.options.spiderfyOnMaxZoom, true, 'spiderfyOnMaxZoom should be true');
assert.strictEqual(typeof g.options.iconCreateFunction, 'function', 'iconCreateFunction should be set');
});
test('cluster group accepts markers via addLayer', () => {
const g = internals.createClusterGroup();
const m1 = ctx.L.marker(ctx.L.latLng(37.7, -122.4));
const m2 = ctx.L.marker(ctx.L.latLng(37.8, -122.5));
g.addLayer(m1);
g.addLayer(m2);
assert.strictEqual(g.getLayers().length, 2, 'cluster group should hold added markers');
});
test('makeClusterIcon: includes total count and role-pill counts', () => {
const markers = [
{ _role: 'repeater' }, { _role: 'repeater' }, { _role: 'repeater' },
{ _role: 'companion' }, { _role: 'companion' },
{ _role: 'room' },
];
const cluster = { getAllChildMarkers: () => markers, getChildCount: () => markers.length };
const icon = internals.makeClusterIcon(cluster);
assert.ok(icon && icon._isDivIcon, 'expected an L.divIcon');
const html = icon.html || '';
assert.ok(/>6</.test(html) || html.indexOf('>6<') >= 0, `total count 6 not in html: ${html}`);
// Role pill counts should appear
assert.ok(html.indexOf('>3<') >= 0, `repeater pill (3) not in html: ${html}`);
assert.ok(html.indexOf('>2<') >= 0, `companion pill (2) not in html: ${html}`);
assert.ok(html.indexOf('>1<') >= 0, `room pill (1) not in html: ${html}`);
// CoreScope-themed wrapper class
assert.ok((icon.className || '').indexOf('mc-cluster') >= 0, `expected mc-cluster class, got: ${icon.className}`);
});
test('makeClusterIcon: bucket sm/md/lg by total', () => {
const mk = (n, role='companion') => Array.from({length:n}, () => ({ _role: role }));
function clusterOf(n) { const ms = mk(n); return { getAllChildMarkers: () => ms, getChildCount: () => n }; }
const small = internals.makeClusterIcon(clusterOf(5));
const med = internals.makeClusterIcon(clusterOf(40));
const large = internals.makeClusterIcon(clusterOf(150));
assert.ok(/mc-sm/.test(small.html || small.className || ''), 'small bucket missing');
assert.ok(/mc-md/.test(med.html || med.className || ''), 'medium bucket missing');
assert.ok(/mc-lg/.test(large.html || large.className || ''), 'large bucket missing');
});
}
if (failed > 0) {
console.log(`\n${failed} test(s) failed, ${passed} passed`);
process.exit(1);
}
console.log(`\nAll ${passed} test(s) passed`);
+67
View File
@@ -0,0 +1,67 @@
/* test-observers-headings.js Issue #1039 regression test.
* Asserts observer table thead column count matches tbody row column count.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}\n ${e.message}`); }
}
function extractBlock(s, openRe, closeRe) {
const m = s.match(openRe);
if (!m) throw new Error('open marker not found');
const start = m.index + m[0].length;
const rest = s.slice(start);
const cm = rest.match(closeRe);
if (!cm) throw new Error('close marker not found');
return rest.slice(0, cm.index);
}
console.log('── Observers table headings (#1039) ──');
test('thead column count equals tbody row column count', () => {
const thead = extractBlock(src, /<thead><tr>/, /<\/tr><\/thead>/);
const thCount = (thead.match(/<th\b/g) || []).length;
// tbody row template lives inside a backtick-template `<tr ...>...</tr>`.
// Grab from the first `<tr ` after `tbody>` up to the first `</tr>`.
const tbodyStart = src.indexOf('<tbody>');
assert.ok(tbodyStart > 0, '<tbody> not found in observers.js');
const after = src.slice(tbodyStart);
const trOpen = after.search(/`<tr\b/);
assert.ok(trOpen > 0, 'row template `<tr` not found');
const rowStart = trOpen;
const rowEnd = after.indexOf('</tr>', rowStart);
assert.ok(rowEnd > rowStart, '</tr> not found in row template');
const row = after.slice(rowStart, rowEnd);
const tdCount = (row.match(/<td\b/g) || []).length;
assert.strictEqual(
tdCount, thCount,
`Observer table column mismatch: ${thCount} <th> headings vs ${tdCount} <td> cells per row. ` +
`Headings drift after "Last Packet" — see issue #1039.`
);
});
test('expected headings present and ordered', () => {
const thead = extractBlock(src, /<thead><tr>/, /<\/tr><\/thead>/);
const labels = [];
const re = /<th[^>]*>([^<]+)<\/th>/g;
let m;
while ((m = re.exec(thead)) !== null) labels.push(m[1].trim());
const expected = ['Status', 'Name', 'Region', 'Last Status', 'Last Packet',
'Packet Health', 'Total Packets', 'Packets/Hour', 'Clock Offset', 'Uptime'];
assert.deepStrictEqual(labels, expected,
`Headings out of sync.\nGot: ${JSON.stringify(labels)}\nExpected: ${JSON.stringify(expected)}`);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
+116
View File
@@ -0,0 +1,116 @@
/* Unit tests for packet filter timestamp predicates (issue #289) */
'use strict';
const vm = require('vm');
const fs = require('fs');
const code = fs.readFileSync('public/packet-filter.js', 'utf8');
const ctx = { window: {}, console };
vm.createContext(ctx);
vm.runInContext(code, ctx);
const PF = ctx.window.PacketFilter;
let pass = 0, fail = 0;
function test(name, fn) {
try { fn(); pass++; }
catch (e) { console.log(`FAIL: ${name}${e.message}`); fail++; }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); }
const NOW = Date.now();
function isoOffset(ms) { return new Date(NOW - ms).toISOString(); }
// Packets at known offsets
const pkt30m = { payload_type: 4, timestamp: isoOffset(30 * 60 * 1000) }; // 30 minutes ago
const pkt2h = { payload_type: 4, timestamp: isoOffset(2 * 60 * 60 * 1000) }; // 2 hours ago
const pkt2d = { payload_type: 4, timestamp: isoOffset(2 * 24 * 60 * 60 * 1000) }; // 2 days ago
const pkt2024 = { payload_type: 4, timestamp: '2024-06-15T12:00:00Z' };
const pkt2023 = { payload_type: 4, timestamp: '2023-06-15T12:00:00Z' };
const pkt2025 = { payload_type: 4, timestamp: '2025-06-15T12:00:00Z' };
// --- after / before on time field ---
test('time after 2024-01-01 matches 2024 packet', () => {
assert(PF.compile('time after "2024-01-01"').filter(pkt2024));
});
test('time after 2024-01-01 rejects 2023 packet', () => {
assert(!PF.compile('time after "2024-01-01"').filter(pkt2023));
});
test('time before 2024-12-31 matches 2024 packet', () => {
assert(PF.compile('time before "2024-12-31"').filter(pkt2024));
});
test('time before 2024-12-31 rejects 2025 packet', () => {
assert(!PF.compile('time before "2024-12-31"').filter(pkt2025));
});
// --- between on time field ---
test('time between matches packet inside range', () => {
assert(PF.compile('time between "2024-01-01" "2024-12-31"').filter(pkt2024));
});
test('time between rejects packet outside range', () => {
assert(!PF.compile('time between "2024-01-01" "2024-12-31"').filter(pkt2023));
assert(!PF.compile('time between "2024-01-01" "2024-12-31"').filter(pkt2025));
});
// --- ISO datetime with time component ---
test('time after with full ISO datetime', () => {
assert(PF.compile('time after "2024-06-15T00:00:00Z"').filter(pkt2024));
assert(!PF.compile('time after "2024-06-16T00:00:00Z"').filter(pkt2024));
});
// --- age with relative durations ---
test('age < 1h matches 30-min-old packet', () => {
assert(PF.compile('age < 1h').filter(pkt30m));
});
test('age < 1h rejects 2-hour-old packet', () => {
assert(!PF.compile('age < 1h').filter(pkt2h));
});
test('age > 1h matches 2-hour-old packet', () => {
assert(PF.compile('age > 1h').filter(pkt2h));
});
test('age > 24h matches 2-day-old packet', () => {
assert(PF.compile('age > 24h').filter(pkt2d));
});
test('age > 24h rejects 30-min-old packet', () => {
assert(!PF.compile('age > 24h').filter(pkt30m));
});
test('age < 7d matches 2-day-old packet', () => {
assert(PF.compile('age < 7d').filter(pkt2d));
});
test('age units: m (minutes)', () => {
assert(PF.compile('age > 15m').filter(pkt30m));
assert(!PF.compile('age > 60m').filter(pkt30m));
});
test('age units: s (seconds)', () => {
assert(PF.compile('age > 60s').filter(pkt30m));
});
// --- combining time predicates with logic ---
test('age < 1h && type == ADVERT', () => {
assert(PF.compile('age < 1h && type == ADVERT').filter(pkt30m));
assert(!PF.compile('age < 1h && type == ADVERT').filter(pkt2h));
});
// --- null timestamp safety ---
test('time predicate on packet without timestamp → false', () => {
assert(!PF.compile('time after "2024-01-01"').filter({ payload_type: 4 }));
assert(!PF.compile('age < 1h').filter({ payload_type: 4 }));
assert(!PF.compile('time between "2024-01-01" "2024-12-31"').filter({ payload_type: 4 }));
});
// --- error handling ---
test('invalid datetime → error', () => {
const c = PF.compile('time after "not-a-date"');
assert(c.error !== null, 'should error on invalid date');
});
test('invalid duration → error', () => {
const c = PF.compile('age < 1xyz');
assert(c.error !== null, 'should error on invalid duration');
});
// --- packets.first_seen fallback ---
test('time field falls back to first_seen when timestamp missing', () => {
const p = { payload_type: 4, first_seen: '2024-06-15T12:00:00Z' };
assert(PF.compile('time after "2024-01-01"').filter(p));
});
console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`);
process.exit(fail > 0 ? 1 : 0);
+190
View File
@@ -0,0 +1,190 @@
/* Unit tests for filter UX helpers: PacketFilter metadata + autocomplete +
* SavedFilters store (issue #966). Pure-logic only DOM exercised by E2E.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
function loadInCtx(files, ctx) {
for (const f of files) vm.runInContext(fs.readFileSync(f, 'utf8'), ctx);
}
// Fake DOM-less window with a localStorage shim so filter-ux.js can be loaded
// in Node without touching the document object model.
function makeCtx() {
const store = {};
const ls = {
getItem: k => Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null,
setItem: (k, v) => { store[k] = String(v); },
removeItem: k => { delete store[k]; },
clear: () => { for (const k of Object.keys(store)) delete store[k]; },
};
// Minimal document stub — filter-ux.js init() must early-exit when DOM missing.
const doc = { getElementById: () => null, addEventListener: () => {}, body: null };
const win = { localStorage: ls, document: doc, addEventListener: () => {} };
const ctx = { window: win, document: doc, localStorage: ls, console };
vm.createContext(ctx);
return ctx;
}
let pass = 0, fail = 0;
function test(name, fn) {
try { fn(); pass++; console.log(' ✓ ' + name); }
catch (e) { console.log(' ✗ ' + name + ' — ' + e.message); fail++; }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
const ctx = makeCtx();
loadInCtx(['public/packet-filter.js', 'public/filter-ux.js'], ctx);
const PF = ctx.window.PacketFilter;
const UX = ctx.window.FilterUX;
console.log('\n=== #966 filter-UX unit tests ===');
// ── Metadata exposed by PacketFilter ──────────────────────────────────────
test('PacketFilter.FIELDS exposes top-level fields', () => {
assert(Array.isArray(PF.FIELDS), 'FIELDS is array');
const names = PF.FIELDS.map(f => f.name);
for (const want of ['type', 'route', 'snr', 'rssi', 'hops', 'observer', 'hash', 'size', 'age']) {
assert(names.includes(want), 'FIELDS missing ' + want);
}
for (const f of PF.FIELDS) { assert(typeof f.desc === 'string' && f.desc.length, 'desc required for ' + f.name); }
});
test('PacketFilter.OPERATORS lists comparison operators with examples', () => {
assert(Array.isArray(PF.OPERATORS), 'OPERATORS array');
const ops = PF.OPERATORS.map(o => o.op);
for (const want of ['==', '!=', '>', '<', '>=', '<=', 'contains', 'starts_with']) {
assert(ops.includes(want), 'OPERATORS missing ' + want);
}
for (const o of PF.OPERATORS) { assert(typeof o.example === 'string' && o.example.length, 'example required for ' + o.op); }
});
test('PacketFilter.TYPE_VALUES exposes canonical type names', () => {
assert(Array.isArray(PF.TYPE_VALUES));
for (const want of ['ADVERT', 'GRP_TXT', 'GRP_DATA', 'TXT_MSG', 'ACK']) {
assert(PF.TYPE_VALUES.includes(want), 'TYPE_VALUES missing ' + want);
}
});
test('PacketFilter.ROUTE_VALUES exposes route names', () => {
assert(Array.isArray(PF.ROUTE_VALUES));
for (const want of ['FLOOD', 'DIRECT', 'TRANSPORT_FLOOD', 'TRANSPORT_DIRECT']) {
assert(PF.ROUTE_VALUES.includes(want), 'ROUTE_VALUES missing ' + want);
}
});
// ── Autocomplete suggestions ──────────────────────────────────────────────
test('suggest() on empty input returns top-level fields', () => {
const r = PF.suggest('', 0);
assert(r && Array.isArray(r.suggestions), 'returns object with suggestions');
const vals = r.suggestions.map(s => s.value);
assert(vals.includes('type'), 'suggests type');
assert(vals.includes('snr'), 'suggests snr');
});
test('suggest() prefix-matches field names', () => {
const r = PF.suggest('pay', 3);
const vals = r.suggestions.map(s => s.value);
// payload.* aliases or payload_bytes/payload_hex should surface
assert(vals.some(v => v.startsWith('payload')), 'no payload* suggestion: ' + vals.join(','));
assert(r.replaceStart === 0 && r.replaceEnd === 3, 'replace range covers prefix');
});
test('suggest() after `type ==` lists type values', () => {
const r = PF.suggest('type == ', 8);
const vals = r.suggestions.map(s => s.value);
assert(vals.includes('ADVERT'), 'ADVERT in type values');
assert(vals.includes('GRP_TXT'), 'GRP_TXT in type values');
});
test('suggest() after `type == AD` filters type values', () => {
const r = PF.suggest('type == AD', 10);
const vals = r.suggestions.map(s => s.value);
assert(vals.includes('ADVERT'), 'ADVERT matches AD prefix');
assert(!vals.includes('GRP_TXT'), 'GRP_TXT filtered out');
});
test('suggest() after `route ==` lists route values', () => {
const r = PF.suggest('route == ', 9);
const vals = r.suggestions.map(s => s.value);
assert(vals.includes('FLOOD'), 'FLOOD route');
assert(vals.includes('DIRECT'), 'DIRECT route');
});
test('suggest() after operator suggests operators when no field given yet (no crash)', () => {
const r = PF.suggest('snr ', 4);
const vals = r.suggestions.map(s => s.value);
assert(vals.includes('>') || vals.includes('==') || vals.includes('<'), 'op suggested: ' + vals.join(','));
});
test('suggest() includes payload.* keys from dynamic discovery', () => {
const r = PF.suggest('payload.', 8, { payloadKeys: ['name', 'lat', 'channelHash'] });
const vals = r.suggestions.map(s => s.value);
assert(vals.includes('payload.name'), 'payload.name from dynamic keys');
assert(vals.includes('payload.lat'), 'payload.lat from dynamic keys');
});
// ── Improved parse-error positioning ──────────────────────────────────────
test('error message cites position for unknown character', () => {
const r = PF.parse('snr @ 5');
assert(r.error && /position/i.test(r.error), 'error should cite position: ' + r.error);
});
// ── Saved filters store ───────────────────────────────────────────────────
test('SavedFilters.defaults() returns at least 5 starter filters', () => {
const d = UX.SavedFilters.defaults();
assert(Array.isArray(d) && d.length >= 5, 'defaults length ≥ 5: ' + (d && d.length));
for (const f of d) { assert(f.name && f.expr, 'each default has name + expr'); }
});
test('SavedFilters.list() includes defaults when nothing saved', () => {
ctx.window.localStorage.clear();
const list = UX.SavedFilters.list();
assert(list.length >= 5, 'list seeded with defaults');
});
test('SavedFilters.save() persists to localStorage and survives list()', () => {
ctx.window.localStorage.clear();
UX.SavedFilters.save('my filter', 'snr > 10');
const list = UX.SavedFilters.list();
const found = list.find(f => f.name === 'my filter' && f.expr === 'snr > 10');
assert(found, 'saved filter present in list');
// Must persist to LS, not memory
const raw = ctx.window.localStorage.getItem('corescope_saved_filters_v1');
assert(raw && raw.includes('snr > 10'), 'persisted to LS: ' + raw);
});
test('SavedFilters.delete() removes user filter but keeps defaults', () => {
ctx.window.localStorage.clear();
UX.SavedFilters.save('temp', 'hops > 0');
UX.SavedFilters.delete('temp');
const list = UX.SavedFilters.list();
assert(!list.find(f => f.name === 'temp'), 'temp removed');
assert(list.length >= 5, 'defaults still present');
});
test('SavedFilters.save() overwrites existing user filter with same name', () => {
ctx.window.localStorage.clear();
UX.SavedFilters.save('x', 'snr > 1');
UX.SavedFilters.save('x', 'snr > 99');
const list = UX.SavedFilters.list();
const matches = list.filter(f => f.name === 'x');
assert(matches.length === 1, 'no duplicate name');
assert(matches[0].expr === 'snr > 99', 'overwritten to latest');
});
// ── Filter-by-cell helper (for right-click) ───────────────────────────────
test('buildCellFilterClause() emits field == "value" with quoting for strings', () => {
assert(UX.buildCellFilterClause('observer', 'Dorrington', '==') === 'observer == "Dorrington"');
assert(UX.buildCellFilterClause('snr', '8.5', '==') === 'snr == 8.5');
assert(UX.buildCellFilterClause('type', 'ADVERT', '==') === 'type == ADVERT');
});
test('appendClauseToExpr() appends with && when expr present', () => {
assert(UX.appendClauseToExpr('', 'snr > 5') === 'snr > 5');
assert(UX.appendClauseToExpr('type == ADVERT', 'snr > 5') === 'type == ADVERT && snr > 5');
});
console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`);
process.exit(fail > 0 ? 1 : 0);
+225
View File
@@ -0,0 +1,225 @@
/* test-pull-to-reconnect.js behavioral tests for pull-to-reconnect (#1063)
* Loads app.js in a vm sandbox, stubs WebSocket + DOM, asserts that:
* - pullReconnect() exists as a global helper
* - calling it closes the existing WS (which triggers the existing
* auto-reconnect path)
* - setupPullToReconnect() exists and wires touchstart/touchmove/touchend
* listeners on the document
* - a pull-down gesture at scrollTop=0 over the threshold triggers
* pullReconnect
* - a touch when scrollTop > 0 does NOT trigger pullReconnect (don't
* hijack normal scrolling)
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
console.log('--- test-pull-to-reconnect.js ---');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}\n ${e.stack.split('\n').slice(1, 3).join('\n ')}`); }
}
function makeSandbox(opts) {
opts = opts || {};
const listeners = {}; // event name -> [fn]
const elements = {};
function makeEl(id) {
const el = {
id, textContent: '', innerHTML: '', value: '',
style: {}, dataset: {},
_classes: new Set(),
classList: {
add: function() { for (const a of arguments) el._classes.add(a); },
remove: function() { for (const a of arguments) el._classes.delete(a); },
toggle: function(c) { if (el._classes.has(c)) el._classes.delete(c); else el._classes.add(c); },
contains: function(c) { return el._classes.has(c); },
},
addEventListener: function(ev, fn) { (el['_on_' + ev] = el['_on_' + ev] || []).push(fn); },
removeEventListener: function() {},
setAttribute: function() {}, getAttribute: function() { return null; },
appendChild: function(child) { (el._children = el._children || []).push(child); return child; },
remove: function() {},
querySelector: function() { return null; },
querySelectorAll: function() { return []; },
};
elements[id] = el;
return el;
}
// Pre-create elements app.js touches at WS time
makeEl('liveDot');
// Stub WebSocket — track instances + close calls
const wsInstances = [];
function FakeWS(url) {
this.url = url;
this.readyState = 1; // OPEN
this.closed = false;
this.onopen = null; this.onclose = null; this.onerror = null; this.onmessage = null;
wsInstances.push(this);
// simulate immediate open so onopen fires synchronously isn't required;
// tests will invoke handlers directly when needed.
}
FakeWS.prototype.close = function() {
this.closed = true;
if (typeof this.onclose === 'function') this.onclose({});
};
FakeWS.prototype.send = function() {};
const body = makeEl('body');
const ctx = {
console,
setTimeout: function(fn, ms) { return 0; }, // suppress reconnect loop
clearTimeout: function() {},
setInterval: function() { return 0; },
clearInterval: function() {},
Date, Math, JSON, Object, Array, String, Number, Boolean,
Error, RegExp, Map, Set, Symbol, Promise,
requestAnimationFrame: function(fn) { return 0; },
performance: { now: function() { return 0; } },
location: { protocol: 'http:', host: 'localhost', hash: '' },
navigator: { userAgent: 'test' },
WebSocket: FakeWS,
fetch: function() { return Promise.resolve({ ok: true, json: function() { return Promise.resolve({}); } }); },
localStorage: {
_data: {},
getItem: function(k) { return this._data[k] || null; },
setItem: function(k, v) { this._data[k] = String(v); },
removeItem: function(k) { delete this._data[k]; },
},
document: {
readyState: 'complete',
documentElement: { scrollTop: opts.scrollTop || 0, style: { setProperty: function() {} }, setAttribute: function() {}, getAttribute: function() { return null; } },
body: body,
head: { appendChild: function() {} },
createElement: function(tag) { return makeEl(tag); },
getElementById: function(id) { return elements[id] || null; },
querySelector: function() { return null; },
querySelectorAll: function() { return []; },
addEventListener: function(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); },
removeEventListener: function() {},
dispatchEvent: function(e) { (listeners[e.type] || []).forEach(function(fn) { fn(e); }); return true; },
},
window: {
addEventListener: function() {}, removeEventListener: function() {}, dispatchEvent: function() {},
matchMedia: function() { return { matches: false, addEventListener: function() {} }; },
ontouchstart: opts.touch === false ? undefined : null,
},
CustomEvent: function(type, init) { this.type = type; this.detail = (init || {}).detail; },
};
ctx.window.location = ctx.location;
ctx.window.localStorage = ctx.localStorage;
ctx.window.document = ctx.document;
ctx.self = ctx.window;
ctx.globalThis = ctx;
vm.createContext(ctx);
return { ctx, elements, wsInstances, listeners };
}
function loadApp(box) {
const src = fs.readFileSync('public/app.js', 'utf8');
vm.runInContext(src, box.ctx);
}
console.log('\n=== pullReconnect helper exists ===');
test('pullReconnect is exposed on window', () => {
const box = makeSandbox();
loadApp(box);
assert.strictEqual(typeof box.ctx.window.pullReconnect, 'function',
'window.pullReconnect must be a function');
});
console.log('\n=== setupPullToReconnect exists ===');
test('setupPullToReconnect is exposed on window', () => {
const box = makeSandbox();
loadApp(box);
assert.strictEqual(typeof box.ctx.window.setupPullToReconnect, 'function',
'window.setupPullToReconnect must be a function');
});
console.log('\n=== pullReconnect closes existing WS ===');
test('calling pullReconnect() closes the current WebSocket', () => {
const box = makeSandbox();
loadApp(box);
// app.js does NOT call connectWS until DOMContentLoaded. Force one:
box.ctx.window.connectWS && box.ctx.window.connectWS();
// If app.js doesn't expose connectWS, fall back to invoking pullReconnect
// and checking that something tries to open a new socket.
const beforeCount = box.wsInstances.length;
box.ctx.window.pullReconnect();
// Either: existing WS got closed, OR a new WS was opened (reconnect)
const closed = box.wsInstances.some(function(w) { return w.closed; });
const opened = box.wsInstances.length > beforeCount;
assert.ok(closed || opened,
'pullReconnect must close the WS or open a new one (got closed=' + closed + ', opened=' + opened + ')');
});
console.log('\n=== setupPullToReconnect wires document touch listeners ===');
test('setupPullToReconnect attaches touchstart listener', () => {
const box = makeSandbox();
loadApp(box);
box.ctx.window.setupPullToReconnect();
assert.ok((box.listeners['touchstart'] || []).length > 0,
'touchstart listener must be attached to document');
assert.ok((box.listeners['touchmove'] || []).length > 0,
'touchmove listener must be attached to document');
assert.ok((box.listeners['touchend'] || []).length > 0,
'touchend listener must be attached to document');
});
console.log('\n=== Pull gesture at scrollTop=0 triggers reconnect ===');
test('pull-down past threshold at scrollTop=0 triggers pullReconnect', () => {
const box = makeSandbox({ scrollTop: 0 });
loadApp(box);
box.ctx.window.connectWS && box.ctx.window.connectWS();
box.ctx.window.setupPullToReconnect();
let triggered = false;
const orig = box.ctx.window.pullReconnect;
box.ctx.window.pullReconnect = function() { triggered = true; return orig.apply(this, arguments); };
function fire(name, y) {
(box.listeners[name] || []).forEach(function(fn) {
fn({ touches: [{ clientY: y }], changedTouches: [{ clientY: y }], preventDefault: function() {}, type: name });
});
}
fire('touchstart', 10);
fire('touchmove', 100);
fire('touchmove', 200);
fire('touchend', 200);
assert.ok(triggered, 'pullReconnect must be called after pull > threshold at scrollTop=0');
});
console.log('\n=== Pull gesture when scrolled DOWN does NOT trigger ===');
test('pull when scrollTop > 0 does NOT trigger pullReconnect', () => {
const box = makeSandbox({ scrollTop: 500 });
loadApp(box);
box.ctx.window.connectWS && box.ctx.window.connectWS();
box.ctx.window.setupPullToReconnect();
let triggered = false;
box.ctx.window.pullReconnect = function() { triggered = true; };
function fire(name, y) {
(box.listeners[name] || []).forEach(function(fn) {
fn({ touches: [{ clientY: y }], changedTouches: [{ clientY: y }], preventDefault: function() {}, type: name });
});
}
fire('touchstart', 10);
fire('touchmove', 200);
fire('touchend', 200);
assert.strictEqual(triggered, false,
'pullReconnect must NOT fire when page is scrolled (scrollTop > 0)');
});
console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===\n');
process.exit(failed > 0 ? 1 : 0);
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env node
/* Issue #1060 / PR #1067 follow-up touch targets behavior test.
*
* MAJOR-2 from pr-polish review: the previous version of this file
* grep'd CSS strings, which is tautological it asserted that the
* source contained the literal characters that were just edited in.
* It would have passed even if the CSS was syntactically broken or
* if selectors didn't match any element on the real page.
*
* This rewrite loads public/style.css into a real Chromium page via
* Playwright with an iPhone-class touch emulation context, renders
* representative DOM samples for every selector we claim to harden,
* and reads getBoundingClientRect()/getComputedStyle() to assert the
* 48x48 minimum hit area. It also exercises the .sort-help tap-to-
* reveal flow (focus event must un-hide the .sort-help-tip) since
* MAJOR-1 is enforced both in markup (tabindex="0" in packets.js) and
* in CSS (:focus / :focus-within rule in the Touch Targets section).
*/
'use strict';
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const { chromium, devices } = require('playwright');
const REPO = __dirname;
const CSS = fs.readFileSync(path.join(REPO, 'public/style.css'), 'utf8');
// Selectors we claim to make 48x48. Each entry: [selector, tag, classes,
// optional inner-html]. Tag matters because some rules are scoped to
// `button.ch-item` and some only apply to specific input[type=...].
const BUTTON_SELECTORS = [
['.btn', 'button', 'btn'],
['.btn-icon', 'button', 'btn-icon'],
['.nav-btn', 'button', 'nav-btn'],
['.ch-icon-btn', 'button', 'ch-icon-btn'],
['.ch-remove-btn', 'button', 'ch-remove-btn'],
['.ch-share-btn', 'button', 'ch-share-btn'],
['.ch-gear-btn', 'button', 'ch-gear-btn'],
['.panel-close-btn', 'button', 'panel-close-btn'],
['.mc-jump-btn', 'button', 'mc-jump-btn'],
['button.ch-item', 'button', 'ch-item'],
['.btn-link', 'button', 'btn-link'],
['.col-toggle-btn', 'button', 'col-toggle-btn'],
['.filter-toggle-btn', 'button', 'filter-toggle-btn'],
['.ch-add-channel-btn', 'button', 'ch-add-channel-btn'],
['.ch-back-btn', 'button', 'ch-back-btn'],
['.ch-modal-btn-secondary','button', 'ch-modal-btn-secondary'],
['.ch-scroll-btn', 'button', 'ch-scroll-btn'],
['.chooser-btn', 'button', 'chooser-btn'],
['.clock-filter-btn', 'button', 'clock-filter-btn'],
['.compare-btn', 'button', 'compare-btn'],
['.copy-link-btn', 'button', 'copy-link-btn'],
['.alab-btn', 'button', 'alab-btn'],
];
// Form controls. min-WIDTH is not enforced on these (text fields legitimately
// span a wide column); we only require min-height: 48px.
const FIELD_SELECTORS = [
['select', 'select', '', '<option>x</option>'],
['input[type=text]', 'input', '', null, { type: 'text' }],
['input[type=search]', 'input', '', null, { type: 'search' }],
['input[type=number]', 'input', '', null, { type: 'number' }],
['input[type=email]', 'input', '', null, { type: 'email' }],
['input[type=password]', 'input', '', null, { type: 'password' }],
['input[type=tel]', 'input', '', null, { type: 'tel' }],
['input[type=url]', 'input', '', null, { type: 'url' }],
['input[type=date]', 'input', '', null, { type: 'date' }],
['input[type=time]', 'input', '', null, { type: 'time' }],
];
function buildSampleHtml() {
const buttons = BUTTON_SELECTORS
.map(([_, tag, cls]) => `<${tag} class="${cls}" data-sel="${cls}">x</${tag}>`)
.join('\n ');
const fields = FIELD_SELECTORS
.map(([sel, tag, cls, inner, attrs]) => {
const attrStr = attrs
? Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(' ')
: '';
const open = `<${tag} ${attrStr} data-sel="${sel.replace(/[\[\]=]/g, '_')}">`;
const close = tag === 'input' ? '' : `${inner || ''}</${tag}>`;
return open + close;
})
.join('\n ');
// .sort-help sample mirrors the markup the JS produces (post-fix):
// tabindex="0" so :focus-within can fire on touch tap.
return `<!doctype html>
<html><head><meta charset="utf-8">
<style>${CSS}</style>
</head><body>
<div id="harness" style="padding: 16px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start;">
${buttons}
${fields}
<span class="sort-help" id="sortHelp" tabindex="0" role="button" aria-label="Sort help">
<span class="sort-help-tip">Tip body</span>
</span>
</div>
</body></html>`;
}
async function run() {
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
// Allow the test to be skipped on hosts where Chromium cannot launch
// (e.g. some musl-libc dev boxes). CI uses standard glibc Ubuntu runners
// where this path is never taken. Set TOUCH_TARGETS_REQUIRE=1 to force
// a hard failure even when Chromium is unavailable.
if (process.env.TOUCH_TARGETS_REQUIRE === '1') throw err;
console.log(`test-touch-targets.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
// iPhone 13 has hasTouch:true, isMobile:true, no hover. Exactly the
// capability matrix that the @media (hover: hover) gate and 48px
// minimums are designed for.
const iPhone = devices['iPhone 13'];
const context = await browser.newContext({ ...iPhone });
const page = await context.newPage();
// Load the harness via a data: URL so we don't need a running server.
const html = buildSampleHtml();
await page.setContent(html, { waitUntil: 'load' });
if (page.evaluate) {
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
}
let failures = 0;
function record(name, ok, detail) {
if (ok) {
console.log(` \u2705 ${name}`);
} else {
console.log(` \u274c ${name}: ${detail}`);
failures++;
}
}
// --- Buttons: rendered hit area must be at least 48x48 CSS px.
for (const [selector, , cls] of BUTTON_SELECTORS) {
const dim = await page.$eval(`[data-sel="${cls}"]`, (el) => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return { w: r.width, h: r.height, mh: cs.minHeight, mw: cs.minWidth };
});
const okH = dim.h >= 48;
const okW = dim.w >= 48;
record(`${selector}: rendered ${dim.w.toFixed(1)}x${dim.h.toFixed(1)} (min ${dim.mw}/${dim.mh})`,
okH && okW,
`expected >=48x48, got ${dim.w}x${dim.h}`);
}
// --- Form controls: rendered height must be at least 48 CSS px.
for (const [selector, , , , attrs] of FIELD_SELECTORS) {
const dataKey = selector.replace(/[\[\]=]/g, '_');
const dim = await page.$eval(`[data-sel="${dataKey}"]`, (el) => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return { h: r.height, mh: cs.minHeight };
});
record(`${selector}: rendered height ${dim.h.toFixed(1)} (min ${dim.mh})`,
dim.h >= 48,
`expected height >=48, got ${dim.h}`);
}
// --- MAJOR-1 verification: .sort-help is keyboard/tap focusable AND the
// tooltip becomes visible on focus (tap-to-reveal works without hover).
const tabIndex = await page.$eval('#sortHelp', (el) => el.getAttribute('tabindex'));
record('.sort-help has tabindex="0" in markup', tabIndex === '0',
`expected "0", got ${JSON.stringify(tabIndex)}`);
const tipBeforeFocus = await page.$eval('#sortHelp .sort-help-tip',
(el) => getComputedStyle(el).display);
// CSS rule on touch-only viewport: hover-rule is gated, focus-rule reveals.
record('.sort-help-tip is hidden by default on touch', tipBeforeFocus === 'none',
`expected display:none initially, got ${tipBeforeFocus}`);
await page.focus('#sortHelp');
const tipAfterFocus = await page.$eval('#sortHelp .sort-help-tip',
(el) => getComputedStyle(el).display);
record('.sort-help-tip becomes visible on focus (tap-to-reveal)',
tipAfterFocus === 'block',
`expected display:block after focus, got ${tipAfterFocus}`);
// --- Hover-only rule must be gated behind @media (hover: hover) so that on
// touch the iPhone context never enters a "stuck hover" state when a tap
// toggles :hover. We assert this by reading the matchMedia value the page
// sees and confirming :hover did NOT take effect on tap.
const hoverCapable = await page.evaluate(() => matchMedia('(hover: hover)').matches);
record('iPhone context reports (hover: hover) = false', hoverCapable === false,
`expected false on touch device, got ${hoverCapable}`);
await browser.close();
if (failures > 0) {
console.log(`\ntest-touch-targets.js: FAIL (${failures} assertion(s))`);
process.exit(1);
}
console.log('\ntest-touch-targets.js: OK');
}
run().catch((err) => {
console.error('test-touch-targets.js: fatal', err);
process.exit(1);
});
+103
View File
@@ -0,0 +1,103 @@
/* Unit tests for URL state helpers (issue #749) */
'use strict';
const assert = require('assert');
const URLState = require('./public/url-state.js');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ✅ ' + name); }
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
}
console.log('── URL State Helpers ──');
// ------- parseSort -------
test('parseSort: column only defaults to desc', function () {
assert.deepStrictEqual(URLState.parseSort('time'), { column: 'time', direction: 'desc' });
});
test('parseSort: column:asc', function () {
assert.deepStrictEqual(URLState.parseSort('lastSeen:asc'), { column: 'lastSeen', direction: 'asc' });
});
test('parseSort: column:desc', function () {
assert.deepStrictEqual(URLState.parseSort('time:desc'), { column: 'time', direction: 'desc' });
});
test('parseSort: invalid direction → desc', function () {
assert.deepStrictEqual(URLState.parseSort('time:weird'), { column: 'time', direction: 'desc' });
});
test('parseSort: empty/null → null', function () {
assert.strictEqual(URLState.parseSort(''), null);
assert.strictEqual(URLState.parseSort(null), null);
assert.strictEqual(URLState.parseSort(undefined), null);
});
// ------- serializeSort -------
test('serializeSort: desc default omitted', function () {
assert.strictEqual(URLState.serializeSort('time', 'desc'), 'time');
});
test('serializeSort: asc included', function () {
assert.strictEqual(URLState.serializeSort('lastSeen', 'asc'), 'lastSeen:asc');
});
test('serializeSort: empty column → empty string', function () {
assert.strictEqual(URLState.serializeSort('', 'desc'), '');
assert.strictEqual(URLState.serializeSort(null, 'asc'), '');
});
// ------- parseHash -------
test('parseHash: bare route', function () {
assert.deepStrictEqual(URLState.parseHash('#/packets'), { route: 'packets', params: {} });
});
test('parseHash: route with params', function () {
var r = URLState.parseHash('#/packets?filter=type%3D%3DADVERT&sort=time');
assert.strictEqual(r.route, 'packets');
assert.strictEqual(r.params.filter, 'type==ADVERT');
assert.strictEqual(r.params.sort, 'time');
});
test('parseHash: route with subpath kept (existing deep links)', function () {
var r = URLState.parseHash('#/nodes/abc123def?tab=repeaters');
assert.strictEqual(r.route, 'nodes/abc123def');
assert.strictEqual(r.params.tab, 'repeaters');
});
test('parseHash: empty hash', function () {
assert.deepStrictEqual(URLState.parseHash(''), { route: '', params: {} });
assert.deepStrictEqual(URLState.parseHash('#/'), { route: '', params: {} });
});
// ------- buildHash -------
test('buildHash: bare route', function () {
assert.strictEqual(URLState.buildHash('packets', {}), '#/packets');
});
test('buildHash: with params, omits empty values', function () {
var h = URLState.buildHash('packets', { filter: 'type==ADVERT', sort: '', empty: null, blank: undefined });
assert.strictEqual(h, '#/packets?filter=type%3D%3DADVERT');
});
test('buildHash: encodes special chars', function () {
var h = URLState.buildHash('analytics', { tab: 'topology', window: '7d' });
// Order is preserved in object iteration
assert.ok(h === '#/analytics?tab=topology&window=7d' || h === '#/analytics?window=7d&tab=topology');
});
test('buildHash: leading "#/" is OK on route, normalized', function () {
assert.strictEqual(URLState.buildHash('#/packets', { sort: 'time' }), '#/packets?sort=time');
});
// ------- updateHashParams -------
test('updateHashParams: round-trip preserves route subpath', function () {
// Simulate location.hash environment
var fakeLocation = { hash: '#/nodes/abcdef?tab=repeaters' };
var newHash = URLState.updateHashParams({ sort: 'lastSeen:asc' }, fakeLocation.hash);
// Must keep the nodes/abcdef subpath
var r = URLState.parseHash(newHash);
assert.strictEqual(r.route, 'nodes/abcdef');
assert.strictEqual(r.params.tab, 'repeaters');
assert.strictEqual(r.params.sort, 'lastSeen:asc');
});
test('updateHashParams: setting empty/null removes key', function () {
var newHash = URLState.updateHashParams({ tab: '' }, '#/nodes?tab=repeaters&search=foo');
var r = URLState.parseHash(newHash);
assert.strictEqual(r.params.tab, undefined);
assert.strictEqual(r.params.search, 'foo');
});
console.log('');
console.log(passed + ' passed, ' + failed + ' failed');
process.exit(failed > 0 ? 1 : 0);