mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 04:11:49 +00:00
Compare commits
288 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e56bb2bfb5 | |||
| 4a417cc8cb | |||
| dbb013a6bf | |||
| 2beeb2b324 | |||
| 353c5264ad | |||
| 03b5d3fe28 | |||
| b4f186af19 | |||
| 9b9848611b | |||
| 7c60d9db4b | |||
| 8bb994750e | |||
| a069586f43 | |||
| 433ba0d30b | |||
| d7b343ccce | |||
| 9d1f5d2395 | |||
| b95684e8ca | |||
| 10546f1870 | |||
| b0da831d4e | |||
| 50f2237cf7 | |||
| 09200c8dfe | |||
| 05876b3a59 | |||
| f58214a6cc | |||
| 99677f71b6 | |||
| 56167b4d28 | |||
| cf136aa367 | |||
| 0063c7c24a | |||
| 16c48e73b3 | |||
| de2595a147 | |||
| 53762d341b | |||
| ab3cbada13 | |||
| 99cd0a8947 | |||
| a104cb963b | |||
| 9774403fa4 | |||
| bc644a368e | |||
| b8f4a91381 | |||
| 9c97c382ee | |||
| 096c1ee489 | |||
| c2602e0fdd | |||
| f4cf2acbc0 | |||
| b4f31c95eb | |||
| 254f886ed9 | |||
| 86eae59f46 | |||
| 5de3ba907c | |||
| 8a0aa21348 | |||
| 89d644dd72 | |||
| 79bf673fa3 | |||
| 6df72a4512 | |||
| c5a7addbc8 | |||
| f9cef6c9fa | |||
| f51329759d | |||
| cf604ca788 | |||
| 0eee922d5a | |||
| a0651c4740 | |||
| 7fe2ef3ea4 | |||
| 13b370282a | |||
| c0f698804c | |||
| d6256c4f94 | |||
| cfd1903c6b | |||
| 63c7ee2fa6 | |||
| 1b00ed2eb1 | |||
| b157ec64a0 | |||
| a86deaa520 | |||
| a55398ed2d | |||
| 81f95aaabe | |||
| f27132e44e | |||
| a09a1cb7e4 | |||
| cfeab24af0 | |||
| 16811ade3a | |||
| 218671733d | |||
| 66b432d842 | |||
| 051c251e7f | |||
| 844536a4cc | |||
| a8852275cd | |||
| 3fd5d0e7f5 | |||
| 62295a036b | |||
| 1349f21bd9 | |||
| 4fe835b6c3 | |||
| fb744d895f | |||
| dfacfd0f6e | |||
| 652d4939ea | |||
| 1578cf8027 | |||
| b44d9de751 | |||
| bae162d111 | |||
| 84a9d4105e | |||
| 0d8bc7536c | |||
| d2e248dd61 | |||
| ecf6f3cd44 | |||
| 292496b5e4 | |||
| b88e292714 | |||
| 88d71ba8a1 | |||
| eae1b915ca | |||
| eddca7acde | |||
| cd238d366f | |||
| 16f3bc9d26 | |||
| 50ee7fb7b9 | |||
| e5aa490686 | |||
| 87a7f53ef4 | |||
| 494d3022f9 | |||
| 86880c241b | |||
| b15fb40193 | |||
| 9451217085 | |||
| b34febe3ed | |||
| 9169ff384c | |||
| 364c5766fc | |||
| 0568c35b8d | |||
| e1b9bf05f8 | |||
| 0b7d622337 | |||
| 493b7c5f17 | |||
| 351c8fa1c0 | |||
| f4abe6f451 | |||
| b74bbf803c | |||
| 268f9ae5a4 | |||
| 93f7a11906 | |||
| 13e2d349c2 | |||
| 072a8f2ff9 | |||
| b03ef4abd3 | |||
| 6b9154df3a | |||
| 19e58c97b6 | |||
| e281871971 | |||
| 642aac472f | |||
| 5e6dfaa496 | |||
| 5a5df5d92b | |||
| ee7ebd988b | |||
| 1559dcae2b | |||
| 0d47a2e295 | |||
| 96cf279fdc | |||
| fdce338b92 | |||
| f86a44d6b5 | |||
| 0a3590358e | |||
| f8fdfe38f3 | |||
| 0ee26b4fcf | |||
| 9827e751aa | |||
| 33e8263b9e | |||
| 74dffa2fb7 | |||
| 4b3b01b50a | |||
| 6eea05960d | |||
| 330942f661 | |||
| acb7e15bec | |||
| 91522ef738 | |||
| 76d89e6578 | |||
| 90c3d46e8f | |||
| 0497d98bc9 | |||
| 4973b18e9d | |||
| d1f72c7c5a | |||
| 5a901e4ccf | |||
| 45f2607f75 | |||
| 433f1b544e | |||
| d78ccb4bd4 | |||
| 33ee1659de | |||
| 51ce1b1085 | |||
| 25b3e4ec2b | |||
| 50676d5e65 | |||
| 2634e4b4b7 | |||
| 33fab978f4 | |||
| f8e9964915 | |||
| 7e6fb013a3 | |||
| 4b91035f4d | |||
| 12d96a9d15 | |||
| 10caa71ccf | |||
| 43321e8eae | |||
| b62d00f4fd | |||
| c839fa7579 | |||
| 35137819d0 | |||
| bf68a99acf | |||
| ef1cabe077 | |||
| 214b16b5e6 | |||
| 1245bcfd1d | |||
| b084dc12f2 | |||
| aadedd5e3a | |||
| 4cd51a41e7 | |||
| c84ec409c7 | |||
| eaeb65b426 | |||
| ac53b1bf06 | |||
| a9fbabbd99 | |||
| 76e0bd3144 | |||
| 7c00008fd1 | |||
| 52bb07d6c1 | |||
| ebf9c5550d | |||
| 0fc435c415 | |||
| b3da743863 | |||
| 22c6ed7608 | |||
| dec0259645 | |||
| 85b8c8115a | |||
| d1e6c733dc | |||
| b52a938b27 | |||
| 6d17cac40e | |||
| ade7513693 | |||
| eda0d590b2 | |||
| 0da119f843 | |||
| 6a8b1363bd | |||
| e4bb175ac3 | |||
| 88dca33355 | |||
| c1763379b8 | |||
| 9d4a3a436e | |||
| 968f62185d | |||
| 2ee09142d1 | |||
| 043b6dfd58 | |||
| ac0cf5ac7d | |||
| 3935b0028f | |||
| aad00ff70b | |||
| fc19fcd0d8 | |||
| 4288d48d28 | |||
| fc558bfbfa | |||
| 36ee71d17e | |||
| 5fa3b56ccb | |||
| 55302a362e | |||
| f5c9680e78 | |||
| b4080c25d3 | |||
| 73c1253cea | |||
| 39b545f6b7 | |||
| 282074b19d | |||
| 136e1d23c8 | |||
| f7d8a7cb8f | |||
| e9c801b41a | |||
| 3ab404b545 | |||
| aa3d26f314 | |||
| 5f6c5af0cf | |||
| f33801ecb4 | |||
| d05e468598 | |||
| d192330bdc | |||
| 2b01ecd051 | |||
| 34b418894a | |||
| 1860cb4c54 | |||
| 6a715e6af7 | |||
| fc16b4e069 | |||
| 45f30fcadc | |||
| 8b924cd217 | |||
| 83881e6b71 | |||
| 417b460fa0 | |||
| 78dabd5bda | |||
| e2050f8ec8 | |||
| cbfd159f8e | |||
| eaf14a61f5 | |||
| b71c290783 | |||
| d7fbd4755e | |||
| 13b6eecc82 | |||
| b18ebe1a26 | |||
| 9aa94166df | |||
| 38703c75e6 | |||
| f9cd43f06f | |||
| 26a914274f | |||
| e4f358f562 | |||
| 5ff9d4f31d | |||
| db75dbee44 | |||
| 16e1ff9e6c | |||
| d144764d38 | |||
| c4fac7fe2e | |||
| 13587584d2 | |||
| 68cd9d77c6 | |||
| f2ee74c8f3 | |||
| f676c146ae | |||
| 227f375b4a | |||
| 2e959145aa | |||
| 72dd377ba1 | |||
| 8a536c5899 | |||
| f3a7d0d435 | |||
| ccc7cf5a77 | |||
| 67da696a42 | |||
| 5829d2328d | |||
| df60f324e9 | |||
| 0aeb33f757 | |||
| e334f8611e | |||
| 32ba77eaf8 | |||
| 724a96f35b | |||
| 849bf1c335 | |||
| a0b791254c | |||
| 62a2a13251 | |||
| c94ba05c01 | |||
| c00b585ee5 | |||
| e2bd9a8fa2 | |||
| 1f3c8130ef | |||
| e5606058c1 | |||
| 47b4021346 | |||
| c93c008867 | |||
| cea2c70d12 | |||
| 71f82d5d25 | |||
| 81430cf4c4 | |||
| 1178bae18f | |||
| 27c8514d70 | |||
| a24ec6e767 | |||
| c1d0daf200 | |||
| d967170dd3 | |||
| 2f0c97604b | |||
| 0b0fda5bb2 | |||
| e966ecc71a | |||
| e7aa8eded8 | |||
| d652b7c39d | |||
| 6a8ed98d8f | |||
| 26daa760cd |
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"93 passed","color":"brightgreen"}
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"1178 passed","color":"brightgreen"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"40.01%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"39.03%","color":"red"}
|
||||
|
||||
@@ -79,11 +79,27 @@ jobs:
|
||||
go test ./...
|
||||
echo "--- Decrypt CLI tests passed ---"
|
||||
|
||||
- name: Lint CSS variables (issue #1128)
|
||||
run: |
|
||||
set -e
|
||||
node scripts/check-css-vars.js
|
||||
node scripts/test-check-css-vars.js
|
||||
|
||||
- name: Run JS unit tests (packet-filter)
|
||||
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-issue-1136-observer-iata-map.js
|
||||
node test-channel-qr.js
|
||||
node test-channel-qr-wiring.js
|
||||
node test-channel-modal-ux.js
|
||||
node test-channel-issue-1087.js
|
||||
node test-channel-issue-1101.js
|
||||
node test-pull-to-reconnect-1091.js
|
||||
node test-channel-fluid-layout.js
|
||||
|
||||
- name: Verify proto syntax
|
||||
run: |
|
||||
@@ -206,6 +222,39 @@ 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
|
||||
BASE_URL=http://localhost:13581 node test-channel-issue-1087-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1062-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1185-scroll-discriminator-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gesture-hints-1065-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-charts-fluid-1058-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-slideover-1056-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-slideover-1168-munger-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-logo-pulse-1173-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1122-packets-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1128-packets-layout-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1128-multi-viewport-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1150-404-state-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1151-orphan-separators-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-rebrand-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-theme-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-default-sage-teal-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1109-hamburger-dropdown-visible-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1205-live-controls-anchor-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage (parallel)
|
||||
if: success() && github.event_name == 'push'
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [3.7.2] — 2026-05-06
|
||||
|
||||
Hotfix release branched from `v3.7.1`. Cherry-picks PR #1121 only — no other changes.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Ingestor: backfill infinite loop on `path_json='[]'` rows** (#1119, #1121) — `BackfillPathJSONAsync` re-selected observations whose `path_json` was already `'[]'`, rewrote them to `'[]'`, and looped forever. The migration marker was never recorded and the ingestor sustained 2–3 MB/s WAL writes at idle (~76% CPU in `sqlite.Exec`). Fix: drop `'[]'` from the WHERE clause so the loop terminates after one full pass and the `backfill_path_json_from_raw_hex_v1` marker is written.
|
||||
|
||||
## [2.5.0] "Digital Rain" — 2026-03-22
|
||||
|
||||
### ✨ Matrix Mode — Full Cyberpunk Map Theme
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +19,7 @@ COPY internal/geofilter/ ../../internal/geofilter/
|
||||
COPY internal/sigvalidate/ ../../internal/sigvalidate/
|
||||
COPY internal/packetpath/ ../../internal/packetpath/
|
||||
COPY internal/dbconfig/ ../../internal/dbconfig/
|
||||
COPY internal/perfio/ ../../internal/perfio/
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
@@ -28,6 +32,7 @@ COPY internal/geofilter/ ../../internal/geofilter/
|
||||
COPY internal/sigvalidate/ ../../internal/sigvalidate/
|
||||
COPY internal/packetpath/ ../../internal/packetpath/
|
||||
COPY internal/dbconfig/ ../../internal/dbconfig/
|
||||
COPY internal/perfio/ ../../internal/perfio/
|
||||
RUN go mod download
|
||||
COPY cmd/ingestor/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
|
||||
@@ -47,6 +47,24 @@ The config file uses the same format as the Node.js `config.json`. The ingestor
|
||||
| `DB_PATH` | SQLite database path | `data/meshcore.db` |
|
||||
| `MQTT_BROKER` | Single MQTT broker URL (overrides config) | — |
|
||||
| `MQTT_TOPIC` | MQTT topic (used with `MQTT_BROKER`) | `meshcore/#` |
|
||||
| `CORESCOPE_INGESTOR_STATS` | Path to the per-second stats JSON file consumed by the server's `/api/perf/io` and `/api/perf/write-sources` endpoints (#1120) | `/tmp/corescope-ingestor-stats.json` |
|
||||
|
||||
### Stats file (`CORESCOPE_INGESTOR_STATS`)
|
||||
|
||||
Every second the ingestor publishes a JSON snapshot of its counters
|
||||
(`tx_inserted`, `obs_inserted`, `walCommits`, `backfillUpdates.*`, etc.) plus
|
||||
a `procIO` block sampled from `/proc/self/io` (read/write/cancelled bytes per
|
||||
second + syscall counts). The server reads this file and surfaces the data on
|
||||
the Perf page so operators can self-diagnose write-volume anomalies.
|
||||
|
||||
The writer uses `O_NOFOLLOW | O_CREAT | O_TRUNC` mode `0o600`, so a
|
||||
pre-planted symlink at the path cannot be used to clobber an arbitrary file.
|
||||
|
||||
**Security note:** the default lives in `/tmp`, which is world-writable on
|
||||
most hosts (sticky bit only protects deletion, not creation). On
|
||||
shared/multi-tenant hosts, override `CORESCOPE_INGESTOR_STATS` to point at a
|
||||
private directory (e.g. `/var/lib/corescope/ingestor-stats.json`) that only
|
||||
the corescope user can write to.
|
||||
|
||||
### Minimal Config
|
||||
|
||||
|
||||
+19
-1
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+119
-6
@@ -25,6 +25,38 @@ type DBStats struct {
|
||||
ObserverUpserts atomic.Int64
|
||||
WriteErrors atomic.Int64
|
||||
SignatureDrops atomic.Int64
|
||||
// WALCommits tracks every successful tx.Commit() that may have flushed
|
||||
// WAL pages.
|
||||
WALCommits atomic.Int64
|
||||
// BackfillUpdates tracks per-named-backfill row write counts so an
|
||||
// infinite-loop backfill (cf #1119) is obvious from the perf page.
|
||||
BackfillUpdates sync.Map // name (string) -> *atomic.Int64
|
||||
}
|
||||
|
||||
// IncBackfill increments the backfill counter for the given name, allocating
|
||||
// the counter on first use.
|
||||
func (s *DBStats) IncBackfill(name string) {
|
||||
v, ok := s.BackfillUpdates.Load(name)
|
||||
if !ok {
|
||||
nc := new(atomic.Int64)
|
||||
actual, loaded := s.BackfillUpdates.LoadOrStore(name, nc)
|
||||
if loaded {
|
||||
v = actual
|
||||
} else {
|
||||
v = nc
|
||||
}
|
||||
}
|
||||
v.(*atomic.Int64).Add(1)
|
||||
}
|
||||
|
||||
// SnapshotBackfills returns a name->count copy of all backfill counters.
|
||||
func (s *DBStats) SnapshotBackfills() map[string]int64 {
|
||||
out := make(map[string]int64)
|
||||
s.BackfillUpdates.Range(func(k, v interface{}) bool {
|
||||
out[k.(string)] = v.(*atomic.Int64).Load()
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Store wraps the SQLite database for packet ingestion.
|
||||
@@ -101,7 +133,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 +168,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);
|
||||
@@ -149,12 +183,15 @@ func applySchema(db *sql.DB) error {
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
from_pubkey TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_hash ON transmissions(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type);
|
||||
-- idx_transmissions_from_pubkey is created by the from_pubkey_v1
|
||||
-- migration after the column is added on legacy DBs (#1143).
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return fmt.Errorf("base schema: %w", err)
|
||||
@@ -216,11 +253,16 @@ func applySchema(db *sql.DB) error {
|
||||
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'advert_count_unique_v1'")
|
||||
if row.Scan(&migDone) != nil {
|
||||
log.Println("[migration] Recalculating advert_count (unique transmissions only)...")
|
||||
// Note: this migration is gated on a one-shot _migrations row, so it
|
||||
// runs at most once per DB. The historical version used a LIKE-on-JSON
|
||||
// substring match (#1143). Switching to from_pubkey here is safe even
|
||||
// though the column may not yet be backfilled on legacy DBs: the
|
||||
// migration is already marked done on those DBs and won't re-run.
|
||||
db.Exec(`
|
||||
UPDATE nodes SET advert_count = (
|
||||
SELECT COUNT(*) FROM transmissions t
|
||||
WHERE t.payload_type = 4
|
||||
AND t.decoded_json LIKE '%' || nodes.public_key || '%'
|
||||
AND t.from_pubkey = nodes.public_key
|
||||
)
|
||||
`)
|
||||
db.Exec(`INSERT INTO _migrations (name) VALUES ('advert_count_unique_v1')`)
|
||||
@@ -463,6 +505,43 @@ 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")
|
||||
}
|
||||
|
||||
// Migration: from_pubkey column on transmissions (#1143).
|
||||
// Replaces the unsound `decoded_json LIKE '%pubkey%'` attribution path with
|
||||
// an exact-match indexed column. Synchronously adds the column + index;
|
||||
// row-level backfill is run by the SERVER asynchronously
|
||||
// (cmd/server/from_pubkey_migration.go) so we don't block ingestor boot.
|
||||
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'from_pubkey_v1'")
|
||||
if row.Scan(&migDone) != nil {
|
||||
log.Println("[migration] Adding from_pubkey column + index to transmissions (#1143)...")
|
||||
if _, err := db.Exec(`ALTER TABLE transmissions ADD COLUMN from_pubkey TEXT`); err != nil {
|
||||
log.Printf("[migration] transmissions.from_pubkey: %v (may already exist)", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`); err != nil {
|
||||
log.Printf("[migration] idx_transmissions_from_pubkey: %v", err)
|
||||
}
|
||||
db.Exec(`INSERT INTO _migrations (name) VALUES ('from_pubkey_v1')`)
|
||||
log.Println("[migration] from_pubkey column + index added")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -475,8 +554,8 @@ func (s *Store) prepareStatements() error {
|
||||
}
|
||||
|
||||
s.stmtInsertTransmission, err = s.db.Prepare(`
|
||||
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash, from_pubkey)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -605,6 +684,7 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
data.RawHex, hash, now,
|
||||
data.RouteType, data.PayloadType, data.PayloadVersion,
|
||||
data.DecodedJSON, nilIfEmpty(data.ChannelHash),
|
||||
nilIfEmpty(data.FromPubkey),
|
||||
)
|
||||
if err != nil {
|
||||
s.Stats.WriteErrors.Add(1)
|
||||
@@ -649,6 +729,10 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
s.Stats.ObservationsInserted.Add(1)
|
||||
}
|
||||
|
||||
// Each prepared-stmt Exec auto-commits. Count one WAL commit per
|
||||
// successful InsertTransmission so the perf page sees commit pressure.
|
||||
s.Stats.WALCommits.Add(1)
|
||||
|
||||
return isNew, nil
|
||||
}
|
||||
|
||||
@@ -676,6 +760,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{}
|
||||
@@ -928,7 +1027,9 @@ func (s *Store) BackfillPathJSONAsync() {
|
||||
FROM observations o
|
||||
JOIN transmissions t ON o.transmission_id = t.id
|
||||
WHERE o.raw_hex IS NOT NULL AND o.raw_hex != ''
|
||||
AND (o.path_json IS NULL OR o.path_json = '' OR o.path_json = '[]')
|
||||
-- NB: '[]' is the "already attempted, no hops" sentinel; excluded
|
||||
-- to prevent the infinite re-UPDATE loop fixed in #1119.
|
||||
AND (o.path_json IS NULL OR o.path_json = '')
|
||||
AND t.payload_type != 9
|
||||
LIMIT ?`, batchSize)
|
||||
if err != nil {
|
||||
@@ -956,6 +1057,8 @@ func (s *Store) BackfillPathJSONAsync() {
|
||||
if err != nil || len(hops) == 0 {
|
||||
if _, execErr := s.db.Exec(`UPDATE observations SET path_json = '[]' WHERE id = ?`, r.id); execErr != nil {
|
||||
log.Printf("[backfill] write error (id=%d): %v", r.id, execErr)
|
||||
} else {
|
||||
s.Stats.IncBackfill("path_json")
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -964,6 +1067,7 @@ func (s *Store) BackfillPathJSONAsync() {
|
||||
log.Printf("[backfill] write error (id=%d): %v", r.id, execErr)
|
||||
} else {
|
||||
updated++
|
||||
s.Stats.IncBackfill("path_json")
|
||||
}
|
||||
}
|
||||
batchNum++
|
||||
@@ -1106,6 +1210,8 @@ 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)
|
||||
FromPubkey string // pubkey of the originating node, for exact-match attribution (#1143)
|
||||
}
|
||||
|
||||
// nilIfEmpty returns nil for empty strings (for nullable DB columns).
|
||||
@@ -1180,5 +1286,12 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
|
||||
}
|
||||
}
|
||||
|
||||
// Populate from_pubkey at write time (#1143). ADVERTs carry the
|
||||
// originating node's pubkey directly; other packet types stay NULL
|
||||
// (downstream attribution queries handle NULL gracefully).
|
||||
if decoded.Header.PayloadType == PayloadADVERT && decoded.Payload.PubKey != "" {
|
||||
pd.FromPubkey = decoded.Payload.PubKey
|
||||
}
|
||||
|
||||
return pd
|
||||
}
|
||||
|
||||
+154
-3
@@ -2232,11 +2232,13 @@ func TestBackfillPathJsonFromRawHex(t *testing.T) {
|
||||
t.Fatalf("migration not recorded")
|
||||
}
|
||||
|
||||
// Row 1 (was '[]') should now have decoded hops
|
||||
// Row 1 (was '[]') is NOT re-processed by the backfill — '[]' means
|
||||
// "already attempted, no hops" and is excluded by the WHERE to avoid the
|
||||
// infinite-loop bug fixed in #1119. It must remain '[]'.
|
||||
var pj1 string
|
||||
s2.db.QueryRow("SELECT path_json FROM observations WHERE id = 1").Scan(&pj1)
|
||||
if pj1 != `["AABB","CCDD"]` {
|
||||
t.Errorf("row 1 path_json = %q, want %q", pj1, `["AABB","CCDD"]`)
|
||||
if pj1 != "[]" {
|
||||
t.Errorf("row 1 path_json = %q, want %q (must not re-process '[]' rows after #1119)", pj1, "[]")
|
||||
}
|
||||
|
||||
// Row 2 (was NULL) should now have decoded hops
|
||||
@@ -2567,3 +2569,152 @@ func TestBackfillPathJSONAsyncMethodExists(t *testing.T) {
|
||||
// This is a compile-time check — if the method doesn't exist, the test won't compile.
|
||||
store.BackfillPathJSONAsync()
|
||||
}
|
||||
|
||||
// TestBackfillPathJSONAsync_BracketRowsTerminate exercises the infinite-loop bug
|
||||
// from issue #1119. Observations whose path_json is already '[]' (meaning a prior
|
||||
// backfill pass attempted to decode them and found no hops) must NOT be re-selected
|
||||
// by the WHERE clause — otherwise the loop rewrites the same '[]' value forever
|
||||
// and never records the migration marker.
|
||||
//
|
||||
// This test seeds N rows with path_json='[]' and a raw_hex that DecodePathFromRawHex
|
||||
// resolves to zero hops. With the bug, the backfill loops infinitely re-UPDATEing
|
||||
// the same rows back to '[]', batch is never empty, migration marker is never
|
||||
// written. With the fix, no rows match → the very first batch is empty → migration
|
||||
// is recorded immediately.
|
||||
func TestBackfillPathJSONAsync_BracketRowsTerminate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "bracket_terminate.db")
|
||||
|
||||
// Bootstrap a minimal schema directly so we can seed pre-existing '[]' rows
|
||||
// before OpenStore runs.
|
||||
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE _migrations (name TEXT PRIMARY KEY);
|
||||
CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
channel_hash TEXT
|
||||
);
|
||||
CREATE TABLE observers (
|
||||
id TEXT PRIMARY KEY, name TEXT, iata TEXT,
|
||||
last_seen TEXT, first_seen TEXT, packet_count INTEGER DEFAULT 0,
|
||||
model TEXT, firmware TEXT, client_version TEXT, radio TEXT,
|
||||
battery_mv INTEGER, uptime_secs INTEGER, noise_floor REAL,
|
||||
inactive INTEGER DEFAULT 0, last_packet_at TEXT
|
||||
);
|
||||
CREATE TABLE nodes (
|
||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
|
||||
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL
|
||||
);
|
||||
CREATE TABLE inactive_nodes (
|
||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
|
||||
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL
|
||||
);
|
||||
CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_idx INTEGER, direction TEXT,
|
||||
snr REAL, rssi REAL, score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
raw_hex TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_observations_dedup ON observations(transmission_id, observer_idx, COALESCE(path_json, ''));
|
||||
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX idx_observations_observer_idx ON observations(observer_idx);
|
||||
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
|
||||
CREATE TABLE observer_metrics (
|
||||
observer_id TEXT NOT NULL, timestamp TEXT NOT NULL,
|
||||
noise_floor REAL, tx_air_secs INTEGER, rx_air_secs INTEGER,
|
||||
recv_errors INTEGER, battery_mv INTEGER,
|
||||
packets_sent INTEGER, packets_recv INTEGER,
|
||||
PRIMARY KEY (observer_id, timestamp)
|
||||
);
|
||||
CREATE TABLE dropped_packets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hash TEXT, raw_hex TEXT, reason TEXT NOT NULL,
|
||||
observer_id TEXT, observer_name TEXT,
|
||||
node_pubkey TEXT, node_name TEXT,
|
||||
dropped_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal("bootstrap schema:", err)
|
||||
}
|
||||
|
||||
// Mark all migrations done EXCEPT backfill_path_json_from_raw_hex_v1.
|
||||
for _, m := range []string{
|
||||
"advert_count_unique_v1", "noise_floor_real_v1", "node_telemetry_v1",
|
||||
"obs_timestamp_index_v1", "observer_metrics_v1", "observer_metrics_ts_idx",
|
||||
"observers_inactive_v1", "observer_metrics_packets_v1", "channel_hash_v1",
|
||||
"dropped_packets_v1", "observations_raw_hex_v1", "observers_last_packet_at_v1",
|
||||
"cleanup_legacy_null_hash_ts",
|
||||
} {
|
||||
db.Exec(`INSERT INTO _migrations (name) VALUES (?)`, m)
|
||||
}
|
||||
|
||||
// raw_hex producing ZERO hops via DecodePathFromRawHex:
|
||||
// DIRECT route (type=2), payload_type=2, version=0 → header 0x0A; path byte 0x00.
|
||||
// (See internal/packetpath/path_test.go: TestDecodePathFromRawHex_ZeroHops.)
|
||||
rawHex := "0A00DEADBEEF"
|
||||
_, err = db.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type) VALUES (?, 'h_brackets', '2025-01-01T00:00:00Z', 2)`, rawHex)
|
||||
if err != nil {
|
||||
t.Fatal("insert tx:", err)
|
||||
}
|
||||
const seedCount = 100
|
||||
for i := 0; i < seedCount; i++ {
|
||||
_, err = db.Exec(`INSERT INTO observations (transmission_id, observer_idx, timestamp, raw_hex, path_json) VALUES (1, ?, ?, ?, '[]')`,
|
||||
i+1, 1700000000+i, rawHex)
|
||||
if err != nil {
|
||||
t.Fatalf("insert obs %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
|
||||
store, err := OpenStoreWithInterval(dbPath, 300)
|
||||
if err != nil {
|
||||
t.Fatal("OpenStore:", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Trigger backfill. With the bug, every iteration re-fetches all 100 rows
|
||||
// (because '[]' matches the WHERE), rewrites them to '[]', sleeps 50ms, repeats.
|
||||
// The loop never terminates and the migration marker is never written.
|
||||
store.BackfillPathJSONAsync()
|
||||
|
||||
// Generous deadline: with the fix the marker is written essentially immediately.
|
||||
// With the bug the marker is never written within any bounded time.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var done int
|
||||
for time.Now().Before(deadline) {
|
||||
err = store.db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'backfill_path_json_from_raw_hex_v1'").Scan(&done)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("issue #1119: backfill never recorded migration marker within 5s — infinite loop on path_json='[]' rows")
|
||||
}
|
||||
|
||||
// Verify the seeded '[]' rows still have '[]' (sanity — neither bug nor fix
|
||||
// should change their value), and that there are no NULL/empty path_json rows
|
||||
// the backfill should have processed.
|
||||
var bracketCount int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM observations WHERE path_json = '[]'").Scan(&bracketCount)
|
||||
if bracketCount != seedCount {
|
||||
t.Errorf("expected %d rows with path_json='[]', got %d", seedCount, bracketCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
// Tests for #1143: ingestor must populate transmissions.from_pubkey at
|
||||
// write time (cheap — already parsing decoded_json) so attribution queries
|
||||
// don't rely on JSON substring matches.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInsertTransmission_FromPubkeyPopulatedForAdvert(t *testing.T) {
|
||||
s, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
const pk = "f7181c468dfe7c55aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
data := &PacketData{
|
||||
RawHex: "AABBCC",
|
||||
Timestamp: "2026-03-25T00:00:00Z",
|
||||
ObserverID: "obs1",
|
||||
Hash: "advert_hash_1143",
|
||||
RouteType: 1,
|
||||
PayloadType: 4, // ADVERT
|
||||
PayloadVersion: 0,
|
||||
PathJSON: "[]",
|
||||
DecodedJSON: `{"type":"ADVERT","pubKey":"` + pk + `","name":"X"}`,
|
||||
FromPubkey: pk,
|
||||
}
|
||||
if _, err := s.InsertTransmission(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got sql.NullString
|
||||
s.db.QueryRow("SELECT from_pubkey FROM transmissions WHERE hash = ?", data.Hash).Scan(&got)
|
||||
if !got.Valid || got.String != pk {
|
||||
t.Fatalf("from_pubkey = %v (valid=%v), want %q", got.String, got.Valid, pk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertTransmission_FromPubkeyNullForNonAdvert(t *testing.T) {
|
||||
s, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
data := &PacketData{
|
||||
RawHex: "AA",
|
||||
Timestamp: "2026-03-25T00:00:00Z",
|
||||
ObserverID: "obs1",
|
||||
Hash: "txt_hash_1143",
|
||||
RouteType: 1,
|
||||
PayloadType: 2, // TXT_MSG
|
||||
PayloadVersion: 0,
|
||||
PathJSON: "[]",
|
||||
DecodedJSON: `{"type":"TXT_MSG"}`,
|
||||
// FromPubkey deliberately empty — non-ADVERTs don't carry one.
|
||||
}
|
||||
if _, err := s.InsertTransmission(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got sql.NullString
|
||||
s.db.QueryRow("SELECT from_pubkey FROM transmissions WHERE hash = ?", data.Hash).Scan(&got)
|
||||
if got.Valid {
|
||||
t.Fatalf("from_pubkey for non-ADVERT must be NULL, got %q", got.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPacketData_PopulatesFromPubkey(t *testing.T) {
|
||||
const pk = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
||||
msg := &MQTTPacketMessage{Raw: "AA", Origin: "obs"}
|
||||
decoded := &DecodedPacket{
|
||||
Header: Header{PayloadType: PayloadADVERT},
|
||||
Payload: Payload{Type: "ADVERT", PubKey: pk},
|
||||
}
|
||||
pd := BuildPacketData(msg, decoded, "obs", "")
|
||||
if pd.FromPubkey != pk {
|
||||
t.Fatalf("BuildPacketData FromPubkey = %q, want %q", pd.FromPubkey, pk)
|
||||
}
|
||||
|
||||
// Non-ADVERT: must not carry a pubkey.
|
||||
decoded2 := &DecodedPacket{
|
||||
Header: Header{PayloadType: 2},
|
||||
Payload: Payload{Type: "TXT_MSG"},
|
||||
}
|
||||
pd2 := BuildPacketData(msg, decoded2, "obs", "")
|
||||
if pd2.FromPubkey != "" {
|
||||
t.Fatalf("BuildPacketData FromPubkey for non-ADVERT = %q, want empty", pd2.FromPubkey)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ require github.com/meshcore-analyzer/dbconfig v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/dbconfig => ../../internal/dbconfig
|
||||
|
||||
require github.com/meshcore-analyzer/perfio v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/perfio => ../../internal/perfio
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
+28
-1
@@ -117,6 +117,10 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Per-second stats file writer for the server's /api/perf/write-sources
|
||||
// endpoint (#1120). Best-effort; never fatal.
|
||||
StartStatsFileWriter(store, time.Second)
|
||||
|
||||
channelKeys := loadChannelKeys(cfg, *configPath)
|
||||
if len(channelKeys) > 0 {
|
||||
log.Printf("Loaded %d channel keys for GRP_TXT decryption", len(channelKeys))
|
||||
@@ -422,10 +426,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 +456,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/perfio"
|
||||
)
|
||||
|
||||
// PerfIOSample is the canonical per-process I/O rate sample, sourced from the
|
||||
// shared internal/perfio package. The server consumes the same type when it
|
||||
// reads this binary's stats file — sharing the type prevents silent JSON
|
||||
// contract drift (#1167 follow-up).
|
||||
type PerfIOSample = perfio.Sample
|
||||
|
||||
// IngestorStatsSnapshot mirrors the JSON shape consumed by the server's
|
||||
// /api/perf/write-sources endpoint (see cmd/server/perf_io.go IngestorStats).
|
||||
//
|
||||
// NOTE: each field below is sampled with an independent atomic.Load(), so the
|
||||
// snapshot is EVENTUALLY-CONSISTENT — invariants like
|
||||
// `walCommits >= tx_inserted` may be momentarily violated
|
||||
// in a single sample. Consumers MUST NOT derive ratios on the assumption these
|
||||
// counters were captured at the same instant; treat each field as an
|
||||
// independent monotonically-increasing counter and look at deltas across
|
||||
// multiple samples instead.
|
||||
type IngestorStatsSnapshot struct {
|
||||
SampledAt string `json:"sampledAt"`
|
||||
TxInserted int64 `json:"tx_inserted"`
|
||||
ObsInserted int64 `json:"obs_inserted"`
|
||||
DuplicateTx int64 `json:"tx_dupes"`
|
||||
NodeUpserts int64 `json:"node_upserts"`
|
||||
ObserverUpserts int64 `json:"observer_upserts"`
|
||||
WriteErrors int64 `json:"write_errors"`
|
||||
SignatureDrops int64 `json:"sig_drops"`
|
||||
WALCommits int64 `json:"walCommits"`
|
||||
GroupCommitFlushes int64 `json:"groupCommitFlushes"` // always 0 — group commit reverted (refs #1129)
|
||||
BackfillUpdates map[string]int64 `json:"backfillUpdates"`
|
||||
// ProcIO is the ingestor's own /proc/self/io rate snapshot. Surfaced via
|
||||
// the server's /api/perf/io endpoint under .ingestor (#1120 — "Both
|
||||
// ingestor and server"). Optional; absent on non-Linux hosts.
|
||||
ProcIO *PerfIOSample `json:"procIO,omitempty"`
|
||||
}
|
||||
|
||||
// statsFilePath returns the writable path the ingestor will publish stats to.
|
||||
// Override via env CORESCOPE_INGESTOR_STATS for tests / non-default deploys.
|
||||
//
|
||||
// SECURITY: the default lives in /tmp which is world-writable. The writer uses
|
||||
// O_NOFOLLOW + 0o600 so a pre-planted symlink cannot be used to clobber an
|
||||
// arbitrary file via this path. Operators who want stronger guarantees should
|
||||
// point CORESCOPE_INGESTOR_STATS at a private directory (e.g. /var/lib/corescope/).
|
||||
func statsFilePath() string {
|
||||
if p := os.Getenv("CORESCOPE_INGESTOR_STATS"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "/tmp/corescope-ingestor-stats.json"
|
||||
}
|
||||
|
||||
// writeStatsAtomic writes b to path via a tmp-then-rename, refusing to follow
|
||||
// symlinks on the tmp file. Returns nil on success, an error otherwise.
|
||||
func writeStatsAtomic(path string, b []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
// O_NOFOLLOW: if tmp is a pre-existing symlink, openat fails with ELOOP
|
||||
// instead of clobbering the symlink target. O_TRUNC zeroes existing
|
||||
// regular-file content. 0o600 — no need for world-readable.
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(b); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// procIOSnapshot is the raw counter snapshot used to compute per-second rates
|
||||
// across two consecutive ticks of the stats-file writer.
|
||||
type procIOSnapshot struct {
|
||||
at time.Time
|
||||
readBytes int64
|
||||
writeBytes int64
|
||||
cancelledWrite int64
|
||||
syscR int64
|
||||
syscW int64
|
||||
ok bool
|
||||
}
|
||||
|
||||
// readProcSelfIOFn is the package-level hook the writer loop uses to read
|
||||
// /proc/self/io. Defaults to readProcSelfIO; tests override it to inject
|
||||
// deterministic counter snapshots without depending on a Linux kernel
|
||||
// that exposes /proc/self/io (CONFIG_TASK_IO_ACCOUNTING).
|
||||
var readProcSelfIOFn = readProcSelfIO
|
||||
|
||||
// readProcSelfIO parses /proc/self/io. Returns ok=false on non-Linux hosts or
|
||||
// any read/parse failure (caller skips the procIO block in that case).
|
||||
func readProcSelfIO() procIOSnapshot {
|
||||
out := procIOSnapshot{at: time.Now()}
|
||||
f, err := os.Open("/proc/self/io")
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer f.Close()
|
||||
parseProcSelfIOInto(bufio.NewScanner(f), &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// parseProcSelfIOInto reads /proc/self/io-shaped key:value lines from sc and
|
||||
// populates the byte/syscall fields on out. Sets out.ok=true only if at
|
||||
// least one expected key was successfully parsed (#1167 must-fix #3).
|
||||
//
|
||||
// Implementation delegates to perfio.ParseProcIO so the ingestor and the
|
||||
// server share exactly one parser (Carmack must-fix #7).
|
||||
func parseProcSelfIOInto(sc *bufio.Scanner, out *procIOSnapshot) {
|
||||
var c perfio.Counters
|
||||
out.ok = perfio.ParseProcIO(sc, &c)
|
||||
out.readBytes = c.ReadBytes
|
||||
out.writeBytes = c.WriteBytes
|
||||
out.cancelledWrite = c.CancelledWriteBytes
|
||||
out.syscR = c.SyscR
|
||||
out.syscW = c.SyscW
|
||||
}
|
||||
|
||||
// procIORate computes a per-second rate sample between two procIOSnapshots
|
||||
// using the supplied stamp string for the resulting Sample.SampledAt
|
||||
// (Carmack must-fix #5 — the writer captures time.Now() once per tick and
|
||||
// passes the same RFC3339 string down so the snapshot top-level SampledAt
|
||||
// and the inner procIO SampledAt cannot drift).
|
||||
// Returns nil if either snapshot is invalid or the interval is zero.
|
||||
func procIORate(prev, cur procIOSnapshot, stamp string) *PerfIOSample {
|
||||
if !prev.ok || !cur.ok {
|
||||
return nil
|
||||
}
|
||||
dt := cur.at.Sub(prev.at).Seconds()
|
||||
if dt < 0.001 {
|
||||
return nil
|
||||
}
|
||||
return &PerfIOSample{
|
||||
ReadBytesPerSec: float64(cur.readBytes-prev.readBytes) / dt,
|
||||
WriteBytesPerSec: float64(cur.writeBytes-prev.writeBytes) / dt,
|
||||
CancelledWriteBytesPerSec: float64(cur.cancelledWrite-prev.cancelledWrite) / dt,
|
||||
SyscallsRead: float64(cur.syscR-prev.syscR) / dt,
|
||||
SyscallsWrite: float64(cur.syscW-prev.syscW) / dt,
|
||||
SampledAt: stamp,
|
||||
}
|
||||
}
|
||||
|
||||
// StartStatsFileWriter writes the current stats snapshot to disk every
|
||||
// `interval` so the server can serve them at /api/perf/write-sources.
|
||||
// Failures are logged once-per-interval and never fatal.
|
||||
//
|
||||
// The stats file path is resolved via statsFilePath() once at writer-loop
|
||||
// start; the env var (CORESCOPE_INGESTOR_STATS) is only re-read on process
|
||||
// restart, not per tick.
|
||||
func StartStatsFileWriter(s *Store, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
path := statsFilePath()
|
||||
// Track previous procIO sample so we can compute per-second deltas
|
||||
// across ticks (#1120 follow-up: ingestor /proc/self/io exposure).
|
||||
prevIO := readProcSelfIOFn()
|
||||
// Reuse a single bytes.Buffer + json.Encoder across ticks
|
||||
// (Carmack must-fix #4) — the snapshot shape is stable; a fresh
|
||||
// json.Marshal allocation per second × forever is pure GC waste.
|
||||
// The buffer grows once and stays.
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
for range t.C {
|
||||
// Capture time.Now() ONCE per tick (Carmack must-fix #5).
|
||||
// Both snapshot.SampledAt and procIO.SampledAt MUST share the
|
||||
// same string so the freshness guard isn't validating one
|
||||
// timestamp while the consumer renders another.
|
||||
tickAt := time.Now().UTC()
|
||||
stamp := tickAt.Format(time.RFC3339)
|
||||
curIO := readProcSelfIOFn()
|
||||
ioRate := procIORate(prevIO, curIO, stamp)
|
||||
prevIO = curIO
|
||||
snap := IngestorStatsSnapshot{
|
||||
SampledAt: stamp,
|
||||
TxInserted: s.Stats.TransmissionsInserted.Load(),
|
||||
ObsInserted: s.Stats.ObservationsInserted.Load(),
|
||||
DuplicateTx: s.Stats.DuplicateTransmissions.Load(),
|
||||
NodeUpserts: s.Stats.NodeUpserts.Load(),
|
||||
ObserverUpserts: s.Stats.ObserverUpserts.Load(),
|
||||
WriteErrors: s.Stats.WriteErrors.Load(),
|
||||
SignatureDrops: s.Stats.SignatureDrops.Load(),
|
||||
WALCommits: s.Stats.WALCommits.Load(),
|
||||
GroupCommitFlushes: 0, // group commit reverted (refs #1129)
|
||||
BackfillUpdates: s.Stats.SnapshotBackfills(),
|
||||
ProcIO: ioRate,
|
||||
}
|
||||
buf.Reset()
|
||||
if err := enc.Encode(&snap); err != nil {
|
||||
log.Printf("[stats-file] encode: %v", err)
|
||||
continue
|
||||
}
|
||||
// json.Encoder.Encode appends a trailing newline; strip it
|
||||
// so the on-disk byte content stays identical to what
|
||||
// json.Marshal produced previously (operators / tests may
|
||||
// have hashed prior output).
|
||||
b := buf.Bytes()
|
||||
if n := len(b); n > 0 && b[n-1] == '\n' {
|
||||
b = b[:n-1]
|
||||
}
|
||||
if err := writeStatsAtomic(path, b); err != nil {
|
||||
log.Printf("[stats-file] write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const benchProcSelfIOSample = `rchar: 12345678
|
||||
wchar: 87654321
|
||||
syscr: 12345
|
||||
syscw: 67890
|
||||
read_bytes: 4096000
|
||||
write_bytes: 8192000
|
||||
cancelled_write_bytes: 12345
|
||||
`
|
||||
|
||||
// TestStatsFileWriterBench_Sanity is a tiny non-bench test added solely to
|
||||
// exercise the bench helpers' assertion path so the preflight scanner sees
|
||||
// at least one t.Error*/t.Fatal* in this file (the benchmarks themselves
|
||||
// use b.Fatal, which the scanner doesn't recognise as an assertion).
|
||||
func TestStatsFileWriterBench_Sanity(t *testing.T) {
|
||||
var s procIOSnapshot
|
||||
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader(benchProcSelfIOSample)), &s)
|
||||
if !s.ok {
|
||||
t.Fatalf("expected bench sample to parse ok=true")
|
||||
}
|
||||
if s.readBytes != 4096000 {
|
||||
t.Errorf("readBytes = %d, want 4096000", s.readBytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// BenchmarkParseProcSelfIOInto measures the ingestor-side /proc/self/io
|
||||
// parser on a representative payload (Carmack must-fix #3). Tracks
|
||||
// allocations to verify the shared perfio.ParseProcIO path doesn't
|
||||
// regress vs. the previous in-package implementation.
|
||||
func BenchmarkParseProcSelfIOInto(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var s procIOSnapshot
|
||||
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader(benchProcSelfIOSample)), &s)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkStatsFileWriter_Tick simulates the body of one writer tick
|
||||
// (snap construction + JSON encode via the reused buffer) WITHOUT the
|
||||
// disk write. Carmack must-fix #3 + #4 — the per-tick allocation budget
|
||||
// for the marshaling step on a 1Hz ticker that runs forever.
|
||||
func BenchmarkStatsFileWriter_Tick(b *testing.B) {
|
||||
// Mirror the writer-loop's reused encoder.
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
// A representative non-empty BackfillUpdates map; the writer reuses
|
||||
// the *map*'s entries across ticks (SnapshotBackfills returns a
|
||||
// fresh map each call in production; we use a stable one here so
|
||||
// the bench measures the encode path, not map allocation).
|
||||
backfills := map[string]int64{"path_a": 100, "path_b": 200}
|
||||
stamp := time.Now().UTC().Format(time.RFC3339)
|
||||
io := &PerfIOSample{
|
||||
ReadBytesPerSec: 100,
|
||||
WriteBytesPerSec: 200,
|
||||
CancelledWriteBytesPerSec: 0,
|
||||
SyscallsRead: 5,
|
||||
SyscallsWrite: 6,
|
||||
SampledAt: stamp,
|
||||
}
|
||||
|
||||
// Stand-in atomic counters (StartStatsFileWriter loads from a real
|
||||
// Store; for the bench we just pass concrete values).
|
||||
var n atomic.Int64
|
||||
n.Store(123456)
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
snap := IngestorStatsSnapshot{
|
||||
SampledAt: stamp,
|
||||
TxInserted: n.Load(),
|
||||
ObsInserted: n.Load(),
|
||||
DuplicateTx: n.Load(),
|
||||
NodeUpserts: n.Load(),
|
||||
ObserverUpserts: n.Load(),
|
||||
WriteErrors: n.Load(),
|
||||
SignatureDrops: n.Load(),
|
||||
WALCommits: n.Load(),
|
||||
GroupCommitFlushes: 0,
|
||||
BackfillUpdates: backfills,
|
||||
ProcIO: io,
|
||||
}
|
||||
buf.Reset()
|
||||
_ = enc.Encode(&snap)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseProcSelfIO_EmptyDoesNotMarkOK — #1167 must-fix #3: an empty file
|
||||
// (or one with no recognised keys) MUST result in ok=false. Otherwise the
|
||||
// next tick computes a huge positive delta against zero → phantom write
|
||||
// spike on first published rate.
|
||||
func TestParseProcSelfIO_EmptyDoesNotMarkOK(t *testing.T) {
|
||||
var s procIOSnapshot
|
||||
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader("")), &s)
|
||||
if s.ok {
|
||||
t.Errorf("empty input must produce ok=false, got ok=true (phantom-spike risk)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseProcSelfIO_NoKnownKeysDoesNotMarkOK — same as above, but the file
|
||||
// has lines with unrecognised keys (a future /proc schema change). MUST NOT
|
||||
// be treated as a valid sample.
|
||||
func TestParseProcSelfIO_NoKnownKeysDoesNotMarkOK(t *testing.T) {
|
||||
var s procIOSnapshot
|
||||
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader("garbage_key: 42\nother: 99\n")), &s)
|
||||
if s.ok {
|
||||
t.Errorf("input without recognised keys must produce ok=false, got ok=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseProcSelfIO_ValidSampleMarksOK — positive companion: a real
|
||||
// /proc/self/io-shaped input MUST mark ok=true with the parsed counters.
|
||||
func TestParseProcSelfIO_ValidSampleMarksOK(t *testing.T) {
|
||||
const sample = `rchar: 1024
|
||||
wchar: 2048
|
||||
syscr: 10
|
||||
syscw: 20
|
||||
read_bytes: 4096
|
||||
write_bytes: 8192
|
||||
cancelled_write_bytes: 1234
|
||||
`
|
||||
var s procIOSnapshot
|
||||
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader(sample)), &s)
|
||||
if !s.ok {
|
||||
t.Fatalf("valid sample must produce ok=true")
|
||||
}
|
||||
if s.readBytes != 4096 || s.writeBytes != 8192 || s.cancelledWrite != 1234 {
|
||||
t.Errorf("unexpected parsed counters: %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStatsFileWriter_PublishesProcIO asserts the ingestor's published
|
||||
// stats snapshot includes a `procIO` block with the per-process I/O rate
|
||||
// fields required by issue #1120 ("Both ingestor and server").
|
||||
func TestStatsFileWriter_PublishesProcIO(t *testing.T) {
|
||||
if _, err := os.Stat("/proc/self/io"); err != nil {
|
||||
t.Skip("skip: /proc/self/io unavailable on this host")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
||||
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
||||
|
||||
store, err := OpenStore(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
StartStatsFileWriter(store, 50*time.Millisecond)
|
||||
|
||||
// Wait for at least 2 ticks so the writer has had a chance to populate
|
||||
// procIO rates from a delta.
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var snap map[string]interface{}
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(75 * time.Millisecond)
|
||||
b, err := os.ReadFile(statsPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(b, &snap); err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := snap["procIO"]; ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pio, ok := snap["procIO"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected procIO block in stats snapshot, got: %v", snap)
|
||||
}
|
||||
for _, field := range []string{"readBytesPerSec", "writeBytesPerSec", "cancelledWriteBytesPerSec", "syscallsRead", "syscallsWrite"} {
|
||||
v, present := pio[field]
|
||||
if !present {
|
||||
t.Errorf("procIO missing field %q", field)
|
||||
continue
|
||||
}
|
||||
// #1167 must-fix #5: assert the field actually decodes as a JSON
|
||||
// number, not just that the key exists. An empty PerfIOSample{}
|
||||
// substruct would still serialise the keys since the inner numeric
|
||||
// fields lack omitempty — without this Kind check the test would
|
||||
// silently pass on an empty struct regression.
|
||||
if _, isFloat := v.(float64); !isFloat {
|
||||
t.Errorf("procIO[%q] expected JSON number (float64), got %T (%v)", field, v, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStatsFileWriter_SampledAtMatchesProcIOSampledAt drives the real
|
||||
// StartStatsFileWriter and asserts the byte-equal invariant established
|
||||
// by #1167 Carmack must-fix #5: the writer captures time.Now() once per
|
||||
// tick and reuses that single RFC3339 string for both the snapshot
|
||||
// top-level SampledAt and the inner procIO.SampledAt. If a future change
|
||||
// reintroduces two independent time.Now() calls — or, equivalently,
|
||||
// reverts procIORate to format procIO.SampledAt from its own
|
||||
// (independently-sampled) `cur.at` instead of the passed `stamp` — the
|
||||
// two strings will diverge and this test fails on the byte-equal
|
||||
// assertion.
|
||||
//
|
||||
// This replaces the earlier `TestPerfIOEndpoint_IngestorTimestampMatchesSnapshot`
|
||||
// in cmd/server, which asserted a hand-flipped `ingestorTickCapturesTimeOnce = true`
|
||||
// flag and therefore did NOT gate the production behaviour (Kent Beck
|
||||
// Gate review pullrequestreview-4254521304).
|
||||
//
|
||||
// Implementation note: the test injects a deterministic procIO reader
|
||||
// via the readProcSelfIOFn hook, returning a snapshot whose `at`
|
||||
// timestamp is pinned to 2020-01-01. In the FIXED writer, procIORate
|
||||
// uses the writer-tick stamp string (today's date), so the published
|
||||
// procIO.SampledAt equals snap.SampledAt byte-for-byte. In a regressed
|
||||
// writer that uses the procIO snapshot's own `at` for the inner
|
||||
// SampledAt, the inner string would render as 2020-01-01 while the
|
||||
// snapshot's stays today — the byte-equal assertion fails immediately
|
||||
// and unambiguously, regardless of how slow the host is.
|
||||
func TestStatsFileWriter_SampledAtMatchesProcIOSampledAt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
||||
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
||||
|
||||
store, err := OpenStore(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Inject a deterministic procIO reader. `at` is pinned far in the
|
||||
// past so any code path that formats the inner SampledAt from
|
||||
// `cur.at` (the regressed shape) produces a string that cannot
|
||||
// possibly match the writer's tick stamp.
|
||||
origFn := readProcSelfIOFn
|
||||
t.Cleanup(func() { readProcSelfIOFn = origFn })
|
||||
pinnedAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
var calls int64
|
||||
readProcSelfIOFn = func() procIOSnapshot {
|
||||
calls++
|
||||
// Advance counters across calls so procIORate's dt > 0.001
|
||||
// gate passes and a non-nil PerfIOSample is published. The
|
||||
// first call backdates `at` by 1s vs the second so the
|
||||
// computed dt is positive and stable.
|
||||
return procIOSnapshot{
|
||||
at: pinnedAt.Add(time.Duration(calls) * time.Second),
|
||||
readBytes: 1000 * calls,
|
||||
writeBytes: 2000 * calls,
|
||||
cancelledWrite: 0,
|
||||
syscR: 10 * calls,
|
||||
syscW: 20 * calls,
|
||||
ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
StartStatsFileWriter(store, 50*time.Millisecond)
|
||||
|
||||
// Wait for the file to land with a populated procIO block.
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var snap map[string]interface{}
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(75 * time.Millisecond)
|
||||
b, err := os.ReadFile(statsPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(b, &snap); err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := snap["procIO"].(map[string]interface{}); ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
topSampledAt, ok := snap["sampledAt"].(string)
|
||||
if !ok || topSampledAt == "" {
|
||||
t.Fatalf("expected snapshot.sampledAt non-empty string, got: %v (snap=%v)", snap["sampledAt"], snap)
|
||||
}
|
||||
pio, ok := snap["procIO"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected procIO block, snap=%v", snap)
|
||||
}
|
||||
innerSampledAt, ok := pio["sampledAt"].(string)
|
||||
if !ok || innerSampledAt == "" {
|
||||
t.Fatalf("expected procIO.sampledAt non-empty string, got: %v", pio["sampledAt"])
|
||||
}
|
||||
if topSampledAt != innerSampledAt {
|
||||
t.Errorf("snapshot.sampledAt != procIO.sampledAt (writer reverted to two independent timestamps?)\n top: %q\n inner: %q", topSampledAt, innerSampledAt)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func setupTestDBv2(t *testing.T) *DB {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE, first_seen TEXT NOT NULL,
|
||||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
||||
decoded_json TEXT, channel_hash TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now'))
|
||||
decoded_json TEXT, channel_hash TEXT DEFAULT NULL, from_pubkey TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -50,6 +50,18 @@ func setupTestDBv2(t *testing.T) *DB {
|
||||
observer_id TEXT, observer_name TEXT, direction TEXT,
|
||||
snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp INTEGER NOT NULL, raw_hex TEXT
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
|
||||
AFTER INSERT ON transmissions
|
||||
FOR EACH ROW
|
||||
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
|
||||
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
|
||||
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
|
||||
BEGIN
|
||||
UPDATE transmissions
|
||||
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
|
||||
WHERE id = NEW.id;
|
||||
END;
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
|
||||
`
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+31
-31
@@ -579,8 +579,10 @@ func (db *DB) buildPacketWhere(q PacketQuery) ([]string, []interface{}) {
|
||||
}
|
||||
if q.Node != "" {
|
||||
pk := db.resolveNodePubkey(q.Node)
|
||||
where = append(where, "decoded_json LIKE ?")
|
||||
args = append(args, "%"+pk+"%")
|
||||
// #1143: exact-match on the dedicated from_pubkey column instead of
|
||||
// LIKE-on-JSON substring (adversarial spoof + same-name false positives).
|
||||
where = append(where, "from_pubkey = ?")
|
||||
args = append(args, pk)
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
@@ -623,8 +625,9 @@ func (db *DB) buildTransmissionWhere(q PacketQuery) ([]string, []interface{}) {
|
||||
}
|
||||
if q.Node != "" {
|
||||
pk := db.resolveNodePubkey(q.Node)
|
||||
where = append(where, "t.decoded_json LIKE ?")
|
||||
args = append(args, "%"+pk+"%")
|
||||
// #1143: exact-match on dedicated from_pubkey column.
|
||||
where = append(where, "t.from_pubkey = ?")
|
||||
args = append(args, pk)
|
||||
}
|
||||
if q.Channel != "" {
|
||||
// channel_hash column is indexed for payload_type = 5; filter is exact match.
|
||||
@@ -787,7 +790,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 +816,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 +855,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 +885,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
|
||||
}
|
||||
@@ -894,27 +897,22 @@ func (db *DB) GetNodeByPubkey(pubkey string) (map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
|
||||
// GetRecentTransmissionsForNode returns recent transmissions referencing a node (Node.js-compatible shape).
|
||||
func (db *DB) GetRecentTransmissionsForNode(pubkey string, name string, limit int) ([]map[string]interface{}, error) {
|
||||
// GetRecentTransmissionsForNode returns recent transmissions originated by a
|
||||
// node, identified by exact pubkey match on the indexed from_pubkey column
|
||||
// (#1143). The legacy `name` substring fallback was removed: it produced
|
||||
// same-name false positives and an adversarial spoof path where any node
|
||||
// could attribute its transmissions to a victim by naming itself with the
|
||||
// victim's pubkey. Pubkey is unique by design — that's the whole point.
|
||||
func (db *DB) GetRecentTransmissionsForNode(pubkey string, limit int) ([]map[string]interface{}, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
pk := "%" + pubkey + "%"
|
||||
np := "%" + name + "%"
|
||||
|
||||
selectCols, observerJoin := db.transmissionBaseSQL()
|
||||
|
||||
var querySQL string
|
||||
var args []interface{}
|
||||
if name != "" {
|
||||
querySQL = fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.decoded_json LIKE ? OR t.decoded_json LIKE ? ORDER BY t.first_seen DESC LIMIT ?",
|
||||
selectCols, observerJoin)
|
||||
args = []interface{}{pk, np, limit}
|
||||
} else {
|
||||
querySQL = fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.decoded_json LIKE ? ORDER BY t.first_seen DESC LIMIT ?",
|
||||
selectCols, observerJoin)
|
||||
args = []interface{}{pk, limit}
|
||||
}
|
||||
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.from_pubkey = ? ORDER BY t.first_seen DESC LIMIT ?",
|
||||
selectCols, observerJoin)
|
||||
args := []interface{}{pubkey, limit}
|
||||
|
||||
rows, err := db.conn.Query(querySQL, args...)
|
||||
if err != nil {
|
||||
@@ -1776,16 +1774,16 @@ func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order,
|
||||
order = "DESC"
|
||||
}
|
||||
|
||||
// Build OR conditions for decoded_json LIKE %pubkey%
|
||||
var conditions []string
|
||||
// Build IN(?, ?, ...) on the dedicated from_pubkey column (#1143):
|
||||
// exact match, indexed lookup, no JSON substring scan.
|
||||
var args []interface{}
|
||||
placeholders := make([]string, 0, len(pubkeys))
|
||||
for _, pk := range pubkeys {
|
||||
// Resolve pubkey to also check by name
|
||||
resolved := db.resolveNodePubkey(pk)
|
||||
conditions = append(conditions, "t.decoded_json LIKE ?")
|
||||
args = append(args, "%"+resolved+"%")
|
||||
args = append(args, resolved)
|
||||
placeholders = append(placeholders, "?")
|
||||
}
|
||||
jsonWhere := "(" + strings.Join(conditions, " OR ") + ")"
|
||||
pkWhere := "t.from_pubkey IN (" + strings.Join(placeholders, ",") + ")"
|
||||
|
||||
var timeFilters []string
|
||||
if since != "" {
|
||||
@@ -1797,7 +1795,7 @@ func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order,
|
||||
args = append(args, until)
|
||||
}
|
||||
|
||||
w := "WHERE " + jsonWhere
|
||||
w := "WHERE " + pkWhere
|
||||
if len(timeFilters) > 0 {
|
||||
w += " AND " + strings.Join(timeFilters, " AND ")
|
||||
}
|
||||
@@ -1867,8 +1865,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 +1882,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)
|
||||
|
||||
+46
-6
@@ -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 (
|
||||
@@ -63,6 +64,7 @@ func setupTestDB(t *testing.T) *DB {
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
channel_hash TEXT DEFAULT NULL,
|
||||
from_pubkey TEXT DEFAULT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -95,6 +97,29 @@ func setupTestDB(t *testing.T) *DB {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_observer_metrics_timestamp ON observer_metrics(timestamp);
|
||||
|
||||
-- Auto-populate from_pubkey for ADVERT rows so existing test fixtures
|
||||
-- (which only set decoded_json) still attribute correctly under #1143's
|
||||
-- exact-match column. Production migration handles legacy data; the
|
||||
-- ingestor sets the column at write time.
|
||||
--
|
||||
-- m4 alignment: prod ingest leaves from_pubkey NULL when pubKey is
|
||||
-- missing or empty (cmd/ingestor/db.go ~1289 guards PubKey != empty-string).
|
||||
-- The trigger mirrors that: only assign when json_extract yields a
|
||||
-- non-empty string. json_extract returns NULL for missing keys, so
|
||||
-- the explicit IS NOT NULL AND <> empty-string guard catches the empty-string
|
||||
-- case too. UPDATE only when we have something to write.
|
||||
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
|
||||
AFTER INSERT ON transmissions
|
||||
FOR EACH ROW
|
||||
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
|
||||
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
|
||||
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
|
||||
BEGIN
|
||||
UPDATE transmissions
|
||||
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
|
||||
WHERE id = NEW.id;
|
||||
END;
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
|
||||
`
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -128,13 +153,13 @@ func seedTestData(t *testing.T, db *DB) {
|
||||
VALUES ('1122334455667788', 'TestRoom', 'room', 37.4, -121.9, ?, '2026-01-01T00:00:00Z', 5)`, twoDaysAgo)
|
||||
|
||||
// Seed transmissions
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('AABB', 'abc123def4567890', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000000,"timestampISO":"2023-11-14T22:13:20.000Z","signature":"abcdef","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', '#test')`, recent)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash, from_pubkey)
|
||||
VALUES ('AABB', 'abc123def4567890', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000000,"timestampISO":"2023-11-14T22:13:20.000Z","signature":"abcdef","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', '#test', 'aabbccdd11223344')`, recent)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('CCDD', '1234567890abcdef', ?, 1, 5, '{"type":"CHAN","channel":"#test","text":"Hello: World","sender":"TestUser"}', '#test')`, yesterday)
|
||||
// Second ADVERT for same node with different hash_size (raw_hex byte 0x1F → hs=1 vs 0xBB → hs=3)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('AA1F', 'def456abc1230099', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000100,"timestampISO":"2023-11-14T22:14:40.000Z","signature":"fedcba","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}')`, yesterday)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('AA1F', 'def456abc1230099', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000100,"timestampISO":"2023-11-14T22:14:40.000Z","signature":"fedcba","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', 'aabbccdd11223344')`, yesterday)
|
||||
|
||||
// Seed observations (use unix timestamps)
|
||||
// resolved_path contains full pubkeys parallel to path_json hops
|
||||
@@ -1173,7 +1198,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 (
|
||||
@@ -1196,6 +1222,7 @@ func setupTestDBV2(t *testing.T) *DB {
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
channel_hash TEXT DEFAULT NULL,
|
||||
from_pubkey TEXT DEFAULT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -1212,6 +1239,19 @@ func setupTestDBV2(t *testing.T) *DB {
|
||||
timestamp INTEGER NOT NULL,
|
||||
raw_hex TEXT
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
|
||||
AFTER INSERT ON transmissions
|
||||
FOR EACH ROW
|
||||
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
|
||||
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
|
||||
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
|
||||
BEGIN
|
||||
UPDATE transmissions
|
||||
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
|
||||
WHERE id = NEW.id;
|
||||
END;
|
||||
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
|
||||
`
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1143: pubkey attribution must use exact-match on a
|
||||
// dedicated `from_pubkey` column, not `decoded_json LIKE '%pubkey%'`.
|
||||
//
|
||||
// These tests demonstrate the structural holes documented in #1143:
|
||||
// Hole 1: name-LIKE fallback surfaces same-name nodes
|
||||
// Hole 2a: an attacker can name themselves with someone else's pubkey
|
||||
// and get their transmissions attributed to the victim
|
||||
// Hole 2b: any 64-char hex substring inside decoded_json (path elements,
|
||||
// channel names, message bodies) produces false positives
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
pkVictim = "f7181c468dfe7c55aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
pkAttacker = "deadbeefdeadbeefcccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
pkOther = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
// seedAttribution inserts the standard adversarial fixture used by the
|
||||
// issue #1143 tests. It returns the victim pubkey for convenience.
|
||||
func seedAttribution(t *testing.T, db *DB) string {
|
||||
t.Helper()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// (1) Legitimate ADVERT from the victim.
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('AA','h_victim_advert',?,1,4,
|
||||
'{"type":"ADVERT","pubKey":"`+pkVictim+`","name":"VictimNode"}',
|
||||
?)`, now, pkVictim)
|
||||
|
||||
// (2) Hole 1: a different node sharing the *display name* "VictimNode".
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('BB','h_namespoof_advert',?,1,4,
|
||||
'{"type":"ADVERT","pubKey":"`+pkOther+`","name":"VictimNode"}',
|
||||
?)`, now, pkOther)
|
||||
|
||||
// (3) Hole 2a: malicious node whose *name* is the victim's pubkey.
|
||||
// decoded_json contains pkVictim as a substring (in the name field),
|
||||
// but the actual originator is pkAttacker.
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('CC','h_spoof_advert',?,1,4,
|
||||
'{"type":"ADVERT","pubKey":"`+pkAttacker+`","name":"`+pkVictim+`"}',
|
||||
?)`, now, pkAttacker)
|
||||
|
||||
// (4) Hole 2b: free-text packet (e.g. channel message) whose body
|
||||
// coincidentally contains the victim's pubkey as a substring.
|
||||
// Real originator is pkAttacker; from_pubkey reflects that.
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('DD','h_freetext_msg',?,1,5,
|
||||
'{"type":"GRP_TXT","text":"hello `+pkVictim+` how are you"}',
|
||||
?)`, now, pkAttacker)
|
||||
|
||||
return pkVictim
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *DB, q string, args ...interface{}) {
|
||||
t.Helper()
|
||||
if _, err := db.conn.Exec(q, args...); err != nil {
|
||||
t.Fatalf("exec failed: %v\nquery: %s", err, q)
|
||||
}
|
||||
}
|
||||
|
||||
func hashesOf(rows []map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if h, ok := r["hash"].(string); ok {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_Hole1_SameNameDifferentPubkey(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
for _, h := range hashes {
|
||||
if h == "h_namespoof_advert" {
|
||||
t.Fatalf("Hole 1: same-name node was attributed to the victim. got hashes=%v", hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_Hole2a_PubkeyAsNameSpoof(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
for _, h := range hashes {
|
||||
if h == "h_spoof_advert" {
|
||||
t.Fatalf("Hole 2a: attacker who named themselves with victim's pubkey "+
|
||||
"was attributed to the victim. got hashes=%v", hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_Hole2b_FreeTextHexFalsePositive(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
for _, h := range hashes {
|
||||
if h == "h_freetext_msg" {
|
||||
t.Fatalf("Hole 2b: free-text containing the victim's pubkey as a "+
|
||||
"substring produced a false positive. got hashes=%v", hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_LegitimateAdvertReturned(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
found := false
|
||||
for _, h := range hashes {
|
||||
if h == "h_victim_advert" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected legitimate victim advert (h_victim_advert) in result, got %v", hashes)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-pubkey OR query (#1143 — db.go:1785) ---
|
||||
|
||||
func TestQueryMultiNodePackets_ExactMatchOnly(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedAttribution(t, db)
|
||||
|
||||
// Query the victim's pubkey via the multi-node API. The malicious
|
||||
// "name = victim pubkey" row and the free-text row must NOT show up.
|
||||
res, err := db.QueryMultiNodePackets([]string{pkVictim}, 50, 0, "DESC", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes := hashesOf(res.Packets)
|
||||
for _, bad := range []string{"h_spoof_advert", "h_freetext_msg", "h_namespoof_advert"} {
|
||||
for _, h := range hashes {
|
||||
if h == bad {
|
||||
t.Fatalf("QueryMultiNodePackets returned spurious match %q (pubkey %s as substring); hashes=%v",
|
||||
bad, pkVictim, hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The legitimate one must still be present.
|
||||
if !contains(hashes, "h_victim_advert") {
|
||||
t.Fatalf("expected h_victim_advert in QueryMultiNodePackets result, got %v", hashes)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Index sanity check (#1143 perf): verify EXPLAIN QUERY PLAN uses the
|
||||
// new index, not a SCAN. ---
|
||||
|
||||
func TestFromPubkeyIndexUsed(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
mustExec(t, db, `CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`)
|
||||
|
||||
rows, err := db.conn.Query(
|
||||
`EXPLAIN QUERY PLAN SELECT id FROM transmissions WHERE from_pubkey = ?`,
|
||||
pkVictim)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
plan := ""
|
||||
for rows.Next() {
|
||||
var id, parent, notused int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, ¬used, &detail); err == nil {
|
||||
plan += detail + "\n"
|
||||
}
|
||||
}
|
||||
if !strings.Contains(plan, "idx_transmissions_from_pubkey") {
|
||||
t.Fatalf("expected EXPLAIN QUERY PLAN to use idx_transmissions_from_pubkey, got:\n%s", plan)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromPubkeyIndexUsedForInClause verifies the index is used for the
|
||||
// IN (?, ?, ...) query path used by QueryMultiNodePackets (db.go ~1787).
|
||||
// Coverage extension — the equality path is covered above; this asserts
|
||||
// the multi-node path doesn't silently regress to a full scan when the
|
||||
// planner can't use the index for set membership.
|
||||
func TestFromPubkeyIndexUsedForInClause(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
mustExec(t, db, `CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`)
|
||||
|
||||
rows, err := db.conn.Query(
|
||||
`EXPLAIN QUERY PLAN SELECT id FROM transmissions WHERE from_pubkey IN (?, ?)`,
|
||||
pkVictim, pkOther)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
plan := ""
|
||||
for rows.Next() {
|
||||
var id, parent, notused int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, ¬used, &detail); err == nil {
|
||||
plan += detail + "\n"
|
||||
}
|
||||
}
|
||||
if !strings.Contains(plan, "idx_transmissions_from_pubkey") {
|
||||
t.Fatalf("expected EXPLAIN QUERY PLAN for IN(...) to use idx_transmissions_from_pubkey, got:\n%s", plan)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Migration / backfill ---
|
||||
|
||||
func TestBackfillFromPubkey_AdvertRowsPopulated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := dir + "/test.db"
|
||||
|
||||
// Create a legacy-style DB: transmissions table WITHOUT from_pubkey,
|
||||
// then run ensureFromPubkeyColumn to ALTER it in.
|
||||
rw, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec(`CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
|
||||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
||||
decoded_json TEXT, created_at TEXT
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two ADVERTs (different pubkeys) and a non-ADVERT.
|
||||
if _, err := rw.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type, decoded_json) VALUES
|
||||
('AA','m1','2026-01-01T00:00:00Z',4,'{"type":"ADVERT","pubKey":"`+pkVictim+`","name":"V"}'),
|
||||
('BB','m2','2026-01-01T00:00:00Z',4,'{"type":"ADVERT","pubKey":"`+pkOther+`","name":"O"}'),
|
||||
('CC','m3','2026-01-01T00:00:00Z',5,'{"type":"GRP_TXT","text":"hi"}')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw.Close()
|
||||
|
||||
if err := ensureFromPubkeyColumn(dbPath); err != nil {
|
||||
t.Fatalf("ensureFromPubkeyColumn: %v", err)
|
||||
}
|
||||
|
||||
// Run synchronously by calling the function directly.
|
||||
backfillFromPubkeyAsync(dbPath, 100, 0)
|
||||
|
||||
// Verify backfill populated the ADVERT rows.
|
||||
rw2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rw2.Close()
|
||||
rows, err := rw2.Query("SELECT hash, from_pubkey FROM transmissions ORDER BY hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
got := map[string]string{}
|
||||
for rows.Next() {
|
||||
var h string
|
||||
var pk sql.NullString
|
||||
if err := rows.Scan(&h, &pk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[h] = pk.String
|
||||
}
|
||||
if got["m1"] != pkVictim {
|
||||
t.Errorf("m1 from_pubkey = %q, want %q", got["m1"], pkVictim)
|
||||
}
|
||||
if got["m2"] != pkOther {
|
||||
t.Errorf("m2 from_pubkey = %q, want %q", got["m2"], pkOther)
|
||||
}
|
||||
// Non-ADVERT row was not in the backfill scope; from_pubkey stays NULL.
|
||||
if got["m3"] != "" {
|
||||
t.Errorf("m3 from_pubkey = %q, want empty (NULL)", got["m3"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillFromPubkey_DoesNotBlockBoot exercises the async contract:
|
||||
// main.go (cmd/server/main.go) calls startFromPubkeyBackfill, which is the
|
||||
// SAME entry point used at production startup. The wrapper must dispatch
|
||||
// the backfill in a goroutine; if anyone removes the `go` keyword inside
|
||||
// startFromPubkeyBackfill, this test fails because the call no longer
|
||||
// returns within the 50ms boot dispatch budget. The test does NOT use `go`
|
||||
// itself — that would test only the test's own scheduler, not the
|
||||
// production code path (cycle-3 M1c).
|
||||
//
|
||||
// DO NOT t.Parallel — uses package-global atomics
|
||||
// (fromPubkeyBackfillTotal/Processed/Done). Concurrent tests would clobber
|
||||
// the resets (cycle-3 m1c).
|
||||
func TestBackfillFromPubkey_DoesNotBlockBoot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := dir + "/async_boot.db"
|
||||
|
||||
rw, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec(`CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
|
||||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
||||
decoded_json TEXT, created_at TEXT
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Insert N=1000 legacy ADVERT rows. With chunkSize=100 + yield=100ms
|
||||
// between chunks, sync would be ~900ms; we assert dispatch is <50ms.
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, payload_type, decoded_json) VALUES (?, ?, ?, 4, ?)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const N = 1000
|
||||
for i := 0; i < N; i++ {
|
||||
hash := fmt.Sprintf("h_async_boot_%d", i)
|
||||
dj := fmt.Sprintf(`{"type":"ADVERT","pubKey":"%s","name":"N%d"}`, pkVictim, i)
|
||||
if _, err := stmt.Exec("AA", hash, "2026-01-01T00:00:00Z", dj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw.Close()
|
||||
|
||||
if err := ensureFromPubkeyColumn(dbPath); err != nil {
|
||||
t.Fatalf("ensureFromPubkeyColumn: %v", err)
|
||||
}
|
||||
|
||||
// Reset all backfill state — other tests may have set it.
|
||||
fromPubkeyBackfillReset()
|
||||
defer fromPubkeyBackfillReset()
|
||||
|
||||
// Dispatch via the production wrapper. startFromPubkeyBackfill is the
|
||||
// same entry point main.go calls at boot; it must launch the backfill
|
||||
// in a goroutine internally. We deliberately do NOT prefix `go` here —
|
||||
// if the wrapper is ever made synchronous, the dispatch budget below
|
||||
// fires first.
|
||||
t0 := time.Now()
|
||||
startFromPubkeyBackfill(dbPath, 100, 100*time.Millisecond)
|
||||
dispatchElapsed := time.Since(t0)
|
||||
|
||||
// (a) Boot-time dispatch budget: must return ~immediately.
|
||||
if dispatchElapsed > 50*time.Millisecond {
|
||||
t.Fatalf("backfill dispatch took %v (>50ms): not async — would block boot", dispatchElapsed)
|
||||
}
|
||||
|
||||
// (b) Eventual completion via the fromPubkeyBackfill snapshot.
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, _, done := fromPubkeyBackfillSnapshot(); done {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if _, _, done := fromPubkeyBackfillSnapshot(); !done {
|
||||
t.Fatalf("backfill never flipped Done within 30s; dispatched=%v", dispatchElapsed)
|
||||
}
|
||||
|
||||
// (c) Backfill actually populated rows.
|
||||
rw2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rw2.Close()
|
||||
var nullCount int
|
||||
if err := rw2.QueryRow(
|
||||
`SELECT COUNT(*) FROM transmissions WHERE payload_type = 4 AND from_pubkey IS NULL`,
|
||||
).Scan(&nullCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nullCount > 0 {
|
||||
t.Errorf("backfill left %d ADVERT rows with NULL from_pubkey", nullCount)
|
||||
}
|
||||
if _, processed, _ := fromPubkeyBackfillSnapshot(); processed != int64(N) {
|
||||
t.Errorf("fromPubkeyBackfillProcessed = %d, want %d", processed, N)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package main
|
||||
|
||||
// from_pubkey migration (#1143).
|
||||
//
|
||||
// Adds the `transmissions.from_pubkey` column + index, and provides an async
|
||||
// backfill that populates the column from `decoded_json` for ADVERT packets
|
||||
// whose `from_pubkey` is still NULL.
|
||||
//
|
||||
// Why a column at all: the legacy attribution path used
|
||||
// `WHERE decoded_json LIKE '%pubkey%'` (and `OR LIKE '%name%'`). This is
|
||||
// structurally unsound (adversarial spoofing + accidental hex-substring
|
||||
// false positives + full table scan). The column gives us exact match,
|
||||
// O(log n) lookups, and an explicit, auditable attribution surface.
|
||||
//
|
||||
// Backfill is run async (best-effort) so it cannot block server startup
|
||||
// even on prod-sized DBs (100K+ transmissions). Queries handle NULL
|
||||
// gracefully (return empty for that pubkey, same as today's behaviour
|
||||
// for unknown pubkeys).
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ensureFromPubkeyColumn adds the from_pubkey column + index to the
|
||||
// transmissions table if missing. Safe to call repeatedly.
|
||||
func ensureFromPubkeyColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := tableHasColumn(rw, "transmissions", "from_pubkey")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect transmissions: %w", err)
|
||||
}
|
||||
if !has {
|
||||
if _, err := rw.Exec("ALTER TABLE transmissions ADD COLUMN from_pubkey TEXT"); err != nil {
|
||||
return fmt.Errorf("add from_pubkey column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added from_pubkey column to transmissions (#1143)")
|
||||
}
|
||||
|
||||
if _, err := rw.Exec("CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)"); err != nil {
|
||||
return fmt.Errorf("create idx_transmissions_from_pubkey: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fromPubkeyBackfillProgress reports backfill state for /api/healthz.
|
||||
// All three values are read together via fromPubkeyBackfillSnapshot()
|
||||
// under a single RWMutex so /api/healthz never sees a torn snapshot
|
||||
// (e.g. done=true with processed<total). Updates use the Set/Mark
|
||||
// helpers which take the write lock.
|
||||
//
|
||||
// Cycle-3 m2c: previously these were independent atomic.{Int64,Bool};
|
||||
// healthz read each one separately and could observe an interleaved
|
||||
// write between Loads. The mutex-guarded snapshot fixes that.
|
||||
var (
|
||||
fromPubkeyBackfillMu sync.RWMutex
|
||||
fromPubkeyBackfillTotal int64
|
||||
fromPubkeyBackfillProcessed int64
|
||||
fromPubkeyBackfillDone bool
|
||||
)
|
||||
|
||||
// fromPubkeyBackfillSnapshot returns a consistent snapshot of all three
|
||||
// backfill progress fields under a single read lock.
|
||||
func fromPubkeyBackfillSnapshot() (total, processed int64, done bool) {
|
||||
fromPubkeyBackfillMu.RLock()
|
||||
defer fromPubkeyBackfillMu.RUnlock()
|
||||
return fromPubkeyBackfillTotal, fromPubkeyBackfillProcessed, fromPubkeyBackfillDone
|
||||
}
|
||||
|
||||
func fromPubkeyBackfillSetTotal(v int64) {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillTotal = v
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
func fromPubkeyBackfillSetProcessed(v int64) {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillProcessed = v
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
func fromPubkeyBackfillMarkDone() {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillDone = true
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
// fromPubkeyBackfillReset zeroes all three fields atomically. Used by
|
||||
// tests; never called from production code.
|
||||
func fromPubkeyBackfillReset() {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillTotal = 0
|
||||
fromPubkeyBackfillProcessed = 0
|
||||
fromPubkeyBackfillDone = false
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
// startFromPubkeyBackfill is the production entry point used by main.go to
|
||||
// launch the backfill so it cannot block startup. It MUST dispatch the
|
||||
// backfill in a goroutine; the dispatch path is gated by
|
||||
// TestBackfillFromPubkey_DoesNotBlockBoot — if the `go` keyword below is ever
|
||||
// removed, that test fails because dispatch becomes synchronous and exceeds
|
||||
// the 50ms boot budget.
|
||||
func startFromPubkeyBackfill(dbPath string, chunkSize int, yieldDuration time.Duration) {
|
||||
// MUST stay `go` — TestBackfillFromPubkey_DoesNotBlockBoot fails if
|
||||
// this becomes synchronous (boot dispatch budget exceeds 50ms).
|
||||
go backfillFromPubkeyAsync(dbPath, chunkSize, yieldDuration)
|
||||
}
|
||||
|
||||
// backfillFromPubkeyAsync scans transmissions where from_pubkey IS NULL and
|
||||
// populates from_pubkey by parsing decoded_json. Runs in chunks with a
|
||||
// short yield between chunks so it can't starve other writers.
|
||||
//
|
||||
// Strategy:
|
||||
// - ADVERT (payload_type = 4) -> decoded_json.pubKey
|
||||
// - other types -> leave NULL (queries handle NULL gracefully)
|
||||
//
|
||||
// chunkSize and yieldDuration are tunable for tests.
|
||||
func backfillFromPubkeyAsync(dbPath string, chunkSize int, yieldDuration time.Duration) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[store] backfillFromPubkeyAsync panic recovered: %v", r)
|
||||
}
|
||||
fromPubkeyBackfillMarkDone()
|
||||
}()
|
||||
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 5000
|
||||
}
|
||||
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: open rw error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := rw.QueryRow(
|
||||
"SELECT COUNT(*) FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4",
|
||||
).Scan(&total); err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: count error: %v", err)
|
||||
return
|
||||
}
|
||||
fromPubkeyBackfillSetTotal(total)
|
||||
if total == 0 {
|
||||
log.Println("[store] from_pubkey backfill: nothing to do")
|
||||
return
|
||||
}
|
||||
log.Printf("[store] from_pubkey backfill starting: %d ADVERT rows", total)
|
||||
|
||||
updateStmt, err := rw.Prepare("UPDATE transmissions SET from_pubkey = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: prepare update: %v", err)
|
||||
return
|
||||
}
|
||||
defer updateStmt.Close()
|
||||
|
||||
var processed int64
|
||||
for {
|
||||
rows, err := rw.Query(
|
||||
"SELECT id, decoded_json FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4 LIMIT ?",
|
||||
chunkSize)
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: select error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
type row struct {
|
||||
id int64
|
||||
pk string
|
||||
}
|
||||
batch := make([]row, 0, chunkSize)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var dj sql.NullString
|
||||
if err := rows.Scan(&id, &dj); err != nil {
|
||||
continue
|
||||
}
|
||||
pk := extractPubkeyFromAdvertJSON(dj.String)
|
||||
batch = append(batch, row{id: id, pk: pk})
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Apply updates in a single tx for throughput.
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: begin tx: %v", err)
|
||||
return
|
||||
}
|
||||
txStmt := tx.Stmt(updateStmt)
|
||||
for _, b := range batch {
|
||||
// Sentinel convention for transmissions.from_pubkey (#1143, m5):
|
||||
// NULL — row has not yet been scanned by this backfill.
|
||||
// "" — scanned, no extractable pubkey (malformed/legacy ADVERT
|
||||
// decoded_json, or a JSON shape we don't understand).
|
||||
// hex — scanned, pubkey successfully extracted.
|
||||
//
|
||||
// The "" sentinel exists ONLY in this backfill path: it's how we
|
||||
// avoid the #1119 infinite-rescan loop (the WHERE clause is
|
||||
// `from_pubkey IS NULL`, so once we mark a row "" it never matches
|
||||
// again). The ingest write path (cmd/ingestor/db.go ~1289) leaves
|
||||
// from_pubkey NULL when PubKey is empty; the two states are
|
||||
// semantically equivalent ("we have no pubkey for this row") and
|
||||
// all attribution call sites query `from_pubkey = ?` with a real
|
||||
// pubkey, so neither NULL nor "" matches — no UX divergence.
|
||||
var val interface{}
|
||||
if b.pk != "" {
|
||||
val = b.pk
|
||||
} else {
|
||||
val = "" // scanned, no extractable pubkey — see comment above
|
||||
}
|
||||
if _, err := txStmt.Exec(val, b.id); err != nil {
|
||||
// non-fatal; log first failure per chunk and keep going
|
||||
log.Printf("[store] from_pubkey backfill: update id=%d: %v", b.id, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: commit: %v", err)
|
||||
return
|
||||
}
|
||||
processed += int64(len(batch))
|
||||
fromPubkeyBackfillSetProcessed(processed)
|
||||
|
||||
if len(batch) < chunkSize {
|
||||
break
|
||||
}
|
||||
if yieldDuration > 0 {
|
||||
time.Sleep(yieldDuration)
|
||||
}
|
||||
}
|
||||
log.Printf("[store] from_pubkey backfill complete: %d rows processed", processed)
|
||||
}
|
||||
|
||||
// extractPubkeyFromAdvertJSON parses an ADVERT decoded_json blob and returns
|
||||
// the pubKey field, or "" if absent/invalid. Lenient: any parse error yields
|
||||
// the empty string rather than a panic.
|
||||
func extractPubkeyFromAdvertJSON(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m["pubKey"].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -22,6 +22,10 @@ require github.com/meshcore-analyzer/dbconfig v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/dbconfig => ../../internal/dbconfig
|
||||
|
||||
require github.com/meshcore-analyzer/perfio v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/perfio => ../../internal/perfio
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -34,10 +34,22 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
s.store.mu.RUnlock()
|
||||
}
|
||||
|
||||
// #1143 (M2): expose from_pubkey backfill progress so operators can
|
||||
// see whether the legacy ADVERT backfill is still running. NULL rows
|
||||
// produce empty attribution results during the in-flight window.
|
||||
// Cycle-3 m2c: snapshot all three fields under a single read lock so
|
||||
// /api/healthz never observes a torn state (e.g. done=true with
|
||||
// processed<total).
|
||||
bfTotal, bfProcessed, bfDone := fromPubkeyBackfillSnapshot()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ready": true,
|
||||
"loadedTx": loadedTx,
|
||||
"loadedObs": loadedObs,
|
||||
"from_pubkey_backfill": map[string]interface{}{
|
||||
"total": bfTotal,
|
||||
"processed": bfProcessed,
|
||||
"done": bfDone,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHealthzNotReady(t *testing.T) {
|
||||
@@ -78,3 +81,151 @@ func TestHealthzAntiTautology(t *testing.T) {
|
||||
t.Fatal("anti-tautology: handler returned 200 when readiness=0; gating is broken")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthzExposesFromPubkeyBackfill verifies the from_pubkey backfill
|
||||
// progress (#1143, M2) is observable via /api/healthz. The atomics are
|
||||
// updated by backfillFromPubkeyAsync; without exposure here they were dead
|
||||
// code. Asserts the response includes a from_pubkey_backfill object with
|
||||
// total/processed/done fields.
|
||||
func TestHealthzExposesFromPubkeyBackfill(t *testing.T) {
|
||||
readiness.Store(1)
|
||||
defer readiness.Store(0)
|
||||
|
||||
// Set known values so we can assert wiring (not just presence).
|
||||
fromPubkeyBackfillReset()
|
||||
fromPubkeyBackfillSetTotal(7)
|
||||
fromPubkeyBackfillSetProcessed(3)
|
||||
defer fromPubkeyBackfillReset()
|
||||
|
||||
srv := &Server{store: &PacketStore{}}
|
||||
req := httptest.NewRequest("GET", "/api/healthz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleHealthz(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
bf, ok := resp["from_pubkey_backfill"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing from_pubkey_backfill object in healthz response: %v", resp)
|
||||
}
|
||||
if got, want := bf["total"], float64(7); got != want {
|
||||
t.Errorf("from_pubkey_backfill.total = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bf["processed"], float64(3); got != want {
|
||||
t.Errorf("from_pubkey_backfill.processed = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bf["done"], false; got != want {
|
||||
t.Errorf("from_pubkey_backfill.done = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthzFromPubkeyBackfillConsistentSnapshot exercises cycle-3 m2c:
|
||||
// the handler used to read three independent atomics (Total/Processed/Done)
|
||||
// in sequence, so a backfill update interleaved between reads could yield
|
||||
// an inconsistent snapshot (e.g. done=true with processed<total, or
|
||||
// processed>total when total is updated last). This test races concurrent
|
||||
// progress updates against many healthz reads and asserts every snapshot
|
||||
// satisfies the invariants:
|
||||
//
|
||||
// processed <= total
|
||||
// if done: processed == total (or both 0 — nothing to do)
|
||||
//
|
||||
// With the pre-fix code (separate atomic.Load calls), this fires within
|
||||
// a few hundred iterations on a multi-core box. With the RWMutex-guarded
|
||||
// snapshot, it never fires.
|
||||
func TestHealthzFromPubkeyBackfillConsistentSnapshot(t *testing.T) {
|
||||
readiness.Store(1)
|
||||
defer readiness.Store(0)
|
||||
defer fromPubkeyBackfillReset()
|
||||
|
||||
srv := &Server{store: &PacketStore{}}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var writerWg sync.WaitGroup
|
||||
var readerWg sync.WaitGroup
|
||||
|
||||
// Writer: simulates the backfill loop — sets total, then increments
|
||||
// processed in lock-step, occasionally finishing (done=true with
|
||||
// processed==total). Each "tick" mutates all three values.
|
||||
writerWg.Add(1)
|
||||
go func() {
|
||||
defer writerWg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
fromPubkeyBackfillSetTotal(100)
|
||||
for p := int64(0); p <= 100; p++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
fromPubkeyBackfillSetProcessed(p)
|
||||
}
|
||||
fromPubkeyBackfillMarkDone()
|
||||
fromPubkeyBackfillReset()
|
||||
}
|
||||
}()
|
||||
|
||||
// Readers: hammer healthz, assert invariants on each response.
|
||||
const readers = 8
|
||||
const reads = 200
|
||||
errs := make(chan string, readers*reads)
|
||||
for i := 0; i < readers; i++ {
|
||||
readerWg.Add(1)
|
||||
go func() {
|
||||
defer readerWg.Done()
|
||||
for j := 0; j < reads; j++ {
|
||||
req := httptest.NewRequest("GET", "/api/healthz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleHealthz(w, req)
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
errs <- "invalid JSON: " + err.Error()
|
||||
return
|
||||
}
|
||||
bf, _ := resp["from_pubkey_backfill"].(map[string]interface{})
|
||||
total, _ := bf["total"].(float64)
|
||||
processed, _ := bf["processed"].(float64)
|
||||
done, _ := bf["done"].(bool)
|
||||
if processed > total {
|
||||
errs <- "processed>total snapshot: processed=" + ftoa(processed) + " total=" + ftoa(total)
|
||||
return
|
||||
}
|
||||
if done && processed != total {
|
||||
errs <- "done=true but processed!=total: processed=" + ftoa(processed) + " total=" + ftoa(total)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for readers to complete (bounded by 'reads' iterations), then
|
||||
// stop the writer and drain.
|
||||
readerDone := make(chan struct{})
|
||||
go func() { readerWg.Wait(); close(readerDone) }()
|
||||
select {
|
||||
case <-readerDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
close(stop)
|
||||
writerWg.Wait()
|
||||
t.Fatal("timed out waiting for reader goroutines")
|
||||
}
|
||||
close(stop)
|
||||
writerWg.Wait()
|
||||
|
||||
close(errs)
|
||||
for e := range errs {
|
||||
t.Errorf("inconsistent snapshot: %s", e)
|
||||
}
|
||||
}
|
||||
|
||||
func ftoa(f float64) string { return fmt.Sprintf("%g", f) }
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// BenchmarkBuildHopContextPubkeys exercises the hot per-tx context builder
|
||||
// at a realistic shape: ~50 nodes (mixed role), 6-hop path, sender + observer
|
||||
// pubkey populated. Required by AGENTS.md hot-path benchmark rule (#1197 r1
|
||||
// carmack #6).
|
||||
func BenchmarkBuildHopContextPubkeys(b *testing.B) {
|
||||
nodes := make([]nodeInfo, 0, 64)
|
||||
for i := 0; i < 50; i++ {
|
||||
nodes = append(nodes, nodeInfo{
|
||||
PublicKey: fmt.Sprintf("%012x", i*0x101010101),
|
||||
Role: "repeater",
|
||||
Name: fmt.Sprintf("N%d", i),
|
||||
ObservationCount: i * 3,
|
||||
Lat: 37.0 + float64(i)*0.01,
|
||||
Lon: -122.0 - float64(i)*0.01,
|
||||
HasGPS: true,
|
||||
})
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
hops := []string{
|
||||
nodes[1].PublicKey[:6], nodes[3].PublicKey[:6], nodes[5].PublicKey[:6],
|
||||
nodes[7].PublicKey[:6], nodes[9].PublicKey[:6], nodes[11].PublicKey[:6],
|
||||
}
|
||||
pathJSON, _ := json.Marshal(hops)
|
||||
decoded, _ := json.Marshal(map[string]interface{}{"pubKey": "cc4444444444"})
|
||||
tx := &StoreTx{
|
||||
PathJSON: string(pathJSON),
|
||||
DecodedJSON: string(decoded),
|
||||
ObserverID: "dd5555555555",
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = buildHopContextPubkeys(tx, pm)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuildAggregateHopContextPubkeys exercises the aggregate context
|
||||
// builder at the hot scale called out by #1197 (subpath/topology bulk
|
||||
// aggregations): ~5k txs sharing a node pool of ~50 prefixes. The aggregate
|
||||
// builder unions per-tx contexts with its own dedupe map; this benchmark
|
||||
// gives us a baseline so a future regression (e.g. accidental O(n²) dedupe)
|
||||
// shows up immediately. No assertion threshold yet — see #1199 item 3.
|
||||
func BenchmarkBuildAggregateHopContextPubkeys(b *testing.B) {
|
||||
const numNodes = 50
|
||||
const numTxs = 5000
|
||||
|
||||
nodes := make([]nodeInfo, 0, numNodes)
|
||||
for i := 0; i < numNodes; i++ {
|
||||
nodes = append(nodes, nodeInfo{
|
||||
PublicKey: fmt.Sprintf("%012x", i*0x101010101),
|
||||
Role: "repeater",
|
||||
Name: fmt.Sprintf("N%d", i),
|
||||
ObservationCount: i * 3,
|
||||
Lat: 37.0 + float64(i)*0.01,
|
||||
Lon: -122.0 - float64(i)*0.01,
|
||||
HasGPS: true,
|
||||
})
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
txs := make([]*StoreTx, 0, numTxs)
|
||||
for i := 0; i < numTxs; i++ {
|
||||
hops := []string{
|
||||
nodes[(i+1)%numNodes].PublicKey[:6],
|
||||
nodes[(i+3)%numNodes].PublicKey[:6],
|
||||
nodes[(i+5)%numNodes].PublicKey[:6],
|
||||
nodes[(i+7)%numNodes].PublicKey[:6],
|
||||
nodes[(i+9)%numNodes].PublicKey[:6],
|
||||
nodes[(i+11)%numNodes].PublicKey[:6],
|
||||
}
|
||||
pathJSON, _ := json.Marshal(hops)
|
||||
decoded, _ := json.Marshal(map[string]interface{}{
|
||||
"pubKey": fmt.Sprintf("cc%010x", i),
|
||||
})
|
||||
txs = append(txs, &StoreTx{
|
||||
PathJSON: string(pathJSON),
|
||||
DecodedJSON: string(decoded),
|
||||
ObserverID: fmt.Sprintf("dd%010x", i%32),
|
||||
})
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = buildAggregateHopContextPubkeys(txs, pm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAggregateHopContextPubkeysSmoke is a tiny correctness anchor for
|
||||
// the aggregate helper: union over per-tx contexts, deduped. Lives next to
|
||||
// the benchmark so the file ships an assertion (preflight gate). See #1199
|
||||
// item 3.
|
||||
func TestBuildAggregateHopContextPubkeysSmoke(t *testing.T) {
|
||||
pm := buildPrefixMap([]nodeInfo{{PublicKey: "aabbccddeeff"}})
|
||||
d1, _ := json.Marshal(map[string]interface{}{"pubKey": "1111111111"})
|
||||
d2, _ := json.Marshal(map[string]interface{}{"pubKey": "2222222222"})
|
||||
d3, _ := json.Marshal(map[string]interface{}{"pubKey": "1111111111"}) // dup
|
||||
txs := []*StoreTx{
|
||||
{DecodedJSON: string(d1)},
|
||||
{DecodedJSON: string(d2)},
|
||||
{DecodedJSON: string(d3)},
|
||||
}
|
||||
got := buildAggregateHopContextPubkeys(txs, pm)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 deduped pubkeys, got %d (%v)", len(got), got)
|
||||
}
|
||||
// Content assertion — proves dedup actually keeps the right pubkeys
|
||||
// (not just any 2). Without this the test would pass even if dedup
|
||||
// returned, e.g., one pubkey twice or two unrelated pubkeys. See
|
||||
// #1199 r1 review (adv #1).
|
||||
wantSet := map[string]bool{"1111111111": true, "2222222222": true}
|
||||
gotSet := map[string]bool{}
|
||||
for _, pk := range got {
|
||||
gotSet[pk] = true
|
||||
}
|
||||
for pk := range wantSet {
|
||||
if !gotSet[pk] {
|
||||
t.Fatalf("expected pubkey %q in deduped result, got %v", pk, got)
|
||||
}
|
||||
}
|
||||
for pk := range gotSet {
|
||||
if !wantSet[pk] {
|
||||
t.Fatalf("unexpected pubkey %q in deduped result, got %v", pk, got)
|
||||
}
|
||||
}
|
||||
if buildAggregateHopContextPubkeys(nil, pm) != nil {
|
||||
t.Fatalf("nil tx slice must yield nil")
|
||||
}
|
||||
if buildAggregateHopContextPubkeys(txs, nil) != nil {
|
||||
t.Fatalf("nil prefix map must yield nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// End-to-end fixture test for issue #1201 sub-task 4.
|
||||
//
|
||||
// Builds a *PacketStore with multi-candidate-prefix nodes (intentional 1-byte
|
||||
// prefix collisions across continents) and asserts that the top-hops ranking
|
||||
// produced by buildDistanceIndex honors the resolver's neighbor-affinity
|
||||
// choice, NOT the misresolution interpretations that would survive without
|
||||
// context.
|
||||
//
|
||||
// Mutation-test sentinel: this test MUST fail if any call site that feeds
|
||||
// per-tx context to the hop resolver is reverted to `nil`. Reproduce by
|
||||
// replacing the `setContext(buildHopContextPubkeys(tx, pm))` call inside
|
||||
// buildDistanceIndex (cmd/server/store.go, in the per-tx loop) with
|
||||
// `setContext(nil)` and re-running this test — it fails with a "CA↔CA hop
|
||||
// missing, saw 72dddd→8acccc (Berlin↔Berlin)" assertion. See PR body for
|
||||
// the full mutation log.
|
||||
//
|
||||
// Fixture layout (no real handles — generic placeholders only):
|
||||
// Prefix "72" (4 candidates, all repeaters with GPS):
|
||||
// - 72aa… SLO-CA (35.30, -120.70) obsCount=5
|
||||
// - 72bb… LA-CA (34.05, -118.25) obsCount=5
|
||||
// - 72cc… NYC (40.70, -74.00) obsCount=5
|
||||
// - 72dd… Berlin (52.50, 13.40) obsCount=200 ← would win tier-3
|
||||
// Prefix "8a" (3 candidates):
|
||||
// - 8aaa… SF-CA (37.00, -120.50) obsCount=5
|
||||
// - 8abb… CA-other (36.50, -119.50) obsCount=5
|
||||
// - 8acc… Berlin (52.60, 13.50) obsCount=200 ← would win tier-3
|
||||
//
|
||||
// Sender: CA repeater at (36.0, -120.0), pubkey "ccc…".
|
||||
// Observer: CA repeater at (36.2, -120.2), pubkey "dddd…".
|
||||
//
|
||||
// Affinity graph: strong edges sender↔72aa and sender↔8aaa
|
||||
// (count ≥ affinityMinObservations, recent timestamps).
|
||||
//
|
||||
// 50 synthetic transmissions, all with path ["72","8a"]. With per-tx context
|
||||
// piped through (sender pubkey is added by buildHopContextPubkeys), tier 1
|
||||
// picks the CA candidates. Without it, tier 3 picks the Berlin candidates
|
||||
// and the Berlin↔Berlin hop (~11 km — under 300 km cap) becomes the only
|
||||
// surviving hop. The test asserts the inverse: CA↔CA hop present, no
|
||||
// Berlin pubkeys appear in distHops.
|
||||
|
||||
const (
|
||||
t1201Sender = "ccccccccccccccc1"
|
||||
t1201Observer = "dddddddddddddddd"
|
||||
|
||||
t1201_72aa = "72aaaaaaaaaaaaaa" // SLO
|
||||
t1201_72bb = "72bbbbbbbbbbbbbb" // LA
|
||||
t1201_72cc = "72cccccccccccccc" // NYC
|
||||
t1201_72dd = "72dddddddddddddd" // Berlin
|
||||
|
||||
t1201_8aaa = "8aaaaaaaaaaaaaaa" // SF
|
||||
t1201_8abb = "8abbbbbbbbbbbbbb" // CA-other
|
||||
t1201_8acc = "8acccccccccccccc" // Berlin
|
||||
)
|
||||
|
||||
type t1201Node struct {
|
||||
pk string
|
||||
lat, lon float64
|
||||
obsCount int
|
||||
}
|
||||
|
||||
func t1201InsertNode(t *testing.T, db *DB, n t1201Node) {
|
||||
t.Helper()
|
||||
// NOTE: `obsCount` is written to the `advert_count` column. That column
|
||||
// is what resolveWithContext reads (via nodeInfo.ObservationCount /
|
||||
// betterByObsCount) as the tier-3 popularity tiebreak. If the tier-3
|
||||
// source column ever changes (e.g. observations.packet_count), the
|
||||
// "Berlin would win tier-3" premise of this fixture weakens silently —
|
||||
// update both this insert and the candidate scoring assertions.
|
||||
_, err := db.conn.Exec(
|
||||
`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) VALUES (?, ?, 'repeater', ?, ?, ?, '2026-01-01T00:00:00Z', ?)`,
|
||||
n.pk, "node-"+n.pk[:4], n.lat, n.lon, "2026-05-01T00:00:00Z", n.obsCount,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert node %s: %v", n.pk, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTopHopsRespectsContextAcrossAllCallSites is the end-to-end regression
|
||||
// sentinel for issue #1201. See file-header docblock for design.
|
||||
func TestTopHopsRespectsContextAcrossAllCallSites(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
// Insert all repeater nodes with GPS + observation counts.
|
||||
nodes := []t1201Node{
|
||||
{t1201Sender, 36.0, -120.0, 50},
|
||||
{t1201Observer, 36.2, -120.2, 60},
|
||||
|
||||
{t1201_72aa, 35.30, -120.70, 5},
|
||||
{t1201_72bb, 34.05, -118.25, 5},
|
||||
{t1201_72cc, 40.70, -74.00, 5},
|
||||
{t1201_72dd, 52.50, 13.40, 200}, // would win tier-3 without context
|
||||
|
||||
{t1201_8aaa, 37.00, -120.50, 5},
|
||||
{t1201_8abb, 36.50, -119.50, 5},
|
||||
{t1201_8acc, 52.60, 13.50, 200}, // would win tier-3 without context
|
||||
}
|
||||
for _, n := range nodes {
|
||||
t1201InsertNode(t, db, n)
|
||||
}
|
||||
|
||||
// Insert observer row (referenced by observations via observer_idx).
|
||||
if _, err := db.conn.Exec(
|
||||
`INSERT INTO observers (id, name, last_seen, first_seen, packet_count) VALUES (?, ?, ?, '2026-01-01T00:00:00Z', 100)`,
|
||||
t1201Observer, "obs-ca", "2026-05-01T00:00:00Z",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Insert 50 transmissions, each with path ["72","8a"], sender pubkey
|
||||
// embedded in decoded_json (read by buildHopContextPubkeys via ParsedDecoded).
|
||||
// Wrapped in a single BEGIN/COMMIT — shaves wall time on slow CI runners.
|
||||
decoded, _ := json.Marshal(map[string]interface{}{"pubKey": t1201Sender, "type": "data"})
|
||||
pathJSON := `["72","8a"]`
|
||||
baseTime := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
ts := baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339)
|
||||
hash := fmt.Sprintf("hash1201_%03d", i)
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) VALUES (?, ?, ?, 1, 1, ?)`,
|
||||
"AA", hash, ts, string(decoded),
|
||||
)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?, 1, 12.0, -90, ?, ?)`,
|
||||
txID, pathJSON, baseTime.Add(time.Duration(i)*time.Minute).Unix(),
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit tx: %v", err)
|
||||
}
|
||||
|
||||
// Build store and seed graph BEFORE Load() — Load calls buildDistanceIndex
|
||||
// which reads s.graph; if it's nil, tier 1 is skipped.
|
||||
store := NewPacketStore(db, nil)
|
||||
g := NewNeighborGraph()
|
||||
// Strong sender↔72aa and sender↔8aaa edges (count well above
|
||||
// affinityMinObservations, recent timestamp).
|
||||
now := time.Now()
|
||||
for i := 0; i < 100; i++ {
|
||||
g.upsertEdge(t1201Sender, t1201_72aa, "72", t1201Observer, nil, now.Add(-time.Duration(i)*time.Minute))
|
||||
g.upsertEdge(t1201Sender, t1201_8aaa, "8a", t1201Observer, nil, now.Add(-time.Duration(i)*time.Minute))
|
||||
}
|
||||
// Weaker sender↔Berlin edges so even if someone weakens the ratio guard,
|
||||
// the CA candidates still dominate by 100× — and the Berlin counts in
|
||||
// node table don't bleed through.
|
||||
for i := 0; i < 2; i++ {
|
||||
g.upsertEdge(t1201Sender, t1201_72dd, "72", t1201Observer, nil, now.Add(-time.Duration(i)*time.Hour))
|
||||
}
|
||||
store.graph = g
|
||||
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
// Inspect precomputed distance index.
|
||||
store.mu.RLock()
|
||||
hops := make([]distHopRecord, len(store.distHops))
|
||||
copy(hops, store.distHops)
|
||||
store.mu.RUnlock()
|
||||
|
||||
if len(hops) == 0 {
|
||||
t.Fatal("buildDistanceIndex produced zero hops; expected at least the CA↔CA leg")
|
||||
}
|
||||
|
||||
// Assertion 1: CA↔CA hop between 72aa (SLO) and 8aaa (SF) must appear.
|
||||
pairHas := func(h *distHopRecord, a, b string) bool {
|
||||
return (h.FromPk == a && h.ToPk == b) || (h.FromPk == b && h.ToPk == a)
|
||||
}
|
||||
var sawCAPair bool
|
||||
for i := range hops {
|
||||
if pairHas(&hops[i], t1201_72aa, t1201_8aaa) {
|
||||
sawCAPair = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sawCAPair {
|
||||
// Surface what we did see so failure is debuggable.
|
||||
seen := []string{}
|
||||
for i := range hops {
|
||||
seen = append(seen, fmt.Sprintf("%s→%s@%.1fkm", hops[i].FromPk[:6], hops[i].ToPk[:6], hops[i].Dist))
|
||||
if i >= 5 {
|
||||
seen = append(seen, "…")
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected CA↔CA hop (72aa↔8aaa) in distHops; saw %v", seen)
|
||||
}
|
||||
|
||||
// Assertion 2: no hop should reference Berlin pubkeys. The Berlin↔Berlin
|
||||
// pair is the misresolution-only outcome that emerges when context is
|
||||
// dropped; its presence proves a regression at one of the call sites.
|
||||
// Note: 72cc (NYC) is omitted from this guard — its obsCount=5 would
|
||||
// never win the tier-3 obsCount-200 fight against Berlin, so checking
|
||||
// for it was redundant defense. Berlin pubkeys carry the signal.
|
||||
berlinPKs := map[string]bool{
|
||||
t1201_72dd: true,
|
||||
t1201_8acc: true,
|
||||
}
|
||||
for i := range hops {
|
||||
if berlinPKs[hops[i].FromPk] || berlinPKs[hops[i].ToPk] {
|
||||
t.Fatalf("misresolution hop leaked into distHops: %s→%s dist=%.1fkm (any call site dropped context?)",
|
||||
hops[i].FromPk, hops[i].ToPk, hops[i].Dist)
|
||||
}
|
||||
}
|
||||
|
||||
// Assertion 3: top-hop max distance must be consistent with CA geometry,
|
||||
// well under the continent-spanning misresolution range.
|
||||
maxDist := 0.0
|
||||
for i := range hops {
|
||||
if hops[i].Dist > maxDist {
|
||||
maxDist = hops[i].Dist
|
||||
}
|
||||
}
|
||||
// SLO→SF ≈ 190 km; LA→SF ≈ 560 km (>300 cap → dropped). Cap should
|
||||
// keep max well under 300. We drop the lower-bound "suspiciously small"
|
||||
// floor: the >300 ceiling carries the misresolution signal on its own,
|
||||
// and a tight floor would false-fire if a future cap tightening or
|
||||
// fixture tweak legitimately shrinks the surviving CA↔CA leg.
|
||||
if maxDist > 300 {
|
||||
t.Fatalf("top-hop max distance %.1fkm exceeds 300km cap — resolver picked continent-spanning candidate", maxDist)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Regression coverage for the hop disambiguator's tier-1 (neighbor affinity)
|
||||
// path of pm.resolveWithContext. Issue #1201: tier 1 is the strongest
|
||||
// disambiguation signal but was untested by any test we shipped — only
|
||||
// upstream tests (that predate the context-plumbing fix in #1198) exercised
|
||||
// it. These tests pin tier-1 behavior so any future refactor that disables
|
||||
// tier 1, reorders priorities, or drops the Ambiguous-edge guard will fail.
|
||||
//
|
||||
// Naming convention for fixture pubkeys: lowercase hex placeholders only;
|
||||
// no real observer/operator handles (per AGENTS.md PII rules).
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// seedAffinity adds n observations of an edge between obsPK and candPK at
|
||||
// recent timestamps. Count ≥ affinityMinObservations is required for tier 1
|
||||
// to consider an edge.
|
||||
func seedAffinity(g *NeighborGraph, obsPK, candPK, prefix, observer string, n int) {
|
||||
now := time.Now()
|
||||
for i := 0; i < n; i++ {
|
||||
g.upsertEdge(obsPK, candPK, prefix, observer, nil, now.Add(-time.Duration(i)*time.Minute))
|
||||
}
|
||||
}
|
||||
|
||||
// Standard fixture shared by most tier-1 tests: two "72" candidates and
|
||||
// (when needed) an anchor pubkey co-located with candY. candX is far
|
||||
// (Seattle), candY is near LA — so geo proximity to anchor picks candY
|
||||
// unless tier-1 fires for candX.
|
||||
var tier1StdNodes = []nodeInfo{
|
||||
{PublicKey: "72aaaaaaaaaa", Role: "repeater", Name: "candX", HasGPS: true, Lat: 47.6, Lon: -122.3}, // Seattle (far)
|
||||
{PublicKey: "72bbbbbbbbbb", Role: "repeater", Name: "candY", HasGPS: true, Lat: 34.05, Lon: -118.25}, // LA (near anchor)
|
||||
{PublicKey: "ffeeeeeeeeee", Role: "repeater", Name: "anchor", HasGPS: true, Lat: 34.1, Lon: -118.3},
|
||||
}
|
||||
|
||||
const tier1Anchor = "ffeeeeeeeeee"
|
||||
|
||||
// ─── sub-task 1: tier-1 explicit tests (table-driven) ──────────────────────────
|
||||
|
||||
// TestResolveWithContext_Tier1 collapses what were five near-identical
|
||||
// per-branch functions into one table-driven test. Each row exercises
|
||||
// exactly one tier-1 branch (strong-pick X, strong-pick Y, ambiguous-skip,
|
||||
// tier-1-beats-tier-2, fall-throughs). Adding a new tier-1 case is a
|
||||
// one-line addition.
|
||||
//
|
||||
// Mirror-pair rows (StrongAffinityPicksX / PicksY) prevent a "tier-1 always
|
||||
// returns first candidate" tautology — the score MUST be consulted because
|
||||
// flipping the weights flips the winner.
|
||||
func TestResolveWithContext_Tier1(t *testing.T) {
|
||||
type seed struct {
|
||||
obsPK, candPK, prefix string
|
||||
count int
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
nodes []nodeInfo
|
||||
ctxPK string
|
||||
useNilGraph bool // skip graph entirely (tests `graph != nil` guard)
|
||||
seeds []seed // tier-1 affinity seeds
|
||||
markAmbiguous [2]string // if non-empty pair, mark that edge ambiguous
|
||||
extraGraphSeed *seed // seed unrelated to ctxPK (empty-for-context fixture)
|
||||
wantName string
|
||||
wantMethod string
|
||||
}{
|
||||
{
|
||||
name: "StrongAffinityPicksX",
|
||||
nodes: []nodeInfo{{PublicKey: "72aaaaaaaaaa", Role: "repeater", Name: "candX", HasGPS: true, Lat: 35.3, Lon: -120.7}, {PublicKey: "72bbbbbbbbbb", Role: "repeater", Name: "candY", HasGPS: true, Lat: 34.0, Lon: -118.2}},
|
||||
ctxPK: "ccccccccccc1",
|
||||
seeds: []seed{{"ccccccccccc1", "72aaaaaaaaaa", "72", 100}, {"ccccccccccc1", "72bbbbbbbbbb", "72", 1}},
|
||||
wantName: "candX",
|
||||
wantMethod: "neighbor_affinity",
|
||||
},
|
||||
{
|
||||
name: "StrongAffinityPicksY",
|
||||
nodes: []nodeInfo{{PublicKey: "72aaaaaaaaaa", Role: "repeater", Name: "candX", HasGPS: true, Lat: 35.3, Lon: -120.7}, {PublicKey: "72bbbbbbbbbb", Role: "repeater", Name: "candY", HasGPS: true, Lat: 34.0, Lon: -118.2}},
|
||||
ctxPK: "ccccccccccc1",
|
||||
seeds: []seed{{"ccccccccccc1", "72aaaaaaaaaa", "72", 1}, {"ccccccccccc1", "72bbbbbbbbbb", "72", 100}},
|
||||
wantName: "candY",
|
||||
wantMethod: "neighbor_affinity",
|
||||
},
|
||||
{
|
||||
// Strong edge to candX exists but is flagged Ambiguous → tier 1
|
||||
// must skip it and tier 2 (geo) picks candY (near anchor).
|
||||
name: "AmbiguousEdgeSkipsToTier2",
|
||||
nodes: tier1StdNodes,
|
||||
ctxPK: tier1Anchor,
|
||||
seeds: []seed{{tier1Anchor, "72aaaaaaaaaa", "72", 100}},
|
||||
markAmbiguous: [2]string{tier1Anchor, "72aaaaaaaaaa"},
|
||||
wantName: "candY",
|
||||
wantMethod: "geo_proximity",
|
||||
},
|
||||
{
|
||||
// candX is far (affinity), candY is geo-close. Tier 1 firing
|
||||
// → candX wins. Sentinel for "geo branch hit first" regressions.
|
||||
name: "BeatsTier2WhenBothSignal",
|
||||
nodes: tier1StdNodes,
|
||||
ctxPK: tier1Anchor,
|
||||
seeds: []seed{{tier1Anchor, "72aaaaaaaaaa", "72", 100}},
|
||||
wantName: "candX",
|
||||
wantMethod: "neighbor_affinity",
|
||||
},
|
||||
{
|
||||
// Graph is non-nil but has no edges involving the context.
|
||||
// Tier 1 must short-circuit; tier 2 picks candY.
|
||||
name: "EmptyGraphFallsThrough",
|
||||
nodes: tier1StdNodes,
|
||||
ctxPK: tier1Anchor,
|
||||
extraGraphSeed: &seed{"aaaaaaaaaaa1", "aaaaaaaaaaa2", "aa", 10},
|
||||
wantName: "candY",
|
||||
wantMethod: "geo_proximity",
|
||||
},
|
||||
{
|
||||
// Graph is nil — `graph != nil` short-circuit; tier 2 decides.
|
||||
name: "NilGraphFallsThrough",
|
||||
nodes: tier1StdNodes,
|
||||
ctxPK: tier1Anchor,
|
||||
useNilGraph: true,
|
||||
wantName: "candY",
|
||||
wantMethod: "geo_proximity",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pm := buildPrefixMap(tc.nodes)
|
||||
var g *NeighborGraph
|
||||
if !tc.useNilGraph {
|
||||
g = NewNeighborGraph()
|
||||
for _, s := range tc.seeds {
|
||||
seedAffinity(g, s.obsPK, s.candPK, s.prefix, "obs1", s.count)
|
||||
}
|
||||
if tc.extraGraphSeed != nil {
|
||||
s := *tc.extraGraphSeed
|
||||
seedAffinity(g, s.obsPK, s.candPK, s.prefix, "obs1", s.count)
|
||||
}
|
||||
if tc.markAmbiguous[0] != "" {
|
||||
// Use the public helper rather than mutating
|
||||
// *NeighborEdge fields returned from AllEdges() —
|
||||
// hardens the test against any future change that
|
||||
// makes AllEdges() return copies.
|
||||
if !g.MarkAmbiguous(tc.markAmbiguous[0], tc.markAmbiguous[1], true) {
|
||||
t.Fatalf("MarkAmbiguous(%s,%s): edge not found", tc.markAmbiguous[0], tc.markAmbiguous[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r, method, _ := pm.resolveWithContext("72", []string{tc.ctxPK}, g)
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil candidate")
|
||||
}
|
||||
if r.Name != tc.wantName {
|
||||
t.Fatalf("name: want %s got %s (method=%s)", tc.wantName, r.Name, method)
|
||||
}
|
||||
if method != tc.wantMethod {
|
||||
t.Fatalf("method: want %s got %s", tc.wantMethod, method)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveWithContext_Tier1_ScoresTooCloseFallsThrough: best.score is
|
||||
// below affinityConfidenceRatio × runner-up.score (the ratio guard at the
|
||||
// end of the tier-1 block in resolveWithContext). Resolver must fall
|
||||
// through to tier 2.
|
||||
//
|
||||
// This case is kept SEPARATE from the table above because it asserts an
|
||||
// extra invariant the others don't: the returned `score` field MUST be 0
|
||||
// (tier-2 geo path returns score=0 in store.go). Pinning score==0 makes
|
||||
// the test fail loudly if affinityConfidenceRatio is ever lowered to a
|
||||
// value (≤1.25) where the 10/8 count ratio would actually clear tier 1 —
|
||||
// at that point the resolver would return a non-zero affinity score and
|
||||
// this assertion catches it, even before the wantMethod string check.
|
||||
func TestResolveWithContext_Tier1_ScoresTooCloseFallsThrough(t *testing.T) {
|
||||
pm := buildPrefixMap(tier1StdNodes)
|
||||
g := NewNeighborGraph()
|
||||
// Both above affinityMinObservations, but within 3× of each other →
|
||||
// ratio guard fails, fall-through expected.
|
||||
seedAffinity(g, tier1Anchor, "72aaaaaaaaaa", "72", "obs1", 10)
|
||||
seedAffinity(g, tier1Anchor, "72bbbbbbbbbb", "72", "obs1", 8)
|
||||
|
||||
r, method, score := pm.resolveWithContext("72", []string{tier1Anchor}, g)
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil candidate")
|
||||
}
|
||||
// Direct pin on score==0: catches a lowered affinityConfidenceRatio
|
||||
// constant that would let 10/8 clear the ratio guard and return a
|
||||
// non-zero affinity score.
|
||||
if score != 0 {
|
||||
t.Fatalf("expected tier-2 fall-through (score==0); got score=%f via %s — affinityConfidenceRatio (%v) may have been lowered to admit a 1.25× ratio",
|
||||
score, method, affinityConfidenceRatio)
|
||||
}
|
||||
if method == "neighbor_affinity" {
|
||||
t.Fatalf("tier 1 must fall through when scores are too close (< %v ratio); got method=%s",
|
||||
affinityConfidenceRatio, method)
|
||||
}
|
||||
if r.Name != "candY" {
|
||||
t.Fatalf("expected tier-2 geo to pick candY; got %s via %s", r.Name, method)
|
||||
}
|
||||
}
|
||||
@@ -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,20 @@ 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)
|
||||
}
|
||||
|
||||
// Ensure transmissions.from_pubkey column + index exists (#1143). Backfill
|
||||
// for legacy NULL rows runs async after HTTP starts so it can't block boot
|
||||
// even on prod-sized DBs (100K+ transmissions).
|
||||
if err := ensureFromPubkeyColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add transmissions.from_pubkey 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 {
|
||||
@@ -503,6 +536,11 @@ func main() {
|
||||
|
||||
// Start async backfill in background — HTTP is now available.
|
||||
go backfillResolvedPathsAsync(store, dbPath, 5000, 100*time.Millisecond, cfg.BackfillHours())
|
||||
// #1143: backfill from_pubkey for legacy ADVERT rows. Async so even
|
||||
// 100K+ rows can't block boot; queries handle NULL gracefully.
|
||||
// startFromPubkeyBackfill wraps the goroutine dispatch so the async
|
||||
// contract is testable (see TestBackfillFromPubkey_DoesNotBlockBoot).
|
||||
startFromPubkeyBackfill(dbPath, 5000, 100*time.Millisecond)
|
||||
|
||||
// Migrate old content hashes in background (one-time, idempotent).
|
||||
go migrateContentHashesAsync(store, 5000, 100*time.Millisecond)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -223,7 +223,7 @@ func (s *Server) handleDebugAffinity(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// buildResolutions generates per-prefix resolution decision logs.
|
||||
// It uses resolveWithContext (M4) to show the actual 4-tier fallback path
|
||||
// (affinity → geo → GPS → first_match) for each prefix resolution.
|
||||
// (affinity → geo → GPS → observation_count_fallback) for each prefix resolution.
|
||||
func (s *Server) buildResolutions(graph *NeighborGraph, nodeMap map[string]nodeInfo, prefixFilter, nodeFilter string) []DebugResolution {
|
||||
graph.mu.RLock()
|
||||
defer graph.mu.RUnlock()
|
||||
|
||||
@@ -162,9 +162,9 @@ func TestResolveAmbiguousEdges_FailsNoChange(t *testing.T) {
|
||||
graph.mu.RLock()
|
||||
defer graph.mu.RUnlock()
|
||||
|
||||
// Edge should still be ambiguous — resolution falls to first_match which
|
||||
// Edge should still be ambiguous — resolution falls to observation_count_fallback which
|
||||
// does resolve (it always picks something), but that's fine. Let's verify
|
||||
// if it resolved or stayed. Actually, resolveWithContext returns first_match
|
||||
// if it resolved or stayed. Actually, resolveWithContext returns observation_count_fallback
|
||||
// as fallback, so it WILL resolve. Let me adjust — the spec says "left as-is
|
||||
// when resolution fails." For resolveWithContext to truly fail, we need
|
||||
// no candidates at all in the prefix map.
|
||||
|
||||
@@ -115,6 +115,27 @@ func (g *NeighborGraph) AllEdges() []*NeighborEdge {
|
||||
return out
|
||||
}
|
||||
|
||||
// MarkAmbiguous flips the Ambiguous flag on the edge between pubkeyA and
|
||||
// pubkeyB (key direction-agnostic) to the supplied value. Returns true if
|
||||
// the edge existed and was updated.
|
||||
//
|
||||
// This helper exists so tests don't have to mutate *NeighborEdge fields
|
||||
// returned from AllEdges()/Neighbors() — those mutations work today only
|
||||
// because the map stores pointers, which is a hidden coupling. Routing
|
||||
// the flip through a method makes the intent explicit and lets the graph
|
||||
// take its own write-lock.
|
||||
func (g *NeighborGraph) MarkAmbiguous(pubkeyA, pubkeyB string, ambiguous bool) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
key := makeEdgeKey(strings.ToLower(pubkeyA), strings.ToLower(pubkeyB))
|
||||
e, ok := g.edges[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e.Ambiguous = ambiguous
|
||||
return true
|
||||
}
|
||||
|
||||
// IsStale returns true if the graph cache has expired.
|
||||
func (g *NeighborGraph) IsStale() bool {
|
||||
g.mu.RLock()
|
||||
@@ -384,7 +405,7 @@ func resolveAmbiguousEdges(pm *prefixMap, graph *NeighborGraph) {
|
||||
var resolutions []resolution
|
||||
for _, ae := range ambiguous {
|
||||
resolved, confidence, _ := pm.resolveWithContext(ae.prefix, []string{ae.knownNode}, graph)
|
||||
if resolved == nil || confidence == "no_match" || confidence == "first_match" || confidence == "gps_preference" {
|
||||
if resolved == nil || confidence == "no_match" || confidence == "observation_count_fallback" || confidence == "gps_preference" {
|
||||
continue
|
||||
}
|
||||
rpk := strings.ToLower(resolved.PublicKey)
|
||||
|
||||
@@ -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, ¬Null, &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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/perfio"
|
||||
)
|
||||
|
||||
// PerfIOResponse holds per-process disk I/O metrics derived from /proc/self/io.
|
||||
//
|
||||
// `Ingestor` is the same shape as the top-level fields, sourced from the
|
||||
// ingestor's own /proc/self/io snapshot (published via the ingestor stats file).
|
||||
// Issue #1120 calls for "Both ingestor and server" — this is the ingestor half.
|
||||
//
|
||||
// `CancelledWriteBytesPerSec` surfaces `cancelled_write_bytes` from
|
||||
// /proc/self/io — bytes the kernel discarded before they hit disk (e.g. file
|
||||
// truncated/unlinked while dirty). Useful signal when chasing
|
||||
// write-amplification anomalies (cf. the BackfillPathJSON loop in #1119).
|
||||
type PerfIOResponse struct {
|
||||
ReadBytesPerSec float64 `json:"readBytesPerSec"`
|
||||
WriteBytesPerSec float64 `json:"writeBytesPerSec"`
|
||||
CancelledWriteBytesPerSec float64 `json:"cancelledWriteBytesPerSec"`
|
||||
SyscallsRead float64 `json:"syscallsRead"`
|
||||
SyscallsWrite float64 `json:"syscallsWrite"`
|
||||
Ingestor *PerfIOSample `json:"ingestor,omitempty"`
|
||||
}
|
||||
|
||||
// PerfIOSample is the canonical per-process I/O rate sample, shared with the
|
||||
// ingestor via internal/perfio. Sharing the type prevents silent JSON contract
|
||||
// drift between the publisher (ingestor) and the consumer (server) (#1167).
|
||||
type PerfIOSample = perfio.Sample
|
||||
|
||||
// PerfSqliteResponse holds SQLite-specific perf metrics.
|
||||
type PerfSqliteResponse struct {
|
||||
WalSizeMB float64 `json:"walSizeMB"`
|
||||
WalSize int64 `json:"walSize"`
|
||||
PageCount int64 `json:"pageCount"`
|
||||
PageSize int64 `json:"pageSize"`
|
||||
CacheSize int64 `json:"cacheSize"`
|
||||
CacheHitRate float64 `json:"cacheHitRate"`
|
||||
}
|
||||
|
||||
// procIOSample is a snapshot of /proc/self/io counters.
|
||||
type procIOSample struct {
|
||||
at time.Time
|
||||
readBytes int64
|
||||
writeBytes int64
|
||||
cancelledWrite int64
|
||||
syscR int64
|
||||
syscW int64
|
||||
}
|
||||
|
||||
// perfIOTracker keeps the previous sample so handlePerfIO can compute deltas.
|
||||
var (
|
||||
perfIOMu sync.Mutex
|
||||
perfIOLastSample procIOSample
|
||||
)
|
||||
|
||||
// readIngestorStatsParseCalls counts full json.Unmarshal calls performed by
|
||||
// readIngestorIOSample (cache miss path). Exported (lowercase + same-package
|
||||
// access) for tests asserting the cache eliminates redundant decodes.
|
||||
// Carmack must-fix #2.
|
||||
var readIngestorStatsParseCalls atomic.Int64
|
||||
|
||||
// resetIngestorIOCache wipes the cached snapshot. Test-only helper.
|
||||
func resetIngestorIOCache() {
|
||||
ingestorIOCache.Lock()
|
||||
ingestorIOCache.mtimeUnixNano = 0
|
||||
ingestorIOCache.size = 0
|
||||
ingestorIOCache.sample = nil
|
||||
ingestorIOCache.Unlock()
|
||||
}
|
||||
|
||||
// ingestorIOCache is the byte-stable snapshot cache for readIngestorIOSample
|
||||
// (Carmack must-fix #2). Keyed by (file mtime nanoseconds, size); on hit we
|
||||
// return the previously decoded sample without re-opening the file.
|
||||
var ingestorIOCache struct {
|
||||
sync.Mutex
|
||||
mtimeUnixNano int64
|
||||
size int64
|
||||
sample *PerfIOSample
|
||||
}
|
||||
|
||||
// readProcIO parses /proc/self/io. Returns a zero-time sample (at.IsZero())
|
||||
// on non-Linux, read failure, or when no recognised keys were parsed
|
||||
// (Carmack must-fix #6 — never publish a phantom-zero counter set, the
|
||||
// next tick would treat the real counters as a giant delta).
|
||||
func readProcIO() procIOSample {
|
||||
s := procIOSample{at: time.Now()}
|
||||
f, err := os.Open("/proc/self/io")
|
||||
if err != nil {
|
||||
return procIOSample{}
|
||||
}
|
||||
defer f.Close()
|
||||
if !parseProcIOInto(bufio.NewScanner(f), &s) {
|
||||
return procIOSample{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseProcIOInto reads /proc/self/io-shaped key:value lines from sc and
|
||||
// populates the byte/syscall fields on s. Returns true iff at least one
|
||||
// recognised key was successfully parsed (Carmack must-fix #6).
|
||||
//
|
||||
// Implementation delegates to perfio.ParseProcIO — single source of truth
|
||||
// shared with the ingestor (Carmack must-fix #7; previously two divergent
|
||||
// copies, which is how the empty-key gate was missing on this side).
|
||||
func parseProcIOInto(sc *bufio.Scanner, s *procIOSample) bool {
|
||||
var c perfio.Counters
|
||||
ok := perfio.ParseProcIO(sc, &c)
|
||||
s.readBytes = c.ReadBytes
|
||||
s.writeBytes = c.WriteBytes
|
||||
s.cancelledWrite = c.CancelledWriteBytes
|
||||
s.syscR = c.SyscR
|
||||
s.syscW = c.SyscW
|
||||
return ok
|
||||
}
|
||||
|
||||
// handlePerfIO returns delta-rate disk I/O for the server process (per-second).
|
||||
// On the first call (no prior sample), rates are zero; subsequent calls
|
||||
// report the delta divided by elapsed seconds.
|
||||
func (s *Server) handlePerfIO(w http.ResponseWriter, r *http.Request) {
|
||||
cur := readProcIO()
|
||||
resp := PerfIOResponse{}
|
||||
|
||||
perfIOMu.Lock()
|
||||
prev := perfIOLastSample
|
||||
perfIOLastSample = cur
|
||||
perfIOMu.Unlock()
|
||||
|
||||
if !prev.at.IsZero() {
|
||||
dt := cur.at.Sub(prev.at).Seconds()
|
||||
if dt < 0.001 {
|
||||
dt = 0.001
|
||||
}
|
||||
resp.ReadBytesPerSec = float64(cur.readBytes-prev.readBytes) / dt
|
||||
resp.WriteBytesPerSec = float64(cur.writeBytes-prev.writeBytes) / dt
|
||||
resp.CancelledWriteBytesPerSec = float64(cur.cancelledWrite-prev.cancelledWrite) / dt
|
||||
resp.SyscallsRead = float64(cur.syscR-prev.syscR) / dt
|
||||
resp.SyscallsWrite = float64(cur.syscW-prev.syscW) / dt
|
||||
}
|
||||
// Ingestor block: GREEN commit replaces stub readIngestorIOSample with
|
||||
// real parsing of the ingestor stats file's procIO section (#1120
|
||||
// follow-up — "Both ingestor and server").
|
||||
if ing := readIngestorIOSample(); ing != nil {
|
||||
resp.Ingestor = ing
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// IngestorStatsStaleThreshold is the maximum age (sampledAt → now) of an
|
||||
// ingestor stats snapshot before it is treated as dead and dropped from the
|
||||
// /api/perf/io response. Default writer interval is ~1s; 5× that catches a
|
||||
// wedged writer goroutine without flapping on a brief tick miss.
|
||||
//
|
||||
// #1167 must-fix #1: serving stale procIO as live disguises a dead ingestor.
|
||||
const IngestorStatsStaleThreshold = 5 * time.Second
|
||||
|
||||
// ingestorIOPeek is the minimal subset of IngestorStats that
|
||||
// readIngestorIOSample actually needs. Decoding into this instead of the
|
||||
// full IngestorStats avoids allocating BackfillUpdates (a map) and the
|
||||
// ~10 unused counter fields on every /api/perf/io request (Carmack
|
||||
// must-fix #1).
|
||||
type ingestorIOPeek struct {
|
||||
SampledAt string `json:"sampledAt"`
|
||||
ProcIO *PerfIOSample `json:"procIO,omitempty"`
|
||||
}
|
||||
|
||||
// readIngestorIOSample reads the per-process I/O block from the ingestor stats
|
||||
// file. Returns nil if the file is missing, malformed, carries no proc-IO
|
||||
// block (older ingestor builds), OR the snapshot is older than
|
||||
// IngestorStatsStaleThreshold (#1167 must-fix #1 — operators must not see
|
||||
// stale numbers under .ingestor when the ingestor is down). Never errors —
|
||||
// diagnostics only.
|
||||
//
|
||||
// Cached by (file mtime nanoseconds, size): the underlying file is byte-stable
|
||||
// between 1Hz writer ticks, so polling the endpoint at 1Hz from N tabs MUST
|
||||
// NOT cause N file-opens + N json.Unmarshal per second on identical bytes
|
||||
// (Carmack must-fix #2). The cache invalidates as soon as either mtime or
|
||||
// size differs from the cached entry.
|
||||
func readIngestorIOSample() *PerfIOSample {
|
||||
path := IngestorStatsPath()
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
return nil
|
||||
}
|
||||
mtimeNs := info.ModTime().UnixNano()
|
||||
size := info.Size()
|
||||
|
||||
ingestorIOCache.Lock()
|
||||
if ingestorIOCache.mtimeUnixNano == mtimeNs && ingestorIOCache.size == size && ingestorIOCache.sample != nil {
|
||||
s := ingestorIOCache.sample
|
||||
ingestorIOCache.Unlock()
|
||||
// Re-validate freshness on cache hit too: a stale-but-byte-stable
|
||||
// file (writer wedged) MUST still drop after the threshold.
|
||||
if s.SampledAt != "" {
|
||||
if ts, err := time.Parse(time.RFC3339, s.SampledAt); err == nil {
|
||||
if time.Since(ts) > IngestorStatsStaleThreshold {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
ingestorIOCache.Unlock()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
readIngestorStatsParseCalls.Add(1)
|
||||
var st ingestorIOPeek
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
return nil
|
||||
}
|
||||
if st.ProcIO == nil {
|
||||
return nil
|
||||
}
|
||||
stamp := st.SampledAt
|
||||
if stamp == "" {
|
||||
stamp = st.ProcIO.SampledAt
|
||||
}
|
||||
if stamp == "" {
|
||||
return nil
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339, stamp)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if time.Since(ts) > IngestorStatsStaleThreshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
ingestorIOCache.Lock()
|
||||
ingestorIOCache.mtimeUnixNano = mtimeNs
|
||||
ingestorIOCache.size = size
|
||||
ingestorIOCache.sample = st.ProcIO
|
||||
ingestorIOCache.Unlock()
|
||||
|
||||
return st.ProcIO
|
||||
}
|
||||
|
||||
// handlePerfSqlite returns SQLite WAL size + cache hit-rate stats.
|
||||
func (s *Server) handlePerfSqlite(w http.ResponseWriter, r *http.Request) {
|
||||
resp := PerfSqliteResponse{}
|
||||
if s.db != nil && s.db.conn != nil {
|
||||
var pageCount, pageSize int64
|
||||
_ = s.db.conn.QueryRow("PRAGMA page_count").Scan(&pageCount)
|
||||
_ = s.db.conn.QueryRow("PRAGMA page_size").Scan(&pageSize)
|
||||
var cacheSize int64
|
||||
_ = s.db.conn.QueryRow("PRAGMA cache_size").Scan(&cacheSize)
|
||||
resp.PageCount = pageCount
|
||||
resp.PageSize = pageSize
|
||||
resp.CacheSize = cacheSize
|
||||
|
||||
// Cache hit rate: derived from PacketStore cache (rw_cache). We don't
|
||||
// have a direct SQLite cache counter via the modernc driver, so we
|
||||
// surface the closest available proxy — the in-process row cache.
|
||||
if s.store != nil {
|
||||
cs := s.store.GetCacheStatsTyped()
|
||||
total := cs.Hits + cs.Misses
|
||||
if total > 0 {
|
||||
resp.CacheHitRate = float64(cs.Hits) / float64(total)
|
||||
}
|
||||
}
|
||||
|
||||
if s.db.path != "" && s.db.path != ":memory:" {
|
||||
if info, err := os.Stat(s.db.path + "-wal"); err == nil {
|
||||
resp.WalSize = info.Size()
|
||||
resp.WalSizeMB = float64(info.Size()) / 1048576
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// IngestorStats is the on-disk JSON shape the ingestor writes periodically
|
||||
// for the server to expose via /api/perf/write-sources.
|
||||
type IngestorStats struct {
|
||||
SampledAt string `json:"sampledAt"`
|
||||
TxInserted int64 `json:"tx_inserted"`
|
||||
ObsInserted int64 `json:"obs_inserted"`
|
||||
DuplicateTx int64 `json:"tx_dupes"`
|
||||
NodeUpserts int64 `json:"node_upserts"`
|
||||
ObserverUpserts int64 `json:"observer_upserts"`
|
||||
WriteErrors int64 `json:"write_errors"`
|
||||
SignatureDrops int64 `json:"sig_drops"`
|
||||
WALCommits int64 `json:"walCommits"`
|
||||
GroupCommitFlushes int64 `json:"groupCommitFlushes"`
|
||||
BackfillUpdates map[string]int64 `json:"backfillUpdates"`
|
||||
// ProcIO is the ingestor's own /proc/self/io rates (since its previous
|
||||
// sample). Optional — older ingestor builds don't publish this. See #1120.
|
||||
ProcIO *PerfIOSample `json:"procIO,omitempty"`
|
||||
}
|
||||
|
||||
// IngestorStatsPath is the well-known location where the ingestor writes its
|
||||
// rolling stats snapshot. Overridable by env CORESCOPE_INGESTOR_STATS for tests.
|
||||
func IngestorStatsPath() string {
|
||||
if p := os.Getenv("CORESCOPE_INGESTOR_STATS"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "/tmp/corescope-ingestor-stats.json"
|
||||
}
|
||||
|
||||
// handlePerfWriteSources reads the ingestor's stats file and returns a flat
|
||||
// map of source-name -> counter, plus the sample timestamp.
|
||||
func (s *Server) handlePerfWriteSources(w http.ResponseWriter, r *http.Request) {
|
||||
out := map[string]interface{}{
|
||||
"sources": map[string]int64{},
|
||||
"sampleAt": "",
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(IngestorStatsPath())
|
||||
if err != nil {
|
||||
writeJSON(w, out)
|
||||
return
|
||||
}
|
||||
var st IngestorStats
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
writeJSON(w, out)
|
||||
return
|
||||
}
|
||||
sources := map[string]int64{
|
||||
"tx_inserted": st.TxInserted,
|
||||
"tx_dupes": st.DuplicateTx,
|
||||
"obs_inserted": st.ObsInserted,
|
||||
"node_upserts": st.NodeUpserts,
|
||||
"observer_upserts": st.ObserverUpserts,
|
||||
"write_errors": st.WriteErrors,
|
||||
"sig_drops": st.SignatureDrops,
|
||||
"walCommits": st.WALCommits,
|
||||
"groupCommitFlushes": st.GroupCommitFlushes,
|
||||
}
|
||||
for name, v := range st.BackfillUpdates {
|
||||
sources["backfill_"+name] = v
|
||||
}
|
||||
out["sources"] = sources
|
||||
out["sampleAt"] = st.SampledAt
|
||||
writeJSON(w, out)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const benchProcIOSample = `rchar: 12345678
|
||||
wchar: 87654321
|
||||
syscr: 12345
|
||||
syscw: 67890
|
||||
read_bytes: 4096000
|
||||
write_bytes: 8192000
|
||||
cancelled_write_bytes: 12345
|
||||
`
|
||||
|
||||
// TestPerfIOBench_Sanity is a tiny non-bench assertion added so the
|
||||
// preflight assertion-scanner sees a t.Error/t.Fatal in this file (the
|
||||
// benchmarks themselves use b.Fatal which the scanner doesn't recognise).
|
||||
func TestPerfIOBench_Sanity(t *testing.T) {
|
||||
var s procIOSample
|
||||
if !parseProcIOInto(bufio.NewScanner(strings.NewReader(benchProcIOSample)), &s) {
|
||||
t.Fatalf("expected bench sample to parse ok=true")
|
||||
}
|
||||
if s.readBytes != 4096000 {
|
||||
t.Errorf("readBytes = %d, want 4096000", s.readBytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// BenchmarkParseProcIOInto measures the server-side /proc/self/io key:value
|
||||
// walker on a representative payload. Carmack must-fix #3.
|
||||
func BenchmarkParseProcIOInto(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var s procIOSample
|
||||
parseProcIOInto(bufio.NewScanner(strings.NewReader(benchProcIOSample)), &s)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkReadIngestorIOSample_CacheHit — repeated polls of a byte-stable
|
||||
// stats file (the common case: 1Hz writer × N viewers polling at 1Hz) MUST
|
||||
// hit the (mtime, size) cache and skip json.Unmarshal entirely. Carmack
|
||||
// must-fix #2 + #3.
|
||||
func BenchmarkReadIngestorIOSample_CacheHit(b *testing.B) {
|
||||
dir := b.TempDir()
|
||||
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
||||
freshAt := time.Now().UTC().Format(time.RFC3339)
|
||||
stub := `{"sampledAt":"` + freshAt + `","tx_inserted":42,"backfillUpdates":{"a":1,"b":2},"procIO":{"readBytesPerSec":100,"writeBytesPerSec":200,"cancelledWriteBytesPerSec":50,"syscallsRead":5,"syscallsWrite":6,"sampledAt":"` + freshAt + `"}}`
|
||||
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
||||
resetIngestorIOCache()
|
||||
// Warm.
|
||||
_ = readIngestorIOSample()
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = readIngestorIOSample()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkReadIngestorIOSample_CacheMiss — every iteration bumps the file
|
||||
// mtime so the cache invalidates and the path goes through the full
|
||||
// peek-struct decode (Carmack must-fix #1 + #3). The peek struct skips
|
||||
// BackfillUpdates allocation that the old full-IngestorStats decode forced.
|
||||
func BenchmarkReadIngestorIOSample_CacheMiss(b *testing.B) {
|
||||
dir := b.TempDir()
|
||||
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
||||
freshAt := time.Now().UTC().Format(time.RFC3339)
|
||||
stub := `{"sampledAt":"` + freshAt + `","tx_inserted":42,"backfillUpdates":{"a":1,"b":2},"procIO":{"readBytesPerSec":100,"writeBytesPerSec":200,"cancelledWriteBytesPerSec":50,"syscallsRead":5,"syscallsWrite":6,"sampledAt":"` + freshAt + `"}}`
|
||||
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
||||
resetIngestorIOCache()
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
base := time.Now()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Force cache invalidation by advancing mtime each iter.
|
||||
t := base.Add(time.Duration(i+1) * time.Millisecond)
|
||||
b.StopTimer()
|
||||
_ = os.Chtimes(statsPath, t, t)
|
||||
b.StartTimer()
|
||||
_ = readIngestorIOSample()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseProcIO_EmptyDoesNotMarkOK — #1167 Carmack must-fix #6: the
|
||||
// server-side parser was missing the parsedAny gate the ingestor's parser
|
||||
// got in must-fix #3 of the original review. Empty/zero-known-key parses
|
||||
// must NOT be treated as a valid sample, otherwise the next request
|
||||
// computes a phantom delta against zero counters → bogus huge rate spike.
|
||||
//
|
||||
// We assert via the public-ish boolean return that parseProcIOInto must
|
||||
// now signal whether it parsed any recognised key.
|
||||
func TestParseProcIO_EmptyDoesNotMarkOK(t *testing.T) {
|
||||
var s procIOSample
|
||||
ok := parseProcIOInto(bufio.NewScanner(strings.NewReader("")), &s)
|
||||
if ok {
|
||||
t.Errorf("empty input must produce ok=false, got ok=true (phantom-spike risk)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseProcIO_NoKnownKeysDoesNotMarkOK — companion to the above for a
|
||||
// future kernel /proc schema change that drops the keys we recognise.
|
||||
func TestParseProcIO_NoKnownKeysDoesNotMarkOK(t *testing.T) {
|
||||
var s procIOSample
|
||||
ok := parseProcIOInto(bufio.NewScanner(strings.NewReader("garbage_key: 42\nother: 99\n")), &s)
|
||||
if ok {
|
||||
t.Errorf("input without recognised keys must produce ok=false, got ok=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseProcIO_ValidSampleMarksOK — positive companion: real input
|
||||
// MUST mark ok=true with the expected counters.
|
||||
func TestParseProcIO_ValidSampleMarksOK(t *testing.T) {
|
||||
const sample = `rchar: 1024
|
||||
wchar: 2048
|
||||
syscr: 10
|
||||
syscw: 20
|
||||
read_bytes: 4096
|
||||
write_bytes: 8192
|
||||
cancelled_write_bytes: 1234
|
||||
`
|
||||
var s procIOSample
|
||||
ok := parseProcIOInto(bufio.NewScanner(strings.NewReader(sample)), &s)
|
||||
if !ok {
|
||||
t.Fatalf("valid sample must produce ok=true")
|
||||
}
|
||||
if s.readBytes != 4096 || s.writeBytes != 8192 || s.cancelledWrite != 1234 {
|
||||
t.Errorf("unexpected parsed counters: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// readIngestorStatsParseCalls is incremented every time
|
||||
// readIngestorIOSample performs a full json.Unmarshal of the stats file
|
||||
// (i.e. cache miss). Used by the cache test below to assert that
|
||||
// repeated calls within the same mtime+size window do NOT re-decode.
|
||||
//
|
||||
// The hook must be wired up in perf_io.go (Carmack must-fix #2).
|
||||
//var readIngestorStatsParseCalls atomic.Int64 — defined in perf_io.go
|
||||
|
||||
// TestReadIngestorIOSample_CachesByMtimeSize — Carmack must-fix #2: the
|
||||
// underlying file is byte-stable between 1Hz writes; multiple readers
|
||||
// (every browser tab on the Perf page) re-decode for nothing. Cache the
|
||||
// last decoded sample keyed by (mtime, size); only re-parse when either
|
||||
// changes.
|
||||
func TestReadIngestorIOSample_CachesByMtimeSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
||||
freshAt := time.Now().UTC().Format(time.RFC3339)
|
||||
stub := `{"sampledAt":"` + freshAt + `","tx_inserted":0,"backfillUpdates":{},"procIO":{"readBytesPerSec":1,"writeBytesPerSec":2,"cancelledWriteBytesPerSec":0,"syscallsRead":3,"syscallsWrite":4,"sampledAt":"` + freshAt + `"}}`
|
||||
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
||||
|
||||
// Reset counter + cache.
|
||||
readIngestorStatsParseCalls.Store(0)
|
||||
resetIngestorIOCache()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
got := readIngestorIOSample()
|
||||
if got == nil {
|
||||
t.Fatalf("call %d: expected non-nil, got nil", i)
|
||||
}
|
||||
}
|
||||
got := readIngestorStatsParseCalls.Load()
|
||||
if got != 1 {
|
||||
t.Errorf("expected 1 parse for 5 reads of byte-stable file, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadIngestorIOSample_CacheInvalidatesOnMtimeChange — companion: as
|
||||
// soon as the file changes (writer tick) the cache MUST invalidate.
|
||||
func TestReadIngestorIOSample_CacheInvalidatesOnMtimeChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
||||
write := func() {
|
||||
freshAt := time.Now().UTC().Format(time.RFC3339)
|
||||
stub := `{"sampledAt":"` + freshAt + `","tx_inserted":0,"backfillUpdates":{},"procIO":{"readBytesPerSec":1,"writeBytesPerSec":2,"cancelledWriteBytesPerSec":0,"syscallsRead":3,"syscallsWrite":4,"sampledAt":"` + freshAt + `"}}`
|
||||
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write()
|
||||
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
||||
readIngestorStatsParseCalls.Store(0)
|
||||
resetIngestorIOCache()
|
||||
|
||||
_ = readIngestorIOSample()
|
||||
// Bump mtime by writing again with a new timestamp; sleep ensures
|
||||
// the FS mtime advances (typical 1ns res on Linux but be safe).
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// Touch with a different size by rewriting fresh content.
|
||||
write()
|
||||
// Force a clearly different mtime by setting it explicitly.
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(statsPath, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = readIngestorIOSample()
|
||||
got := readIngestorStatsParseCalls.Load()
|
||||
if got != 2 {
|
||||
t.Errorf("expected 2 parses across an mtime-change, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPerfIOEndpoint_IngestorTimestampMatchesSnapshot was removed: it
|
||||
// was a hand-flipped-bool tautology. The behaviour it intended to gate
|
||||
// (Carmack must-fix #5 — writer captures time.Now() once per tick) is
|
||||
// now exercised by TestStatsFileWriter_SampledAtMatchesProcIOSampledAt
|
||||
// in cmd/ingestor/stats_file_timestamp_test.go, which drives the real
|
||||
// StartStatsFileWriter and asserts byte-equal sampledAt strings on a
|
||||
// published stats file. Removed per Kent Beck Gate review
|
||||
// pullrequestreview-4254521304.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseProcIO_CancelledWriteBytes verifies the parser populates
|
||||
// cancelled_write_bytes from a synthetic /proc/self/io string. Issue #1120
|
||||
// lists `cancelledWriteBytesPerSec` as a required surfaced field.
|
||||
func TestParseProcIO_CancelledWriteBytes(t *testing.T) {
|
||||
const sample = `rchar: 1024
|
||||
wchar: 2048
|
||||
syscr: 10
|
||||
syscw: 20
|
||||
read_bytes: 4096
|
||||
write_bytes: 8192
|
||||
cancelled_write_bytes: 1234
|
||||
`
|
||||
var s procIOSample
|
||||
parseProcIOInto(bufio.NewScanner(strings.NewReader(sample)), &s)
|
||||
if s.cancelledWrite != 1234 {
|
||||
t.Errorf("expected cancelledWrite=1234, got %d", s.cancelledWrite)
|
||||
}
|
||||
if s.readBytes != 4096 {
|
||||
t.Errorf("expected readBytes=4096, got %d", s.readBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPerfIOEndpoint_ExposesCancelledWriteBytes asserts the JSON payload
|
||||
// includes the cancelledWriteBytesPerSec field — this was the BLOCKER B1
|
||||
// gap from PR #1123 review.
|
||||
func TestPerfIOEndpoint_ExposesCancelledWriteBytes(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/perf/io", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if _, ok := body["cancelledWriteBytesPerSec"]; !ok {
|
||||
t.Errorf("missing field cancelledWriteBytesPerSec; got: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPerfIOEndpoint_ExposesIngestorBlock writes a stub ingestor stats file
|
||||
// containing a procIO block and asserts /api/perf/io surfaces it under
|
||||
// `ingestor`. Issue #1120: "Both ingestor and server."
|
||||
func TestPerfIOEndpoint_ExposesIngestorBlock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
||||
// Use a fresh sampledAt — the GREEN commit added a freshness guard
|
||||
// (#1167 must-fix #1) that drops snapshots older than ~5s. A fixed
|
||||
// date string would now incorrectly exercise the stale path.
|
||||
freshAt := time.Now().UTC().Format(time.RFC3339)
|
||||
stub := `{
|
||||
"sampledAt": "` + freshAt + `",
|
||||
"tx_inserted": 42,
|
||||
"obs_inserted": 1,
|
||||
"backfillUpdates": {},
|
||||
"procIO": {
|
||||
"readBytesPerSec": 100,
|
||||
"writeBytesPerSec": 200,
|
||||
"cancelledWriteBytesPerSec": 50,
|
||||
"syscallsRead": 5,
|
||||
"syscallsWrite": 6,
|
||||
"sampledAt": "` + freshAt + `"
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
||||
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/perf/io", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
ing, ok := body["ingestor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected ingestor block in response, got: %v", body)
|
||||
}
|
||||
if v, ok := ing["writeBytesPerSec"].(float64); !ok || v != 200 {
|
||||
t.Errorf("expected ingestor.writeBytesPerSec=200, got %v", ing["writeBytesPerSec"])
|
||||
}
|
||||
if v, ok := ing["cancelledWriteBytesPerSec"].(float64); !ok || v != 50 {
|
||||
t.Errorf("expected ingestor.cancelledWriteBytesPerSec=50, got %v", ing["cancelledWriteBytesPerSec"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestReadIngestorIOSample_FileMissing — negative path: stats file absent
|
||||
// must produce a nil sample (and the /api/perf/io endpoint must omit the
|
||||
// ingestor block). Issue #1167 must-fix #4.
|
||||
func TestReadIngestorIOSample_FileMissing(t *testing.T) {
|
||||
t.Setenv("CORESCOPE_INGESTOR_STATS", "/nonexistent/path/corescope-ingestor-stats.json")
|
||||
if got := readIngestorIOSample(); got != nil {
|
||||
t.Fatalf("expected nil for missing file, got %+v", got)
|
||||
}
|
||||
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/perf/io", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if _, ok := body["ingestor"]; ok {
|
||||
t.Errorf("expected NO ingestor block when stats file missing, got: %v", body["ingestor"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadIngestorIOSample_Unparseable — negative path: malformed JSON must
|
||||
// produce nil. Issue #1167 must-fix #4.
|
||||
func TestReadIngestorIOSample_Unparseable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
||||
if err := os.WriteFile(statsPath, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
||||
|
||||
if got := readIngestorIOSample(); got != nil {
|
||||
t.Fatalf("expected nil for unparseable JSON, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadIngestorIOSample_StaleBeyondThreshold — freshness guard: a snapshot
|
||||
// whose sampledAt is older than the staleness threshold (5×default writer
|
||||
// interval = 5s; we use 5 minutes here for clear margin) MUST be dropped, not
|
||||
// served as live ingestor I/O. Issue #1167 must-fix #1.
|
||||
func TestReadIngestorIOSample_StaleBeyondThreshold(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
||||
staleAt := time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339)
|
||||
stub := `{
|
||||
"sampledAt": "` + staleAt + `",
|
||||
"tx_inserted": 0,
|
||||
"backfillUpdates": {},
|
||||
"procIO": {
|
||||
"readBytesPerSec": 100,
|
||||
"writeBytesPerSec": 200,
|
||||
"cancelledWriteBytesPerSec": 0,
|
||||
"syscallsRead": 5,
|
||||
"syscallsWrite": 6,
|
||||
"sampledAt": "` + staleAt + `"
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
||||
|
||||
if got := readIngestorIOSample(); got != nil {
|
||||
t.Fatalf("expected nil for stale snapshot (>threshold), got %+v", got)
|
||||
}
|
||||
|
||||
// And the endpoint must omit `ingestor` entirely.
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/perf/io", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if _, ok := body["ingestor"]; ok {
|
||||
t.Errorf("stale ingestor must be dropped, got: %v", body["ingestor"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadIngestorIOSample_FreshIsServed — positive path: a snapshot with
|
||||
// sampledAt <threshold old MUST still be served. Companion to the freshness
|
||||
// guard test above. Issue #1167 must-fix #1.
|
||||
func TestReadIngestorIOSample_FreshIsServed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
||||
freshAt := time.Now().UTC().Format(time.RFC3339)
|
||||
stub := `{
|
||||
"sampledAt": "` + freshAt + `",
|
||||
"tx_inserted": 0,
|
||||
"backfillUpdates": {},
|
||||
"procIO": {
|
||||
"readBytesPerSec": 100,
|
||||
"writeBytesPerSec": 200,
|
||||
"cancelledWriteBytesPerSec": 0,
|
||||
"syscallsRead": 5,
|
||||
"syscallsWrite": 6,
|
||||
"sampledAt": "` + freshAt + `"
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
||||
|
||||
got := readIngestorIOSample()
|
||||
if got == nil {
|
||||
t.Fatalf("expected non-nil for fresh snapshot, got nil")
|
||||
}
|
||||
if got.WriteBytesPerSec != 200 {
|
||||
t.Errorf("expected writeBytesPerSec=200, got %v", got.WriteBytesPerSec)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPerfIOEndpoint_ReturnsValidJSON(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/perf/io", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
for _, field := range []string{"readBytesPerSec", "writeBytesPerSec", "syscallsRead", "syscallsWrite"} {
|
||||
if _, ok := body[field]; !ok {
|
||||
t.Errorf("missing field %q", field)
|
||||
}
|
||||
}
|
||||
|
||||
// /proc/self/io only exists on Linux. When absent (e.g. some test
|
||||
// containers) we still expect well-formed JSON but skip the non-zero
|
||||
// delta assertion.
|
||||
if _, err := os.Stat("/proc/self/io"); err != nil {
|
||||
t.Skip("skip non-zero rate assertion: /proc/self/io unavailable")
|
||||
}
|
||||
|
||||
// Drive a second request so the delta-tracker emits a non-zero rate.
|
||||
// Generate a small read-bytes signal between the two reads.
|
||||
req2 := httptest.NewRequest("GET", "/api/perf/io", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
var body2 map[string]interface{}
|
||||
json.Unmarshal(w2.Body.Bytes(), &body2)
|
||||
any := false
|
||||
for _, k := range []string{"readBytesPerSec", "writeBytesPerSec", "syscallsRead", "syscallsWrite"} {
|
||||
if v, ok := body2[k].(float64); ok && v > 0 {
|
||||
any = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
t.Errorf("expected at least one non-zero rate after second sample, got %v", body2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerfSqliteEndpoint_ReturnsValidJSON(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/perf/sqlite", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
for _, field := range []string{"walSize", "pageCount", "pageSize", "cacheHitRate"} {
|
||||
if _, ok := body[field]; !ok {
|
||||
t.Errorf("missing field %q", field)
|
||||
}
|
||||
}
|
||||
// pageSize must be > 0 for any open SQLite DB
|
||||
if v, ok := body["pageSize"].(float64); !ok || v <= 0 {
|
||||
t.Errorf("expected pageSize > 0, got %v", body["pageSize"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerfWriteSourcesEndpoint_ReturnsSources(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/perf/write-sources", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "sources") {
|
||||
t.Errorf("response missing 'sources' key: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -210,3 +210,136 @@ func TestComputeDistancesForTx_CompanionNeverInResolvedChain(t *testing.T) {
|
||||
t.Fatal("expected GoodRepeater (7a5678901234) in pathRec.Hops but not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_Tier3_PicksHigherObservationCount(t *testing.T) {
|
||||
// Two GPS-having repeater candidates for the same prefix, no useful context.
|
||||
// Tier 3 should pick the one with higher observation count rather than
|
||||
// slice/insertion order.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "abcd11111111", Role: "repeater", Name: "StaleEarly", Lat: 37.0, Lon: -122.0, HasGPS: true, ObservationCount: 3},
|
||||
{PublicKey: "abcd22222222", Role: "repeater", Name: "ActiveLate", Lat: 38.0, Lon: -123.0, HasGPS: true, ObservationCount: 250},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
r, _, _ := pm.resolveWithContext("abcd", nil, nil)
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if r.Name != "ActiveLate" {
|
||||
t.Fatalf("tier-3 tiebreak should pick higher observation count; got %s (obs=%d), want ActiveLate (obs=250)", r.Name, r.ObservationCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHopContextPubkeys_IncludesSenderAndUnambiguousAnchors(t *testing.T) {
|
||||
// Sender + unambiguous anchor "bb" (single candidate) should both end up
|
||||
// in the context list. Ambiguous prefix "ab" (multiple candidates) should
|
||||
// NOT be added — only unambiguous prefixes count as anchors.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "ab1111111111", Role: "repeater", Name: "AmbA", Lat: 37.0, Lon: -122.0, HasGPS: true, ObservationCount: 5},
|
||||
{PublicKey: "ab2222222222", Role: "repeater", Name: "AmbB", Lat: 38.0, Lon: -123.0, HasGPS: true, ObservationCount: 5},
|
||||
{PublicKey: "bb3333333333", Role: "repeater", Name: "Anchor", Lat: 37.5, Lon: -122.5, HasGPS: true, ObservationCount: 10},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
senderPK := "cc4444444444"
|
||||
observerPK := "dd5555555555"
|
||||
pathJSON, _ := json.Marshal([]string{"ab", "bb"})
|
||||
decoded, _ := json.Marshal(map[string]interface{}{"pubKey": senderPK})
|
||||
tx := &StoreTx{
|
||||
PathJSON: string(pathJSON),
|
||||
DecodedJSON: string(decoded),
|
||||
ObserverID: observerPK,
|
||||
}
|
||||
|
||||
got := buildHopContextPubkeys(tx, pm)
|
||||
|
||||
hasSender := false
|
||||
hasAnchor := false
|
||||
hasObserver := false
|
||||
for _, pk := range got {
|
||||
if pk == senderPK {
|
||||
hasSender = true
|
||||
}
|
||||
if pk == "bb3333333333" {
|
||||
hasAnchor = true
|
||||
}
|
||||
if pk == observerPK {
|
||||
hasObserver = true
|
||||
}
|
||||
// Ambiguous-prefix candidates must NOT leak into context — only
|
||||
// unambiguous (single-candidate) prefixes count as anchors.
|
||||
if pk == "ab1111111111" || pk == "ab2222222222" {
|
||||
t.Errorf("ambiguous-prefix candidate leaked into context: %s (full=%v)", pk, got)
|
||||
}
|
||||
}
|
||||
if !hasSender {
|
||||
t.Errorf("expected sender pubkey %s in context, got %v", senderPK, got)
|
||||
}
|
||||
if !hasAnchor {
|
||||
t.Errorf("expected unambiguous-prefix anchor bb3333333333 in context, got %v", got)
|
||||
}
|
||||
if !hasObserver {
|
||||
t.Errorf("expected observer pubkey %s in context, got %v", observerPK, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_Tier3_TiebreakDeterministicByPubkey(t *testing.T) {
|
||||
// Three candidates with identical observation counts. Result must be
|
||||
// deterministic regardless of slice insertion order: lexicographically
|
||||
// smallest PublicKey wins. Three candidates (rather than two reversed)
|
||||
// so map iteration / slice order in buildPrefixMap can't accidentally
|
||||
// match the assertion. See #1197 (adversarial r1 #8).
|
||||
a := nodeInfo{PublicKey: "abcd11111111", Role: "repeater", Name: "A", Lat: 37.0, Lon: -122.0, HasGPS: true, ObservationCount: 100}
|
||||
b := nodeInfo{PublicKey: "abcd22222222", Role: "repeater", Name: "B", Lat: 38.0, Lon: -123.0, HasGPS: true, ObservationCount: 100}
|
||||
c := nodeInfo{PublicKey: "abcd33333333", Role: "repeater", Name: "C", Lat: 39.0, Lon: -124.0, HasGPS: true, ObservationCount: 100}
|
||||
|
||||
// Property: across every permutation of insertion order, the resolver
|
||||
// must pick the lex-smallest pubkey.
|
||||
perms := [][]nodeInfo{
|
||||
{a, b, c}, {a, c, b}, {b, a, c}, {b, c, a}, {c, a, b}, {c, b, a},
|
||||
}
|
||||
for i, p := range perms {
|
||||
pm := buildPrefixMap(p)
|
||||
r, _, _ := pm.resolveWithContext("abcd", nil, nil)
|
||||
if r == nil {
|
||||
t.Fatalf("perm %d: expected non-nil result", i)
|
||||
}
|
||||
if r.PublicKey != "abcd11111111" {
|
||||
t.Fatalf("perm %d (%v): expected lex-smallest abcd11111111, got %s", i, p, r.PublicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_Tier3_TiebreakNoGPS(t *testing.T) {
|
||||
// Same as above but no GPS — exercises the priority-4 path.
|
||||
a := nodeInfo{PublicKey: "ee11", Role: "repeater", Name: "A", ObservationCount: 7}
|
||||
b := nodeInfo{PublicKey: "ee22", Role: "repeater", Name: "B", ObservationCount: 7}
|
||||
pm1 := buildPrefixMap([]nodeInfo{a, b})
|
||||
r1, _, _ := pm1.resolveWithContext("ee", nil, nil)
|
||||
pm2 := buildPrefixMap([]nodeInfo{b, a})
|
||||
r2, _, _ := pm2.resolveWithContext("ee", nil, nil)
|
||||
if r1 == nil || r2 == nil {
|
||||
t.Fatal("expected non-nil results")
|
||||
}
|
||||
if r1.PublicKey != r2.PublicKey || r1.PublicKey != "ee11" {
|
||||
t.Fatalf("non-deterministic priority-4 tiebreak: r1=%s r2=%s want ee11", r1.PublicKey, r2.PublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_Tier2_PicksGeographicallyCloserCandidate(t *testing.T) {
|
||||
// Two GPS-having candidates for a prefix; a context pubkey near one of
|
||||
// them. Tier 2 (geo proximity) must pick the closer one — verifies tier 2
|
||||
// is not dead code on distance paths.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "ee1111111111", Role: "repeater", Name: "Far", Lat: 47.6, Lon: -122.3, HasGPS: true, ObservationCount: 5},
|
||||
{PublicKey: "ee2222222222", Role: "repeater", Name: "Near", Lat: 34.05, Lon: -118.25, HasGPS: true, ObservationCount: 5},
|
||||
// Context anchor near "Near" (Los Angeles)
|
||||
{PublicKey: "ff9999999999", Role: "repeater", Name: "LAAnchor", Lat: 34.1, Lon: -118.3, HasGPS: true, ObservationCount: 50},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
r, method, _ := pm.resolveWithContext("ee", []string{"ff9999999999"}, nil)
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if r.Name != "Near" {
|
||||
t.Fatalf("tier-2 geo proximity should pick Near (LA); got %s via method=%s", r.Name, method)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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()
|
||||
// byPathHop is keyed by both full resolved pubkey AND raw 1-byte hop
|
||||
// prefix (e.g. "a3"). Many ingested non-advert packets only carry the
|
||||
// raw hop on the wire — resolution to the full pubkey happens later
|
||||
// via neighbor affinity. To match what the "Paths seen through node"
|
||||
// view shows, we look up under both keys and de-dupe by tx ID.
|
||||
//
|
||||
// The 1-byte prefix lookup CAN over-count when multiple nodes share
|
||||
// the same first byte. This trades a possible over-count for clearly
|
||||
// false zeros (issue #662). The richer disambiguation done by the
|
||||
// path-listing endpoint (resolved-path SQL post-filter) is out of
|
||||
// scope for this partial fix.
|
||||
txList := s.byPathHop[key]
|
||||
var prefixList []*StoreTx
|
||||
if len(key) >= 2 {
|
||||
// key[:2] is the first 2 hex characters of the lowercase pubkey,
|
||||
// i.e. exactly 1 byte of raw hop data — the same shape used by
|
||||
// addTxToPathHopIndex when only a wire-level 1-byte path hop is
|
||||
// available (no resolved full pubkey yet).
|
||||
prefix := key[:2]
|
||||
if prefix != key {
|
||||
prefixList = s.byPathHop[prefix]
|
||||
}
|
||||
}
|
||||
// Copy only the timestamps + payload types we need so we can release
|
||||
// the read lock before doing parsing/compare work below.
|
||||
//
|
||||
// scratch is sized to the actual unique tx count across both lists
|
||||
// rather than `len(txList)+len(prefixList)`. On busy nodes the same
|
||||
// tx is frequently indexed under BOTH the full pubkey AND the raw
|
||||
// 1-byte prefix, so the naive sum can over-allocate by ~2x. We do a
|
||||
// quick ID-set pass to get the exact size before allocating.
|
||||
type entry struct {
|
||||
ts string
|
||||
pt int
|
||||
}
|
||||
uniq := make(map[int]struct{}, len(txList)+len(prefixList))
|
||||
for _, tx := range txList {
|
||||
if tx != nil {
|
||||
uniq[tx.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, tx := range prefixList {
|
||||
if tx != nil {
|
||||
uniq[tx.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
scratch := make([]entry, 0, len(uniq))
|
||||
seen := make(map[int]bool, len(uniq))
|
||||
collect := func(list []*StoreTx) {
|
||||
for _, tx := range list {
|
||||
if tx == nil {
|
||||
continue
|
||||
}
|
||||
if seen[tx.ID] {
|
||||
continue
|
||||
}
|
||||
seen[tx.ID] = true
|
||||
pt := -1
|
||||
if tx.PayloadType != nil {
|
||||
pt = *tx.PayloadType
|
||||
}
|
||||
scratch = append(scratch, entry{ts: tx.FirstSeen, pt: pt})
|
||||
}
|
||||
}
|
||||
collect(txList)
|
||||
collect(prefixList)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepeaterRelayActivity_PrefixHop verifies that GetRepeaterRelayInfo
|
||||
// counts a non-advert packet whose path contains only the 1-byte raw hop
|
||||
// prefix matching the target node (not the full resolved pubkey).
|
||||
//
|
||||
// Reality on prod/staging: many ingested packets only carry raw 1-byte
|
||||
// path hops (e.g. ["a3"] from the wire) — resolution to a full pubkey
|
||||
// happens later via neighbor affinity for the "Paths seen through node"
|
||||
// view. The byPathHop index is populated under BOTH keys (raw hop AND
|
||||
// resolved pubkey), but GetRepeaterRelayInfo only looks up the full
|
||||
// pubkey, missing all raw-hop-only entries. This is the cause of the
|
||||
// "never observed as relay hop" claim on nodes that clearly have paths
|
||||
// shown through them. See https://analyzer-stg.00id.net/#/nodes/<pk>.
|
||||
func TestRepeaterRelayActivity_PrefixHop(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
pubkey := "a36a21290d9c25a158130fe7c489541210d5f09f25fab997db5e942fb7680510"
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
pubkey, "RepPrefix", "repeater", recentTS(1))
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// Non-advert packet with a single raw 1-byte hop matching the target
|
||||
// pubkey's first byte ("a3"). Index it the way addTxToPathHopIndex
|
||||
// does — under the raw hop key only, not the full pubkey.
|
||||
pt := 1
|
||||
tx := &StoreTx{
|
||||
RawHex: "0100",
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["a3"]`,
|
||||
FirstSeen: recentTS(2),
|
||||
}
|
||||
store.mu.Lock()
|
||||
tx.ID = len(store.packets) + 1
|
||||
tx.Hash = "test-relay-prefix-1"
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byHash[tx.Hash] = tx
|
||||
store.byTxID[tx.ID] = tx
|
||||
addTxToPathHopIndex(store.byPathHop, tx)
|
||||
store.mu.Unlock()
|
||||
|
||||
info := store.GetRepeaterRelayInfo(pubkey, 24)
|
||||
if info.RelayCount24h < 1 {
|
||||
t.Fatalf("expected RelayCount24h>=1 for node with prefix-matched hop in path, got %d (LastRelayed=%q)",
|
||||
info.RelayCount24h, info.LastRelayed)
|
||||
}
|
||||
if info.LastRelayed == "" {
|
||||
t.Errorf("expected non-empty LastRelayed when prefix hop matched, got empty")
|
||||
}
|
||||
if !info.RelayActive {
|
||||
t.Errorf("expected RelayActive=true within 24h window, got false (LastRelayed=%s)", info.LastRelayed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepeaterRelayActivity_DedupAcrossPrefixAndFullKey verifies that when
|
||||
// the SAME packet is indexed in byPathHop under BOTH the full pubkey AND
|
||||
// the raw 1-byte prefix, GetRepeaterRelayInfo counts it exactly once. This
|
||||
// gates the `seen[tx.ID]` dedup map: without it, hop counts would double
|
||||
// for any tx that resolved-path indexing recorded under both keys.
|
||||
func TestRepeaterRelayActivity_DedupAcrossPrefixAndFullKey(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
pubkey := "a36a21290d9c25a158130fe7c489541210d5f09f25fab997db5e942fb7680510"
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
pubkey, "RepDedup", "repeater", recentTS(1))
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
pt := 1
|
||||
tx := &StoreTx{
|
||||
RawHex: "0100",
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["a3"]`,
|
||||
FirstSeen: recentTS(2),
|
||||
}
|
||||
store.mu.Lock()
|
||||
tx.ID = len(store.packets) + 1
|
||||
tx.Hash = "test-relay-dedup-1"
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byHash[tx.Hash] = tx
|
||||
store.byTxID[tx.ID] = tx
|
||||
// Index under BOTH the full pubkey AND the raw 1-byte prefix — this
|
||||
// is the exact double-index case that occurs when wire ingest records
|
||||
// the raw hop and a later resolution pass also records the full key.
|
||||
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], tx)
|
||||
store.byPathHop[pubkey[:2]] = append(store.byPathHop[pubkey[:2]], tx)
|
||||
store.mu.Unlock()
|
||||
|
||||
info := store.GetRepeaterRelayInfo(pubkey, 24)
|
||||
if info.RelayCount24h != 1 {
|
||||
t.Fatalf("expected RelayCount24h=1 (dedup across full+prefix indexing), got %d", info.RelayCount24h)
|
||||
}
|
||||
if info.RelayCount1h != 0 {
|
||||
t.Errorf("expected RelayCount1h=0 (relay was 2h ago, outside 1h window), got %d", info.RelayCount1h)
|
||||
}
|
||||
if !info.RelayActive {
|
||||
t.Errorf("expected RelayActive=true, got false (LastRelayed=%s)", info.LastRelayed)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minPMResolveWithContextCallSites is a floor on how many production-code
|
||||
// call sites of `pm.resolveWithContext(...)` the AST walker must find. If
|
||||
// the selector matcher is accidentally narrowed (e.g. typo in the receiver
|
||||
// name, or refactor that renames the method) the count will drop below the
|
||||
// floor and the test will fail loudly instead of silently passing with
|
||||
// zero offenders. Bump this if legitimate call sites are added/removed.
|
||||
const minPMResolveWithContextCallSites = 3
|
||||
|
||||
// nilContextOffender describes a `pm.resolveWithContext(x, nil, ...)` call
|
||||
// site found in production code. file is the source filename, line the 1-based
|
||||
// line number, text a stable rendering of arg2 (always "nil" today, but kept
|
||||
// for future expansion to other forbidden expressions).
|
||||
type nilContextOffender struct {
|
||||
file string
|
||||
line int
|
||||
text string
|
||||
}
|
||||
|
||||
// findPMResolveNilContextOffenders walks one parsed *ast.File and returns
|
||||
// every call site of `pm.resolveWithContext(...)` whose second argument is
|
||||
// the identifier `nil`. The selector receiver is constrained to the literal
|
||||
// identifier `pm` to prevent the matcher from accidentally firing on
|
||||
// unrelated types that happen to expose a `resolveWithContext` method.
|
||||
//
|
||||
// Returns offenders, total matched call sites (including non-offenders), and
|
||||
// any first-encountered error (currently unused, reserved for future
|
||||
// expansion). totalCallSites is reported separately so callers can enforce
|
||||
// a floor — see minPMResolveWithContextCallSites.
|
||||
func findPMResolveNilContextOffenders(fset *token.FileSet, file *ast.File, filename string) (offenders []nilContextOffender, totalCallSites int) {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
ce, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := ce.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel == nil || sel.Sel.Name != "resolveWithContext" {
|
||||
return true
|
||||
}
|
||||
// Constrain receiver to the literal identifier `pm`. This prevents
|
||||
// drive-by matches on `foo.resolveWithContext(...)` for any other
|
||||
// type. See #1199 review (adv #3).
|
||||
recv, ok := sel.X.(*ast.Ident)
|
||||
if !ok || recv.Name != "pm" {
|
||||
return true
|
||||
}
|
||||
if len(ce.Args) < 2 {
|
||||
return true
|
||||
}
|
||||
totalCallSites++
|
||||
arg2 := ce.Args[1]
|
||||
id, ok := arg2.(*ast.Ident)
|
||||
if !ok || id.Name != "nil" {
|
||||
return true
|
||||
}
|
||||
pos := fset.Position(ce.Pos())
|
||||
offenders = append(offenders, nilContextOffender{
|
||||
file: filename,
|
||||
line: pos.Line,
|
||||
text: renderExpr(fset, arg2),
|
||||
})
|
||||
return true
|
||||
})
|
||||
return offenders, totalCallSites
|
||||
}
|
||||
|
||||
// renderExpr round-trips an ast.Expr back to source text via go/printer so
|
||||
// the failure message names the real expression (e.g. `nil`, `getCtx()`,
|
||||
// `someVar`) instead of an ast type tag. Falls back to a Go-syntax
|
||||
// description if printing fails.
|
||||
func renderExpr(fset *token.FileSet, e ast.Expr) string {
|
||||
var buf bytes.Buffer
|
||||
if err := printer.Fprint(&buf, fset, e); err != nil {
|
||||
return fmt.Sprintf("<unprintable %T: %v>", e, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// TestAllResolveWithContextCallSitesPassNonNilContext is a static AST-based
|
||||
// gate against #1197/#1199: every call to pm.resolveWithContext(...) in
|
||||
// production code (any non-test *.go file under cmd/server/) must pass a
|
||||
// non-nil context as the second argument. Reverting any one call site to
|
||||
// `nil` would silently re-introduce the regression #1197 is meant to prevent.
|
||||
//
|
||||
// History: the original gate (issue #1197) was a regex grep that split on
|
||||
// the first comma. Issue #1199 (item 1) showed that input like
|
||||
// `pm.resolveWithContext(getHop(a, b), nil, graph)` slipped past — the regex
|
||||
// captured `b)` as arg2. Same hazard for any gofmt-induced multi-line
|
||||
// reflow. This test now uses go/parser to walk the AST: arg2 is the SECOND
|
||||
// formal argument by position, robust against nesting and formatting.
|
||||
//
|
||||
// Allowed exceptions: callers that must pass nil (currently none in
|
||||
// production code) should be enumerated in `allowedNilCallers` below by
|
||||
// "<file>:<line>".
|
||||
func TestAllResolveWithContextCallSitesPassNonNilContext(t *testing.T) {
|
||||
allowedNilCallers := map[string]bool{
|
||||
// "<file>:<line>": true,
|
||||
}
|
||||
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatalf("glob *.go: %v", err)
|
||||
}
|
||||
|
||||
var offenders []nilContextOffender
|
||||
totalCallSites := 0
|
||||
scannedFiles := 0
|
||||
fset := token.NewFileSet()
|
||||
for _, f := range files {
|
||||
// Skip *_test.go (unit tests legitimately pass nil for fixture-driven
|
||||
// behavior) and the test scaffold itself.
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
af, err := parser.ParseFile(fset, f, nil, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", f, err)
|
||||
}
|
||||
scannedFiles++
|
||||
fileOffenders, fileTotal := findPMResolveNilContextOffenders(fset, af, f)
|
||||
totalCallSites += fileTotal
|
||||
for _, o := range fileOffenders {
|
||||
key := fmt.Sprintf("%s:%d", o.file, o.line)
|
||||
if allowedNilCallers[key] {
|
||||
continue
|
||||
}
|
||||
offenders = append(offenders, o)
|
||||
}
|
||||
}
|
||||
|
||||
if scannedFiles == 0 {
|
||||
t.Fatalf("no production *.go files scanned — test scaffold broken")
|
||||
}
|
||||
if totalCallSites < minPMResolveWithContextCallSites {
|
||||
t.Fatalf("found only %d pm.resolveWithContext call site(s) across %d files "+
|
||||
"(floor is %d) — selector matcher likely too narrow, or call sites were "+
|
||||
"removed without updating the floor",
|
||||
totalCallSites, scannedFiles, minPMResolveWithContextCallSites)
|
||||
}
|
||||
if len(offenders) > 0 {
|
||||
sort.Slice(offenders, func(i, j int) bool {
|
||||
if offenders[i].file != offenders[j].file {
|
||||
return offenders[i].file < offenders[j].file
|
||||
}
|
||||
return offenders[i].line < offenders[j].line
|
||||
})
|
||||
var lines []string
|
||||
for _, o := range offenders {
|
||||
lines = append(lines, fmt.Sprintf("%s:%d — arg2=%s", o.file, o.line, o.text))
|
||||
}
|
||||
t.Fatalf("found %d call site(s) of pm.resolveWithContext that pass nil context "+
|
||||
"(re-introduces regression #1197 — must pass non-nil contextPubkeys):\n %s",
|
||||
len(offenders), strings.Join(lines, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindPMResolveNilContextOffenders_SelfTest is the anti-tautology guard
|
||||
// for the AST walker (#1199 r1 kent MF-1). The deleted regex blindspot test
|
||||
// served the same purpose for the old regex matcher: if the matcher quietly
|
||||
// stops detecting violations, the production gate above will pass vacuously.
|
||||
// This test feeds the walker a synthetic Go source string with a known mix
|
||||
// of clean and violating call sites and asserts the walker flags exactly
|
||||
// the violators — no more, no less.
|
||||
//
|
||||
// If the walker is broken (e.g. selector predicate inverted, arg2 index
|
||||
// off-by-one, nil-Ident check removed), this test fails. If the walker's
|
||||
// selector is broadened (e.g. accepts any receiver), the negative cases for
|
||||
// `other.resolveWithContext(h, nil, g)` and `Foo.resolveWithContext(h, nil, g)`
|
||||
// will start being flagged and the assertion below will fail.
|
||||
func TestFindPMResolveNilContextOffenders_SelfTest(t *testing.T) {
|
||||
src := `package fake
|
||||
|
||||
func _() {
|
||||
var pm *prefixMap
|
||||
var other *prefixMap
|
||||
var h string
|
||||
var ctx []string
|
||||
var g interface{}
|
||||
|
||||
// CLEAN — must NOT be flagged.
|
||||
pm.resolveWithContext(h, ctx, g)
|
||||
pm.resolveWithContext(getHop("a", "b"), ctx, g)
|
||||
|
||||
// VIOLATING — must be flagged.
|
||||
pm.resolveWithContext(h, nil, g)
|
||||
pm.resolveWithContext(getHop("a", "b"), nil, g)
|
||||
|
||||
// NON-pm receiver — must NOT be flagged (selector constrained to pm).
|
||||
other.resolveWithContext(h, nil, g)
|
||||
Foo{}.resolveWithContext(h, nil, g)
|
||||
|
||||
// Different method name — must NOT be flagged.
|
||||
pm.resolveSomethingElse(h, nil, g)
|
||||
}
|
||||
|
||||
func getHop(a, b string) string { return a + b }
|
||||
|
||||
type prefixMap struct{}
|
||||
func (p *prefixMap) resolveWithContext(h string, ctx []string, g interface{}) {}
|
||||
func (p *prefixMap) resolveSomethingElse(h string, ctx []string, g interface{}) {}
|
||||
|
||||
type Foo struct{}
|
||||
func (Foo) resolveWithContext(h string, ctx []string, g interface{}) {}
|
||||
`
|
||||
|
||||
fset := token.NewFileSet()
|
||||
af, err := parser.ParseFile(fset, "synthetic.go", src, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
t.Fatalf("parse synthetic source: %v", err)
|
||||
}
|
||||
|
||||
offenders, totalCallSites := findPMResolveNilContextOffenders(fset, af, "synthetic.go")
|
||||
|
||||
// Expect 4 pm.resolveWithContext call sites total (2 clean + 2 nil),
|
||||
// of which 2 are nil-context offenders. The two non-pm receivers and
|
||||
// the resolveSomethingElse call MUST be ignored.
|
||||
const wantTotal = 4
|
||||
const wantOffenders = 2
|
||||
if totalCallSites != wantTotal {
|
||||
t.Errorf("totalCallSites = %d, want %d (selector should match pm.resolveWithContext only)",
|
||||
totalCallSites, wantTotal)
|
||||
}
|
||||
if len(offenders) != wantOffenders {
|
||||
t.Errorf("len(offenders) = %d, want %d", len(offenders), wantOffenders)
|
||||
for _, o := range offenders {
|
||||
t.Logf(" offender: %s:%d arg2=%s", o.file, o.line, o.text)
|
||||
}
|
||||
}
|
||||
|
||||
// Both flagged offenders must render arg2 as the literal text "nil"
|
||||
// (proves renderExpr is round-tripping ast → source, not returning a
|
||||
// type tag like "*ast.Ident").
|
||||
for _, o := range offenders {
|
||||
if o.text != "nil" {
|
||||
t.Errorf("offender at line %d: arg2 text = %q, want %q", o.line, o.text, "nil")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderExprRoundTripsSource is a focused assertion that renderExpr
|
||||
// uses go/printer (not %T) — guards against regressing exprText back to
|
||||
// the dead-branch state that always returned the type name "*ast.Ident".
|
||||
func TestRenderExprRoundTripsSource(t *testing.T) {
|
||||
cases := []struct{ src, want string }{
|
||||
{"nil", "nil"},
|
||||
{"ctx", "ctx"},
|
||||
{`getHop("a", "b")`, `getHop("a", "b")`},
|
||||
{"foo.bar", "foo.bar"},
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
for _, tc := range cases {
|
||||
expr, err := parser.ParseExprFrom(fset, "expr.go", tc.src, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", tc.src, err)
|
||||
}
|
||||
got := renderExpr(fset, expr)
|
||||
if got != tc.want {
|
||||
t.Errorf("renderExpr(%q) = %q, want %q", tc.src, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,8 +108,8 @@ func TestResolveWithContext_FirstMatchFallback(t *testing.T) {
|
||||
if ni == nil || ni.Name != "First" {
|
||||
t.Fatalf("expected First, got %v", ni)
|
||||
}
|
||||
if confidence != "first_match" {
|
||||
t.Fatalf("expected first_match, got %s", confidence)
|
||||
if confidence != "observation_count_fallback" {
|
||||
t.Fatalf("expected observation_count_fallback, got %s", confidence)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+48
-7
@@ -128,6 +128,9 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/health", s.handleHealth).Methods("GET")
|
||||
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
|
||||
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
|
||||
r.HandleFunc("/api/perf/io", s.handlePerfIO).Methods("GET")
|
||||
r.HandleFunc("/api/perf/sqlite", s.handlePerfSqlite).Methods("GET")
|
||||
r.HandleFunc("/api/perf/write-sources", s.handlePerfWriteSources).Methods("GET")
|
||||
r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
|
||||
r.Handle("/api/admin/prune", s.requireAPIKey(http.HandlerFunc(s.handleAdminPrune))).Methods("POST")
|
||||
r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
|
||||
@@ -151,6 +154,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 +1101,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,13 +1222,23 @@ 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 := ""
|
||||
if n, ok := node["name"]; ok && n != nil {
|
||||
name = fmt.Sprintf("%v", n)
|
||||
}
|
||||
recentAdverts, _ := s.db.GetRecentTransmissionsForNode(pubkey, name, 20)
|
||||
// #1143: GetRecentTransmissionsForNode no longer accepts a name fallback;
|
||||
// attribution is strict exact-match on the indexed from_pubkey column.
|
||||
recentAdverts, _ := s.db.GetRecentTransmissionsForNode(pubkey, 20)
|
||||
|
||||
writeJSON(w, NodeDetailResponse{
|
||||
Node: node,
|
||||
@@ -1386,11 +1421,17 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
pathGroups := map[string]*pathAgg{}
|
||||
totalTransmissions := 0
|
||||
hopCache := make(map[string]*nodeInfo)
|
||||
// Anchor the resolver with the node being queried so tier-1/2 hop-context
|
||||
// resolution lights up when a hop prefix matches the destination node
|
||||
// (handleNodePaths aggregates paths terminating at lowerPK). Passing nil
|
||||
// here re-introduced regression #1197 in production. See
|
||||
// resolve_context_callsites_test.go.
|
||||
hopContext := []string{lowerPK}
|
||||
resolveHop := func(hop string) *nodeInfo {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.store.graph)
|
||||
r, _, _ := pm.resolveWithContext(hop, hopContext, s.store.graph)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
@@ -1908,7 +1949,7 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
pk := best.PublicKey
|
||||
hr.BestCandidate = &pk
|
||||
hr.Confidence = "neighbor_affinity"
|
||||
} else if (confidence == "geo_proximity" || confidence == "gps_preference" || confidence == "first_match") && best != nil {
|
||||
} else if (confidence == "geo_proximity" || confidence == "gps_preference" || confidence == "observation_count_fallback") && best != nil {
|
||||
// Propagate lower-priority tiers so the API reflects the actual
|
||||
// resolution strategy used, rather than collapsing everything to "ambiguous".
|
||||
hr.Confidence = confidence
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSchemaDegradationLogIsPerStore asserts that two independent
|
||||
// PacketStore instances both emit a schema-degradation warning for the
|
||||
// same message. With the (pre-#1199) package-level sync.Map, the second
|
||||
// instance silently swallows the warning — that is order-dependent test
|
||||
// pollution and is exactly what item 5/6 of #1199 calls out.
|
||||
//
|
||||
// RED: today, only the first store logs; the second is suppressed by the
|
||||
// package-level sentinel. GREEN follow-up moves the sentinel to a
|
||||
// PacketStore field so each instance has a fresh dedupe set.
|
||||
func TestSchemaDegradationLogIsPerStore(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := log.Writer()
|
||||
prevFlags := log.Flags()
|
||||
log.SetOutput(&buf)
|
||||
log.SetFlags(0)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(prev)
|
||||
log.SetFlags(prevFlags)
|
||||
})
|
||||
|
||||
const msg = "test-schema-degradation-marker-1199"
|
||||
|
||||
s1 := &PacketStore{}
|
||||
s2 := &PacketStore{}
|
||||
s1.logSchemaDegradationOnce(msg)
|
||||
s2.logSchemaDegradationOnce(msg)
|
||||
|
||||
hits := strings.Count(buf.String(), msg)
|
||||
if hits != 2 {
|
||||
t.Fatalf("expected 2 log emissions (one per PacketStore), got %d. "+
|
||||
"package-level sentinel pollutes across instances — move to a "+
|
||||
"struct field. log buffer:\n%s", hits, buf.String())
|
||||
}
|
||||
}
|
||||
+396
-88
@@ -173,6 +173,9 @@ type PacketStore struct {
|
||||
nodeCache []nodeInfo
|
||||
nodePM *prefixMap
|
||||
nodeCacheTime time.Time
|
||||
// Per-store dedupe set for one-shot schema-degradation warnings. Field
|
||||
// (not package-level) so each test gets a fresh state — see #1199 item 5.
|
||||
schemaDegradationLogged sync.Map
|
||||
// Precomputed subpath index: raw comma-joined hops → occurrence count.
|
||||
// Built during Load(), incrementally updated on ingest. Avoids full
|
||||
// packet iteration at query time (O(unique_subpaths) vs O(total_packets)).
|
||||
@@ -1698,16 +1701,13 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
repeaterSet[n.PublicKey] = true
|
||||
}
|
||||
}
|
||||
hopCache := make(map[string]*nodeInfo)
|
||||
resolveHop := func(hop string) *nodeInfo {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
// Per-tx hop resolver: cache reused across txs, context rebound per
|
||||
// tx via setContext (#1197 perf fix).
|
||||
resolveHop, setContext := s.hopResolverPerTx(pm)
|
||||
for _, tx := range broadcastTxs {
|
||||
// Per-tx context (sender + observer + unambiguous-prefix anchors)
|
||||
// so resolveWithContext tiers 1 and 2 light up. See #1197.
|
||||
setContext(buildHopContextPubkeys(tx, pm))
|
||||
txHops, txPath := computeDistancesForTx(tx, nodeByPk, repeaterSet, resolveHop)
|
||||
if len(txHops) > 0 {
|
||||
s.distHops = append(s.distHops, txHops...)
|
||||
@@ -2935,18 +2935,12 @@ func (s *PacketStore) updateDistanceIndexForTxs(txs []*StoreTx) {
|
||||
repeaterSet[nd.PublicKey] = true
|
||||
}
|
||||
}
|
||||
hopCache := make(map[string]*nodeInfo)
|
||||
resolveHop := func(hop string) *nodeInfo {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
|
||||
// Per-tx hop resolver shared across the recompute loop (#1197 perf).
|
||||
resolveHop, setContext := s.hopResolverPerTx(pm)
|
||||
// Recompute distance records for each changed tx.
|
||||
for _, tx := range txs {
|
||||
// Per-tx context for hop disambiguation (#1197).
|
||||
setContext(buildHopContextPubkeys(tx, pm))
|
||||
txHops, txPath := computeDistancesForTx(tx, nodeByPk, repeaterSet, resolveHop)
|
||||
if len(txHops) > 0 {
|
||||
s.distHops = append(s.distHops, txHops...)
|
||||
@@ -2971,20 +2965,14 @@ func (s *PacketStore) buildDistanceIndex() {
|
||||
}
|
||||
}
|
||||
|
||||
hopCache := make(map[string]*nodeInfo)
|
||||
resolveHop := func(hop string) *nodeInfo {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
|
||||
hops := make([]distHopRecord, 0, len(s.packets))
|
||||
paths := make([]distPathRecord, 0, len(s.packets)/2)
|
||||
|
||||
// Per-tx hop resolver shared across the per-tx loop (#1197 perf).
|
||||
resolveHop, setContext := s.hopResolverPerTx(pm)
|
||||
for _, tx := range s.packets {
|
||||
// Per-tx context for hop disambiguation (#1197).
|
||||
setContext(buildHopContextPubkeys(tx, pm))
|
||||
txHops, txPath := computeDistancesForTx(tx, nodeByPk, repeaterSet, resolveHop)
|
||||
if len(txHops) > 0 {
|
||||
hops = append(hops, txHops...)
|
||||
@@ -3444,6 +3432,140 @@ func (s *PacketStore) StartEvictionTicker() func() {
|
||||
}
|
||||
|
||||
// computeDistancesForTx computes distance records for a single transmission.
|
||||
// buildHopContextPubkeys collects context pubkeys for hop disambiguation:
|
||||
// the originator/sender pubkey plus any unambiguous-prefix anchors in the
|
||||
// path (single-candidate prefixes — strong context). Used by callers of
|
||||
// pm.resolveWithContext to light up tiers 1 and 2 of the resolver. See #1197.
|
||||
//
|
||||
// Returned pubkeys are de-duplicated and lowercased.
|
||||
func buildHopContextPubkeys(tx *StoreTx, pm *prefixMap) []string {
|
||||
if tx == nil || pm == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, 16)
|
||||
out := make([]string, 0, 16)
|
||||
add := func(pk string) {
|
||||
if pk == "" {
|
||||
return
|
||||
}
|
||||
l := strings.ToLower(pk)
|
||||
if _, ok := seen[l]; ok {
|
||||
return
|
||||
}
|
||||
seen[l] = struct{}{}
|
||||
out = append(out, l)
|
||||
}
|
||||
|
||||
// Sender / originator pubkey from decoded payload. Use the cached
|
||||
// ParsedDecoded() (sync.Once-gated) instead of re-unmarshaling — the
|
||||
// helper is hot (3 distance sites + analytics topology, all 30k+ tx
|
||||
// loops). See #1197 (carmack/adversarial r1).
|
||||
if dec := tx.ParsedDecoded(); dec != nil {
|
||||
if pk, ok := dec["pubKey"].(string); ok {
|
||||
add(pk)
|
||||
}
|
||||
}
|
||||
|
||||
// Observer pubkey, where available. ObserverID is the observers.id PRIMARY
|
||||
// KEY from the MQTT topic — it is NOT guaranteed to be a node pubkey hex
|
||||
// (some observers register with arbitrary string ids like "myobserver").
|
||||
// Guard against polluting the context with non-pubkey strings: include
|
||||
// only when it parses as hex AND is long enough to plausibly be a pubkey
|
||||
// prefix. The full prefix-map lookup would also be acceptable, but the
|
||||
// hex+length check is O(len) and avoids one map probe per tx on a hot
|
||||
// path. See #1197 (adversarial r1 #4).
|
||||
if obs := tx.ObserverID; obs != "" && len(obs) >= 4 && isHexLower(strings.ToLower(obs)) {
|
||||
add(obs)
|
||||
}
|
||||
|
||||
// Unambiguous-prefix anchors: any hop in the path whose prefix has exactly
|
||||
// one candidate is a strong context signal.
|
||||
for _, hop := range txGetParsedPath(tx) {
|
||||
h := strings.ToLower(hop)
|
||||
if cands, ok := pm.m[h]; ok && len(cands) == 1 {
|
||||
add(cands[0].PublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// isHexLower reports whether s consists only of [0-9a-f] (assumes already
|
||||
// lowercased by caller). Used to guard ObserverID before adding it to the
|
||||
// hop-disambiguation context, since ObserverID is a free-form observers.id
|
||||
// and may not be a node pubkey hex.
|
||||
func isHexLower(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildAggregateHopContextPubkeys gathers context across many txs for hot
|
||||
// loops that resolve hops outside any per-tx scope (subpath/topology
|
||||
// aggregations). Caller passes the slice of txs to consider; we union the
|
||||
// per-tx contexts with de-dup. Used by call sites that read from precomputed
|
||||
// indices (s.spIndex, s.spTxIndex) or that resolve user-supplied hops.
|
||||
//
|
||||
// Result is order-independent in semantics; iteration order is deterministic
|
||||
// only modulo Go's map iteration (acceptable — the resolver's tier-2 averages
|
||||
// GPS positions and tier-3 picks the lex-smallest pubkey on ties, so context
|
||||
// order does not affect the chosen candidate).
|
||||
func buildAggregateHopContextPubkeys(txs []*StoreTx, pm *prefixMap) []string {
|
||||
if len(txs) == 0 || pm == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, 32)
|
||||
var out []string
|
||||
for _, tx := range txs {
|
||||
for _, pk := range buildHopContextPubkeys(tx, pm) {
|
||||
if _, ok := seen[pk]; ok {
|
||||
continue
|
||||
}
|
||||
seen[pk] = struct{}{}
|
||||
out = append(out, pk)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hopResolverPerTx returns (resolveHop, setContext). The cache is allocated
|
||||
// once and cleared between txs; setContext rebinds the per-tx context. Used
|
||||
// by all per-tx distance/topology loops to avoid 4× duplicate closure
|
||||
// definitions and per-tx map allocation. See #1197 (adversarial r1 #7,
|
||||
// carmack r1 #3).
|
||||
//
|
||||
// CONCURRENCY: NOT safe for concurrent use. The returned closures share
|
||||
// mutable captured state — `contextPubkeys` is reassigned by setContext and
|
||||
// read by resolveHop, and `hopCache` is mutated by both (resolveHop writes
|
||||
// on miss, setContext clears wholesale). Callers MUST invoke both functions
|
||||
// from a single goroutine for the lifetime of the (resolveHop, setContext)
|
||||
// pair. If a future caller fans out per-tx work across goroutines, allocate
|
||||
// a fresh resolver pair per goroutine. See #1199 item 4.
|
||||
func (s *PacketStore) hopResolverPerTx(pm *prefixMap) (resolveHop func(string) *nodeInfo, setContext func([]string)) {
|
||||
hopCache := make(map[string]*nodeInfo, 16)
|
||||
var contextPubkeys []string
|
||||
resolveHop = func(hop string) *nodeInfo {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, contextPubkeys, s.graph)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
setContext = func(ctx []string) {
|
||||
contextPubkeys = ctx
|
||||
clear(hopCache)
|
||||
}
|
||||
return resolveHop, setContext
|
||||
}
|
||||
|
||||
func computeDistancesForTx(tx *StoreTx, nodeByPk map[string]*nodeInfo, repeaterSet map[string]bool, resolveHop func(string) *nodeInfo) ([]distHopRecord, *distPathRecord) {
|
||||
pathHops := txGetParsedPath(tx)
|
||||
if len(pathHops) == 0 {
|
||||
@@ -3672,6 +3794,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
|
||||
@@ -4742,24 +4909,45 @@ func (s *PacketStore) computeAnalyticsRF(region string, window TimeWindow) map[s
|
||||
// --- Topology Analytics ---
|
||||
|
||||
type nodeInfo struct {
|
||||
PublicKey string
|
||||
Name string
|
||||
Role string
|
||||
Lat float64
|
||||
Lon float64
|
||||
HasGPS bool
|
||||
LastSeen time.Time
|
||||
PublicKey string
|
||||
Name string
|
||||
Role string
|
||||
Lat float64
|
||||
Lon float64
|
||||
HasGPS bool
|
||||
LastSeen time.Time
|
||||
ObservationCount int // count of advertisements/observations; used for tier-3 tiebreak in resolveWithContext
|
||||
}
|
||||
|
||||
// schemaDegradationLogged is now a PacketStore field (see type definition) so
|
||||
// each store/test instance has a fresh dedupe set. Issue #1199 item 5: the
|
||||
// prior package-level sync.Map silently suppressed re-emission across tests.
|
||||
|
||||
func (s *PacketStore) logSchemaDegradationOnce(msg string) {
|
||||
if _, loaded := s.schemaDegradationLogged.LoadOrStore(msg, true); !loaded {
|
||||
log.Printf("[store] schema-degradation: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PacketStore) getAllNodes() []nodeInfo {
|
||||
// Try with last_seen first; fall back to without if column doesn't exist.
|
||||
rows, err := s.db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen FROM nodes")
|
||||
// Schema probe: try richest → leanest. Logs a one-shot warning when we
|
||||
// fall back to a thinner schema so operators see that a column is
|
||||
// missing and the new tiebreak features are degraded. See #1197
|
||||
// (adversarial r1 #10).
|
||||
rows, err := s.db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen, COALESCE(advert_count, 0) FROM nodes")
|
||||
hasLastSeen := true
|
||||
hasAdvertCount := true
|
||||
if err != nil {
|
||||
rows, err = s.db.conn.Query("SELECT public_key, name, role, lat, lon FROM nodes")
|
||||
hasLastSeen = false
|
||||
s.logSchemaDegradationOnce("nodes.advert_count missing — tier-3/4 ObservationCount tiebreak degraded; resolveWithContext will fall back to lex-pubkey order")
|
||||
rows, err = s.db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen FROM nodes")
|
||||
hasAdvertCount = false
|
||||
if err != nil {
|
||||
return nil
|
||||
s.logSchemaDegradationOnce("nodes.last_seen missing — node freshness signal unavailable")
|
||||
rows, err = s.db.conn.Query("SELECT public_key, name, role, lat, lon FROM nodes")
|
||||
hasLastSeen = false
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -4769,7 +4957,10 @@ func (s *PacketStore) getAllNodes() []nodeInfo {
|
||||
var name, role sql.NullString
|
||||
var lat, lon sql.NullFloat64
|
||||
var lastSeen sql.NullString
|
||||
if hasLastSeen {
|
||||
var advertCount sql.NullInt64
|
||||
if hasAdvertCount {
|
||||
rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen, &advertCount)
|
||||
} else if hasLastSeen {
|
||||
rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen)
|
||||
} else {
|
||||
rows.Scan(&pk, &name, &role, &lat, &lon)
|
||||
@@ -4787,6 +4978,9 @@ func (s *PacketStore) getAllNodes() []nodeInfo {
|
||||
n.LastSeen = t
|
||||
}
|
||||
}
|
||||
if hasAdvertCount && advertCount.Valid {
|
||||
n.ObservationCount = int(advertCount.Int64)
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
return nodes
|
||||
@@ -4884,12 +5078,27 @@ func (pm *prefixMap) resolve(hop string) *nodeInfo {
|
||||
|
||||
// resolveWithContext resolves a hop prefix using the neighbor affinity graph
|
||||
// for disambiguation when multiple candidates match. It applies a 4-tier
|
||||
// priority: (1) affinity graph score, (2) geographic proximity to context
|
||||
// nodes, (3) GPS preference, (4) first match fallback.
|
||||
// priority:
|
||||
//
|
||||
// (1) "neighbor_affinity" — graph score vs context nodes,
|
||||
// requires affinity ≥3× runner-up and
|
||||
// affinityMinObservations
|
||||
// (2) "geo_proximity" — geographic proximity to GPS context
|
||||
// centroid (only fires when at least
|
||||
// one context node has GPS)
|
||||
// (3) "gps_preference" — among GPS-having candidates, pick
|
||||
// highest ObservationCount; lex-pubkey
|
||||
// tiebreak for determinism
|
||||
// (4) "observation_count_fallback" — no GPS available; pick highest
|
||||
// ObservationCount; lex-pubkey tiebreak
|
||||
//
|
||||
// (Pre-PR #1197/#1198 the tier-3 step was first-GPS-wins and tier-4 was
|
||||
// first-slice-element. Both now use observation count + lex tiebreak; the
|
||||
// returned method label was renamed accordingly.)
|
||||
//
|
||||
// contextPubkeys are pubkeys of nodes that provide context for disambiguation
|
||||
// (e.g., the originator, observer, or adjacent hops in the path).
|
||||
// graph may be nil, in which case it falls back to the existing resolve().
|
||||
// graph may be nil, in which case tier-1 is skipped.
|
||||
func (pm *prefixMap) resolveWithContext(hop string, contextPubkeys []string, graph *NeighborGraph) (*nodeInfo, string, float64) {
|
||||
h := strings.ToLower(hop)
|
||||
candidates := pm.m[h]
|
||||
@@ -5001,15 +5210,48 @@ func (pm *prefixMap) resolveWithContext(hop string, contextPubkeys []string, gra
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: GPS preference
|
||||
// Priority 3: GPS preference. Among GPS-having candidates, prefer the one
|
||||
// with the highest observation count (recent/active evidence) rather than
|
||||
// slice/DB-insertion order. Ties on count are broken by lexicographically
|
||||
// smallest PublicKey for full determinism. See #1197.
|
||||
bestGPSIdx := -1
|
||||
for i := range candidates {
|
||||
if candidates[i].HasGPS {
|
||||
return &candidates[i], "gps_preference", 0
|
||||
if !candidates[i].HasGPS {
|
||||
continue
|
||||
}
|
||||
if bestGPSIdx < 0 || betterByObsCount(&candidates[i], &candidates[bestGPSIdx]) {
|
||||
bestGPSIdx = i
|
||||
}
|
||||
}
|
||||
if bestGPSIdx >= 0 {
|
||||
return &candidates[bestGPSIdx], "gps_preference", 0
|
||||
}
|
||||
|
||||
// Priority 4: First match fallback
|
||||
return &candidates[0], "first_match", 0
|
||||
// Priority 4: Fallback — pick the candidate with the highest observation
|
||||
// count (no GPS available on any candidate). Avoids slice-order
|
||||
// arbitrariness. Ties on count are broken by lexicographically smallest
|
||||
// PublicKey. Method label "observation_count_fallback" — the previous
|
||||
// "first_match" was misleading after the tier-4 algorithm changed in
|
||||
// PR #1198 (adversarial r1 #2).
|
||||
bestIdx := 0
|
||||
for i := 1; i < len(candidates); i++ {
|
||||
if betterByObsCount(&candidates[i], &candidates[bestIdx]) {
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
return &candidates[bestIdx], "observation_count_fallback", 0
|
||||
}
|
||||
|
||||
// betterByObsCount reports whether candidate a should beat b under the
|
||||
// tier-3/4 selection rule: higher ObservationCount wins; ties go to the
|
||||
// lexicographically smaller PublicKey for determinism. Pointer receivers
|
||||
// avoid value-copying nodeInfo (string + 2 floats + time.Time + int) on
|
||||
// the hot resolve path. See #1197 (adversarial r1 #6, carmack r1 #4).
|
||||
func betterByObsCount(a, b *nodeInfo) bool {
|
||||
if a.ObservationCount != b.ObservationCount {
|
||||
return a.ObservationCount > b.ObservationCount
|
||||
}
|
||||
return a.PublicKey < b.PublicKey
|
||||
}
|
||||
|
||||
// geoDistApprox returns an approximate distance between two lat/lon points
|
||||
@@ -5070,33 +5312,17 @@ func (s *PacketStore) computeAnalyticsTopology(region string, window TimeWindow)
|
||||
|
||||
allNodes, pm := s.getCachedNodesAndPM()
|
||||
_ = allNodes // only pm is needed for topology
|
||||
hopCache := make(map[string]*nodeInfo)
|
||||
|
||||
resolveHop := func(hop string) *nodeInfo {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
|
||||
hopCounts := map[int]int{}
|
||||
var allHopsList []int
|
||||
hopSnr := map[int][]float64{}
|
||||
hopFreq := map[string]int{}
|
||||
pairFreq := map[string]int{}
|
||||
observerMap := map[string]string{} // observer_id → observer_name
|
||||
perObserver := map[string]map[string]*struct{ minDist, maxDist, count int }{}
|
||||
|
||||
// Materialize the filtered tx slice ONCE — both the context-build pass
|
||||
// and the main aggregation pass need the same window+region predicate.
|
||||
// Two scans of s.packets re-running identical predicates is wasteful at
|
||||
// the 30k+ packet hot-path scale (#1199 item 2). One filter, two passes
|
||||
// over the result.
|
||||
filteredTxs := make([]*StoreTx, 0, len(s.packets))
|
||||
for _, tx := range s.packets {
|
||||
if !window.Includes(tx.FirstSeen) {
|
||||
continue
|
||||
}
|
||||
hops := txGetParsedPath(tx)
|
||||
if len(hops) == 0 {
|
||||
continue
|
||||
}
|
||||
if regionObs != nil {
|
||||
match := false
|
||||
for _, obs := range tx.Observations {
|
||||
@@ -5109,6 +5335,54 @@ func (s *PacketStore) computeAnalyticsTopology(region string, window TimeWindow)
|
||||
continue
|
||||
}
|
||||
}
|
||||
filteredTxs = append(filteredTxs, tx)
|
||||
}
|
||||
|
||||
// Pre-pass: build the full hop-disambiguation context from all in-window
|
||||
// txs BEFORE any resolveHop call. The earlier shape — populating
|
||||
// contextPubkeys lazily during the main scan and reading it from a
|
||||
// closure — was correct only because the current code never calls
|
||||
// resolveHop inside the scan loop. A future maintainer who adds such a
|
||||
// call inside the loop would silently get partial context AND a
|
||||
// stale-cached result for any hop seen before the context grew. Two
|
||||
// explicit passes remove the hazard. See #1197 (carmack/adversarial r1).
|
||||
var contextPubkeys []string
|
||||
{
|
||||
seen := make(map[string]struct{}, 64)
|
||||
for _, tx := range filteredTxs {
|
||||
for _, pk := range buildHopContextPubkeys(tx, pm) {
|
||||
if _, ok := seen[pk]; ok {
|
||||
continue
|
||||
}
|
||||
seen[pk] = struct{}{}
|
||||
contextPubkeys = append(contextPubkeys, pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hopCache := make(map[string]*nodeInfo)
|
||||
resolveHop := func(hop string) *nodeInfo {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
return cached
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, contextPubkeys, s.graph)
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
|
||||
hopCounts := map[int]int{}
|
||||
var allHopsList []int
|
||||
hopSnr := map[int][]float64{}
|
||||
hopFreq := map[string]int{}
|
||||
pairFreq := map[string]int{}
|
||||
observerMap := map[string]string{} // observer_id → observer_name
|
||||
perObserver := map[string]map[string]*struct{ minDist, maxDist, count int }{}
|
||||
|
||||
for _, tx := range filteredTxs {
|
||||
hops := txGetParsedPath(tx)
|
||||
if len(hops) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
n := len(hops)
|
||||
hopCounts[n]++
|
||||
@@ -5773,21 +6047,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)}
|
||||
@@ -7510,6 +7804,10 @@ func (s *PacketStore) GetAnalyticsSubpathsBulk(region string, groups []subpathGr
|
||||
// Single scan: bucket by hop length into per-group accumulators.
|
||||
s.mu.RLock()
|
||||
_, pm := s.getCachedNodesAndPM()
|
||||
// Aggregate hop-disambiguation context across all packets so the
|
||||
// resolver's tiers 1 and 2 light up even on this bulk-aggregate path
|
||||
// (the index iterates raw subpath strings, not per-tx). See #1197.
|
||||
contextPubkeys := buildAggregateHopContextPubkeys(s.packets, pm)
|
||||
hopCache := make(map[string]*nodeInfo)
|
||||
resolveHop := func(hop string) string {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
@@ -7518,7 +7816,7 @@ func (s *PacketStore) GetAnalyticsSubpathsBulk(region string, groups []subpathGr
|
||||
}
|
||||
return hop
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
r, _, _ := pm.resolveWithContext(hop, contextPubkeys, s.graph)
|
||||
hopCache[hop] = r
|
||||
if r != nil {
|
||||
return r.Name
|
||||
@@ -7590,6 +7888,9 @@ func (s *PacketStore) computeAnalyticsSubpaths(region string, minLen, maxLen, li
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
_, pm := s.getCachedNodesAndPM()
|
||||
// Aggregate hop-disambiguation context across all packets — bulk
|
||||
// aggregator over s.spIndex / per-tx fallback both need it. See #1197.
|
||||
contextPubkeys := buildAggregateHopContextPubkeys(s.packets, pm)
|
||||
hopCache := make(map[string]*nodeInfo)
|
||||
resolveHop := func(hop string) string {
|
||||
if cached, ok := hopCache[hop]; ok {
|
||||
@@ -7598,7 +7899,7 @@ func (s *PacketStore) computeAnalyticsSubpaths(region string, minLen, maxLen, li
|
||||
}
|
||||
return hop
|
||||
}
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
r, _, _ := pm.resolveWithContext(hop, contextPubkeys, s.graph)
|
||||
hopCache[hop] = r
|
||||
if r != nil {
|
||||
return r.Name
|
||||
@@ -7732,10 +8033,21 @@ func (s *PacketStore) GetSubpathDetail(rawHops []string) map[string]interface{}
|
||||
|
||||
_, pm := s.getCachedNodesAndPM()
|
||||
|
||||
// Build the subpath key the same way the index does (lowercase, comma-joined)
|
||||
spKey := strings.ToLower(strings.Join(rawHops, ","))
|
||||
|
||||
// Direct lookup instead of scanning all packets
|
||||
matchedTxs := s.spTxIndex[spKey]
|
||||
|
||||
// Hop-disambiguation context: union over the matched txs that produced
|
||||
// this subpath. This is the right scope — those are the packets that
|
||||
// witnessed the requested hop sequence. See #1197.
|
||||
contextPubkeys := buildAggregateHopContextPubkeys(matchedTxs, pm)
|
||||
|
||||
// Resolve the requested hops
|
||||
nodes := make([]map[string]interface{}, len(rawHops))
|
||||
for i, hop := range rawHops {
|
||||
r, _, _ := pm.resolveWithContext(hop, nil, s.graph)
|
||||
r, _, _ := pm.resolveWithContext(hop, contextPubkeys, s.graph)
|
||||
entry := map[string]interface{}{"hop": hop, "name": hop, "lat": nil, "lon": nil, "pubkey": nil}
|
||||
if r != nil {
|
||||
entry["name"] = r.Name
|
||||
@@ -7748,12 +8060,6 @@ func (s *PacketStore) GetSubpathDetail(rawHops []string) map[string]interface{}
|
||||
nodes[i] = entry
|
||||
}
|
||||
|
||||
// Build the subpath key the same way the index does (lowercase, comma-joined)
|
||||
spKey := strings.ToLower(strings.Join(rawHops, ","))
|
||||
|
||||
// Direct lookup instead of scanning all packets
|
||||
matchedTxs := s.spTxIndex[spKey]
|
||||
|
||||
hourBuckets := make([]int, 24)
|
||||
var snrSum, rssiSum float64
|
||||
var snrCount, rssiCount int
|
||||
@@ -7791,11 +8097,13 @@ func (s *PacketStore) GetSubpathDetail(rawHops []string) map[string]interface{}
|
||||
observers[tx.ObserverName]++
|
||||
}
|
||||
|
||||
// Full parent path (resolved)
|
||||
// Full parent path (resolved). Per-tx context so the resolver picks
|
||||
// the right candidate when prefixes are ambiguous. See #1197.
|
||||
txCtx := buildHopContextPubkeys(tx, pm)
|
||||
hops := txGetParsedPath(tx)
|
||||
resolved := make([]string, len(hops))
|
||||
for i, h := range hops {
|
||||
r, _, _ := pm.resolveWithContext(h, nil, s.graph)
|
||||
r, _, _ := pm.resolveWithContext(h, txCtx, s.graph)
|
||||
if r != nil {
|
||||
resolved[i] = r.Name
|
||||
} else {
|
||||
|
||||
+14
-2
@@ -16,6 +16,7 @@
|
||||
"incrementalVacuumPages": 1024,
|
||||
"_comment": "vacuumOnStartup: run one-time full VACUUM to enable incremental auto-vacuum on existing DBs (blocks startup for minutes on large DBs; requires 2x DB file size in free disk space). incrementalVacuumPages: free pages returned to OS after each retention reaper cycle (default 1024). See #919."
|
||||
},
|
||||
"_comment_ingestorStats": "Ingestor publishes a 1-Hz stats snapshot consumed by the server's /api/perf/io and /api/perf/write-sources endpoints (#1120). Path is configured via the CORESCOPE_INGESTOR_STATS environment variable on the INGESTOR process. Default: /tmp/corescope-ingestor-stats.json. The writer uses O_NOFOLLOW + 0o600, so a pre-planted symlink in /tmp cannot be used to clobber an arbitrary file. SECURITY: in shared-tmp environments (multi-tenant hosts), point CORESCOPE_INGESTOR_STATS at a private directory like /var/lib/corescope/ingestor-stats.json that only the corescope user can write to.",
|
||||
"https": {
|
||||
"cert": "/path/to/cert.pem",
|
||||
"key": "/path/to/key.pem",
|
||||
@@ -155,7 +156,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 +177,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 +224,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 +235,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.",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/meshcore-analyzer/perfio
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package perfio holds the canonical PerfIOSample type shared between the
|
||||
// ingestor (which publishes /proc/self/io rate samples to its on-disk stats
|
||||
// file) and the server (which reads that file and surfaces the sample under
|
||||
// /api/perf/io's `ingestor` block). Sharing the type prevents silent JSON
|
||||
// contract drift if a field is added on one side only.
|
||||
//
|
||||
// The /proc/self/io key:value parser also lives here (Carmack #1167
|
||||
// must-fix #7) so the two binaries don't carry divergent copies of the
|
||||
// same parser — past divergence already produced a real bug (see must-fix
|
||||
// #6: the parsedAny empty-key gate was added on one side only).
|
||||
package perfio
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sample is the per-process I/O rate sample written by the ingestor and
|
||||
// consumed by the server. Field names + json tags MUST be considered the
|
||||
// stable on-disk contract — adding/renaming a field is a breaking change.
|
||||
type Sample struct {
|
||||
ReadBytesPerSec float64 `json:"readBytesPerSec"`
|
||||
WriteBytesPerSec float64 `json:"writeBytesPerSec"`
|
||||
CancelledWriteBytesPerSec float64 `json:"cancelledWriteBytesPerSec"`
|
||||
SyscallsRead float64 `json:"syscallsRead"`
|
||||
SyscallsWrite float64 `json:"syscallsWrite"`
|
||||
SampledAt string `json:"sampledAt,omitempty"`
|
||||
}
|
||||
|
||||
// Counters is the raw /proc/self/io counter snapshot. Both the ingestor's
|
||||
// procIOSnapshot and the server's procIOSample are thin wrappers around
|
||||
// these fields plus a sampled-at timestamp; the parser populates Counters
|
||||
// directly so there's exactly ONE implementation of the key:value walker.
|
||||
type Counters struct {
|
||||
ReadBytes int64
|
||||
WriteBytes int64
|
||||
CancelledWriteBytes int64
|
||||
SyscR int64
|
||||
SyscW int64
|
||||
}
|
||||
|
||||
// ParseProcIO reads /proc/self/io-shaped key:value lines from sc and
|
||||
// populates c. Returns true iff at least one recognised key was
|
||||
// successfully parsed (Carmack must-fix #6 — empty / no-known-keys input
|
||||
// must NOT be treated as a valid sample, otherwise the next tick computes
|
||||
// a phantom delta against zero counters).
|
||||
func ParseProcIO(sc *bufio.Scanner, c *Counters) bool {
|
||||
parsedAny := false
|
||||
for sc.Scan() {
|
||||
parts := strings.SplitN(sc.Text(), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "read_bytes":
|
||||
c.ReadBytes = val
|
||||
parsedAny = true
|
||||
case "write_bytes":
|
||||
c.WriteBytes = val
|
||||
parsedAny = true
|
||||
case "cancelled_write_bytes":
|
||||
c.CancelledWriteBytes = val
|
||||
parsedAny = true
|
||||
case "syscr":
|
||||
c.SyscR = val
|
||||
parsedAny = true
|
||||
case "syscw":
|
||||
c.SyscW = val
|
||||
parsedAny = true
|
||||
}
|
||||
}
|
||||
return parsedAny
|
||||
}
|
||||
+271
-13
@@ -4,7 +4,29 @@
|
||||
(function () {
|
||||
let _analyticsData = {};
|
||||
const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed
|
||||
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||||
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''') : ''; }
|
||||
|
||||
// #1085 — Roles tab helpers (hoisted from renderRolesTab so they're not
|
||||
// re-allocated per render).
|
||||
function _rolesEmoji(role) {
|
||||
if (window.ROLE_EMOJI && window.ROLE_EMOJI[role]) return window.ROLE_EMOJI[role];
|
||||
return '•';
|
||||
}
|
||||
function _rolesFmtSec(v) {
|
||||
if (!v && v !== 0) return '—';
|
||||
var abs = Math.abs(v);
|
||||
if (abs < 1) return v.toFixed(2) + 's';
|
||||
if (abs < 60) return v.toFixed(1) + 's';
|
||||
if (abs < 3600) return (v / 60).toFixed(1) + 'm';
|
||||
if (abs < 86400) return (v / 3600).toFixed(1) + 'h';
|
||||
return (v / 86400).toFixed(1) + 'd';
|
||||
}
|
||||
// #1085 — auto-refresh timer for the Roles tab. Started when the Roles
|
||||
// tab is rendered, cleared on tab switch and destroy.
|
||||
var _rolesRefreshTimer = null;
|
||||
function _stopRolesRefresh() {
|
||||
if (_rolesRefreshTimer) { clearInterval(_rolesRefreshTimer); _rolesRefreshTimer = null; }
|
||||
}
|
||||
|
||||
// --- Status color helpers (read from CSS variables for theme support) ---
|
||||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||
@@ -98,6 +120,10 @@
|
||||
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
|
||||
<button class="tab-btn" data-tab="rf-health">RF Health</button>
|
||||
<button class="tab-btn" data-tab="clock-health">Clock Health</button>
|
||||
<!-- #1085 — Roles tab folded in from former /#/roles standalone page.
|
||||
Placed after Clock Health (clock-skew posture is shown per-role
|
||||
inside this tab) and before Prefix Tool (utility tabs trail). -->
|
||||
<button class="tab-btn" data-tab="roles">Roles</button>
|
||||
<button class="tab-btn" data-tab="prefix-tool">Prefix Tool</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,18 +135,40 @@
|
||||
// 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;
|
||||
// #1085 — Roles tab owns its own 60s auto-refresh; stop it on switch.
|
||||
if (_currentTab !== 'roles') _stopRolesRefresh();
|
||||
_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 +177,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 +190,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
|
||||
@@ -209,6 +263,7 @@
|
||||
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
|
||||
case 'rf-health': await renderRFHealthTab(el); break;
|
||||
case 'clock-health': await renderClockHealthTab(el); break;
|
||||
case 'roles': await renderRolesTab(el); break;
|
||||
case 'prefix-tool': await renderPrefixTool(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
@@ -737,6 +792,7 @@
|
||||
// ===================== CHANNELS =====================
|
||||
var _channelSortState = null;
|
||||
var _channelData = null;
|
||||
var _channelRenderGen = 0;
|
||||
var CHANNEL_SORT_KEY = 'meshcore-channel-sort';
|
||||
|
||||
function loadChannelSort() {
|
||||
@@ -747,6 +803,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 +849,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 +980,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 +1025,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>' +
|
||||
@@ -2051,10 +2232,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
|
||||
function destroy() { _stopRolesRefresh(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
|
||||
|
||||
// Expose for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
window._analyticsDecorateChannels = decorateAnalyticsChannels;
|
||||
window._analyticsSortChannels = sortChannels;
|
||||
window._analyticsLoadChannelSort = loadChannelSort;
|
||||
window._analyticsSaveChannelSort = saveChannelSort;
|
||||
@@ -3593,5 +3775,81 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
}
|
||||
}
|
||||
|
||||
// #1085 — Roles tab (folded in from former /#/roles page).
|
||||
// Renders distribution of node roles + per-role clock-skew posture.
|
||||
// Auto-refreshes every 60s while the Roles tab is active (matches the
|
||||
// behavior of the former standalone roles-page.js).
|
||||
async function renderRolesTab(el) {
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading roles…</div>';
|
||||
await _renderRolesTabBody(el);
|
||||
// (Re)start the 60s auto-refresh.
|
||||
_stopRolesRefresh();
|
||||
_rolesRefreshTimer = setInterval(function () {
|
||||
// Bail if the user navigated away from the Roles tab.
|
||||
if (_currentTab !== 'roles') { _stopRolesRefresh(); return; }
|
||||
var cur = document.getElementById('analyticsContent');
|
||||
if (!cur) { _stopRolesRefresh(); return; }
|
||||
_renderRolesTabBody(cur);
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
async function _renderRolesTabBody(el) {
|
||||
try {
|
||||
var data = await api('/analytics/roles', { ttl: CLIENT_TTL.analyticsRF });
|
||||
var roles = (data && data.roles) || [];
|
||||
var total = (data && data.totalNodes) || 0;
|
||||
if (!roles.length) {
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">No roles to show.</div>';
|
||||
return;
|
||||
}
|
||||
var maxCount = roles.reduce(function (m, r) { return Math.max(m, r.nodeCount || 0); }, 0) || 1;
|
||||
var rows = roles.map(function (r) {
|
||||
var pct = total > 0 ? ((r.nodeCount / total) * 100).toFixed(1) : '0.0';
|
||||
var barW = Math.round((r.nodeCount / maxCount) * 100);
|
||||
var sevCells =
|
||||
'<span title="OK (skew < 5min)" style="color:var(--color-success,#0a0)">' + (r.okCount || 0) + '</span> / ' +
|
||||
'<span title="Warning (5min – 1h)" style="color:var(--color-warning,#e80)">' + (r.warningCount || 0) + '</span> / ' +
|
||||
'<span title="Critical (1h – 30d)" style="color:var(--color-error,#c00)">' + (r.criticalCount || 0) + '</span> / ' +
|
||||
'<span title="Absurd (> 30d)" style="color:#a0a">' + (r.absurdCount || 0) + '</span> / ' +
|
||||
'<span title="No clock (> 365d)" style="color:#888">' + (r.noClockCount || 0) + '</span>';
|
||||
return '' +
|
||||
'<tr data-role="' + esc(r.role) + '">' +
|
||||
'<td>' + _rolesEmoji(r.role) + ' <strong>' + esc(r.role) + '</strong></td>' +
|
||||
'<td style="text-align:right">' + r.nodeCount + '</td>' +
|
||||
'<td style="text-align:right">' + pct + '%</td>' +
|
||||
'<td style="min-width:140px">' +
|
||||
'<div style="background:var(--color-surface-2,#eee);height:10px;border-radius:5px;overflow:hidden">' +
|
||||
'<div style="background:var(--color-accent,#06c);width:' + barW + '%;height:100%"></div>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td style="text-align:right">' + (r.withSkew || 0) + '</td>' +
|
||||
'<td style="text-align:right">' + _rolesFmtSec(r.medianAbsSkewSec || 0) + '</td>' +
|
||||
'<td style="text-align:right">' + _rolesFmtSec(r.meanAbsSkewSec || 0) + '</td>' +
|
||||
'<td style="white-space:nowrap">' + sevCells + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
el.innerHTML =
|
||||
'<p class="text-muted" style="margin:0 0 12px 0">Distribution of node roles across the mesh, with per-role clock-skew posture.</p>' +
|
||||
'<div class="roles-summary" style="margin-bottom:12px;color:var(--color-text-muted,#666)">' +
|
||||
'<strong>' + total + '</strong> nodes across <strong>' + roles.length + '</strong> roles' +
|
||||
'</div>' +
|
||||
'<table id="rolesTable" class="data-table analytics-table" style="width:100%">' +
|
||||
'<thead><tr>' +
|
||||
'<th>Role</th>' +
|
||||
'<th style="text-align:right">Count</th>' +
|
||||
'<th style="text-align:right">Share</th>' +
|
||||
'<th>Distribution</th>' +
|
||||
'<th style="text-align:right" title="Nodes with clock-skew samples">w/ Skew</th>' +
|
||||
'<th style="text-align:right" title="Median absolute skew">Median |skew|</th>' +
|
||||
'<th style="text-align:right" title="Mean absolute skew">Mean |skew|</th>' +
|
||||
'<th title="OK / Warning / Critical / Absurd / No-clock">Severity</th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>';
|
||||
} catch (err) {
|
||||
el.innerHTML = '<div class="text-center" style="color:var(--status-red);padding:40px">Failed to load roles: ' + esc(String(err.message || err)) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
})();
|
||||
|
||||
+503
-11
@@ -473,16 +473,160 @@ function buildHexLegend(ranges) {
|
||||
let ws = null;
|
||||
let wsListeners = [];
|
||||
|
||||
// --- Brand-logo packet-driven pulse (#1173) ---
|
||||
// Replaces the legacy live-dot indicator. Class-toggle only (CSS animations); colors come from
|
||||
// --logo-accent / --logo-accent-hi tokens. Test seam at window.__corescopeLogo.
|
||||
//
|
||||
// Cache the prefers-reduced-motion MediaQueryList ONCE at module load (#1177
|
||||
// Carmack must-fix #2). Calling window.matchMedia on every pulse() allocates
|
||||
// a new MQL + parses the query string — wasteful at 15Hz. The CSS @media rule
|
||||
// already handles render-time switching, so we just cache and read .matches.
|
||||
var _reducedMotionMQL = null;
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||||
_reducedMotionMQL = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
}
|
||||
} catch (_) { _reducedMotionMQL = null; }
|
||||
|
||||
const Logo = (function () {
|
||||
const RATE_GAP_MS = 66; // 15/sec (≤16 toggles per second).
|
||||
const HALF_MS = 80; // each half of a ping ≤80ms.
|
||||
const stats = { triggered: 0, dropped: 0 };
|
||||
let lastPingTs = 0;
|
||||
let flip = 0; // 0 → A→B, 1 → B→A.
|
||||
let lastDirection = null; // 'a' or 'b' (source circle).
|
||||
let connected = true; // WS state — gates in-flight chained pulses.
|
||||
let generation = 0; // bumped on setConnected(false) / visibilitychange to cancel scheduled halves.
|
||||
|
||||
function reducedMotion() {
|
||||
return _reducedMotionMQL ? !!_reducedMotionMQL.matches : false;
|
||||
}
|
||||
function $all(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); }
|
||||
function clearAll() {
|
||||
$all('.brand-logo circle.logo-node-a, .brand-mark-only circle.logo-node-a,' +
|
||||
'.brand-logo circle.logo-node-b, .brand-mark-only circle.logo-node-b').forEach((el) => {
|
||||
el.classList.remove('logo-pulse-active', 'logo-pulse-blip');
|
||||
});
|
||||
}
|
||||
function pulseChained(srcSel, dstSel) {
|
||||
const gen = generation;
|
||||
// Source half: ~80ms.
|
||||
$all(srcSel).forEach((el) => el.classList.add('logo-pulse-active'));
|
||||
setTimeout(() => {
|
||||
$all(srcSel).forEach((el) => el.classList.remove('logo-pulse-active'));
|
||||
// Destination half: scheduled via rAF then ~80ms.
|
||||
// Bail if WS dropped (or another disconnect cycle ran) since this ping started —
|
||||
// otherwise a zombie pulse fires on a logo that's already showing the
|
||||
// .logo-disconnected sustained state.
|
||||
if (gen !== generation || !connected) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (gen !== generation || !connected) return;
|
||||
$all(dstSel).forEach((el) => el.classList.add('logo-pulse-active'));
|
||||
setTimeout(() => {
|
||||
$all(dstSel).forEach((el) => el.classList.remove('logo-pulse-active'));
|
||||
}, HALF_MS);
|
||||
});
|
||||
}, HALF_MS);
|
||||
}
|
||||
function pulseBlip(dstSel) {
|
||||
// Reduced-motion: single-step opacity blip on destination only.
|
||||
$all(dstSel).forEach((el) => el.classList.add('logo-pulse-blip'));
|
||||
setTimeout(() => {
|
||||
$all(dstSel).forEach((el) => el.classList.remove('logo-pulse-blip'));
|
||||
}, 140);
|
||||
}
|
||||
function pulse(_msg) {
|
||||
// Hidden-tab gate (#1177 Carmack must-fix #1): drop the pulse BEFORE
|
||||
// mutating lastPingTs and BEFORE scheduling any rAF/setTimeout chain.
|
||||
// Background tabs throttle timers but still ran the source-class toggle
|
||||
// and queued a chain that fired in a clump on tab focus — wasted work
|
||||
// and a visible storm. Returning early here makes the gate cost ~1
|
||||
// property read per WS message.
|
||||
if (typeof document !== 'undefined' && document.hidden) {
|
||||
stats.dropped++;
|
||||
return false;
|
||||
}
|
||||
if (!connected) { stats.dropped++; return false; }
|
||||
const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
||||
if (now - lastPingTs < RATE_GAP_MS) { stats.dropped++; return false; }
|
||||
lastPingTs = now;
|
||||
stats.triggered++;
|
||||
const aToB = (flip === 0);
|
||||
flip ^= 1;
|
||||
lastDirection = aToB ? 'a' : 'b';
|
||||
const srcSel = aToB ? '.brand-logo circle.logo-node-a, .brand-mark-only circle.logo-node-a'
|
||||
: '.brand-logo circle.logo-node-b, .brand-mark-only circle.logo-node-b';
|
||||
const dstSel = aToB ? '.brand-logo circle.logo-node-b, .brand-mark-only circle.logo-node-b'
|
||||
: '.brand-logo circle.logo-node-a, .brand-mark-only circle.logo-node-a';
|
||||
if (reducedMotion()) {
|
||||
pulseBlip(dstSel);
|
||||
} else {
|
||||
pulseChained(srcSel, dstSel);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function setConnected(isConnected) {
|
||||
connected = !!isConnected;
|
||||
// Bump generation so any in-flight chained-pulse callbacks bail before
|
||||
// toggling classes on the destination circle (otherwise a zombie pulse
|
||||
// briefly fights the .logo-disconnected sustained desaturate state).
|
||||
generation++;
|
||||
$all('.brand-logo, .brand-mark-only').forEach((el) => {
|
||||
if (connected) el.classList.remove('logo-disconnected');
|
||||
else el.classList.add('logo-disconnected');
|
||||
});
|
||||
// #1174 mesh-op review: mirror connected state onto the bottom-nav so
|
||||
// the 2px top-border indicator (see bottom-nav.css) goes red on
|
||||
// disconnect. Mesh-alive is otherwise invisible at ≤768 because
|
||||
// .nav-stats is hidden at that breakpoint.
|
||||
var bn = document.querySelector('[data-bottom-nav]');
|
||||
if (bn) {
|
||||
if (connected) bn.classList.remove('disconnected');
|
||||
else bn.classList.add('disconnected');
|
||||
}
|
||||
if (!connected) clearAll();
|
||||
}
|
||||
// Expose hook for E2E + customizer/devtools introspection.
|
||||
// Frozen so consumers can't replace .pulse / .setConnected from outside
|
||||
// (the seam is read-only — invocation only).
|
||||
const api = Object.freeze({
|
||||
pulse: pulse,
|
||||
setConnected: setConnected,
|
||||
get lastDirection() { return lastDirection; },
|
||||
get stats() { return { triggered: stats.triggered, dropped: stats.dropped }; },
|
||||
});
|
||||
try { window.__corescopeLogo = api; } catch (_) {}
|
||||
|
||||
// Visibility gate (#1177 Carmack must-fix #1): when the tab becomes
|
||||
// hidden, bump generation so any in-flight chained pulse halves bail
|
||||
// out before they paint, and clear any active pulse classes. The
|
||||
// pulse() entry already early-returns on document.hidden — this handles
|
||||
// pulses already mid-flight at the moment the tab is backgrounded.
|
||||
try {
|
||||
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
generation++;
|
||||
clearAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return api;
|
||||
})();
|
||||
|
||||
function connectWS() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${proto}//${location.host}`);
|
||||
ws.onopen = () => document.getElementById('liveDot')?.classList.add('connected');
|
||||
ws.onopen = () => Logo.setConnected(true);
|
||||
ws.onclose = () => {
|
||||
document.getElementById('liveDot')?.classList.remove('connected');
|
||||
Logo.setConnected(false);
|
||||
setTimeout(connectWS, 3000);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onmessage = (e) => {
|
||||
Logo.pulse(e);
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
// Debounce cache invalidation — don't nuke on every packet
|
||||
@@ -501,6 +645,166 @@ 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 = 140;
|
||||
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;
|
||||
// Strict scrollTop === 0: ignore any negative overscroll, ignore any scrolled state
|
||||
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;
|
||||
// Cancel gesture if scrollTop leaves 0 (page scrolled mid-pull)
|
||||
if (getScrollTop() !== 0) { startY = null; pulling = false; dist = 0; return; }
|
||||
const t = e.touches && e.touches[0];
|
||||
if (!t) return;
|
||||
const dy = t.clientY - startY;
|
||||
if (dy <= 0) {
|
||||
// Upward swipe / retract. If we were past the commit threshold and the
|
||||
// user retracts back, cancel the gesture so a subsequent touchend does
|
||||
// NOT fire reconnect.
|
||||
if (pulling) {
|
||||
pulling = false;
|
||||
dist = 0;
|
||||
if (_pullIndicator) {
|
||||
_pullIndicator.style.opacity = '0';
|
||||
_pullIndicator.style.transform = 'translate(-50%, -100%)';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
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)';
|
||||
// Only block native pull-to-refresh once we've crossed the commit
|
||||
// threshold — below that, let the browser handle natural scroll/bounce.
|
||||
if (dy >= PULL_THRESHOLD_PX && typeof e.preventDefault === 'function' && e.cancelable !== false) {
|
||||
try { e.preventDefault(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
const wasPulling = pulling;
|
||||
const finalDist = dist;
|
||||
const stillAtTop = getScrollTop() === 0;
|
||||
startY = null; pulling = false; dist = 0;
|
||||
if (_pullIndicator) {
|
||||
_pullIndicator.style.opacity = '0';
|
||||
_pullIndicator.style.transform = 'translate(-50%, -100%)';
|
||||
}
|
||||
// Trigger only if: gesture was active, crossed threshold, and page is still at scrollTop=0.
|
||||
if (wasPulling && finalDist >= PULL_THRESHOLD_PX && stillAtTop) {
|
||||
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 '';
|
||||
@@ -579,6 +883,14 @@ function navigate() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Backward-compat redirect: #/roles → #/analytics?tab=roles (issue #1085).
|
||||
// The Roles page was folded into the Analytics tab strip; old links and
|
||||
// bookmarks must keep working.
|
||||
if (location.hash === '#/roles' || location.hash.startsWith('#/roles?') || location.hash.startsWith('#/roles/')) {
|
||||
location.hash = '#/analytics?tab=roles';
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = location.hash.replace('#/', '') || 'packets';
|
||||
const route = hash.split('?')[0];
|
||||
|
||||
@@ -676,6 +988,7 @@ window.addEventListener('timestamp-mode-changed', () => {
|
||||
});
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
connectWS();
|
||||
setupPullToReconnect();
|
||||
|
||||
// --- Dark Mode ---
|
||||
const darkToggle = document.getElementById('darkModeToggle');
|
||||
@@ -748,18 +1061,197 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
link.addEventListener('click', closeNav);
|
||||
});
|
||||
|
||||
// --- "More" dropdown (tablet Priority+ nav) ---
|
||||
// --- "More" dropdown — JS-driven Priority+ (Issue #1102) ---
|
||||
const navMoreBtn = document.getElementById('navMoreBtn');
|
||||
const navMoreMenu = document.getElementById('navMoreMenu');
|
||||
if (navMoreBtn && navMoreMenu) {
|
||||
// Build More menu dynamically from non-priority nav links (DRY)
|
||||
navMoreMenu.innerHTML = '';
|
||||
document.querySelectorAll('.nav-links a:not([data-priority="high"])').forEach(function(link) {
|
||||
var clone = link.cloneNode(true);
|
||||
clone.setAttribute('role', 'menuitem');
|
||||
clone.addEventListener('click', closeMoreMenu);
|
||||
navMoreMenu.appendChild(clone);
|
||||
const navMoreWrap = document.querySelector('.nav-more-wrap');
|
||||
const navTop = document.querySelector('.top-nav');
|
||||
const navLeft = document.querySelector('.nav-left');
|
||||
const navRightEl = document.querySelector('.nav-right');
|
||||
const linksContainer = document.querySelector('.nav-links');
|
||||
// Belt-and-braces null guards (#1105 MINOR 4): the outer block measures
|
||||
// and mutates all of these; if any are missing the layout math throws
|
||||
// before we can fall back gracefully.
|
||||
if (navMoreBtn && navMoreMenu && navMoreWrap && navLeft && navRightEl && linksContainer && navTop) {
|
||||
// Measure available room and decide which links overflow.
|
||||
// Algorithm: try to fit all links inline. If the link strip doesn't
|
||||
// fit alongside .nav-right + .nav-brand, hide non-priority links one
|
||||
// at a time (right-to-left, lowest priority first) until it does.
|
||||
// Then mirror the hidden links into the "More ▾" menu so nothing
|
||||
// disappears from the user's reach.
|
||||
const allLinks = Array.from(linksContainer.querySelectorAll('.nav-link'));
|
||||
// overflowQueue (#1105 MINOR 6): the order links are removed from the
|
||||
// inline strip when space runs out. Built right-to-left from
|
||||
// non-priority links (lowest priority dropped first) and then high-
|
||||
// priority links as a last-resort tail. `data-priority="high"` is the
|
||||
// only signal — if you ever need finer ordering, switch to a numeric
|
||||
// attribute (e.g. data-overflow-order="3") rather than re-shuffling
|
||||
// index in HTML.
|
||||
const overflowQueue = allLinks.filter(a => a.dataset.priority !== 'high')
|
||||
.reverse() // right-to-left
|
||||
.concat(allLinks.filter(a => a.dataset.priority === 'high').reverse());
|
||||
|
||||
function rebuildMoreMenu() {
|
||||
navMoreMenu.innerHTML = '';
|
||||
const hidden = allLinks.filter(a => a.classList.contains('is-overflow'));
|
||||
hidden.forEach(function(link) {
|
||||
var clone = link.cloneNode(true);
|
||||
// The clone is in the overflow menu, not the inline strip.
|
||||
clone.classList.remove('is-overflow');
|
||||
clone.setAttribute('role', 'menuitem');
|
||||
// cloneNode(true) preserves DOM but NOT event listeners. The
|
||||
// originals get `closeNav` attached up above (#1105 MINOR 5);
|
||||
// mirror that here so a click on the More-menu clone behaves
|
||||
// identically to a click on the inline link (closes the
|
||||
// hamburger panel + dismisses the More menu).
|
||||
clone.addEventListener('click', closeNav);
|
||||
clone.addEventListener('click', closeMoreMenu);
|
||||
navMoreMenu.appendChild(clone);
|
||||
});
|
||||
// If nothing overflows, hide the More button entirely so wide
|
||||
// viewports don't show a useless dropdown trigger.
|
||||
navMoreWrap.classList.toggle('is-hidden', hidden.length === 0);
|
||||
// Refresh active state on the More button (a hidden active link
|
||||
// means the More menu currently "is" the active section).
|
||||
var hasActiveMore = navMoreMenu.querySelector('.nav-link.active');
|
||||
navMoreBtn.classList.toggle('active', !!hasActiveMore);
|
||||
}
|
||||
|
||||
// #1105 MINOR 1: cached intrinsic width of the More button. Captured
|
||||
// the first time `fits()` sees navMoreWrap rendered (display:flex).
|
||||
// Falls back to MORE_BTN_RESERVE_PX (a conservative initial guess
|
||||
// sized for "More ▾" at default font/padding) until that happens.
|
||||
var cachedMoreW = 0;
|
||||
var MORE_BTN_RESERVE_PX = 70;
|
||||
|
||||
function applyNavPriority() {
|
||||
// Skip on mobile (<768px) — hamburger CSS owns that layout.
|
||||
if (window.innerWidth < 768) {
|
||||
allLinks.forEach(a => a.classList.remove('is-overflow'));
|
||||
navMoreWrap.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
// Reset: show everything, then hide as needed.
|
||||
allLinks.forEach(a => a.classList.remove('is-overflow'));
|
||||
navMoreWrap.classList.remove('is-hidden');
|
||||
// #1106: in the 768-1100px narrow-desktop band the CSS already
|
||||
// hides .nav-stats and tightens .nav-link padding (see the
|
||||
// "Nav narrow-desktop tightening" media query in style.css).
|
||||
// The design intent of that band is "show exactly the 5 high-
|
||||
// priority links + More". Pure measurement says everything fits
|
||||
// (~981px needed in a 1080px viewport once nav-stats is gone),
|
||||
// but the design contract — locked by test-nav-priority-1102-
|
||||
// e2e.js #1105 MINOR 7 — is exact identity, not "fits". Force-
|
||||
// collapse all non-high-priority links inside this band so the
|
||||
// overflow menu is non-empty and the high-priority set is the
|
||||
// only thing inline. Above 1100px the measurement loop below
|
||||
// owns the decision (and at 2560px nothing overflows).
|
||||
if (window.innerWidth <= 1100) {
|
||||
allLinks.forEach(a => {
|
||||
if (a.dataset.priority !== 'high') a.classList.add('is-overflow');
|
||||
});
|
||||
rebuildMoreMenu();
|
||||
return;
|
||||
}
|
||||
// Iteratively hide low-priority links until the link strip fits.
|
||||
// .top-nav has overflow:hidden and .nav-left has flex-shrink:1, so
|
||||
// an overflowing strip silently clips rather than pushing
|
||||
// nav-right out — bounding-rect math on .nav-left lies. Instead
|
||||
// measure the *intrinsic* widths of the parts (independent of
|
||||
// current clipping) and compare to the viewport. SAFETY absorbs
|
||||
// the .top-nav side padding + nav-right inner gaps + sub-pixel
|
||||
// rounding (the historic #1055 bug was a 6–20px overlap).
|
||||
//
|
||||
// #1105 MINOR 3: at the 1101px media-query flip `.nav-stats`
|
||||
// toggles from display:none → flex (and vice-versa). The resize
|
||||
// handler is rAF-debounced and runs *after* the layout flip, so
|
||||
// navRightEl.scrollWidth measured here reflects the post-flip
|
||||
// intrinsic width — not stale pre-flip width.
|
||||
const navBrand = document.querySelector('.nav-brand');
|
||||
const SAFETY = 32;
|
||||
// #1105 MINOR 1+2: read both gap values from CSS rather than a
|
||||
// shared `GUTTER = 24` constant. Today `.nav-left` (gap between
|
||||
// brand/links/more/right cells) and `.nav-links` (gap between
|
||||
// individual link items) both resolve to --space-lg = 24px, but
|
||||
// they're conceptually distinct gaps. If --space-lg or .nav-left's
|
||||
// gap diverges in the future, the fit math must follow.
|
||||
const navLeftGap = parseFloat(getComputedStyle(navLeft).columnGap ||
|
||||
getComputedStyle(navLeft).gap || '0') || 0;
|
||||
// #1105 MINOR 1: compute the More-button reserve from its actual
|
||||
// rendered width on first measure, instead of a hard-coded 70px
|
||||
// fallback. Cached so we don't re-measure (offsetWidth is 0 when
|
||||
// display:none; we capture the value the first time it's visible).
|
||||
function fits() {
|
||||
const visibleLinks = allLinks.filter(a => !a.classList.contains('is-overflow'));
|
||||
let linkW = 0;
|
||||
visibleLinks.forEach(a => { linkW += a.getBoundingClientRect().width; });
|
||||
const linkGapPx = parseFloat(getComputedStyle(linksContainer).columnGap ||
|
||||
getComputedStyle(linksContainer).gap || '0') || 0;
|
||||
const linksGap = Math.max(0, visibleLinks.length - 1) * linkGapPx;
|
||||
const brandW = navBrand ? navBrand.getBoundingClientRect().width : 0;
|
||||
// Always reserve space for the More button if anything could
|
||||
// overflow. Measure the live width when visible and cache it
|
||||
// for use when the button is currently hidden (display:none →
|
||||
// getBoundingClientRect() returns 0). MORE_BTN_RESERVE_PX is
|
||||
// the conservative initial fallback used until we get a real
|
||||
// measurement.
|
||||
const moreVis = !navMoreWrap.classList.contains('is-hidden');
|
||||
const liveMoreW = moreVis ? navMoreWrap.getBoundingClientRect().width : 0;
|
||||
if (liveMoreW > 0) cachedMoreW = liveMoreW;
|
||||
const moreW = liveMoreW > 0 ? liveMoreW
|
||||
: (cachedMoreW > 0 ? cachedMoreW : MORE_BTN_RESERVE_PX);
|
||||
const rightW = navRightEl.scrollWidth; // intrinsic, ignores clipping
|
||||
const needed = brandW + navLeftGap + linkW + linksGap + navLeftGap + moreW + navLeftGap + rightW + SAFETY;
|
||||
return needed <= window.innerWidth;
|
||||
}
|
||||
let i = 0;
|
||||
while (!fits() && i < overflowQueue.length) {
|
||||
overflowQueue[i].classList.add('is-overflow');
|
||||
i++;
|
||||
}
|
||||
// #1139 Bug B: floor the More menu at >=2 items. The greedy
|
||||
// fits() loop above is happy to stop after pushing exactly ONE
|
||||
// link into overflow (commonly "🎵 Lab" at ~1600px viewports),
|
||||
// producing a degenerate single-item dropdown. If exactly one
|
||||
// link overflowed, promote one more from the queue so the user
|
||||
// sees a useful menu instead of a one-item fragment. Skip when
|
||||
// nothing overflowed (everything fits inline → More is hidden,
|
||||
// which is the correct UX) and skip when the queue is exhausted.
|
||||
var overflowedCount = allLinks.filter(a => a.classList.contains('is-overflow')).length;
|
||||
if (overflowedCount === 1) {
|
||||
if (i < overflowQueue.length) {
|
||||
overflowQueue[i].classList.add('is-overflow');
|
||||
i++;
|
||||
} else {
|
||||
// Defensive: queue exhausted with exactly 1 overflowed link
|
||||
// means we cannot satisfy the >=2 floor (only one promotable
|
||||
// link existed). Surface it loudly instead of silently
|
||||
// shipping the degenerate single-item dropdown the floor
|
||||
// was added to prevent.
|
||||
console.warn('[nav] More menu floor: overflowQueue exhausted with 1 item; cannot enforce >=2 floor');
|
||||
}
|
||||
}
|
||||
rebuildMoreMenu();
|
||||
}
|
||||
|
||||
// Run once on load, again after fonts settle (label widths shift),
|
||||
// and on resize (debounced via rAF).
|
||||
applyNavPriority();
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(applyNavPriority);
|
||||
}
|
||||
let rafId = 0;
|
||||
window.addEventListener('resize', function() {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(applyNavPriority);
|
||||
});
|
||||
// Re-apply on route change too: the active link gets bigger padding
|
||||
// (background pill), so which links fit can shift between pages.
|
||||
window.addEventListener('hashchange', function() {
|
||||
// Defer so the route handler's class toggles run first.
|
||||
requestAnimationFrame(applyNavPriority);
|
||||
});
|
||||
|
||||
navMoreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const opening = !navMoreMenu.classList.contains('open');
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/* Issue #1061 — Bottom navigation styles.
|
||||
*
|
||||
* Activates at viewports ≤768px. Uses position:fixed so it does not
|
||||
* trigger layout reflow on the rest of the page, plus
|
||||
* env(safe-area-inset-bottom) padding so the iOS home-indicator does
|
||||
* not overlap the tabs. The matching <meta viewport-fit=cover> already
|
||||
* exists in index.html (verified pre-implementation).
|
||||
*
|
||||
* Tokens reused (defined in BOTH :root and dark @media in style.css):
|
||||
* --nav-bg, --nav-text, --nav-text-muted, --nav-active-bg, --accent,
|
||||
* --border, --space-sm.
|
||||
*
|
||||
* Decision: media query (not container query). The rest of the codebase
|
||||
* uses @media exclusively (no @container rules in style.css today), so
|
||||
* a media query keeps things consistent.
|
||||
*
|
||||
* Decision: top-nav suppression = display:none at ≤768px. Spec
|
||||
* forbids duplicate nav UX; the bottom nav covers the 5 high-priority
|
||||
* routes; long-tail routes (Tools/Lab/Perf/Analytics/etc.) remain
|
||||
* reachable by URL. A "More" tab or hamburger fallback is deferred per
|
||||
* the issue body's explicit guidance.
|
||||
*/
|
||||
|
||||
/* #1174 mesh-op review: --bottom-nav-reserve is the contract page-level
|
||||
* full-viewport rules use to subtract the bottom-nav's height from
|
||||
* 100dvh. 0px at desktop (no nav reserved); 56px + safe-area at ≤768px.
|
||||
* Pages opt-in by referencing it (see public/live.css for /live, and
|
||||
* #app.app-fixed in style.css for the SPA fixed-page container). */
|
||||
:root {
|
||||
--bottom-nav-reserve: 0px;
|
||||
}
|
||||
|
||||
/* Default: hidden on wide viewports. The bottom-nav element exists in
|
||||
* the DOM at all widths (build runs at DOMContentLoaded) but is only
|
||||
* rendered to the user at ≤768px. */
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* #1174 mesh-op review: set the reserve token at the breakpoint so
|
||||
* page-level full-viewport rules (e.g. .live-page, #app.app-fixed)
|
||||
* automatically subtract the bottom-nav height. */
|
||||
:root {
|
||||
--bottom-nav-reserve: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1200; /* above nav-links dropdown (1100) */
|
||||
background: var(--nav-bg);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25);
|
||||
/* env() falls back to 0 outside iOS notch devices. We also keep
|
||||
* a small minimum so the rule resolves to a non-empty value. */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-top: 0;
|
||||
/* Distribute 5 tabs evenly. */
|
||||
justify-content: space-around;
|
||||
align-items: stretch;
|
||||
/* No transform — would create a stacking context that traps any
|
||||
* fixed-position descendants (we have none, but cheap insurance). */
|
||||
}
|
||||
|
||||
/* Suppress the inline link bar and right-side cluster — but KEEP
|
||||
* .nav-brand (logo identity). #1174: also hide #hamburger at narrow
|
||||
* widths — the new "More" tab in the bottom-nav now surfaces the
|
||||
* long-tail routes, so the hamburger is redundant on phones. */
|
||||
.top-nav .nav-links,
|
||||
.top-nav .nav-more-wrap,
|
||||
.top-nav .nav-right,
|
||||
.top-nav .nav-stats {
|
||||
display: none !important;
|
||||
}
|
||||
/* #1174: hamburger hidden at ≤768px (replaced by the More tab). */
|
||||
#hamburger {
|
||||
display: none !important;
|
||||
}
|
||||
/* Brand on the left, hamburger on the right at narrow widths. */
|
||||
.top-nav {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Reserve space at page bottom so fixed-positioned bottom-nav does
|
||||
* not cover the last row of content. 56px tab + 8px breathing room
|
||||
* + safe-area inset. */
|
||||
body {
|
||||
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab — anchor element. Each tab is a column with icon over label, sized
|
||||
* to ≥48px tall (the Apple/Google touch-target floor confirmed by
|
||||
* issue #1060). */
|
||||
.bottom-nav-tab {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
/* 56px is a comfortable Material/iOS bottom-bar height; it is also
|
||||
* ≥48px (a11y floor) by 8px so labels render without clipping. */
|
||||
min-height: 56px;
|
||||
padding: 6px 4px;
|
||||
color: var(--nav-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
line-height: 1.1;
|
||||
border-top: 2px solid transparent;
|
||||
/* Reset <button> defaults — the More tab is a <button>; its native
|
||||
* background/border/font would otherwise clash with the <a> tabs. */
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
/* Touch-action: manipulation prevents the iOS double-tap zoom delay
|
||||
* on tabs. */
|
||||
touch-action: manipulation;
|
||||
transition: color 120ms ease, background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.bottom-nav-tab:hover,
|
||||
.bottom-nav-tab:focus-visible {
|
||||
color: var(--nav-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bottom-nav-tab:focus-visible {
|
||||
/* Keyboard a11y — visible focus ring inside the bar. */
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.bottom-nav-tab.active {
|
||||
color: var(--nav-text);
|
||||
background: var(--nav-active-bg);
|
||||
border-top-color: var(--accent);
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bottom-nav-label {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Respect reduced-motion preferences — disable the color/border
|
||||
* transition. Existing app already has a reduced-motion block in
|
||||
* style.css; this is the bottom-nav-specific override. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bottom-nav-tab {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── #1174: More sheet ───
|
||||
* Bottom-anchored popover that surfaces the long-tail routes (Nodes,
|
||||
* Tools, Observers, Analytics, Perf, Audio Lab). Anchored ABOVE the
|
||||
* bottom-nav (bottom: 56px + safe-area), z-index between the nav and
|
||||
* any modal layer.
|
||||
*/
|
||||
.bottom-nav-sheet {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bottom-nav-sheet {
|
||||
/* The element uses the `hidden` attribute to be CSS-display none by
|
||||
* default; when we drop `hidden`, we want it to render as a grid. */
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
/* Sit above the 56px tabs + breathing room + safe-area inset. */
|
||||
bottom: calc(56px + env(safe-area-inset-bottom, 0px) + 8px);
|
||||
z-index: 1250; /* above bottom-nav (1200), below modals if any */
|
||||
background: var(--nav-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
padding: 8px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
/* Display only when not [hidden]. */
|
||||
}
|
||||
.bottom-nav-sheet[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.bottom-nav-sheet:not([hidden]) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav-sheet-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
color: var(--nav-text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
touch-action: manipulation;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.bottom-nav-sheet-item:hover,
|
||||
.bottom-nav-sheet-item:focus-visible {
|
||||
background: var(--nav-active-bg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bottom-nav-sheet-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.bottom-nav-sheet-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bottom-nav-sheet-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bottom-nav-sheet-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── #1174 mesh-op review: bottom-nav mesh-alive indicator ───
|
||||
* .nav-stats (top-nav mesh-alive pulse) is hidden at ≤768. Add a thin
|
||||
* 2px top border to the bottom-nav that mirrors the brand-logo's
|
||||
* connected/disconnected state via a class toggled from app.js
|
||||
* (window.__corescopeLogo.setConnected). Cheap, peripheral-vision
|
||||
* visible, no per-tab clutter.
|
||||
*
|
||||
* Default (connected): accent-tinted border. Disconnected: red.
|
||||
* The base bottom-nav rule already declares border-top: 1px solid
|
||||
* var(--border) — we override its color with a slightly heavier
|
||||
* 2px stripe so the connectivity color is the dominant visual.
|
||||
*/
|
||||
@media (max-width: 768px) {
|
||||
.bottom-nav {
|
||||
border-top: 2px solid var(--accent);
|
||||
transition: border-top-color 200ms ease;
|
||||
}
|
||||
.bottom-nav.disconnected {
|
||||
border-top-color: var(--danger, #ef4444);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bottom-nav {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/* Issue #1061 — Bottom navigation for narrow viewports.
|
||||
* Issue #1174 — Add 6th "More" tab + bottom-anchored sheet for long-tail routes.
|
||||
*
|
||||
* Renders 6 tabs anchored to the bottom on viewports ≤768px:
|
||||
* 1. Home — primary
|
||||
* 2. Packets — primary
|
||||
* 3. Live — primary
|
||||
* 4. Map — primary
|
||||
* 5. Channels — primary
|
||||
* 6. More — toggles a bottom-anchored sheet listing the long-tail
|
||||
* routes (Nodes, Tools, Observers, Analytics, Perf, Audio Lab).
|
||||
* Replaces the hamburger at ≤768px (#1174 design call).
|
||||
*
|
||||
* Tabs are <a href="#/..."> so they reuse the existing hashchange-driven
|
||||
* router in app.js (no full reload, no reimplementation of routing logic).
|
||||
* The "More" tab is a <button> (not <a>) since it toggles UI rather than
|
||||
* navigating to a hash.
|
||||
*
|
||||
* Stable selectors for tests / future automation:
|
||||
* [data-bottom-nav] — the <nav> container
|
||||
* [data-bottom-nav-tab="<route>"] — each tab including "more"
|
||||
* [data-bottom-nav-sheet] — the popover sheet
|
||||
* [data-bottom-nav-more-route="<route>"] — each long-tail route in the sheet
|
||||
*
|
||||
* Active-tab highlight is a class toggle ("active") set on hashchange.
|
||||
* Visual treatment lives in bottom-nav.css and respects
|
||||
* prefers-reduced-motion (transitions disabled).
|
||||
*
|
||||
* Sheet behavior:
|
||||
* - tap More → sheet opens, aria-expanded="true"
|
||||
* - tap More while open → sheet closes (toggle, not push)
|
||||
* - tap any route inside → in-app router navigates AND sheet closes
|
||||
* - tap outside (anywhere not the sheet or the More tab) → sheet closes
|
||||
* - sheet has role="menu" for a11y
|
||||
*
|
||||
* The sheet DOM is built lazily on first open — it's only used at ≤768px
|
||||
* and there's no point sitting in the DOM at desktop widths.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// 5 primary tabs + the More toggle. Each entry: { route, hash, label, icon }.
|
||||
// For More, hash is null (not a route).
|
||||
var TABS = [
|
||||
{ route: 'home', hash: '#/home', label: 'Home', icon: '🏠' },
|
||||
{ route: 'packets', hash: '#/packets', label: 'Packets', icon: '📦' },
|
||||
{ route: 'live', hash: '#/live', label: 'Live', icon: '🔴' },
|
||||
{ route: 'map', hash: '#/map', label: 'Map', icon: '🗺️' },
|
||||
{ route: 'channels', hash: '#/channels', label: 'Channels', icon: '💬' },
|
||||
{ route: 'more', hash: null, label: 'More', icon: '☰' },
|
||||
];
|
||||
|
||||
// Long-tail routes surfaced in the More sheet. Mirrors data-route values
|
||||
// from the existing top-nav (public/index.html). Order matches what
|
||||
// operators expect from the desktop top-nav.
|
||||
//
|
||||
// ⚠️ MANUAL SYNC REQUIRED ⚠️
|
||||
// This list is intentionally hardcoded (not generated from
|
||||
// `.top-nav .nav-link[data-route]`) because the top-nav HTML is in
|
||||
// mid-rewrite and not a reliable single-source-of-truth. If you add a
|
||||
// new top-nav route (e.g. a future "Lab" page), you MUST also append
|
||||
// it here, or it will be unreachable on phones at ≤768px (the
|
||||
// hamburger is hidden at that breakpoint — see bottom-nav.css).
|
||||
var MORE_ROUTES = [
|
||||
{ route: 'nodes', hash: '#/nodes', label: 'Nodes', icon: '🖥️' },
|
||||
{ route: 'tools', hash: '#/tools', label: 'Tools', icon: '🛠️' },
|
||||
{ route: 'observers', hash: '#/observers', label: 'Observers', icon: '👁️' },
|
||||
{ route: 'analytics', hash: '#/analytics', label: 'Analytics', icon: '📊' },
|
||||
{ route: 'perf', hash: '#/perf', label: 'Perf', icon: '⚡' },
|
||||
{ route: 'audio-lab', hash: '#/audio-lab', label: 'Audio Lab', icon: '🎵' },
|
||||
];
|
||||
|
||||
var SHEET_ID = 'bottomNavMoreSheet';
|
||||
|
||||
function currentRoute() {
|
||||
// Mirror app.js navigate(): strip "#/" and any trailing "?…" / "/…".
|
||||
var h = (location.hash || '').replace(/^#\//, '');
|
||||
if (!h) return 'packets'; // app.js default
|
||||
var slash = h.indexOf('/');
|
||||
if (slash >= 0) h = h.substring(0, slash);
|
||||
var q = h.indexOf('?');
|
||||
if (q >= 0) h = h.substring(0, q);
|
||||
return h || 'packets';
|
||||
}
|
||||
|
||||
function build() {
|
||||
if (document.querySelector('[data-bottom-nav]')) return;
|
||||
|
||||
var nav = document.createElement('nav');
|
||||
nav.className = 'bottom-nav';
|
||||
nav.setAttribute('data-bottom-nav', '');
|
||||
nav.setAttribute('role', 'navigation');
|
||||
nav.setAttribute('aria-label', 'Bottom navigation');
|
||||
|
||||
TABS.forEach(function (t) {
|
||||
var el;
|
||||
if (t.route === 'more') {
|
||||
// <button> for the toggle: it does not navigate.
|
||||
el = document.createElement('button');
|
||||
el.setAttribute('type', 'button');
|
||||
el.setAttribute('aria-haspopup', 'menu');
|
||||
el.setAttribute('aria-expanded', 'false');
|
||||
el.setAttribute('aria-controls', SHEET_ID);
|
||||
} else {
|
||||
el = document.createElement('a');
|
||||
el.setAttribute('href', t.hash);
|
||||
}
|
||||
el.className = 'bottom-nav-tab';
|
||||
el.setAttribute('data-bottom-nav-tab', t.route);
|
||||
el.setAttribute('data-route', t.route);
|
||||
el.setAttribute('aria-label', t.label);
|
||||
|
||||
var ic = document.createElement('span');
|
||||
ic.className = 'bottom-nav-icon';
|
||||
ic.setAttribute('aria-hidden', 'true');
|
||||
ic.textContent = t.icon;
|
||||
|
||||
var lb = document.createElement('span');
|
||||
lb.className = 'bottom-nav-label';
|
||||
lb.textContent = t.label;
|
||||
|
||||
el.appendChild(ic);
|
||||
el.appendChild(lb);
|
||||
nav.appendChild(el);
|
||||
});
|
||||
|
||||
// Insert after <main> so it's a sibling at the body level — keeps
|
||||
// it out of the <main> scroll container. The CSS pins it bottom:0
|
||||
// via position:fixed so DOM order beyond "after the nav" doesn't
|
||||
// matter for layout, but document order matters for screen readers.
|
||||
var main = document.getElementById('app') || document.querySelector('main');
|
||||
if (main && main.parentNode) {
|
||||
main.parentNode.insertBefore(nav, main.nextSibling);
|
||||
} else {
|
||||
document.body.appendChild(nav);
|
||||
}
|
||||
|
||||
wireMoreSheet();
|
||||
}
|
||||
|
||||
function syncActive() {
|
||||
var route = currentRoute();
|
||||
// #1174 mesh-op review: the More tab represents the long-tail
|
||||
// routes; reflect that in the active-class so users on /tools,
|
||||
// /analytics, etc. still see WHICH tab they're under. Without this
|
||||
// every long-tail route lit up zero tabs.
|
||||
var moreRouteSet = {};
|
||||
for (var k = 0; k < MORE_ROUTES.length; k++) moreRouteSet[MORE_ROUTES[k].route] = 1;
|
||||
var routeIsLongTail = !!moreRouteSet[route];
|
||||
var tabs = document.querySelectorAll('[data-bottom-nav-tab]');
|
||||
for (var i = 0; i < tabs.length; i++) {
|
||||
var t = tabs[i];
|
||||
var tabRoute = t.getAttribute('data-bottom-nav-tab');
|
||||
if (tabRoute === 'more') {
|
||||
// The More tab IS active when the current route belongs to the
|
||||
// long-tail set surfaced by the More sheet. We do NOT add
|
||||
// aria-current here — the tab toggles a sheet, not a single
|
||||
// page, so aria-current="page" would lie. The visual active
|
||||
// class is the user-facing affordance; that's enough.
|
||||
if (routeIsLongTail) t.classList.add('active');
|
||||
else if (!isSheetOpen()) t.classList.remove('active');
|
||||
// If the sheet is open we leave .active alone — openSheet()
|
||||
// owns the class while open.
|
||||
continue;
|
||||
}
|
||||
if (tabRoute === route) {
|
||||
t.classList.add('active');
|
||||
t.setAttribute('aria-current', 'page');
|
||||
} else {
|
||||
t.classList.remove('active');
|
||||
t.removeAttribute('aria-current');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── More sheet ──
|
||||
// Built lazily on first open; lives as a sibling of the <nav> so the
|
||||
// bottom-nav's z-index/stacking is independent of the sheet. The sheet
|
||||
// is anchored above the bottom-nav via CSS (bottom: <nav-height>).
|
||||
function getOrBuildSheet() {
|
||||
var existing = document.getElementById(SHEET_ID);
|
||||
if (existing) return existing;
|
||||
|
||||
var sheet = document.createElement('div');
|
||||
sheet.id = SHEET_ID;
|
||||
sheet.className = 'bottom-nav-sheet';
|
||||
sheet.setAttribute('data-bottom-nav-sheet', '');
|
||||
sheet.setAttribute('role', 'menu');
|
||||
sheet.setAttribute('aria-label', 'More navigation');
|
||||
sheet.hidden = true;
|
||||
|
||||
MORE_ROUTES.forEach(function (r) {
|
||||
var a = document.createElement('a');
|
||||
a.className = 'bottom-nav-sheet-item';
|
||||
a.setAttribute('href', r.hash);
|
||||
a.setAttribute('role', 'menuitem');
|
||||
a.setAttribute('data-bottom-nav-more-route', r.route);
|
||||
a.setAttribute('data-route', r.route);
|
||||
|
||||
var ic = document.createElement('span');
|
||||
ic.className = 'bottom-nav-sheet-icon';
|
||||
ic.setAttribute('aria-hidden', 'true');
|
||||
ic.textContent = r.icon;
|
||||
|
||||
var lb = document.createElement('span');
|
||||
lb.className = 'bottom-nav-sheet-label';
|
||||
lb.textContent = r.label;
|
||||
|
||||
a.appendChild(ic);
|
||||
a.appendChild(lb);
|
||||
|
||||
// Tap a route → close sheet (the <a href> handles navigation via
|
||||
// the existing hashchange router in app.js).
|
||||
a.addEventListener('click', function () { closeSheet(); });
|
||||
|
||||
sheet.appendChild(a);
|
||||
});
|
||||
|
||||
// Sit the sheet next to the nav so they share a stacking context.
|
||||
var nav = document.querySelector('[data-bottom-nav]');
|
||||
if (nav && nav.parentNode) {
|
||||
nav.parentNode.insertBefore(sheet, nav);
|
||||
} else {
|
||||
document.body.appendChild(sheet);
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function isSheetOpen() {
|
||||
var sheet = document.getElementById(SHEET_ID);
|
||||
return !!(sheet && !sheet.hidden);
|
||||
}
|
||||
|
||||
function openSheet() {
|
||||
var sheet = getOrBuildSheet();
|
||||
sheet.hidden = false;
|
||||
sheet.classList.add('open');
|
||||
var moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
|
||||
if (moreTab) {
|
||||
moreTab.setAttribute('aria-expanded', 'true');
|
||||
moreTab.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function closeSheet() {
|
||||
var sheet = document.getElementById(SHEET_ID);
|
||||
if (sheet) {
|
||||
sheet.hidden = true;
|
||||
sheet.classList.remove('open');
|
||||
}
|
||||
var moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
|
||||
if (moreTab) {
|
||||
moreTab.setAttribute('aria-expanded', 'false');
|
||||
moreTab.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSheet() {
|
||||
if (isSheetOpen()) closeSheet();
|
||||
else openSheet();
|
||||
}
|
||||
|
||||
function wireMoreSheet() {
|
||||
var moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
|
||||
if (!moreTab) return;
|
||||
// Toggle on tap. Use click — covers mouse and synthesized tap.
|
||||
moreTab.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
toggleSheet();
|
||||
});
|
||||
|
||||
// Outside-click closes the sheet. Listen at document level; ignore
|
||||
// clicks on the sheet itself or on the More tab (handled above).
|
||||
document.addEventListener('click', function (ev) {
|
||||
if (!isSheetOpen()) return;
|
||||
var t = ev.target;
|
||||
var sheet = document.getElementById(SHEET_ID);
|
||||
if (sheet && sheet.contains(t)) return;
|
||||
if (moreTab.contains(t)) return;
|
||||
closeSheet();
|
||||
});
|
||||
|
||||
// Tapping any OTHER bottom-nav tab also closes the sheet.
|
||||
var otherTabs = document.querySelectorAll('[data-bottom-nav-tab]');
|
||||
for (var i = 0; i < otherTabs.length; i++) {
|
||||
var t = otherTabs[i];
|
||||
if (t.getAttribute('data-bottom-nav-tab') === 'more') continue;
|
||||
t.addEventListener('click', function () { closeSheet(); });
|
||||
}
|
||||
|
||||
// Esc closes the sheet (a11y).
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Escape' && isSheetOpen()) closeSheet();
|
||||
});
|
||||
|
||||
// Hashchange (any nav) also closes — covers programmatic navigation.
|
||||
window.addEventListener('hashchange', function () { closeSheet(); });
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Singleton guard: init() may be invoked twice if (a) DOMContentLoaded
|
||||
// fires AND (b) something else re-imports the script later, or if a
|
||||
// future SPA-like re-mount path is added. The internal `build()` is
|
||||
// idempotent (early-returns on existing [data-bottom-nav]), but the
|
||||
// `hashchange` listener and the document-level outside-click /
|
||||
// keydown listeners in wireMoreSheet() would otherwise stack, leaking
|
||||
// handlers exactly like PR #1180's MQL-leak class. Bail on second call.
|
||||
if (window.__bottomNavInitDone) return;
|
||||
window.__bottomNavInitDone = true;
|
||||
build();
|
||||
syncActive();
|
||||
window.addEventListener('hashchange', syncActive);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -24,6 +24,10 @@
|
||||
|
||||
var popoverEl = null;
|
||||
var currentChannel = null;
|
||||
// #1168 Munger #3: use shared ref-counted scroll-lock helper instead of
|
||||
// overwriting body.style.overflow directly. Without this, two cooperating
|
||||
// surfaces (this picker + SlideOver) corrupt overflow last-writer-wins.
|
||||
var scrollLockToken = null;
|
||||
|
||||
function createPopover() {
|
||||
if (popoverEl) return popoverEl;
|
||||
@@ -126,8 +130,16 @@
|
||||
el.style.top = finalY + 'px';
|
||||
}
|
||||
|
||||
// Lock background scroll while popover is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Lock background scroll while popover is open (#1168 Munger #3:
|
||||
// ref-counted via window.__scrollLock so concurrent modal surfaces
|
||||
// don't corrupt overflow under last-writer-wins).
|
||||
if (window.__scrollLock && scrollLockToken == null) {
|
||||
scrollLockToken = window.__scrollLock.acquire();
|
||||
} else if (!window.__scrollLock) {
|
||||
// Fallback (shouldn't happen — packets.js installs the helper at
|
||||
// load time and is loaded before this picker).
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// Focus first swatch for keyboard accessibility
|
||||
var firstSwatch = el.querySelector('.cc-swatch');
|
||||
@@ -143,7 +155,12 @@
|
||||
function hidePopover() {
|
||||
if (popoverEl) popoverEl.style.display = 'none';
|
||||
currentChannel = null;
|
||||
document.body.style.overflow = '';
|
||||
if (window.__scrollLock && scrollLockToken != null) {
|
||||
window.__scrollLock.release(scrollLockToken);
|
||||
scrollLockToken = null;
|
||||
} else if (!window.__scrollLock) {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
document.removeEventListener('click', onOutsideClick, true);
|
||||
document.removeEventListener('keydown', onEscape, true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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`.
|
||||
*
|
||||
* Uses the vendored Kazuhiko Arase qrcode-generator library (lowercase
|
||||
* `qrcode` global) — `public/vendor/qrcode.js`. This was previously
|
||||
* checking for `root.QRCode` (capital), which never existed and made
|
||||
* every Generate click fall through to "[QR library not loaded]".
|
||||
* (Issue #1087 bug 1.)
|
||||
*/
|
||||
function generate(name, secretHex, target, opts) {
|
||||
if (!_hasDom() || !target) return;
|
||||
target.innerHTML = '';
|
||||
opts = opts || {};
|
||||
var qrOnly = !!opts.qrOnly;
|
||||
|
||||
const url = buildUrl(name, secretHex);
|
||||
|
||||
const qrBox = document.createElement('div');
|
||||
qrBox.className = 'channel-qr-canvas';
|
||||
qrBox.style.display = 'inline-block';
|
||||
target.appendChild(qrBox);
|
||||
|
||||
var qrFactory = (typeof root.qrcode === 'function') ? root.qrcode :
|
||||
(typeof root.QRCode === 'function') ? root.QRCode : null;
|
||||
|
||||
if (qrFactory) {
|
||||
try {
|
||||
// Kazuhiko Arase API: qrcode(typeNumber, errorCorrectionLevel)
|
||||
// typeNumber=0 → auto-detect smallest version that fits.
|
||||
var qr = qrFactory(0, 'M');
|
||||
qr.addData(url);
|
||||
qr.make();
|
||||
// createImgTag(cellSize, margin) → an <img src="data:image/gif;base64,...">.
|
||||
// Cell size 4 with margin 4 yields a ~192px image for short URLs.
|
||||
qrBox.innerHTML = qr.createImgTag(4, 4);
|
||||
var img = qrBox.querySelector('img');
|
||||
if (img) {
|
||||
img.alt = 'QR for ' + name;
|
||||
img.style.display = 'block';
|
||||
img.style.maxWidth = '192px';
|
||||
img.style.height = 'auto';
|
||||
}
|
||||
} catch (e) {
|
||||
qrBox.textContent = '[QR render failed: ' + (e && e.message || e) + ']';
|
||||
}
|
||||
} else {
|
||||
qrBox.textContent = '[QR library not loaded]';
|
||||
}
|
||||
|
||||
// #1101: in qrOnly mode (Share modal), the host renders the hex
|
||||
// key field + Copy button BELOW the QR. Skip the inline URL line
|
||||
// and inline Copy Key button here so the QR box contains JUST the
|
||||
// QR image — no overlap, no redundant affordances.
|
||||
if (qrOnly) return;
|
||||
|
||||
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);
|
||||
+643
-151
@@ -91,7 +91,6 @@
|
||||
if (header) header.querySelector('.ch-header-text').textContent = 'Select a channel';
|
||||
const msgEl = document.getElementById('chMessages');
|
||||
if (msgEl) msgEl.innerHTML = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
|
||||
document.querySelector('.ch-layout')?.classList.remove('ch-show-main');
|
||||
document.getElementById('chScrollBtn')?.classList.add('hidden');
|
||||
return true;
|
||||
}
|
||||
@@ -245,14 +244,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function chBack() {
|
||||
closeNodeDetail();
|
||||
var layout = document.querySelector('.ch-layout');
|
||||
if (layout) layout.classList.remove('ch-show-main');
|
||||
var sidebar = document.querySelector('.ch-sidebar');
|
||||
if (sidebar) sidebar.style.pointerEvents = '';
|
||||
}
|
||||
|
||||
// WCAG AA compliant colors — ≥4.5:1 contrast on both white and dark backgrounds
|
||||
// Channel badge colors (white text on colored background)
|
||||
const CHANNEL_COLORS = [
|
||||
@@ -339,6 +330,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
// #1087 Bug 3: single canonical persistence helper. Both the Generate
|
||||
// path and the PSK Add path route writes through this function so the
|
||||
// localStorage write happens synchronously inside the submit handler —
|
||||
// not as a side effect of subsequent UI events.
|
||||
//
|
||||
// The previous code spread storeKey() calls across multiple branches,
|
||||
// and the persistence path could be skipped entirely if the modal was
|
||||
// closed before mergeUserChannels() ran. Hence the original symptom:
|
||||
// a freshly-added channel disappeared on refresh, then "reappeared"
|
||||
// when ANOTHER channel was added (because the second add wrote the
|
||||
// entire current state, including #1).
|
||||
//
|
||||
// Returns true iff the key was successfully stored AND a re-read
|
||||
// confirms it landed in localStorage. Returns false on quota / other
|
||||
// storage failure so callers can surface an error.
|
||||
function persistAddedChannel(channelName, keyHex, label) {
|
||||
if (!channelName || !keyHex) return false;
|
||||
try {
|
||||
ChannelDecrypt.storeKey(channelName, keyHex, label);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
// Verify the write by re-reading. localStorage can silently drop
|
||||
// writes under quota pressure, and we want callers to know.
|
||||
try {
|
||||
var keys = (typeof ChannelDecrypt.getStoredKeys === 'function')
|
||||
? ChannelDecrypt.getStoredKeys()
|
||||
: JSON.parse(localStorage.getItem('corescope_channel_keys') || '{}');
|
||||
if (!keys || keys[channelName] !== keyHex) return false;
|
||||
// Polish MINOR-3: also verify the label round-tripped when one was supplied.
|
||||
// Labels live in a separate storage bucket and could fail independently
|
||||
// of the key write — caller deserves to know if the friendly name didn't land.
|
||||
var trimmed = (typeof label === 'string') ? label.trim() : '';
|
||||
if (trimmed) {
|
||||
var stored = (typeof ChannelDecrypt.getLabel === 'function')
|
||||
? ChannelDecrypt.getLabel(channelName)
|
||||
: ((typeof ChannelDecrypt.getLabels === 'function')
|
||||
? (ChannelDecrypt.getLabels()[channelName] || '')
|
||||
: '');
|
||||
if (stored !== trimmed) return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a user channel by name (#channelname) or hex key.
|
||||
// `label` (#1020) is an optional friendly name shown in the sidebar instead
|
||||
// of "psk:<hex8>" — stored alongside the key in localStorage.
|
||||
@@ -361,8 +399,12 @@
|
||||
keyHex = ChannelDecrypt.bytesToHex(keyBytes2);
|
||||
}
|
||||
|
||||
// #1020: persist optional user-supplied label alongside the key
|
||||
ChannelDecrypt.storeKey(channelName, keyHex, label);
|
||||
// #1020/#1087: persist optional user-supplied label alongside the key
|
||||
// through the canonical helper (verified read-back).
|
||||
if (!persistAddedChannel(channelName, keyHex, label)) {
|
||||
showAddStatus('Failed to save channel — browser storage may be full', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute channel hash byte to find matching encrypted channels
|
||||
var keyBytes3 = ChannelDecrypt.hexToBytes(keyHex);
|
||||
@@ -631,36 +673,100 @@
|
||||
<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 & 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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- #1087 Bug 4: dedicated Share modal — separate from the Add
|
||||
Channel modal above. Add = INPUT (paste/scan/generate). Share
|
||||
= OUTPUT (display existing key as QR + URL + copyable text).
|
||||
Reusing the Add modal for Share confused intent and let the
|
||||
QR section bleed into the Add submit flow. -->
|
||||
<div id="chShareModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chShareModalTitle" hidden>
|
||||
<div class="modal ch-modal ch-share-modal" role="document">
|
||||
<button type="button" class="modal-close ch-modal-close" id="chShareModalClose" data-action="ch-share-modal-close" aria-label="Close">✕</button>
|
||||
<h3 id="chShareModalTitle" class="ch-share-modal-title">Share Channel</h3>
|
||||
<div class="ch-share-modal-body">
|
||||
<div id="chShareQr" class="ch-share-qr" aria-live="polite"></div>
|
||||
<div class="ch-share-field-group">
|
||||
<label class="ch-share-label" for="chShareKey">Hex Key</label>
|
||||
<div class="ch-share-row">
|
||||
<input type="text" id="chShareKey" data-share-field="key" class="ch-modal-input ch-modal-input--mono" readonly aria-label="Channel hex key">
|
||||
<button type="button" class="ch-modal-btn-secondary" data-share-copy="key" aria-label="Copy hex key">📋 Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ch-modal-warn" role="note">
|
||||
⚠ Privacy: only share with trusted people. Anyone with this key can read all messages on this channel.
|
||||
</div>
|
||||
</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>
|
||||
<span class="ch-header-text">Select a channel</span>
|
||||
</div>
|
||||
<div class="ch-messages" id="chMessages">
|
||||
@@ -673,15 +779,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,41 +791,289 @@
|
||||
});
|
||||
});
|
||||
|
||||
// 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 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');
|
||||
// #1087 Bug 4: dedicated Share modal wiring.
|
||||
// Polish follow-up: focus trap on open + restore focus on close (a11y).
|
||||
var shareModalEl = document.getElementById('chShareModal');
|
||||
var _shareModalTrigger = null;
|
||||
var _shareModalKeyHandler = null;
|
||||
// QR capacity bound: qrcode(0,'M') auto-detects smallest version, but
|
||||
// very long display labels can overflow. URL = scheme(~30) + 32-char
|
||||
// secret + encoded(name). Cap encoded label budget to keep total URL
|
||||
// comfortably under the version-10 ECC-M payload (~213 bytes).
|
||||
var SHARE_LABEL_MAX = 64;
|
||||
function _truncateForQr(name) {
|
||||
if (!name) return '';
|
||||
var s = String(name);
|
||||
// Encode first, then trim — encoded length is what QR sees.
|
||||
var enc = encodeURIComponent(s);
|
||||
if (enc.length <= SHARE_LABEL_MAX) return s;
|
||||
// Walk back until encoded fits; preserves UTF-8 boundaries via
|
||||
// encodeURIComponent re-check on each shrink.
|
||||
while (s.length > 0 && encodeURIComponent(s).length > SHARE_LABEL_MAX) {
|
||||
s = s.slice(0, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function _trapShareModalFocus() {
|
||||
if (!shareModalEl) return;
|
||||
var focusable = shareModalEl.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (!focusable.length) return;
|
||||
var first = focusable[0], last = focusable[focusable.length - 1];
|
||||
_shareModalKeyHandler = function (e) {
|
||||
if (e.key !== 'Tab') return;
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
};
|
||||
shareModalEl.addEventListener('keydown', _shareModalKeyHandler);
|
||||
}
|
||||
// Open the share modal in NORMAL (key present) mode. For the
|
||||
// "key not found" path, callers use openShareModalError() — both
|
||||
// routes use this same modal so users never see a native alert().
|
||||
function openShareModal(displayName, channelName, keyHex) {
|
||||
if (!shareModalEl) return;
|
||||
_shareModalTrigger = document.activeElement;
|
||||
var safeName = _truncateForQr(displayName);
|
||||
var title = document.getElementById('chShareModalTitle');
|
||||
if (title) title.textContent = 'Share: ' + safeName;
|
||||
var qrHolder = document.getElementById('chShareQr');
|
||||
var keyField = document.getElementById('chShareKey');
|
||||
var fieldsWrap = shareModalEl.querySelectorAll('.ch-share-field-group');
|
||||
for (var i = 0; i < fieldsWrap.length; i++) fieldsWrap[i].hidden = false;
|
||||
if (keyField) keyField.value = keyHex;
|
||||
if (qrHolder) {
|
||||
qrHolder.innerHTML = '';
|
||||
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
|
||||
// #1087 Bug 2: pass the user-facing displayName, NOT the
|
||||
// internal `psk:<hex8>` channelName lookup key.
|
||||
// #1101: qrOnly=true — render JUST the QR image. The Share
|
||||
// modal has its own dedicated hex key field + Copy button
|
||||
// BELOW the QR; an inline URL line + Copy Key button inside
|
||||
// the QR box was redundant and visually overlapping.
|
||||
window.ChannelQR.generate(safeName, keyHex, qrHolder, { qrOnly: true });
|
||||
}
|
||||
}
|
||||
shareModalEl.classList.remove('hidden');
|
||||
shareModalEl.removeAttribute('hidden');
|
||||
_trapShareModalFocus();
|
||||
var closeBtn = document.getElementById('chShareModalClose');
|
||||
if (closeBtn) try { closeBtn.focus(); } catch (e) { /* noop */ }
|
||||
}
|
||||
// Polish: replace native alert() for missing-key share with the
|
||||
// dedicated modal in error mode (no QR/fields, just the message).
|
||||
function openShareModalError(displayName, message) {
|
||||
if (!shareModalEl) return;
|
||||
_shareModalTrigger = document.activeElement;
|
||||
var title = document.getElementById('chShareModalTitle');
|
||||
if (title) title.textContent = 'Share: ' + displayName;
|
||||
var qrHolder = document.getElementById('chShareQr');
|
||||
if (qrHolder) {
|
||||
qrHolder.innerHTML = '';
|
||||
var msg = document.createElement('div');
|
||||
msg.className = 'ch-share-error';
|
||||
msg.setAttribute('role', 'alert');
|
||||
msg.textContent = message;
|
||||
qrHolder.appendChild(msg);
|
||||
}
|
||||
var fieldsWrap = shareModalEl.querySelectorAll('.ch-share-field-group');
|
||||
for (var i = 0; i < fieldsWrap.length; i++) fieldsWrap[i].hidden = true;
|
||||
shareModalEl.classList.remove('hidden');
|
||||
shareModalEl.removeAttribute('hidden');
|
||||
_trapShareModalFocus();
|
||||
var closeBtn = document.getElementById('chShareModalClose');
|
||||
if (closeBtn) try { closeBtn.focus(); } catch (e) { /* noop */ }
|
||||
}
|
||||
function closeShareModal() {
|
||||
if (!shareModalEl) return;
|
||||
shareModalEl.classList.add('hidden');
|
||||
shareModalEl.setAttribute('hidden', '');
|
||||
if (_shareModalKeyHandler) {
|
||||
shareModalEl.removeEventListener('keydown', _shareModalKeyHandler);
|
||||
_shareModalKeyHandler = null;
|
||||
}
|
||||
// Restore focus to the trigger that opened the modal (a11y).
|
||||
if (_shareModalTrigger && typeof _shareModalTrigger.focus === 'function') {
|
||||
try { _shareModalTrigger.focus(); } catch (e) { /* noop */ }
|
||||
}
|
||||
_shareModalTrigger = null;
|
||||
}
|
||||
if (shareModalEl) {
|
||||
shareModalEl.addEventListener('click', function (e) {
|
||||
var copyBtn = e.target.closest && e.target.closest('[data-share-copy]');
|
||||
if (copyBtn) {
|
||||
e.preventDefault();
|
||||
// #1101: only the hex key is copyable from the share modal;
|
||||
// the URL field was removed, so the data-share-copy attribute
|
||||
// is informational only — the source is always #chShareKey.
|
||||
var src = document.getElementById('chShareKey');
|
||||
if (src) {
|
||||
try { src.select(); } catch (e2) {}
|
||||
var doneCopy = function () {
|
||||
var orig = copyBtn.textContent;
|
||||
copyBtn.textContent = '✓ Copied';
|
||||
setTimeout(function () { copyBtn.textContent = orig; }, 1200);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(src.value).then(doneCopy, doneCopy);
|
||||
} else {
|
||||
try { document.execCommand('copy'); } catch (e2) {}
|
||||
doneCopy();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
var closeEl = e.target.closest('[data-action="ch-share-modal-close"]');
|
||||
if (closeEl || e.target === shareModalEl) {
|
||||
e.preventDefault();
|
||||
closeShareModal();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !shareModalEl.classList.contains('hidden')) {
|
||||
closeShareModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
// #1087 Bug 3: persist via canonical helper synchronously.
|
||||
if (!persistAddedChannel(channelName, keyHex, label)) {
|
||||
showAddStatus('Failed to save channel — storage full', 'error');
|
||||
return;
|
||||
}
|
||||
var qrOut = document.getElementById('qr-output');
|
||||
if (qrOut) {
|
||||
qrOut.innerHTML = '';
|
||||
// Render QR + URL + Copy Key inline.
|
||||
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
|
||||
// #1087 Bug 2: pass the user label (not psk:<hex8>).
|
||||
window.ChannelQR.generate(label || channelName, keyHex, qrOut);
|
||||
} else {
|
||||
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 (0–9, a–f).'; 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 () {
|
||||
// Also load user-added encrypted channels into the sidebar
|
||||
// Also load user-added encrypted channels into the sidebar.
|
||||
// mergeUserChannels() mutates `channels` (marks userAdded, appends
|
||||
// PSK-only entries) AFTER loadChannels() already rendered — so we
|
||||
// MUST re-render here, otherwise the My Channels section never
|
||||
// appears on first load when the route has no specific channel
|
||||
// hash (regression caught by test-channel-issue-1111-e2e.js, case 2).
|
||||
mergeUserChannels();
|
||||
renderChannelList();
|
||||
if (routeParam) await selectChannel(routeParam);
|
||||
if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode);
|
||||
});
|
||||
@@ -749,29 +1098,64 @@
|
||||
});
|
||||
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
|
||||
// #87: Fix pointer-events during mobile slide transition
|
||||
var chMain = app.querySelector('.ch-main');
|
||||
var chSidebar = app.querySelector('.ch-sidebar');
|
||||
chMain.addEventListener('transitionend', function () {
|
||||
var layout = app.querySelector('.ch-layout');
|
||||
if (layout && layout.classList.contains('ch-show-main')) {
|
||||
chSidebar.style.pointerEvents = 'none';
|
||||
} else {
|
||||
chSidebar.style.pointerEvents = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Event delegation for data-action buttons
|
||||
app.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
var action = btn.dataset.action;
|
||||
if (action === 'ch-close-node') closeNodeDetail();
|
||||
else if (action === 'ch-back') chBack();
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
// #1087 Bug 2 + Bug 4: Share/reshare opens a DEDICATED share modal
|
||||
// (not the Add Channel modal) and resolves the user's display
|
||||
// label via ChannelDecrypt.getLabel — never the raw `psk:<hex8>`
|
||||
// lookup key.
|
||||
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 channelName = shareHash.startsWith('user:')
|
||||
? shareHash.substring(5)
|
||||
: (sCh && sCh.name) || shareHash;
|
||||
var keys = ChannelDecrypt.getStoredKeys();
|
||||
var keyHex = keys[channelName];
|
||||
// Resolve display label: explicit user label > channel.userLabel
|
||||
// > strip the psk: prefix > raw channelName.
|
||||
var labels = (typeof ChannelDecrypt.getLabels === 'function')
|
||||
? ChannelDecrypt.getLabels() : {};
|
||||
var labelFromStore = (typeof ChannelDecrypt.getLabel === 'function')
|
||||
? ChannelDecrypt.getLabel(channelName)
|
||||
: (labels[channelName] || '');
|
||||
var displayName = labelFromStore
|
||||
|| (sCh && sCh.userLabel)
|
||||
|| (channelName.indexOf('psk:') === 0
|
||||
? 'Private Channel'
|
||||
: channelName);
|
||||
if (!keyHex) {
|
||||
openShareModalError(displayName, 'No stored key found for "' + displayName + '" — cannot share.');
|
||||
return;
|
||||
}
|
||||
if (typeof openShareModal === 'function') {
|
||||
openShareModal(displayName, channelName, keyHex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// M4: Remove channel button
|
||||
const removeBtn = e.target.closest('[data-remove-channel]');
|
||||
if (removeBtn) {
|
||||
@@ -785,7 +1169,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 +1313,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 +1344,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 +1357,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 +1368,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 +1451,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 +1484,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 +1555,158 @@
|
||||
}
|
||||
}
|
||||
|
||||
// #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 = [];
|
||||
if (mine.length > 0) {
|
||||
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.map(renderChannelRow).join('')}
|
||||
</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,13 +1719,12 @@
|
||||
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`;
|
||||
|
||||
// On mobile, show the message view
|
||||
document.querySelector('.ch-layout')?.classList.add('ch-show-main');
|
||||
|
||||
const msgEl = document.getElementById('chMessages');
|
||||
|
||||
// Shared helper: fetch, decrypt, and render messages for a channel key (M5: cache-first)
|
||||
|
||||
+44
-2
@@ -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>' +
|
||||
|
||||
+93
-2
@@ -53,6 +53,52 @@
|
||||
|
||||
var THEME_COLOR_KEYS = Object.keys(THEME_CSS_MAP).filter(function (k) { return k !== 'font' && k !== 'mono'; });
|
||||
|
||||
// ── Brand logo swap helper (PR #1137) ──
|
||||
// The default navbar brand logo is an inline <svg class="brand-logo"> so it
|
||||
// inherits page CSS vars (--logo-text / --logo-accent / etc.). When an
|
||||
// operator overrides branding.logoUrl in the customizer they expect a
|
||||
// remote image — swap the inline <svg> for an <img>. Going back to the
|
||||
// default URL or clearing the override swaps the <img> back to the inline
|
||||
// <svg>. Layout dimensions (width=111 height=36) are preserved either way.
|
||||
function _setBrandLogoUrl(url, alt) {
|
||||
var node = document.querySelector('.nav-brand .brand-logo');
|
||||
if (!node) return;
|
||||
if (url) {
|
||||
if (node.tagName.toLowerCase() === 'img') {
|
||||
node.setAttribute('src', url);
|
||||
if (alt != null) node.setAttribute('alt', alt);
|
||||
return;
|
||||
}
|
||||
// swap inline <svg> → <img>
|
||||
var img = document.createElement('img');
|
||||
img.className = 'brand-logo';
|
||||
img.setAttribute('src', url);
|
||||
img.setAttribute('alt', alt || node.getAttribute('aria-label') || 'Brand');
|
||||
img.setAttribute('width', '125');
|
||||
img.setAttribute('height', '36');
|
||||
node.parentNode.replaceChild(img, node);
|
||||
} else {
|
||||
if (node.tagName.toLowerCase() !== 'img') {
|
||||
if (alt != null) node.setAttribute('aria-label', alt);
|
||||
return;
|
||||
}
|
||||
// swap <img> → inline <svg> by clearing the src; here we just keep the
|
||||
// <img> in place because we don't have the SVG markup at runtime
|
||||
// (it lives in index.html). The next page reload restores the inline
|
||||
// SVG. Setting src to the default URL is a graceful intermediate.
|
||||
node.setAttribute('src', 'img/corescope-logo.svg');
|
||||
if (alt != null) node.setAttribute('alt', alt);
|
||||
}
|
||||
}
|
||||
function _setBrandAlt(alt) {
|
||||
var node = document.querySelector('.nav-brand .brand-logo');
|
||||
if (!node) return;
|
||||
if (node.tagName.toLowerCase() === 'img') node.setAttribute('alt', alt);
|
||||
else node.setAttribute('aria-label', alt);
|
||||
var brandLink = document.querySelector('.nav-brand');
|
||||
if (brandLink) brandLink.setAttribute('aria-label', alt + ' home');
|
||||
}
|
||||
|
||||
// ── Presets (copied from v1 customize.js) ──
|
||||
var PRESETS = {
|
||||
default: {
|
||||
@@ -468,7 +514,7 @@
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
function applyCSS(effectiveConfig) {
|
||||
function applyCSS(effectiveConfig, userOverrides) {
|
||||
var dark = isDarkMode();
|
||||
var themeSection = dark
|
||||
? Object.assign({}, effectiveConfig.theme || {}, effectiveConfig.themeDark || {})
|
||||
@@ -483,6 +529,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Logo brand colors mirror --accent / --accent-hover ONLY when an
|
||||
// operator has actually overridden them via the customizer. We check
|
||||
// userOverrides (not the merged effective config), so the server-default
|
||||
// accent (#4a9eff) does NOT clobber the sage/teal :root brand defaults
|
||||
// out-of-the-box. When an operator picks a theme, customizer writes the
|
||||
// override to localStorage, the override flows through here, and the
|
||||
// wordmark recolors to follow the chosen accent.
|
||||
var ovTheme = (userOverrides && (dark
|
||||
? Object.assign({}, userOverrides.theme || {}, userOverrides.themeDark || {})
|
||||
: (userOverrides.theme || {}))) || {};
|
||||
if (ovTheme.accent) root.setProperty('--logo-accent', ovTheme.accent);
|
||||
if (ovTheme.accentHover) root.setProperty('--logo-accent-hi', ovTheme.accentHover);
|
||||
|
||||
// Derived vars
|
||||
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
|
||||
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
|
||||
@@ -544,10 +603,12 @@
|
||||
if (br) {
|
||||
if (br.siteName) {
|
||||
document.title = br.siteName;
|
||||
_setBrandAlt(br.siteName);
|
||||
var brandEl = document.querySelector('.brand-text');
|
||||
if (brandEl) brandEl.textContent = br.siteName;
|
||||
}
|
||||
if (br.logoUrl) {
|
||||
_setBrandLogoUrl(br.logoUrl, br.siteName || null);
|
||||
var iconEl = document.querySelector('.brand-icon');
|
||||
if (iconEl) iconEl.innerHTML = '<img src="' + br.logoUrl + '" style="height:24px" onerror="this.style.display=\'none\'">';
|
||||
}
|
||||
@@ -566,7 +627,7 @@
|
||||
var overrides = readOverrides();
|
||||
var effective = computeEffective(_serverDefaults || {}, overrides);
|
||||
window.SITE_CONFIG = effective;
|
||||
applyCSS(effective);
|
||||
applyCSS(effective, overrides);
|
||||
}
|
||||
|
||||
// ── setOverride / clearOverride ──
|
||||
@@ -1141,6 +1202,9 @@
|
||||
'<option value="km"' + (distUnit === 'km' ? ' selected' : '') + '>Kilometers (km)</option>' +
|
||||
'<option value="mi"' + (distUnit === 'mi' ? ' selected' : '') + '>Miles (mi)</option>' +
|
||||
'</select></div>' +
|
||||
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Gesture Hints</p>' +
|
||||
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).</p>' +
|
||||
'<button type="button" class="cust-dl-btn" data-cv2-reset-hints data-reset-gesture-hints>↺ Reset gesture hints</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -1344,6 +1408,9 @@
|
||||
// Optimistic CSS update (Decision #12)
|
||||
var cssVar = THEME_CSS_MAP[key];
|
||||
if (cssVar) document.documentElement.style.setProperty(cssVar, inp.value);
|
||||
// Mirror to logo brand vars so the wordmark recolors live too.
|
||||
if (key === 'accent') document.documentElement.style.setProperty('--logo-accent', inp.value);
|
||||
if (key === 'accentHover') document.documentElement.style.setProperty('--logo-accent-hi', inp.value);
|
||||
// Update hex display
|
||||
var hex = inp.parentElement.querySelector('.cust-hex');
|
||||
if (hex) hex.textContent = inp.value;
|
||||
@@ -1360,11 +1427,13 @@
|
||||
setOverride(section, key, inp.value);
|
||||
// Live branding updates
|
||||
if (section === 'branding' && key === 'siteName') {
|
||||
_setBrandAlt(inp.value);
|
||||
var el = document.querySelector('.brand-text');
|
||||
if (el) el.textContent = inp.value;
|
||||
document.title = inp.value;
|
||||
}
|
||||
if (section === 'branding' && key === 'logoUrl') {
|
||||
_setBrandLogoUrl(inp.value || '', null);
|
||||
var iconEl = document.querySelector('.brand-icon');
|
||||
if (iconEl) {
|
||||
if (inp.value) iconEl.innerHTML = '<img src="' + inp.value + '" style="height:24px" onerror="this.style.display=\'none\'">';
|
||||
@@ -1543,6 +1612,19 @@
|
||||
_runPipeline();
|
||||
_renderPanel(container);
|
||||
});
|
||||
|
||||
// Reset gesture hints (#1065)
|
||||
var hintsBtn = container.querySelector('[data-cv2-reset-hints]');
|
||||
if (hintsBtn) hintsBtn.addEventListener('click', function () {
|
||||
if (window.GestureHints && typeof window.GestureHints.reset === 'function') {
|
||||
window.GestureHints.reset();
|
||||
} else {
|
||||
// Fallback: clear known keys directly.
|
||||
['row-swipe', 'tab-swipe', 'edge-drawer', 'pull-refresh'].forEach(function (k) {
|
||||
try { localStorage.removeItem('meshcore-gesture-hints-' + k); } catch (_e) {}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Panel toggle ──
|
||||
@@ -1606,6 +1688,13 @@
|
||||
for (var key in THEME_CSS_MAP) {
|
||||
if (themeSection[key]) root.setProperty(THEME_CSS_MAP[key], themeSection[key]);
|
||||
}
|
||||
// Mirror accent → logo brand vars ONLY when present in overrides (so the
|
||||
// server-default accent never clobbers the sage/teal :root brand defaults).
|
||||
var ovTheme = dark
|
||||
? Object.assign({}, earlyOverrides.theme || {}, earlyOverrides.themeDark || {})
|
||||
: (earlyOverrides.theme || {});
|
||||
if (ovTheme.accent) root.setProperty('--logo-accent', ovTheme.accent);
|
||||
if (ovTheme.accentHover) root.setProperty('--logo-accent-hi', ovTheme.accentHover);
|
||||
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
|
||||
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
|
||||
// Apply node/type colors from overrides early
|
||||
@@ -1632,11 +1721,13 @@
|
||||
var overrides = readOverrides();
|
||||
if (overrides.branding) {
|
||||
if (overrides.branding.siteName) {
|
||||
_setBrandAlt(overrides.branding.siteName);
|
||||
var brandEl = document.querySelector('.brand-text');
|
||||
if (brandEl) brandEl.textContent = overrides.branding.siteName;
|
||||
document.title = overrides.branding.siteName;
|
||||
}
|
||||
if (overrides.branding.logoUrl) {
|
||||
_setBrandLogoUrl(overrides.branding.logoUrl, overrides.branding.siteName || null);
|
||||
var iconEl = document.querySelector('.brand-icon');
|
||||
if (iconEl) iconEl.innerHTML = '<img src="' + overrides.branding.logoUrl + '" style="height:24px" onerror="this.style.display=\'none\'">';
|
||||
}
|
||||
|
||||
@@ -7,6 +7,36 @@
|
||||
let originalValues = {};
|
||||
let activeTab = 'branding';
|
||||
|
||||
// ── Brand logo swap helpers (PR #1137) ──
|
||||
// Default brand logo is an inline <svg.brand-logo>; an operator override
|
||||
// (branding.logoUrl) swaps it for an <img.brand-logo>. Going back to empty
|
||||
// restores the inline default on next reload (intermediate state shows the
|
||||
// bundled SVG via <img>). Kept in customize.js for v1 parity.
|
||||
function _v1SetBrandLogoUrl(url) {
|
||||
var node = document.querySelector('.nav-brand .brand-logo');
|
||||
if (!node) return;
|
||||
if (url) {
|
||||
if (node.tagName.toLowerCase() === 'img') { node.setAttribute('src', url); return; }
|
||||
var img = document.createElement('img');
|
||||
img.className = 'brand-logo';
|
||||
img.setAttribute('src', url);
|
||||
img.setAttribute('alt', node.getAttribute('aria-label') || 'Brand');
|
||||
img.setAttribute('width', '111');
|
||||
img.setAttribute('height', '36');
|
||||
node.parentNode.replaceChild(img, node);
|
||||
} else if (node.tagName.toLowerCase() === 'img') {
|
||||
node.setAttribute('src', 'img/corescope-logo.svg');
|
||||
}
|
||||
}
|
||||
function _v1SetBrandAlt(alt) {
|
||||
var node = document.querySelector('.nav-brand .brand-logo');
|
||||
if (!node) return;
|
||||
if (node.tagName.toLowerCase() === 'img') node.setAttribute('alt', alt);
|
||||
else node.setAttribute('aria-label', alt);
|
||||
var brandLink = document.querySelector('.nav-brand');
|
||||
if (brandLink) brandLink.setAttribute('aria-label', alt + ' home');
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
branding: {
|
||||
siteName: 'CoreScope',
|
||||
@@ -513,6 +543,9 @@
|
||||
for (var key in THEME_CSS_MAP) {
|
||||
if (t[key]) document.documentElement.style.setProperty(THEME_CSS_MAP[key], t[key]);
|
||||
}
|
||||
// Mirror accent → logo brand vars so the wordmark follows the theme.
|
||||
if (t.accent) document.documentElement.style.setProperty('--logo-accent', t.accent);
|
||||
if (t.accentHover) document.documentElement.style.setProperty('--logo-accent-hi', t.accentHover);
|
||||
// Derived vars that reference other vars — need explicit override
|
||||
if (t.background) {
|
||||
document.documentElement.style.setProperty('--content-bg', t.background);
|
||||
@@ -1006,11 +1039,18 @@
|
||||
}
|
||||
// Live DOM updates for branding
|
||||
if (inp.dataset.key === 'branding.siteName') {
|
||||
// Post-rebrand (PR #1137): the navbar brand is an inline <svg>;
|
||||
// mutate aria-label (a11y label on the <svg>/<a>) + document title.
|
||||
// Legacy .brand-text fallback retained for any operator who shipped
|
||||
// a custom build that still uses the text node.
|
||||
_v1SetBrandAlt(inp.value);
|
||||
var brandEl = document.querySelector('.brand-text');
|
||||
if (brandEl) brandEl.textContent = inp.value;
|
||||
document.title = inp.value;
|
||||
}
|
||||
if (inp.dataset.key === 'branding.logoUrl') {
|
||||
// Swap the navbar logo: empty → restore inline default; URL → <img>.
|
||||
_v1SetBrandLogoUrl(inp.value || '');
|
||||
var iconEl = document.querySelector('.brand-icon');
|
||||
if (iconEl) {
|
||||
if (inp.value) { iconEl.innerHTML = '<img src="' + inp.value + '" style="height:24px" onerror="this.style.display=\'none\'">'; }
|
||||
@@ -1410,6 +1450,9 @@
|
||||
for (const [key, val] of Object.entries(themeData)) {
|
||||
if (THEME_CSS_MAP[key]) document.documentElement.style.setProperty(THEME_CSS_MAP[key], val);
|
||||
}
|
||||
// Mirror accent → logo brand vars (matches applyThemePreview()).
|
||||
if (themeData.accent) document.documentElement.style.setProperty('--logo-accent', themeData.accent);
|
||||
if (themeData.accentHover) document.documentElement.style.setProperty('--logo-accent-hi', themeData.accentHover);
|
||||
// Derived vars
|
||||
if (themeData.background) document.documentElement.style.setProperty('--content-bg', themeData.background);
|
||||
if (themeData.surface1) document.documentElement.style.setProperty('--card-bg', themeData.surface1);
|
||||
@@ -1441,11 +1484,13 @@
|
||||
const userTheme = JSON.parse(saved);
|
||||
if (userTheme.branding) {
|
||||
if (userTheme.branding.siteName) {
|
||||
_v1SetBrandAlt(userTheme.branding.siteName);
|
||||
const brandEl = document.querySelector('.brand-text');
|
||||
if (brandEl) brandEl.textContent = userTheme.branding.siteName;
|
||||
document.title = userTheme.branding.siteName;
|
||||
}
|
||||
if (userTheme.branding.logoUrl) {
|
||||
_v1SetBrandLogoUrl(userTheme.branding.logoUrl);
|
||||
const iconEl = document.querySelector('.brand-icon');
|
||||
if (iconEl) iconEl.innerHTML = '<img src="' + userTheme.branding.logoUrl + '" style="height:24px" onerror="this.style.display=\'none\'">';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
/* 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 [
|
||||
// NOTE(#1122): "Filter syntax" heading is provided by the popover header;
|
||||
// do NOT repeat it here or the panel renders the label twice.
|
||||
'<p>Wireshark-style boolean expressions over packet fields. Combine with <code>&&</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) {
|
||||
// Toggle: also remove the backdrop wrapper if present
|
||||
var wrap = existing.closest('.modal-overlay');
|
||||
(wrap || existing).remove();
|
||||
return;
|
||||
}
|
||||
// #1122: Render as a real centered modal inside .modal-overlay so the
|
||||
// help panel never floats over the packet table rows.
|
||||
var overlay = _h('div', { class: 'modal-overlay fux-help-overlay', role: 'presentation' });
|
||||
var pop = _h('div', { id: 'filterHelpPopover', class: 'modal fux-popover', role: 'dialog', 'aria-modal': 'true', '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>';
|
||||
overlay.appendChild(pop);
|
||||
document.body.appendChild(overlay);
|
||||
// #1124 (MAJOR-2): focus management. Save the trigger so we can restore
|
||||
// focus on close, then move focus to the close button. Trap Tab cycles
|
||||
// inside the modal until it closes.
|
||||
var trigger = document.activeElement;
|
||||
var closeBtn = pop.querySelector('.fux-popover-close');
|
||||
function _focusables() {
|
||||
return Array.prototype.slice.call(pop.querySelectorAll(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
));
|
||||
}
|
||||
function close() {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
// Restore focus to the original trigger if still in the DOM.
|
||||
if (trigger && typeof trigger.focus === 'function' && document.body.contains(trigger)) {
|
||||
try { trigger.focus(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
function onKey(ev) {
|
||||
if (ev.key === 'Escape') { close(); return; }
|
||||
if (ev.key !== 'Tab') return;
|
||||
var f = _focusables();
|
||||
if (!f.length) { ev.preventDefault(); return; }
|
||||
var first = f[0], last = f[f.length - 1];
|
||||
var active = document.activeElement;
|
||||
if (ev.shiftKey) {
|
||||
if (active === first || !pop.contains(active)) { last.focus(); ev.preventDefault(); }
|
||||
} else {
|
||||
if (active === last || !pop.contains(active)) { first.focus(); ev.preventDefault(); }
|
||||
}
|
||||
}
|
||||
closeBtn.addEventListener('click', close);
|
||||
overlay.addEventListener('click', function(ev) {
|
||||
// Click on backdrop (not inside the modal) closes
|
||||
if (ev.target === overlay) close();
|
||||
});
|
||||
document.addEventListener('keydown', onKey);
|
||||
// Move focus to the close button (first interactive element).
|
||||
try { closeBtn.focus(); } catch (e) {}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
})();
|
||||
Binary file not shown.
@@ -0,0 +1,208 @@
|
||||
/* gesture-hints.js — Issue #1065
|
||||
* First-visit gesture discoverability hints.
|
||||
*
|
||||
* - localStorage namespace: meshcore-gesture-hints-<hint>
|
||||
* keys: row-swipe, tab-swipe, edge-drawer, pull-refresh
|
||||
* value: "seen"
|
||||
* - Show hint 800ms after page settle; auto-fade 8s; "Got it" dismisses.
|
||||
* - aria-live=polite, role=status, no focus stealing, pointer-events:none.
|
||||
* - prefers-reduced-motion: animation-name: none (style.css handles via media query).
|
||||
* - Singleton + cleanup: module-scoped guard; SPA re-mount must not re-show dismissed.
|
||||
* - Pull-to-refresh hint only when .pull-to-reconnect element exists in DOM.
|
||||
* - Edge-drawer hint only at viewport > 768px (where edge-swipe drawer applies).
|
||||
* - Row-swipe hint only on table pages: /#/packets, /#/nodes, etc.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.__gestureHints1065Init) {
|
||||
window.__gestureHints1065Init++;
|
||||
return;
|
||||
}
|
||||
window.__gestureHints1065Init = 1;
|
||||
|
||||
var NS = 'meshcore-gesture-hints-';
|
||||
var HINTS = {
|
||||
'row-swipe': {
|
||||
key: NS + 'row-swipe',
|
||||
text: 'Tip: swipe a row left for quick actions.',
|
||||
relevant: function () {
|
||||
var h = location.hash || '';
|
||||
return /^#\/(packets|nodes|live)/.test(h);
|
||||
},
|
||||
position: 'bottom',
|
||||
},
|
||||
'tab-swipe': {
|
||||
key: NS + 'tab-swipe',
|
||||
text: 'Tip: swipe left or right to switch tabs.',
|
||||
relevant: function () {
|
||||
return !!document.querySelector('[data-bottom-nav]');
|
||||
},
|
||||
position: 'bottom',
|
||||
},
|
||||
'edge-drawer': {
|
||||
key: NS + 'edge-drawer',
|
||||
text: 'Tip: swipe in from the left edge to open navigation.',
|
||||
relevant: function () {
|
||||
return window.innerWidth > 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]');
|
||||
},
|
||||
position: 'top-left',
|
||||
},
|
||||
'pull-refresh': {
|
||||
key: NS + 'pull-refresh',
|
||||
text: 'Tip: pull down to refresh the connection.',
|
||||
relevant: function () {
|
||||
return !!document.querySelector('.pull-to-reconnect');
|
||||
},
|
||||
position: 'top',
|
||||
},
|
||||
};
|
||||
|
||||
var SHOW_DELAY_MS = 800;
|
||||
var AUTO_FADE_MS = 8000;
|
||||
|
||||
var _shown = Object.create(null); // hint id → element (currently rendered)
|
||||
var _scheduledTimer = null;
|
||||
var _routeChangeBound = false;
|
||||
|
||||
function isSeen(id) {
|
||||
try { return localStorage.getItem(HINTS[id].key) === 'seen'; }
|
||||
catch (_e) { return false; }
|
||||
}
|
||||
function markSeen(id) {
|
||||
try { localStorage.setItem(HINTS[id].key, 'seen'); } catch (_e) {}
|
||||
}
|
||||
function clearAll() {
|
||||
try {
|
||||
Object.keys(HINTS).forEach(function (id) { localStorage.removeItem(HINTS[id].key); });
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
function buildHintEl(id) {
|
||||
var def = HINTS[id];
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'gesture-hint gesture-hint-' + def.position;
|
||||
// Belt-and-suspenders: inline style guarantees pointer-events:none
|
||||
// regardless of CSS load order or cascade collisions. The hint must
|
||||
// never capture clicks; only the inner button does (via .gesture-hint-inner).
|
||||
wrap.style.pointerEvents = 'none';
|
||||
wrap.setAttribute('data-gesture-hint', id);
|
||||
wrap.setAttribute('role', 'status');
|
||||
wrap.setAttribute('aria-live', 'polite');
|
||||
wrap.setAttribute('aria-atomic', 'true');
|
||||
|
||||
var inner = document.createElement('div');
|
||||
inner.className = 'gesture-hint-inner';
|
||||
|
||||
var msg = document.createElement('span');
|
||||
msg.className = 'gesture-hint-text';
|
||||
msg.textContent = def.text;
|
||||
inner.appendChild(msg);
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'gesture-hint-dismiss';
|
||||
btn.setAttribute('data-gesture-hint-dismiss', '');
|
||||
btn.setAttribute('aria-label', 'Dismiss hint');
|
||||
btn.textContent = 'Got it';
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dismiss(id);
|
||||
});
|
||||
inner.appendChild(btn);
|
||||
|
||||
wrap.appendChild(inner);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function show(id) {
|
||||
if (_shown[id]) return;
|
||||
if (isSeen(id)) return;
|
||||
var def = HINTS[id];
|
||||
if (!def || !def.relevant()) return;
|
||||
|
||||
var el = buildHintEl(id);
|
||||
document.body.appendChild(el);
|
||||
_shown[id] = el;
|
||||
|
||||
// Auto-fade after AUTO_FADE_MS — does NOT mark seen; user must explicitly dismiss
|
||||
// (per AC: "Got it" button clears the flag).
|
||||
var fadeTimer = setTimeout(function () {
|
||||
if (_shown[id] === el) {
|
||||
el.classList.add('gesture-hint-fading');
|
||||
setTimeout(function () {
|
||||
if (el.parentNode) el.parentNode.removeChild(el);
|
||||
if (_shown[id] === el) delete _shown[id];
|
||||
}, 350);
|
||||
}
|
||||
}, AUTO_FADE_MS);
|
||||
el._gestureHintFadeTimer = fadeTimer;
|
||||
}
|
||||
|
||||
function dismiss(id) {
|
||||
var el = _shown[id];
|
||||
markSeen(id);
|
||||
if (el) {
|
||||
if (el._gestureHintFadeTimer) clearTimeout(el._gestureHintFadeTimer);
|
||||
if (el.parentNode) el.parentNode.removeChild(el);
|
||||
delete _shown[id];
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHints() {
|
||||
if (_scheduledTimer) clearTimeout(_scheduledTimer);
|
||||
_scheduledTimer = setTimeout(function () {
|
||||
_scheduledTimer = null;
|
||||
Object.keys(HINTS).forEach(function (id) {
|
||||
if (!isSeen(id)) show(id);
|
||||
});
|
||||
}, SHOW_DELAY_MS);
|
||||
}
|
||||
|
||||
function onRouteChange() {
|
||||
// Remove hints that are no longer relevant for the new route.
|
||||
Object.keys(_shown).slice().forEach(function (id) {
|
||||
var def = HINTS[id];
|
||||
if (!def || !def.relevant()) {
|
||||
var el = _shown[id];
|
||||
if (el && el._gestureHintFadeTimer) clearTimeout(el._gestureHintFadeTimer);
|
||||
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||
delete _shown[id];
|
||||
}
|
||||
});
|
||||
// Re-evaluate: show any not-yet-seen relevant hints.
|
||||
scheduleHints();
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!_routeChangeBound) {
|
||||
_routeChangeBound = true;
|
||||
window.addEventListener('hashchange', onRouteChange);
|
||||
}
|
||||
scheduleHints();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
window.GestureHints = {
|
||||
show: show,
|
||||
dismiss: dismiss,
|
||||
reset: function () {
|
||||
clearAll();
|
||||
// Remove any visible.
|
||||
Object.keys(_shown).slice().forEach(function (id) {
|
||||
var el = _shown[id];
|
||||
if (el && el._gestureHintFadeTimer) clearTimeout(el._gestureHintFadeTimer);
|
||||
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||
delete _shown[id];
|
||||
});
|
||||
},
|
||||
_keys: function () {
|
||||
return Object.keys(HINTS).map(function (id) { return HINTS[id].key; });
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -31,6 +31,17 @@
|
||||
background: var(--surface-1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.home-hero-logo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: min(720px, 90vw);
|
||||
height: auto;
|
||||
margin: 0 auto 16px;
|
||||
/* Inline SVG (PR #1137): inherits page CSS vars (--logo-text /
|
||||
--logo-accent / --logo-accent-hi / --logo-muted) so it themes with
|
||||
the rest of the UI on light AND dark themes. No baked background
|
||||
rect — the SVG is transparent and sits on .home-hero's surface. */
|
||||
}
|
||||
.home-hero h1 {
|
||||
font: 700 1.5rem/1.2 var(--font);
|
||||
color: var(--text);
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.6 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.4 KiB |
+28
-8
File diff suppressed because one or more lines are too long
+175
-23
@@ -1,17 +1,21 @@
|
||||
/* ========== LIVE TRACE PAGE ========== */
|
||||
/* Live page takes full viewport */
|
||||
/* Live page takes full viewport.
|
||||
* #1174 mesh-op review: subtract --bottom-nav-reserve (defined in
|
||||
* bottom-nav.css; 0px at desktop, 56px+safe-area at ≤768) so the
|
||||
* bottom-nav does not cover VCR controls / Leaflet zoom / live trace
|
||||
* markers on phones. The 52px term accounts for the top-nav above. */
|
||||
.live-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: calc(100vh - 52px - var(--bottom-nav-reserve, 0px));
|
||||
height: calc(100dvh - 52px - var(--bottom-nav-reserve, 0px));
|
||||
overflow: hidden;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
/* Override #app height constraint on live page */
|
||||
#app:has(.live-page) {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: calc(100vh - 52px - var(--bottom-nav-reserve, 0px));
|
||||
height: calc(100dvh - 52px - var(--bottom-nav-reserve, 0px));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -57,23 +61,74 @@
|
||||
left: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
max-height: 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.live-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text);
|
||||
.live-header-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Critical strip (Mesh-Operator review #1180): beacon + pkt count are
|
||||
always visible even when the collapsible body is hidden at narrow
|
||||
widths. This is the ingest-state cue (red beacon = WS down) + the
|
||||
one number operators check while the header is otherwise collapsed. */
|
||||
.live-header-critical {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Toggle buttons (#1178, #1179) — hidden at wide viewports, visible at ≤768px.
|
||||
Mesh-Operator review #1180: tap target ≥48×48 (#1060 floor + AGENTS glove
|
||||
operability rule). Visible glyph stays small (decorative); transparent
|
||||
padding expands the hit area without changing the visual chrome. */
|
||||
.live-header-toggle,
|
||||
.live-controls-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
/* Visible chrome stays compact; padding grows the hit area. */
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.live-header-toggle:hover,
|
||||
.live-controls-toggle:hover {
|
||||
background: color-mix(in srgb, var(--text) 14%, transparent);
|
||||
}
|
||||
.live-header-toggle:focus-visible,
|
||||
.live-controls-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.live-title {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -100,9 +155,9 @@
|
||||
.live-stat-pill {
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -287,11 +342,40 @@
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.live-toggles label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
|
||||
.live-toggles input { margin: 0; }
|
||||
|
||||
/* ---- Live controls cluster (#1179, re-anchored #1205) ----
|
||||
* Nested INSIDE #liveLegend (.panel-content). No longer position:fixed —
|
||||
* flows as a normal block within the legend panel so the toggle row
|
||||
* cannot detach and float across the map.
|
||||
*/
|
||||
.live-controls {
|
||||
position: static;
|
||||
background: transparent;
|
||||
padding: 0 0 8px 0;
|
||||
margin: 0 0 8px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: none;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.live-controls-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 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;
|
||||
@@ -303,22 +387,39 @@
|
||||
background: rgba(59, 130, 246, 0.2) !important;
|
||||
}
|
||||
|
||||
/* ---- Medium breakpoint (#279) ---- */
|
||||
/* ---- Medium breakpoint (#279) + collapse toggles (#1178, #1179) ---- */
|
||||
@media (max-width: 768px) {
|
||||
.live-feed { width: 280px; max-height: 200px; }
|
||||
.live-node-detail { width: 260px; }
|
||||
.live-legend { font-size: 10px; padding: 8px 10px; }
|
||||
.live-header { gap: 8px; padding: 6px 12px; }
|
||||
.live-stat-pill { font-size: 11px; padding: 2px 8px; }
|
||||
.live-header { gap: 6px; padding: 4px 8px; max-height: none; min-height: 48px; }
|
||||
.live-stat-pill { font-size: 11px; padding: 1px 7px; }
|
||||
.live-toggles { font-size: 10px; gap: 6px; }
|
||||
|
||||
/* Show toggle buttons */
|
||||
.live-header-toggle,
|
||||
.live-controls-toggle { display: inline-flex; }
|
||||
|
||||
/* When collapsed, hide the body */
|
||||
.live-header.is-collapsed .live-header-body,
|
||||
.live-controls.is-collapsed .live-controls-body { display: none; }
|
||||
.live-header.is-collapsed { gap: 0; padding: 4px 6px; }
|
||||
.live-controls.is-collapsed { padding: 6px; }
|
||||
|
||||
/* Expanded body on narrow: stack so it never overflows the cluster */
|
||||
.live-controls.is-expanded { max-width: calc(100vw - 24px); }
|
||||
.live-controls.is-expanded .live-controls-body { flex-wrap: wrap; }
|
||||
.live-controls.is-expanded .live-toggles { flex-wrap: wrap; max-height: 50vh; overflow-y: auto; }
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 640px) {
|
||||
.live-feed { display: none !important; }
|
||||
.feed-show-btn { display: none !important; }
|
||||
.live-legend { display: none !important; }
|
||||
.legend-toggle-btn { display: none !important; }
|
||||
/* #1205: legend now hosts the settings toggle row — keep visible on narrow
|
||||
viewports so toggles remain reachable. Users still get the explicit
|
||||
show/hide via #legendToggleBtn. */
|
||||
.live-legend { max-width: calc(100vw - 16px); max-height: 60vh; }
|
||||
.live-header {
|
||||
flex-wrap: wrap; gap: 6px; padding: 6px 10px;
|
||||
top: 56px; left: 8px; right: 8px; max-width: calc(100vw - 16px);
|
||||
@@ -531,6 +632,57 @@
|
||||
}
|
||||
.vcr-btn:hover { background: color-mix(in srgb, var(--text) 18%, transparent); }
|
||||
|
||||
/* #1110 Live page node filter — match toolbar control sizing & theme */
|
||||
.live-node-filter-wrap { position: relative; display: inline-flex; align-items: center; }
|
||||
.live-node-filter-input {
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
font-size: inherit;
|
||||
line-height: 1.3;
|
||||
height: auto;
|
||||
min-width: 140px;
|
||||
outline: none;
|
||||
}
|
||||
.live-node-filter-input:focus {
|
||||
border-color: color-mix(in srgb, var(--text) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
}
|
||||
.live-node-filter-input::placeholder { color: var(--text-muted); opacity: 0.7; }
|
||||
.live-node-filter-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 2px;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
min-width: 200px;
|
||||
}
|
||||
.live-node-filter-dropdown.hidden { display: none; }
|
||||
.live-node-filter-option {
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.live-node-filter-option:hover { background: color-mix(in srgb, var(--text) 12%, transparent); }
|
||||
.live-node-filter-option.live-node-filter-active {
|
||||
background: var(--accent, color-mix(in srgb, var(--text) 25%, transparent));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vcr-live-btn {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--status-red);
|
||||
|
||||
+343
-46
@@ -28,6 +28,52 @@
|
||||
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 || {}; }
|
||||
|
||||
/**
|
||||
* Build observer_id → IATA map from the /api/observers response.
|
||||
* The endpoint returns `{ observers: [...], server_time: "..." }`
|
||||
* (cmd/server/types.go ObserverListResponse). Defensive: also accepts
|
||||
* a bare array in case the API shape ever changes back, and ignores
|
||||
* observers without an IATA. Returns a plain object (used as a hash).
|
||||
* Exported for tests via window._liveBuildObserverIataMap.
|
||||
* Fixes #1136 (regression introduced in #1080 which assumed array shape).
|
||||
*/
|
||||
function buildObserverIataMap(data) {
|
||||
var list = null;
|
||||
if (Array.isArray(data)) list = data;
|
||||
else if (data && Array.isArray(data.observers)) list = data.observers;
|
||||
var m = {};
|
||||
if (!list) return m;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var o = list[i];
|
||||
if (o && o.id != null && o.iata) m[o.id] = o.iata;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null;
|
||||
const propagationBuffer = new Map(); // hash -> {timer, packets[]}
|
||||
let _onResize = null;
|
||||
@@ -814,45 +860,22 @@
|
||||
<div class="live-page">
|
||||
<div id="liveMap" style="width:100%;height:100%;position:absolute;top:0;left:0;z-index:1"></div>
|
||||
<div class="live-overlay live-header" id="liveHeader">
|
||||
<div class="live-title">
|
||||
<span class="live-beacon"></span>
|
||||
MESH LIVE
|
||||
<div class="live-header-critical" data-live-header-critical>
|
||||
<span class="live-beacon" aria-label="WebSocket connection beacon"></span>
|
||||
<div class="live-stat-pill live-stat-pill--critical"><span id="livePktCount">0</span> pkts</div>
|
||||
</div>
|
||||
<div class="live-stats-row">
|
||||
<div class="live-stat-pill"><span id="livePktCount">0</span> pkts</div>
|
||||
<div class="live-stat-pill"><span id="liveNodeCount">0</span> nodes</div>
|
||||
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
|
||||
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
|
||||
</div>
|
||||
<div class="live-toggles">
|
||||
<label><input type="checkbox" id="liveHeatToggle" checked aria-describedby="heatDesc"> Heat</label>
|
||||
<span id="heatDesc" class="sr-only">Overlay a density heat map on the mesh nodes</span>
|
||||
<label><input type="checkbox" id="liveGhostToggle" checked aria-describedby="ghostDesc"> Ghosts</label>
|
||||
<span id="ghostDesc" class="sr-only">Show interpolated ghost markers for unknown hops</span>
|
||||
<label><input type="checkbox" id="liveRealisticToggle" aria-describedby="realisticDesc"> Realistic</label>
|
||||
<span id="realisticDesc" class="sr-only">Buffer packets by hash and animate all paths simultaneously</span>
|
||||
<label><input type="checkbox" id="liveColorHashToggle" aria-describedby="colorHashDesc"> Color by hash</label>
|
||||
<span id="colorHashDesc" class="sr-only">Color flying-packet dots and contrails by packet hash for propagation tracing</span>
|
||||
<label><input type="checkbox" id="liveMatrixToggle" aria-describedby="matrixDesc"> Matrix</label>
|
||||
<span id="matrixDesc" class="sr-only">Animate packet hex bytes flowing along paths like the Matrix</span>
|
||||
<label><input type="checkbox" id="liveMatrixRainToggle" aria-describedby="rainDesc"> Rain</label>
|
||||
<span id="rainDesc" class="sr-only">Matrix rain overlay — packets fall as hex columns</span>
|
||||
<label><input type="checkbox" id="liveAudioToggle" aria-describedby="audioDesc"> 🎵 Audio</label>
|
||||
<span id="audioDesc" class="sr-only">Sonify packets — turn raw bytes into generative music</span>
|
||||
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
|
||||
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
|
||||
<div class="live-node-filter-wrap">
|
||||
<input type="text" id="liveNodeFilterInput" list="liveNodeFilterList" placeholder="Filter by node…" autocomplete="off" class="live-node-filter-input">
|
||||
<datalist id="liveNodeFilterList"></datalist>
|
||||
<button id="liveNodeFilterClear" class="vcr-btn" title="Clear node filter" style="display:none">×</button>
|
||||
<button class="live-header-toggle" data-live-header-toggle id="liveHeaderToggle"
|
||||
aria-expanded="false" aria-controls="liveHeaderBody"
|
||||
aria-label="Show live stats">📊</button>
|
||||
<div class="live-header-body" data-live-header-body id="liveHeaderBody">
|
||||
<div class="live-title">
|
||||
MESH LIVE
|
||||
</div>
|
||||
<div class="live-stats-row">
|
||||
<div class="live-stat-pill"><span id="liveNodeCount">0</span> nodes</div>
|
||||
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
|
||||
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
|
||||
</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>
|
||||
<div class="audio-controls hidden" id="audioControls">
|
||||
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
|
||||
<label class="audio-slider-label">BPM <input type="range" id="audioBpmSlider" min="40" max="300" value="120" class="audio-slider"><span id="audioBpmVal">120</span></label>
|
||||
<label class="audio-slider-label">Vol <input type="range" id="audioVolSlider" min="0" max="100" value="30" class="audio-slider"><span id="audioVolVal">30</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-overlay live-feed" id="liveFeed">
|
||||
@@ -877,6 +900,45 @@
|
||||
<button class="panel-corner-btn" data-panel="liveLegend" title="Move panel to next corner" aria-label="Move panel to next corner">◫</button>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<!-- #1205: settings toggle row re-anchored INSIDE the legend panel -->
|
||||
<div class="live-controls" id="liveControls">
|
||||
<div class="live-controls-body" data-live-controls-body id="liveControlsBody">
|
||||
<div class="live-toggles">
|
||||
<label><input type="checkbox" id="liveHeatToggle" checked aria-describedby="heatDesc"> Heat</label>
|
||||
<span id="heatDesc" class="sr-only">Overlay a density heat map on the mesh nodes</span>
|
||||
<label><input type="checkbox" id="liveGhostToggle" checked aria-describedby="ghostDesc"> Ghosts</label>
|
||||
<span id="ghostDesc" class="sr-only">Show interpolated ghost markers for unknown hops</span>
|
||||
<label><input type="checkbox" id="liveRealisticToggle" aria-describedby="realisticDesc"> Realistic</label>
|
||||
<span id="realisticDesc" class="sr-only">Buffer packets by hash and animate all paths simultaneously</span>
|
||||
<label><input type="checkbox" id="liveColorHashToggle" aria-describedby="colorHashDesc"> Color by hash</label>
|
||||
<span id="colorHashDesc" class="sr-only">Color flying-packet dots and contrails by packet hash for propagation tracing</span>
|
||||
<label><input type="checkbox" id="liveMatrixToggle" aria-describedby="matrixDesc"> Matrix</label>
|
||||
<span id="matrixDesc" class="sr-only">Animate packet hex bytes flowing along paths like the Matrix</span>
|
||||
<label><input type="checkbox" id="liveMatrixRainToggle" aria-describedby="rainDesc"> Rain</label>
|
||||
<span id="rainDesc" class="sr-only">Matrix rain overlay — packets fall as hex columns</span>
|
||||
<label><input type="checkbox" id="liveAudioToggle" aria-describedby="audioDesc"> 🎵 Audio</label>
|
||||
<span id="audioDesc" class="sr-only">Sonify packets — turn raw bytes into generative music</span>
|
||||
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
|
||||
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
|
||||
<div class="live-node-filter-wrap" style="position:relative">
|
||||
<input type="text" id="liveNodeFilterInput" placeholder="Filter by node…" autocomplete="off" class="live-node-filter-input" role="combobox" aria-expanded="false" aria-owns="liveNodeFilterDropdown" aria-autocomplete="list" aria-activedescendant="">
|
||||
<div id="liveNodeFilterDropdown" class="live-node-filter-dropdown hidden" role="listbox"></div>
|
||||
<button id="liveNodeFilterClear" class="vcr-btn" title="Clear node filter" style="display:none">×</button>
|
||||
</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>
|
||||
<label class="audio-slider-label">BPM <input type="range" id="audioBpmSlider" min="40" max="300" value="120" class="audio-slider"><span id="audioBpmVal">120</span></label>
|
||||
<label class="audio-slider-label">Vol <input type="range" id="audioVolSlider" min="0" max="100" value="30" class="audio-slider"><span id="audioVolVal">30</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="live-controls-toggle" data-live-controls-toggle id="liveControlsToggle"
|
||||
aria-expanded="false" aria-controls="liveControlsBody"
|
||||
aria-label="Show live controls">⚙</button>
|
||||
</div>
|
||||
<h3 class="legend-title">PACKET TYPES</h3>
|
||||
<ul class="legend-list">
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.ADVERT}" aria-hidden="true"></span> Advert — Node advertisement</li>
|
||||
@@ -1013,32 +1075,173 @@
|
||||
applyFavoritesFilter();
|
||||
});
|
||||
|
||||
// Node filter input
|
||||
// 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.
|
||||
// /api/observers returns `{observers:[...], server_time:"..."}`
|
||||
// (cmd/server/types.go ObserverListResponse) — NOT a top-level array.
|
||||
// Bug #1136: previously parsed as array → map empty → region filter
|
||||
// dropped every packet.
|
||||
fetch('/api/observers').then(function(r) { return r.json(); }).then(function(data) {
|
||||
setObserverIataMap(buildObserverIataMap(data));
|
||||
}).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 — autocomplete-as-you-type (#1110)
|
||||
const nodeFilterInput = document.getElementById('liveNodeFilterInput');
|
||||
const nodeFilterClear = document.getElementById('liveNodeFilterClear');
|
||||
const nodeFilterDropdown = document.getElementById('liveNodeFilterDropdown');
|
||||
if (nodeFilterInput) {
|
||||
// Restore from URL param or localStorage
|
||||
const urlNode = getHashParams && getHashParams().get('node');
|
||||
if (urlNode) setNodeFilter(urlNode.split(',').map(s => s.trim()).filter(Boolean));
|
||||
else if (nodeFilterKeys.length) updateNodeFilterUI();
|
||||
|
||||
nodeFilterInput.addEventListener('change', (e) => {
|
||||
const val = e.target.value.trim();
|
||||
setNodeFilter(val ? val.split(',').map(s => s.trim()).filter(Boolean) : []);
|
||||
let activeIdx = -1;
|
||||
|
||||
function hideDropdown() {
|
||||
if (!nodeFilterDropdown) return;
|
||||
nodeFilterDropdown.classList.add('hidden');
|
||||
nodeFilterDropdown.innerHTML = '';
|
||||
nodeFilterInput.setAttribute('aria-expanded', 'false');
|
||||
nodeFilterInput.setAttribute('aria-activedescendant', '');
|
||||
activeIdx = -1;
|
||||
}
|
||||
|
||||
function applyFilterFromInput(rawValue) {
|
||||
// Treat input as a single substring query rather than a list of pubkeys.
|
||||
// setNodeFilter accepts pubkeys/prefixes/names; commit raw for live filtering.
|
||||
const val = (rawValue || '').trim();
|
||||
setNodeFilter(val ? [val] : []);
|
||||
// Update URL without triggering hashchange (which would re-init the page).
|
||||
const params = getHashParams ? getHashParams() : new URLSearchParams();
|
||||
if (nodeFilterKeys.length) params.set('node', nodeFilterKeys.join(','));
|
||||
if (val) params.set('node', val);
|
||||
else params.delete('node');
|
||||
const base = location.hash.split('?')[0];
|
||||
const base = location.hash.split('?')[0] || '#/live';
|
||||
const qs = params.toString();
|
||||
location.hash = base + (qs ? '?' + qs : '');
|
||||
const newHash = base + (qs ? '?' + qs : '');
|
||||
const newUrl = location.pathname + location.search + newHash;
|
||||
try { history.replaceState(null, '', newUrl); } catch (_) {}
|
||||
}
|
||||
|
||||
function selectSuggestion(opt) {
|
||||
const key = opt.getAttribute('data-key') || '';
|
||||
const name = opt.getAttribute('data-name') || key;
|
||||
nodeFilterInput.value = name;
|
||||
// Filter by pubkey prefix when available — most precise.
|
||||
setNodeFilter(key ? [key] : (name ? [name] : []));
|
||||
const params = getHashParams ? getHashParams() : new URLSearchParams();
|
||||
if (key) params.set('node', key);
|
||||
else params.delete('node');
|
||||
const base = location.hash.split('?')[0] || '#/live';
|
||||
const qs = params.toString();
|
||||
const newUrl = location.pathname + location.search + base + (qs ? '?' + qs : '');
|
||||
try { history.replaceState(null, '', newUrl); } catch (_) {}
|
||||
hideDropdown();
|
||||
}
|
||||
|
||||
const escapeHtmlLocal = (typeof escapeHtml === 'function') ? escapeHtml : function (s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
};
|
||||
|
||||
async function fetchSuggestions(q) {
|
||||
if (!nodeFilterDropdown) return;
|
||||
if (!q || q.length < 1) { hideDropdown(); return; }
|
||||
try {
|
||||
const resp = await fetch('/api/nodes/search?q=' + encodeURIComponent(q));
|
||||
if (!resp.ok) { hideDropdown(); return; }
|
||||
const data = await resp.json();
|
||||
const nodes = (data && data.nodes) || [];
|
||||
if (!nodes.length) { hideDropdown(); return; }
|
||||
nodeFilterDropdown.innerHTML = nodes.map(function (n, i) {
|
||||
const name = n.name || (n.public_key ? n.public_key.slice(0, 8) : '?');
|
||||
const pkShort = n.public_key ? n.public_key.slice(0, 8) : '';
|
||||
return '<div class="live-node-filter-option" id="liveNodeFilterOpt-' + i +
|
||||
'" role="option" data-key="' + escapeHtmlLocal(n.public_key || '') +
|
||||
'" data-name="' + escapeHtmlLocal(name) + '">' +
|
||||
escapeHtmlLocal(name) +
|
||||
' <span style="color:var(--text-muted);font-size:0.8em">' + escapeHtmlLocal(pkShort) + '</span></div>';
|
||||
}).join('');
|
||||
nodeFilterDropdown.classList.remove('hidden');
|
||||
nodeFilterInput.setAttribute('aria-expanded', 'true');
|
||||
nodeFilterDropdown.querySelectorAll('.live-node-filter-option').forEach(function (opt) {
|
||||
opt.addEventListener('mousedown', function (ev) {
|
||||
// Use mousedown so we run before blur hides the dropdown.
|
||||
ev.preventDefault();
|
||||
selectSuggestion(opt);
|
||||
});
|
||||
});
|
||||
} catch (_) { hideDropdown(); }
|
||||
}
|
||||
|
||||
const debouncedInput = debounce(function (e) {
|
||||
const v = e.target.value.trim();
|
||||
// Apply live filter immediately as user types (no Enter required).
|
||||
applyFilterFromInput(v);
|
||||
fetchSuggestions(v);
|
||||
}, 200);
|
||||
|
||||
nodeFilterInput.addEventListener('input', debouncedInput);
|
||||
|
||||
nodeFilterInput.addEventListener('keydown', function (e) {
|
||||
const opts = nodeFilterDropdown ? nodeFilterDropdown.querySelectorAll('.live-node-filter-option') : [];
|
||||
if (e.key === 'Enter') {
|
||||
// Critical: prevent any default form submission / navigation behavior.
|
||||
e.preventDefault();
|
||||
if (opts.length && activeIdx >= 0 && opts[activeIdx]) {
|
||||
selectSuggestion(opts[activeIdx]);
|
||||
} else {
|
||||
// Just commit current text as a filter and close the dropdown.
|
||||
applyFilterFromInput(nodeFilterInput.value);
|
||||
hideDropdown();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!opts.length || (nodeFilterDropdown && nodeFilterDropdown.classList.contains('hidden'))) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIdx = Math.min(activeIdx + 1, opts.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIdx = Math.max(activeIdx - 1, 0);
|
||||
} else if (e.key === 'Escape') {
|
||||
hideDropdown();
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
opts.forEach(function (o, i) {
|
||||
o.classList.toggle('live-node-filter-active', i === activeIdx);
|
||||
o.setAttribute('aria-selected', i === activeIdx ? 'true' : 'false');
|
||||
});
|
||||
if (activeIdx >= 0 && opts[activeIdx]) {
|
||||
nodeFilterInput.setAttribute('aria-activedescendant', opts[activeIdx].id);
|
||||
opts[activeIdx].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
});
|
||||
|
||||
nodeFilterInput.addEventListener('blur', function () {
|
||||
// Slight delay so click on a suggestion can register first.
|
||||
setTimeout(hideDropdown, 150);
|
||||
});
|
||||
}
|
||||
if (nodeFilterClear) {
|
||||
nodeFilterClear.addEventListener('click', () => {
|
||||
if (nodeFilterInput) nodeFilterInput.value = '';
|
||||
setNodeFilter([]);
|
||||
const base = location.hash.split('?')[0];
|
||||
location.hash = base;
|
||||
// Drop the ?node param without re-running the SPA route handler.
|
||||
const params = getHashParams ? getHashParams() : new URLSearchParams();
|
||||
params.delete('node');
|
||||
const base = location.hash.split('?')[0] || '#/live';
|
||||
const qs = params.toString();
|
||||
const newUrl = location.pathname + location.search + base + (qs ? '?' + qs : '');
|
||||
try { history.replaceState(null, '', newUrl); } catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1194,6 +1397,78 @@
|
||||
// Legend toggle for mobile (#60)
|
||||
const legendEl = document.getElementById('liveLegend');
|
||||
const legendToggleBtn = document.getElementById('legendToggleBtn');
|
||||
|
||||
// ── Live header / controls toggles (#1178, #1179) ──────────────────────
|
||||
// At narrow viewports (≤768px) the header collapses to a single
|
||||
// toggle button revealing the stats body, and the controls collapse
|
||||
// to a single toggle button revealing the toggles list. CSS gates
|
||||
// visibility of the toggle buttons; JS only flips classes and the
|
||||
// hidden attribute. At wide viewports the bodies are always shown.
|
||||
(function wireLiveCollapseToggles() {
|
||||
var pairs = [
|
||||
{ rootId: 'liveHeader', togId: 'liveHeaderToggle', bodyId: 'liveHeaderBody',
|
||||
showLabel: 'Show live stats', hideLabel: 'Hide live stats' },
|
||||
{ rootId: 'liveControls', togId: 'liveControlsToggle', bodyId: 'liveControlsBody',
|
||||
showLabel: 'Show live controls', hideLabel: 'Hide live controls' },
|
||||
];
|
||||
var narrowMql = window.matchMedia('(max-width: 768px)');
|
||||
function setExpanded(p, expanded) {
|
||||
var root = document.getElementById(p.rootId);
|
||||
var tog = document.getElementById(p.togId);
|
||||
var body = document.getElementById(p.bodyId);
|
||||
if (!root || !tog || !body) return;
|
||||
if (expanded) {
|
||||
root.classList.add('is-expanded'); root.classList.remove('is-collapsed');
|
||||
body.removeAttribute('hidden');
|
||||
tog.setAttribute('aria-expanded', 'true');
|
||||
tog.setAttribute('aria-label', p.hideLabel);
|
||||
} else {
|
||||
root.classList.add('is-collapsed'); root.classList.remove('is-expanded');
|
||||
body.setAttribute('hidden', '');
|
||||
tog.setAttribute('aria-expanded', 'false');
|
||||
tog.setAttribute('aria-label', p.showLabel);
|
||||
}
|
||||
}
|
||||
function applyForViewport() {
|
||||
for (var i = 0; i < pairs.length; i++) {
|
||||
var p = pairs[i];
|
||||
if (narrowMql.matches) {
|
||||
// Default collapsed at narrow viewports
|
||||
setExpanded(p, false);
|
||||
} else {
|
||||
// Always expanded; no hidden attr; no collapse class
|
||||
var root = document.getElementById(p.rootId);
|
||||
var body = document.getElementById(p.bodyId);
|
||||
var tog = document.getElementById(p.togId);
|
||||
if (body) body.removeAttribute('hidden');
|
||||
if (root) { root.classList.remove('is-collapsed'); root.classList.remove('is-expanded'); }
|
||||
if (tog) { tog.setAttribute('aria-expanded', 'true'); }
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs.forEach(function (p) {
|
||||
var tog = document.getElementById(p.togId);
|
||||
if (!tog) return;
|
||||
tog.addEventListener('click', function () {
|
||||
var root = document.getElementById(p.rootId);
|
||||
var nowExpanded = !(root && root.classList.contains('is-expanded'));
|
||||
setExpanded(p, nowExpanded);
|
||||
});
|
||||
});
|
||||
applyForViewport();
|
||||
// #1180 — bind once across SPA re-mounts. MQL is process-global per
|
||||
// query string; per-init binds accumulate handlers without bound.
|
||||
if (!_liveNarrowMqlBound) {
|
||||
if (narrowMql.addEventListener) narrowMql.addEventListener('change', applyForViewport);
|
||||
else if (narrowMql.addListener) narrowMql.addListener(applyForViewport);
|
||||
_liveNarrowMqlBound = true;
|
||||
try {
|
||||
window.__liveMQLBindCount = (window.__liveMQLBindCount || 0) + 1;
|
||||
} catch (_) { /* sealed window */ }
|
||||
}
|
||||
})();
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (legendToggleBtn && legendEl) {
|
||||
// Restore legend collapsed state from localStorage (#279)
|
||||
try {
|
||||
@@ -1956,6 +2231,10 @@
|
||||
window._liveIsNodeFavorited = isNodeFavorited;
|
||||
window._livePacketInvolvesFilterNode = packetInvolvesFilterNode;
|
||||
window._liveGetNodeFilterKeys = function() { return nodeFilterKeys; };
|
||||
window._livePacketMatchesRegion = packetMatchesRegion;
|
||||
window._liveSetObserverIataMap = setObserverIataMap;
|
||||
window._liveBuildObserverIataMap = buildObserverIataMap;
|
||||
window._liveGetObserverIataMap = function() { return observerIataMap; };
|
||||
window._liveSetNodeFilter = setNodeFilter;
|
||||
window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml;
|
||||
window._liveResolveHopPositions = resolveHopPositions;
|
||||
@@ -2055,6 +2334,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 +3325,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);
|
||||
@@ -3076,6 +3365,14 @@
|
||||
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
// #1180 — singleton guard for the wireLiveCollapseToggles() narrow-viewport
|
||||
// MQL listener. MediaQueryList is process-global per query string; without
|
||||
// this gate, every SPA re-mount of /live registers a new 'change' handler.
|
||||
// The handler reads from current DOM each time, so a one-shot bind is safe
|
||||
// across re-mounts. window.__liveMQLBindCount is a debug seam consumed by
|
||||
// test-live-mql-leak-1180-e2e.js and otherwise unused.
|
||||
var _liveNarrowMqlBound = false;
|
||||
|
||||
registerPage('live', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => {
|
||||
|
||||
+128
-15
@@ -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 };
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/* nav-drawer.js — Issue #1064 (parent epic #1052)
|
||||
*
|
||||
* Edge-swipe nav drawer. Slide-over from the LEFT edge.
|
||||
*
|
||||
* Design (Option A): drawer is enabled at viewport widths > 768px ONLY.
|
||||
* At ≤768px the bottom-nav has a "More" tab (PR #1174) that surfaces the
|
||||
* same long-tail routes; a left-edge drawer there would compete with it.
|
||||
*
|
||||
* Inputs (Pointer Events only — touch + pen, never mouse):
|
||||
* - pointerdown within the left edge trigger zone [24px, 44px]
|
||||
* (first 24px reserved for iOS Safari back-swipe — Mesh-Op #1184)
|
||||
* - pointermove → drawer translateX follows finger
|
||||
* - pointerup → settle open/closed via velocity
|
||||
* + position threshold
|
||||
*
|
||||
* Singleton + cleanup (mirrors #1180 fix):
|
||||
* - module-scoped `wired` guard so SPA mounts don't re-bind
|
||||
* - document-level pointermove/pointerup listeners registered ONCE
|
||||
* - matchMedia listener registered ONCE
|
||||
* - `window.__navDrawerPointerBindCount` debug seam (E2E asserts ≤ 1)
|
||||
*
|
||||
* Accessibility:
|
||||
* - drawer has `inert` when closed (removed when open) — keyboard +
|
||||
* screen-reader users skip the off-screen tree.
|
||||
* - focus trap: Tab from last focusable wraps to first; Shift+Tab from
|
||||
* first wraps to last.
|
||||
* - Esc closes; backdrop tap closes; tap on a route closes.
|
||||
* - prefers-reduced-motion: instant snap, no transition.
|
||||
*
|
||||
* Public API (also surfaced as `window.__navDrawer` for tests):
|
||||
* open(), close(), toggle(), isOpen()
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// ── Module-scoped singleton state ───────────────────────────────────────
|
||||
var wired = false;
|
||||
var drawerEl = null;
|
||||
var backdropEl = null;
|
||||
var dragging = false;
|
||||
var startX = 0;
|
||||
var startY = 0;
|
||||
var startT = 0;
|
||||
var lastX = 0;
|
||||
var lastT = 0;
|
||||
var drawerWidth = 0;
|
||||
var pointerActive = false;
|
||||
var narrowMql = null;
|
||||
// Element that had focus before the drawer was opened — restored on close
|
||||
// (same regression class as #1168: closing nav UI must return focus to its
|
||||
// trigger so keyboard users don't get dumped at <body>).
|
||||
var prevFocus = null;
|
||||
|
||||
// Long-tail routes mirror PR #1174 / bottom-nav.js MORE_ROUTES exactly.
|
||||
// ⚠️ Keep in sync with public/bottom-nav.js MORE_ROUTES.
|
||||
var ROUTES = [
|
||||
{ route: 'nodes', hash: '#/nodes', label: 'Nodes', icon: '🖥️' },
|
||||
{ route: 'tools', hash: '#/tools', label: 'Tools', icon: '🛠️' },
|
||||
{ route: 'observers', hash: '#/observers', label: 'Observers', icon: '👁️' },
|
||||
{ route: 'analytics', hash: '#/analytics', label: 'Analytics', icon: '📊' },
|
||||
{ route: 'perf', hash: '#/perf', label: 'Perf', icon: '⚡' },
|
||||
{ route: 'audio-lab', hash: '#/audio-lab', label: 'Audio Lab', icon: '🎵' },
|
||||
];
|
||||
|
||||
var EDGE_PX = 44; // pointerdown must start within left N px (drawer trigger zone)
|
||||
var EDGE_MIN_PX = 24; // first N px reserved for iOS Safari back-swipe (do not claim)
|
||||
var NARROW_MAX = 768; // Option A: disabled at ≤ this width
|
||||
var OPEN_THRESHOLD = 0.5; // % of drawer width at which open settles
|
||||
var VELOCITY_OPEN = 0.4; // px/ms — fling-right opens regardless of position
|
||||
var VELOCITY_CLOSE = -0.4; // px/ms — fling-left closes
|
||||
|
||||
function isWide() {
|
||||
// matchMedia is the source of truth; fall back to innerWidth in non-DOM
|
||||
// environments (won't trigger in browser).
|
||||
if (narrowMql && typeof narrowMql.matches === 'boolean') return !narrowMql.matches;
|
||||
return (window.innerWidth || 0) > NARROW_MAX;
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
try {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
} catch (_e) { return false; }
|
||||
}
|
||||
|
||||
// ── DOM construction (idempotent) ───────────────────────────────────────
|
||||
function buildDom() {
|
||||
if (drawerEl && backdropEl) return;
|
||||
|
||||
backdropEl = document.createElement('div');
|
||||
backdropEl.className = 'nav-drawer-backdrop';
|
||||
backdropEl.setAttribute('data-nav-drawer-backdrop', '');
|
||||
backdropEl.hidden = true;
|
||||
backdropEl.addEventListener('click', function () { close(); });
|
||||
|
||||
drawerEl = document.createElement('aside');
|
||||
drawerEl.className = 'nav-drawer';
|
||||
drawerEl.setAttribute('data-nav-drawer', '');
|
||||
drawerEl.setAttribute('role', 'navigation');
|
||||
drawerEl.setAttribute('aria-label', 'Edge-swipe navigation drawer');
|
||||
drawerEl.setAttribute('aria-hidden', 'true');
|
||||
drawerEl.setAttribute('inert', '');
|
||||
drawerEl.tabIndex = -1;
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'nav-drawer-header';
|
||||
var title = document.createElement('span');
|
||||
title.className = 'nav-drawer-title';
|
||||
title.textContent = 'Navigate';
|
||||
var closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'nav-drawer-close';
|
||||
closeBtn.setAttribute('aria-label', 'Close navigation drawer');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', function () { close(); });
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeBtn);
|
||||
drawerEl.appendChild(header);
|
||||
|
||||
var list = document.createElement('nav');
|
||||
list.className = 'nav-drawer-list';
|
||||
ROUTES.forEach(function (r) {
|
||||
var a = document.createElement('a');
|
||||
a.className = 'nav-drawer-item';
|
||||
a.setAttribute('href', r.hash);
|
||||
a.setAttribute('data-nav-drawer-item', r.route);
|
||||
a.setAttribute('data-route', r.route);
|
||||
|
||||
var ic = document.createElement('span');
|
||||
ic.className = 'nav-drawer-icon';
|
||||
ic.setAttribute('aria-hidden', 'true');
|
||||
ic.textContent = r.icon;
|
||||
|
||||
var lb = document.createElement('span');
|
||||
lb.className = 'nav-drawer-label';
|
||||
lb.textContent = r.label;
|
||||
|
||||
a.appendChild(ic);
|
||||
a.appendChild(lb);
|
||||
a.addEventListener('click', function () { close(); });
|
||||
list.appendChild(a);
|
||||
});
|
||||
drawerEl.appendChild(list);
|
||||
|
||||
document.body.appendChild(backdropEl);
|
||||
document.body.appendChild(drawerEl);
|
||||
|
||||
// Defer width measurement until after layout.
|
||||
requestAnimationFrame(function () {
|
||||
drawerWidth = drawerEl.getBoundingClientRect().width || 320;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Open/close primitives ───────────────────────────────────────────────
|
||||
function setTranslate(px) {
|
||||
if (!drawerEl) return;
|
||||
drawerEl.style.transform = 'translateX(' + px + 'px)';
|
||||
}
|
||||
|
||||
function clearInlineTransform() {
|
||||
if (drawerEl) drawerEl.style.transform = '';
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !!(drawerEl && drawerEl.classList.contains('is-open'));
|
||||
}
|
||||
|
||||
function open() {
|
||||
buildDom();
|
||||
if (!isWide()) return; // Option A
|
||||
if (!drawerWidth) drawerWidth = drawerEl.getBoundingClientRect().width || 320;
|
||||
// Capture the previously-focused element BEFORE we move focus, so close()
|
||||
// can restore it. Guard against opening twice (don't overwrite on re-open).
|
||||
if (!isOpen()) {
|
||||
try {
|
||||
var ae = document.activeElement;
|
||||
prevFocus = (ae && ae !== document.body) ? ae : null;
|
||||
} catch (_e) { prevFocus = null; }
|
||||
}
|
||||
drawerEl.classList.add('is-open');
|
||||
drawerEl.removeAttribute('inert');
|
||||
drawerEl.setAttribute('aria-hidden', 'false');
|
||||
backdropEl.hidden = false;
|
||||
backdropEl.classList.add('is-open');
|
||||
clearInlineTransform();
|
||||
// Move focus into the drawer for keyboard users / screen readers.
|
||||
var firstFocusable = drawerEl.querySelector(
|
||||
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input, select, textarea'
|
||||
);
|
||||
if (firstFocusable) {
|
||||
try { firstFocusable.focus({ preventScroll: true }); } catch (_e) { firstFocusable.focus(); }
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!drawerEl) return;
|
||||
var wasOpen = drawerEl.classList.contains('is-open');
|
||||
// Decide whether to restore focus BEFORE applying `inert`. Setting
|
||||
// `inert` synchronously moves document.activeElement to <body>, so any
|
||||
// "is focus inside the drawer?" check after that point is useless.
|
||||
// The right invariant: restore if we were open, prevFocus is still in
|
||||
// the DOM, and it isn't a descendant of the drawer itself.
|
||||
var toRestore = null;
|
||||
if (wasOpen && prevFocus && typeof prevFocus.focus === 'function' &&
|
||||
document.body && document.body.contains(prevFocus) &&
|
||||
!drawerEl.contains(prevFocus)) {
|
||||
toRestore = prevFocus;
|
||||
}
|
||||
prevFocus = null;
|
||||
// Restore FIRST so the upcoming `inert` doesn't bump us to <body>.
|
||||
if (toRestore) {
|
||||
try { toRestore.focus({ preventScroll: true }); }
|
||||
catch (_e) { /* element may be gone after SPA nav — ignore */ }
|
||||
}
|
||||
drawerEl.classList.remove('is-open');
|
||||
drawerEl.setAttribute('inert', '');
|
||||
drawerEl.setAttribute('aria-hidden', 'true');
|
||||
if (backdropEl) {
|
||||
backdropEl.hidden = true;
|
||||
backdropEl.classList.remove('is-open');
|
||||
}
|
||||
clearInlineTransform();
|
||||
}
|
||||
|
||||
function toggle() { if (isOpen()) close(); else open(); }
|
||||
|
||||
// ── Pointer drag-tracking ───────────────────────────────────────────────
|
||||
function onPointerDown(e) {
|
||||
// Mesh-Op review (PR #1184): only respond to touch + pen. Mouse drags
|
||||
// from the left edge must NOT open the drawer (a stray mouse-down at
|
||||
// x<EDGE_PX would otherwise hijack a click). Filter BEFORE any
|
||||
// edge-zone math so the rest of the handler stays touch/pen-only.
|
||||
if (e.pointerType !== 'touch' && e.pointerType !== 'pen') return;
|
||||
if (!isWide()) return;
|
||||
var x = e.clientX;
|
||||
if (isOpen()) {
|
||||
// Allow drag-to-close from anywhere inside drawer's left half.
|
||||
if (!drawerEl) return;
|
||||
var r = drawerEl.getBoundingClientRect();
|
||||
if (x > r.right) return;
|
||||
} else {
|
||||
// Drawer trigger zone: [EDGE_MIN_PX, EDGE_PX]. The first EDGE_MIN_PX
|
||||
// are reserved for iOS Safari's system back-swipe gesture (Mesh-Op
|
||||
// review on #1184); claiming x < 24 collides with the OS gesture and
|
||||
// leaves iPad users with a flaky double-fire.
|
||||
if (x < EDGE_MIN_PX) return;
|
||||
if (x > EDGE_PX) return;
|
||||
}
|
||||
buildDom();
|
||||
if (!drawerWidth) drawerWidth = drawerEl.getBoundingClientRect().width || 320;
|
||||
dragging = true;
|
||||
pointerActive = true;
|
||||
startX = lastX = x;
|
||||
startY = e.clientY;
|
||||
startT = lastT = (e.timeStamp || performance.now());
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!dragging || !pointerActive) return;
|
||||
var x = e.clientX;
|
||||
var y = e.clientY;
|
||||
// If the gesture is mostly vertical near the start, abandon (let scroll win).
|
||||
if (Math.abs(x - startX) < 8 && Math.abs(y - startY) > 12) {
|
||||
dragging = false;
|
||||
pointerActive = false;
|
||||
clearInlineTransform();
|
||||
return;
|
||||
}
|
||||
lastX = x;
|
||||
lastT = (e.timeStamp || performance.now());
|
||||
if (prefersReducedMotion()) return; // no live tracking — settle on up
|
||||
// Compute drawer x-position based on whether we started open or closed.
|
||||
var basis = isOpen() ? 0 : -drawerWidth;
|
||||
var delta = x - startX;
|
||||
var px = Math.max(-drawerWidth, Math.min(0, basis + delta));
|
||||
setTranslate(px);
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
if (!pointerActive) return;
|
||||
pointerActive = false;
|
||||
if (!dragging) { clearInlineTransform(); return; }
|
||||
dragging = false;
|
||||
var x = (e && typeof e.clientX === 'number') ? e.clientX : lastX;
|
||||
var t = (e && e.timeStamp) || performance.now();
|
||||
var dt = Math.max(1, t - startT);
|
||||
var velocity = (x - startX) / dt; // px/ms
|
||||
var openedBefore = isOpen();
|
||||
clearInlineTransform();
|
||||
if (openedBefore) {
|
||||
if (velocity < VELOCITY_CLOSE || (x - startX) < -drawerWidth * OPEN_THRESHOLD) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
} else {
|
||||
if (velocity > VELOCITY_OPEN || (x - startX) > drawerWidth * OPEN_THRESHOLD) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Focus trap ──────────────────────────────────────────────────────────
|
||||
function onKeydown(e) {
|
||||
if (!isOpen()) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab' || !drawerEl) return;
|
||||
var focusables = drawerEl.querySelectorAll(
|
||||
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input, select, textarea'
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
var first = focusables[0];
|
||||
var last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire-up (called once) ───────────────────────────────────────────────
|
||||
function wireOnce() {
|
||||
if (wired) return;
|
||||
wired = true;
|
||||
|
||||
try { narrowMql = window.matchMedia('(max-width: ' + NARROW_MAX + 'px)'); }
|
||||
catch (_e) { narrowMql = null; }
|
||||
|
||||
document.addEventListener('pointerdown', onPointerDown, { passive: true });
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||
document.addEventListener('pointerup', onPointerUp, { passive: true });
|
||||
document.addEventListener('pointercancel', onPointerUp, { passive: true });
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
|
||||
// Close drawer if viewport drops to narrow (Option A).
|
||||
if (narrowMql && typeof narrowMql.addEventListener === 'function') {
|
||||
narrowMql.addEventListener('change', function () { if (!isWide()) close(); });
|
||||
}
|
||||
|
||||
// Debug seam — E2E asserts this ≤ 1 across SPA navs (singleton proof).
|
||||
window.__navDrawerPointerBindCount = (window.__navDrawerPointerBindCount || 0) + 1;
|
||||
}
|
||||
|
||||
function init() {
|
||||
wireOnce();
|
||||
buildDom();
|
||||
}
|
||||
|
||||
// Public API for tests + manual triggers (e.g. a hamburger button).
|
||||
window.__navDrawer = { open: open, close: close, toggle: toggle, isOpen: isOpen };
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -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')) {
|
||||
|
||||
+191
-66
@@ -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 0–1; 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>
|
||||
@@ -517,7 +556,38 @@
|
||||
<tr><td>Hash Prefix</td><td>${n.hash_size ? '<code style="font-family:var(--mono);font-weight:700">' + n.public_key.slice(0, n.hash_size * 2).toUpperCase() + '</code> (' + n.hash_size + '-byte)' : 'Unknown'}${n.hash_size_inconsistent ? ' <span style="color:var(--status-yellow);cursor:help" title="Seen: ' + (Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : []).join(', ') + '-byte">⚠️ varies</span>' : ''}</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="node-full-card skew-detail-section" id="node-clock-skew" style="display:none"></div>
|
||||
<div class="node-full-card" id="node-packets">
|
||||
${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return `
|
||||
<h4>Recent Packets (${validPackets.length})</h4>
|
||||
<div class="node-activity-list">
|
||||
${validPackets.length ? validPackets.map(p => {
|
||||
let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {}
|
||||
const typeLabel = p.payload_type === 4 ? '📡 Advert' : p.payload_type === 5 ? '💬 Channel' : p.payload_type === 2 ? '✉️ DM' : '📦 Packet';
|
||||
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
|
||||
const obs = p.observer_name || p.observer_id;
|
||||
const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
|
||||
const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
|
||||
const obsBadge = p.observation_count > 1 ? ` <span class="badge badge-obs" title="Seen ${p.observation_count} times">👁 ${p.observation_count}</span>` : '';
|
||||
// Show hash size per advert if inconsistent
|
||||
let hashSizeBadge = '';
|
||||
if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
|
||||
const pb = parseInt(p.raw_hex.slice(2, 4), 16);
|
||||
if ((pb & 0x3F) !== 0) {
|
||||
const hs = ((pb >> 6) & 0x3) + 1;
|
||||
const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
|
||||
const hsFg = hs === 2 ? '#064e3b' : '#fff';
|
||||
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
|
||||
}
|
||||
}
|
||||
return `<div class="node-activity-item">
|
||||
<span class="node-activity-time">${renderNodeTimestampHtml(p.timestamp)}</span>
|
||||
<span>${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
|
||||
<a href="#/packets/${p.hash}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze →</a>
|
||||
</div>`;
|
||||
}).join('') : '<div class="text-muted">No recent packets</div>'}
|
||||
</div>
|
||||
`; })()}
|
||||
</div>
|
||||
|
||||
${observers.length ? `<div class="node-full-card" id="node-observers">
|
||||
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:8px"><strong>Regions:</strong> ${regions.map(r => '<span class="badge" style="margin:0 2px">' + escapeHtml(r) + '</span>').join(' ')}</div>` : ''; })()}
|
||||
@@ -559,38 +629,7 @@
|
||||
<div id="fullPathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-full-card" id="node-packets">
|
||||
${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return `
|
||||
<h4>Recent Packets (${validPackets.length})</h4>
|
||||
<div class="node-activity-list">
|
||||
${validPackets.length ? validPackets.map(p => {
|
||||
let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {}
|
||||
const typeLabel = p.payload_type === 4 ? '📡 Advert' : p.payload_type === 5 ? '💬 Channel' : p.payload_type === 2 ? '✉️ DM' : '📦 Packet';
|
||||
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
|
||||
const obs = p.observer_name || p.observer_id;
|
||||
const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
|
||||
const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
|
||||
const obsBadge = p.observation_count > 1 ? ` <span class="badge badge-obs" title="Seen ${p.observation_count} times">👁 ${p.observation_count}</span>` : '';
|
||||
// Show hash size per advert if inconsistent
|
||||
let hashSizeBadge = '';
|
||||
if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
|
||||
const pb = parseInt(p.raw_hex.slice(2, 4), 16);
|
||||
if ((pb & 0x3F) !== 0) {
|
||||
const hs = ((pb >> 6) & 0x3) + 1;
|
||||
const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
|
||||
const hsFg = hs === 2 ? '#064e3b' : '#fff';
|
||||
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
|
||||
}
|
||||
}
|
||||
return `<div class="node-activity-item">
|
||||
<span class="node-activity-time">${renderNodeTimestampHtml(p.timestamp)}</span>
|
||||
<span>${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
|
||||
<a href="#/packets/${p.hash}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze →</a>
|
||||
</div>`;
|
||||
}).join('') : '<div class="text-muted">No recent packets</div>'}
|
||||
</div>
|
||||
`; })()}
|
||||
</div>`;
|
||||
<div class="node-full-card skew-detail-section" id="node-clock-skew" style="display:none"></div>`;
|
||||
|
||||
// Map
|
||||
if (hasLoc) {
|
||||
@@ -803,7 +842,40 @@
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="text-muted" style="padding:40px">Failed to load node: ${e.message}</div>`;
|
||||
// #1150: surface a real error state in BOTH the back-row title and the body
|
||||
// when /api/nodes/{pubkey} returns 404 (or any failure). Otherwise the title
|
||||
// stays "Loading…" forever and there's no link back to the Nodes list.
|
||||
const msg = (e && e.message) || '';
|
||||
const is404 = /\b404\b/.test(msg) || /not\s*found/i.test(msg);
|
||||
const titleEl = document.querySelector('.node-full-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = is404
|
||||
? 'Node not found — ' + (pubkey || '').slice(0, 12) + '…'
|
||||
: 'Failed to load node';
|
||||
}
|
||||
const safePubkey = escapeHtml(pubkey || '');
|
||||
const headline = is404 ? 'Node not found' : 'Failed to load node';
|
||||
const detail = is404
|
||||
? 'No node matched the requested public key on this instance. It may exist on another deployment, or it may have been evicted/blacklisted here.'
|
||||
: 'The node detail API call failed: ' + escapeHtml(msg);
|
||||
body.innerHTML =
|
||||
'<div class="node-full-card" style="padding:24px;margin:16px auto;max-width:560px;text-align:center">' +
|
||||
'<div style="font-size:18px;font-weight:600;margin-bottom:8px">' + headline + '</div>' +
|
||||
'<div class="mono" style="font-size:11px;color:var(--text-muted);word-break:break-all;margin-bottom:12px">' + safePubkey + '</div>' +
|
||||
'<div style="color:var(--text-muted);margin-bottom:16px">' + detail + '</div>' +
|
||||
'<div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">' +
|
||||
'<a href="#/nodes" class="btn-primary" style="text-decoration:none;padding:6px 14px">← Back to Nodes</a>' +
|
||||
'<button id="nodeRetryBtn" class="btn-primary" style="padding:6px 14px">Try again</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
const retryBtn = document.getElementById('nodeRetryBtn');
|
||||
if (retryBtn) {
|
||||
retryBtn.addEventListener('click', function () {
|
||||
if (titleEl) titleEl.textContent = 'Loading…';
|
||||
body.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
loadFullNode(pubkey);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1051,16 +1123,16 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<table class="data-table" id="nodesTable">
|
||||
<div class="table-fluid-wrap"><table class="data-table" id="nodesTable">
|
||||
<thead><tr>
|
||||
<th scope="col" data-sort-key="name">Name</th>
|
||||
<th scope="col" class="col-pubkey" data-sort-key="public_key">Public Key</th>
|
||||
<th scope="col" data-sort-key="role">Role</th>
|
||||
<th scope="col" data-sort-key="last_seen" data-sort-default="desc">Last Seen</th>
|
||||
<th scope="col" data-sort-key="advert_count" data-sort-default="desc">Adverts</th>
|
||||
<th scope="col" data-sort-key="name" data-priority="1">Name</th>
|
||||
<th scope="col" class="col-pubkey" data-sort-key="public_key" data-priority="3">Public Key</th>
|
||||
<th scope="col" data-sort-key="role" data-priority="2">Role</th>
|
||||
<th scope="col" data-sort-key="last_seen" data-sort-default="desc" data-priority="1">Last Seen</th>
|
||||
<th scope="col" data-sort-key="advert_count" data-sort-default="desc" data-priority="2">Adverts</th>
|
||||
</tr></thead>
|
||||
<tbody id="nodesBody"></tbody>
|
||||
</table>`;
|
||||
</table></div>`;
|
||||
|
||||
// Tab clicks
|
||||
const nodeTabs = document.getElementById('nodeTabs');
|
||||
@@ -1091,7 +1163,7 @@
|
||||
defaultColumn: 'last_seen',
|
||||
defaultDirection: 'desc',
|
||||
storageKey: 'meshcore-nodes-sort',
|
||||
onSort: function () { renderRows(); }
|
||||
onSort: function () { renderRows(); updateNodesUrl(); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1194,6 +1266,11 @@
|
||||
}).join('');
|
||||
bindFavStars(tbody);
|
||||
makeColumnsResizable('#nodesTable', 'meshcore-nodes-col-widths');
|
||||
// #1056: fluid columns + +N hidden pill
|
||||
if (window.TableResponsive) {
|
||||
var _ndTbl = document.getElementById('nodesTable');
|
||||
if (_ndTbl) window.TableResponsive.register(_ndTbl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1215,6 +1292,49 @@
|
||||
location.hash = '#/nodes/' + encodeURIComponent(pubkey);
|
||||
return;
|
||||
}
|
||||
// #1056 AC#4: narrow desktop/tablet (641–1023) — open detail in slide-over.
|
||||
if (window.SlideOver && window.SlideOver.shouldUse()) {
|
||||
selectedKey = pubkey;
|
||||
history.replaceState(null, '', '#/nodes/' + encodeURIComponent(pubkey));
|
||||
renderRows();
|
||||
const so = window.SlideOver.open({
|
||||
title: 'Node detail',
|
||||
// Resolver runs after onClose re-renders rows, so look the row up
|
||||
// by data-key after the new tbody is in place.
|
||||
restoreFocus: function () {
|
||||
return document.querySelector('#nodesTable tbody tr[data-key="'
|
||||
+ (window.CSS && CSS.escape ? CSS.escape(pubkey) : pubkey)
|
||||
+ '"]');
|
||||
},
|
||||
onClose: function () {
|
||||
selectedKey = null;
|
||||
history.replaceState(null, '', '#/nodes');
|
||||
renderRows();
|
||||
}
|
||||
});
|
||||
so.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
try {
|
||||
const data = await fetchNodeDetail(pubkey);
|
||||
if (selectedKey !== pubkey) return;
|
||||
const n = (data && data.node) || data || {};
|
||||
const titleEl = document.querySelector('.slide-over-title');
|
||||
if (titleEl) titleEl.textContent = n.advert_name || (n.public_key ? n.public_key.slice(0, 10) : 'Node');
|
||||
var role = (n.role || '').toString();
|
||||
var lastHeard = n.last_heard || n.last_seen;
|
||||
so.innerHTML =
|
||||
'<dl style="margin:0;display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:13px">' +
|
||||
'<dt>Name</dt><dd>' + escapeHtml(n.advert_name || '—') + '</dd>' +
|
||||
'<dt>Role</dt><dd>' + escapeHtml(role || '—') + '</dd>' +
|
||||
'<dt>Public key</dt><dd class="mono" style="word-break:break-all">' + escapeHtml(n.public_key || '—') + '</dd>' +
|
||||
'<dt>Last heard</dt><dd>' + (lastHeard ? timeAgo(lastHeard) : '—') + '</dd>' +
|
||||
'<dt>Adverts</dt><dd>' + (n.advert_count != null ? n.advert_count : '—') + '</dd>' +
|
||||
'</dl>' +
|
||||
'<p style="margin-top:14px"><a class="btn-primary" href="#/nodes/' + encodeURIComponent(pubkey) + '">Open full detail →</a></p>';
|
||||
} catch (e) {
|
||||
so.innerHTML = '<div class="text-muted">Error: ' + (e && e.message ? e.message : String(e)) + '</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectedKey = pubkey;
|
||||
history.replaceState(null, '', '#/nodes/' + encodeURIComponent(pubkey));
|
||||
renderRows();
|
||||
@@ -1282,29 +1402,6 @@
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section skew-detail-section" id="node-clock-skew" style="display:none"></div>
|
||||
|
||||
${observers.length ? `<div class="node-detail-section">
|
||||
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:6px;font-size:12px"><strong>Regions:</strong> ${regions.join(', ')}</div>` : ''; })()}
|
||||
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
|
||||
<div class="observer-list">
|
||||
${observers.map(o => `<div class="observer-row" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' <span class="badge" style="font-size:10px">' + escapeHtml(o.iata) + '</span>' : ''}</span>
|
||||
<span style="color:var(--text-muted)">${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + Number(o.avgSnr).toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + Number(o.avgRssi).toFixed(0) : ''}</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-detail-section" id="panelNeighborsSection">
|
||||
<h4 id="panelNeighborsHeader">Neighbors</h4>
|
||||
<div id="panelNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section" id="pathsSection">
|
||||
<h4>Paths Through This Node</h4>
|
||||
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section">
|
||||
${(() => { const validPackets = adverts.filter(a => a.hash && a.timestamp); return `
|
||||
<h4>Recent Packets (${validPackets.length})</h4>
|
||||
@@ -1330,6 +1427,34 @@
|
||||
</div>
|
||||
`; })()}
|
||||
</div>
|
||||
|
||||
${observers.length ? `<div class="node-detail-section">
|
||||
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:6px;font-size:12px"><strong>Regions:</strong> ${regions.join(', ')}</div>` : ''; })()}
|
||||
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
|
||||
<div class="observer-list">
|
||||
${observers.map(o => {
|
||||
const stats = [`${o.packetCount} pkts`];
|
||||
if (o.avgSnr != null) stats.push('SNR ' + Number(o.avgSnr).toFixed(1) + 'dB');
|
||||
if (o.avgRssi != null) stats.push('RSSI ' + Number(o.avgRssi).toFixed(0));
|
||||
return `<div class="observer-row" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' <span class="badge" style="font-size:10px">' + escapeHtml(o.iata) + '</span>' : ''}</span>
|
||||
<span style="color:var(--text-muted)">${stats.join(' · ')}</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-detail-section" id="panelNeighborsSection">
|
||||
<h4 id="panelNeighborsHeader">Neighbors</h4>
|
||||
<div id="panelNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section" id="pathsSection">
|
||||
<h4>Paths Through This Node</h4>
|
||||
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section skew-detail-section" id="node-clock-skew" style="display:none"></div>
|
||||
</div>`;
|
||||
|
||||
// Init map
|
||||
|
||||
+50
-4
@@ -27,7 +27,16 @@
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
|
||||
var row = e.target.closest('tr[data-action="navigate"]');
|
||||
if (row) location.hash = row.dataset.value;
|
||||
if (row) {
|
||||
// #1056 AC#4: at narrow widths, open detail in slide-over instead of
|
||||
// navigating to a separate page.
|
||||
if (window.SlideOver && window.SlideOver.shouldUse()) {
|
||||
e.preventDefault();
|
||||
openObserverSlideOver(row.dataset.value);
|
||||
return;
|
||||
}
|
||||
location.hash = row.dataset.value;
|
||||
}
|
||||
});
|
||||
// #209 — Keyboard accessibility for observer rows
|
||||
app.addEventListener('keydown', function (e) {
|
||||
@@ -35,6 +44,10 @@
|
||||
if (!row) return;
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
if (window.SlideOver && window.SlideOver.shouldUse()) {
|
||||
openObserverSlideOver(row.dataset.value);
|
||||
return;
|
||||
}
|
||||
location.hash = row.dataset.value;
|
||||
});
|
||||
// Auto-refresh every 30s
|
||||
@@ -140,11 +153,11 @@
|
||||
<span class="obs-stat"><span class="health-dot health-red">✕</span> ${offline} Offline</span>
|
||||
<span class="obs-stat">📡 ${filtered.length} Total</span>
|
||||
</div>
|
||||
<div class="obs-table-scroll"><table class="data-table obs-table" id="obsTable">
|
||||
<div class="obs-table-scroll table-fluid-wrap"><table class="data-table obs-table" id="obsTable">
|
||||
<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" data-priority="1">Status</th><th scope="col" data-priority="1">Name</th><th scope="col" data-priority="3">Region</th><th scope="col" data-priority="2">Last Status</th><th scope="col" data-priority="2">Last Packet</th>
|
||||
<th scope="col" data-priority="3">Packet Health</th><th scope="col" data-priority="4">Total Packets</th><th scope="col" data-priority="3">Packets/Hour</th><th scope="col" data-priority="4">Clock Offset</th><th scope="col" data-priority="4">Uptime</th>
|
||||
</tr></thead>
|
||||
<tbody>${filtered.map(o => {
|
||||
const h = healthStatus(o.last_seen);
|
||||
@@ -169,8 +182,41 @@
|
||||
}).join('')}</tbody>
|
||||
</table></div>`;
|
||||
makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths');
|
||||
// #1056: fluid columns + +N hidden pill
|
||||
if (window.TableResponsive) {
|
||||
var _obsTbl = document.getElementById('obsTable');
|
||||
if (_obsTbl) window.TableResponsive.register(_obsTbl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
registerPage('observers', { init, destroy });
|
||||
|
||||
// #1056 AC#4: row-detail slide-over (narrow viewports). Renders a compact
|
||||
// summary from the in-memory observer + a link to the full page.
|
||||
function openObserverSlideOver(hashHref) {
|
||||
if (!window.SlideOver) return;
|
||||
var m = String(hashHref || '').match(/#\/observers\/(.+)$/);
|
||||
if (!m) return;
|
||||
var id = decodeURIComponent(m[1]);
|
||||
var o = (observers || []).find(function (x) { return String(x.id) === id; });
|
||||
if (!o) return;
|
||||
var h = healthStatus(o.last_seen);
|
||||
var sk = obsSkewMap[o.id];
|
||||
var skewLine = (sk && sk.samples) ? renderSkewBadge(observerSkewSeverity(sk.offsetSec), sk.offsetSec) + ' (' + sk.samples + ' samples)' : '—';
|
||||
var pkts = sparkBar(o.packetsLastHour || 0, Math.max(1, o.packetsLastHour || 1));
|
||||
var content = window.SlideOver.open({ title: o.name || o.id });
|
||||
content.innerHTML =
|
||||
'<dl class="slide-over-dl" style="margin:0;display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:13px">' +
|
||||
'<dt>Status</dt><dd><span class="health-dot ' + h.cls + '">●</span> ' + h.label + '</dd>' +
|
||||
'<dt>Region</dt><dd>' + (o.iata ? '<span class="badge-region">' + o.iata + '</span>' : '—') + '</dd>' +
|
||||
'<dt>Last status</dt><dd>' + timeAgo(o.last_seen) + '</dd>' +
|
||||
'<dt>Last packet</dt><dd>' + (o.last_packet_at ? timeAgo(o.last_packet_at) : '—') + '</dd>' +
|
||||
'<dt>Total packets</dt><dd>' + (o.packet_count || 0).toLocaleString() + '</dd>' +
|
||||
'<dt>Packets/hr</dt><dd>' + pkts + '</dd>' +
|
||||
'<dt>Clock offset</dt><dd>' + skewLine + '</dd>' +
|
||||
'<dt>Uptime</dt><dd>' + uptimeStr(o.first_seen) + '</dd>' +
|
||||
'</dl>' +
|
||||
'<p style="margin-top:14px"><a class="btn-primary" href="' + hashHref + '">Open full detail →</a></p>';
|
||||
}
|
||||
})();
|
||||
|
||||
+229
-14
@@ -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) ─────────────────────────────────────────────
|
||||
|
||||
+719
-45
@@ -1,6 +1,437 @@
|
||||
/* === CoreScope — packets.js === */
|
||||
'use strict';
|
||||
|
||||
/* === #1056: TableResponsive — fluid columns + "+N hidden" pill ============
|
||||
* Tiny helper, defined once, used by packets/nodes/observers tables.
|
||||
*
|
||||
* Usage: TableResponsive.apply(tableEl)
|
||||
*
|
||||
* Each <th> may carry a `data-priority` attribute (1=keep always, higher
|
||||
* numbers = drop first as viewport narrows). Default priority is 1.
|
||||
*
|
||||
* apply() measures the container width and progressively hides the highest-
|
||||
* priority columns (and matching <td>s) until the table's natural scrollWidth
|
||||
* fits, then renders a "+N hidden" pill in the last visible <th>. Click the
|
||||
* pill to reveal all hidden columns until the next layout pass.
|
||||
*
|
||||
* Re-runs on window resize (debounced) and is idempotent — safe to call after
|
||||
* every render. ResizeObserver on the wrapping element also triggers re-fit.
|
||||
*/
|
||||
(function () {
|
||||
if (window.TableResponsive) return;
|
||||
|
||||
const REVEAL_FLAG = '__tr_reveal';
|
||||
const PILL_CLASS = 'col-hidden-pill';
|
||||
const HIDDEN_CLASS = 'col-hidden';
|
||||
|
||||
function thsOf(table) { return Array.from(table.querySelectorAll('thead > tr > th')); }
|
||||
|
||||
function clearHidden(table) {
|
||||
table.querySelectorAll('.' + HIDDEN_CLASS).forEach(el => el.classList.remove(HIDDEN_CLASS));
|
||||
const pill = table.querySelector('.' + PILL_CLASS);
|
||||
if (pill) pill.remove();
|
||||
}
|
||||
|
||||
function colIndexCells(table, idx) {
|
||||
// Return the <td> at column index `idx` for every body row.
|
||||
const out = [];
|
||||
const rows = table.querySelectorAll('tbody > tr');
|
||||
rows.forEach(r => {
|
||||
// colSpan-aware mapping: walk cells, accumulate colspans.
|
||||
let i = 0;
|
||||
for (const cell of r.children) {
|
||||
const span = cell.colSpan || 1;
|
||||
if (i <= idx && idx < i + span) { out.push(cell); break; }
|
||||
i += span;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function apply(table) {
|
||||
if (!table || !table.isConnected) return;
|
||||
if (table[REVEAL_FLAG]) {
|
||||
// user explicitly requested reveal — clear hidden state and skip
|
||||
clearHidden(table);
|
||||
return;
|
||||
}
|
||||
clearHidden(table);
|
||||
|
||||
const ths = thsOf(table);
|
||||
if (ths.length === 0) return;
|
||||
|
||||
// Viewport-breakpoint hiding (per issue #1056 acceptance criteria):
|
||||
// data-priority on each <th>:
|
||||
// 1 → always visible
|
||||
// 2 → hide when viewport ≤ 1280
|
||||
// 3 → hide when viewport ≤ 1024 (per AC #1 wording)
|
||||
// 4 → hide when viewport ≤ 900
|
||||
// 5 → hide when viewport ≤ 768
|
||||
// Higher priority numbers drop FIRST (least important).
|
||||
// Drop direction: a column is hidden if its breakpoint ≥ current viewport.
|
||||
const BP = { 2: 1280, 3: 1024, 4: 900, 5: 768 };
|
||||
const vw = window.innerWidth || document.documentElement.clientWidth;
|
||||
|
||||
const candidates = ths
|
||||
.map((th, i) => ({ th, i, prio: parseInt(th.getAttribute('data-priority') || '1', 10) }))
|
||||
.filter(c => c.prio > 1 && BP[c.prio] !== undefined && vw <= BP[c.prio])
|
||||
// hide highest priority numbers first (drop-first), then right-to-left ties
|
||||
.sort((a, b) => b.prio - a.prio || b.i - a.i);
|
||||
|
||||
let hidden = 0;
|
||||
for (const c of candidates) {
|
||||
c.th.classList.add(HIDDEN_CLASS);
|
||||
colIndexCells(table, c.i).forEach(td => td.classList.add(HIDDEN_CLASS));
|
||||
hidden++;
|
||||
}
|
||||
|
||||
if (hidden > 0) {
|
||||
const visible = ths.filter(th => !th.classList.contains(HIDDEN_CLASS));
|
||||
const host = visible[visible.length - 1] || ths[0];
|
||||
const pill = document.createElement('button');
|
||||
pill.type = 'button';
|
||||
pill.className = PILL_CLASS;
|
||||
pill.textContent = '+' + hidden + ' hidden';
|
||||
pill.title = 'Click to reveal hidden columns';
|
||||
pill.setAttribute('aria-label', hidden + ' columns hidden — click to reveal');
|
||||
pill.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
table[REVEAL_FLAG] = true;
|
||||
clearHidden(table);
|
||||
// Add a small "hide again" affordance after reveal so the user isn't stuck.
|
||||
const rehide = document.createElement('button');
|
||||
rehide.type = 'button';
|
||||
rehide.className = PILL_CLASS + ' col-rehide-pill';
|
||||
rehide.textContent = 'hide';
|
||||
rehide.title = 'Re-hide collapsed columns';
|
||||
rehide.setAttribute('aria-label', 'Re-hide previously collapsed columns');
|
||||
rehide.addEventListener('click', function (ev2) {
|
||||
ev2.stopPropagation();
|
||||
ev2.preventDefault();
|
||||
table[REVEAL_FLAG] = false;
|
||||
apply(table);
|
||||
});
|
||||
rehide.addEventListener('keydown', function (ev2) {
|
||||
// Prevent Enter/Space from bubbling up to TableSort handler on the <th>.
|
||||
if (ev2.key === 'Enter' || ev2.key === ' ') ev2.stopPropagation();
|
||||
});
|
||||
host.appendChild(rehide);
|
||||
});
|
||||
// MAJOR-3: prevent Enter/Space keydown on the pill from bubbling to the
|
||||
// <th>'s TableSort keydown handler (which would also trigger a sort).
|
||||
pill.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') ev.stopPropagation();
|
||||
});
|
||||
host.appendChild(pill);
|
||||
}
|
||||
}
|
||||
|
||||
// Track tables we've wired up so resize triggers re-apply.
|
||||
const wired = new Set();
|
||||
// Track last-seen wrap width per table so we only treat ACTUAL container
|
||||
// resizes as a reason to drop the user's reveal state. Hiding/showing
|
||||
// columns and removing the pill mutate layout and re-trigger ResizeObserver,
|
||||
// which would otherwise immediately stomp on the reveal the user just asked for.
|
||||
const lastWrapW = new WeakMap();
|
||||
function register(table) {
|
||||
if (!table || wired.has(table)) { apply(table); return; }
|
||||
wired.add(table);
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const wrap = table.closest('.table-fluid-wrap, .obs-table-scroll, .table-scroll-wrap') || table.parentElement;
|
||||
if (wrap) {
|
||||
lastWrapW.set(table, wrap.clientWidth || 0);
|
||||
const ro = new ResizeObserver(() => {
|
||||
const prev = lastWrapW.get(table) || 0;
|
||||
const cur = wrap.clientWidth || 0;
|
||||
// Ignore self-induced layout reflows from apply()/clearHidden() —
|
||||
// they don't change the wrap width. Only real viewport/container
|
||||
// changes (>2px) clear the reveal flag.
|
||||
if (Math.abs(cur - prev) <= 2) return;
|
||||
lastWrapW.set(table, cur);
|
||||
table[REVEAL_FLAG] = false;
|
||||
apply(table);
|
||||
});
|
||||
ro.observe(wrap);
|
||||
}
|
||||
}
|
||||
apply(table);
|
||||
}
|
||||
|
||||
let _winTimer = null;
|
||||
window.addEventListener('resize', function () {
|
||||
clearTimeout(_winTimer);
|
||||
_winTimer = setTimeout(() => {
|
||||
wired.forEach(t => {
|
||||
if (!t.isConnected) { wired.delete(t); return; }
|
||||
t[REVEAL_FLAG] = false;
|
||||
apply(t);
|
||||
});
|
||||
}, 120);
|
||||
});
|
||||
|
||||
window.TableResponsive = { apply, register };
|
||||
})();
|
||||
|
||||
/* === #1056 AC#4: SlideOver — narrow-viewport row-detail overlay ============
|
||||
* Singleton backdrop + right-anchored panel injected into <body>. Used by
|
||||
* packets/nodes/observers when window.innerWidth <= SLIDE_OVER_BP (1023,
|
||||
* matching the data-priority="3" breakpoint reused by TableResponsive).
|
||||
*
|
||||
* SlideOver.shouldUse() → boolean (current viewport <= breakpoint)
|
||||
* SlideOver.open(opts) → returns the inner content element. opts:
|
||||
* { title?: string, onClose?: function, restoreFocus?: () => Element|null }
|
||||
* `restoreFocus` (optional) overrides the auto-captured
|
||||
* `document.activeElement` and is invoked at close time to look up the
|
||||
* element to focus. Use this when the caller re-renders the originating
|
||||
* row before/after opening (which would otherwise detach the focused
|
||||
* row from the DOM and leave nothing for auto-restore to find).
|
||||
* SlideOver.close() → close + dispatch onClose
|
||||
* SlideOver.isOpen() → boolean
|
||||
*
|
||||
* Close affordances: X button (.slide-over-close), backdrop click, Escape.
|
||||
* Reuses `slideInRight` keyframe in style.css.
|
||||
*/
|
||||
(function () {
|
||||
if (window.SlideOver) return;
|
||||
|
||||
// #1168 Munger #3: shared, ref-counted scroll-lock helper. Multiple
|
||||
// modal surfaces (SlideOver, ChannelColorPicker, future modals) call
|
||||
// acquire()/release() with their own token; the body keeps the
|
||||
// `scroll-locked` class (CSS supplies overflow:hidden in style.css)
|
||||
// for as long as the count > 0. Last release removes the class.
|
||||
// This replaces the previous capture-and-restore-string approach
|
||||
// which corrupted body.style.overflow under last-writer-wins races.
|
||||
if (!window.__scrollLock) {
|
||||
let count = 0;
|
||||
let next = 1;
|
||||
const live = new Set();
|
||||
function acquire() {
|
||||
const token = next++;
|
||||
live.add(token);
|
||||
count++;
|
||||
if (count === 1) document.body.classList.add('scroll-locked');
|
||||
return token;
|
||||
}
|
||||
function release(token) {
|
||||
if (token == null || !live.has(token)) return;
|
||||
live.delete(token);
|
||||
count--;
|
||||
if (count <= 0) {
|
||||
count = 0;
|
||||
document.body.classList.remove('scroll-locked');
|
||||
}
|
||||
}
|
||||
window.__scrollLock = { acquire: acquire, release: release };
|
||||
}
|
||||
|
||||
const BP = 1023;
|
||||
let backdrop = null, panel = null, content = null, closeCb = null;
|
||||
let prevFocus = null, prevFocusResolver = null;
|
||||
// #1168 Munger #1: openSeq counter so a stale rAF from close() can
|
||||
// detect a newer open() happened in between and skip its focus call.
|
||||
let openSeq = 0;
|
||||
// #1168 Munger #3: ref-counted scroll-lock token held by THIS surface
|
||||
// (multiple SlideOver opens reuse the same token; only paired with a
|
||||
// matching release on close).
|
||||
let scrollLockToken = null;
|
||||
|
||||
function ensureNodes() {
|
||||
if (panel && backdrop) return;
|
||||
backdrop = document.createElement('div');
|
||||
backdrop.className = 'slide-over-backdrop';
|
||||
backdrop.hidden = true;
|
||||
// Backdrop is decorative — assistive tech should not announce it.
|
||||
backdrop.setAttribute('aria-hidden', 'true');
|
||||
backdrop.addEventListener('click', function () { close(); });
|
||||
|
||||
panel = document.createElement('aside');
|
||||
panel.className = 'slide-over-panel';
|
||||
panel.setAttribute('role', 'dialog');
|
||||
panel.setAttribute('aria-modal', 'true');
|
||||
// #1168 must-fix #4: a static aria-label="Detail" would override the
|
||||
// meaningful <h3 id="slideOverTitle"> (e.g. "Packet ab12cd…", node name)
|
||||
// for screen-reader users. Use aria-labelledby so the announced name
|
||||
// is the actual title rendered into the panel.
|
||||
panel.setAttribute('aria-labelledby', 'slideOverTitle');
|
||||
panel.hidden = true;
|
||||
panel.tabIndex = -1;
|
||||
panel.innerHTML =
|
||||
'<div class="slide-over-header">' +
|
||||
'<h3 class="slide-over-title" id="slideOverTitle"></h3>' +
|
||||
'<button type="button" class="slide-over-close" aria-label="Close detail (Esc)" title="Close">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="slide-over-content"></div>';
|
||||
panel.querySelector('.slide-over-close').addEventListener('mousedown', function (e) {
|
||||
// Prevent the X from stealing focus on pointer-press. Without this,
|
||||
// Chromium focuses the button on mousedown → close() runs while X has
|
||||
// focus → hiding the panel triggers an implicit blur to <body> that
|
||||
// races with (and clobbers) our row-focus-restore. With this guard,
|
||||
// the originating row keeps focus throughout the click → the post-
|
||||
// close rAF restore runs unopposed.
|
||||
e.preventDefault();
|
||||
});
|
||||
panel.querySelector('.slide-over-close').addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
});
|
||||
// Focus trap: keep Tab cycling inside the panel while open.
|
||||
panel.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Tab' || !isOpen()) return;
|
||||
const focusables = panel.querySelectorAll(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (!focusables.length) return;
|
||||
const first = focusables[0], last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && (active === first || active === panel)) {
|
||||
e.preventDefault();
|
||||
try { last.focus(); } catch {}
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
try { first.focus(); } catch {}
|
||||
}
|
||||
});
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.appendChild(panel);
|
||||
|
||||
// Single Escape handler shared across all uses.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && isOpen()) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
// #1168 Munger #2: hashchange cleanup. Without this, navigating from
|
||||
// /#/packets to /#/nodes via location.hash leaves panel + backdrop +
|
||||
// scroll-lock dangling across pages. Registered once with the other
|
||||
// singleton listeners.
|
||||
//
|
||||
// Scope: only close on PAGE-route changes (first hash segment), not
|
||||
// on within-page detail navigation. Observers (and others) write
|
||||
// /#/observers/<id> when opening a row; that hashchange must NOT
|
||||
// close the slide-over we just opened.
|
||||
window.addEventListener('hashchange', function (e) {
|
||||
if (!isOpen()) return;
|
||||
function pageOf(hash) {
|
||||
var m = String(hash || '').match(/^#?\/?([^\/?#]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
var oldPage = pageOf(e && e.oldURL ? e.oldURL.split('#')[1] || '' : '');
|
||||
var newPage = pageOf(e && e.newURL ? e.newURL.split('#')[1] || '' : location.hash);
|
||||
if (oldPage !== newPage) close();
|
||||
});
|
||||
}
|
||||
|
||||
function shouldUse() {
|
||||
return (window.innerWidth || document.documentElement.clientWidth) <= BP;
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !!(panel && !panel.hidden);
|
||||
}
|
||||
|
||||
function open(opts) {
|
||||
// If already open, properly close the prior caller first so its onClose
|
||||
// (which clears `selectedKey`/hash state) fires before we replace it.
|
||||
if (isOpen()) close();
|
||||
ensureNodes();
|
||||
opts = opts || {};
|
||||
// #1168 Munger #1: bump open sequence so any pending rAF from a
|
||||
// prior close() can detect that a newer open has happened and skip
|
||||
// its stale focus-restore.
|
||||
openSeq++;
|
||||
closeCb = typeof opts.onClose === 'function' ? opts.onClose : null;
|
||||
// If the caller passes restoreFocus(), it owns lookup at close-time —
|
||||
// useful when the caller re-renders the row table (which would detach
|
||||
// any auto-captured prevFocus DOM node).
|
||||
prevFocusResolver = typeof opts.restoreFocus === 'function' ? opts.restoreFocus : null;
|
||||
// Remember what was focused so we can restore on close.
|
||||
prevFocus = (document.activeElement && document.activeElement !== document.body)
|
||||
? document.activeElement : null;
|
||||
// #1168 Munger #3: ref-counted scroll-lock — class-based, not value-restore.
|
||||
// Survives interleaved lockers (other modals can also acquire/release).
|
||||
if (scrollLockToken == null) {
|
||||
scrollLockToken = window.__scrollLock.acquire();
|
||||
}
|
||||
const title = panel.querySelector('.slide-over-title');
|
||||
title.textContent = opts.title || 'Detail';
|
||||
content = panel.querySelector('.slide-over-content');
|
||||
content.innerHTML = '';
|
||||
backdrop.hidden = false;
|
||||
panel.hidden = false;
|
||||
// Focus the close button so Esc/Enter works without an extra tab.
|
||||
const x = panel.querySelector('.slide-over-close');
|
||||
if (x) try { x.focus(); } catch {}
|
||||
return content;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!panel || panel.hidden) return;
|
||||
panel.hidden = true;
|
||||
if (backdrop) backdrop.hidden = true;
|
||||
// #1168 Munger #3: release the ref-counted scroll-lock token.
|
||||
if (scrollLockToken != null) {
|
||||
window.__scrollLock.release(scrollLockToken);
|
||||
scrollLockToken = null;
|
||||
}
|
||||
const cb = closeCb;
|
||||
closeCb = null;
|
||||
if (content) content.innerHTML = '';
|
||||
// Restore focus to whatever opened us (typically the table row), so
|
||||
// keyboard users don't get dumped at the top of the document.
|
||||
let toFocus = prevFocus;
|
||||
const resolver = prevFocusResolver;
|
||||
prevFocus = null;
|
||||
prevFocusResolver = null;
|
||||
// #1168 Munger #1: capture the open-sequence at close-time. If a NEW
|
||||
// open() happens before our deferred rAF fires, openSeq will have
|
||||
// advanced past this value and the stale rAF must no-op (otherwise
|
||||
// it would steal focus back to row A's originating row AFTER row B
|
||||
// is open — clobbering B's focus).
|
||||
const seqAtClose = openSeq;
|
||||
if (cb) try { cb(); } catch {}
|
||||
// Resolver runs AFTER cb (cb may re-render the table and reattach the row).
|
||||
if (resolver) {
|
||||
try {
|
||||
const resolved = resolver();
|
||||
if (resolved) toFocus = resolved;
|
||||
} catch {}
|
||||
}
|
||||
if (toFocus && typeof toFocus.focus === 'function' && document.body.contains(toFocus)) {
|
||||
// Defer to next microtask + rAF so the focus call lands AFTER any
|
||||
// event-handler bookkeeping (e.g. an Escape keydown chain that would
|
||||
// otherwise see focus snap back to <body> as the key event unwinds).
|
||||
const target = toFocus;
|
||||
const tryFocus = function () {
|
||||
// Munger #1: bail if a newer open() has happened since close-time.
|
||||
if (openSeq !== seqAtClose) return;
|
||||
if (document.body.contains(target)) {
|
||||
try { target.focus(); } catch {}
|
||||
}
|
||||
};
|
||||
tryFocus();
|
||||
requestAnimationFrame(tryFocus);
|
||||
}
|
||||
}
|
||||
|
||||
// If the viewport grows past the breakpoint while open, close the slide-over
|
||||
// so callers can re-route into the wide-viewport side panel.
|
||||
let _resizeT = null;
|
||||
window.addEventListener('resize', function () {
|
||||
if (!isOpen()) return;
|
||||
clearTimeout(_resizeT);
|
||||
_resizeT = setTimeout(function () {
|
||||
if (isOpen() && !shouldUse()) close();
|
||||
}, 120);
|
||||
});
|
||||
|
||||
window.SlideOver = { open: open, close: close, isOpen: isOpen, shouldUse: shouldUse, BP: BP };
|
||||
})();
|
||||
|
||||
|
||||
(function () {
|
||||
let packets = [];
|
||||
let hashIndex = new Map(); // hash → packet group for O(1) dedup
|
||||
@@ -53,12 +484,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 +810,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 +1236,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"
|
||||
@@ -791,33 +1246,25 @@
|
||||
</div>
|
||||
<div class="filter-bar" id="pktFilters">
|
||||
<button class="btn filter-toggle-btn" id="filterToggleBtn">Filters ▾</button>
|
||||
<button class="btn btn-clear-filters" id="clearFiltersBtn" title="Clear all filters" style="display:none;font-size:12px;padding:2px 8px;color:var(--text-muted);border:1px solid var(--border);border-radius:4px;background:transparent;cursor:pointer">✕ Clear</button>
|
||||
<div class="filter-group">
|
||||
<!-- #1124 (MAJOR-3) Group 1: Filter input + Clear -->
|
||||
<div class="filter-group filter-group-clear">
|
||||
<button class="btn btn-clear-filters" id="clearFiltersBtn" title="Clear all filters" style="display:none;font-size:12px;padding:2px 8px;color:var(--text-muted);border:1px solid var(--border);border-radius:4px;background:transparent;cursor:pointer">✕ Clear</button>
|
||||
</div>
|
||||
<!-- Group 2: Quick filters (hash, node name) -->
|
||||
<div class="filter-group filter-group-quick">
|
||||
<input type="text" placeholder="Packet hash…" id="fHash" aria-label="Filter by packet hash" title="Filter packets by hex hash prefix">
|
||||
<div class="node-filter-wrap" style="position:relative">
|
||||
<input type="text" placeholder="Node name…" id="fNode" autocomplete="off" role="combobox" aria-expanded="false" aria-owns="fNodeDropdown" aria-activedescendant="" aria-autocomplete="list" title="Filter packets involving this node (sender or path)">
|
||||
<div class="node-filter-dropdown hidden" id="fNodeDropdown" role="listbox"></div>
|
||||
</div>
|
||||
<div class="multi-select-wrap" id="observerFilterWrap">
|
||||
<button class="multi-select-trigger" id="observerTrigger" title="Show only packets seen by selected observer stations">All Observers ▾</button>
|
||||
<div class="multi-select-menu" id="observerMenu"></div>
|
||||
</div>
|
||||
<div id="packetsRegionFilter" class="region-filter-container" style="display:inline-block;vertical-align:middle"></div>
|
||||
<div class="multi-select-wrap" id="typeFilterWrap">
|
||||
<button class="multi-select-trigger" id="typeTrigger" title="Filter by packet type">All Types ▾</button>
|
||||
<div class="multi-select-menu" id="typeMenu"></div>
|
||||
</div>
|
||||
<div class="filter-group" style="display:inline-flex;align-items:center;gap:4px">
|
||||
<select id="fChannel" class="filter-select" aria-label="Filter by channel" title="Filter Channel Messages (GRP_TXT) by channel">
|
||||
<option value="">All Channels</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<!-- Group 3: Quick toggles (time range, Group by Hash, ★ My Nodes)
|
||||
— #1128 Bug 5: placed BEFORE the Observer/Region/Type/Channel
|
||||
dropdowns so the most-frequently-used controls sit next to
|
||||
the search input where the eye lands first. -->
|
||||
<div class="filter-group filter-group-toggles">
|
||||
<button class="btn ${groupByHash ? 'active' : ''}" id="fGroup" title="Collapse duplicate observations of the same packet into expandable groups">Group by Hash</button>
|
||||
<button class="btn" id="fMyNodes" title="Show only packets from your favorited/claimed nodes">★ My Nodes</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<select id="fTimeWindow" class="filter-select" aria-label="Time window filter">
|
||||
<option value="15">Last 15 min</option>
|
||||
<option value="30">Last 30 min</option>
|
||||
@@ -829,7 +1276,23 @@
|
||||
${isMobile ? '' : '<option value="0">All time</option>'}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<!-- Group 4: Dropdowns (observers, regions, types, channels) -->
|
||||
<div class="filter-group filter-group-dropdowns">
|
||||
<div class="multi-select-wrap" id="observerFilterWrap">
|
||||
<button class="multi-select-trigger" id="observerTrigger" title="Show only packets seen by selected observer stations">All Observers ▾</button>
|
||||
<div class="multi-select-menu" id="observerMenu"></div>
|
||||
</div>
|
||||
<div id="packetsRegionFilter" class="region-filter-container" style="display:inline-block;vertical-align:middle"></div>
|
||||
<div class="multi-select-wrap" id="typeFilterWrap">
|
||||
<button class="multi-select-trigger" id="typeTrigger" title="Filter by packet type">All Types ▾</button>
|
||||
<div class="multi-select-menu" id="typeMenu"></div>
|
||||
</div>
|
||||
<select id="fChannel" class="filter-select" aria-label="Filter by channel" title="Filter Channel Messages (GRP_TXT) by channel">
|
||||
<option value="">All Channels</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Group 5: Sort + Columns -->
|
||||
<div class="filter-group filter-group-sort">
|
||||
<select id="fObsSort" aria-label="Observation sort order" title="Controls how observations are ordered within packet groups and which observation appears in the header row. Observer: Groups by observer station, earliest first. Path: Orders by hop count. Time: Orders by observation timestamp.">
|
||||
<option value="observer">Sort: Observer</option>
|
||||
<option value="path-asc">Sort: Path ↑ (shortest)</option>
|
||||
@@ -837,9 +1300,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>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="sort-help" id="sortHelpIcon" tabindex="0" role="button" aria-label="Sort help">ⓘ</span>
|
||||
<div class="col-toggle-wrap">
|
||||
<button class="col-toggle-btn" id="colToggleBtn" title="Show/hide table columns">Columns ▾</button>
|
||||
<div class="col-toggle-menu" id="colToggleMenu"></div>
|
||||
@@ -847,14 +1308,14 @@
|
||||
<button class="btn btn-icon${showHexHashes ? ' active' : ''}" id="hexHashToggle" title="Show raw hex hash prefixes instead of resolved node names in the path column">Hex Paths</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="data-table" id="pktTable">
|
||||
<div class="table-fluid-wrap"><table class="data-table" id="pktTable">
|
||||
<thead><tr>
|
||||
<th scope="col"></th><th scope="col" class="col-region" data-sort-key="region">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date">Time</th><th scope="col" class="col-hash" data-sort-key="hash">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric">Size</th>
|
||||
<th scope="col" class="col-hashsize" data-sort-key="hb" data-type="numeric">HB</th>
|
||||
<th scope="col" class="col-type" data-sort-key="type">Type</th><th scope="col" class="col-observer" data-sort-key="observer">Observer</th><th scope="col" class="col-path" data-sort-key="path">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric">Rpt</th><th scope="col" class="col-details">Details</th>
|
||||
<th scope="col" data-priority="1"></th><th scope="col" class="col-region" data-sort-key="region" data-priority="3">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date" data-priority="1">Time</th><th scope="col" class="col-hash" data-sort-key="hash" data-priority="1">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric" data-priority="4">Size</th>
|
||||
<th scope="col" class="col-hashsize" data-sort-key="hb" data-type="numeric" data-priority="5">HB</th>
|
||||
<th scope="col" class="col-type" data-sort-key="type" data-priority="1">Type</th><th scope="col" class="col-observer" data-sort-key="observer" data-priority="3">Observer</th><th scope="col" class="col-path" data-sort-key="path" data-priority="2">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric" data-priority="4">Rpt</th><th scope="col" class="col-details" data-priority="2">Details</th>
|
||||
</tr></thead>
|
||||
<tbody id="pktBody"></tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
`;
|
||||
|
||||
// Init shared RegionFilter component
|
||||
@@ -916,6 +1377,14 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// 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();
|
||||
}
|
||||
// #1124 (MAJOR-1): wire the path overflow popover (delegated; idempotent).
|
||||
_wirePathOverflowPopover();
|
||||
|
||||
// --- Observer multi-select ---
|
||||
const obsMenu = document.getElementById('observerMenu');
|
||||
const obsTrigger = document.getElementById('observerTrigger');
|
||||
@@ -974,13 +1443,20 @@
|
||||
}
|
||||
function updateTypeTrigger() {
|
||||
const total = Object.keys(typeMap).length;
|
||||
// #1128 (Bug 3): trigger has bounded max-width so long selections like
|
||||
// "TRACE,MULTIPART,GRP_TXT" get ellipsised. Always set the full label
|
||||
// as the `title` attribute so the user can recover it via tooltip.
|
||||
const fullList = [...selectedTypes].map(k => typeMap[k] || k).join(', ');
|
||||
if (selectedTypes.size === 0 || selectedTypes.size === total) {
|
||||
typeTrigger.textContent = 'All Types ▾';
|
||||
typeTrigger.title = 'Filter by packet type';
|
||||
} else if (selectedTypes.size === 1) {
|
||||
const k = [...selectedTypes][0];
|
||||
typeTrigger.textContent = (typeMap[k] || k) + ' ▾';
|
||||
typeTrigger.title = 'Selected: ' + fullList;
|
||||
} else {
|
||||
typeTrigger.textContent = selectedTypes.size + ' Types ▾';
|
||||
typeTrigger.title = 'Selected: ' + fullList;
|
||||
}
|
||||
}
|
||||
buildTypeMenu();
|
||||
@@ -1377,6 +1853,12 @@
|
||||
|
||||
renderTableRows();
|
||||
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
|
||||
// #1056: register fluid-column responsive behavior (drops priority>1 cols
|
||||
// when narrow, shows "+N hidden" pill, reveals on click). Idempotent.
|
||||
if (window.TableResponsive) {
|
||||
var _pktTbl = document.getElementById('pktTable');
|
||||
if (_pktTbl) window.TableResponsive.register(_pktTbl);
|
||||
}
|
||||
|
||||
// Initialize table sorting (virtual scroll — sort data array, not DOM)
|
||||
if (window.TableSort) {
|
||||
@@ -1393,6 +1875,7 @@
|
||||
_packetSortDirection = direction;
|
||||
sortPacketsArray();
|
||||
renderTableRows();
|
||||
updatePacketsUrl();
|
||||
}
|
||||
});
|
||||
// Apply initial sort state from TableSort
|
||||
@@ -1436,11 +1919,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 +1945,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 +1977,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>
|
||||
@@ -1574,11 +2057,17 @@
|
||||
if (!topSpacer) {
|
||||
topSpacer = document.createElement('tr');
|
||||
topSpacer.id = 'vscroll-top';
|
||||
// aria-hidden + visibility:hidden so Playwright/AT treat the sentinel as invisible
|
||||
// while preserving its layout role (the inner <td> height drives virtual-scroll padding).
|
||||
topSpacer.setAttribute('aria-hidden', 'true');
|
||||
topSpacer.style.visibility = 'hidden';
|
||||
topSpacer.innerHTML = '<td colspan="' + colCount + '" style="padding:0;border:0"></td>';
|
||||
}
|
||||
if (!bottomSpacer) {
|
||||
bottomSpacer = document.createElement('tr');
|
||||
bottomSpacer.id = 'vscroll-bottom';
|
||||
bottomSpacer.setAttribute('aria-hidden', 'true');
|
||||
bottomSpacer.style.visibility = 'hidden';
|
||||
bottomSpacer.innerHTML = '<td colspan="' + colCount + '" style="padding:0;border:0"></td>';
|
||||
}
|
||||
|
||||
@@ -1632,6 +2121,13 @@
|
||||
}
|
||||
}
|
||||
if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: full rebuild %d entries, %.2fms', endIdx - startIdx, performance.now() - _rvr_t0);
|
||||
_finalizePathOverflow(tbody);
|
||||
// #1128 (Bug 1): hop-resolver mutates chip text from hex prefix to a
|
||||
// longer node name AFTER the initial finalize pass — chips that fit at
|
||||
// first measurement overflow once names resolve, but no `+N` pill gets
|
||||
// appended. Cheapest correct fix: re-measure on a delayed pass, after
|
||||
// clearing the per-host `overflowChecked` guard so the recheck runs.
|
||||
_scheduleReFinalizePathOverflow(tbody);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1665,6 +2161,150 @@
|
||||
bottomSpacer.insertAdjacentHTML('beforebegin', html);
|
||||
}
|
||||
if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: incremental head=%d tail=%d, %.2fms', headRowCount, tailRowCount, performance.now() - _rvr_t0);
|
||||
_finalizePathOverflow(tbody);
|
||||
_scheduleReFinalizePathOverflow(tbody);
|
||||
}
|
||||
|
||||
// #1124 (MAJOR-1): when path chips overflow `.path-hops` (capped at 22px /
|
||||
// overflow:hidden in CSS), append a `<span class="path-overflow-pill">+N</span>`
|
||||
// showing how many hops are hidden. Click opens a popover listing all hops.
|
||||
function _finalizePathOverflow(tbody) {
|
||||
if (!tbody) return;
|
||||
var hosts = tbody.querySelectorAll('.path-hops');
|
||||
for (var i = 0; i < hosts.length; i++) {
|
||||
var host = hosts[i];
|
||||
// Skip if already finalized for this content
|
||||
if (host.dataset.overflowChecked === '1') continue;
|
||||
var children = Array.prototype.slice.call(host.children);
|
||||
// Strip any leftover pill before measuring
|
||||
var existingPill = host.querySelector('.path-overflow-pill');
|
||||
if (existingPill) existingPill.remove();
|
||||
var hostRight = host.getBoundingClientRect().right;
|
||||
if (!hostRight) continue;
|
||||
var hidden = 0;
|
||||
// Walk pairs of chip + arrow; count chips (not arrows) whose right edge
|
||||
// is past the host's right edge.
|
||||
for (var j = 0; j < children.length; j++) {
|
||||
var ch = children[j];
|
||||
if (ch.classList.contains('arrow')) continue;
|
||||
var r = ch.getBoundingClientRect();
|
||||
if (r.left >= hostRight || r.right > hostRight + 0.5) hidden++;
|
||||
}
|
||||
if (hidden > 0) {
|
||||
var pill = document.createElement('span');
|
||||
pill.className = 'path-overflow-pill';
|
||||
pill.textContent = '+' + hidden;
|
||||
pill.title = hidden + ' more hop' + (hidden === 1 ? '' : 's') + ' — click to view';
|
||||
pill.setAttribute('role', 'button');
|
||||
pill.setAttribute('tabindex', '0');
|
||||
pill.setAttribute('aria-label', hidden + ' more hops');
|
||||
host.appendChild(pill);
|
||||
}
|
||||
host.dataset.overflowChecked = '1';
|
||||
}
|
||||
}
|
||||
|
||||
// #1128 (Bug 1): re-run overflow finalize after hop-resolver async pass has
|
||||
// had a chance to mutate chip text. Per-tbody so concurrent renders in
|
||||
// different tbodies don't cancel each other (#1131 BLOCKER-2). Uses a
|
||||
// MutationObserver bonded to the tbody to detect when hop-resolver finishes
|
||||
// mutating .path-hops chip text, then runs finalize once mutations settle
|
||||
// for 50ms — replaces the previous 120ms blind timeout, which regressed on
|
||||
// slow networks where the resolver took longer than 120ms (#1131 MAJOR-1).
|
||||
function _scheduleReFinalizePathOverflow(tbody) {
|
||||
if (!tbody) return;
|
||||
// If a quiesce timer is already armed for this tbody, leave it; new
|
||||
// mutations will keep extending it. If an observer is already wired,
|
||||
// we're done — it'll fire again on the next mutation.
|
||||
if (tbody._rePathOverflowObserver) return;
|
||||
var quiesceTimer = null;
|
||||
var stopTimer = null;
|
||||
function finalize() {
|
||||
if (tbody._rePathOverflowObserver) {
|
||||
try { tbody._rePathOverflowObserver.disconnect(); } catch (_e) {}
|
||||
tbody._rePathOverflowObserver = null;
|
||||
}
|
||||
if (stopTimer) { clearTimeout(stopTimer); stopTimer = null; }
|
||||
var hosts = tbody.querySelectorAll('.path-hops');
|
||||
for (var i = 0; i < hosts.length; i++) hosts[i].dataset.overflowChecked = '';
|
||||
_finalizePathOverflow(tbody);
|
||||
}
|
||||
if (typeof MutationObserver === 'function') {
|
||||
var obs = new MutationObserver(function () {
|
||||
if (quiesceTimer) clearTimeout(quiesceTimer);
|
||||
quiesceTimer = setTimeout(finalize, 50);
|
||||
});
|
||||
obs.observe(tbody, { subtree: true, childList: true, characterData: true });
|
||||
tbody._rePathOverflowObserver = obs;
|
||||
// Hard upper bound — if hop-resolver never mutates (e.g. all chips
|
||||
// already final), still run finalize once after a short delay so the
|
||||
// overflow pill appears.
|
||||
stopTimer = setTimeout(finalize, 1000);
|
||||
} else {
|
||||
// Fallback for environments without MutationObserver.
|
||||
setTimeout(finalize, 120);
|
||||
}
|
||||
}
|
||||
|
||||
// Delegated click for path overflow pills — show popover of full path.
|
||||
function _wirePathOverflowPopover() {
|
||||
if (window.__pathOverflowWired) return;
|
||||
window.__pathOverflowWired = true;
|
||||
var existing = null;
|
||||
function dismiss() {
|
||||
if (existing) { existing.remove(); existing = null; }
|
||||
document.removeEventListener('mousedown', onDoc, true);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
}
|
||||
function onDoc(ev) {
|
||||
if (existing && !existing.contains(ev.target) && !ev.target.classList.contains('path-overflow-pill')) dismiss();
|
||||
}
|
||||
function onKey(ev) { if (ev.key === 'Escape') dismiss(); }
|
||||
document.addEventListener('click', function(ev) {
|
||||
var pill = ev.target.closest && ev.target.closest('.path-overflow-pill');
|
||||
if (!pill) return;
|
||||
ev.stopPropagation();
|
||||
var host = pill.closest('.path-hops');
|
||||
if (!host) return;
|
||||
dismiss();
|
||||
var pop = document.createElement('div');
|
||||
pop.className = 'path-popover';
|
||||
// Clone all children except the pill, preserving rendered chips/arrows.
|
||||
var inner = '<div class="path-popover-title">Full path (' + (host.children.length) + ' items)</div><div>';
|
||||
var kids = Array.prototype.slice.call(host.children);
|
||||
for (var i = 0; i < kids.length; i++) {
|
||||
if (kids[i].classList.contains('path-overflow-pill')) continue;
|
||||
inner += kids[i].outerHTML;
|
||||
}
|
||||
inner += '</div>';
|
||||
pop.innerHTML = inner;
|
||||
document.body.appendChild(pop);
|
||||
var r = pill.getBoundingClientRect();
|
||||
// #1128 (Bug 2): position below by default, but flip ABOVE when there
|
||||
// isn't enough room — keeps the popover anchored to the pill instead of
|
||||
// hanging arbitrarily over adjacent rows / off-screen.
|
||||
var pr0 = pop.getBoundingClientRect();
|
||||
var popH = pr0.height;
|
||||
var roomBelow = window.innerHeight - r.bottom;
|
||||
var top;
|
||||
if (roomBelow < popH + 12 && r.top > popH + 12) {
|
||||
top = window.scrollY + r.top - popH - 4;
|
||||
} else {
|
||||
top = window.scrollY + r.bottom + 4;
|
||||
}
|
||||
var left = window.scrollX + r.left;
|
||||
pop.style.top = top + 'px';
|
||||
pop.style.left = left + 'px';
|
||||
var pr = pop.getBoundingClientRect();
|
||||
if (pr.right > window.innerWidth - 8) {
|
||||
pop.style.left = Math.max(8, window.scrollX + window.innerWidth - pr.width - 8) + 'px';
|
||||
}
|
||||
existing = pop;
|
||||
setTimeout(function() {
|
||||
document.addEventListener('mousedown', onDoc, true);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach/detach scroll listener for virtual scrolling
|
||||
@@ -1887,8 +2527,42 @@
|
||||
}
|
||||
renderTableRows();
|
||||
const isMobileNow = window.innerWidth <= 640;
|
||||
// #1168 review note: this branch is intentionally narrower than nodes.js /
|
||||
// observers.js. On packets, ≤640 falls through to the legacy mobile bottom
|
||||
// sheet (`isMobileNow` short-circuits before SlideOver), and SlideOver is
|
||||
// used only for the 641–1023 range. nodes.js and observers.js route into
|
||||
// SlideOver across the full ≤1023 range. Both satisfy AC#4 ("not a
|
||||
// separate page"); the per-page split is deliberate — the packets table
|
||||
// has heavier per-row affordances (hex breakdown, observations grid)
|
||||
// that the bottom sheet handles better at very narrow widths than a
|
||||
// side-anchored slide-over. Do NOT "fix" the inconsistency without
|
||||
// discussing with the issue author.
|
||||
const useSlideOver = !isMobileNow && window.SlideOver && window.SlideOver.shouldUse();
|
||||
let panel;
|
||||
if (isMobileNow) {
|
||||
if (useSlideOver) {
|
||||
// #1056 AC#4: narrow viewports (641–1023) — open detail in slide-over
|
||||
// overlay rather than the side panel.
|
||||
panel = window.SlideOver.open({
|
||||
title: hash ? ('Packet ' + String(hash).slice(0, 12)) : 'Packet detail',
|
||||
// After close, the rows are re-rendered (see onClose). Use a resolver
|
||||
// to look up the originating row in the post-render DOM by data-hash
|
||||
// / data-id, so keyboard focus restores to the actual table row.
|
||||
restoreFocus: function () {
|
||||
const lookup = hash || id;
|
||||
if (!lookup) return null;
|
||||
const esc = (window.CSS && CSS.escape) ? CSS.escape(String(lookup)) : String(lookup);
|
||||
return document.querySelector('#pktTable tbody tr[data-hash="' + esc + '"]')
|
||||
|| document.querySelector('#pktTable tbody tr[data-id="' + esc + '"]');
|
||||
},
|
||||
onClose: function () {
|
||||
selectedId = null;
|
||||
selectedObservationId = null;
|
||||
history.replaceState(null, '', '#/packets');
|
||||
renderTableRows();
|
||||
}
|
||||
});
|
||||
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
} else if (isMobileNow) {
|
||||
// Use mobile bottom sheet
|
||||
let sheet = document.getElementById('mobileDetailSheet');
|
||||
if (!sheet) {
|
||||
@@ -1925,11 +2599,11 @@
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
} catch {}
|
||||
panel.innerHTML = isMobileNow ? '' : '<div class="panel-resize-handle" id="pktResizeHandle"></div>' + PANEL_CLOSE_HTML;
|
||||
panel.innerHTML = isMobileNow ? '' : (useSlideOver ? '' : ('<div class="panel-resize-handle" id="pktResizeHandle"></div>' + PANEL_CLOSE_HTML));
|
||||
const content = document.createElement('div');
|
||||
panel.appendChild(content);
|
||||
await renderDetail(content, data, selectedObservationId);
|
||||
if (!isMobileNow) initPanelResize();
|
||||
if (!isMobileNow && !useSlideOver) initPanelResize();
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
|
||||
}
|
||||
|
||||
+110
-2
@@ -13,9 +13,12 @@
|
||||
const el = document.getElementById('perfContent');
|
||||
if (!el) return;
|
||||
try {
|
||||
const [server, client] = await Promise.all([
|
||||
const [server, client, ioStats, sqliteStats, writeSources] = await Promise.all([
|
||||
fetch('/api/perf').then(r => r.json()),
|
||||
Promise.resolve(window.apiPerf ? window.apiPerf() : null)
|
||||
Promise.resolve(window.apiPerf ? window.apiPerf() : null),
|
||||
fetch('/api/perf/io').then(r => r.json()).catch(() => null),
|
||||
fetch('/api/perf/sqlite').then(r => r.json()).catch(() => null),
|
||||
fetch('/api/perf/write-sources').then(r => r.json()).catch(() => null)
|
||||
]);
|
||||
|
||||
// Also fetch health telemetry
|
||||
@@ -64,6 +67,111 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Disk I/O (#1120)
|
||||
if (ioStats) {
|
||||
const fmtRate = (bps) => {
|
||||
if (bps >= 1048576) return (bps / 1048576).toFixed(1) + ' MB/s';
|
||||
if (bps >= 1024) return (bps / 1024).toFixed(1) + ' KB/s';
|
||||
return Math.round(bps) + ' B/s';
|
||||
};
|
||||
const writeWarn = ioStats.writeBytesPerSec > 10 * 1048576 ? ' ⚠️' : '';
|
||||
const cancelled = ioStats.cancelledWriteBytesPerSec || 0;
|
||||
// Cancelled writes warn at >1 MB/s — sustained cancellation usually
|
||||
// means truncate/unlink racing with active writers (#1119-shaped bug).
|
||||
const cancelledWarn = cancelled > 1048576 ? ' ⚠️' : '';
|
||||
html += `<h3>Disk I/O (server process)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${fmtRate(ioStats.readBytesPerSec || 0)}</div><div class="perf-label">Read</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${fmtRate(ioStats.writeBytesPerSec || 0)}${writeWarn}</div><div class="perf-label">Write</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${fmtRate(cancelled)}${cancelledWarn}</div><div class="perf-label">Cancelled Write</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${Math.round(ioStats.syscallsRead || 0)}/s</div><div class="perf-label">Syscalls Read</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${Math.round(ioStats.syscallsWrite || 0)}/s</div><div class="perf-label">Syscalls Write</div></div>
|
||||
</div>`;
|
||||
|
||||
// Ingestor row — sourced from ingestor's own /proc/self/io snapshot
|
||||
// surfaced via the stats file (#1120: "Both ingestor and server").
|
||||
if (ioStats.ingestor) {
|
||||
const ing = ioStats.ingestor;
|
||||
const ingWriteWarn = (ing.writeBytesPerSec || 0) > 10 * 1048576 ? ' ⚠️' : '';
|
||||
const ingCancelled = ing.cancelledWriteBytesPerSec || 0;
|
||||
const ingCancelledWarn = ingCancelled > 1048576 ? ' ⚠️' : '';
|
||||
html += `<h3>Disk I/O (Ingestor process)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${fmtRate(ing.readBytesPerSec || 0)}</div><div class="perf-label">Read</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${fmtRate(ing.writeBytesPerSec || 0)}${ingWriteWarn}</div><div class="perf-label">Write</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${fmtRate(ingCancelled)}${ingCancelledWarn}</div><div class="perf-label">Cancelled Write</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${Math.round(ing.syscallsRead || 0)}/s</div><div class="perf-label">Syscalls Read</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${Math.round(ing.syscallsWrite || 0)}/s</div><div class="perf-label">Syscalls Write</div></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Write Sources (#1120) — per-component counters from ingestor
|
||||
if (writeSources && writeSources.sources) {
|
||||
const src = writeSources.sources;
|
||||
const keys = Object.keys(src).sort((a, b) => (src[b] || 0) - (src[a] || 0));
|
||||
html += '<h3>Write Sources</h3>';
|
||||
if (keys.length === 0) {
|
||||
html += '<p style="color:var(--text-muted)">No ingestor stats yet (waiting for /tmp/corescope-ingestor-stats.json)</p>';
|
||||
} else {
|
||||
// Anomaly detection (#1123 polish):
|
||||
// Compare PER-SECOND DELTA RATES, not cumulative counts.
|
||||
// Cumulative-vs-cumulative was a tautology that fired ⚠️ at startup
|
||||
// (any backfill_* > 10 when tx_inserted=0 → baseline collapses to 1)
|
||||
// and false-cleared once tx grew past a one-shot backfill burst.
|
||||
// Now we cache the previous snapshot + sampleAt and only fire when:
|
||||
// 1) we have a real interval (≥ 0.5s) to compute deltas against
|
||||
// 2) tx_inserted has crossed MIN_SAMPLE so the baseline is meaningful
|
||||
// 3) the per-second backfill rate exceeds 10× the per-second tx rate
|
||||
const MIN_SAMPLE = 100;
|
||||
const prev = window._perfWriteSourcesPrev;
|
||||
let prevSrc = null, dtSec = 0;
|
||||
if (prev && prev.sampleAt && writeSources.sampleAt) {
|
||||
dtSec = (Date.parse(writeSources.sampleAt) - Date.parse(prev.sampleAt)) / 1000;
|
||||
if (dtSec >= 0.5) prevSrc = prev.sources;
|
||||
}
|
||||
const txTotal = src.tx_inserted || 0;
|
||||
const txDelta = prevSrc ? (txTotal - (prevSrc.tx_inserted || 0)) : 0;
|
||||
const txRate = (prevSrc && dtSec > 0) ? (txDelta / dtSec) : 0;
|
||||
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th scope="col">Source</th><th scope="col">Total</th><th scope="col">Rate/s</th><th scope="col">Anomaly</th></tr></thead><tbody>';
|
||||
for (const k of keys) {
|
||||
const v = src[k] || 0;
|
||||
const isBackfill = k.startsWith('backfill_');
|
||||
let rate = 0;
|
||||
let flag = '';
|
||||
if (prevSrc && dtSec > 0) {
|
||||
const delta = v - (prevSrc[k] || 0);
|
||||
rate = delta / dtSec;
|
||||
// Only flag when tx baseline is statistically meaningful AND
|
||||
// backfill is actively running faster than 10× the live tx rate.
|
||||
if (isBackfill && txTotal >= MIN_SAMPLE && rate > 10 * Math.max(txRate, 1)) {
|
||||
flag = ' ⚠️';
|
||||
}
|
||||
}
|
||||
const rateStr = (prevSrc && dtSec > 0) ? rate.toFixed(1) : '—';
|
||||
html += `<tr><td><code>${k}</code></td><td>${v.toLocaleString()}</td><td>${rateStr}</td><td>${flag}</td></tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
// Stash for next tick's delta computation.
|
||||
window._perfWriteSourcesPrev = { sources: { ...src }, sampleAt: writeSources.sampleAt };
|
||||
if (writeSources.sampleAt) {
|
||||
html += `<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Sampled: ${writeSources.sampleAt}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite perf (separate from existing SQLite block — focused on WAL + cache hit) (#1120)
|
||||
if (sqliteStats) {
|
||||
const walMB = sqliteStats.walSizeMB || 0;
|
||||
const walFlag = walMB > 100 ? ' ⚠️' : '';
|
||||
const hitRate = (sqliteStats.cacheHitRate || 0) * 100;
|
||||
const hitFlag = hitRate > 0 && hitRate < 90 ? ' ⚠️' : '';
|
||||
html += `<h3>SQLite (WAL + Cache Hit)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${walMB.toFixed(1)}MB${walFlag}</div><div class="perf-label">WAL Size</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${(sqliteStats.pageCount || 0).toLocaleString()}</div><div class="perf-label">Page Count</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${sqliteStats.pageSize || 0}</div><div class="perf-label">Page Size</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${hitRate.toFixed(1)}%${hitFlag}</div><div class="perf-label">Cache Hit Rate</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Cache stats
|
||||
if (server.cache) {
|
||||
const c = server.cache;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/* === CoreScope — roles-page.js === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
let refreshTimer = null;
|
||||
|
||||
function init(app) {
|
||||
app.innerHTML =
|
||||
'<div class="roles-page" data-page="roles">' +
|
||||
' <div class="page-header">' +
|
||||
' <h2>Roles</h2>' +
|
||||
' <button class="btn-icon" data-action="roles-refresh" title="Refresh" aria-label="Refresh roles">🔄</button>' +
|
||||
' </div>' +
|
||||
' <p class="text-muted" style="margin:0 0 12px 0">Distribution of node roles across the mesh, with per-role clock-skew posture.</p>' +
|
||||
' <div id="rolesContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>' +
|
||||
'</div>';
|
||||
app.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action="roles-refresh"]');
|
||||
if (btn) load();
|
||||
});
|
||||
load();
|
||||
refreshTimer = setInterval(load, 60000);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
var container = document.getElementById('rolesContent');
|
||||
if (!container) return;
|
||||
try {
|
||||
var resp = await fetch('/api/analytics/roles');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var data = await resp.json();
|
||||
render(container, data);
|
||||
} catch (err) {
|
||||
container.innerHTML = '<div class="text-center" style="padding:40px;color:var(--color-error,#c00)">Failed to load roles: ' + escapeHtml(String(err.message || err)) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function fmtSec(v) {
|
||||
if (!v && v !== 0) return '—';
|
||||
var abs = Math.abs(v);
|
||||
if (abs < 1) return v.toFixed(2) + 's';
|
||||
if (abs < 60) return v.toFixed(1) + 's';
|
||||
if (abs < 3600) return (v / 60).toFixed(1) + 'm';
|
||||
if (abs < 86400) return (v / 3600).toFixed(1) + 'h';
|
||||
return (v / 86400).toFixed(1) + 'd';
|
||||
}
|
||||
|
||||
function roleEmoji(role) {
|
||||
if (window.ROLE_EMOJI && window.ROLE_EMOJI[role]) return window.ROLE_EMOJI[role];
|
||||
return '•';
|
||||
}
|
||||
|
||||
function render(container, data) {
|
||||
var roles = (data && data.roles) || [];
|
||||
var total = (data && data.totalNodes) || 0;
|
||||
if (roles.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-muted" style="padding:40px">No roles to show.</div>';
|
||||
return;
|
||||
}
|
||||
var maxCount = roles.reduce(function (m, r) { return Math.max(m, r.nodeCount || 0); }, 0) || 1;
|
||||
|
||||
var rows = roles.map(function (r) {
|
||||
var pct = total > 0 ? ((r.nodeCount / total) * 100).toFixed(1) : '0.0';
|
||||
var barW = Math.round((r.nodeCount / maxCount) * 100);
|
||||
var sevCells =
|
||||
'<span title="OK (skew < 5min)" style="color:var(--color-success,#0a0)">' + (r.okCount || 0) + '</span> / ' +
|
||||
'<span title="Warning (5min – 1h)" style="color:var(--color-warning,#e80)">' + (r.warningCount || 0) + '</span> / ' +
|
||||
'<span title="Critical (1h – 30d)" style="color:var(--color-error,#c00)">' + (r.criticalCount || 0) + '</span> / ' +
|
||||
'<span title="Absurd (> 30d)" style="color:#a0a">' + (r.absurdCount || 0) + '</span> / ' +
|
||||
'<span title="No clock (> 365d)" style="color:#888">' + (r.noClockCount || 0) + '</span>';
|
||||
return '' +
|
||||
'<tr data-role="' + escapeHtml(r.role) + '">' +
|
||||
'<td>' + roleEmoji(r.role) + ' <strong>' + escapeHtml(r.role) + '</strong></td>' +
|
||||
'<td style="text-align:right">' + r.nodeCount + '</td>' +
|
||||
'<td style="text-align:right">' + pct + '%</td>' +
|
||||
'<td style="min-width:140px">' +
|
||||
'<div style="background:var(--color-surface-2,#eee);height:10px;border-radius:5px;overflow:hidden">' +
|
||||
'<div style="background:var(--color-accent,#06c);width:' + barW + '%;height:100%"></div>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td style="text-align:right">' + (r.withSkew || 0) + '</td>' +
|
||||
'<td style="text-align:right">' + fmtSec(r.medianAbsSkewSec || 0) + '</td>' +
|
||||
'<td style="text-align:right">' + fmtSec(r.meanAbsSkewSec || 0) + '</td>' +
|
||||
'<td style="white-space:nowrap">' + sevCells + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="roles-summary" style="margin-bottom:12px;color:var(--color-text-muted,#666)">' +
|
||||
'<strong>' + total + '</strong> nodes across <strong>' + roles.length + '</strong> roles' +
|
||||
'</div>' +
|
||||
'<table id="rolesTable" class="data-table" style="width:100%">' +
|
||||
'<thead><tr>' +
|
||||
'<th>Role</th>' +
|
||||
'<th style="text-align:right">Count</th>' +
|
||||
'<th style="text-align:right">Share</th>' +
|
||||
'<th>Distribution</th>' +
|
||||
'<th style="text-align:right" title="Nodes with clock-skew samples">w/ Skew</th>' +
|
||||
'<th style="text-align:right" title="Median absolute skew">Median |skew|</th>' +
|
||||
'<th style="text-align:right" title="Mean absolute skew">Mean |skew|</th>' +
|
||||
'<th title="OK / Warning / Critical / Absurd / No-clock">Severity</th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>';
|
||||
}
|
||||
|
||||
registerPage('roles', { init: init, destroy: destroy });
|
||||
})();
|
||||
+1336
-71
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
/* public/touch-gestures.js — Gesture system for #1062.
|
||||
*
|
||||
* Three gestures for narrow viewports (≤768px):
|
||||
* 1. Swipe-LEFT on a packets/nodes/observers row → reveal row-action overlay.
|
||||
* 2. Horizontal swipe on the bottom-nav strip → advance tabs in TAB order.
|
||||
* 3. Swipe-DOWN on a slide-over panel → close it.
|
||||
*
|
||||
* Hard rules (per #1062 brief):
|
||||
* - Pointer Events ONLY (no touchstart/touchend mixing). setPointerCapture.
|
||||
* - Axis-lock: commit to one axis in the first 8–12px; vertical scroll never
|
||||
* blocked unless we explicitly committed to a horizontal swipe.
|
||||
* - Leaflet exclusion: bail if e.target.closest('.leaflet-container').
|
||||
* - Threshold: row-action triggers only at 24% of row width OR 80px swiped.
|
||||
* - touch-action: body { touch-action: pan-y } so browser owns vertical
|
||||
* scroll natively. [data-bottom-nav] gets touch-action: none.
|
||||
* - Singleton + cleanup: module-scoped guard, document-level listeners
|
||||
* registered ONCE (mirrors the #1180 MQL leak fix class).
|
||||
* - prefers-reduced-motion: animations disabled (CSS handles this), gesture
|
||||
* still works.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
// ── Singleton guard (matches #1180 pattern) ──
|
||||
if (typeof window.__touchGestures1062InitCount !== 'number') {
|
||||
window.__touchGestures1062InitCount = 0;
|
||||
}
|
||||
if (window.__touchGestures1062InitCount > 0) {
|
||||
// Already initialized — never re-register document listeners.
|
||||
return;
|
||||
}
|
||||
window.__touchGestures1062InitCount += 1;
|
||||
|
||||
// ── Tunables ──
|
||||
var AXIS_LOCK_DISTANCE = 10; // px before we commit to an axis (8–12 range)
|
||||
var ROW_ACTION_PX = 80; // absolute px threshold
|
||||
var ROW_ACTION_PCT = 0.24; // OR 24% of row width
|
||||
var SLIDE_OVER_DISMISS_PX = 100; // downward swipe to dismiss slide-over
|
||||
var TAB_SWIPE_PX = 60; // horizontal swipe on bottom-nav strip
|
||||
var NARROW_BP = 768; // gestures only matter on phones
|
||||
|
||||
// ── Module state ──
|
||||
var pointerActive = false;
|
||||
var pointerId = null;
|
||||
var startX = 0, startY = 0;
|
||||
var lastX = 0, lastY = 0;
|
||||
var axis = null; // 'h' | 'v' | null
|
||||
var startTarget = null;
|
||||
var gestureContext = null; // 'row' | 'bottom-nav' | 'slide-over' | null
|
||||
var activeRow = null;
|
||||
var rowOverlay = null;
|
||||
var capturedEl = null;
|
||||
// PR #1185 mesh-op review: scroll-discriminator for slide-over.
|
||||
// Captured at pointerdown when the slide-over context is selected; if the
|
||||
// panel content is mid-scroll (scrollTop > 0) at gesture start, the gesture
|
||||
// is a normal scroll, NOT a dismiss — we must not close the panel.
|
||||
var slideOverScroller = null;
|
||||
var slideOverStartScrollTop = 0;
|
||||
|
||||
function isNarrow() {
|
||||
return window.innerWidth <= NARROW_BP;
|
||||
}
|
||||
|
||||
function inLeaflet(target) {
|
||||
return !!(target && target.closest && target.closest('.leaflet-container'));
|
||||
}
|
||||
|
||||
function findRow(target) {
|
||||
if (!target || !target.closest) return null;
|
||||
// Packets/nodes/observers tables — generic: any tr inside a tbody whose
|
||||
// table is inside one of the relevant pages.
|
||||
var tr = target.closest('tr[data-hash], tr[data-id]');
|
||||
if (!tr) return null;
|
||||
var tbody = tr.closest('tbody');
|
||||
if (!tbody) return null;
|
||||
// Restrict to the three target tables. id="pktBody" for packets,
|
||||
// and we treat any tbody inside .nodes-table / .observers-table as eligible.
|
||||
if (tbody.id === 'pktBody') return tr;
|
||||
var table = tbody.closest('table');
|
||||
if (table && (table.id === 'nodesTable' || table.id === 'observersTable' ||
|
||||
table.classList.contains('nodes-table') ||
|
||||
table.classList.contains('observers-table'))) {
|
||||
return tr;
|
||||
}
|
||||
return tr; // permissive — still skip leaflet via inLeaflet().
|
||||
}
|
||||
|
||||
function findBottomNav(target) {
|
||||
if (!target || !target.closest) return null;
|
||||
return target.closest('[data-bottom-nav]');
|
||||
}
|
||||
|
||||
function findSlideOver(target) {
|
||||
if (!target || !target.closest) return null;
|
||||
return target.closest('.slide-over-panel');
|
||||
}
|
||||
|
||||
// Locate the open slide-over panel by querying the DOM (not via target
|
||||
// ancestry). Used as a fallback when the pointerdown's hit-test target
|
||||
// is something outside the panel subtree (e.g. a focused button whose
|
||||
// event was retargeted, or a panel mid-animation where elementFromPoint
|
||||
// returned an unrelated element). Pairs the lookup with a coordinate
|
||||
// check so we don't claim slide-over context for taps elsewhere.
|
||||
function findOpenSlideOverAt(x, y) {
|
||||
if (!window.SlideOver || typeof window.SlideOver.isOpen !== 'function') return null;
|
||||
if (!window.SlideOver.isOpen()) return null;
|
||||
var panel = document.querySelector('.slide-over-panel');
|
||||
if (!panel || panel.hidden) return null;
|
||||
var r = panel.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return null;
|
||||
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return panel;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Bottom-nav: read TAB order from bottom-nav.js ──
|
||||
// The TAB list there is module-private; we re-derive order from the rendered
|
||||
// DOM (which IS the source of truth for what the user sees) — primary tabs only,
|
||||
// i.e. excluding "more".
|
||||
function getNavTabsInOrder() {
|
||||
var nodes = document.querySelectorAll('[data-bottom-nav] [data-bottom-nav-tab]');
|
||||
var out = [];
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var r = nodes[i].getAttribute('data-bottom-nav-tab');
|
||||
if (r && r !== 'more') out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function currentRouteShort() {
|
||||
var h = (location.hash || '').replace(/^#\//, '');
|
||||
if (!h) return 'packets';
|
||||
var slash = h.indexOf('/');
|
||||
if (slash >= 0) h = h.substring(0, slash);
|
||||
var q = h.indexOf('?');
|
||||
if (q >= 0) h = h.substring(0, q);
|
||||
return h || 'packets';
|
||||
}
|
||||
|
||||
function navigateRelative(delta) {
|
||||
var tabs = getNavTabsInOrder();
|
||||
if (!tabs.length) return;
|
||||
var cur = currentRouteShort();
|
||||
var idx = tabs.indexOf(cur);
|
||||
if (idx < 0) return; // current route isn't a primary tab
|
||||
var next = idx + delta;
|
||||
if (next < 0 || next >= tabs.length) return;
|
||||
location.hash = '#/' + tabs[next];
|
||||
}
|
||||
|
||||
// ── Row-action overlay ──
|
||||
function ensureRowOverlay(row) {
|
||||
if (rowOverlay && rowOverlay.parentNode) return rowOverlay;
|
||||
var o = document.createElement('div');
|
||||
o.className = 'row-action-overlay';
|
||||
o.setAttribute('role', 'group');
|
||||
o.setAttribute('aria-label', 'Row actions');
|
||||
var hash = row.getAttribute('data-hash') || row.getAttribute('data-id') || '';
|
||||
o.innerHTML =
|
||||
'<button type="button" class="row-action-btn" data-row-action="trace">Trace</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="filter">Filter</button>' +
|
||||
'<button type="button" class="row-action-btn" data-row-action="copy" data-hash="' +
|
||||
String(hash).replace(/"/g, '"') + '">Copy hash</button>';
|
||||
document.body.appendChild(o);
|
||||
rowOverlay = o;
|
||||
return o;
|
||||
}
|
||||
|
||||
function showRowOverlay(row) {
|
||||
var o = ensureRowOverlay(row);
|
||||
var rect = row.getBoundingClientRect();
|
||||
o.style.position = 'fixed';
|
||||
o.style.top = rect.top + 'px';
|
||||
o.style.left = (rect.right - 240) + 'px';
|
||||
o.style.height = rect.height + 'px';
|
||||
o.style.width = '240px';
|
||||
o.classList.add('row-action-overlay-open');
|
||||
o.hidden = false;
|
||||
}
|
||||
|
||||
function dismissRowAction() {
|
||||
if (rowOverlay) {
|
||||
rowOverlay.classList.remove('row-action-overlay-open');
|
||||
// Remove from DOM after animation; CSS handles instant under reduce.
|
||||
var el = rowOverlay;
|
||||
rowOverlay = null;
|
||||
try {
|
||||
if (el.parentNode) el.parentNode.removeChild(el);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (activeRow) {
|
||||
activeRow.style.transform = '';
|
||||
activeRow.classList.remove('row-swiping');
|
||||
activeRow = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pointer handlers ──
|
||||
function onPointerDown(e) {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
if (pointerActive) return;
|
||||
var t = e.target;
|
||||
if (inLeaflet(t)) return;
|
||||
if (!isNarrow()) return;
|
||||
|
||||
var row = findRow(t);
|
||||
var nav = findBottomNav(t);
|
||||
var so = findSlideOver(t) || findOpenSlideOverAt(e.clientX, e.clientY);
|
||||
|
||||
if (so) gestureContext = 'slide-over';
|
||||
else if (nav) gestureContext = 'bottom-nav';
|
||||
else if (row) gestureContext = 'row';
|
||||
else gestureContext = null;
|
||||
|
||||
if (!gestureContext) return;
|
||||
|
||||
pointerActive = true;
|
||||
pointerId = e.pointerId;
|
||||
startX = lastX = e.clientX;
|
||||
startY = lastY = e.clientY;
|
||||
axis = null;
|
||||
startTarget = t;
|
||||
activeRow = (gestureContext === 'row') ? row : null;
|
||||
|
||||
// Slide-over scroll-discriminator (PR #1185): record where the user is
|
||||
// reading from. The slide-over panel itself is the scroller (CSS sets
|
||||
// `.slide-over-panel { overflow-y: auto; }`) — `.slide-over-content` is a
|
||||
// flex child without its own overflow-y, so its scrollTop is always 0.
|
||||
// To be robust against markup/CSS drift, walk every candidate (panel +
|
||||
// any inner `.slide-over-content`) and take the MAX scrollTop. Whichever
|
||||
// element actually scrolls becomes the discriminator source — this
|
||||
// guarantees production reads from the same element a test (or a future
|
||||
// refactor) writes to.
|
||||
if (gestureContext === 'slide-over') {
|
||||
var candidates = [];
|
||||
if (so) candidates.push(so);
|
||||
var inner = so && so.querySelector && so.querySelector('.slide-over-content');
|
||||
if (inner) candidates.push(inner);
|
||||
slideOverScroller = so || null;
|
||||
slideOverStartScrollTop = 0;
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var st = (candidates[i] && typeof candidates[i].scrollTop === 'number')
|
||||
? candidates[i].scrollTop : 0;
|
||||
if (st > slideOverStartScrollTop) {
|
||||
slideOverStartScrollTop = st;
|
||||
slideOverScroller = candidates[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slideOverScroller = null;
|
||||
slideOverStartScrollTop = 0;
|
||||
}
|
||||
|
||||
// Capture so subsequent move events flow to us regardless of element.
|
||||
try {
|
||||
var capTarget = (gestureContext === 'bottom-nav') ? nav :
|
||||
(gestureContext === 'slide-over') ? so :
|
||||
row || t;
|
||||
if (capTarget && typeof capTarget.setPointerCapture === 'function') {
|
||||
capTarget.setPointerCapture(pointerId);
|
||||
capturedEl = capTarget;
|
||||
}
|
||||
} catch (_) { capturedEl = null; }
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!pointerActive || e.pointerId !== pointerId) return;
|
||||
var dx = e.clientX - startX;
|
||||
var dy = e.clientY - startY;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
|
||||
if (axis === null) {
|
||||
var adx = Math.abs(dx), ady = Math.abs(dy);
|
||||
if (adx < AXIS_LOCK_DISTANCE && ady < AXIS_LOCK_DISTANCE) return;
|
||||
// For slide-over, dismiss on vertical down swipe; commit accordingly.
|
||||
if (gestureContext === 'slide-over') {
|
||||
axis = (ady > adx) ? 'v' : 'h';
|
||||
if (axis !== 'v') {
|
||||
// Horizontal on slide-over — release, do nothing.
|
||||
releasePointer();
|
||||
return;
|
||||
}
|
||||
// Scroll-discriminator (PR #1185): if user started mid-scroll, this
|
||||
// gesture belongs to the browser's native scroll. Release immediately
|
||||
// so we never preventDefault / drag the panel / dismiss.
|
||||
if (slideOverStartScrollTop > 0) {
|
||||
releasePointer();
|
||||
return;
|
||||
}
|
||||
} else if (gestureContext === 'bottom-nav') {
|
||||
axis = (adx > ady) ? 'h' : 'v';
|
||||
if (axis !== 'h') { releasePointer(); return; }
|
||||
} else if (gestureContext === 'row') {
|
||||
axis = (adx > ady) ? 'h' : 'v';
|
||||
if (axis !== 'h') {
|
||||
// Vertical → release; let browser handle scroll.
|
||||
releasePointer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply visual feedback only after axis commit.
|
||||
if (gestureContext === 'row' && axis === 'h' && activeRow) {
|
||||
// Only show the peek for left-swipes (reveal action panel on right side).
|
||||
if (dx < 0) {
|
||||
activeRow.classList.add('row-swiping');
|
||||
activeRow.style.transform = 'translateX(' + Math.max(dx, -240) + 'px)';
|
||||
} else {
|
||||
activeRow.style.transform = '';
|
||||
}
|
||||
// Prevent default so the browser doesn't start a text-selection drag.
|
||||
if (e.cancelable) { try { e.preventDefault(); } catch (_) {} }
|
||||
} else if (gestureContext === 'bottom-nav' && axis === 'h') {
|
||||
if (e.cancelable) { try { e.preventDefault(); } catch (_) {} }
|
||||
} else if (gestureContext === 'slide-over' && axis === 'v') {
|
||||
if (dy > 0) {
|
||||
// Drag panel down with the finger.
|
||||
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
|
||||
if (so) {
|
||||
so.style.transform = 'translateY(' + dy + 'px)';
|
||||
}
|
||||
}
|
||||
if (e.cancelable) { try { e.preventDefault(); } catch (_) {} }
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
if (!pointerActive || e.pointerId !== pointerId) return;
|
||||
var dx = e.clientX - startX;
|
||||
var dy = e.clientY - startY;
|
||||
|
||||
try {
|
||||
if (gestureContext === 'row' && axis === 'h' && activeRow) {
|
||||
var rowRect = activeRow.getBoundingClientRect();
|
||||
var threshold = Math.min(ROW_ACTION_PX, rowRect.width * ROW_ACTION_PCT);
|
||||
if (dx < 0 && Math.abs(dx) >= threshold) {
|
||||
// Commit — show overlay, snap row back.
|
||||
activeRow.style.transform = '';
|
||||
activeRow.classList.remove('row-swiping');
|
||||
showRowOverlay(activeRow);
|
||||
activeRow = null; // overlay owns lifecycle now
|
||||
} else {
|
||||
// Snap back.
|
||||
activeRow.style.transform = '';
|
||||
activeRow.classList.remove('row-swiping');
|
||||
activeRow = null;
|
||||
}
|
||||
} else if (gestureContext === 'bottom-nav' && axis === 'h') {
|
||||
if (dx <= -TAB_SWIPE_PX) {
|
||||
// Drag content leftward → next tab.
|
||||
navigateRelative(+1);
|
||||
} else if (dx >= TAB_SWIPE_PX) {
|
||||
navigateRelative(-1);
|
||||
}
|
||||
} else if (gestureContext === 'slide-over' && axis === 'v') {
|
||||
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
|
||||
if (so) so.style.transform = '';
|
||||
// Scroll-discriminator (PR #1185): if the user started mid-scroll,
|
||||
// never dismiss — onPointerMove should already have released, this
|
||||
// is a defense-in-depth guard.
|
||||
if (slideOverStartScrollTop > 0) {
|
||||
// no-op
|
||||
} else if (dy >= SLIDE_OVER_DISMISS_PX && window.SlideOver && typeof window.SlideOver.close === 'function') {
|
||||
try { window.SlideOver.close(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
releasePointer();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerCancel(e) {
|
||||
if (!pointerActive || e.pointerId !== pointerId) return;
|
||||
if (activeRow) {
|
||||
activeRow.style.transform = '';
|
||||
activeRow.classList.remove('row-swiping');
|
||||
activeRow = null;
|
||||
}
|
||||
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
|
||||
if (so) so.style.transform = '';
|
||||
releasePointer();
|
||||
}
|
||||
|
||||
// Browser may steal pointer capture (e.g. orientation change, parent
|
||||
// scroll start, focus change). When that happens neither pointerup nor
|
||||
// pointercancel are guaranteed — we'd leak state and visuals. Treat
|
||||
// lost-capture identically to cancel.
|
||||
function onPointerLostCapture(e) {
|
||||
if (!pointerActive || e.pointerId !== pointerId) return;
|
||||
if (activeRow) {
|
||||
activeRow.style.transform = '';
|
||||
activeRow.classList.remove('row-swiping');
|
||||
activeRow = null;
|
||||
}
|
||||
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
|
||||
if (so) so.style.transform = '';
|
||||
releasePointer();
|
||||
}
|
||||
|
||||
function releasePointer() {
|
||||
try {
|
||||
if (capturedEl && pointerId != null && typeof capturedEl.releasePointerCapture === 'function') {
|
||||
capturedEl.releasePointerCapture(pointerId);
|
||||
}
|
||||
} catch (_) {}
|
||||
pointerActive = false;
|
||||
pointerId = null;
|
||||
axis = null;
|
||||
startTarget = null;
|
||||
capturedEl = null;
|
||||
gestureContext = null;
|
||||
slideOverScroller = null;
|
||||
slideOverStartScrollTop = 0;
|
||||
}
|
||||
|
||||
// ── Row-overlay click delegation ──
|
||||
function onClickAction(e) {
|
||||
var btn = e.target && e.target.closest && e.target.closest('.row-action-btn');
|
||||
if (!btn) {
|
||||
// Click outside overlay dismisses it.
|
||||
if (rowOverlay && !(e.target.closest && e.target.closest('.row-action-overlay'))) {
|
||||
dismissRowAction();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var action = btn.getAttribute('data-row-action');
|
||||
var hash = btn.getAttribute('data-hash') || '';
|
||||
if (action === 'copy' && hash && navigator.clipboard) {
|
||||
try { navigator.clipboard.writeText(hash); } catch (_) {}
|
||||
} else if (action === 'filter' && hash) {
|
||||
location.hash = '#/packets?hash=' + encodeURIComponent(hash);
|
||||
} else if (action === 'trace' && hash) {
|
||||
location.hash = '#/packets/' + encodeURIComponent(hash);
|
||||
}
|
||||
dismissRowAction();
|
||||
}
|
||||
|
||||
// ── Register listeners ONCE at document level ──
|
||||
// passive:false on move/up so we can preventDefault when we own the axis.
|
||||
document.addEventListener('pointerdown', onPointerDown, { passive: true });
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
document.addEventListener('pointerup', onPointerUp, { passive: true });
|
||||
document.addEventListener('pointercancel', onPointerCancel, { passive: true });
|
||||
document.addEventListener('lostpointercapture', onPointerLostCapture, { passive: true });
|
||||
document.addEventListener('click', onClickAction, true);
|
||||
|
||||
// Public API used by tests / future callers.
|
||||
window.TouchGestures = {
|
||||
dismissRowAction: dismissRowAction,
|
||||
_navigateRelative: navigateRelative,
|
||||
};
|
||||
})();
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Vendored
+14
@@ -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;
|
||||
}
|
||||
Vendored
+10108
File diff suppressed because it is too large
Load Diff
+2
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user