mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 01:11:37 +00:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35e1f46b36 | |||
| 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":"104 passed","color":"brightgreen"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"40.01%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"38.41%","color":"red"}
|
||||
|
||||
@@ -83,7 +83,12 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
node test-packet-filter.js
|
||||
node test-packet-filter-time.js
|
||||
node test-channel-decrypt-insecure-context.js
|
||||
node test-live-region-filter.js
|
||||
node test-channel-qr.js
|
||||
node test-channel-qr-wiring.js
|
||||
node test-channel-modal-ux.js
|
||||
|
||||
- name: Verify proto syntax
|
||||
run: |
|
||||
@@ -206,6 +211,7 @@ jobs:
|
||||
- name: Run Playwright E2E tests (fail-fast)
|
||||
run: |
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage (parallel)
|
||||
if: success() && github.event_name == 'push'
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Build stage always runs natively on the builder's arch ($BUILDPLATFORM)
|
||||
# and cross-compiles to $TARGETOS/$TARGETARCH via Go toolchain. No QEMU.
|
||||
# BUILDPLATFORM is auto-set by buildx; default to linux/amd64 so plain
|
||||
# `docker build` (without buildx) doesn't fail on an empty platform string.
|
||||
ARG BUILDPLATFORM=linux/amd64
|
||||
FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS builder
|
||||
|
||||
ARG APP_VERSION=unknown
|
||||
|
||||
+19
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-2
@@ -101,7 +101,8 @@ func applySchema(db *sql.DB) error {
|
||||
first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0,
|
||||
battery_mv INTEGER,
|
||||
temperature_c REAL
|
||||
temperature_c REAL,
|
||||
foreign_advert INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS observers (
|
||||
@@ -135,7 +136,8 @@ func applySchema(db *sql.DB) error {
|
||||
first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0,
|
||||
battery_mv INTEGER,
|
||||
temperature_c REAL
|
||||
temperature_c REAL,
|
||||
foreign_advert INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inactive_nodes_last_seen ON inactive_nodes(last_seen);
|
||||
@@ -463,6 +465,25 @@ func applySchema(db *sql.DB) error {
|
||||
db.Exec(`INSERT INTO _migrations (name) VALUES ('cleanup_legacy_null_hash_ts')`)
|
||||
}
|
||||
|
||||
// Migration: foreign_advert column on nodes/inactive_nodes (#730)
|
||||
// Marks nodes whose ADVERT GPS lies outside the configured geofilter polygon.
|
||||
// Default 0; set to 1 by the ingestor when GeoFilter is configured and
|
||||
// PassesFilter() returns false. Allows operators to surface bridged/leaked
|
||||
// adverts without silently dropping them.
|
||||
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'foreign_advert_v1'")
|
||||
if row.Scan(&migDone) != nil {
|
||||
log.Println("[migration] Adding foreign_advert column to nodes/inactive_nodes...")
|
||||
if _, err := db.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
|
||||
log.Printf("[migration] nodes.foreign_advert: %v (may already exist)", err)
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TABLE inactive_nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
|
||||
log.Printf("[migration] inactive_nodes.foreign_advert: %v (may already exist)", err)
|
||||
}
|
||||
db.Exec(`CREATE INDEX IF NOT EXISTS idx_nodes_foreign_advert ON nodes(foreign_advert) WHERE foreign_advert = 1`)
|
||||
db.Exec(`INSERT INTO _migrations (name) VALUES ('foreign_advert_v1')`)
|
||||
log.Println("[migration] foreign_advert column added")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -676,6 +697,21 @@ func (s *Store) IncrementAdvertCount(pubKey string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkNodeForeign sets foreign_advert=1 on the node row identified by pubKey.
|
||||
// Used when an ADVERT arrives whose GPS lies outside the configured geofilter
|
||||
// polygon (#730). Idempotent — safe to call repeatedly. No-op if pubKey is
|
||||
// empty.
|
||||
func (s *Store) MarkNodeForeign(pubKey string) error {
|
||||
if pubKey == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE nodes SET foreign_advert = 1 WHERE public_key = ?`, pubKey)
|
||||
if err != nil {
|
||||
s.Stats.WriteErrors.Add(1)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateNodeTelemetry updates battery and temperature for a node.
|
||||
func (s *Store) UpdateNodeTelemetry(pubKey string, batteryMv *int, temperatureC *float64) error {
|
||||
var bv, tc interface{}
|
||||
@@ -1106,6 +1142,7 @@ type PacketData struct {
|
||||
DecodedJSON string
|
||||
ChannelHash string // grouping key for channel queries (#762)
|
||||
Region string // observer region: payload > topic > source config (#788)
|
||||
Foreign bool // true when ADVERT GPS lies outside configured geofilter (#730)
|
||||
}
|
||||
|
||||
// nilIfEmpty returns nil for empty strings (for nullable DB columns).
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHandleMessageAdvertForeign_FlagModeStoresWithFlag asserts that when an
|
||||
// ADVERT comes from a node whose GPS is OUTSIDE the configured geofilter,
|
||||
// the ingestor (in default "flag" mode) stores the node and marks it foreign,
|
||||
// instead of silently dropping it (#730).
|
||||
func TestHandleMessageAdvertForeign_FlagModeStoresWithFlag(t *testing.T) {
|
||||
store, source := newTestContext(t)
|
||||
|
||||
// Real ADVERT raw hex from existing TestHandleMessageAdvertGeoFiltered.
|
||||
// Decoder will produce a node with a known GPS — the test below just
|
||||
// asserts that with a tight geofilter that EXCLUDES that GPS, the node
|
||||
// is still stored AND tagged as foreign.
|
||||
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
|
||||
|
||||
latMin, latMax := -1.0, 1.0
|
||||
lonMin, lonMax := -1.0, 1.0
|
||||
gf := &GeoFilterConfig{
|
||||
LatMin: &latMin, LatMax: &latMax,
|
||||
LonMin: &lonMin, LonMax: &lonMax,
|
||||
}
|
||||
|
||||
msg := &mockMessage{
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
// Default mode (no ForeignAdverts.Mode set) MUST be "flag", per #730 design.
|
||||
handleMessage(store, "test", source, msg, nil, &Config{GeoFilter: gf})
|
||||
|
||||
var nodeCount int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nodeCount != 1 {
|
||||
t.Fatalf("nodes=%d, want 1 (foreign advert should be stored, not dropped, in flag mode)", nodeCount)
|
||||
}
|
||||
|
||||
var foreign int
|
||||
if err := store.db.QueryRow("SELECT foreign_advert FROM nodes").Scan(&foreign); err != nil {
|
||||
t.Fatalf("foreign_advert column missing or unreadable: %v", err)
|
||||
}
|
||||
if foreign != 1 {
|
||||
t.Errorf("foreign_advert=%d, want 1", foreign)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMessageAdvertForeign_DropModeStillDrops asserts the legacy
|
||||
// drop-on-foreign behavior is preserved when ForeignAdverts.Mode = "drop".
|
||||
func TestHandleMessageAdvertForeign_DropModeStillDrops(t *testing.T) {
|
||||
store, source := newTestContext(t)
|
||||
|
||||
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
|
||||
|
||||
latMin, latMax := -1.0, 1.0
|
||||
lonMin, lonMax := -1.0, 1.0
|
||||
gf := &GeoFilterConfig{
|
||||
LatMin: &latMin, LatMax: &latMax,
|
||||
LonMin: &lonMin, LonMax: &lonMax,
|
||||
}
|
||||
|
||||
msg := &mockMessage{
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
cfg := &Config{
|
||||
GeoFilter: gf,
|
||||
ForeignAdverts: &ForeignAdvertConfig{Mode: "drop"},
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, cfg)
|
||||
|
||||
var nodeCount int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nodeCount != 0 {
|
||||
t.Errorf("nodes=%d, want 0 (drop mode preserves legacy silent-drop behavior)", nodeCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMessageAdvertInRegion_NotFlaggedForeign asserts in-region
|
||||
// adverts are NOT marked foreign.
|
||||
func TestHandleMessageAdvertInRegion_NotFlaggedForeign(t *testing.T) {
|
||||
store, source := newTestContext(t)
|
||||
|
||||
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
|
||||
|
||||
// Wide-open geofilter: every coord passes.
|
||||
latMin, latMax := -90.0, 90.0
|
||||
lonMin, lonMax := -180.0, 180.0
|
||||
gf := &GeoFilterConfig{
|
||||
LatMin: &latMin, LatMax: &latMax,
|
||||
LonMin: &lonMin, LonMax: &lonMax,
|
||||
}
|
||||
msg := &mockMessage{
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{GeoFilter: gf})
|
||||
|
||||
var foreign int
|
||||
err := store.db.QueryRow("SELECT foreign_advert FROM nodes").Scan(&foreign)
|
||||
if err != nil {
|
||||
t.Fatalf("query foreign_advert: %v", err)
|
||||
}
|
||||
if foreign != 0 {
|
||||
t.Errorf("foreign_advert=%d, want 0 (in-region node)", foreign)
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -422,10 +422,28 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
})
|
||||
return
|
||||
}
|
||||
foreign := false
|
||||
if !NodePassesGeoFilter(decoded.Payload.Lat, decoded.Payload.Lon, cfg.GeoFilter) {
|
||||
return
|
||||
if cfg.ForeignAdverts.IsDropMode() {
|
||||
return
|
||||
}
|
||||
foreign = true
|
||||
lat, lon := 0.0, 0.0
|
||||
if decoded.Payload.Lat != nil {
|
||||
lat = *decoded.Payload.Lat
|
||||
}
|
||||
if decoded.Payload.Lon != nil {
|
||||
lon = *decoded.Payload.Lon
|
||||
}
|
||||
truncPK := decoded.Payload.PubKey
|
||||
if len(truncPK) > 16 {
|
||||
truncPK = truncPK[:16]
|
||||
}
|
||||
log.Printf("MQTT [%s] foreign advert: node=%s name=%s lat=%.4f lon=%.4f observer=%s",
|
||||
tag, truncPK, decoded.Payload.Name, lat, lon, firstNonEmpty(mqttMsg.Origin, observerID))
|
||||
}
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
|
||||
pktData.Foreign = foreign
|
||||
isNew, err := store.InsertTransmission(pktData)
|
||||
if err != nil {
|
||||
log.Printf("MQTT [%s] db insert error: %v", tag, err)
|
||||
@@ -434,6 +452,11 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
|
||||
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
|
||||
}
|
||||
if foreign {
|
||||
if err := store.MarkNodeForeign(decoded.Payload.PubKey); err != nil {
|
||||
log.Printf("MQTT [%s] mark foreign error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
if isNew {
|
||||
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
|
||||
log.Printf("MQTT [%s] advert count error: %v", tag, err)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,9 @@ type Config struct {
|
||||
|
||||
ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"`
|
||||
NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"`
|
||||
|
||||
// BatteryThresholds: voltage cutoffs for low/critical alerts (#663).
|
||||
BatteryThresholds *BatteryThresholdsConfig `json:"batteryThresholds,omitempty"`
|
||||
}
|
||||
|
||||
// weakAPIKeys is the blocklist of known default/example API keys that must be rejected.
|
||||
@@ -221,6 +224,10 @@ type HealthThresholds struct {
|
||||
InfraSilentHours float64 `json:"infraSilentHours"`
|
||||
NodeDegradedHours float64 `json:"nodeDegradedHours"`
|
||||
NodeSilentHours float64 `json:"nodeSilentHours"`
|
||||
// RelayActiveHours: how recent a path-hop appearance must be for a
|
||||
// repeater to be considered "actively relaying" vs only "alive
|
||||
// (advert-only)". See issue #662. Defaults to 24h.
|
||||
RelayActiveHours float64 `json:"relayActiveHours"`
|
||||
}
|
||||
|
||||
// ThemeFile mirrors theme.json overlay.
|
||||
@@ -289,6 +296,7 @@ func (c *Config) GetHealthThresholds() HealthThresholds {
|
||||
InfraSilentHours: 72,
|
||||
NodeDegradedHours: 1,
|
||||
NodeSilentHours: 24,
|
||||
RelayActiveHours: 24,
|
||||
}
|
||||
if c.HealthThresholds != nil {
|
||||
if c.HealthThresholds.InfraDegradedHours > 0 {
|
||||
@@ -303,6 +311,9 @@ func (c *Config) GetHealthThresholds() HealthThresholds {
|
||||
if c.HealthThresholds.NodeSilentHours > 0 {
|
||||
h.NodeSilentHours = c.HealthThresholds.NodeSilentHours
|
||||
}
|
||||
if c.HealthThresholds.RelayActiveHours > 0 {
|
||||
h.RelayActiveHours = c.HealthThresholds.RelayActiveHours
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
+7
-5
@@ -787,7 +787,7 @@ func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortB
|
||||
var total int
|
||||
db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM nodes %s", w), args...).Scan(&total)
|
||||
|
||||
querySQL := fmt.Sprintf("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c FROM nodes %s ORDER BY %s LIMIT ? OFFSET ?", w, order)
|
||||
querySQL := fmt.Sprintf("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert FROM nodes %s ORDER BY %s LIMIT ? OFFSET ?", w, order)
|
||||
qArgs := append(args, limit, offset)
|
||||
|
||||
rows, err := db.conn.Query(querySQL, qArgs...)
|
||||
@@ -813,7 +813,7 @@ func (db *DB) SearchNodes(query string, limit int) ([]map[string]interface{}, er
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
rows, err := db.conn.Query(`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c
|
||||
rows, err := db.conn.Query(`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert
|
||||
FROM nodes WHERE name LIKE ? OR public_key LIKE ? ORDER BY last_seen DESC LIMIT ?`,
|
||||
"%"+query+"%", query+"%", limit)
|
||||
if err != nil {
|
||||
@@ -852,7 +852,7 @@ func (db *DB) GetNodeByPrefix(prefix string) (map[string]interface{}, bool, erro
|
||||
}
|
||||
}
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c
|
||||
`SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert
|
||||
FROM nodes WHERE public_key LIKE ? LIMIT 2`,
|
||||
prefix+"%",
|
||||
)
|
||||
@@ -882,7 +882,7 @@ func (db *DB) GetNodeByPrefix(prefix string) (map[string]interface{}, bool, erro
|
||||
|
||||
// GetNodeByPubkey returns a single node.
|
||||
func (db *DB) GetNodeByPubkey(pubkey string) (map[string]interface{}, error) {
|
||||
rows, err := db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c FROM nodes WHERE public_key = ?", pubkey)
|
||||
rows, err := db.conn.Query("SELECT public_key, name, role, lat, lon, last_seen, first_seen, advert_count, battery_mv, temperature_c, foreign_advert FROM nodes WHERE public_key = ?", pubkey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1867,8 +1867,9 @@ func scanNodeRow(rows *sql.Rows) map[string]interface{} {
|
||||
var advertCount int
|
||||
var batteryMv sql.NullInt64
|
||||
var temperatureC sql.NullFloat64
|
||||
var foreign sql.NullInt64
|
||||
|
||||
if err := rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC); err != nil {
|
||||
if err := rows.Scan(&pk, &name, &role, &lat, &lon, &lastSeen, &firstSeen, &advertCount, &batteryMv, &temperatureC, &foreign); err != nil {
|
||||
return nil
|
||||
}
|
||||
m := map[string]interface{}{
|
||||
@@ -1883,6 +1884,7 @@ func scanNodeRow(rows *sql.Rows) map[string]interface{} {
|
||||
"last_heard": nullStr(lastSeen),
|
||||
"hash_size": nil,
|
||||
"hash_size_inconsistent": false,
|
||||
"foreign": foreign.Valid && foreign.Int64 != 0,
|
||||
}
|
||||
if batteryMv.Valid {
|
||||
m["battery_mv"] = int(batteryMv.Int64)
|
||||
|
||||
@@ -32,7 +32,8 @@ func setupTestDB(t *testing.T) *DB {
|
||||
first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0,
|
||||
battery_mv INTEGER,
|
||||
temperature_c REAL
|
||||
temperature_c REAL,
|
||||
foreign_advert INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE observers (
|
||||
@@ -1173,7 +1174,8 @@ func setupTestDBV2(t *testing.T) *DB {
|
||||
first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0,
|
||||
battery_mv INTEGER,
|
||||
temperature_c REAL
|
||||
temperature_c REAL,
|
||||
foreign_advert INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE observers (
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,25 @@ func main() {
|
||||
log.Printf("[security] WARNING: API key is weak or a known default — write endpoints are vulnerable")
|
||||
}
|
||||
|
||||
// Apply Go runtime soft memory limit (#836).
|
||||
// Honors GOMEMLIMIT if set; otherwise derives from packetStore.maxMemoryMB.
|
||||
{
|
||||
_, envSet := os.LookupEnv("GOMEMLIMIT")
|
||||
maxMB := 0
|
||||
if cfg.PacketStore != nil {
|
||||
maxMB = cfg.PacketStore.MaxMemoryMB
|
||||
}
|
||||
limit, source := applyMemoryLimit(maxMB, envSet)
|
||||
switch source {
|
||||
case "env":
|
||||
log.Printf("[memlimit] using GOMEMLIMIT from environment (%s)", os.Getenv("GOMEMLIMIT"))
|
||||
case "derived":
|
||||
log.Printf("[memlimit] derived from packetStore.maxMemoryMB=%d → %d MiB (1.5x headroom)", maxMB, limit/(1024*1024))
|
||||
default:
|
||||
log.Printf("[memlimit] no soft memory limit set (GOMEMLIMIT unset, packetStore.maxMemoryMB=0); recommend setting one to avoid container OOM-kill")
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve DB path
|
||||
resolvedDB := cfg.ResolveDBPath(configDir)
|
||||
log.Printf("[config] port=%d db=%s public=%s", cfg.Port, resolvedDB, publicDir)
|
||||
@@ -186,6 +205,13 @@ func main() {
|
||||
log.Printf("[store] warning: could not add observers.last_packet_at column: %v", err)
|
||||
}
|
||||
|
||||
// Ensure nodes.foreign_advert column exists (#730 reads it on every /api/nodes
|
||||
// scan; ingestor migration foreign_advert_v1 adds it but server may run against
|
||||
// DBs ingestor never touched, e.g. e2e fixture).
|
||||
if err := ensureForeignAdvertColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add nodes.foreign_advert column: %v", err)
|
||||
}
|
||||
|
||||
// Soft-delete observers that are in the blacklist (mark inactive=1) so
|
||||
// historical data from a prior unblocked window is hidden too.
|
||||
if len(cfg.ObserverBlacklist) > 0 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RepeaterRelayInfo describes whether a repeater has been observed
|
||||
// relaying traffic (appearing as a path hop in non-advert packets) and
|
||||
// when. This is distinct from advert-based liveness (last_seen / last_heard),
|
||||
// which only proves the repeater can transmit its own adverts.
|
||||
//
|
||||
// See issue #662.
|
||||
type RepeaterRelayInfo struct {
|
||||
// LastRelayed is the ISO-8601 timestamp of the most recent non-advert
|
||||
// packet where this pubkey appeared as a relay hop. Empty if never.
|
||||
LastRelayed string `json:"lastRelayed,omitempty"`
|
||||
// RelayActive is true if LastRelayed falls within the configured
|
||||
// activity window (default 24h).
|
||||
RelayActive bool `json:"relayActive"`
|
||||
// WindowHours is the active-window threshold actually used.
|
||||
WindowHours float64 `json:"windowHours"`
|
||||
// RelayCount1h is the count of distinct non-advert packets where this
|
||||
// pubkey appeared as a relay hop in the last 1 hour.
|
||||
RelayCount1h int `json:"relayCount1h"`
|
||||
// RelayCount24h is the count of distinct non-advert packets where this
|
||||
// pubkey appeared as a relay hop in the last 24 hours.
|
||||
RelayCount24h int `json:"relayCount24h"`
|
||||
}
|
||||
|
||||
// payloadTypeAdvert is the MeshCore payload type for ADVERT packets.
|
||||
// See firmware/src/Mesh.h. Adverts are NOT considered relay activity:
|
||||
// a repeater that only sends adverts proves it is alive, not that it
|
||||
// is forwarding traffic for other nodes.
|
||||
const payloadTypeAdvert = 4
|
||||
|
||||
// parseRelayTS attempts to parse a packet first-seen timestamp using the
|
||||
// formats CoreScope writes in practice. Returns zero time and false on
|
||||
// failure. Accepted (in order):
|
||||
// - RFC3339Nano — Go's default UTC marshal output
|
||||
// - RFC3339 — second-precision ISO-8601 with offset
|
||||
// - "2006-01-02T15:04:05.000Z" — millisecond-precision Z form used by ingest
|
||||
func parseRelayTS(ts string) (time.Time, bool) {
|
||||
if ts == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
|
||||
return t, true
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||
return t, true
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02T15:04:05.000Z", ts); err == nil {
|
||||
return t, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// GetRepeaterRelayInfo returns relay-activity information for a node by
|
||||
// scanning the byPathHop index for non-advert packets that name the
|
||||
// pubkey as a hop. It computes the most recent appearance timestamp,
|
||||
// 1h/24h hop counts, and whether the latest appearance falls within
|
||||
// windowHours.
|
||||
//
|
||||
// Cost: O(N) over the indexed entries for `pubkey`. The byPathHop index
|
||||
// is bounded by store eviction; on real data this is small per-node.
|
||||
//
|
||||
// Note on self-as-source: byPathHop is keyed by every hop in a packet's
|
||||
// resolved path, including the originator. For ADVERT packets that's the
|
||||
// node itself, which is filtered above by the payloadTypeAdvert check.
|
||||
// For non-advert packets a node "originates" rather than "relays" only
|
||||
// when it is the source; we don't currently have a clean signal for that
|
||||
// distinction, so the count here is *path-hop appearances in non-advert
|
||||
// packets*. In practice for a repeater nearly all such appearances are
|
||||
// relay hops (the firmware doesn't originate user traffic), so this is
|
||||
// the right approximation for issue #662.
|
||||
func (s *PacketStore) GetRepeaterRelayInfo(pubkey string, windowHours float64) RepeaterRelayInfo {
|
||||
info := RepeaterRelayInfo{WindowHours: windowHours}
|
||||
if pubkey == "" {
|
||||
return info
|
||||
}
|
||||
key := strings.ToLower(pubkey)
|
||||
|
||||
s.mu.RLock()
|
||||
txList := s.byPathHop[key]
|
||||
// Copy only the timestamps + payload types we need so we can release
|
||||
// the read lock before doing parsing/compare work below.
|
||||
type entry struct {
|
||||
ts string
|
||||
pt int
|
||||
}
|
||||
scratch := make([]entry, 0, len(txList))
|
||||
for _, tx := range txList {
|
||||
if tx == nil {
|
||||
continue
|
||||
}
|
||||
pt := -1
|
||||
if tx.PayloadType != nil {
|
||||
pt = *tx.PayloadType
|
||||
}
|
||||
scratch = append(scratch, entry{ts: tx.FirstSeen, pt: pt})
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
cutoff1h := now.Add(-1 * time.Hour)
|
||||
cutoff24h := now.Add(-24 * time.Hour)
|
||||
|
||||
var latest time.Time
|
||||
var latestRaw string
|
||||
for _, e := range scratch {
|
||||
// Self-originated adverts are not relay activity (see header comment).
|
||||
if e.pt == payloadTypeAdvert {
|
||||
continue
|
||||
}
|
||||
t, ok := parseRelayTS(e.ts)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t.After(latest) {
|
||||
latest = t
|
||||
latestRaw = e.ts
|
||||
}
|
||||
if t.After(cutoff24h) {
|
||||
info.RelayCount24h++
|
||||
if t.After(cutoff1h) {
|
||||
info.RelayCount1h++
|
||||
}
|
||||
}
|
||||
}
|
||||
if latestRaw == "" {
|
||||
return info
|
||||
}
|
||||
info.LastRelayed = latestRaw
|
||||
|
||||
if windowHours > 0 {
|
||||
cutoff := now.Add(-time.Duration(windowHours * float64(time.Hour)))
|
||||
if latest.After(cutoff) {
|
||||
info.RelayActive = true
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRepeaterRelayActivity_Active verifies that a repeater whose pubkey
|
||||
// appears as a relay hop in a recent (non-advert) packet is reported with
|
||||
// a non-zero lastRelayed timestamp and relayActive=true.
|
||||
func TestRepeaterRelayActivity_Active(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
pubkey := "aabbccdd11223344"
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
pubkey, "RepActive", "repeater", recentTS(1))
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// A non-advert packet (payload_type=1, TXT_MSG) with the repeater pubkey
|
||||
// indexed as a path hop. Index by lowercase pubkey directly to mirror
|
||||
// the resolved-path entries that decode-window writes.
|
||||
pt := 1
|
||||
relayed := &StoreTx{
|
||||
RawHex: "0100",
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["aa"]`,
|
||||
FirstSeen: recentTS(2),
|
||||
}
|
||||
store.mu.Lock()
|
||||
relayed.ID = len(store.packets) + 1
|
||||
relayed.Hash = "test-relay-1"
|
||||
store.packets = append(store.packets, relayed)
|
||||
store.byHash[relayed.Hash] = relayed
|
||||
store.byTxID[relayed.ID] = relayed
|
||||
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], relayed)
|
||||
store.mu.Unlock()
|
||||
|
||||
info := store.GetRepeaterRelayInfo(pubkey, 24)
|
||||
if info.LastRelayed == "" {
|
||||
t.Fatalf("expected non-empty LastRelayed for active relayer, got empty (RelayActive=%v)", info.RelayActive)
|
||||
}
|
||||
if !info.RelayActive {
|
||||
t.Errorf("expected RelayActive=true within 24h window, got false (LastRelayed=%s)", info.LastRelayed)
|
||||
}
|
||||
if info.RelayCount1h != 0 {
|
||||
t.Errorf("expected RelayCount1h=0 (relay was 2h ago, outside 1h window), got %d", info.RelayCount1h)
|
||||
}
|
||||
if info.RelayCount24h != 1 {
|
||||
t.Errorf("expected RelayCount24h=1 (relay was 2h ago, inside 24h window), got %d", info.RelayCount24h)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepeaterRelayActivity_Idle verifies that a repeater whose pubkey
|
||||
// has not appeared as a relay hop reports an empty LastRelayed and
|
||||
// relayActive=false.
|
||||
func TestRepeaterRelayActivity_Idle(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
pubkey := "ccddeeff55667788"
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
pubkey, "RepIdle", "repeater", recentTS(1))
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
info := store.GetRepeaterRelayInfo(pubkey, 24)
|
||||
if info.LastRelayed != "" {
|
||||
t.Errorf("expected empty LastRelayed for idle repeater, got %q", info.LastRelayed)
|
||||
}
|
||||
if info.RelayActive {
|
||||
t.Errorf("expected RelayActive=false for idle repeater, got true")
|
||||
}
|
||||
if info.RelayCount1h != 0 || info.RelayCount24h != 0 {
|
||||
t.Errorf("expected zero relay counts for idle repeater, got 1h=%d 24h=%d", info.RelayCount1h, info.RelayCount24h)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepeaterRelayActivity_Stale verifies that a repeater whose only
|
||||
// relay-hop appearances are older than the configured window reports
|
||||
// a non-empty LastRelayed but relayActive=false.
|
||||
func TestRepeaterRelayActivity_Stale(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
pubkey := "1122334455667788"
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
pubkey, "RepStale", "repeater", recentTS(1))
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
pt := 1
|
||||
staleTS := time.Now().UTC().Add(-48 * time.Hour).Format("2006-01-02T15:04:05.000Z")
|
||||
old := &StoreTx{
|
||||
RawHex: "0100",
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["11"]`,
|
||||
FirstSeen: staleTS,
|
||||
}
|
||||
store.mu.Lock()
|
||||
old.ID = len(store.packets) + 1
|
||||
old.Hash = "test-relay-stale"
|
||||
store.packets = append(store.packets, old)
|
||||
store.byHash[old.Hash] = old
|
||||
store.byTxID[old.ID] = old
|
||||
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], old)
|
||||
store.mu.Unlock()
|
||||
|
||||
info := store.GetRepeaterRelayInfo(pubkey, 24)
|
||||
if info.LastRelayed != staleTS {
|
||||
t.Errorf("expected LastRelayed=%q (stale ts), got %q", staleTS, info.LastRelayed)
|
||||
}
|
||||
if info.RelayActive {
|
||||
t.Errorf("expected RelayActive=false for relay older than window, got true")
|
||||
}
|
||||
if info.RelayCount1h != 0 || info.RelayCount24h != 0 {
|
||||
t.Errorf("expected zero relay counts for stale (>24h) repeater, got 1h=%d 24h=%d", info.RelayCount1h, info.RelayCount24h)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepeaterRelayActivity_IgnoresAdverts verifies that adverts originated
|
||||
// by the repeater itself (payload_type=4) are NOT counted as relay activity —
|
||||
// adverts demonstrate liveness, not relaying.
|
||||
func TestRepeaterRelayActivity_IgnoresAdverts(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
|
||||
pubkey := "deadbeef00000001"
|
||||
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
||||
pubkey, "RepAdvertOnly", "repeater", recentTS(1))
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// Self-advert with the repeater as its own first hop. Should NOT count.
|
||||
pt := 4
|
||||
adv := &StoreTx{
|
||||
RawHex: "0140de",
|
||||
PayloadType: &pt,
|
||||
PathJSON: `["de"]`,
|
||||
FirstSeen: recentTS(2),
|
||||
}
|
||||
store.mu.Lock()
|
||||
adv.ID = len(store.packets) + 1
|
||||
adv.Hash = "test-advert-1"
|
||||
store.packets = append(store.packets, adv)
|
||||
store.byHash[adv.Hash] = adv
|
||||
store.byTxID[adv.ID] = adv
|
||||
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], adv)
|
||||
store.mu.Unlock()
|
||||
|
||||
info := store.GetRepeaterRelayInfo(pubkey, 24)
|
||||
if info.LastRelayed != "" {
|
||||
t.Errorf("expected empty LastRelayed (adverts ignored), got %q", info.LastRelayed)
|
||||
}
|
||||
if info.RelayActive {
|
||||
t.Errorf("expected RelayActive=false (adverts ignored), got true")
|
||||
}
|
||||
if info.RelayCount1h != 0 || info.RelayCount24h != 0 {
|
||||
t.Errorf("expected zero relay counts (adverts ignored), got 1h=%d 24h=%d", info.RelayCount1h, info.RelayCount24h)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/nodes/{pubkey}/health", s.handleNodeHealth).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/paths", s.handleNodePaths).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/analytics", s.handleNodeAnalytics).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/battery", s.handleNodeBattery).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/clock-skew", s.handleFleetClockSkew).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/clock-skew", s.handleNodeClockSkew).Methods("GET")
|
||||
r.HandleFunc("/api/observers/clock-skew", s.handleObserverClockSkew).Methods("GET")
|
||||
@@ -1097,16 +1098,37 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
if s.store != nil {
|
||||
hashInfo := s.store.GetNodeHashSizeInfo()
|
||||
mbCap := s.store.GetMultiByteCapMap()
|
||||
relayWindow := s.cfg.GetHealthThresholds().RelayActiveHours
|
||||
for _, node := range nodes {
|
||||
if pk, ok := node["public_key"].(string); ok {
|
||||
EnrichNodeWithHashSize(node, hashInfo[pk])
|
||||
EnrichNodeWithMultiByte(node, mbCap[pk])
|
||||
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
|
||||
info := s.store.GetRepeaterRelayInfo(pk, relayWindow)
|
||||
if info.LastRelayed != "" {
|
||||
node["last_relayed"] = info.LastRelayed
|
||||
}
|
||||
node["relay_active"] = info.RelayActive
|
||||
node["relay_count_1h"] = info.RelayCount1h
|
||||
node["relay_count_24h"] = info.RelayCount24h
|
||||
node["usefulness_score"] = s.store.GetRepeaterUsefulnessScore(pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cfg.GeoFilter != nil {
|
||||
filtered := nodes[:0]
|
||||
for _, node := range nodes {
|
||||
// Foreign-flagged nodes (#730) are kept even when their GPS lies
|
||||
// outside the geofilter polygon — that's the whole point of the
|
||||
// flag: operators need to SEE bridged/leaked nodes, not have them
|
||||
// filtered away. The ingestor sets foreign_advert=1 when its
|
||||
// configured geo_filter rejected the advert; the server must
|
||||
// surface those.
|
||||
if isForeign, _ := node["foreign"].(bool); isForeign {
|
||||
filtered = append(filtered, node)
|
||||
continue
|
||||
}
|
||||
if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
@@ -1197,6 +1219,18 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
EnrichNodeWithHashSize(node, hashInfo[pubkey])
|
||||
mbCap := s.store.GetMultiByteCapMap()
|
||||
EnrichNodeWithMultiByte(node, mbCap[pubkey])
|
||||
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
|
||||
ht := s.cfg.GetHealthThresholds()
|
||||
info := s.store.GetRepeaterRelayInfo(pubkey, ht.RelayActiveHours)
|
||||
if info.LastRelayed != "" {
|
||||
node["last_relayed"] = info.LastRelayed
|
||||
}
|
||||
node["relay_active"] = info.RelayActive
|
||||
node["relay_window_hours"] = info.WindowHours
|
||||
node["relay_count_1h"] = info.RelayCount1h
|
||||
node["relay_count_24h"] = info.RelayCount24h
|
||||
node["usefulness_score"] = s.store.GetRepeaterUsefulnessScore(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
name := ""
|
||||
|
||||
+70
-5
@@ -3672,6 +3672,51 @@ func (s *PacketStore) GetChannels(region string) []map[string]interface{} {
|
||||
})
|
||||
}
|
||||
|
||||
// #688: scan decoded message text for #hashtag mentions and surface any
|
||||
// previously-unseen channel names as discovered channels. We dedup against
|
||||
// channelMap (matched by name) so a channel that already has traffic does
|
||||
// NOT also appear as discovered.
|
||||
discovered := map[string]string{} // name -> lastActivity
|
||||
for _, snap := range snapshots {
|
||||
if !snap.hasRegion {
|
||||
continue
|
||||
}
|
||||
var decoded decodedGrp
|
||||
if json.Unmarshal([]byte(snap.decodedJSON), &decoded) != nil {
|
||||
continue
|
||||
}
|
||||
if decoded.Type != "CHAN" || decoded.Text == "" {
|
||||
continue
|
||||
}
|
||||
if hasGarbageChars(decoded.Text) {
|
||||
continue
|
||||
}
|
||||
for _, tag := range extractHashtagsFromText(decoded.Text) {
|
||||
// Skip if already a known/decoded channel (by name with or without '#').
|
||||
bare := tag[1:]
|
||||
if _, ok := channelMap[tag]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := channelMap[bare]; ok {
|
||||
continue
|
||||
}
|
||||
if existing, ok := discovered[tag]; !ok || snap.firstSeen > existing {
|
||||
discovered[tag] = snap.firstSeen
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, lastActivity := range discovered {
|
||||
channels = append(channels, map[string]interface{}{
|
||||
"hash": name,
|
||||
"name": name,
|
||||
"lastMessage": nil,
|
||||
"lastSender": nil,
|
||||
"messageCount": 0,
|
||||
"lastActivity": lastActivity,
|
||||
"discovered": true,
|
||||
})
|
||||
}
|
||||
|
||||
s.channelsCacheMu.Lock()
|
||||
s.channelsCacheRes = channels
|
||||
s.channelsCacheKey = cacheKey
|
||||
@@ -5773,21 +5818,41 @@ func (s *PacketStore) GetAnalyticsHashSizes(region string) map[string]interface{
|
||||
|
||||
result := s.computeAnalyticsHashSizes(region)
|
||||
|
||||
// Add multi-byte capability data (only for unfiltered/global view)
|
||||
// Multi-byte capability is a NODE property (derived from each node's own
|
||||
// adverts), not a function of the observing region. The region filter
|
||||
// should only control which nodes appear in the analytics list, not the
|
||||
// evidence used to classify their capability. Always compute capability
|
||||
// against the GLOBAL advert dataset so a region-filtered view doesn't
|
||||
// downgrade every adopter to "unknown" just because the confirming
|
||||
// advert was heard by an out-of-region observer (#bug: meshat.se/JKG
|
||||
// showed 14 unknown vs 0 unknown unfiltered).
|
||||
globalAdopterHS := make(map[string]int)
|
||||
if region == "" {
|
||||
// Pass adopter hash sizes so capability can cross-reference
|
||||
adopterHS := make(map[string]int)
|
||||
if mbNodes, ok := result["multiByteNodes"].([]map[string]interface{}); ok {
|
||||
for _, n := range mbNodes {
|
||||
pk, _ := n["pubkey"].(string)
|
||||
hs, _ := n["hashSize"].(int)
|
||||
if pk != "" && hs >= 2 {
|
||||
adopterHS[pk] = hs
|
||||
globalAdopterHS[pk] = hs
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pull the global multiByteNodes set without the region filter.
|
||||
// Use a separate compute call (not the cached path) to avoid
|
||||
// recursive locking on hashCache and to keep this side-effect free.
|
||||
globalRes := s.computeAnalyticsHashSizes("")
|
||||
if mbNodes, ok := globalRes["multiByteNodes"].([]map[string]interface{}); ok {
|
||||
for _, n := range mbNodes {
|
||||
pk, _ := n["pubkey"].(string)
|
||||
hs, _ := n["hashSize"].(int)
|
||||
if pk != "" && hs >= 2 {
|
||||
globalAdopterHS[pk] = hs
|
||||
}
|
||||
}
|
||||
}
|
||||
result["multiByteCapability"] = s.computeMultiByteCapability(adopterHS)
|
||||
}
|
||||
result["multiByteCapability"] = s.computeMultiByteCapability(globalAdopterHS)
|
||||
|
||||
s.cacheMu.Lock()
|
||||
s.hashCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
|
||||
|
||||
+13
-2
@@ -155,7 +155,8 @@
|
||||
"infraSilentHours": 72,
|
||||
"nodeDegradedHours": 1,
|
||||
"nodeSilentHours": 24,
|
||||
"_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others."
|
||||
"relayActiveHours": 24,
|
||||
"_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others. relayActiveHours: a repeater is shown as 'actively relaying' if its pubkey appeared as a path hop in a non-advert packet within this window (issue #662)."
|
||||
},
|
||||
"defaultRegion": "SJC",
|
||||
"mapDefaults": {
|
||||
@@ -175,6 +176,10 @@
|
||||
"bufferKm": 20,
|
||||
"_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon, save drafts to localStorage with Save Draft, and export a config snippet with Download — paste the snippet here as the `geo_filter` block. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through."
|
||||
},
|
||||
"foreignAdverts": {
|
||||
"mode": "flag",
|
||||
"_comment": "Controls how the ingestor handles ADVERTs whose GPS is OUTSIDE the geo_filter polygon (#730). 'flag' (default): store the advert/node and tag it foreign_advert=1 so operators can see bridged/leaked nodes via the API ('foreign': true on /api/nodes). 'drop': legacy behavior — silently discard the advert (no log, no node row). Only applies when geo_filter is configured; otherwise has no effect."
|
||||
},
|
||||
"regions": {
|
||||
"SJC": "San Jose, US",
|
||||
"SFO": "San Francisco, US",
|
||||
@@ -218,7 +223,8 @@
|
||||
"maxMemoryMB": 1024,
|
||||
"estimatedPacketBytes": 450,
|
||||
"retentionHours": 168,
|
||||
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. retentionHours: only packets younger than this are loaded on startup and kept in memory (0 = unlimited, not recommended for large DBs — causes OOM on cold start). 168 = 7 days. Must be ≤ retention.packetDays * 24."
|
||||
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. retentionHours: only packets younger than this are loaded on startup and kept in memory (0 = unlimited, not recommended for large DBs — causes OOM on cold start). 168 = 7 days. Must be ≤ retention.packetDays * 24.",
|
||||
"_comment_gomemlimit": "On startup the server reads GOMEMLIMIT from the environment if set; otherwise it derives a Go runtime soft memory limit of maxMemoryMB * 1.5 and applies it via debug.SetMemoryLimit. This forces aggressive GC under cgroup pressure so the process self-throttles before the kernel SIGKILLs it. To override, set GOMEMLIMIT explicitly (e.g. GOMEMLIMIT=850MiB). See issue #836."
|
||||
},
|
||||
"resolvedPath": {
|
||||
"backfillHours": 24,
|
||||
@@ -228,6 +234,11 @@
|
||||
"maxAgeDays": 5,
|
||||
"_comment": "Neighbor edges older than this many days are pruned on startup and daily. Default: 5."
|
||||
},
|
||||
"batteryThresholds": {
|
||||
"lowMv": 3300,
|
||||
"criticalMv": 3000,
|
||||
"_comment": "Voltage cutoffs (millivolts) for the per-node battery trend chart on /node-analytics. Latest sample below lowMv shows the node as ⚠️ Low; below criticalMv shows 🪫 Critical. Both default to 3300 / 3000 if omitted. Source data: observer_metrics.battery_mv populated from observer status messages; only nodes that are themselves observers (matching pubkey ↔ observer id) yield a series. Issue #663."
|
||||
},
|
||||
"_comment_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional). region: default IATA region for this source — used when packet/topic doesn't specify one (optional, priority: payload > topic > this field).",
|
||||
"_comment_channelKeys": "Hex keys for decrypting channel messages. Key name = channel display name. public channel key is well-known.",
|
||||
"_comment_hashChannels": "Channel names whose keys are derived via SHA256. Key = SHA256(name)[:16]. Listed here so the ingestor can auto-derive keys.",
|
||||
|
||||
+164
-11
@@ -109,18 +109,38 @@
|
||||
// Tab handling
|
||||
const analyticsTabs = document.getElementById('analyticsTabs');
|
||||
initTabBar(analyticsTabs);
|
||||
// #749 — keep analytics tab + window in URL for deep-linking.
|
||||
function _updateAnalyticsUrl() {
|
||||
if (!window.URLState) return;
|
||||
var twElNow = document.getElementById('analyticsTimeWindow');
|
||||
var updates = {
|
||||
tab: _currentTab && _currentTab !== 'overview' ? _currentTab : '',
|
||||
window: twElNow && twElNow.value ? twElNow.value : ''
|
||||
};
|
||||
// Drop any subview-specific keys that don't belong to the active tab
|
||||
// so switching tabs gives a clean URL. (rf-health uses 'range', 'observer', 'from', 'to')
|
||||
if (_currentTab !== 'rf-health') {
|
||||
var cleared = ['range', 'observer', 'from', 'to'];
|
||||
for (var i = 0; i < cleared.length; i++) updates[cleared[i]] = '';
|
||||
}
|
||||
var newHash = URLState.updateHashParams(updates, location.hash);
|
||||
if (newHash !== location.hash) history.replaceState(null, '', newHash);
|
||||
}
|
||||
|
||||
analyticsTabs.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.tab-btn');
|
||||
if (!btn) return;
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_currentTab = btn.dataset.tab;
|
||||
_updateAnalyticsUrl();
|
||||
renderTab(_currentTab);
|
||||
});
|
||||
|
||||
// Deep-link: #/analytics?tab=collisions
|
||||
// Deep-link: #/analytics?tab=collisions&window=7d
|
||||
const hashParams = location.hash.split('?')[1] || '';
|
||||
const urlTab = new URLSearchParams(hashParams).get('tab');
|
||||
const _ap = new URLSearchParams(hashParams);
|
||||
const urlTab = _ap.get('tab');
|
||||
if (urlTab) {
|
||||
const tabBtn = analyticsTabs.querySelector(`[data-tab="${urlTab}"]`);
|
||||
if (tabBtn) {
|
||||
@@ -129,6 +149,12 @@
|
||||
_currentTab = urlTab;
|
||||
}
|
||||
}
|
||||
// #749 — restore time window from URL.
|
||||
const urlWindow = _ap.get('window');
|
||||
if (urlWindow) {
|
||||
const twInit = document.getElementById('analyticsTimeWindow');
|
||||
if (twInit) twInit.value = urlWindow;
|
||||
}
|
||||
|
||||
RegionFilter.init(document.getElementById('analyticsRegionFilter'));
|
||||
RegionFilter.onChange(function () { loadAnalytics(); });
|
||||
@@ -136,7 +162,7 @@
|
||||
// Time-window picker (#842) — refresh analytics on change.
|
||||
const tw = document.getElementById('analyticsTimeWindow');
|
||||
if (tw) {
|
||||
tw.addEventListener('change', function () { loadAnalytics(); });
|
||||
tw.addEventListener('change', function () { _updateAnalyticsUrl(); loadAnalytics(); });
|
||||
}
|
||||
|
||||
// Delegated click/keyboard handler for clickable table rows
|
||||
@@ -737,6 +763,7 @@
|
||||
// ===================== CHANNELS =====================
|
||||
var _channelSortState = null;
|
||||
var _channelData = null;
|
||||
var _channelRenderGen = 0;
|
||||
var CHANNEL_SORT_KEY = 'meshcore-channel-sort';
|
||||
|
||||
function loadChannelSort() {
|
||||
@@ -747,6 +774,18 @@
|
||||
return { col: 'lastActivity', dir: 'desc' };
|
||||
}
|
||||
|
||||
// True when the user has explicitly chosen a sort (saved in localStorage).
|
||||
// Used by the grouped analytics view to decide whether to apply its own
|
||||
// default ("messages desc") instead of the global flat-list default.
|
||||
function hasSavedChannelSort() {
|
||||
try {
|
||||
var s = localStorage.getItem(CHANNEL_SORT_KEY);
|
||||
if (!s) return false;
|
||||
var p = JSON.parse(s);
|
||||
return !!(p && p.col && p.dir);
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function saveChannelSort(state) {
|
||||
try { localStorage.setItem(CHANNEL_SORT_KEY, JSON.stringify(state)); } catch (e) {}
|
||||
}
|
||||
@@ -781,20 +820,107 @@
|
||||
}
|
||||
|
||||
function channelRowHtml(c) {
|
||||
var name = c.displayName || c.name || 'Unknown';
|
||||
return '<tr class="clickable-row" data-action="navigate" data-value="#/channels?ch=' + c.hash + '" tabindex="0" role="row">' +
|
||||
'<td><strong>' + esc(c.name || 'Unknown') + '</strong></td>' +
|
||||
'<td><strong>' + esc(name) + '</strong></td>' +
|
||||
'<td class="mono">' + (typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash) + '</td>' +
|
||||
'<td>' + c.messages + '</td>' +
|
||||
'<td>' + c.senders + '</td>' +
|
||||
'<td>' + timeAgo(c.lastActivity) + '</td>' +
|
||||
'<td>' + (c.encrypted ? '🔒' : '✅') + '</td>' +
|
||||
'<td>' + (c.encrypted ? (c.group === 'mine' ? '🔑' : '🔒') : '✅') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function channelTbodyHtml(channels, col, dir) {
|
||||
// ── PSK-aware decoration ──────────────────────────────────────────────────
|
||||
// Server returns raw "chNNN" placeholder names for encrypted channels it
|
||||
// doesn't know. Decorate so the UI shows a useful display name and a
|
||||
// group bucket: mine / network / encrypted. Pure function for testability.
|
||||
function decorateAnalyticsChannels(channels, hashByteToKeyName, labels) {
|
||||
var keyMap = hashByteToKeyName || {};
|
||||
var lab = labels || {};
|
||||
var out = [];
|
||||
for (var i = 0; i < (channels || []).length; i++) {
|
||||
var c = channels[i];
|
||||
var copy = Object.assign({}, c);
|
||||
var hashNum = typeof c.hash === 'number' ? c.hash : parseInt(c.hash, 10);
|
||||
var rawName = String(c.name || '');
|
||||
var isPlaceholder = /^ch(\d+|\?)$/.test(rawName);
|
||||
if (c.encrypted) {
|
||||
var keyName = !isNaN(hashNum) ? keyMap[hashNum] : null;
|
||||
if (keyName) {
|
||||
copy.displayName = lab[keyName] || keyName;
|
||||
copy.group = 'mine';
|
||||
} else if (isPlaceholder || !rawName) {
|
||||
// Placeholder ("chNNN") or empty name → render as opaque encrypted.
|
||||
// Empty-name encrypted rows would otherwise leak through with an
|
||||
// empty <strong> in the row; force the placeholder rendering.
|
||||
copy.displayName = !isNaN(hashNum)
|
||||
? '🔒 Encrypted (0x' + hashNum.toString(16).toUpperCase().padStart(2, '0') + ')'
|
||||
: '🔒 Encrypted';
|
||||
copy.group = 'encrypted';
|
||||
} else {
|
||||
// Server gave us a real name (rainbow table hit) for an encrypted ch.
|
||||
copy.displayName = rawName;
|
||||
copy.group = 'network';
|
||||
}
|
||||
} else {
|
||||
copy.displayName = rawName || 'Unknown';
|
||||
copy.group = 'network';
|
||||
}
|
||||
out.push(copy);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build the (hash byte → key name) map from ChannelDecrypt's stored keys.
|
||||
// Async because computeChannelHash uses subtle.digest. Returns {} if the
|
||||
// module or its keys are unavailable (graceful fallback).
|
||||
async function buildHashKeyMap() {
|
||||
if (typeof ChannelDecrypt === 'undefined' || !ChannelDecrypt.getStoredKeys) return {};
|
||||
var keys = ChannelDecrypt.getStoredKeys();
|
||||
var map = {};
|
||||
var names = Object.keys(keys || {});
|
||||
for (var ni = 0; ni < names.length; ni++) {
|
||||
var name = names[ni];
|
||||
try {
|
||||
var bytes = ChannelDecrypt.hexToBytes(keys[name]);
|
||||
var hb = await ChannelDecrypt.computeChannelHash(bytes);
|
||||
if (typeof hb === 'number') map[hb] = name;
|
||||
} catch (e) { /* skip bad key */ }
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function channelTbodyHtml(channels, col, dir, opts) {
|
||||
var sorted = sortChannels(channels, col, dir);
|
||||
var parts = [];
|
||||
for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i]));
|
||||
if (opts && opts.grouped) {
|
||||
// Group by .group: mine → network → encrypted. Inside each group keep
|
||||
// the active sort (caller passes col/dir; for the integration we sort
|
||||
// by messages desc by default).
|
||||
var groups = { mine: [], network: [], encrypted: [] };
|
||||
for (var gi = 0; gi < sorted.length; gi++) {
|
||||
var g = sorted[gi].group || (sorted[gi].encrypted ? 'encrypted' : 'network');
|
||||
(groups[g] || (groups[g] = [])).push(sorted[gi]);
|
||||
}
|
||||
var sections = [
|
||||
{ key: 'mine', label: '🔑 My Channels' },
|
||||
{ key: 'network', label: '📻 Network' },
|
||||
{ key: 'encrypted', label: '🔒 Encrypted' },
|
||||
];
|
||||
for (var si = 0; si < sections.length; si++) {
|
||||
var rows = groups[sections[si].key] || [];
|
||||
if (!rows.length) continue;
|
||||
parts.push(
|
||||
'<tr class="ch-section-row"><td colspan="6" class="ch-section-header">' +
|
||||
esc(sections[si].label) + ' <span class="text-muted">(' + rows.length + ')</span>' +
|
||||
'</td></tr>'
|
||||
);
|
||||
for (var ri = 0; ri < rows.length; ri++) parts.push(channelRowHtml(rows[ri]));
|
||||
}
|
||||
} else {
|
||||
for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i]));
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
@@ -825,13 +951,39 @@
|
||||
var tbody = document.getElementById('channelsTbody');
|
||||
var thead = document.querySelector('#channelsTable thead');
|
||||
if (!tbody || !_channelData) return;
|
||||
tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir);
|
||||
tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true });
|
||||
if (thead) thead.outerHTML = channelTheadHtml(_channelSortState.col, _channelSortState.dir);
|
||||
}
|
||||
|
||||
function renderChannels(el, ch) {
|
||||
_channelData = ch.channels;
|
||||
if (!_channelSortState) _channelSortState = loadChannelSort();
|
||||
// Decorate first so grouping/display name reflect locally-stored PSK keys.
|
||||
// buildHashKeyMap is async; render once with a sync best-effort empty map,
|
||||
// then upgrade once keys resolve. That keeps first paint fast and avoids
|
||||
// blocking on subtle.digest in environments where it's slow.
|
||||
var rawChannels = ch.channels || [];
|
||||
// Resolve the persisted sort first so the default-fallback below doesn't
|
||||
// shadow what the user previously chose. Default for the grouped view is
|
||||
// messages desc (matches the PR description); only used when nothing saved.
|
||||
if (!_channelSortState) {
|
||||
_channelSortState = hasSavedChannelSort()
|
||||
? loadChannelSort()
|
||||
: { col: 'messages', dir: 'desc' };
|
||||
}
|
||||
var ranOnce = false;
|
||||
// Generation token: if renderChannels is called again before
|
||||
// buildHashKeyMap() resolves, the older promise must not clobber the
|
||||
// newer rawChannels / decoration with stale-key data.
|
||||
var myGen = ++_channelRenderGen;
|
||||
function applyDecorate(map) {
|
||||
if (myGen !== _channelRenderGen) return; // superseded
|
||||
var labels = (typeof ChannelDecrypt !== 'undefined' && ChannelDecrypt.getLabels)
|
||||
? ChannelDecrypt.getLabels() : {};
|
||||
_channelData = decorateAnalyticsChannels(rawChannels, map, labels);
|
||||
if (ranOnce) updateChannelTable();
|
||||
}
|
||||
applyDecorate({});
|
||||
ranOnce = true;
|
||||
buildHashKeyMap().then(applyDecorate).catch(function () { /* graceful */ });
|
||||
|
||||
var timelineHtml = renderChannelTimeline(ch.channelTimeline);
|
||||
var topSendersHtml = renderTopSenders(ch.topSenders);
|
||||
@@ -844,7 +996,7 @@
|
||||
'<table class="analytics-table" id="channelsTable">' +
|
||||
channelTheadHtml(_channelSortState.col, _channelSortState.dir) +
|
||||
'<tbody id="channelsTbody">' +
|
||||
channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir) +
|
||||
channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true }) +
|
||||
'</tbody>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
@@ -2055,6 +2207,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
|
||||
// Expose for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
window._analyticsDecorateChannels = decorateAnalyticsChannels;
|
||||
window._analyticsSortChannels = sortChannels;
|
||||
window._analyticsLoadChannelSort = loadChannelSort;
|
||||
window._analyticsSaveChannelSort = saveChannelSort;
|
||||
|
||||
+143
@@ -501,6 +501,148 @@ function connectWS() {
|
||||
function onWS(fn) { wsListeners.push(fn); }
|
||||
function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); }
|
||||
|
||||
// --- Pull-to-reconnect (#1063) ---
|
||||
// Touch-device pull-down at scrollTop=0 reconnects the WebSocket
|
||||
// (instead of triggering native pull-to-refresh full-page reload).
|
||||
// Visual indicator pulses during pull; toast confirms result.
|
||||
const PULL_THRESHOLD_PX = 80;
|
||||
let _pullToast = null;
|
||||
let _pullToastTimer = null;
|
||||
let _pullIndicator = null;
|
||||
|
||||
function _ensurePullIndicator() {
|
||||
if (_pullIndicator && document.body && typeof document.body.contains === 'function' && document.body.contains(_pullIndicator)) return _pullIndicator;
|
||||
if (_pullIndicator) return _pullIndicator;
|
||||
const el = document.createElement('div');
|
||||
el.id = 'pullReconnectIndicator';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
el.innerHTML = '<span class="prr-icon">⟳</span>';
|
||||
el.style.cssText = [
|
||||
'position:fixed', 'top:0', 'left:50%', 'transform:translate(-50%,-100%)',
|
||||
'z-index:99999', 'padding:8px 14px', 'border-radius:0 0 12px 12px',
|
||||
'background:var(--accent,#2563eb)', 'color:#fff', 'font:14px/1 var(--font,system-ui)',
|
||||
'box-shadow:0 2px 8px rgba(0,0,0,.2)', 'pointer-events:none',
|
||||
'transition:transform .15s ease, opacity .15s ease', 'opacity:0',
|
||||
].join(';');
|
||||
document.body.appendChild(el);
|
||||
_pullIndicator = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
function _showPullToast(msg, ok) {
|
||||
try {
|
||||
if (_pullToast && _pullToast.remove) _pullToast.remove();
|
||||
} catch (e) {}
|
||||
if (_pullToastTimer) { try { clearTimeout(_pullToastTimer); } catch (e) {} _pullToastTimer = null; }
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pull-reconnect-toast';
|
||||
el.textContent = msg;
|
||||
el.style.cssText = [
|
||||
'position:fixed', 'top:12px', 'left:50%', 'transform:translateX(-50%)',
|
||||
'z-index:99999', 'padding:8px 16px', 'border-radius:8px',
|
||||
'background:' + (ok ? 'var(--status-green,#16a34a)' : 'var(--status-red,#dc2626)'),
|
||||
'color:#fff', 'font:14px/1.2 var(--font,system-ui)',
|
||||
'box-shadow:0 2px 8px rgba(0,0,0,.2)', 'pointer-events:none',
|
||||
].join(';');
|
||||
document.body.appendChild(el);
|
||||
_pullToast = el;
|
||||
_pullToastTimer = setTimeout(function () {
|
||||
_pullToastTimer = null;
|
||||
try { el.remove(); } catch (e) {}
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function pullReconnect() {
|
||||
// If WS is connected (readyState OPEN), give a brief "Connected ✓"
|
||||
// confirmation but still cycle so the user sees fresh data.
|
||||
const wasOpen = ws && ws.readyState === 1;
|
||||
if (wasOpen) {
|
||||
_showPullToast('Connected ✓', true);
|
||||
// Fast cycle: close and let onclose reconnect immediately
|
||||
try { ws.close(); } catch (e) {}
|
||||
} else {
|
||||
_showPullToast('Reconnecting…', true);
|
||||
try { if (ws) ws.close(); } catch (e) {}
|
||||
// onclose handler schedules reconnect; force one now in case ws was null
|
||||
try { connectWS(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function _isTouchDevice() {
|
||||
try {
|
||||
return ('ontouchstart' in window) ||
|
||||
(navigator && (navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0));
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function setupPullToReconnect() {
|
||||
// Always attach listeners (tests + future-proof). Inside the handler we
|
||||
// gate on _isTouchDevice() AND scrollTop=0 so desktop/scrolled pages are
|
||||
// unaffected.
|
||||
let startY = null;
|
||||
let pulling = false;
|
||||
let dist = 0;
|
||||
|
||||
function getScrollTop() {
|
||||
return (document.documentElement && document.documentElement.scrollTop) ||
|
||||
(document.body && document.body.scrollTop) || 0;
|
||||
}
|
||||
|
||||
function onStart(e) {
|
||||
if (!_isTouchDevice()) return;
|
||||
if (getScrollTop() > 0) { startY = null; pulling = false; return; }
|
||||
const t = e.touches && e.touches[0];
|
||||
startY = t ? t.clientY : null;
|
||||
pulling = false;
|
||||
dist = 0;
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (startY == null) return;
|
||||
if (getScrollTop() > 0) { startY = null; pulling = false; return; }
|
||||
const t = e.touches && e.touches[0];
|
||||
if (!t) return;
|
||||
const dy = t.clientY - startY;
|
||||
if (dy <= 0) return; // upward swipe — ignore
|
||||
dist = dy;
|
||||
if (dy > 8) {
|
||||
pulling = true;
|
||||
const ind = _ensurePullIndicator();
|
||||
const pct = Math.min(1, dy / PULL_THRESHOLD_PX);
|
||||
ind.style.opacity = String(pct);
|
||||
ind.style.transform = 'translate(-50%, ' + (-100 + pct * 100) + '%)';
|
||||
const icon = ind.querySelector && ind.querySelector('.prr-icon');
|
||||
if (icon) icon.style.transform = 'rotate(' + Math.round(pct * 360) + 'deg)';
|
||||
// Prevent native pull-to-refresh ONLY once we've committed to the gesture
|
||||
if (dy > 16 && typeof e.preventDefault === 'function' && e.cancelable !== false) {
|
||||
try { e.preventDefault(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
const wasPulling = pulling;
|
||||
const finalDist = dist;
|
||||
startY = null; pulling = false; dist = 0;
|
||||
if (_pullIndicator) {
|
||||
_pullIndicator.style.opacity = '0';
|
||||
_pullIndicator.style.transform = 'translate(-50%, -100%)';
|
||||
}
|
||||
if (wasPulling && finalDist >= PULL_THRESHOLD_PX) {
|
||||
try { (window.pullReconnect || pullReconnect)(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', onStart, { passive: true });
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('touchend', onEnd, { passive: true });
|
||||
document.addEventListener('touchcancel', onEnd, { passive: true });
|
||||
}
|
||||
|
||||
window.pullReconnect = pullReconnect;
|
||||
window.setupPullToReconnect = setupPullToReconnect;
|
||||
window.connectWS = connectWS;
|
||||
|
||||
/* Global escapeHtml — used by multiple pages */
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return '';
|
||||
@@ -676,6 +818,7 @@ window.addEventListener('timestamp-mode-changed', () => {
|
||||
});
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
connectWS();
|
||||
setupPullToReconnect();
|
||||
|
||||
// --- Dark Mode ---
|
||||
const darkToggle = document.getElementById('darkModeToggle');
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* channel-qr.js — QR code generation + scanning for MeshCore channels.
|
||||
*
|
||||
* URL format (per firmware spec):
|
||||
* meshcore://channel/add?name=<urlencoded>&secret=<32hex>
|
||||
*
|
||||
* Public API (window.ChannelQR):
|
||||
* buildUrl(name, secretHex) → string
|
||||
* parseChannelUrl(url) → {name, secret} | null
|
||||
* generate(name, secretHex, target) → renders QR + URL + Copy Key into `target`
|
||||
* scan() → Promise<{name, secret} | null>
|
||||
*
|
||||
* Self-contained: does NOT touch channels.js / channel-decrypt.js.
|
||||
* The PR that wires the modal into this module is #3.
|
||||
*
|
||||
* Vendored deps (loaded by index.html):
|
||||
* - public/vendor/qrcode.js (davidshimjs/qrcodejs, MIT) — QR rendering
|
||||
* - public/vendor/jsqr.min.js (cozmo/jsQR, Apache-2.0) — QR decoding from camera
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const SCHEME_PREFIX = 'meshcore://channel/add';
|
||||
const HEX32_RE = /^[0-9a-fA-F]{32}$/;
|
||||
|
||||
function buildUrl(name, secretHex) {
|
||||
return SCHEME_PREFIX + '?name=' + encodeURIComponent(String(name)) +
|
||||
'&secret=' + String(secretHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* parseChannelUrl(url) → { name, secret } | null
|
||||
* Strict: scheme must be `meshcore:`, host+path `//channel/add`,
|
||||
* both `name` and `secret` query params present, secret must be 32 hex chars.
|
||||
*/
|
||||
function parseChannelUrl(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
if (url.indexOf(SCHEME_PREFIX) !== 0) return null;
|
||||
|
||||
// Strip prefix → query string
|
||||
const rest = url.slice(SCHEME_PREFIX.length);
|
||||
if (rest[0] !== '?' && rest !== '') return null;
|
||||
const qs = rest.slice(1);
|
||||
if (!qs) return null;
|
||||
|
||||
const params = {};
|
||||
const pairs = qs.split('&');
|
||||
for (let i = 0; i < pairs.length; i++) {
|
||||
const eq = pairs[i].indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const k = pairs[i].slice(0, eq);
|
||||
const v = pairs[i].slice(eq + 1);
|
||||
try { params[k] = decodeURIComponent(v); }
|
||||
catch (_e) { return null; }
|
||||
}
|
||||
|
||||
if (!params.name || !params.secret) return null;
|
||||
if (!HEX32_RE.test(params.secret)) return null;
|
||||
|
||||
return { name: params.name, secret: params.secret.toLowerCase() };
|
||||
}
|
||||
|
||||
// ---------- DOM helpers (browser-only) ----------
|
||||
|
||||
function _hasDom() {
|
||||
return typeof document !== 'undefined' && document.createElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render QR + URL + Copy Key button into `target`.
|
||||
* Requires window.QRCode (vendor/qrcode.js) loaded.
|
||||
*/
|
||||
function generate(name, secretHex, target) {
|
||||
if (!_hasDom() || !target) return;
|
||||
target.innerHTML = '';
|
||||
|
||||
const url = buildUrl(name, secretHex);
|
||||
|
||||
const qrBox = document.createElement('div');
|
||||
qrBox.className = 'channel-qr-canvas';
|
||||
qrBox.style.display = 'inline-block';
|
||||
target.appendChild(qrBox);
|
||||
|
||||
if (typeof root.QRCode === 'function') {
|
||||
try {
|
||||
// davidshimjs/qrcodejs API: new QRCode(el, {text, width, height, ...})
|
||||
new root.QRCode(qrBox, {
|
||||
text: url,
|
||||
width: 192,
|
||||
height: 192,
|
||||
correctLevel: root.QRCode.CorrectLevel ? root.QRCode.CorrectLevel.M : 0,
|
||||
});
|
||||
} catch (e) {
|
||||
qrBox.textContent = '[QR render failed: ' + (e && e.message || e) + ']';
|
||||
}
|
||||
} else {
|
||||
qrBox.textContent = '[QR library not loaded]';
|
||||
}
|
||||
|
||||
const urlLine = document.createElement('div');
|
||||
urlLine.className = 'channel-qr-url';
|
||||
urlLine.style.cssText = 'font-family:monospace;font-size:11px;word-break:break-all;margin-top:6px;';
|
||||
urlLine.textContent = url;
|
||||
target.appendChild(urlLine);
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'channel-qr-copy';
|
||||
copyBtn.textContent = '📋 Copy Key';
|
||||
copyBtn.style.cssText = 'margin-top:6px;';
|
||||
copyBtn.addEventListener('click', function () {
|
||||
const text = secretHex;
|
||||
const done = function () {
|
||||
const orig = copyBtn.textContent;
|
||||
copyBtn.textContent = '✓ Copied';
|
||||
setTimeout(function () { copyBtn.textContent = orig; }, 1200);
|
||||
};
|
||||
if (root.navigator && root.navigator.clipboard && root.navigator.clipboard.writeText) {
|
||||
root.navigator.clipboard.writeText(text).then(done, function () {
|
||||
// Fallback: select text in a temp input
|
||||
_fallbackCopy(text); done();
|
||||
});
|
||||
} else {
|
||||
_fallbackCopy(text); done();
|
||||
}
|
||||
});
|
||||
target.appendChild(copyBtn);
|
||||
}
|
||||
|
||||
function _fallbackCopy(text) {
|
||||
if (!_hasDom()) return;
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (_e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
// ---------- Camera scan ----------
|
||||
|
||||
/**
|
||||
* scan() → Promise<{name, secret} | null>
|
||||
*
|
||||
* Opens a small modal with a live camera preview, decodes via jsQR,
|
||||
* resolves with the parsed channel info on first valid match. Closes
|
||||
* camera on resolve/reject. Resolves with `null` if user cancels or
|
||||
* camera permission is denied (graceful fallback path).
|
||||
*/
|
||||
function scan() {
|
||||
if (!_hasDom()) return Promise.resolve(null);
|
||||
const nav = root.navigator;
|
||||
if (!nav || !nav.mediaDevices || !nav.mediaDevices.getUserMedia ||
|
||||
typeof root.jsQR !== 'function') {
|
||||
_showCameraFallback();
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return new Promise(function (resolve) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'channel-qr-scan-overlay';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);' +
|
||||
'display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:99999;';
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.setAttribute('playsinline', 'true');
|
||||
video.style.cssText = 'max-width:90vw;max-height:60vh;background:#000;';
|
||||
overlay.appendChild(video);
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.style.cssText = 'color:#fff;margin-top:12px;font-family:sans-serif;';
|
||||
status.textContent = 'Point camera at a MeshCore channel QR…';
|
||||
overlay.appendChild(status);
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.style.cssText = 'margin-top:12px;';
|
||||
overlay.appendChild(cancelBtn);
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let stream = null;
|
||||
let rafId = 0;
|
||||
let done = false;
|
||||
|
||||
function cleanup(result) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(function (t) { try { t.stop(); } catch (_e) {} });
|
||||
}
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
cancelBtn.addEventListener('click', function () { cleanup(null); });
|
||||
|
||||
function tick() {
|
||||
if (done) return;
|
||||
if (video.readyState === video.HAVE_ENOUGH_DATA) {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
let imgData;
|
||||
try { imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); }
|
||||
catch (_e) { rafId = requestAnimationFrame(tick); return; }
|
||||
const code = root.jsQR(imgData.data, imgData.width, imgData.height, {
|
||||
inversionAttempts: 'dontInvert',
|
||||
});
|
||||
if (code && code.data) {
|
||||
const parsed = parseChannelUrl(code.data);
|
||||
if (parsed) { cleanup(parsed); return; }
|
||||
status.textContent = 'QR found but not a MeshCore channel — keep trying…';
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
nav.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
|
||||
.then(function (s) {
|
||||
stream = s;
|
||||
video.srcObject = s;
|
||||
video.play().then(function () { tick(); }, function () { tick(); });
|
||||
})
|
||||
.catch(function () {
|
||||
status.textContent = 'Camera not available — paste key manually.';
|
||||
setTimeout(function () { cleanup(null); }, 1800);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _showCameraFallback() {
|
||||
if (!_hasDom()) return;
|
||||
const note = document.createElement('div');
|
||||
note.className = 'channel-qr-fallback';
|
||||
note.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);' +
|
||||
'background:#222;color:#fff;padding:10px 14px;border-radius:6px;z-index:99999;';
|
||||
note.textContent = 'Camera not available — paste key manually.';
|
||||
document.body.appendChild(note);
|
||||
setTimeout(function () {
|
||||
if (note.parentNode) note.parentNode.removeChild(note);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
root.ChannelQR = {
|
||||
buildUrl: buildUrl,
|
||||
parseChannelUrl: parseChannelUrl,
|
||||
generate: generate,
|
||||
scan: scan,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+423
-123
@@ -631,33 +631,77 @@
|
||||
<div class="ch-sidebar" aria-label="Channel list">
|
||||
<div class="ch-sidebar-header">
|
||||
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
|
||||
<label class="ch-encrypted-toggle" title="Show encrypted channels (no key configured)">
|
||||
<input type="checkbox" id="chShowEncrypted"> <span class="ch-toggle-label">🔒 No key</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ch-key-input-wrap" style="padding:4px 8px">
|
||||
<form id="chKeyForm" autocomplete="off" class="ch-add-form">
|
||||
<div class="ch-add-row">
|
||||
<input type="text" id="chKeyInput" class="ch-key-input"
|
||||
placeholder="#channelname"
|
||||
aria-label="Channel name or hex key" spellcheck="false">
|
||||
<button type="submit" class="ch-add-btn" title="Add channel">+</button>
|
||||
</div>
|
||||
<div class="ch-add-row">
|
||||
<input type="text" id="chKeyLabelInput" class="ch-key-label-input"
|
||||
placeholder="optional name (e.g. My Crew)"
|
||||
aria-label="Optional display name for this channel" spellcheck="false">
|
||||
</div>
|
||||
<div class="ch-add-hint">e.g. #LongFast or 32-char hex key — decrypted in your browser.</div>
|
||||
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
|
||||
</form>
|
||||
<button type="button" id="chAddChannelBtn" class="ch-add-channel-btn"
|
||||
aria-label="Add channel" title="Add a channel — generate, paste a key, or monitor a hashtag">+ Add Channel</button>
|
||||
</div>
|
||||
<a href="#/analytics" class="ch-analytics-link"
|
||||
title="Open the Analytics page to see channel activity stats">📊 Channel Analytics →</a>
|
||||
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
|
||||
<div id="chRegionFilter" class="region-filter-container" style="padding:0 8px"></div>
|
||||
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
|
||||
<div class="ch-loading">Loading channels…</div>
|
||||
</div>
|
||||
<div class="ch-sidebar-resize" aria-hidden="true"></div>
|
||||
</div>
|
||||
<!-- #1034 PR1: Add Channel modal -->
|
||||
<div id="chAddChannelModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chModalTitle" hidden>
|
||||
<div class="modal ch-modal" role="document">
|
||||
<button type="button" class="modal-close ch-modal-close" id="chModalClose" data-action="ch-modal-close" aria-label="Close">✕</button>
|
||||
<h3 id="chModalTitle">Add Channel</h3>
|
||||
<div class="ch-modal-callout" role="note">
|
||||
⚠️ Channels are saved to <strong>THIS browser only</strong>. They won't appear on other devices or browsers, and clearing browser data will remove them.
|
||||
</div>
|
||||
|
||||
<section class="ch-modal-section" aria-labelledby="chSecGenTitle">
|
||||
<h4 id="chSecGenTitle" class="ch-modal-section-title">Generate PSK Channel</h4>
|
||||
<p class="ch-modal-section-hint">Create a new private channel with a random key. Share the QR code with others to add it.</p>
|
||||
<div class="ch-modal-row">
|
||||
<input type="text" id="chGenerateName" class="ch-modal-input" placeholder="Channel name (e.g. My Crew)" aria-label="Channel name" spellcheck="false">
|
||||
<button type="button" id="chGenerateBtn" class="btn-primary">Generate & Show QR</button>
|
||||
</div>
|
||||
<div id="qr-output" class="ch-qr-output" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="ch-modal-section" aria-labelledby="chSecPskTitle">
|
||||
<h4 id="chSecPskTitle" class="ch-modal-section-title">Add Private Channel (PSK)</h4>
|
||||
<p class="ch-modal-section-hint">Paste a 32-character hex key someone shared with you, or scan their QR code.</p>
|
||||
<div class="ch-modal-row">
|
||||
<input type="text" id="chPskKey" class="ch-modal-input ch-modal-input--mono"
|
||||
placeholder="32-char hex key (0-9, a-f)"
|
||||
pattern="[0-9a-fA-F]{32}"
|
||||
maxlength="32"
|
||||
aria-label="32-character hex PSK key" spellcheck="false" autocomplete="off">
|
||||
<button type="button" id="scan-qr-btn" class="ch-modal-btn-secondary" title="Scan a meshcore:// channel QR with your camera">📷 Scan QR</button>
|
||||
</div>
|
||||
<div class="ch-modal-row">
|
||||
<input type="text" id="chPskName" class="ch-modal-input" placeholder="Display name (optional)" aria-label="Optional display name" spellcheck="false">
|
||||
<button type="button" id="chPskAddBtn" class="btn-primary">Add</button>
|
||||
</div>
|
||||
<div id="chPskError" class="ch-modal-error" style="display:none" role="alert"></div>
|
||||
</section>
|
||||
|
||||
<section class="ch-modal-section" aria-labelledby="chSecTagTitle">
|
||||
<h4 id="chSecTagTitle" class="ch-modal-section-title">Monitor Hashtag Channel</h4>
|
||||
<p class="ch-modal-section-hint">Decrypt traffic on a public hashtag channel by deriving the key from its name.</p>
|
||||
<div class="ch-modal-row ch-hashtag-row">
|
||||
<span class="ch-hashtag-prefix" aria-hidden="true">#</span>
|
||||
<input type="text" id="chHashtagName" class="ch-modal-input"
|
||||
placeholder="meshcore"
|
||||
aria-label="Hashtag channel name (without #)" spellcheck="false" autocomplete="off">
|
||||
<button type="button" id="chHashtagBtn" class="btn-primary">Monitor</button>
|
||||
</div>
|
||||
<div class="ch-modal-warn">⚠ Case-sensitive — <code>#meshcore</code> ≠ <code>#MeshCore</code></div>
|
||||
</section>
|
||||
|
||||
<section id="chShareSection" class="ch-modal-section" hidden aria-labelledby="chShareHeading">
|
||||
<h4 id="chShareHeading" class="ch-modal-section-title">Share Channel</h4>
|
||||
<div id="chShareOutput" class="ch-share-output" aria-live="polite"></div>
|
||||
</section>
|
||||
<div class="ch-modal-footer">
|
||||
🔒 Keys stay in your browser — CoreScope is a passive observer that monitors and decrypts traffic but cannot transmit over RF. Use ✕ to remove individual channels.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ch-main" role="region" aria-label="Channel messages">
|
||||
<div class="ch-main-header" id="chHeader">
|
||||
<button class="ch-back-btn" id="chBackBtn" aria-label="Back to channels" data-action="ch-back">←</button>
|
||||
@@ -673,15 +717,10 @@
|
||||
|
||||
RegionFilter.init(document.getElementById('chRegionFilter'));
|
||||
|
||||
// Encrypted channels toggle (#727)
|
||||
var showEncryptedCb = document.getElementById('chShowEncrypted');
|
||||
var showEncrypted = localStorage.getItem('channels-show-encrypted') === 'true';
|
||||
showEncryptedCb.checked = showEncrypted;
|
||||
showEncryptedCb.addEventListener('change', function () {
|
||||
showEncrypted = showEncryptedCb.checked;
|
||||
localStorage.setItem('channels-show-encrypted', showEncrypted ? 'true' : 'false');
|
||||
loadChannels(true);
|
||||
});
|
||||
// #1034 PR1: encrypted-channels visibility now driven by sectioned sidebar.
|
||||
// Always include encrypted channels in the API call; the renderer groups them.
|
||||
var showEncrypted = true;
|
||||
try { localStorage.setItem('channels-show-encrypted', 'true'); } catch (e) { /* quota */ }
|
||||
|
||||
regionChangeHandler = RegionFilter.onChange(function () {
|
||||
loadChannels(true).then(async function () {
|
||||
@@ -690,36 +729,135 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Channel key input handler (#725 M2, improved UX #759)
|
||||
var chKeyForm = document.getElementById('chKeyForm');
|
||||
if (chKeyForm) {
|
||||
var submitHandler = async function (e) {
|
||||
e.preventDefault();
|
||||
var input = document.getElementById('chKeyInput');
|
||||
var labelInput = document.getElementById('chKeyLabelInput');
|
||||
var val = (input.value || '').trim();
|
||||
var label = labelInput ? (labelInput.value || '').trim() : '';
|
||||
if (!val) return;
|
||||
input.value = '';
|
||||
if (labelInput) labelInput.value = '';
|
||||
await addUserChannel(val, label);
|
||||
};
|
||||
chKeyForm.addEventListener('submit', submitHandler);
|
||||
var chKeyInput = document.getElementById('chKeyInput');
|
||||
if (chKeyInput) {
|
||||
chKeyInput.addEventListener('focus', function () {
|
||||
var st = document.getElementById('chAddStatus');
|
||||
if (st) { st.style.display = 'none'; clearTimeout(statusTimer); statusTimer = null; }
|
||||
});
|
||||
}
|
||||
// #1034 PR1: Add Channel modal wiring (replaces inline form)
|
||||
var modalEl = document.getElementById('chAddChannelModal');
|
||||
function openAddModal() {
|
||||
if (!modalEl) return;
|
||||
modalEl.classList.remove('hidden');
|
||||
modalEl.removeAttribute('hidden');
|
||||
var first = document.getElementById('chGenerateName');
|
||||
if (first) try { first.focus(); } catch (e) { /* noop */ }
|
||||
}
|
||||
function closeAddModal() {
|
||||
if (!modalEl) return;
|
||||
modalEl.classList.add('hidden');
|
||||
modalEl.setAttribute('hidden', '');
|
||||
var err = document.getElementById('chPskError');
|
||||
if (err) { err.style.display = 'none'; err.textContent = ''; }
|
||||
var shareOut = document.getElementById('chShareOutput');
|
||||
if (shareOut) { shareOut.innerHTML = ''; }
|
||||
var shareSec = document.getElementById('chShareSection');
|
||||
if (shareSec) { shareSec.hidden = true; }
|
||||
}
|
||||
var addBtn = document.getElementById('chAddChannelBtn');
|
||||
if (addBtn) addBtn.addEventListener('click', openAddModal);
|
||||
if (modalEl) {
|
||||
modalEl.addEventListener('click', function (e) {
|
||||
// Close on overlay backdrop click or any [data-action=ch-modal-close]
|
||||
var closeEl = e.target.closest('[data-action="ch-modal-close"]');
|
||||
if (closeEl || e.target === modalEl) {
|
||||
e.preventDefault();
|
||||
closeAddModal();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !modalEl.classList.contains('hidden')) {
|
||||
closeAddModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-enable encrypted toggle if deep-linking to an encrypted channel
|
||||
if (routeParam && routeParam.startsWith('enc_') && !showEncrypted) {
|
||||
showEncrypted = true;
|
||||
showEncryptedCb.checked = true;
|
||||
localStorage.setItem('channels-show-encrypted', 'true');
|
||||
}
|
||||
// Section 1: Generate PSK
|
||||
var genBtn = document.getElementById('chGenerateBtn');
|
||||
if (genBtn) genBtn.addEventListener('click', async function () {
|
||||
var nameEl = document.getElementById('chGenerateName');
|
||||
var label = nameEl ? (nameEl.value || '').trim() : '';
|
||||
// 16 random bytes -> 32-char hex
|
||||
var bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
var keyHex = ChannelDecrypt.bytesToHex(bytes);
|
||||
var channelName = 'psk:' + keyHex.substring(0, 8);
|
||||
ChannelDecrypt.storeKey(channelName, keyHex, label);
|
||||
var qrOut = document.getElementById('qr-output');
|
||||
if (qrOut) {
|
||||
qrOut.innerHTML = '';
|
||||
// Render the QR + meshcore:// URL + Copy Key inline. The QR
|
||||
// helper handles canvas rendering + accessible copy controls.
|
||||
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
|
||||
// Use the user-supplied label when provided so the scanned
|
||||
// recipient sees a meaningful name; fall back to the
|
||||
// psk:<prefix> auto-name otherwise.
|
||||
window.ChannelQR.generate(label || channelName, keyHex, qrOut);
|
||||
} else {
|
||||
// Fallback when channel-qr.js failed to load.
|
||||
qrOut.textContent = 'Key generated: ' + keyHex;
|
||||
}
|
||||
}
|
||||
mergeUserChannels();
|
||||
renderChannelList();
|
||||
showAddStatus('Generated channel ' + (label || channelName), 'success');
|
||||
});
|
||||
|
||||
// Section 2: Add PSK
|
||||
var pskBtn = document.getElementById('chPskAddBtn');
|
||||
if (pskBtn) pskBtn.addEventListener('click', async function () {
|
||||
var keyEl = document.getElementById('chPskKey');
|
||||
var nameEl = document.getElementById('chPskName');
|
||||
var errEl = document.getElementById('chPskError');
|
||||
var raw = keyEl ? (keyEl.value || '').trim() : '';
|
||||
var label = nameEl ? (nameEl.value || '').trim() : '';
|
||||
if (!isHexKey(raw)) {
|
||||
if (errEl) { errEl.textContent = 'Key must be 32 hex characters (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 () {
|
||||
@@ -771,7 +909,60 @@
|
||||
});
|
||||
|
||||
// Event delegation for channel selection (touch-friendly)
|
||||
document.getElementById('chList').addEventListener('click', (e) => {
|
||||
var chListEl = document.getElementById('chList');
|
||||
// Keyboard accessibility for the role="button" remove/share spans
|
||||
// (Enter/Space). Single .closest() call with a combined selector.
|
||||
chListEl.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
|
||||
var rb = e.target.closest && e.target.closest('[data-remove-channel],[data-share-channel]');
|
||||
if (!rb) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Re-dispatch as a click so the existing click handler runs.
|
||||
rb.click();
|
||||
});
|
||||
chListEl.addEventListener('click', (e) => {
|
||||
// Share/reshare: open the Add Channel modal and render QR + URL
|
||||
// for the existing key (no re-generation).
|
||||
const shareBtn = e.target.closest('[data-share-channel]');
|
||||
if (shareBtn) {
|
||||
e.stopPropagation();
|
||||
var shareHash = shareBtn.getAttribute('data-share-channel');
|
||||
if (!shareHash) return;
|
||||
var sCh = channels.find(function (c) { return c.hash === shareHash; });
|
||||
var sName = shareHash.startsWith('user:')
|
||||
? shareHash.substring(5)
|
||||
: (sCh && sCh.name) || shareHash;
|
||||
var keys = ChannelDecrypt.getStoredKeys();
|
||||
var keyHex = keys[sName];
|
||||
if (typeof openAddModal === 'function') openAddModal();
|
||||
var sec = document.getElementById('chShareSection');
|
||||
var out = document.getElementById('chShareOutput');
|
||||
if (!sec || !out) return;
|
||||
sec.hidden = false;
|
||||
out.innerHTML = '';
|
||||
if (!keyHex) {
|
||||
out.textContent = 'No stored key found for "' + sName + '" — cannot share.';
|
||||
return;
|
||||
}
|
||||
var heading = document.createElement('div');
|
||||
heading.className = 'ch-share-heading';
|
||||
heading.textContent = 'Share "' + sName + '"';
|
||||
out.appendChild(heading);
|
||||
var holder = document.createElement('div');
|
||||
out.appendChild(holder);
|
||||
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
|
||||
window.ChannelQR.generate(sName, keyHex, holder);
|
||||
} else {
|
||||
// Fallback: copyable hex + meshcore:// URL.
|
||||
var url = 'meshcore://channel/add?name=' + encodeURIComponent(sName) +
|
||||
'&secret=' + keyHex;
|
||||
holder.innerHTML =
|
||||
'<div>Key: <code>' + escapeHtml(keyHex) + '</code></div>' +
|
||||
'<div>URL: <code>' + escapeHtml(url) + '</code></div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
// M4: Remove channel button
|
||||
const removeBtn = e.target.closest('[data-remove-channel]');
|
||||
if (removeBtn) {
|
||||
@@ -785,7 +976,7 @@
|
||||
var chName = channelHash.startsWith('user:')
|
||||
? channelHash.substring(5)
|
||||
: (ch && ch.name) || channelHash;
|
||||
if (!confirm('Remove channel "' + chName + '"? This will clear saved keys and cached messages.')) return;
|
||||
if (!confirm('Remove channel "' + chName + '"?\n\nThis will permanently remove the key from this browser and clear cached messages. You will need to re-enter the key to decrypt this channel again.')) return;
|
||||
ChannelDecrypt.removeKey(chName);
|
||||
if (channelHash.startsWith('user:')) {
|
||||
// Pure user-added channel — drop from the list entirely.
|
||||
@@ -929,6 +1120,11 @@
|
||||
if (!payload) continue;
|
||||
|
||||
var channelName = payload.channel || 'unknown';
|
||||
// For live-decrypted user-added (PSK) channels, decryptLivePSKBatch
|
||||
// also stamps payload.channelKey ("user:<name>") so we route the
|
||||
// message to the correct sidebar row and to the open chat view.
|
||||
// Falls back to channelName for server-known CHAN packets.
|
||||
var channelKey = payload.channelKey || channelName;
|
||||
var rawText = payload.text || '';
|
||||
var sender = payload.sender || null;
|
||||
var displayText = rawText;
|
||||
@@ -955,10 +1151,10 @@
|
||||
var observer = m.data?.packet?.observer_name || m.data?.observer || null;
|
||||
|
||||
// Update channel list entry — only once per unique packet hash
|
||||
var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelName);
|
||||
if (pktHash) seenHashes.add(pktHash + ':' + channelName);
|
||||
var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelKey);
|
||||
if (pktHash) seenHashes.add(pktHash + ':' + channelKey);
|
||||
|
||||
var ch = channels.find(function (c) { return c.hash === channelName; });
|
||||
var ch = channels.find(function (c) { return c.hash === channelKey; });
|
||||
if (ch) {
|
||||
if (isFirstObservation) ch.messageCount = (ch.messageCount || 0) + 1;
|
||||
ch.lastActivityMs = Date.now();
|
||||
@@ -968,7 +1164,7 @@
|
||||
} else if (isFirstObservation) {
|
||||
// New channel we haven't seen
|
||||
channels.push({
|
||||
hash: channelName,
|
||||
hash: channelKey,
|
||||
name: channelName,
|
||||
messageCount: 1,
|
||||
lastActivityMs: Date.now(),
|
||||
@@ -979,7 +1175,7 @@
|
||||
}
|
||||
|
||||
// If this message is for the selected channel, append to messages
|
||||
if (selectedHash && channelName === selectedHash) {
|
||||
if (selectedHash && channelKey === selectedHash) {
|
||||
// Deduplicate by packet hash — same message seen by multiple observers
|
||||
var existing = pktHash ? messages.find(function (msg) { return msg.packetHash === pktHash; }) : null;
|
||||
if (existing) {
|
||||
@@ -1062,6 +1258,18 @@
|
||||
// up as a real message instead of an encrypted blob. Keep the original
|
||||
// hash byte for any downstream consumer that wants it.
|
||||
payload.channel = dec.channelName;
|
||||
// For user-added PSK channels the sidebar entry & selectedHash use a
|
||||
// "user:<name>" key (see addUserChannel). Stamp the canonical key on
|
||||
// the payload so processWSBatch routes the live message to the
|
||||
// correct sidebar row and to the open chat view instead of dropping
|
||||
// it / creating a duplicate plain entry. Falls back to the raw name
|
||||
// for non-user channels (server-known CHAN paths still work).
|
||||
var userKey = 'user:' + dec.channelName;
|
||||
var hasUserCh = false;
|
||||
for (var ck = 0; ck < channels.length; ck++) {
|
||||
if (channels[ck].hash === userKey) { hasUserCh = true; break; }
|
||||
}
|
||||
payload.channelKey = hasUserCh ? userKey : dec.channelName;
|
||||
payload.sender = dec.sender;
|
||||
payload.text = dec.sender ? (dec.sender + ': ' + dec.text) : dec.text;
|
||||
payload.decryptedLocally = true;
|
||||
@@ -1083,9 +1291,12 @@
|
||||
for (var i = 0; i < msgs.length; i++) {
|
||||
var p = msgs[i] && msgs[i].data && msgs[i].data.decoded && msgs[i].data.decoded.payload;
|
||||
if (!p || !p.decryptedLocally) continue;
|
||||
var chName = p.channel;
|
||||
if (!chName || chName === prior) continue;
|
||||
var ch = channels.find(function (c) { return c.hash === chName || c.name === chName || c.hash === ('user:' + chName); });
|
||||
// Use the canonical sidebar key stamped by decryptLivePSKBatch so
|
||||
// the comparison against `prior` (= selectedHash) actually matches
|
||||
// for user-added (user:*-prefixed) channels.
|
||||
var chKey = p.channelKey || p.channel;
|
||||
if (!chKey || chKey === prior) continue;
|
||||
var ch = channels.find(function (c) { return c.hash === chKey || c.name === chKey || c.hash === ('user:' + chKey); });
|
||||
if (ch) {
|
||||
ch.unread = (ch.unread || 0) + 1;
|
||||
bumped = true;
|
||||
@@ -1151,69 +1362,156 @@
|
||||
}
|
||||
}
|
||||
|
||||
// #1041: single source of truth for the user-facing placeholder shown
|
||||
// when a PSK channel has no user-supplied label. Hoisted so the helper
|
||||
// and any future call sites stay in sync (i18n / branding-friendly).
|
||||
const PRIVATE_CHANNEL_LABEL = 'Private Channel';
|
||||
|
||||
// Display name for a channel — handles PSK channels where the raw
|
||||
// "psk:<hex8>" key prefix shouldn't be shown to users. Falls back to
|
||||
// userLabel, then a friendly placeholder, then a caller-supplied
|
||||
// fallback, then `Channel <hash>`.
|
||||
//
|
||||
// `fallback` lets row rendering preserve its existing "Unknown" /
|
||||
// "Channel <hash>" semantics for encrypted-but-not-user-added channels
|
||||
// without duplicating the psk:* check.
|
||||
function channelDisplayName(ch, fallback) {
|
||||
if (!ch) return '';
|
||||
const name = ch.name || '';
|
||||
if (ch.userLabel) return ch.userLabel;
|
||||
if (name.indexOf('psk:') === 0) return PRIVATE_CHANNEL_LABEL;
|
||||
if (name) return name;
|
||||
if (fallback) return fallback;
|
||||
return 'Channel ' + (typeof formatHashHex === 'function' ? formatHashHex(ch.hash) : ch.hash);
|
||||
}
|
||||
|
||||
// #1034 PR1: render a single channel row (used by all sidebar sections).
|
||||
function renderChannelRow(ch) {
|
||||
const isEncrypted = ch.encrypted === true;
|
||||
const isUserAdded = ch.userAdded === true;
|
||||
// #1041: route through channelDisplayName so the psk:* → "Private
|
||||
// Channel" rule lives in one place. Pass an `encryptedFallback` so
|
||||
// rows for non-user-added encrypted channels keep showing "Unknown"
|
||||
// (their existing behavior) when there's no name at all.
|
||||
const encryptedFallback = isEncrypted ? 'Unknown' : '';
|
||||
const name = channelDisplayName(ch, encryptedFallback);
|
||||
const color = isEncrypted && !isUserAdded ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash);
|
||||
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
|
||||
// Preview: show last sender+message when we have one. Otherwise show
|
||||
// nothing rather than "0 messages" — the count is misleading for
|
||||
// user-added (PSK) channels where messageCount only reflects
|
||||
// server-known activity, not actually-decrypted messages.
|
||||
let preview;
|
||||
if (ch.lastSender && ch.lastMessage) {
|
||||
preview = `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`;
|
||||
} else if (isEncrypted && !isUserAdded) {
|
||||
preview = `0x${formatHashHex(ch.hash)}`;
|
||||
} else if (typeof ch.messageCount === 'number' && ch.messageCount > 0) {
|
||||
preview = `${ch.messageCount} messages`;
|
||||
} else {
|
||||
preview = '';
|
||||
}
|
||||
const sel = selectedHash === ch.hash ? ' selected' : '';
|
||||
const encClass = isUserAdded
|
||||
? ' ch-user-added'
|
||||
: (isEncrypted ? ' ch-encrypted' : '');
|
||||
const badgeIcon = isUserAdded ? '🔓' : (isEncrypted ? '🔒' : null);
|
||||
const abbr = badgeIcon || (name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase());
|
||||
const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null;
|
||||
const dotStyle = chColor ? ` style="background:${chColor}"` : '';
|
||||
const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : '';
|
||||
// #1033: must NOT be a <button> — outer .ch-item is itself a <button>;
|
||||
// nested <button> is invalid HTML5 and the parser orphans everything
|
||||
// after it. Use <span role="button">; keydown handler on #chList
|
||||
// (Enter/Space) keeps it keyboard-accessible.
|
||||
// Icon button factory — used for the per-row remove/share controls.
|
||||
// Both share the .ch-icon-btn base class (touch target, opacity); a
|
||||
// modifier class (.ch-remove-btn / .ch-share-btn) supplies size + color.
|
||||
function iconBtn(modClass, dataAttr, hash, name, glyph, title, ariaVerb, extraAttrs) {
|
||||
return ' <span class="ch-icon-btn ' + modClass + '" role="button" tabindex="0"'
|
||||
+ ' ' + dataAttr + '="' + escapeHtml(hash) + '"'
|
||||
+ (extraAttrs || '')
|
||||
+ ' title="' + title + '"'
|
||||
+ ' aria-label="' + ariaVerb + ' ' + escapeHtml(name) + '">' + glyph + '</span>';
|
||||
}
|
||||
const removeBtn = isUserAdded
|
||||
? iconBtn('ch-remove-btn', 'data-remove-channel', ch.hash, name, '✕',
|
||||
'Remove channel and clear saved key', 'Remove', '')
|
||||
: '';
|
||||
const shareBtn = isUserAdded
|
||||
? iconBtn('ch-share-btn', 'data-share-channel', ch.hash, name, '📤 Share',
|
||||
'Share channel key (QR + URL)', 'Share', ' aria-haspopup="dialog"')
|
||||
: '';
|
||||
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key">🔑</span>' : '';
|
||||
const unreadBadge = (ch.unread && ch.unread > 0)
|
||||
? ' <span class="ch-unread-badge" data-unread-channel="' + escapeHtml(ch.hash) + '" title="' + ch.unread + ' new" aria-label="' + ch.unread + ' unread">' + (ch.unread > 99 ? '99+' : ch.unread) + '</span>'
|
||||
: '';
|
||||
|
||||
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}"${isEncrypted ? ' data-encrypted="true"' : ''}${isUserAdded ? ' data-user-added="true"' : ''}>
|
||||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${badgeIcon ? badgeIcon : escapeHtml(abbr)}</div>
|
||||
<div class="ch-item-body">
|
||||
<div class="ch-item-top">
|
||||
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}${unreadBadge}
|
||||
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '">✕</span>' : ''}
|
||||
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>${shareBtn}${removeBtn}
|
||||
</div>
|
||||
<div class="ch-item-preview">${escapeHtml(preview)}</div>
|
||||
</div>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// #1034 PR1: sectioned sidebar — My Channels / Network / Encrypted (N).
|
||||
function renderChannelList() {
|
||||
const el = document.getElementById('chList');
|
||||
if (!el) return;
|
||||
if (channels.length === 0) { el.innerHTML = '<div class="ch-empty">No channels found</div>'; return; }
|
||||
|
||||
// Sort by message count desc
|
||||
const sorted = [...channels].sort((a, b) => {
|
||||
return (b.messageCount || 0) - (a.messageCount || 0);
|
||||
});
|
||||
const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0);
|
||||
const sortByCount = (a, b) => (b.messageCount || 0) - (a.messageCount || 0);
|
||||
|
||||
el.innerHTML = sorted.map(ch => {
|
||||
const isEncrypted = ch.encrypted === true;
|
||||
const isUserAdded = ch.userAdded === true;
|
||||
// #1020: prefer user-supplied label over psk:<hex>
|
||||
const baseName = isEncrypted ? (ch.name || 'Unknown') : (ch.name || `Channel ${formatHashHex(ch.hash)}`);
|
||||
const name = (isUserAdded && ch.userLabel) ? ch.userLabel : baseName;
|
||||
const color = isEncrypted ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash);
|
||||
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
|
||||
const preview = isUserAdded
|
||||
? (ch.lastSender && ch.lastMessage
|
||||
? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`
|
||||
: `${ch.messageCount || 0} messages (your key)`)
|
||||
: isEncrypted
|
||||
? `${ch.messageCount} encrypted messages (no key configured)`
|
||||
: ch.lastSender && ch.lastMessage
|
||||
? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`
|
||||
: `${ch.messageCount} messages`;
|
||||
const sel = selectedHash === ch.hash ? ' selected' : '';
|
||||
// #1020: distinct class so styling/tests can tell user-added apart
|
||||
// from server-known encrypted channels.
|
||||
const encClass = isUserAdded
|
||||
? ' ch-user-added'
|
||||
: (isEncrypted ? ' ch-encrypted' : '');
|
||||
// #1020: 🔓 marks "I have the key" vs 🔒 "encrypted, no key"
|
||||
const badgeIcon = isUserAdded ? '🔓' : (isEncrypted ? '🔒' : null);
|
||||
const abbr = badgeIcon || (name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase());
|
||||
// Channel color dot for color picker (#674)
|
||||
const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null;
|
||||
const dotStyle = chColor ? ` style="background:${chColor}"` : '';
|
||||
// Left border for assigned color
|
||||
const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : '';
|
||||
// M4 / #1020: Remove button for user-added channels
|
||||
const removeBtn = isUserAdded ? ' <button class="ch-remove-btn" data-remove-channel="' + escapeHtml(ch.hash) + '" title="Remove channel and clear saved key" aria-label="Remove ' + escapeHtml(name) + '">✕</button>' : '';
|
||||
// #1020: explicit badge marker for "your key" so it's distinguishable
|
||||
// from server-known encrypted rows at a glance and for screen readers.
|
||||
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key">🔑</span>' : '';
|
||||
// #1029 Unread badge — bumped by live PSK decrypt for channels not currently selected.
|
||||
const unreadBadge = (ch.unread && ch.unread > 0)
|
||||
? ' <span class="ch-unread-badge" data-unread-channel="' + escapeHtml(ch.hash) + '" title="' + ch.unread + ' new" aria-label="' + ch.unread + ' unread">' + (ch.unread > 99 ? '99+' : ch.unread) + '</span>'
|
||||
: '';
|
||||
const mine = channels.filter(c => c.userAdded === true).sort(sortByActivity);
|
||||
const network = channels.filter(c => c.userAdded !== true && c.encrypted !== true).sort(sortByActivity);
|
||||
const encrypted = channels.filter(c => c.userAdded !== true && c.encrypted === true).sort(sortByCount);
|
||||
|
||||
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}"${isEncrypted ? ' data-encrypted="true"' : ''}${isUserAdded ? ' data-user-added="true"' : ''}>
|
||||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${badgeIcon ? badgeIcon : escapeHtml(abbr)}</div>
|
||||
<div class="ch-item-body">
|
||||
<div class="ch-item-top">
|
||||
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}${unreadBadge}
|
||||
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '">✕</span>' : ''}
|
||||
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>${removeBtn}
|
||||
</div>
|
||||
<div class="ch-item-preview">${escapeHtml(preview)}</div>
|
||||
// Encrypted section collapsed by default; user toggle persisted in localStorage.
|
||||
const collapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false';
|
||||
|
||||
const sections = [];
|
||||
sections.push(
|
||||
`<div class="ch-section ch-section-mychannels" data-section="mychannels">
|
||||
<div class="ch-section-header">My Channels <span class="ch-section-locality" title="Saved only in this browser on this device">🖥️ (this browser)</span></div>
|
||||
${mine.length ? mine.map(renderChannelRow).join('') : '<div class="ch-section-empty">No channels yet — click [+ Add Channel] to add one.</div>'}
|
||||
</div>`
|
||||
);
|
||||
sections.push(
|
||||
`<div class="ch-section ch-section-network" data-section="network">
|
||||
<div class="ch-section-header">Network</div>
|
||||
${network.length ? network.map(renderChannelRow).join('') : '<div class="ch-section-empty">No public channels reported by the server.</div>'}
|
||||
</div>`
|
||||
);
|
||||
sections.push(
|
||||
`<div class="ch-section ch-section-encrypted" data-section="encrypted" data-encrypted-collapsed="${collapsed ? 'true' : 'false'}">
|
||||
<button type="button" class="ch-section-header ch-section-toggle" id="chEncryptedToggle" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="chEncryptedBody">
|
||||
<span class="ch-section-caret" aria-hidden="true">${collapsed ? '▸' : '▾'}</span>
|
||||
Encrypted (${encrypted.length})
|
||||
</button>
|
||||
<div class="ch-section-body" id="chEncryptedBody"${collapsed ? ' hidden' : ''}>
|
||||
${encrypted.length ? encrypted.map(renderChannelRow).join('') : '<div class="ch-section-empty">No unkeyed encrypted channels seen.</div>'}
|
||||
</div>
|
||||
</button>`;
|
||||
}).join('');
|
||||
</div>`
|
||||
);
|
||||
el.innerHTML = sections.join('');
|
||||
|
||||
// Toggle expand/collapse for the Encrypted section.
|
||||
const toggle = document.getElementById('chEncryptedToggle');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', function () {
|
||||
const wasCollapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false';
|
||||
const next = wasCollapsed ? 'false' : 'true';
|
||||
try { localStorage.setItem('ch-encrypted-collapsed', next); } catch (e) { /* quota */ }
|
||||
renderChannelList();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function selectChannel(hash, decryptOpts) {
|
||||
@@ -1226,7 +1524,9 @@
|
||||
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
|
||||
renderChannelList();
|
||||
const ch = channels.find(c => c.hash === hash);
|
||||
const name = ch?.name || `Channel ${formatHashHex(hash)}`;
|
||||
// #1041: never show raw "psk:<hex>" prefixes in the header — use the
|
||||
// user-supplied label or "Private Channel".
|
||||
const name = ch ? channelDisplayName(ch) : `Channel ${formatHashHex(hash)}`;
|
||||
const header = document.getElementById('chHeader');
|
||||
header.querySelector('.ch-header-text').textContent = `${name} — ${ch?.messageCount || 0} messages`;
|
||||
|
||||
|
||||
+44
-2
@@ -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>' +
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/* filter-ux.js — Wireshark-style filter UX (issue #966)
|
||||
*
|
||||
* Owns:
|
||||
* - Help popover (filter syntax, fields, operators, examples)
|
||||
* - Autocomplete dropdown (field names, operators, type/route values, payload.*)
|
||||
* - Right-click context menu on packet table cells → "Filter by this value"
|
||||
* - Saved-filter dropdown (localStorage, with starter defaults)
|
||||
*
|
||||
* Pure-logic helpers (SavedFilters, buildCellFilterClause, appendClauseToExpr)
|
||||
* are unit-tested in test-packet-filter-ux.js. DOM glue is exercised by
|
||||
* test-filter-ux-e2e.js (Playwright).
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var LS_KEY = 'corescope_saved_filters_v1';
|
||||
|
||||
// ── Saved filters store ────────────────────────────────────────────────
|
||||
var DEFAULT_FILTERS = [
|
||||
{ name: 'Adverts only', expr: 'type == ADVERT', builtin: true },
|
||||
{ name: 'Channel traffic', expr: 'type == GRP_TXT', builtin: true },
|
||||
{ name: 'Direct messages', expr: 'type == TXT_MSG', builtin: true },
|
||||
{ name: 'Strong signal (SNR > 5)', expr: 'snr > 5', builtin: true },
|
||||
{ name: 'Multi-hop (hops > 1)', expr: 'hops > 1', builtin: true },
|
||||
{ name: 'Repeater adverts', expr: 'type == ADVERT && payload.flags.repeater == true', builtin: true },
|
||||
{ name: 'Recent (last 5 min)', expr: 'age < 5m', builtin: true },
|
||||
];
|
||||
|
||||
function _getStore() {
|
||||
try {
|
||||
var raw = window.localStorage.getItem(LS_KEY);
|
||||
if (!raw) return [];
|
||||
var parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
function _setStore(arr) {
|
||||
try { window.localStorage.setItem(LS_KEY, JSON.stringify(arr)); } catch (e) {}
|
||||
}
|
||||
|
||||
var SavedFilters = {
|
||||
defaults: function() { return DEFAULT_FILTERS.slice(); },
|
||||
list: function() {
|
||||
// Defaults first, then user filters (deduped by name — user wins on collision)
|
||||
var user = _getStore();
|
||||
var userNames = {};
|
||||
for (var i = 0; i < user.length; i++) userNames[user[i].name] = true;
|
||||
var defaults = DEFAULT_FILTERS.filter(function(d) { return !userNames[d.name]; });
|
||||
return defaults.concat(user);
|
||||
},
|
||||
save: function(name, expr) {
|
||||
if (!name || !expr) return;
|
||||
var user = _getStore();
|
||||
var idx = -1;
|
||||
for (var i = 0; i < user.length; i++) { if (user[i].name === name) { idx = i; break; } }
|
||||
var entry = { name: name, expr: expr, ts: Date.now() };
|
||||
if (idx >= 0) user[idx] = entry; else user.push(entry);
|
||||
_setStore(user);
|
||||
},
|
||||
delete: function(name) {
|
||||
var user = _getStore();
|
||||
_setStore(user.filter(function(f) { return f.name !== name; }));
|
||||
},
|
||||
};
|
||||
|
||||
// ── Right-click filter clause builders ─────────────────────────────────
|
||||
// Numeric strings stay unquoted; identifiers from TYPE_VALUES/ROUTE_VALUES
|
||||
// stay unquoted; everything else gets double-quoted.
|
||||
function _isNumericString(s) {
|
||||
if (typeof s !== 'string') return false;
|
||||
return /^-?\d+(\.\d+)?$/.test(s.trim());
|
||||
}
|
||||
function _isBareIdentifier(s) {
|
||||
return typeof s === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(s);
|
||||
}
|
||||
function buildCellFilterClause(field, value, op) {
|
||||
op = op || '==';
|
||||
if (value == null) value = '';
|
||||
var v = String(value);
|
||||
var rendered;
|
||||
if (op === 'contains' || op === 'starts_with' || op === 'ends_with') {
|
||||
// String-only ops: always quote
|
||||
rendered = '"' + v.replace(/"/g, '\\"') + '"';
|
||||
} else if (_isNumericString(v)) {
|
||||
rendered = v;
|
||||
} else if (_isBareIdentifier(v)) {
|
||||
rendered = v;
|
||||
} else {
|
||||
rendered = '"' + v.replace(/"/g, '\\"') + '"';
|
||||
}
|
||||
return field + ' ' + op + ' ' + rendered;
|
||||
}
|
||||
function appendClauseToExpr(expr, clause) {
|
||||
if (!expr || !expr.trim()) return clause;
|
||||
return expr.trim() + ' && ' + clause;
|
||||
}
|
||||
|
||||
// ── DOM glue (only runs in browser, after init()) ──────────────────────
|
||||
var _ctxMenu = null;
|
||||
|
||||
function _h(tag, attrs, html) {
|
||||
var el = document.createElement(tag);
|
||||
if (attrs) for (var k in attrs) {
|
||||
if (k === 'class') el.className = attrs[k];
|
||||
else if (k === 'style') el.setAttribute('style', attrs[k]);
|
||||
else if (k.indexOf('data-') === 0) el.setAttribute(k, attrs[k]);
|
||||
else el[k] = attrs[k];
|
||||
}
|
||||
if (html != null) el.innerHTML = html;
|
||||
return el;
|
||||
}
|
||||
function _esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function _buildHelpHtml() {
|
||||
var PF = window.PacketFilter;
|
||||
var rows = (PF.FIELDS || []).map(function(f) {
|
||||
return '<tr><td class="fux-mono">' + _esc(f.name) + '</td><td>' + _esc(f.desc) + '</td></tr>';
|
||||
}).join('');
|
||||
var ops = (PF.OPERATORS || []).map(function(o) {
|
||||
return '<tr><td class="fux-mono">' + _esc(o.op) + '</td><td>' + _esc(o.desc) +
|
||||
'</td><td class="fux-mono">' + _esc(o.example) + '</td></tr>';
|
||||
}).join('');
|
||||
var examples = [
|
||||
'type == ADVERT',
|
||||
'type == GRP_TXT && size > 50',
|
||||
'payload.name contains "Gilroy"',
|
||||
'payload.flags.repeater == true',
|
||||
'snr > 5 && rssi > -90',
|
||||
'hops < 2',
|
||||
'observer == "Dorrington" && type == ADVERT',
|
||||
'(type == ADVERT || type == ACK) && snr > 0',
|
||||
'age < 1h',
|
||||
'time after "2025-01-01"',
|
||||
].map(function(e) { return '<li class="fux-mono">' + _esc(e) + '</li>'; }).join('');
|
||||
return [
|
||||
'<h3>Filter syntax</h3>',
|
||||
'<p>Wireshark-style boolean expressions over packet fields. Combine with <code>&&</code>, <code>||</code>, <code>!</code>, and parentheses. Strings are case-insensitive. Tip: append <code>?filter=…</code> to the URL to share a filter.</p>',
|
||||
'<h4>Fields</h4>',
|
||||
'<table class="fux-table"><thead><tr><th>Name</th><th>Description</th></tr></thead><tbody>' + rows + '</tbody></table>',
|
||||
'<h4>Operators</h4>',
|
||||
'<table class="fux-table"><thead><tr><th>Op</th><th>Meaning</th><th>Example</th></tr></thead><tbody>' + ops + '</tbody></table>',
|
||||
'<h4>Examples</h4>',
|
||||
'<ul class="fux-examples">' + examples + '</ul>',
|
||||
'<h4>Tips</h4>',
|
||||
'<ul>',
|
||||
'<li>Right-click any cell in the packet table to add a clause for that value.</li>',
|
||||
'<li>Type a partial field name to autocomplete; Tab/Enter accepts, Esc dismisses.</li>',
|
||||
'<li>Save commonly-used expressions via the ★ Save button — they appear in the Saved dropdown.</li>',
|
||||
'</ul>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function _showHelp() {
|
||||
var existing = document.getElementById('filterHelpPopover');
|
||||
if (existing) { existing.remove(); return; }
|
||||
var pop = _h('div', { id: 'filterHelpPopover', class: 'fux-popover', role: 'dialog', 'aria-label': 'Filter syntax help' });
|
||||
pop.innerHTML =
|
||||
'<div class="fux-popover-header"><strong>Filter syntax</strong>' +
|
||||
'<button type="button" class="fux-popover-close" aria-label="Close">✕</button></div>' +
|
||||
'<div class="fux-popover-body">' + _buildHelpHtml() + '</div>';
|
||||
document.body.appendChild(pop);
|
||||
pop.querySelector('.fux-popover-close').addEventListener('click', function() { pop.remove(); });
|
||||
document.addEventListener('keydown', function _esc(ev) {
|
||||
if (ev.key === 'Escape') { pop.remove(); document.removeEventListener('keydown', _esc); }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Autocomplete ───────────────────────────────────────────────────────
|
||||
function _wireAutocomplete(input) {
|
||||
var dd = _h('div', { id: 'filterAcDropdown', class: 'fux-ac-dropdown', role: 'listbox' });
|
||||
dd.style.display = 'none';
|
||||
input.parentNode.appendChild(dd);
|
||||
var sel = -1, items = [];
|
||||
|
||||
function _gatherPayloadKeys() {
|
||||
// Best-effort: scan the first ~50 visible packets for decoded_json keys
|
||||
var keys = {};
|
||||
try {
|
||||
var rows = document.querySelectorAll('#pktTable tbody tr');
|
||||
for (var r = 0; r < rows.length && r < 50; r++) {
|
||||
var dj = rows[r].getAttribute('data-decoded');
|
||||
if (!dj) continue;
|
||||
var obj = JSON.parse(dj);
|
||||
for (var k in obj) keys[k] = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
return Object.keys(keys);
|
||||
}
|
||||
|
||||
function close() { dd.style.display = 'none'; sel = -1; items = []; input.removeAttribute('aria-activedescendant'); }
|
||||
function render() {
|
||||
if (!items.length) { close(); return; }
|
||||
dd.innerHTML = items.map(function(it, i) {
|
||||
return '<div class="fux-ac-item' + (i === sel ? ' active' : '') + '" id="fux-ac-' + i +
|
||||
'" role="option" data-idx="' + i + '">' +
|
||||
'<span class="fux-ac-val">' + _esc(it.value) + '</span>' +
|
||||
(it.desc ? '<span class="fux-ac-desc">' + _esc(it.desc) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
dd.style.display = 'block';
|
||||
if (sel >= 0) input.setAttribute('aria-activedescendant', 'fux-ac-' + sel);
|
||||
}
|
||||
function accept(idx) {
|
||||
if (!items[idx]) return;
|
||||
var rs = items._replaceStart, re = items._replaceEnd;
|
||||
var val = items[idx].value;
|
||||
var v = input.value;
|
||||
var newVal = v.slice(0, rs) + val + v.slice(re);
|
||||
var caret = rs + val.length;
|
||||
// Append space + helpful next char for fields (so user can type op)
|
||||
if (items[idx].kind === 'field') { newVal = newVal.slice(0, caret) + ' ' + newVal.slice(caret); caret++; }
|
||||
input.value = newVal;
|
||||
input.setSelectionRange(caret, caret);
|
||||
close();
|
||||
// Trigger filter recompile
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
var PF = window.PacketFilter;
|
||||
if (!PF || !PF.suggest) return close();
|
||||
var r = PF.suggest(input.value, input.selectionStart || 0, { payloadKeys: _gatherPayloadKeys() });
|
||||
items = (r && r.suggestions) ? r.suggestions.slice(0, 12) : [];
|
||||
items._replaceStart = r ? r.replaceStart : 0;
|
||||
items._replaceEnd = r ? r.replaceEnd : 0;
|
||||
sel = items.length ? 0 : -1;
|
||||
render();
|
||||
}
|
||||
input.addEventListener('input', refresh);
|
||||
input.addEventListener('focus', refresh);
|
||||
input.addEventListener('blur', function() { setTimeout(close, 150); });
|
||||
input.addEventListener('keydown', function(ev) {
|
||||
if (dd.style.display === 'none') return;
|
||||
if (ev.key === 'ArrowDown') { sel = (sel + 1) % items.length; render(); ev.preventDefault(); }
|
||||
else if (ev.key === 'ArrowUp') { sel = (sel - 1 + items.length) % items.length; render(); ev.preventDefault(); }
|
||||
else if (ev.key === 'Tab' || ev.key === 'Enter') {
|
||||
if (sel >= 0) { accept(sel); ev.preventDefault(); }
|
||||
} else if (ev.key === 'Escape') { close(); ev.preventDefault(); }
|
||||
});
|
||||
dd.addEventListener('mousedown', function(ev) {
|
||||
var target = ev.target.closest('.fux-ac-item');
|
||||
if (!target) return;
|
||||
ev.preventDefault();
|
||||
accept(parseInt(target.getAttribute('data-idx'), 10));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Right-click context menu ───────────────────────────────────────────
|
||||
function _showContextMenu(x, y, field, value) {
|
||||
if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
|
||||
var input = document.getElementById('packetFilterInput');
|
||||
if (!input) return;
|
||||
var menu = _h('div', { id: 'filterContextMenu', class: 'fux-ctx-menu', role: 'menu' });
|
||||
var ops = [
|
||||
{ label: 'Filter ' + field + ' == "' + value + '"', op: '==' },
|
||||
{ label: 'Filter ' + field + ' != "' + value + '"', op: '!=' },
|
||||
{ label: 'Filter ' + field + ' contains "' + value + '"', op: 'contains' },
|
||||
];
|
||||
menu.innerHTML = ops.map(function(o, i) {
|
||||
return '<button type="button" class="fux-ctx-item" data-idx="' + i + '" role="menuitem">' + _esc(o.label) + '</button>';
|
||||
}).join('');
|
||||
menu.style.left = x + 'px';
|
||||
menu.style.top = y + 'px';
|
||||
document.body.appendChild(menu);
|
||||
_ctxMenu = menu;
|
||||
menu.addEventListener('click', function(ev) {
|
||||
var btn = ev.target.closest('.fux-ctx-item');
|
||||
if (!btn) return;
|
||||
var op = ops[parseInt(btn.getAttribute('data-idx'), 10)].op;
|
||||
var clause = buildCellFilterClause(field, value, op);
|
||||
input.value = appendClauseToExpr(input.value, clause);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
menu.remove(); _ctxMenu = null;
|
||||
});
|
||||
function dismiss(ev) {
|
||||
if (_ctxMenu && !_ctxMenu.contains(ev.target)) { _ctxMenu.remove(); _ctxMenu = null;
|
||||
document.removeEventListener('mousedown', dismiss);
|
||||
document.removeEventListener('keydown', escDismiss);
|
||||
}
|
||||
}
|
||||
function escDismiss(ev) { if (ev.key === 'Escape') dismiss({ target: document.body }); }
|
||||
setTimeout(function() {
|
||||
document.addEventListener('mousedown', dismiss);
|
||||
document.addEventListener('keydown', escDismiss);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _wireContextMenu() {
|
||||
// Delegated listener on the table — extracts field+value from data-* attrs.
|
||||
var tbl = document.getElementById('pktTable');
|
||||
if (!tbl) return;
|
||||
tbl.addEventListener('contextmenu', function(ev) {
|
||||
var cell = ev.target.closest('td[data-filter-field]');
|
||||
if (!cell) return;
|
||||
var field = cell.getAttribute('data-filter-field');
|
||||
var value = cell.getAttribute('data-filter-value');
|
||||
if (!field || value == null || value === '') return;
|
||||
ev.preventDefault();
|
||||
_showContextMenu(ev.pageX, ev.pageY, field, value);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Saved filters dropdown ─────────────────────────────────────────────
|
||||
function _renderSavedDropdown(container, input) {
|
||||
var btn = _h('button', { type: 'button', class: 'fux-saved-trigger', id: 'filterSavedTrigger', title: 'Saved filters' }, '★ Saved ▾');
|
||||
var menu = _h('div', { class: 'fux-saved-menu hidden', id: 'filterSavedMenu', role: 'menu' });
|
||||
container.appendChild(btn);
|
||||
container.appendChild(menu);
|
||||
|
||||
function build() {
|
||||
var list = SavedFilters.list();
|
||||
var rows = list.map(function(f, i) {
|
||||
var del = f.builtin ? '' :
|
||||
'<button type="button" class="fux-saved-del" data-name="' + _esc(f.name) + '" title="Delete">✕</button>';
|
||||
return '<div class="fux-saved-item" data-idx="' + i + '">' +
|
||||
'<span class="fux-saved-name">' + _esc(f.name) + '</span>' +
|
||||
'<span class="fux-saved-expr fux-mono">' + _esc(f.expr) + '</span>' +
|
||||
del + '</div>';
|
||||
}).join('');
|
||||
menu.innerHTML =
|
||||
'<div class="fux-saved-header">Saved filters</div>' +
|
||||
rows +
|
||||
'<div class="fux-saved-footer">' +
|
||||
'<button type="button" id="filterSaveCurrent" class="fux-saved-save">+ Save current expression</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
build();
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
document.addEventListener('click', function(ev) {
|
||||
if (!menu.contains(ev.target) && ev.target !== btn) menu.classList.add('hidden');
|
||||
});
|
||||
menu.addEventListener('click', function(ev) {
|
||||
var del = ev.target.closest('.fux-saved-del');
|
||||
if (del) {
|
||||
SavedFilters.delete(del.getAttribute('data-name'));
|
||||
build();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (ev.target.id === 'filterSaveCurrent') {
|
||||
var expr = (input.value || '').trim();
|
||||
if (!expr) { alert('Type a filter expression first.'); return; }
|
||||
var name = prompt('Name this filter:', '');
|
||||
if (name && name.trim()) {
|
||||
SavedFilters.save(name.trim(), expr);
|
||||
build();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var item = ev.target.closest('.fux-saved-item');
|
||||
if (item) {
|
||||
var list = SavedFilters.list();
|
||||
var f = list[parseInt(item.getAttribute('data-idx'), 10)];
|
||||
if (f) {
|
||||
input.value = f.expr;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init: idempotent, called by packets.js after filter input renders ──
|
||||
function init() {
|
||||
var input = document.getElementById('packetFilterInput');
|
||||
if (!input || input.dataset.fuxInit === '1') return;
|
||||
input.dataset.fuxInit = '1';
|
||||
|
||||
// Help icon + saved-filters dropdown — injected next to the input
|
||||
var wrap = input.parentNode;
|
||||
if (wrap) {
|
||||
var bar = document.getElementById('filterUxBar');
|
||||
if (!bar) {
|
||||
bar = _h('div', { id: 'filterUxBar', class: 'fux-bar' });
|
||||
var helpBtn = _h('button', { type: 'button', class: 'fux-help-btn', id: 'filterHelpBtn',
|
||||
'aria-label': 'Filter syntax help', title: 'Filter syntax help' }, 'ⓘ Help');
|
||||
helpBtn.addEventListener('click', _showHelp);
|
||||
bar.appendChild(helpBtn);
|
||||
_renderSavedDropdown(bar, input);
|
||||
wrap.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
_wireAutocomplete(input);
|
||||
_wireContextMenu();
|
||||
}
|
||||
|
||||
var _exports = {
|
||||
SavedFilters: SavedFilters,
|
||||
buildCellFilterClause: buildCellFilterClause,
|
||||
appendClauseToExpr: appendClauseToExpr,
|
||||
init: init,
|
||||
_showHelp: _showHelp, // exposed for E2E
|
||||
};
|
||||
if (typeof window !== 'undefined') window.FilterUX = _exports;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = _exports;
|
||||
})();
|
||||
@@ -28,9 +28,12 @@
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="vendor/MarkerCluster.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="vendor/MarkerCluster.Default.css?v=__BUST__">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="vendor/leaflet.markercluster.js?v=__BUST__"></script>
|
||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
<script src="https://unpkg.com/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
@@ -92,14 +95,18 @@
|
||||
<script src="hop-resolver.js?v=__BUST__"></script>
|
||||
<script src="hop-display.js?v=__BUST__"></script>
|
||||
<script src="app.js?v=__BUST__"></script>
|
||||
<script src="url-state.js?v=__BUST__"></script>
|
||||
<script src="home.js?v=__BUST__"></script>
|
||||
<script src="table-sort.js?v=__BUST__"></script>
|
||||
<script src="packet-filter.js?v=__BUST__"></script>
|
||||
<script src="filter-ux.js?v=__BUST__"></script>
|
||||
<script src="hash-color.js?v=__BUST__"></script>
|
||||
<script src="packet-helpers.js?v=__BUST__"></script>
|
||||
<script src="vendor/aes-ecb.js?v=__BUST__"></script>
|
||||
<script src="vendor/sha256-hmac.js?v=__BUST__"></script>
|
||||
<script src="channel-decrypt.js?v=__BUST__"></script>
|
||||
<script src="vendor/jsqr.min.js"></script>
|
||||
<script src="channel-qr.js?v=__BUST__"></script>
|
||||
<script src="channel-colors.js?v=__BUST__"></script>
|
||||
<script src="channel-color-picker.js?v=__BUST__"></script>
|
||||
<script src="packets.js?v=__BUST__"></script>
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
.live-toggles label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
|
||||
.live-toggles input { margin: 0; }
|
||||
|
||||
/* Region filter (#1045) inline in live header toggles */
|
||||
.live-toggles .live-region-filter-container { display: inline-flex; align-items: center; }
|
||||
.live-toggles .live-region-filter-container .region-dropdown-trigger { font-size: inherit; padding: 2px 6px; }
|
||||
|
||||
/* ---- Leaflet overrides for dark theme ---- */
|
||||
.live-page .leaflet-control-zoom a {
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, transparent) !important;
|
||||
|
||||
@@ -28,6 +28,30 @@
|
||||
let nodeFilterKeys = (localStorage.getItem('live-node-filter') || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
let nodeFilterTotal = 0;
|
||||
let nodeFilterShown = 0;
|
||||
// Region filter (#1045): observer_id → IATA code, populated from /api/observers
|
||||
let observerIataMap = {};
|
||||
let regionFilterChangeHandler = null;
|
||||
|
||||
/**
|
||||
* Returns true if the packet group matches the selected regions.
|
||||
* - selected null/empty → no filter active, always true.
|
||||
* - Match if ANY observation's observer maps to an IATA in selected (case-insensitive).
|
||||
* Pure helper exposed for unit tests.
|
||||
*/
|
||||
function packetMatchesRegion(packets, obsMap, selected) {
|
||||
if (!selected || !selected.length) return true;
|
||||
if (!packets || !packets.length) return false;
|
||||
const sel = selected.map(function(s) { return String(s).toUpperCase(); });
|
||||
for (var i = 0; i < packets.length; i++) {
|
||||
var oid = packets[i] && packets[i].observer_id;
|
||||
if (oid == null) continue;
|
||||
var iata = obsMap && obsMap[oid];
|
||||
if (!iata) continue;
|
||||
if (sel.indexOf(String(iata).toUpperCase()) !== -1) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function setObserverIataMap(m) { observerIataMap = m || {}; }
|
||||
let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null;
|
||||
const propagationBuffer = new Map(); // hash -> {timer, packets[]}
|
||||
let _onResize = null;
|
||||
@@ -848,6 +872,7 @@
|
||||
</div>
|
||||
<div id="liveNodeFilterCount" class="live-filter-count hidden"></div>
|
||||
<label id="liveGeoFilterLabel" style="display:none"><input type="checkbox" id="liveGeoFilterToggle"> Mesh live area</label>
|
||||
<div id="liveRegionFilter" class="region-filter-container live-region-filter-container" aria-label="Filter live packets by IATA region"></div>
|
||||
</div>
|
||||
<div class="audio-controls hidden" id="audioControls">
|
||||
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
|
||||
@@ -1013,6 +1038,25 @@
|
||||
applyFavoritesFilter();
|
||||
});
|
||||
|
||||
// Region filter (#1045): dropdown of observer IATA regions
|
||||
(function initLiveRegionFilter() {
|
||||
var rfEl = document.getElementById('liveRegionFilter');
|
||||
if (!rfEl || !window.RegionFilter) return;
|
||||
// Fetch observer roster to build observer_id → IATA map
|
||||
fetch('/api/observers').then(function(r) { return r.json(); }).then(function(list) {
|
||||
var m = {};
|
||||
if (Array.isArray(list)) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var o = list[i];
|
||||
if (o && o.id != null && o.iata) m[o.id] = o.iata;
|
||||
}
|
||||
}
|
||||
setObserverIataMap(m);
|
||||
}).catch(function() { /* leave map empty; filter will hide all when active */ });
|
||||
RegionFilter.init(rfEl, { dropdown: true });
|
||||
regionFilterChangeHandler = RegionFilter.onChange(function() { /* selection persisted by RegionFilter; future packets reflect it */ });
|
||||
})();
|
||||
|
||||
// Node filter input
|
||||
const nodeFilterInput = document.getElementById('liveNodeFilterInput');
|
||||
const nodeFilterClear = document.getElementById('liveNodeFilterClear');
|
||||
@@ -1956,6 +2000,8 @@
|
||||
window._liveIsNodeFavorited = isNodeFavorited;
|
||||
window._livePacketInvolvesFilterNode = packetInvolvesFilterNode;
|
||||
window._liveGetNodeFilterKeys = function() { return nodeFilterKeys; };
|
||||
window._livePacketMatchesRegion = packetMatchesRegion;
|
||||
window._liveSetObserverIataMap = setObserverIataMap;
|
||||
window._liveSetNodeFilter = setNodeFilter;
|
||||
window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml;
|
||||
window._liveResolveHopPositions = resolveHopPositions;
|
||||
@@ -2055,6 +2101,12 @@
|
||||
updateNodeFilterUI();
|
||||
}
|
||||
|
||||
// --- Region filter (#1045): drop packet if no observation matches selected IATA ---
|
||||
if (window.RegionFilter && typeof RegionFilter.getSelected === 'function') {
|
||||
var _regionSel = RegionFilter.getSelected();
|
||||
if (_regionSel && _regionSel.length && !packetMatchesRegion(packets, observerIataMap, _regionSel)) return;
|
||||
}
|
||||
|
||||
// --- Ensure ADVERT nodes appear on map ---
|
||||
for (var pi = 0; pi < packets.length; pi++) {
|
||||
var pkt = packets[pi];
|
||||
@@ -3040,6 +3092,10 @@
|
||||
if (_feedTimestampInterval) { clearInterval(_feedTimestampInterval); _feedTimestampInterval = null; }
|
||||
if (_affinityInterval) { clearInterval(_affinityInterval); _affinityInterval = null; }
|
||||
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
||||
if (regionFilterChangeHandler && window.RegionFilter && typeof RegionFilter.offChange === 'function') {
|
||||
RegionFilter.offChange(regionFilterChangeHandler);
|
||||
regionFilterChangeHandler = null;
|
||||
}
|
||||
if (map) { map.remove(); map = null; }
|
||||
if (_onResize) {
|
||||
window.removeEventListener('resize', _onResize);
|
||||
|
||||
+128
-15
@@ -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 };
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -124,6 +124,12 @@
|
||||
<div class="analytics-chart-desc">How many repeater hops packets take — 0 means direct</div>
|
||||
<canvas id="hopChart" role="img" aria-label="Hop distribution chart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card full">
|
||||
<h4>Battery Voltage <span id="batteryStatusBadge" style="font-size:11px;font-weight:normal;margin-left:8px"></span></h4>
|
||||
<div class="analytics-chart-desc">Battery voltage over time from observer status reports — flat line means full, downward slope means draining</div>
|
||||
<canvas id="batteryChart" role="img" aria-label="Battery voltage trend chart"></canvas>
|
||||
<div id="batteryEmpty" style="display:none;padding:20px;text-align:center;color:var(--text-muted);font-size:12px">No battery telemetry recorded for this node in this window.</div>
|
||||
</div>
|
||||
<div class="analytics-chart-card full">
|
||||
<h4>Uptime Heatmap</h4>
|
||||
<div class="analytics-chart-desc">Hour-by-hour activity grid — darker = more packets in that slot</div>
|
||||
@@ -159,6 +165,7 @@
|
||||
buildObserverChart(data);
|
||||
buildHopChart(data);
|
||||
buildHeatmap(data);
|
||||
loadBatteryChart(pubkey, currentDays);
|
||||
}
|
||||
|
||||
function buildActivityChart(data) {
|
||||
@@ -289,6 +296,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBatteryChart(pubkey, days) {
|
||||
let data;
|
||||
try {
|
||||
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/battery?days=' + days);
|
||||
} catch (e) {
|
||||
const empty = document.getElementById('batteryEmpty');
|
||||
if (empty) { empty.style.display = 'block'; empty.textContent = 'Battery data unavailable: ' + e.message; }
|
||||
return;
|
||||
}
|
||||
const ctx = document.getElementById('batteryChart');
|
||||
const empty = document.getElementById('batteryEmpty');
|
||||
const badge = document.getElementById('batteryStatusBadge');
|
||||
const samples = (data && data.samples) || [];
|
||||
const thr = (data && data.thresholds) || { low_mv: 3300, critical_mv: 3000 };
|
||||
|
||||
if (badge) {
|
||||
const STATUS_COLOR = { ok: '#51cf66', low: '#fcc419', critical: '#ff6b6b', unknown: 'var(--text-muted)' };
|
||||
const label = data && data.status === 'ok' ? '🔋 OK'
|
||||
: data && data.status === 'low' ? '⚠️ Low'
|
||||
: data && data.status === 'critical' ? '🪫 Critical'
|
||||
: 'No data';
|
||||
const mv = data && data.latest_mv ? ' · ' + data.latest_mv + ' mV' : '';
|
||||
badge.textContent = label + mv;
|
||||
badge.style.color = STATUS_COLOR[(data && data.status) || 'unknown'];
|
||||
}
|
||||
|
||||
if (!ctx || samples.length === 0) {
|
||||
if (ctx) ctx.style.display = 'none';
|
||||
if (empty) empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
ctx.style.display = '';
|
||||
|
||||
const labels = samples.map(p => {
|
||||
const d = new Date(p.timestamp);
|
||||
return (typeof formatChartAxisLabel === 'function')
|
||||
? formatChartAxisLabel(d, days <= 3)
|
||||
: (days <= 3 ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: d.toLocaleDateString([], { month: 'short', day: 'numeric' }));
|
||||
});
|
||||
const values = samples.map(p => p.battery_mv);
|
||||
|
||||
const c = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{ label: 'Battery (mV)', data: values, borderColor: '#4a9eff', backgroundColor: 'rgba(74,158,255,0.15)', tension: 0.25, pointRadius: 2, fill: true },
|
||||
{ label: 'Low threshold', data: values.map(() => thr.low_mv), borderColor: '#fcc419', borderDash: [6, 4], pointRadius: 0, fill: false },
|
||||
{ label: 'Critical', data: values.map(() => thr.critical_mv), borderColor: '#ff6b6b', borderDash: [6, 4], pointRadius: 0, fill: false }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: true, position: 'bottom' } },
|
||||
scales: { x: { ticks: { maxTicksAutoSkip: true, maxRotation: 45 } }, y: { title: { display: true, text: 'mV' } } }
|
||||
}
|
||||
});
|
||||
charts.push(c);
|
||||
}
|
||||
|
||||
function init(container, routeParam) {
|
||||
// routeParam is "PUBKEY/analytics"
|
||||
if (!routeParam || !routeParam.endsWith('/analytics')) {
|
||||
|
||||
+41
-2
@@ -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>
|
||||
@@ -1091,7 +1130,7 @@
|
||||
defaultColumn: 'last_seen',
|
||||
defaultDirection: 'desc',
|
||||
storageKey: 'meshcore-nodes-sort',
|
||||
onSort: function () { renderRows(); }
|
||||
onSort: function () { renderRows(); updateNodesUrl(); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@
|
||||
<caption class="sr-only">Observer status and statistics</caption>
|
||||
<thead><tr>
|
||||
<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Region</th><th scope="col">Last Status</th><th scope="col">Last Packet</th>
|
||||
<th scope="col">Packets</th><th scope="col">Packets/Hour</th><th scope="col">Clock Offset</th><th scope="col">Uptime</th>
|
||||
<th scope="col">Packet Health</th><th scope="col">Total Packets</th><th scope="col">Packets/Hour</th><th scope="col">Clock Offset</th><th scope="col">Uptime</th>
|
||||
</tr></thead>
|
||||
<tbody>${filtered.map(o => {
|
||||
const h = healthStatus(o.last_seen);
|
||||
|
||||
+229
-14
@@ -22,10 +22,14 @@
|
||||
// ── Lexer ──────────────────────────────────────────────────────────────────
|
||||
var TK = {
|
||||
FIELD: 'FIELD', OP: 'OP', STRING: 'STRING', NUMBER: 'NUMBER', BOOL: 'BOOL',
|
||||
DURATION: 'DURATION',
|
||||
AND: 'AND', OR: 'OR', NOT: 'NOT', LPAREN: 'LPAREN', RPAREN: 'RPAREN'
|
||||
};
|
||||
|
||||
var OP_WORDS = { contains: true, starts_with: true, ends_with: true };
|
||||
var OP_WORDS = { contains: true, starts_with: true, ends_with: true, after: true, before: true, between: true };
|
||||
|
||||
// Duration unit → seconds. Used for `age < 1h`-style filters.
|
||||
var DURATION_UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
|
||||
|
||||
function lex(input) {
|
||||
var tokens = [], i = 0, len = input.length;
|
||||
@@ -66,7 +70,19 @@
|
||||
if (input[i] === '-') i++;
|
||||
while (i < len && /[0-9]/.test(input[i])) i++;
|
||||
if (i < len && input[i] === '.') { i++; while (i < len && /[0-9]/.test(input[i])) i++; }
|
||||
tokens.push({ type: TK.NUMBER, value: parseFloat(input.slice(start, i)) });
|
||||
var numStr = input.slice(start, i);
|
||||
// Duration suffix: 1h, 15m, 7d, 30s, 2w. Rejects bare letters/multi-letter units.
|
||||
if (i < len && /[a-zA-Z]/.test(input[i])) {
|
||||
var unitStart = i;
|
||||
while (i < len && /[a-zA-Z]/.test(input[i])) i++;
|
||||
var unit = input.slice(unitStart, i);
|
||||
if (!DURATION_UNITS[unit]) {
|
||||
return { tokens: null, error: "Invalid duration unit '" + unit + "' at position " + unitStart + " (expected s/m/h/d/w)" };
|
||||
}
|
||||
tokens.push({ type: TK.DURATION, value: parseFloat(numStr) * DURATION_UNITS[unit], raw: numStr + unit });
|
||||
continue;
|
||||
}
|
||||
tokens.push({ type: TK.NUMBER, value: parseFloat(numStr) });
|
||||
continue;
|
||||
}
|
||||
// identifier / keyword / bare value
|
||||
@@ -154,20 +170,41 @@
|
||||
}
|
||||
var op = advance().value;
|
||||
|
||||
// Parse value
|
||||
// `between` takes two values: `field between <a> <b>`
|
||||
if (op === 'between') {
|
||||
var lo = parseValue(field, op);
|
||||
var hi = parseValue(field, op);
|
||||
validateTimeValue(field, op, lo);
|
||||
validateTimeValue(field, op, hi);
|
||||
return { type: 'comparison', field: field, op: op, value: lo, value2: hi };
|
||||
}
|
||||
|
||||
var value = parseValue(field, op);
|
||||
if (op === 'after' || op === 'before') validateTimeValue(field, op, value);
|
||||
return { type: 'comparison', field: field, op: op, value: value };
|
||||
}
|
||||
|
||||
// Validates that a value supplied to a temporal op parses as a date.
|
||||
function validateTimeValue(field, op, v) {
|
||||
if (typeof v !== 'string') return; // numeric epochs are accepted as-is
|
||||
var ms = Date.parse(v);
|
||||
if (isNaN(ms)) {
|
||||
throw new Error("Invalid datetime '" + v + "' for '" + field + ' ' + op + "'");
|
||||
}
|
||||
}
|
||||
|
||||
function parseValue(field, op) {
|
||||
var valTok = peek();
|
||||
if (!valTok) throw new Error("Expected value after '" + field + ' ' + op + "'");
|
||||
var value;
|
||||
if (valTok.type === TK.STRING) { value = advance().value; }
|
||||
else if (valTok.type === TK.NUMBER) { value = advance().value; }
|
||||
else if (valTok.type === TK.BOOL) { value = advance().value; }
|
||||
else if (valTok.type === TK.FIELD) {
|
||||
if (valTok.type === TK.STRING) { return advance().value; }
|
||||
if (valTok.type === TK.NUMBER) { return advance().value; }
|
||||
if (valTok.type === TK.BOOL) { return advance().value; }
|
||||
if (valTok.type === TK.DURATION) { return { __duration: true, seconds: advance().value }; }
|
||||
if (valTok.type === TK.FIELD) {
|
||||
// Bare word as string value (e.g., ADVERT, FLOOD)
|
||||
value = advance().value;
|
||||
return advance().value;
|
||||
}
|
||||
else { throw new Error("Expected value after '" + field + ' ' + op + "'"); }
|
||||
|
||||
return { type: 'comparison', field: field, op: op, value: value };
|
||||
throw new Error("Expected value after '" + field + ' ' + op + "'");
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -197,6 +234,22 @@
|
||||
if (field === 'observer') return packet.observer_name || '';
|
||||
if (field === 'observer_id') return packet.observer_id || '';
|
||||
if (field === 'observations') return packet.observation_count || 0;
|
||||
if (field === 'time' || field === 'timestamp') {
|
||||
// Returns ms-since-epoch or null. Falls back to first_seen when timestamp absent
|
||||
// (group rows from /api/packets?groupByHash=true expose first_seen instead).
|
||||
var ts = packet.timestamp || packet.first_seen || packet.latest;
|
||||
if (!ts) return null;
|
||||
var ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
||||
return isNaN(ms) ? null : ms;
|
||||
}
|
||||
if (field === 'age') {
|
||||
// Age in seconds since the packet timestamp (NOW - ts).
|
||||
var ts2 = packet.timestamp || packet.first_seen || packet.latest;
|
||||
if (!ts2) return null;
|
||||
var ms2 = typeof ts2 === 'number' ? ts2 : Date.parse(ts2);
|
||||
if (isNaN(ms2)) return null;
|
||||
return Math.max(0, (Date.now() - ms2) / 1000);
|
||||
}
|
||||
if (field === 'path') {
|
||||
try { return JSON.parse(packet.path_json || '[]').join(' → '); } catch(e) { return ''; }
|
||||
}
|
||||
@@ -224,6 +277,16 @@
|
||||
}
|
||||
|
||||
// ── Evaluator ──────────────────────────────────────────────────────────────
|
||||
function parseDateValue(v) {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'string') {
|
||||
var ms = Date.parse(v);
|
||||
return isNaN(ms) ? null : ms;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function evaluate(ast, packet) {
|
||||
if (!ast) return true;
|
||||
switch (ast.type) {
|
||||
@@ -241,10 +304,27 @@
|
||||
|
||||
if (fieldVal == null || fieldVal === undefined) return false;
|
||||
|
||||
// Temporal ops: after / before / between operate on epoch-ms.
|
||||
if (op === 'after' || op === 'before' || op === 'between') {
|
||||
var lhsMs = typeof fieldVal === 'number' ? fieldVal : Date.parse(fieldVal);
|
||||
if (isNaN(lhsMs)) return false;
|
||||
var rhs1 = parseDateValue(target);
|
||||
if (rhs1 == null) return false;
|
||||
if (op === 'after') return lhsMs > rhs1;
|
||||
if (op === 'before') return lhsMs < rhs1;
|
||||
var rhs2 = parseDateValue(ast.value2);
|
||||
if (rhs2 == null) return false;
|
||||
var lo = Math.min(rhs1, rhs2), hi = Math.max(rhs1, rhs2);
|
||||
return lhsMs >= lo && lhsMs <= hi;
|
||||
}
|
||||
|
||||
// Numeric operators
|
||||
if (op === '>' || op === '<' || op === '>=' || op === '<=') {
|
||||
var a = typeof fieldVal === 'number' ? fieldVal : parseFloat(fieldVal);
|
||||
var b = typeof target === 'number' ? target : parseFloat(target);
|
||||
// Duration values are pre-converted to seconds at lex time
|
||||
var b = (target && typeof target === 'object' && target.__duration)
|
||||
? target.seconds
|
||||
: (typeof target === 'number' ? target : parseFloat(target));
|
||||
if (isNaN(a) || isNaN(b)) return false;
|
||||
if (op === '>') return a > b;
|
||||
if (op === '<') return a < b;
|
||||
@@ -304,7 +384,142 @@
|
||||
};
|
||||
}
|
||||
|
||||
var _exports = { parse: parse, evaluate: evaluate, compile: compile };
|
||||
// ── Metadata for autocomplete + in-UI documentation (#966) ────────────────
|
||||
var FIELDS = [
|
||||
{ name: 'type', desc: 'Packet payload type (ADVERT, GRP_TXT, TXT_MSG, ACK, …)' },
|
||||
{ name: 'route', desc: 'Route type (FLOOD, DIRECT, TRANSPORT_FLOOD, TRANSPORT_DIRECT)' },
|
||||
{ name: 'transport', desc: 'true if route is TRANSPORT_FLOOD or TRANSPORT_DIRECT' },
|
||||
{ name: 'hash', desc: 'Packet hash (hex)' },
|
||||
{ name: 'raw', desc: 'Full raw hex of the packet' },
|
||||
{ name: 'size', desc: 'Total packet size in bytes' },
|
||||
{ name: 'snr', desc: 'Signal-to-noise ratio (dB)' },
|
||||
{ name: 'rssi', desc: 'Received signal strength (dBm)' },
|
||||
{ name: 'hops', desc: 'Number of hops in the path' },
|
||||
{ name: 'observer', desc: 'Observer station name' },
|
||||
{ name: 'observer_id', desc: 'Observer pubkey/id' },
|
||||
{ name: 'observations', desc: 'Number of observations of this packet' },
|
||||
{ name: 'path', desc: 'Hop path (joined with arrows)' },
|
||||
{ name: 'payload_bytes', desc: 'Payload size in bytes (size - 2 header bytes)' },
|
||||
{ name: 'payload_hex', desc: 'Payload bytes as hex (raw without header)' },
|
||||
{ name: 'time', desc: 'Packet timestamp (epoch ms)' },
|
||||
{ name: 'age', desc: 'Seconds since the packet was observed (use with durations: age < 1h)' },
|
||||
{ name: 'payload.name', desc: 'Decoded payload: node name (adverts)' },
|
||||
{ name: 'payload.lat', desc: 'Decoded payload: latitude' },
|
||||
{ name: 'payload.lon', desc: 'Decoded payload: longitude' },
|
||||
{ name: 'payload.text', desc: 'Decoded payload: message text (channel/DM)' },
|
||||
{ name: 'payload.channel', desc: 'Decoded payload: channel name' },
|
||||
{ name: 'payload.channelHash', desc: 'Decoded payload: channel hash' },
|
||||
{ name: 'payload.sender', desc: 'Decoded payload: sender name' },
|
||||
{ name: 'payload.flags.repeater', desc: 'Decoded payload: advert flag (repeater role)' },
|
||||
{ name: 'payload.flags.room', desc: 'Decoded payload: advert flag (room server)' },
|
||||
{ name: 'payload.flags.hasLocation', desc: 'Decoded payload: advert has location' },
|
||||
];
|
||||
|
||||
var OPERATORS = [
|
||||
{ op: '==', desc: 'Equal (case-insensitive for strings, alias-aware for type/route)', example: 'type == ADVERT' },
|
||||
{ op: '!=', desc: 'Not equal', example: 'type != ACK' },
|
||||
{ op: '>', desc: 'Greater than (numeric)', example: 'snr > 5' },
|
||||
{ op: '<', desc: 'Less than (numeric)', example: 'rssi < -90' },
|
||||
{ op: '>=', desc: 'Greater or equal', example: 'hops >= 2' },
|
||||
{ op: '<=', desc: 'Less or equal', example: 'size <= 100' },
|
||||
{ op: 'contains', desc: 'Substring match (case-insensitive)', example: 'payload.name contains "Gilroy"' },
|
||||
{ op: 'starts_with', desc: 'String prefix match', example: 'hash starts_with "8a91"' },
|
||||
{ op: 'ends_with', desc: 'String suffix match', example: 'hash ends_with "ff"' },
|
||||
{ op: 'after', desc: 'Datetime after (ISO or epoch)', example: 'time after "2025-01-01"' },
|
||||
{ op: 'before', desc: 'Datetime before', example: 'time before "2025-12-31"' },
|
||||
{ op: 'between', desc: 'Datetime between two values', example: 'time between "2025-01-01" "2025-02-01"' },
|
||||
];
|
||||
|
||||
// Canonical type names (firmware payload types)
|
||||
var TYPE_VALUES = ['REQ', 'RESPONSE', 'TXT_MSG', 'ACK', 'ADVERT', 'GRP_TXT', 'GRP_DATA', 'ANON_REQ', 'PATH', 'TRACE', 'MULTIPART', 'CONTROL', 'RAW_CUSTOM'];
|
||||
var ROUTE_VALUES = ['TRANSPORT_FLOOD', 'FLOOD', 'DIRECT', 'TRANSPORT_DIRECT'];
|
||||
|
||||
// suggest(input, cursor, opts?) → { suggestions: [{value, kind, desc?}], replaceStart, replaceEnd }
|
||||
// Token-aware autocomplete:
|
||||
// - Empty / partial-word at cursor → field names
|
||||
// - Right after `field` → operators
|
||||
// - Right after `type ==` → TYPE_VALUES (filtered by partial)
|
||||
// - Right after `route ==` → ROUTE_VALUES
|
||||
// - Partial `payload.<x>` → payload.* fields (incl. dynamic opts.payloadKeys)
|
||||
function suggest(input, cursor, opts) {
|
||||
opts = opts || {};
|
||||
input = input || '';
|
||||
if (cursor == null) cursor = input.length;
|
||||
var before = input.slice(0, cursor);
|
||||
|
||||
// Determine the current word being typed (the replaceable span).
|
||||
// Treat alphanumerics, '_', and '.' as word chars (so "payload.na" is one word).
|
||||
var i = cursor;
|
||||
while (i > 0 && /[A-Za-z0-9_.]/.test(input.charAt(i - 1))) i--;
|
||||
var replaceStart = i;
|
||||
var replaceEnd = cursor;
|
||||
while (replaceEnd < input.length && /[A-Za-z0-9_.]/.test(input.charAt(replaceEnd))) replaceEnd++;
|
||||
var partial = input.slice(replaceStart, cursor);
|
||||
|
||||
// Look at preceding non-space tokens (very small recogniser)
|
||||
var preceding = before.slice(0, replaceStart).replace(/\s+$/, '');
|
||||
var lastTokMatch = preceding.match(/(==|!=|>=|<=|>|<|contains|starts_with|ends_with|after|before|between|&&|\|\||\(|!)$/);
|
||||
var lastTok = lastTokMatch ? lastTokMatch[1] : null;
|
||||
// The token before lastTok (the field, if any)
|
||||
var fieldBefore = null;
|
||||
if (lastTok) {
|
||||
var beforeOp = preceding.slice(0, preceding.length - lastTok.length).replace(/\s+$/, '');
|
||||
var fm = beforeOp.match(/([A-Za-z_][A-Za-z0-9_.]*)$/);
|
||||
if (fm) fieldBefore = fm[1];
|
||||
}
|
||||
|
||||
function makePrefixSuggestions(items, kind) {
|
||||
var p = partial.toLowerCase();
|
||||
var out = [];
|
||||
for (var k = 0; k < items.length; k++) {
|
||||
var it = items[k];
|
||||
var val = typeof it === 'string' ? it : it.value;
|
||||
if (!p || val.toLowerCase().indexOf(p) === 0) {
|
||||
out.push({ value: val, kind: kind, desc: typeof it === 'string' ? '' : (it.desc || '') });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Case A: just typed `field ==` (or other comparison op) → value suggestions
|
||||
if (lastTok && fieldBefore) {
|
||||
if (fieldBefore === 'type' && (lastTok === '==' || lastTok === '!=')) {
|
||||
return { suggestions: makePrefixSuggestions(TYPE_VALUES, 'value'), replaceStart: replaceStart, replaceEnd: replaceEnd };
|
||||
}
|
||||
if (fieldBefore === 'route' && (lastTok === '==' || lastTok === '!=')) {
|
||||
return { suggestions: makePrefixSuggestions(ROUTE_VALUES, 'value'), replaceStart: replaceStart, replaceEnd: replaceEnd };
|
||||
}
|
||||
}
|
||||
|
||||
// Case B: a field is just typed (no operator yet) → operator suggestions
|
||||
// Detect: preceding ends with a known field-like identifier and there's no partial word at cursor
|
||||
if (!partial && preceding.length) {
|
||||
var afterField = preceding.match(/([A-Za-z_][A-Za-z0-9_.]*)$/);
|
||||
if (afterField && !lastTok) {
|
||||
var ops = OPERATORS.map(function(o) { return { value: o.op, kind: 'op', desc: o.desc }; });
|
||||
return { suggestions: ops, replaceStart: replaceStart, replaceEnd: replaceEnd };
|
||||
}
|
||||
}
|
||||
|
||||
// Case C: default → field name suggestions (incl. dynamic payload.* keys)
|
||||
var fieldItems = FIELDS.map(function(f) { return { value: f.name, desc: f.desc }; });
|
||||
if (Array.isArray(opts.payloadKeys)) {
|
||||
var have = {};
|
||||
for (var z = 0; z < fieldItems.length; z++) have[fieldItems[z].value] = true;
|
||||
for (var y = 0; y < opts.payloadKeys.length; y++) {
|
||||
var pkey = 'payload.' + opts.payloadKeys[y];
|
||||
if (!have[pkey]) fieldItems.push({ value: pkey, desc: 'Decoded payload field (dynamic)' });
|
||||
}
|
||||
}
|
||||
return { suggestions: makePrefixSuggestions(fieldItems, 'field'), replaceStart: replaceStart, replaceEnd: replaceEnd };
|
||||
}
|
||||
|
||||
var _exports = {
|
||||
parse: parse, evaluate: evaluate, compile: compile,
|
||||
FIELDS: FIELDS, OPERATORS: OPERATORS,
|
||||
TYPE_VALUES: TYPE_VALUES, ROUTE_VALUES: ROUTE_VALUES,
|
||||
suggest: suggest,
|
||||
};
|
||||
if (typeof window !== 'undefined') window.PacketFilter = _exports;
|
||||
|
||||
// ── Self-tests (Node.js only) ─────────────────────────────────────────────
|
||||
|
||||
+46
-15
@@ -53,12 +53,25 @@
|
||||
if (filters.observer) parts.push('observer=' + encodeURIComponent(filters.observer));
|
||||
if (filters.channel) parts.push('channel=' + encodeURIComponent(filters.channel));
|
||||
if (filters._filterExpr) parts.push('filter=' + encodeURIComponent(filters._filterExpr));
|
||||
// Sort state (#749) — encode as 'col[:asc]'; default 'time:desc' is omitted.
|
||||
if (_packetSortColumn) {
|
||||
var sortDefault = _packetSortColumn === 'time' && _packetSortDirection === 'desc';
|
||||
if (!sortDefault && window.URLState) {
|
||||
var sortToken = URLState.serializeSort(_packetSortColumn, _packetSortDirection);
|
||||
if (sortToken) parts.push('sort=' + encodeURIComponent(sortToken));
|
||||
}
|
||||
}
|
||||
return parts.length ? '?' + parts.join('&') : '';
|
||||
}
|
||||
window.buildPacketsQuery = buildPacketsQuery;
|
||||
|
||||
function updatePacketsUrl() {
|
||||
history.replaceState(null, '', '#/packets' + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam()));
|
||||
// Preserve any subpath after /packets (e.g. #/packets/<hash>).
|
||||
var cur = String(location.hash || '');
|
||||
var subpath = '';
|
||||
var m = cur.match(/^#\/packets(\/[^?]*)?/);
|
||||
if (m && m[1]) subpath = m[1];
|
||||
history.replaceState(null, '', '#/packets' + subpath + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam()));
|
||||
// Update clear-filters button visibility
|
||||
var cb = document.getElementById('clearFiltersBtn');
|
||||
if (cb) {
|
||||
@@ -366,6 +379,17 @@
|
||||
if (_urlChannel) filters.channel = _urlChannel;
|
||||
var _urlFilterExpr = _initUrlParams.get('filter');
|
||||
if (_urlFilterExpr) filters._filterExpr = _urlFilterExpr;
|
||||
// #749 — restore sort state from URL (overrides localStorage).
|
||||
var _urlSort = _initUrlParams.get('sort');
|
||||
if (_urlSort && window.URLState) {
|
||||
var _parsed = URLState.parseSort(_urlSort);
|
||||
if (_parsed) {
|
||||
_packetSortColumn = _parsed.column;
|
||||
_packetSortDirection = _parsed.direction;
|
||||
// Persist so TableSort init picks it up.
|
||||
try { localStorage.setItem('meshcore-packets-sort', JSON.stringify({ column: _parsed.column, direction: _parsed.direction })); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
app.innerHTML = `<div class="split-layout detail-collapsed">
|
||||
<div class="panel-left" id="pktLeft" aria-live="polite" aria-relevant="additions removals"></div>
|
||||
@@ -781,7 +805,7 @@
|
||||
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet" aria-label="Bring Your Own Packet - paste raw packet hex for analysis" aria-haspopup="dialog">📦 BYOP</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group" style="flex:1;margin-bottom:8px">
|
||||
<div class="filter-group" style="flex:1;margin-bottom:8px;position:relative">
|
||||
<input type="text" id="packetFilterInput" class="packet-filter-input"
|
||||
placeholder='Filter: type == Advert && snr > 5 · payload.name contains "Gilroy"'
|
||||
aria-label="Packet filter expression"
|
||||
@@ -837,7 +861,7 @@
|
||||
<option value="chrono-asc">Sort: Time ↑ (earliest)</option>
|
||||
<option value="chrono-desc">Sort: Time ↓ (latest)</option>
|
||||
</select>
|
||||
<span class="sort-help" id="sortHelpIcon">ⓘ</span>
|
||||
<span class="sort-help" id="sortHelpIcon" tabindex="0" role="button" aria-label="Sort help">ⓘ</span>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<div class="col-toggle-wrap">
|
||||
@@ -916,6 +940,12 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// Wireshark-style filter UX (#966): help popover, autocomplete, right-click
|
||||
// context menu, saved-filter dropdown. Idempotent — safe to re-call.
|
||||
if (window.FilterUX && typeof window.FilterUX.init === 'function') {
|
||||
window.FilterUX.init();
|
||||
}
|
||||
|
||||
// --- Observer multi-select ---
|
||||
const obsMenu = document.getElementById('observerMenu');
|
||||
const obsTrigger = document.getElementById('observerTrigger');
|
||||
@@ -1393,6 +1423,7 @@
|
||||
_packetSortDirection = direction;
|
||||
sortPacketsArray();
|
||||
renderTableRows();
|
||||
updatePacketsUrl();
|
||||
}
|
||||
});
|
||||
// Apply initial sort state from TableSort
|
||||
@@ -1436,11 +1467,11 @@
|
||||
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.latest)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${groupSize || ''}">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="col-hashsize mono">${groupHashBytes}</td>
|
||||
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
|
||||
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(groupTypeName || '')}">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(headerObserverId) || '')}">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
|
||||
<td class="col-details">${getDetailPreview(getParsedDecoded(p))}</td>
|
||||
@@ -1462,11 +1493,11 @@
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_childHashStripe ? ' style="' + _childHashStripe + '"' : ''}>
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(c.hash || '')}">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
<td class="col-hashsize mono">${childHashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
|
||||
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(c.observer_id) || '')}">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${getDetailPreview(getParsedDecoded(c))}</td>
|
||||
@@ -1494,11 +1525,11 @@
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" data-entry-idx="${entryIdx}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}"${_flatStyle ? ' style="' + _flatStyle + '"' : ''}>
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
<td class="col-hashsize mono">${hashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
|
||||
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(p.observer_id) || '')}">${truncate(obsName(p.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${detail}</td>
|
||||
|
||||
+454
-9
@@ -1,6 +1,57 @@
|
||||
/* === CoreScope — style.css === */
|
||||
|
||||
/* ============================================================
|
||||
* FLUID SCAFFOLDING (issue #1054)
|
||||
* ------------------------------------------------------------
|
||||
* Global design tokens for spacing, typography, and container
|
||||
* layout. All values use clamp()/min() so the layout scales
|
||||
* smoothly between ~768px and ~2560px viewports without media
|
||||
* queries. Targets at the historic 1440px design width match
|
||||
* the previous hardcoded px values to within ~1px so existing
|
||||
* pages render identically there.
|
||||
*
|
||||
* Component-specific spacing/typography (nav, tables, charts,
|
||||
* map, packets, analytics, …) lives in its own marked region
|
||||
* further below — DO NOT add component CSS in this region.
|
||||
* ============================================================ */
|
||||
|
||||
:root {
|
||||
/* --- Fluid spacing scale ---------------------------------
|
||||
* Targets at 1440px viewport: 4 / 8 / 16 / 24 / 32 / 48 px.
|
||||
* Min/max clamps keep small viewports usable and prevent
|
||||
* runaway growth on ultra-wide displays.
|
||||
*/
|
||||
--space-xs: clamp(3px, 0.15vw + 2px, 6px);
|
||||
--space-sm: clamp(6px, 0.30vw + 4px, 12px);
|
||||
--space-md: clamp(10px, 0.50vw + 8px, 20px);
|
||||
--space-lg: clamp(16px, 0.75vw + 12px, 32px);
|
||||
--space-xl: clamp(24px, 1.00vw + 16px, 48px);
|
||||
--space-2xl: clamp(32px, 2.00vw + 20px, 64px);
|
||||
|
||||
/* --- Fluid type scale ------------------------------------
|
||||
* Targets at 1440px viewport: 13 / 16 / 18 / 24 / 32 px.
|
||||
* Floors ensure readability at 768px; caps prevent giant
|
||||
* text at 2560px+.
|
||||
*/
|
||||
--fs-sm: clamp(12px, 0.15vw + 11px, 14px);
|
||||
--fs-md: clamp(14px, 0.20vw + 13px, 17px);
|
||||
--fs-lg: clamp(15px, 0.30vw + 14px, 20px);
|
||||
--fs-xl: clamp(18px, 0.50vw + 16px, 28px);
|
||||
--fs-2xl: clamp(22px, 0.75vw + 20px, 36px);
|
||||
|
||||
/* --- Fluid radii ----------------------------------------- */
|
||||
--radius-sm: clamp(3px, 0.1vw + 2px, 6px);
|
||||
--radius-md: clamp(6px, 0.2vw + 5px, 12px);
|
||||
--radius-lg: clamp(10px, 0.3vw + 8px, 18px);
|
||||
|
||||
/* --- Container layout ------------------------------------
|
||||
* --gutter scales the side padding; --content-max caps the
|
||||
* usable content width but always leaves a gutter on each
|
||||
* side at small viewports.
|
||||
*/
|
||||
--gutter: clamp(12px, 2vw, 32px);
|
||||
--content-max: min(100% - (2 * var(--gutter)), 1600px);
|
||||
|
||||
--nav-bg: #0f0f23;
|
||||
--nav-bg2: #1a1a2e;
|
||||
--nav-text: #ffffff;
|
||||
@@ -103,7 +154,13 @@
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; font-family: var(--font); background: var(--content-bg); color: var(--text); }
|
||||
html, body { height: 100%; font-family: var(--font); font-size: var(--fs-md); background: var(--content-bg); color: var(--text); }
|
||||
|
||||
/* ============================================================
|
||||
* COMPONENT STYLES — page-specific rules below.
|
||||
* (Nav, tables, charts, map, packets, analytics, etc.)
|
||||
* Tasks 1050-3..6 / 1052-* edit sections inside this region.
|
||||
* ============================================================ */
|
||||
|
||||
/* === Skip Link === */
|
||||
.skip-link { position: absolute; top: -100%; left: 16px; padding: 8px 16px; background: var(--accent); color: #fff; border-radius: 6px; z-index: 999; font-weight: 600; text-decoration: none; }
|
||||
@@ -116,7 +173,202 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
}
|
||||
|
||||
/* === Touch Targets === */
|
||||
.nav-link { min-height: 44px; display: inline-flex; align-items: center; }
|
||||
/* WCAG 2.5.5 / Apple HIG / Material: 48x48 CSS px minimum touch target for
|
||||
all interactive controls. Targets are achieved with min-height/min-width
|
||||
plus inline-flex centering so existing visual styling (font-size, padding,
|
||||
icon size) is preserved on desktop while the *hit area* grows for touch.
|
||||
Issue #1060. */
|
||||
.nav-link { min-height: 48px; display: inline-flex; align-items: center; }
|
||||
|
||||
/* Generic button surfaces — filter bar, modal buttons, inline .btn usages.
|
||||
inline-flex keeps text/icons centered without changing visible padding much. */
|
||||
.btn,
|
||||
.filter-bar .btn,
|
||||
.filter-group .btn {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.ch-icon-btn,
|
||||
.ch-remove-btn,
|
||||
.ch-share-btn {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.ch-gear-btn {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.panel-close-btn {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.mc-jump-btn {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
button.ch-item {
|
||||
min-height: 48px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Additional button-like controls discovered during PR #1067 review (Issue
|
||||
#1060 follow-up). Same 48x48 minimums + touch-action so all interactive
|
||||
surfaces meet WCAG 2.5.5. */
|
||||
.btn-link,
|
||||
.col-toggle-btn,
|
||||
.filter-toggle-btn,
|
||||
.ch-add-channel-btn,
|
||||
.ch-back-btn,
|
||||
.ch-modal-btn-secondary,
|
||||
.ch-scroll-btn,
|
||||
.chooser-btn,
|
||||
.clock-filter-btn,
|
||||
.compare-btn,
|
||||
.copy-link-btn,
|
||||
.alab-btn {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.btn-link:active,
|
||||
.col-toggle-btn:active,
|
||||
.filter-toggle-btn:active,
|
||||
.ch-add-channel-btn:active,
|
||||
.ch-back-btn:active,
|
||||
.ch-modal-btn-secondary:active,
|
||||
.ch-scroll-btn:active,
|
||||
.chooser-btn:active,
|
||||
.clock-filter-btn:active,
|
||||
.compare-btn:active,
|
||||
.copy-link-btn:active,
|
||||
.alab-btn:active {
|
||||
background: var(--row-hover);
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Form controls: native <select> and text-like <input> need 48px tap targets
|
||||
too. Scoped to interactive input types — checkbox/radio/range keep their
|
||||
own visible size and rely on a wrapping label/parent for hit area. */
|
||||
select,
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="tel"],
|
||||
input[type="url"],
|
||||
input[type="date"],
|
||||
input[type="time"],
|
||||
input[type="datetime-local"],
|
||||
input[type="month"],
|
||||
input[type="week"] {
|
||||
min-height: 48px;
|
||||
box-sizing: border-box;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Visible :active states — touch devices have no hover, so :active is the
|
||||
primary feedback channel. Use opacity + slight scale + background shift so
|
||||
the press is felt even when the user's finger covers the control. */
|
||||
.btn:active,
|
||||
.filter-bar .btn:active,
|
||||
.filter-group .btn:active {
|
||||
background: var(--row-hover);
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-icon:active {
|
||||
background: var(--row-hover);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.nav-btn:active {
|
||||
background: var(--nav-bg2);
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ch-icon-btn:active,
|
||||
.ch-remove-btn:active,
|
||||
.ch-share-btn:active {
|
||||
opacity: 1;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
.ch-gear-btn:active,
|
||||
.panel-close-btn:active,
|
||||
.mc-jump-btn:active {
|
||||
background: var(--row-hover);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Hover→tap conversion. Hover-only feedback (e.g., tooltip reveal) is gated
|
||||
behind @media (hover: hover) so touch devices don't get stuck in a hover
|
||||
state after a tap. The same content is exposed on tap via :focus-within
|
||||
below. */
|
||||
@media (hover: hover) {
|
||||
.sort-help:hover .sort-help-tip { display: block; }
|
||||
}
|
||||
|
||||
/* Tap-to-reveal tooltip: .sort-help becomes keyboard/tap focusable (set
|
||||
tabindex="0" in markup). On focus or focus-within, the tip is shown so a
|
||||
tap on touch devices reveals it without requiring hover. */
|
||||
.sort-help { outline: none; }
|
||||
.sort-help:focus,
|
||||
.sort-help:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.sort-help:focus .sort-help-tip,
|
||||
.sort-help:focus-within .sort-help-tip { display: block; }
|
||||
|
||||
/* === Nav === */
|
||||
.top-nav {
|
||||
@@ -124,8 +376,9 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
background: linear-gradient(135deg, var(--nav-bg) 0%, var(--nav-bg2) 100%); color: var(--nav-text); padding: 0 20px; height: 52px;
|
||||
position: sticky; top: 0; z-index: 1100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
||||
flex-wrap: nowrap; overflow: hidden; min-width: 0;
|
||||
}
|
||||
.nav-left { display: flex; align-items: center; gap: 24px; }
|
||||
.nav-left { display: flex; align-items: center; gap: 24px; min-width: 0; flex-shrink: 1; overflow: hidden; }
|
||||
.nav-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--nav-text); font-weight: 700; font-size: 16px; }
|
||||
.brand-icon { font-size: 20px; }
|
||||
.live-dot {
|
||||
@@ -145,6 +398,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
border-bottom: 2px solid transparent; transition: all .15s;
|
||||
background: none; border-top: none; border-left: none; border-right: none;
|
||||
cursor: pointer; font-family: var(--font);
|
||||
white-space: nowrap; /* #1046: never wrap labels — wrapping makes the nav bar grow taller */
|
||||
}
|
||||
.nav-link:hover { color: var(--nav-text); }
|
||||
.nav-link.active {
|
||||
@@ -168,7 +422,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
}
|
||||
.dropdown-item:hover { background: var(--accent); color: #fff; }
|
||||
|
||||
.nav-right { display: flex; align-items: center; gap: 8px; }
|
||||
.nav-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.nav-btn {
|
||||
background: none; border: 1px solid var(--border); color: var(--nav-text-muted); padding: 6px 12px;
|
||||
border-radius: 6px; cursor: pointer; font-size: 14px; transition: all .15s;
|
||||
@@ -537,9 +791,31 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ch-remove-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 13px; padding: 0 2px; margin-left: 4px; opacity: 0; transition: opacity 0.15s; line-height: 1; }
|
||||
button.ch-item:hover .ch-remove-btn { opacity: 0.6; }
|
||||
.ch-remove-btn:hover { opacity: 1 !important; color: var(--danger, #dc2626); }
|
||||
/* Shared icon button base for sidebar row controls (remove ✕, share ⤴).
|
||||
WCAG 2.5.5 / Apple HIG: 44x44 CSS px minimum touch target. */
|
||||
.ch-icon-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: none; border: none; color: var(--text-muted);
|
||||
cursor: pointer; padding: 4px; margin-left: 2px;
|
||||
min-width: 44px; min-height: 44px; box-sizing: border-box;
|
||||
opacity: 0.55; transition: opacity 0.15s, color 0.15s;
|
||||
line-height: 1; user-select: none;
|
||||
}
|
||||
.ch-remove-btn {
|
||||
font-size: 14px;
|
||||
background: var(--statusRed, #b54a4a);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-weight: bold;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ch-share-btn { font-size: 13px; padding: 4px 8px; }
|
||||
button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
.ch-icon-btn:hover, .ch-icon-btn:focus { opacity: 1 !important; outline: none; }
|
||||
.ch-remove-btn:hover, .ch-remove-btn:focus { background: #8b3838; color: white; }
|
||||
.ch-share-btn:hover, .ch-share-btn:focus { color: var(--accent, #3b82f6); }
|
||||
.ch-user-badge { font-size: 12px; margin-left: 2px; opacity: 0.85; flex-shrink: 0; }
|
||||
.ch-item-preview { font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.ch-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; }
|
||||
@@ -936,8 +1212,11 @@ button.ch-item:hover .ch-remove-btn { opacity: 0.6; }
|
||||
.map-controls { width: 180px; font-size: 12px; }
|
||||
}
|
||||
|
||||
/* === Responsive — Tablet Priority+ nav (768–1023px) === */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
/* === Responsive — Tablet/Mid Priority+ nav (768–1279px) ===
|
||||
At <1280px the full nav-links + nav-stats + nav-right buttons can't fit
|
||||
on one row (they total ~1540px on the home page). Collapse non-priority
|
||||
links into the "More ▾" menu so .nav-right stays inside the viewport. */
|
||||
@media (min-width: 768px) and (max-width: 1279px) {
|
||||
.nav-links { display: flex !important; flex-direction: row; gap: 2px; }
|
||||
.nav-links a:not([data-priority="high"]) { display: none; }
|
||||
.nav-more-wrap { display: flex; align-items: center; }
|
||||
@@ -1182,6 +1461,15 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.ch-add-btn:hover { opacity: 0.85; }
|
||||
.ch-add-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; line-height: 1.3; }
|
||||
.ch-add-status { font-size: 12px; margin-top: 4px; padding: 4px 6px; border-radius: 4px; }
|
||||
.ch-analytics-link {
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ch-analytics-link:hover { color: var(--accent); }
|
||||
.ch-add-status--loading { color: var(--text-muted); }
|
||||
.ch-add-status--success { color: var(--success, #22c55e); }
|
||||
.ch-add-status--warn { color: var(--warning, #eab308); }
|
||||
@@ -1251,6 +1539,18 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.analytics-table th.sort-active { color: var(--accent); }
|
||||
.analytics-table th .sort-arrow { font-size: 10px; margin-left: 4px; opacity: 0.7; }
|
||||
.analytics-table td { padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.analytics-table .ch-section-row { background: var(--card-bg); }
|
||||
.analytics-table .ch-section-row td.ch-section-header {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
padding: 10px 8px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
.analytics-table .ch-section-row:hover { background: var(--card-bg); cursor: default; }
|
||||
.hash-bars { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
|
||||
.hash-bar-row { display: flex; align-items: center; gap: 12px; }
|
||||
.hash-bar-label { min-width: 160px; font-size: 13px; }
|
||||
@@ -2369,3 +2669,148 @@ th.sort-active { color: var(--accent, #60a5fa); }
|
||||
.tools-card:hover { border-color: var(--primary); }
|
||||
.tools-card h3 { margin: 0 0 4px 0; font-size: 16px; }
|
||||
.tools-card p { margin: 0; font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
/* ── Map marker clustering (issue #1036) ── */
|
||||
.mc-cluster-wrap { background: transparent !important; border: 0 !important; }
|
||||
.mc-cluster {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
font-family: var(--font, system-ui, sans-serif);
|
||||
color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
border: 2px solid rgba(255,255,255,0.85);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
.mc-cluster:hover { transform: scale(1.06); }
|
||||
.mc-cluster.mc-sm { background: var(--info, #2563eb); width: 40px; height: 40px; }
|
||||
.mc-cluster.mc-md { background: var(--warning, #d97706); width: 48px; height: 48px; }
|
||||
.mc-cluster.mc-lg { background: var(--accent, #dc2626); width: 56px; height: 56px; }
|
||||
.mc-cluster .mc-count { font-size: 14px; font-weight: 700; line-height: 1; }
|
||||
.mc-cluster.mc-lg .mc-count { font-size: 16px; }
|
||||
.mc-cluster .mc-pills {
|
||||
display: flex; gap: 2px; margin-top: 3px;
|
||||
}
|
||||
.mc-cluster .mc-pill {
|
||||
display: inline-block; min-width: 12px; padding: 0 3px;
|
||||
border-radius: 6px; font-size: 9px; font-weight: 600; line-height: 12px;
|
||||
color: #fff; text-align: center; text-shadow: none;
|
||||
border: 1px solid rgba(255,255,255,0.4);
|
||||
}
|
||||
|
||||
/* === #1034 PR1: Channel Add modal + sectioned sidebar === */
|
||||
.ch-add-channel-btn {
|
||||
background: var(--accent, #2563eb); color: #fff; border: none;
|
||||
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.ch-add-channel-btn:hover { background: var(--accent-hover, #1d4ed8); }
|
||||
|
||||
.ch-modal-overlay { z-index: 1100; }
|
||||
.ch-modal-overlay.hidden { display: none; }
|
||||
.ch-modal { width: 560px; max-width: 92vw; padding: 24px 24px 16px; position: relative; }
|
||||
.ch-modal h3 { margin: 0 0 16px; font-size: 18px; }
|
||||
.ch-modal-close {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
font-size: 18px; color: var(--text-muted); padding: 4px 8px; border-radius: 6px;
|
||||
}
|
||||
.ch-modal-close:hover { background: var(--row-hover, rgba(0,0,0,0.05)); color: var(--text); }
|
||||
.ch-modal-section { padding: 12px 0; border-top: 1px solid var(--border); }
|
||||
.ch-modal-section:first-of-type { border-top: none; padding-top: 0; }
|
||||
.ch-modal-section-title { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
|
||||
.ch-modal-section-hint { margin: 0 0 10px; font-size: 12px; color: var(--text-muted); }
|
||||
.ch-modal-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.ch-modal-input {
|
||||
flex: 1; min-width: 0; padding: 7px 10px; font-size: 13px;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--input-bg, var(--card-bg)); color: var(--text);
|
||||
}
|
||||
.ch-modal-input--mono { font-family: var(--mono, monospace); }
|
||||
.ch-modal-btn-secondary {
|
||||
background: var(--card-bg); color: var(--text);
|
||||
border: 1px solid var(--border); padding: 7px 12px;
|
||||
border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.ch-modal-btn-secondary[disabled] { opacity: .5; cursor: not-allowed; }
|
||||
.ch-hashtag-row .ch-hashtag-prefix {
|
||||
font-family: var(--mono, monospace); font-size: 14px; color: var(--text-muted); padding: 0 2px;
|
||||
}
|
||||
.ch-modal-warn { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||
.ch-modal-warn code { background: var(--row-hover, rgba(0,0,0,0.05)); padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
||||
.ch-modal-error { color: var(--status-red, #dc2626); font-size: 12px; margin-top: 4px; }
|
||||
.ch-modal-footer {
|
||||
margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--text-muted); line-height: 1.4;
|
||||
}
|
||||
.ch-modal-callout {
|
||||
margin: 10px 0 14px; padding: 10px 12px; border-radius: 6px;
|
||||
background: var(--warn-bg, #fef3c7); color: var(--warn-text, #92400e);
|
||||
border: 1px solid var(--warn-border, #fcd34d);
|
||||
font-size: 13px; line-height: 1.4;
|
||||
}
|
||||
.ch-section-locality {
|
||||
font-size: 12px; font-weight: 500; text-transform: none;
|
||||
letter-spacing: 0; color: var(--text-muted); opacity: 0.85;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.ch-qr-output { font-size: 11px; font-family: var(--mono, monospace); color: var(--text-muted); word-break: break-all; min-height: 14px; padding: 4px 0; }
|
||||
|
||||
.ch-section { margin-bottom: 8px; }
|
||||
.ch-section-header {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px; font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--text-muted);
|
||||
background: transparent; border: none; width: 100%; text-align: left; cursor: default;
|
||||
}
|
||||
.ch-section-toggle { cursor: pointer; }
|
||||
.ch-section-toggle:hover { color: var(--text); }
|
||||
.ch-section-empty { padding: 8px 12px; font-size: 12px; color: var(--text-muted); font-style: italic; }
|
||||
.ch-section-caret { display: inline-block; width: 10px; }
|
||||
|
||||
/* ── Filter UX (issue #966) ────────────────────────────────────────────── */
|
||||
.fux-bar { display: flex; gap: 6px; margin-top: 4px; align-items: center; flex-wrap: wrap; position: relative; }
|
||||
.fux-help-btn,
|
||||
.fux-saved-trigger { background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 12px; cursor: pointer; }
|
||||
.fux-help-btn:hover,
|
||||
.fux-saved-trigger:hover { background: var(--bg-hover, var(--surface)); }
|
||||
|
||||
.fux-popover { position: fixed; top: 60px; right: 24px; width: min(720px, 92vw); max-height: 80vh; overflow: auto; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.35); z-index: 10000; padding: 0; }
|
||||
.fux-popover-header { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); }
|
||||
.fux-popover-close { background: transparent; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; padding: 0 4px; }
|
||||
.fux-popover-close:hover { color: var(--text); }
|
||||
.fux-popover-body { padding: 12px 16px; font-size: 13px; }
|
||||
.fux-popover-body h3,
|
||||
.fux-popover-body h4 { margin: 12px 0 6px; }
|
||||
.fux-table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 4px 0 12px; }
|
||||
.fux-table th,
|
||||
.fux-table td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.fux-table th { color: var(--text-muted); font-weight: 600; }
|
||||
.fux-mono { font-family: var(--mono); font-size: 12px; }
|
||||
.fux-examples { margin: 4px 0; padding-left: 20px; }
|
||||
.fux-examples li { margin: 2px 0; }
|
||||
|
||||
.fux-ac-dropdown { position: absolute; left: 0; right: 0; top: 100%; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; max-height: 280px; overflow-y: auto; z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.25); margin-top: 2px; }
|
||||
.fux-ac-item { padding: 4px 10px; display: flex; justify-content: space-between; gap: 12px; cursor: pointer; font-size: 12px; }
|
||||
.fux-ac-item:hover,
|
||||
.fux-ac-item.active { background: var(--bg-hover, rgba(120,160,255,0.12)); }
|
||||
.fux-ac-val { font-family: var(--mono); color: var(--text); }
|
||||
.fux-ac-desc { color: var(--text-muted); font-size: 11px; max-width: 60%; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.fux-ctx-menu { position: absolute; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 4px 14px rgba(0,0,0,0.35); z-index: 10001; min-width: 200px; padding: 4px 0; }
|
||||
.fux-ctx-item { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 5px 12px; font-size: 12px; cursor: pointer; font-family: var(--mono); }
|
||||
.fux-ctx-item:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); }
|
||||
|
||||
.fux-saved-menu { position: absolute; top: 100%; left: 0; min-width: 320px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; z-index: 9999; box-shadow: 0 4px 14px rgba(0,0,0,0.3); margin-top: 4px; padding: 4px 0; }
|
||||
.fux-saved-menu.hidden { display: none; }
|
||||
.fux-saved-header { padding: 6px 10px; font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--border); }
|
||||
.fux-saved-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px; cursor: pointer; font-size: 12px; }
|
||||
.fux-saved-item:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); }
|
||||
.fux-saved-name { font-weight: 600; min-width: 120px; }
|
||||
.fux-saved-expr { color: var(--text-muted); font-size: 11px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fux-saved-del { background: transparent; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 0 4px; }
|
||||
.fux-saved-del:hover { color: var(--status-red, #ef4444); }
|
||||
.fux-saved-footer { border-top: 1px solid var(--border); padding: 4px 0; }
|
||||
.fux-saved-save { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 6px 10px; font-size: 12px; cursor: pointer; }
|
||||
.fux-saved-save:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); }
|
||||
|
||||
td[data-filter-field] { cursor: context-menu; }
|
||||
|
||||
@@ -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
@@ -10,11 +10,19 @@ echo ""
|
||||
# Unit tests (deterministic, fast)
|
||||
echo "── Unit Tests ──"
|
||||
node test-packet-filter.js
|
||||
node test-packet-filter-ux.js
|
||||
node test-aging.js
|
||||
node test-frontend-helpers.js
|
||||
node test-url-state.js
|
||||
node test-perf-go-runtime.js
|
||||
node test-channel-psk-ux.js
|
||||
node test-channel-sidebar-layout.js
|
||||
node test-channel-modal-ux.js
|
||||
node test-channel-decrypt-insecure-context.js
|
||||
node test-channel-qr.js
|
||||
node test-channel-qr-wiring.js
|
||||
node test-analytics-channels-integration.js
|
||||
node test-observers-headings.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Analytics → Channels section integration with PSK decrypt UX.
|
||||
*
|
||||
* Bug: the analytics channels list shows nonsense names like "ch185" for
|
||||
* every encrypted channel and ignores the user's locally-decrypted PSK
|
||||
* channels (from ChannelDecrypt.getStoredKeys() + label store).
|
||||
*
|
||||
* Fix:
|
||||
* 1. Replace "chNNN" raw names with "🔒 Encrypted (0xNN)" when the channel
|
||||
* is encrypted and the server only knows its hash byte.
|
||||
* 2. For channels matching a locally-stored PSK key, show the user's
|
||||
* label / key-name instead of the hash-byte placeholder.
|
||||
* 3. Group rendering: My Channels → Network → Encrypted, each sorted by
|
||||
* message count descending.
|
||||
* 4. Add a link from the Channels page to the Analytics page so users can
|
||||
* jump to channel activity stats.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
// ── Set up a tiny browser-ish global so analytics.js loads cleanly ──────────
|
||||
global.window = global;
|
||||
global.document = {
|
||||
documentElement: {},
|
||||
createElement: () => ({ style: {}, addEventListener() {} }),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
getElementById: () => null,
|
||||
};
|
||||
global.localStorage = {
|
||||
_s: {},
|
||||
getItem(k) { return this._s[k] || null; },
|
||||
setItem(k, v) { this._s[k] = String(v); },
|
||||
removeItem(k) { delete this._s[k]; },
|
||||
};
|
||||
global.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
||||
global.registerPage = () => {};
|
||||
global.api = async () => ({});
|
||||
global.fetch = async () => ({ ok: true, json: async () => ({}) });
|
||||
global.CLIENT_TTL = {};
|
||||
global.RegionFilter = { getRegionParam: () => '' };
|
||||
global.Storage = function () {};
|
||||
global.timeAgo = () => '';
|
||||
global.histogram = () => ({ svg: '' });
|
||||
|
||||
// Load analytics.js — it self-registers global helpers we test.
|
||||
const analyticsSrc = fs.readFileSync(
|
||||
path.join(__dirname, 'public/analytics.js'),
|
||||
'utf8'
|
||||
);
|
||||
// Strip top-level `await` / module syntax — analytics.js is plain IIFE so it's
|
||||
// fine to eval as-is.
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(analyticsSrc); // sets window._analyticsDecorateChannels etc.
|
||||
|
||||
console.log('\n=== Analytics channels: decorate with PSK keys ===');
|
||||
|
||||
const decorate = global._analyticsDecorateChannels;
|
||||
assert(typeof decorate === 'function',
|
||||
'_analyticsDecorateChannels exposed for testing');
|
||||
|
||||
// Server response sample — mix of cleartext, rainbow-known encrypted, raw "chNNN".
|
||||
const sampleChannels = [
|
||||
{ hash: 17, name: 'public', messages: 100, senders: 5, encrypted: false },
|
||||
{ hash: 217, name: '#test', messages: 200, senders: 8, encrypted: false },
|
||||
{ hash: 185, name: 'ch185', messages: 50, senders: 0, encrypted: true },
|
||||
{ hash: 64, name: 'ch64', messages: 300, senders: 0, encrypted: true },
|
||||
{ hash: 30, name: 'ch30', messages: 75, senders: 0, encrypted: true },
|
||||
{ hash: 99, name: '#earthquake', messages: 10, senders: 1, encrypted: false },
|
||||
// Rainbow-table hit on an ENCRYPTED channel: server resolved a real name.
|
||||
{ hash: 12, name: 'public-meshcore', messages: 40, senders: 2, encrypted: true },
|
||||
// Encrypted channel with empty name — must not render an empty <strong>.
|
||||
{ hash: 200, name: '', messages: 5, senders: 0, encrypted: true },
|
||||
];
|
||||
|
||||
// User has two PSK keys locally: one matches hash=185 (named "Levski"),
|
||||
// one matches hash=30 (named "secret-room", with label "Garage").
|
||||
const myKeyHashToName = { 185: 'Levski', 30: 'secret-room' };
|
||||
const labels = { 'secret-room': 'Garage' };
|
||||
|
||||
const out = decorate(sampleChannels, myKeyHashToName, labels);
|
||||
assert(Array.isArray(out), 'decorate returns an array');
|
||||
assert(out.length === sampleChannels.length, 'decorate keeps every channel');
|
||||
|
||||
// Find by original hash (and optionally original name) for assertions.
|
||||
// Decoration preserves c.name as-is and writes the user-facing string to
|
||||
// c.displayName, so matching on c.name is unambiguous.
|
||||
function find(hash, name) {
|
||||
return out.find(c => c.hash === hash && (name == null || c.name === name));
|
||||
}
|
||||
|
||||
const mine185 = find(185, 'ch185');
|
||||
assert(mine185 && mine185.displayName === 'Levski',
|
||||
'hash 185 + stored key → displayName = "Levski" (not "ch185")');
|
||||
assert(mine185 && mine185.group === 'mine',
|
||||
'hash 185 grouped as "mine"');
|
||||
|
||||
const mine30 = find(30, 'ch30');
|
||||
assert(mine30 && mine30.displayName === 'Garage',
|
||||
'hash 30 with stored key + label → displayName = "Garage" (label wins)');
|
||||
assert(mine30 && mine30.group === 'mine', 'hash 30 grouped as "mine"');
|
||||
|
||||
const ch64 = find(64, 'ch64');
|
||||
assert(ch64 && ch64.displayName === '🔒 Encrypted (0x40)',
|
||||
'unknown encrypted ch64 → "🔒 Encrypted (0x40)" (no nonsense "ch64")');
|
||||
assert(ch64 && ch64.group === 'encrypted', 'unknown encrypted grouped as "encrypted"');
|
||||
|
||||
const pub = find(17, 'public');
|
||||
assert(pub && pub.displayName === 'public', 'cleartext public name preserved');
|
||||
assert(pub && pub.group === 'network', 'cleartext public grouped as "network"');
|
||||
|
||||
const test = find(217, '#test');
|
||||
assert(test && test.group === 'network', 'rainbow-known #test grouped as "network"');
|
||||
|
||||
// Rainbow-table hit on an ENCRYPTED channel — actually exercises the
|
||||
// "encrypted but server has the real name" branch (was previously dead-untested).
|
||||
const rainbow = find(12, 'public-meshcore');
|
||||
assert(rainbow && rainbow.encrypted === true,
|
||||
'rainbow row preserves encrypted=true');
|
||||
assert(rainbow && rainbow.displayName === 'public-meshcore',
|
||||
'rainbow-decoded encrypted row → displayName = real name');
|
||||
assert(rainbow && rainbow.group === 'network',
|
||||
'rainbow-decoded encrypted row → group = "network"');
|
||||
|
||||
// Empty-name encrypted: must NOT leak through with displayName = ''.
|
||||
const empty = find(200, '');
|
||||
assert(empty && empty.displayName === '🔒 Encrypted (0xC8)',
|
||||
'encrypted with empty name → render as opaque encrypted placeholder');
|
||||
assert(empty && empty.group === 'encrypted',
|
||||
'encrypted with empty name → group = "encrypted"');
|
||||
|
||||
// No "chNNN" leaks into displayName for any row.
|
||||
const leak = out.find(c => /^ch(\d+|\?)$/.test(c.displayName));
|
||||
assert(!leak, 'no displayName matches the raw chNNN placeholder');
|
||||
|
||||
console.log('\n=== Grouped table render: order + sort ===');
|
||||
|
||||
const tbody = global._analyticsChannelTbodyHtml(out, 'messages', 'desc', {
|
||||
grouped: true,
|
||||
});
|
||||
assert(typeof tbody === 'string' && tbody.length > 0,
|
||||
'channelTbodyHtml accepts grouped option and returns html');
|
||||
|
||||
// Group headers must appear in order: My Channels, Network, Encrypted.
|
||||
const iMine = tbody.indexOf('My Channels');
|
||||
const iNet = tbody.indexOf('Network');
|
||||
const iEnc = tbody.indexOf('Encrypted');
|
||||
assert(iMine >= 0 && iNet > iMine && iEnc > iNet,
|
||||
'group headers render in order: My Channels → Network → Encrypted');
|
||||
|
||||
// Within "mine" section, hash=30 (75 msgs) > hash=185 (50 msgs).
|
||||
const i30 = tbody.indexOf('Garage');
|
||||
const i185 = tbody.indexOf('Levski');
|
||||
assert(i30 > 0 && i185 > i30,
|
||||
'within "My Channels" sort by messages desc (Garage 75 before Levski 50)');
|
||||
|
||||
// Within "network" section, #test (200) > public (100) > #earthquake (10).
|
||||
const iT = tbody.indexOf('#test');
|
||||
const iP = tbody.indexOf('public');
|
||||
const iE = tbody.indexOf('#earthquake');
|
||||
assert(iT > 0 && iP > iT && iE > iP,
|
||||
'within "Network" sort by messages desc (#test → public → #earthquake)');
|
||||
|
||||
// Within "encrypted" section, ch64 (300 msgs) appears (only one entry).
|
||||
assert(tbody.indexOf('0x40') > iEnc, 'encrypted section contains 0x40');
|
||||
|
||||
console.log('\n=== Channels page links to Analytics ===');
|
||||
|
||||
const channelsSrc = fs.readFileSync(
|
||||
path.join(__dirname, 'public/channels.js'),
|
||||
'utf8'
|
||||
);
|
||||
assert(/#\/analytics/.test(channelsSrc) &&
|
||||
/Channel Analytics|channel analytics/i.test(channelsSrc),
|
||||
'channels.js sidebar links to #/analytics with "Channel Analytics" text');
|
||||
|
||||
console.log('\n' + (failed ? '✗ ' + failed + ' failed, ' : '') + passed + ' passed');
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Regression test: live PSK decrypt for user-added channels (#1029 follow-up).
|
||||
*
|
||||
* PR #1030 added decryptLivePSKBatch() which rewrites encrypted GRP_TXT
|
||||
* WS packets in place when a stored PSK key matches. It sets
|
||||
* payload.channel = dec.channelName (e.g. "medusa")
|
||||
* but user-added channels are stored in channels[] with hash:
|
||||
* "user:medusa"
|
||||
* (and selectedHash is also "user:medusa" when viewing).
|
||||
*
|
||||
* Symptoms in production:
|
||||
* - selectedHash === "user:medusa" but processWSBatch compares
|
||||
* `channelName === selectedHash` ("medusa" !== "user:medusa") so a live
|
||||
* packet for the open channel is NEVER appended to the message list.
|
||||
* - channels.find(c => c.hash === channelName) misses the user channel and
|
||||
* a duplicate plain entry "medusa" is pushed into the sidebar; the real
|
||||
* user-added channel's lastMessage / messageCount / lastActivityMs never
|
||||
* update.
|
||||
* - The unread bumper guards with `chName === prior` (raw name vs prefixed
|
||||
* selectedHash), so an unread badge is added even when the user IS
|
||||
* actively viewing that channel.
|
||||
*
|
||||
* Fix: have the live decrypt rewrite annotate the payload with the
|
||||
* canonical channel hash that channels[] / selectedHash use. A simple,
|
||||
* non-breaking shape: keep payload.channel = name (so the rest of
|
||||
* processWSBatch keeps working for non-user channels), AND also set
|
||||
* payload.channelKey = "user:" + name when a user-added channel exists for
|
||||
* that name. processWSBatch then uses channelKey when present for the
|
||||
* lookup + selectedHash comparison.
|
||||
*
|
||||
* This test loads the real channels.js in a vm sandbox, primes a
|
||||
* user-added channel, drives an encrypted GRP_TXT through the WS handler
|
||||
* and asserts:
|
||||
* 1. the open channel's message list grows by 1 (text is decrypted-locally
|
||||
* and visible in the messages array)
|
||||
* 2. the user-added channel's messageCount / lastMessage update
|
||||
* 3. NO duplicate plain "medusa" entry is added to channels[]
|
||||
* 4. unread is NOT bumped on the channel currently being viewed
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createCipheriv, createHmac, createHash, webcrypto } = require('crypto');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
function buildEncryptedGrpTxt(channelName, sender, message) {
|
||||
const key = createHash('sha256').update(channelName).digest().slice(0, 16);
|
||||
const channelHash = createHash('sha256').update(key).digest()[0];
|
||||
const text = `${sender}: ${message}`;
|
||||
const inner = 5 + Buffer.byteLength(text, 'utf8') + 1;
|
||||
const padded = Math.ceil(inner / 16) * 16;
|
||||
const pt = Buffer.alloc(padded);
|
||||
pt.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
|
||||
pt[4] = 0;
|
||||
pt.write(text, 5, 'utf8');
|
||||
const cipher = createCipheriv('aes-128-ecb', key, null);
|
||||
cipher.setAutoPadding(false);
|
||||
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
|
||||
const secret = Buffer.concat([key, Buffer.alloc(16)]);
|
||||
const mac = createHmac('sha256', secret).update(ct).digest().slice(0, 2);
|
||||
return {
|
||||
payload: {
|
||||
type: 'GRP_TXT',
|
||||
channelHash,
|
||||
channelHashHex: channelHash.toString(16).padStart(2, '0'),
|
||||
mac: mac.toString('hex'),
|
||||
encryptedData: ct.toString('hex'),
|
||||
decryptionStatus: 'no_key',
|
||||
},
|
||||
keyHex: key.toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
function makeBrowserLikeSandbox() {
|
||||
const storage = {};
|
||||
const elements = {};
|
||||
function makeFakeEl(id) {
|
||||
return {
|
||||
id: id || '', innerHTML: '', textContent: '', value: '', scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
style: {}, dataset: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
querySelector() { return makeFakeEl(); },
|
||||
querySelectorAll() { return []; },
|
||||
getAttribute() { return null; }, setAttribute() {},
|
||||
getBoundingClientRect() { return { width: 240, height: 0, top: 0, left: 0, right: 0, bottom: 0 }; },
|
||||
appendChild() {}, removeChild() {},
|
||||
focus() {}, blur() {},
|
||||
checked: false,
|
||||
};
|
||||
}
|
||||
function el(id) {
|
||||
if (!elements[id]) elements[id] = makeFakeEl(id);
|
||||
return elements[id];
|
||||
}
|
||||
const ctx = {
|
||||
window: {},
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
documentElement: { getAttribute: () => null, setAttribute() {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } } },
|
||||
createElement: () => ({ id: '', textContent: '', innerHTML: '', style: {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, addEventListener() {}, appendChild() {}, querySelector() { return null; }, querySelectorAll() { return []; } }),
|
||||
head: { appendChild() {} },
|
||||
body: { appendChild() {} },
|
||||
getElementById: el,
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
console,
|
||||
Date, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError, Set, Map, Promise,
|
||||
parseInt, parseFloat, isNaN, isFinite,
|
||||
encodeURIComponent, decodeURIComponent,
|
||||
setTimeout: (fn) => { Promise.resolve().then(fn); return 0; },
|
||||
clearTimeout: () => {},
|
||||
setInterval: () => 0,
|
||||
clearInterval: () => {},
|
||||
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
|
||||
performance: { now: () => Date.now() },
|
||||
localStorage: {
|
||||
getItem: (k) => Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null,
|
||||
setItem: (k, v) => { storage[k] = String(v); },
|
||||
removeItem: (k) => { delete storage[k]; },
|
||||
},
|
||||
location: { hash: '' },
|
||||
history: { replaceState() {}, pushState() {} },
|
||||
crypto: webcrypto,
|
||||
TextEncoder, TextDecoder,
|
||||
Uint8Array, Uint16Array, Uint32Array, Int8Array, Int16Array, Int32Array, ArrayBuffer,
|
||||
URLSearchParams,
|
||||
CustomEvent: class CustomEvent {},
|
||||
MutationObserver: class MutationObserver { observe() {} disconnect() {} },
|
||||
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
addEventListener() {}, dispatchEvent() {},
|
||||
getHashParams: () => new URLSearchParams(),
|
||||
};
|
||||
ctx.self = ctx;
|
||||
ctx.globalThis = ctx;
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadInCtx(ctx, file) {
|
||||
const src = fs.readFileSync(path.join(__dirname, file), 'utf8');
|
||||
vm.runInContext(src, ctx, { filename: file });
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('\n=== Live PSK decrypt: user-added channel (user:* prefix) routing ===');
|
||||
|
||||
const ctx = makeBrowserLikeSandbox();
|
||||
ctx.window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} });
|
||||
ctx.window.addEventListener = () => {};
|
||||
ctx.btoa = (s) => Buffer.from(String(s), 'binary').toString('base64');
|
||||
ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('binary');
|
||||
|
||||
// App.js stubs: provide debouncedOnWS / onWS / offWS / api / debounce /
|
||||
// invalidateApiCache / registerPage so channels.js loads cleanly.
|
||||
let wsListeners = [];
|
||||
ctx.onWS = (fn) => { wsListeners.push(fn); };
|
||||
ctx.offWS = (fn) => { wsListeners = wsListeners.filter(f => f !== fn); };
|
||||
ctx.debouncedOnWS = function (fn) {
|
||||
function handler(msg) { fn([msg]); }
|
||||
wsListeners.push(handler);
|
||||
return handler;
|
||||
};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.api = () => Promise.resolve({ channels: [], observers: [] });
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.CLIENT_TTL = { channels: 60000, observers: 600000 };
|
||||
ctx.escapeHtml = (s) => String(s == null ? '' : s);
|
||||
ctx.truncate = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n) : s; };
|
||||
ctx.formatHashHex = (h) => String(h);
|
||||
ctx.formatSecondsAgo = () => '';
|
||||
ctx.payloadTypeName = () => 'GRP_TXT';
|
||||
ctx.RegionFilter = {
|
||||
init() {},
|
||||
onChange(fn) { return () => {}; },
|
||||
offChange() {},
|
||||
getRegionParam() { return ''; },
|
||||
getSelected() { return null; },
|
||||
};
|
||||
ctx.ChannelColors = { get() { return null; }, remove() {} };
|
||||
ctx.ChannelColorPicker = { open() {} };
|
||||
ctx.normalizeObserverNameKey = (s) => String(s || '').toLowerCase();
|
||||
let pageMod = null;
|
||||
ctx.registerPage = (name, mod) => { if (name === 'channels') pageMod = mod; };
|
||||
|
||||
// Load AES + ChannelDecrypt + channels.js
|
||||
loadInCtx(ctx, 'public/vendor/aes-ecb.js');
|
||||
loadInCtx(ctx, 'public/channel-decrypt.js');
|
||||
loadInCtx(ctx, 'public/channels.js');
|
||||
|
||||
const CD = ctx.window.ChannelDecrypt;
|
||||
assert(typeof CD.tryDecryptLive === 'function', 'ChannelDecrypt.tryDecryptLive available');
|
||||
|
||||
const channelName = 'medusa';
|
||||
const fixture = buildEncryptedGrpTxt(channelName, 'Alice', 'hello darkness');
|
||||
CD.storeKey(channelName, fixture.keyHex);
|
||||
|
||||
// Initialize the channels page so wsHandler is wired up
|
||||
const appEl = ctx.document.getElementById('page');
|
||||
appEl.innerHTML = '';
|
||||
await pageMod.init(appEl, null);
|
||||
// pump microtasks
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
ctx.window._channelsSetStateForTest({
|
||||
channels: [{
|
||||
hash: 'user:' + channelName,
|
||||
name: channelName,
|
||||
messageCount: 0,
|
||||
lastActivityMs: 0,
|
||||
lastSender: '',
|
||||
lastMessage: 'Encrypted — click to decrypt',
|
||||
encrypted: true,
|
||||
userAdded: true,
|
||||
}],
|
||||
messages: [],
|
||||
selectedHash: 'user:' + channelName,
|
||||
});
|
||||
|
||||
// Drive the WS path — same shape the Go server broadcasts
|
||||
const wsMsg = {
|
||||
type: 'packet',
|
||||
data: {
|
||||
id: 12345,
|
||||
hash: 'deadbeef',
|
||||
observer_name: 'TestObserver',
|
||||
packet: { observer_name: 'TestObserver' },
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'GRP_TXT' },
|
||||
payload: fixture.payload,
|
||||
},
|
||||
},
|
||||
};
|
||||
for (const fn of wsListeners) fn(wsMsg);
|
||||
// Allow async decryptLivePSKBatch + setTimeout chain to settle
|
||||
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const state = ctx.window._channelsGetStateForTest();
|
||||
|
||||
// (1) Message list for the open channel grew
|
||||
assert(state.messages.length === 1,
|
||||
'open user-added channel receives the live-decrypted message (got ' + state.messages.length + ')');
|
||||
if (state.messages[0]) {
|
||||
assert(state.messages[0].text === 'hello darkness',
|
||||
'decrypted text is rendered (got ' + JSON.stringify(state.messages[0].text) + ')');
|
||||
assert(state.messages[0].sender === 'Alice',
|
||||
'decrypted sender is rendered (got ' + JSON.stringify(state.messages[0].sender) + ')');
|
||||
}
|
||||
|
||||
// (2) The user-added channel's metadata updated
|
||||
const userCh = state.channels.find((c) => c.hash === 'user:' + channelName);
|
||||
assert(userCh && userCh.messageCount === 1,
|
||||
'user-added channel messageCount incremented (got ' + (userCh && userCh.messageCount) + ')');
|
||||
assert(userCh && userCh.lastMessage && userCh.lastMessage.indexOf('hello') !== -1,
|
||||
'user-added channel lastMessage updated (got ' + (userCh && userCh.lastMessage) + ')');
|
||||
|
||||
// (3) No duplicate plain "medusa" entry was created in the sidebar
|
||||
const dupes = state.channels.filter((c) => c.hash === channelName);
|
||||
assert(dupes.length === 0,
|
||||
'no duplicate non-prefixed channel entry created (got ' + dupes.length + ')');
|
||||
assert(state.channels.length === 1,
|
||||
'sidebar still has exactly the one user-added channel (got ' + state.channels.length + ')');
|
||||
|
||||
// (4) Unread NOT bumped on the channel actively being viewed
|
||||
assert(!userCh || !userCh.unread,
|
||||
'unread NOT bumped on the actively-viewed channel (got ' + (userCh && userCh.unread) + ')');
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
console.log('Passed: ' + passed + ', Failed: ' + failed);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* E2E (#1034 PR1): Channel Add modal + sectioned sidebar.
|
||||
*
|
||||
* Boots a headless Chromium against a locally running corescope-server and
|
||||
* exercises:
|
||||
* - sidebar [+ Add Channel] opens modal
|
||||
* - modal renders three labeled sections + privacy footer + QR placeholders
|
||||
* - close (✕) hides modal
|
||||
* - sectioned sidebar renders My Channels / Network / Encrypted sections
|
||||
* - PSK add flow: invalid hex → error; valid hex → modal closes
|
||||
*
|
||||
* Usage: BASE_URL=http://localhost:38201 node test-channel-modal-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:38201';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
||||
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
console.log(`\n=== #1034 PR1 E2E against ${BASE} ===`);
|
||||
|
||||
await step('navigate to /channels', async () => {
|
||||
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#chAddChannelBtn', { timeout: 8000 });
|
||||
});
|
||||
|
||||
await step('Add Channel button is visible', async () => {
|
||||
const text = await page.textContent('#chAddChannelBtn');
|
||||
assert(/Add Channel/.test(text), 'button text: ' + text);
|
||||
});
|
||||
|
||||
await step('modal hidden on load', async () => {
|
||||
const isHidden = await page.evaluate(() => {
|
||||
const m = document.getElementById('chAddChannelModal');
|
||||
return !!m && (m.classList.contains('hidden') || m.hasAttribute('hidden'));
|
||||
});
|
||||
assert(isHidden, 'modal should start hidden');
|
||||
});
|
||||
|
||||
await step('clicking [+ Add Channel] opens modal', async () => {
|
||||
await page.click('#chAddChannelBtn');
|
||||
await page.waitForSelector('#chAddChannelModal:not(.hidden)', { timeout: 3000 });
|
||||
const visible = await page.isVisible('#chAddChannelModal');
|
||||
assert(visible, 'modal should be visible after click');
|
||||
});
|
||||
|
||||
await step('modal renders all three section titles', async () => {
|
||||
const html = await page.innerHTML('#chAddChannelModal');
|
||||
assert(html.includes('Generate PSK Channel'), 'section 1 missing');
|
||||
assert(html.includes('Add Private Channel (PSK)'), 'section 2 missing');
|
||||
assert(html.includes('Monitor Hashtag Channel'), 'section 3 missing');
|
||||
});
|
||||
|
||||
await step('modal renders QR placeholders', async () => {
|
||||
assert(await page.isVisible('#qr-output'), '#qr-output missing');
|
||||
const scanBtn = await page.$('#scan-qr-btn');
|
||||
assert(scanBtn, '#scan-qr-btn missing');
|
||||
const disabled = await scanBtn.getAttribute('disabled');
|
||||
assert(disabled === null, '#scan-qr-btn must be enabled (wired in #1034 PR3)');
|
||||
});
|
||||
|
||||
await step('modal renders privacy footer', async () => {
|
||||
const footer = await page.textContent('#chAddChannelModal .ch-modal-footer');
|
||||
assert(/Keys stay in your browser/.test(footer), 'footer text missing: ' + footer);
|
||||
assert(/passive observer/.test(footer), 'passive observer text missing');
|
||||
});
|
||||
|
||||
await step('modal renders case-sensitivity warning', async () => {
|
||||
const warn = await page.textContent('#chAddChannelModal .ch-modal-warn');
|
||||
assert(/[Cc]ase-sensitive/.test(warn), 'warning missing: ' + warn);
|
||||
});
|
||||
|
||||
await step('PSK add: invalid hex shows inline error', async () => {
|
||||
await page.fill('#chPskKey', 'not-hex');
|
||||
await page.click('#chPskAddBtn');
|
||||
await page.waitForFunction(() => {
|
||||
const e = document.getElementById('chPskError');
|
||||
return e && e.style.display !== 'none' && /hex/i.test(e.textContent);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
await step('close button (✕) hides modal', async () => {
|
||||
await page.click('#chModalClose');
|
||||
await page.waitForFunction(() => {
|
||||
const m = document.getElementById('chAddChannelModal');
|
||||
return m && m.classList.contains('hidden');
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
await step('sidebar renders three sections (My Channels / Network / Encrypted)', async () => {
|
||||
// Wait for channel list to populate from API (or render empty-state).
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.getElementById('chList');
|
||||
if (!el) return false;
|
||||
return el.querySelector('.ch-section-mychannels') &&
|
||||
el.querySelector('.ch-section-network') &&
|
||||
el.querySelector('.ch-section-encrypted');
|
||||
}, { timeout: 8000 });
|
||||
const headers = await page.$$eval('.ch-section-header', els => els.map(e => e.textContent.trim()));
|
||||
const joined = headers.join(' | ');
|
||||
assert(/My Channels/.test(joined), 'My Channels header missing: ' + joined);
|
||||
assert(/Network/.test(joined), 'Network header missing');
|
||||
assert(/Encrypted/.test(joined), 'Encrypted header missing');
|
||||
});
|
||||
|
||||
await step('Encrypted section is collapsed by default', async () => {
|
||||
const collapsed = await page.getAttribute('.ch-section-encrypted', 'data-encrypted-collapsed');
|
||||
assert(collapsed === 'true', 'expected data-encrypted-collapsed=true, got ' + collapsed);
|
||||
const bodyHidden = await page.evaluate(() => {
|
||||
const b = document.getElementById('chEncryptedBody');
|
||||
return b ? b.hasAttribute('hidden') : null;
|
||||
});
|
||||
assert(bodyHidden === true, 'encrypted body should be hidden initially');
|
||||
});
|
||||
|
||||
await step('clicking Encrypted toggle expands it', async () => {
|
||||
await page.click('#chEncryptedToggle');
|
||||
const bodyHidden = await page.evaluate(() => {
|
||||
const b = document.getElementById('chEncryptedBody');
|
||||
return b ? b.hasAttribute('hidden') : null;
|
||||
});
|
||||
assert(bodyHidden === false, 'encrypted body should be visible after toggle');
|
||||
});
|
||||
|
||||
await step('PSK add: valid hex closes modal and persists key', async () => {
|
||||
await page.click('#chAddChannelBtn');
|
||||
await page.waitForSelector('#chAddChannelModal:not(.hidden)');
|
||||
const validHex = 'cafebabe' + '00112233' + '44556677' + '8899aabb';
|
||||
await page.fill('#chPskKey', validHex);
|
||||
await page.fill('#chPskName', 'E2E Test Channel');
|
||||
await page.click('#chPskAddBtn');
|
||||
await page.waitForFunction(() => {
|
||||
const m = document.getElementById('chAddChannelModal');
|
||||
return m && m.classList.contains('hidden');
|
||||
}, { timeout: 5000 });
|
||||
const stored = await page.evaluate(() => localStorage.getItem('corescope_channel_keys') || '');
|
||||
assert(/cafebabe/i.test(stored), 'expected stored key in localStorage corescope_channel_keys, got: ' + stored);
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Tests for #1034 — Channel UX redesign PR1: Modal + sectioned sidebar.
|
||||
*
|
||||
* Pattern follows test-channel-psk-ux.js: string-contract assertions over
|
||||
* public/channels.js + DOM render harness via vm sandbox.
|
||||
*
|
||||
* - [+ Add Channel] button in sidebar (replaces inline form)
|
||||
* - Modal overlay with three labeled sections:
|
||||
* Generate PSK Channel | Add Private Channel (PSK) | Monitor Hashtag Channel
|
||||
* - QR placeholders (#qr-output, #scan-qr-btn[disabled])
|
||||
* - Privacy footer text
|
||||
* - Sectioned sidebar render: My Channels / Network / Encrypted (N)
|
||||
* - "No key" checkbox is gone
|
||||
* - Three modal action handlers wired
|
||||
*
|
||||
* Runs in Node.js — no browser.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
|
||||
const cssSrc = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
|
||||
|
||||
console.log('\n=== #1034 PR1: [+ Add Channel] sidebar button ===');
|
||||
assert(/id="chAddChannelBtn"/.test(chSrc),
|
||||
'sidebar exposes #chAddChannelBtn (replaces inline form)');
|
||||
assert(/\+ Add Channel/.test(chSrc) || /Add Channel/.test(chSrc),
|
||||
'[+ Add Channel] button label present');
|
||||
// Old "No key" toggle must be GONE.
|
||||
assert(!/No key/.test(chSrc),
|
||||
'old "No key" checkbox removed from sidebar');
|
||||
assert(!/id="chShowEncrypted"/.test(chSrc),
|
||||
'old #chShowEncrypted toggle removed');
|
||||
|
||||
console.log('\n=== #1034 PR1: Modal markup ===');
|
||||
assert(/id="chAddChannelModal"/.test(chSrc),
|
||||
'modal element #chAddChannelModal exists');
|
||||
assert(/modal-overlay|ch-modal-overlay/.test(chSrc),
|
||||
'modal uses overlay pattern (matches existing modal-overlay class)');
|
||||
assert(/data-action="ch-modal-close"/.test(chSrc) || /id="chModalClose"/.test(chSrc),
|
||||
'modal has close affordance (data-action ch-modal-close or #chModalClose)');
|
||||
|
||||
console.log('\n=== #1034 PR1: Three sections by label ===');
|
||||
assert(/Generate PSK Channel/.test(chSrc),
|
||||
'section 1 label: "Generate PSK Channel"');
|
||||
assert(/Add Private Channel \(PSK\)/.test(chSrc),
|
||||
'section 2 label: "Add Private Channel (PSK)"');
|
||||
assert(/Monitor Hashtag Channel/.test(chSrc),
|
||||
'section 3 label: "Monitor Hashtag Channel"');
|
||||
|
||||
console.log('\n=== #1034 PR1: Section 1 — Generate PSK ===');
|
||||
assert(/id="chGenerateName"/.test(chSrc),
|
||||
'generate section has #chGenerateName input');
|
||||
assert(/id="chGenerateBtn"/.test(chSrc),
|
||||
'generate section has #chGenerateBtn');
|
||||
assert(/Generate & Show QR|Generate & Show QR/.test(chSrc),
|
||||
'[Generate & Show QR] button label present');
|
||||
assert(/id="qr-output"/.test(chSrc),
|
||||
'#qr-output placeholder div present (QR code render is PR #2)');
|
||||
|
||||
console.log('\n=== #1034 PR1: Section 2 — Add PSK ===');
|
||||
assert(/id="chPskKey"/.test(chSrc),
|
||||
'PSK section has #chPskKey input (32-hex)');
|
||||
assert(/id="chPskName"/.test(chSrc),
|
||||
'PSK section has optional #chPskName input');
|
||||
assert(/id="chPskAddBtn"/.test(chSrc),
|
||||
'PSK section has #chPskAddBtn');
|
||||
assert(/id="scan-qr-btn"/.test(chSrc),
|
||||
'#scan-qr-btn present (wired in PR3 — see test-channel-qr-wiring.js)');
|
||||
assert(/\[0-9a-fA-F\]\{32\}|isHexKey/.test(chSrc),
|
||||
'PSK section validates 32-hex format');
|
||||
|
||||
console.log('\n=== #1034 PR1: Section 3 — Monitor Hashtag ===');
|
||||
assert(/id="chHashtagName"/.test(chSrc),
|
||||
'hashtag section has #chHashtagName input');
|
||||
assert(/id="chHashtagBtn"/.test(chSrc),
|
||||
'hashtag section has #chHashtagBtn');
|
||||
assert(/Case-sensitive|case-sensitive/.test(chSrc),
|
||||
'hashtag section shows case-sensitivity warning');
|
||||
|
||||
console.log('\n=== #1034 PR1: Privacy footer ===');
|
||||
assert(/Keys stay in your browser/.test(chSrc),
|
||||
'privacy footer "Keys stay in your browser" present');
|
||||
assert(/passive observer/.test(chSrc),
|
||||
'privacy footer mentions "passive observer"');
|
||||
|
||||
console.log('\n=== #1034 PR1: Sectioned sidebar ===');
|
||||
assert(/ch-section-mychannels|My Channels/.test(chSrc),
|
||||
'sidebar renders "My Channels" section');
|
||||
assert(/ch-section-network|>Network</.test(chSrc),
|
||||
'sidebar renders "Network" section');
|
||||
assert(/ch-section-encrypted|Encrypted \(/.test(chSrc),
|
||||
'sidebar renders "Encrypted (N)" section');
|
||||
assert(/data-encrypted-collapsed|chEncryptedCollapsed|encrypted-collapsed/.test(chSrc),
|
||||
'Encrypted section is collapsible (collapsed by default)');
|
||||
|
||||
console.log('\n=== #1034 PR1: Modal action wiring ===');
|
||||
assert(/chGenerateBtn[\s\S]{0,400}addEventListener|onGenerate|generatePsk/.test(chSrc),
|
||||
'#chGenerateBtn has a click handler wired');
|
||||
assert(/chPskAddBtn[\s\S]{0,400}addEventListener|onPskAdd/.test(chSrc),
|
||||
'#chPskAddBtn has a click handler wired');
|
||||
assert(/chHashtagBtn[\s\S]{0,400}addEventListener|onHashtag/.test(chSrc),
|
||||
'#chHashtagBtn has a click handler wired');
|
||||
// Generate uses crypto.getRandomValues(16)
|
||||
assert(/getRandomValues\(\s*new Uint8Array\(\s*16\s*\)|getRandomValues\([^)]*16/.test(chSrc),
|
||||
'generate handler uses crypto.getRandomValues(16) for the key');
|
||||
|
||||
console.log('\n=== #1034 PR1: CSS for modal ===');
|
||||
assert(/ch-modal|ch-add-modal|chAddChannelModal/.test(cssSrc) || /\.modal-overlay/.test(cssSrc),
|
||||
'modal CSS present (ch-modal-* or reuses .modal-overlay)');
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
console.log('Passed: ' + passed + ', Failed: ' + failed);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -80,10 +80,10 @@ async function run() {
|
||||
console.log('\n=== #1020 PSK UX: channels.js DOM/contract ===');
|
||||
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
|
||||
|
||||
// E2E DOM: optional label input in add form
|
||||
assert(chSrc.includes('id="chKeyLabelInput"'),
|
||||
'add form contains chKeyLabelInput element');
|
||||
assert(/placeholder="[^"]*name[^"]*"/i.test(chSrc) || chSrc.includes('chKeyLabelInput'),
|
||||
// E2E DOM: optional label input in add form (now in #1034 modal as #chPskName)
|
||||
assert(chSrc.includes('id="chPskName"') || chSrc.includes('id="chKeyLabelInput"'),
|
||||
'add form contains optional label input (#chPskName in modal, was #chKeyLabelInput)');
|
||||
assert(/placeholder="[^"]*name[^"]*"/i.test(chSrc) || chSrc.includes('chPskName') || chSrc.includes('chKeyLabelInput'),
|
||||
'label input has a name-related placeholder');
|
||||
|
||||
// E2E DOM: distinct badge class/marker for user-added channels
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* #1034 PR3: Wiring tests — verify public/channels.js calls into
|
||||
* window.ChannelQR.generate() from the Generate handler, and that the
|
||||
* Scan button is enabled + wired to ChannelQR.scan() that populates
|
||||
* the PSK fields.
|
||||
*
|
||||
* Pure source-string + targeted-snippet assertions (no browser).
|
||||
* E2E behavior is covered by test-channel-modal-e2e.js extensions.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, 'public/channels.js'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
console.log('\n=== #1034 PR3: Generate handler renders QR via ChannelQR.generate ===');
|
||||
|
||||
// Locate the chGenerateBtn handler block.
|
||||
var genIdx = src.indexOf("var genBtn = document.getElementById('chGenerateBtn')");
|
||||
assert(genIdx > 0, 'found chGenerateBtn handler block');
|
||||
var genBlock = src.substring(genIdx, genIdx + 1200);
|
||||
|
||||
assert(/ChannelQR\s*\.\s*generate\s*\(/.test(genBlock) ||
|
||||
/window\.ChannelQR\.generate\s*\(/.test(genBlock),
|
||||
'Generate handler calls ChannelQR.generate(...)');
|
||||
|
||||
// Old placeholder text must be gone (it forced "QR coming in next update").
|
||||
assert(!/QR code coming in next update/.test(genBlock),
|
||||
'Generate handler no longer prints "QR coming in next update" placeholder');
|
||||
|
||||
// The generate call should pass the qr-output element as the render target.
|
||||
assert(/ChannelQR\.generate\([^)]*qrOut|generate\([^)]*qr-output/.test(genBlock),
|
||||
'Generate handler passes #qr-output as the QR render target');
|
||||
|
||||
console.log('\n=== #1034 PR3: Scan button enabled + wired ===');
|
||||
|
||||
// Scan button must be enabled (no `disabled` attribute) — or the wiring
|
||||
// must remove it at init.
|
||||
var scanBtnRender = src.match(/id="scan-qr-btn"[^>]*>/);
|
||||
assert(scanBtnRender, '#scan-qr-btn render present');
|
||||
var hasDisabledAttr = scanBtnRender && /\bdisabled\b/.test(scanBtnRender[0]);
|
||||
var removesDisabled = /scan-qr-btn[\s\S]{0,400}\.removeAttribute\(\s*['"]disabled/.test(src) ||
|
||||
/scanBtn[\s\S]{0,200}\.disabled\s*=\s*false/.test(src);
|
||||
assert(!hasDisabledAttr || removesDisabled,
|
||||
'scan button is enabled (no disabled attr OR runtime removes it)');
|
||||
|
||||
// Click handler wired to ChannelQR.scan
|
||||
assert(/scan-qr-btn[\s\S]{0,800}addEventListener\(\s*['"]click/.test(src) ||
|
||||
/scanBtn[\s\S]{0,400}addEventListener\(\s*['"]click/.test(src),
|
||||
'scan-qr-btn has a click handler attached');
|
||||
|
||||
assert(/ChannelQR\s*\.\s*scan\s*\(/.test(src),
|
||||
'click handler calls ChannelQR.scan()');
|
||||
|
||||
console.log('\n=== #1034 PR3: Scan result populates PSK fields ===');
|
||||
|
||||
// The scan result is {name, secret}. Wiring must populate #chPskKey
|
||||
// and #chPskName from the parsed result.
|
||||
var scanWiring = src.match(/ChannelQR\.scan\([\s\S]{0,1500}/);
|
||||
assert(scanWiring, 'found ChannelQR.scan(...) call site');
|
||||
if (scanWiring) {
|
||||
var sw = scanWiring[0];
|
||||
assert(/chPskKey/.test(sw),
|
||||
'scan success path writes to #chPskKey');
|
||||
assert(/chPskName/.test(sw),
|
||||
'scan success path writes to #chPskName');
|
||||
assert(/\.secret\b|result\.secret|\.value\s*=\s*[^;]*secret/.test(sw),
|
||||
'scan result.secret populates the key field');
|
||||
}
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
console.log('Passed: ' + passed + ', Failed: ' + failed);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Tests for public/channel-qr.js — the QR generation/scanning module
|
||||
* for the channel UX redesign (#1034, PR #2 of 3).
|
||||
*
|
||||
* Pure-JS assertions only: covers buildUrl, parseChannelUrl. The DOM
|
||||
* (generate) and camera (scan) paths are exercised by Playwright E2E
|
||||
* elsewhere in the redesign series.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
function loadChannelQR() {
|
||||
const sandbox = {
|
||||
window: {}, console, Date, JSON, parseInt, Math, String, Number,
|
||||
Object, Array, RegExp, Error, Promise, setTimeout, encodeURIComponent,
|
||||
decodeURIComponent, URL, URLSearchParams,
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public/channel-qr.js'), 'utf8');
|
||||
vm.runInContext(src, sandbox);
|
||||
return sandbox.window.ChannelQR;
|
||||
}
|
||||
|
||||
console.log('── ChannelQR — URL helpers ──');
|
||||
const ChannelQR = loadChannelQR();
|
||||
|
||||
assert(ChannelQR && typeof ChannelQR.buildUrl === 'function',
|
||||
'ChannelQR.buildUrl is exported');
|
||||
assert(typeof ChannelQR.parseChannelUrl === 'function',
|
||||
'ChannelQR.parseChannelUrl is exported');
|
||||
assert(typeof ChannelQR.generate === 'function',
|
||||
'ChannelQR.generate is exported');
|
||||
assert(typeof ChannelQR.scan === 'function',
|
||||
'ChannelQR.scan is exported');
|
||||
|
||||
// --- buildUrl ---
|
||||
const SECRET = '8b3387e1c4be1bbf09c1a4cd5c0fa5a3';
|
||||
const url1 = ChannelQR.buildUrl('Public', SECRET);
|
||||
assert(url1 === 'meshcore://channel/add?name=Public&secret=' + SECRET,
|
||||
'buildUrl produces canonical URL for plain name');
|
||||
|
||||
const url2 = ChannelQR.buildUrl('My Channel & Stuff', SECRET);
|
||||
assert(url2 === 'meshcore://channel/add?name=My%20Channel%20%26%20Stuff&secret=' + SECRET,
|
||||
'buildUrl URL-encodes spaces and ampersands in name');
|
||||
|
||||
// --- parseChannelUrl ---
|
||||
const p1 = ChannelQR.parseChannelUrl(url1);
|
||||
assert(p1 && p1.name === 'Public' && p1.secret === SECRET,
|
||||
'parseChannelUrl extracts name + secret from canonical URL');
|
||||
|
||||
const p2 = ChannelQR.parseChannelUrl(url2);
|
||||
assert(p2 && p2.name === 'My Channel & Stuff' && p2.secret === SECRET,
|
||||
'parseChannelUrl URL-decodes name correctly');
|
||||
|
||||
assert(ChannelQR.parseChannelUrl(null) === null, 'parseChannelUrl(null) → null');
|
||||
assert(ChannelQR.parseChannelUrl('') === null, 'parseChannelUrl("") → null');
|
||||
assert(ChannelQR.parseChannelUrl('https://example.com') === null,
|
||||
'parseChannelUrl rejects non-meshcore scheme');
|
||||
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?name=Foo') === null,
|
||||
'parseChannelUrl rejects URL missing secret');
|
||||
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?secret=' + SECRET) === null,
|
||||
'parseChannelUrl rejects URL missing name');
|
||||
assert(ChannelQR.parseChannelUrl('meshcore://other/add?name=Foo&secret=' + SECRET) === null,
|
||||
'parseChannelUrl rejects wrong host/path');
|
||||
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?name=Foo&secret=zz') === null,
|
||||
'parseChannelUrl rejects non-hex secret');
|
||||
assert(ChannelQR.parseChannelUrl('meshcore://channel/add?name=Foo&secret=' + SECRET.slice(0, 30)) === null,
|
||||
'parseChannelUrl rejects short secret (must be 32 hex chars)');
|
||||
|
||||
console.log('');
|
||||
console.log(` ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Regression: channel sidebar layout for user-added (PSK) channels was
|
||||
* broken by #1024 (✕ remove + 🔑 badge) interacting with the outer
|
||||
* `.ch-item` <button> wrapper.
|
||||
*
|
||||
* Root cause: HTML5 disallows nesting <button> inside <button>. The parser
|
||||
* implicitly closes the outer `.ch-item` button as soon as it hits the
|
||||
* inner `<button class="ch-remove-btn">`. This re-parents the remove
|
||||
* button + everything after it (the `.ch-item-preview` "X: msg" line)
|
||||
* outside the channel entry, producing the visible bug:
|
||||
*
|
||||
* [icon] Levski 🔑 <-- outer button closes early here
|
||||
* ✕ <-- orphaned, "floats"
|
||||
* KpaPocket: Тест <-- preview text orphaned
|
||||
* [icon] #bookclub ...
|
||||
*
|
||||
* This test asserts the rendered template does NOT contain a nested
|
||||
* `<button>` inside the `.ch-item` button. Plus the "No key" toggle gets
|
||||
* clearer copy and stays grouped with the channel controls.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
|
||||
const cssSrc = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
|
||||
|
||||
console.log('\n=== Sidebar layout: no nested <button> inside .ch-item ===');
|
||||
|
||||
// The bug: a literal `<button class="ch-remove-btn"` inside the
|
||||
// `.ch-item` template. After fix, the remove affordance must be a
|
||||
// non-<button> element (e.g. <span role="button">) so HTML parsing
|
||||
// keeps it inside the channel entry.
|
||||
assert(!/<button[^>]*class="ch-remove-btn"/.test(chSrc),
|
||||
'remove (✕) affordance must NOT be a <button> element (would close outer .ch-item button)');
|
||||
|
||||
// Remove control must still be discoverable (data attribute keeps the
|
||||
// existing click handler in `addEventListener('click', ...)`).
|
||||
// PR #1040 refactored to an iconBtn() helper, so the literal
|
||||
// `data-remove-channel="..."` no longer appears verbatim in source —
|
||||
// check that the helper is wired with the right data attribute instead.
|
||||
assert(/data-remove-channel/.test(chSrc),
|
||||
'remove affordance still carries data-remove-channel for click delegation');
|
||||
|
||||
console.log('\n=== Sidebar layout: ✕ visible on user-added rows (not opacity:0) ===');
|
||||
// Bug compounded: even if the button rendered correctly, opacity:0
|
||||
// hide-until-hover made it impossible to discover on touch devices.
|
||||
// The user-added (PSK) row should expose ✕ at full visibility.
|
||||
// PR #1040: shared base class .ch-icon-btn carries the opacity rule.
|
||||
const baseRule = cssSrc.match(/\.ch-icon-btn\s*\{[^}]*\}/);
|
||||
const removeRule = cssSrc.match(/\.ch-remove-btn\s*\{[^}]*\}/);
|
||||
assert(baseRule || removeRule, 'found .ch-icon-btn or .ch-remove-btn CSS rule');
|
||||
if (baseRule) {
|
||||
assert(!/opacity:\s*0\s*[;}]/.test(baseRule[0]),
|
||||
'.ch-icon-btn (base for ✕) must not be opacity:0 by default (was invisible on touch)');
|
||||
}
|
||||
|
||||
console.log('\n=== Encrypted section: header exists and is collapsible (#1037 redesign) ===');
|
||||
// #1037 replaced the binary "No key" visibility toggle with a sectioned
|
||||
// sidebar — encrypted (no-key) channels live in their own collapsible
|
||||
// section grouped with the rest. The old toggle is intentionally gone.
|
||||
assert(/ch-section-encrypted/.test(chSrc),
|
||||
'sidebar renders a dedicated Encrypted section');
|
||||
assert(/id="chEncryptedToggle"/.test(chSrc),
|
||||
'Encrypted section header is a toggle (button#chEncryptedToggle)');
|
||||
assert(/aria-expanded=/.test(chSrc) && /aria-controls="chEncryptedBody"/.test(chSrc),
|
||||
'toggle exposes ARIA collapsible state (aria-expanded + aria-controls)');
|
||||
assert(/Encrypted \(\$\{encrypted\.length\}\)/.test(chSrc),
|
||||
'Encrypted header shows live count');
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
console.log('Passed: ' + passed + ', Failed: ' + failed);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Follow-up UX fixes to #1037 channel modal/sidebar redesign:
|
||||
*
|
||||
* 1. ✕ remove button must hit a 44×44px touch target (WCAG 2.5.5).
|
||||
* 2. Channel rows must NOT display "0 messages" — when no messages
|
||||
* have been decrypted yet, omit the count entirely.
|
||||
* 3. Modal footer wording: keys removed via ✕ button, not by
|
||||
* clearing browser data.
|
||||
* 4. Each user-added (PSK) row must expose a Share affordance that
|
||||
* re-opens the QR/key for that channel without re-generating it.
|
||||
* 5. "(your key)" preview suffix on user-added rows is noise; drop it.
|
||||
* Likewise no key hex in the default row rendering.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
|
||||
const cssSrc = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
console.log('\n=== Fix 1: ✕ touch target ≥ 44×44px (on shared .ch-icon-btn base) ===');
|
||||
const iconBtnRule = (cssSrc.match(/\.ch-icon-btn\s*\{[^}]*\}/) || [''])[0];
|
||||
assert(/min-width:\s*44px/.test(iconBtnRule),
|
||||
'.ch-icon-btn declares min-width: 44px');
|
||||
assert(/min-height:\s*44px/.test(iconBtnRule),
|
||||
'.ch-icon-btn declares min-height: 44px');
|
||||
|
||||
console.log('\n=== Fix 2: no "0 messages" in default row ===');
|
||||
// renderChannelRow must not emit a literal "0 messages" preview when
|
||||
// messageCount is missing/zero. Look for the offending fallback pattern.
|
||||
assert(!/\$\{ch\.messageCount\s*\|\|\s*0\}\s*messages/.test(chSrc),
|
||||
'preview no longer falls back to "${ch.messageCount || 0} messages"');
|
||||
assert(!/\$\{ch\.messageCount\s*\|\|\s*0\}\s*packets/.test(chSrc),
|
||||
'encrypted preview no longer falls back to "${ch.messageCount || 0} packets"');
|
||||
|
||||
console.log('\n=== Fix 3: privacy footer wording ===');
|
||||
assert(!/Clear browser data to remove stored keys/.test(chSrc),
|
||||
'old "Clear browser data to remove stored keys" copy is gone');
|
||||
assert(/Use\s+✕\s+to remove individual channels/.test(chSrc),
|
||||
'new copy points at the ✕ button for individual key removal');
|
||||
|
||||
console.log('\n=== Fix 4: Share/reshare affordance on user-added rows ===');
|
||||
// Source-level: data attribute and helper exist. Behavior-level checks
|
||||
// against rendered output are below in the renderChannelRow section.
|
||||
assert(/data-share-channel/.test(chSrc),
|
||||
'channels.js wires the data-share-channel hook somewhere in render');
|
||||
// Click handler must wire the share button to ChannelQR.generate (or a
|
||||
// QR-display fallback). The handler lives in the chListEl click delegation.
|
||||
assert(/data-share-channel/.test(chSrc) && /ChannelQR/.test(chSrc),
|
||||
'share handler references ChannelQR for QR rendering');
|
||||
// Modal must have a target container for the reshare QR output.
|
||||
assert(/id="chShareOutput"/.test(chSrc) || /id="chReshareOutput"/.test(chSrc),
|
||||
'modal has a reshare QR output container');
|
||||
|
||||
console.log('\n=== Fix 5: "(your key)" suffix removed from preview ===');
|
||||
assert(!/\(your key\)/.test(chSrc),
|
||||
'user-added preview no longer says "(your key)"');
|
||||
|
||||
console.log('\n=== Fix 6: browser-local warning is obvious ===');
|
||||
// A visible callout in the modal — separate from the small privacy footer.
|
||||
assert(/class="ch-modal-callout"/.test(chSrc),
|
||||
'modal has a dedicated .ch-modal-callout for the locality warning');
|
||||
assert(/THIS browser only/.test(chSrc),
|
||||
'callout uses emphatic copy: "Channels are saved to THIS browser only"');
|
||||
assert(/won't appear on other devices or browsers|won.t appear on other devices/.test(chSrc),
|
||||
'callout warns that channels won\u2019t appear on other devices/browsers');
|
||||
|
||||
// Sidebar "My Channels" section header gets a locality marker.
|
||||
assert(/My Channels[\s\S]{0,200}\(this browser\)|🖥️[\s\S]{0,200}My Channels|My Channels[\s\S]{0,200}🖥️/.test(chSrc),
|
||||
'My Channels section header reinforces locality (🖥️ or "(this browser)")');
|
||||
|
||||
// Remove confirm prompt explicitly mentions "this browser".
|
||||
assert(/permanently remove the key from this browser/.test(chSrc),
|
||||
'remove confirm says key is permanently removed from this browser');
|
||||
|
||||
console.log('\n=== Fix 7: default channel reference is #meshcore, not #LongFast ===');
|
||||
// Channels UI must not reference Meshtastic's LongFast as the example
|
||||
// channel — meshcore network's analogous default is #meshcore.
|
||||
assert(!/LongFast/.test(chSrc),
|
||||
'public/channels.js has no "LongFast" references');
|
||||
assert(/#meshcore/.test(chSrc),
|
||||
'public/channels.js uses #meshcore as the example/placeholder');
|
||||
|
||||
console.log('\n=== Behavior: renderChannelRow output structure ===');
|
||||
// Extract renderChannelRow and exercise it against synthetic ch records
|
||||
// to assert behavior (not just source substring presence).
|
||||
const vm = require('vm');
|
||||
// Locate renderChannelRow source by walking braces from the function header.
|
||||
function extractFn(src, header) {
|
||||
const start = src.indexOf(header);
|
||||
if (start < 0) return null;
|
||||
let depth = 0, i = src.indexOf('{', start);
|
||||
if (i < 0) return null;
|
||||
for (let j = i; j < src.length; j++) {
|
||||
const c = src[j];
|
||||
if (c === '{') depth++;
|
||||
else if (c === '}') { depth--; if (depth === 0) return src.substring(start, j + 1); }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const renderRowSrc = extractFn(chSrc, 'function renderChannelRow(ch)');
|
||||
assert(renderRowSrc, 'extracted renderChannelRow source for behavior testing');
|
||||
if (renderRowSrc) {
|
||||
// Stub the helpers renderChannelRow depends on, evaluate it in a sandbox.
|
||||
const sandbox = {
|
||||
escapeHtml: s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
||||
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
||||
}[c])),
|
||||
truncate: (s, n) => (s && s.length > n ? s.substring(0, n) + '…' : s || ''),
|
||||
formatSecondsAgo: () => '5m',
|
||||
formatHashHex: h => h,
|
||||
getChannelColor: () => '#fff',
|
||||
selectedHash: null,
|
||||
customColors: {},
|
||||
window: {},
|
||||
renderChannelRow: null,
|
||||
};
|
||||
// #1041 follow-up: renderChannelRow now delegates to channelDisplayName.
|
||||
// Eval the REAL helper (and its module-local PRIVATE_CHANNEL_LABEL)
|
||||
// into the sandbox so this test stays in sync with production behavior
|
||||
// automatically — no hand-rolled duplicate of the psk:* rule.
|
||||
const helperSrc = extractFn(chSrc, 'function channelDisplayName(ch');
|
||||
assert(helperSrc, 'extracted channelDisplayName source for behavior sandbox');
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext('const PRIVATE_CHANNEL_LABEL = "Private Channel";\n' + helperSrc, sandbox);
|
||||
vm.runInContext(renderRowSrc, sandbox);
|
||||
const userRow = sandbox.renderChannelRow({
|
||||
hash: 'user:Crew',
|
||||
name: 'Crew',
|
||||
userAdded: true,
|
||||
encrypted: true,
|
||||
messageCount: 0,
|
||||
lastActivityMs: Date.now(),
|
||||
});
|
||||
assert(/data-share-channel="user:Crew"/.test(userRow),
|
||||
'renderChannelRow emits a share button for user-added channels');
|
||||
assert(/aria-haspopup="dialog"/.test(userRow),
|
||||
'share button announces it opens a dialog (aria-haspopup="dialog")');
|
||||
assert(/data-remove-channel="user:Crew"/.test(userRow),
|
||||
'renderChannelRow emits a remove button for user-added channels');
|
||||
assert(!/0 messages/.test(userRow) && !/your key/.test(userRow),
|
||||
'user-added preview omits "0 messages" and "your key" when no activity');
|
||||
// Non-user-added encrypted row should NOT carry share/remove.
|
||||
const encRow = sandbox.renderChannelRow({
|
||||
hash: 'abc123', name: 'Net', userAdded: false, encrypted: true,
|
||||
messageCount: 0, lastActivityMs: 0,
|
||||
});
|
||||
assert(!/data-share-channel/.test(encRow),
|
||||
'encrypted (non-user) rows do NOT expose a share button');
|
||||
assert(!/0 packets/.test(encRow),
|
||||
'encrypted preview omits "0 packets" when count is zero');
|
||||
}
|
||||
|
||||
console.log('\n=== Behavior: share output is a labeled section, not a footer trailer ===');
|
||||
// The share output must live inside a labeled section (a11y), not as a
|
||||
// dangling div after .ch-modal-footer.
|
||||
assert(/id="chShareSection"[\s\S]{0,200}aria-labelledby="chShareHeading"/.test(chSrc),
|
||||
'share output is wrapped in a labeled section (chShareSection / chShareHeading)');
|
||||
const footerIdx = chSrc.indexOf('class="ch-modal-footer"');
|
||||
const sectionIdx = chSrc.indexOf('id="chShareSection"');
|
||||
assert(footerIdx > 0 && sectionIdx > 0 && sectionIdx < footerIdx,
|
||||
'share section is rendered BEFORE .ch-modal-footer (footer stays last)');
|
||||
|
||||
console.log('\n=== A11y: locality marker font-size ≥ 11px ===');
|
||||
const localityRule = (cssSrc.match(/\.ch-section-locality\s*\{[^}]*\}/) || [''])[0];
|
||||
const sizeMatch = localityRule.match(/font-size:\s*(\d+)px/);
|
||||
assert(sizeMatch && parseInt(sizeMatch[1], 10) >= 11,
|
||||
'.ch-section-locality font-size is ≥ 11px (got: ' + (sizeMatch ? sizeMatch[1] : 'none') + ')');
|
||||
|
||||
console.log('\n=== Share handler: no native alert(), uses inline output ===');
|
||||
// Walk the share-handler region and verify it doesn't drop to alert().
|
||||
const shareHandlerMatch = chSrc.match(/data-share-channel[\s\S]{0,2000}?return;\n \}/);
|
||||
assert(shareHandlerMatch && !/alert\(/.test(shareHandlerMatch[0]),
|
||||
'share handler does not use native alert() for missing-key error');
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
console.log('Passed: ' + passed + ', Failed: ' + failed);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Follow-up UX round 2 to channels (post #1040):
|
||||
*
|
||||
* 1. Channel header (selected-channel title) must NOT display the raw
|
||||
* "psk:<hex8>" key prefix. Use the user-supplied label when present,
|
||||
* otherwise fall back to "Private Channel".
|
||||
* 2. Sidebar share button uses a recognizable label ("📤 Share" or
|
||||
* similar), not the bare ⤴ glyph.
|
||||
* 3. ✕ remove button has a red background, white text, proper button
|
||||
* styling — looks like a destructive action.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
|
||||
const cssSrc = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
console.log('\n=== Fix 1: header display name for PSK channels ===');
|
||||
// Behavior test: extract channelDisplayName helper and exercise it.
|
||||
const vm = require('vm');
|
||||
function extractFn(src, header) {
|
||||
const start = src.indexOf(header);
|
||||
if (start < 0) return null;
|
||||
let depth = 0, i = src.indexOf('{', start);
|
||||
if (i < 0) return null;
|
||||
for (let j = i; j < src.length; j++) {
|
||||
const c = src[j];
|
||||
if (c === '{') depth++;
|
||||
else if (c === '}') { depth--; if (depth === 0) return src.substring(start, j + 1); }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const helperSrc = extractFn(chSrc, 'function channelDisplayName(ch');
|
||||
assert(helperSrc, 'channelDisplayName helper exists');
|
||||
if (helperSrc) {
|
||||
const sandbox = { formatHashHex: h => h, PRIVATE_CHANNEL_LABEL: 'Private Channel' };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext('const PRIVATE_CHANNEL_LABEL = "Private Channel";\n' + helperSrc, sandbox);
|
||||
assert(sandbox.channelDisplayName({ name: 'psk:372a9c93', userLabel: 'My Crew' }) === 'My Crew',
|
||||
'psk:* with userLabel returns the userLabel');
|
||||
assert(sandbox.channelDisplayName({ name: 'psk:372a9c93' }) === 'Private Channel',
|
||||
'psk:* without label returns "Private Channel"');
|
||||
assert(sandbox.channelDisplayName({ name: '#meshcore' }) === '#meshcore',
|
||||
'non-PSK names pass through unchanged');
|
||||
assert(sandbox.channelDisplayName({ hash: 'abc', name: '' }) === 'Channel abc',
|
||||
'falls back to "Channel <hash>" when name missing');
|
||||
assert(sandbox.channelDisplayName({ hash: 'abc', name: '' }, 'Unknown') === 'Unknown',
|
||||
'caller-supplied fallback overrides "Channel <hash>" default');
|
||||
assert(sandbox.channelDisplayName({ name: 'psk:abc' }, 'Unknown') === 'Private Channel',
|
||||
'fallback does NOT override the psk:* → "Private Channel" rule');
|
||||
}
|
||||
// Source-level: header rendering must call channelDisplayName, not raw ch.name.
|
||||
assert(/channelDisplayName\(ch\)/.test(chSrc),
|
||||
'selectChannel header rendering uses channelDisplayName(ch)');
|
||||
|
||||
console.log('\n=== Fix 2: share button has recognizable label ===');
|
||||
assert(!/'⤴'/.test(chSrc) && !/"⤴"/.test(chSrc),
|
||||
'bare ⤴ glyph no longer used as the share button content');
|
||||
// Tighten: assert the literal '📤 Share' string is the glyph argument
|
||||
// passed into the iconBtn(...) call for ch-share-btn — this catches the
|
||||
// case where someone removes the icon from the button content but leaves
|
||||
// "Share" in an aria-label or title.
|
||||
assert(/iconBtn\(\s*'ch-share-btn'[^)]*'📤 Share'/.test(chSrc),
|
||||
"iconBtn('ch-share-btn', ...) is called with '📤 Share' as the glyph");
|
||||
|
||||
console.log('\n=== Fix 3: ✕ delete button is a visibly red destructive button ===');
|
||||
const removeRule = (cssSrc.match(/\.ch-remove-btn\s*\{[^}]*\}/) || [''])[0];
|
||||
assert(/background:\s*var\(--statusRed/.test(removeRule) || /background:\s*#b54a4a/.test(removeRule),
|
||||
'.ch-remove-btn has red background (var(--statusRed,...) or #b54a4a)');
|
||||
assert(/color:\s*white/.test(removeRule) || /color:\s*#fff/.test(removeRule),
|
||||
'.ch-remove-btn has white text');
|
||||
assert(/border-radius:/.test(removeRule),
|
||||
'.ch-remove-btn has border-radius (button shape)');
|
||||
assert(/font-weight:\s*bold|font-weight:\s*700/.test(removeRule),
|
||||
'.ch-remove-btn has bold font-weight');
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
console.log('Passed: ' + passed + ', Failed: ' + failed);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,111 @@
|
||||
/* Unit tests for compare.js asymmetric overlap stats — Fixes #671 */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
function makeSandbox() {
|
||||
const ctx = {
|
||||
window: { addEventListener: () => {}, dispatchEvent: () => {} },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: () => ({ id: '', textContent: '', innerHTML: '', addEventListener: () => {} }),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => {},
|
||||
},
|
||||
console,
|
||||
setTimeout, clearTimeout,
|
||||
location: { hash: '#/compare', href: '' },
|
||||
history: { replaceState: () => {} },
|
||||
URLSearchParams,
|
||||
Map, Set, Date, Promise,
|
||||
escapeHtml: (s) => s,
|
||||
api: () => Promise.resolve({ observers: [] }),
|
||||
CLIENT_TTL: { observers: 0 },
|
||||
registerPage: () => {},
|
||||
timeAgo: () => '',
|
||||
payloadTypeColor: () => '',
|
||||
};
|
||||
ctx.self = ctx.window;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const ctx = makeSandbox();
|
||||
const sandbox = vm.createContext(ctx);
|
||||
const compareSrc = fs.readFileSync(__dirname + '/public/compare.js', 'utf8');
|
||||
vm.runInContext(compareSrc, sandbox);
|
||||
|
||||
console.log('\ncompare.js asymmetric overlap stats (#671):');
|
||||
|
||||
test('computeOverlapStats is exposed on window', () => {
|
||||
assert.strictEqual(typeof sandbox.window.computeOverlapStats, 'function',
|
||||
'computeOverlapStats should be exposed on window');
|
||||
});
|
||||
|
||||
test('basic asymmetric overlap — A sees 8/10 of B\'s, B sees 8/12 of A\'s', () => {
|
||||
// A: 12 unique packets total (10 shared with B + 2 unique)
|
||||
// B: 10 unique packets total (10 shared with A... wait: 8 shared + 2 unique to B)
|
||||
// Let's do: A has packets 1..10 + extras 11,12; B has packets 1..8 + extras 13,14
|
||||
// shared = {1..8} = 8
|
||||
// onlyA = {9,10,11,12} = 4
|
||||
// onlyB = {13,14} = 2
|
||||
// A total = 12, B total = 10
|
||||
// A sees 8/10 = 80% of B's packets
|
||||
// B sees 8/12 = 66.7% of A's packets
|
||||
const setA = new Set(['1','2','3','4','5','6','7','8','9','10','11','12']);
|
||||
const setB = new Set(['1','2','3','4','5','6','7','8','13','14']);
|
||||
const cmp = sandbox.window.comparePacketSets(setA, setB);
|
||||
const stats = sandbox.window.computeOverlapStats(cmp);
|
||||
assert.strictEqual(stats.totalA, 12, 'totalA');
|
||||
assert.strictEqual(stats.totalB, 10, 'totalB');
|
||||
assert.strictEqual(stats.shared, 8, 'shared');
|
||||
assert.strictEqual(stats.onlyA, 4, 'onlyA');
|
||||
assert.strictEqual(stats.onlyB, 2, 'onlyB');
|
||||
assert.strictEqual(stats.aSeesOfB, 80.0, 'A sees 80% of B\'s');
|
||||
assert.strictEqual(stats.bSeesOfA, Math.round(8/12*1000)/10, 'B sees 66.7% of A\'s');
|
||||
});
|
||||
|
||||
test('zero packets — no division by zero', () => {
|
||||
const cmp = sandbox.window.comparePacketSets(new Set(), new Set());
|
||||
const stats = sandbox.window.computeOverlapStats(cmp);
|
||||
assert.strictEqual(stats.aSeesOfB, 0);
|
||||
assert.strictEqual(stats.bSeesOfA, 0);
|
||||
assert.strictEqual(stats.shared, 0);
|
||||
});
|
||||
|
||||
test('one observer empty — other gets 0% mutual coverage', () => {
|
||||
const cmp = sandbox.window.comparePacketSets(new Set(['x','y']), new Set());
|
||||
const stats = sandbox.window.computeOverlapStats(cmp);
|
||||
assert.strictEqual(stats.totalA, 2);
|
||||
assert.strictEqual(stats.totalB, 0);
|
||||
assert.strictEqual(stats.aSeesOfB, 0, 'no B packets to see');
|
||||
assert.strictEqual(stats.bSeesOfA, 0, 'B saw 0 of A\'s');
|
||||
});
|
||||
|
||||
test('perfect overlap — 100% both ways', () => {
|
||||
const s = new Set(['a','b','c']);
|
||||
const cmp = sandbox.window.comparePacketSets(s, new Set(s));
|
||||
const stats = sandbox.window.computeOverlapStats(cmp);
|
||||
assert.strictEqual(stats.aSeesOfB, 100);
|
||||
assert.strictEqual(stats.bSeesOfA, 100);
|
||||
assert.strictEqual(stats.shared, 3);
|
||||
});
|
||||
|
||||
test('disjoint observers — 0% both ways', () => {
|
||||
const cmp = sandbox.window.comparePacketSets(new Set(['a','b']), new Set(['c','d']));
|
||||
const stats = sandbox.window.computeOverlapStats(cmp);
|
||||
assert.strictEqual(stats.aSeesOfB, 0);
|
||||
assert.strictEqual(stats.bSeesOfA, 0);
|
||||
assert.strictEqual(stats.shared, 0);
|
||||
});
|
||||
|
||||
console.log(`\n ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
+221
-35
@@ -257,10 +257,13 @@ async function run() {
|
||||
});
|
||||
|
||||
// Test: Nodes page has WebSocket auto-update listener (#131)
|
||||
// NOTE: This test verifies the WS *infrastructure* exists on the Nodes page.
|
||||
// It deliberately does NOT wait for `table tbody tr` — that creates a flake
|
||||
// because rows arriving via WS push are timing-dependent in CI. The preceding
|
||||
// "Nodes page loads with data" test already covers initial table population.
|
||||
await test('Nodes page has WebSocket auto-update', async () => {
|
||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 });
|
||||
await page.waitForSelector('table tbody tr');
|
||||
// The live dot in navbar indicates WS connection status
|
||||
const liveDot = await page.$('#liveDot');
|
||||
assert(liveDot, 'Live dot WebSocket indicator (#liveDot) not found');
|
||||
@@ -269,7 +272,8 @@ async function run() {
|
||||
return typeof onWS === 'function' && typeof offWS === 'function';
|
||||
});
|
||||
assert(hasWsInfra, 'WebSocket listener infrastructure (onWS/offWS) should be available');
|
||||
// Wait for WS connection and verify liveDot shows connected state
|
||||
// Best-effort: if WS connects within 5s, verify connected state. Don't fail otherwise —
|
||||
// CI may not have a live MQTT feed. Infra-existence assertions above are the contract.
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const dot = document.getElementById('liveDot');
|
||||
@@ -828,6 +832,57 @@ async function run() {
|
||||
|
||||
// --- Group: Live page ---
|
||||
|
||||
// Test (issue #1046): Activating the Live nav link MUST NOT cause the
|
||||
// "🔴 Live" label to wrap onto two lines, which makes the whole top
|
||||
// nav bar grow taller and "hop". The label has to stay on one line in
|
||||
// every state, and the nav bar height must be identical with/without
|
||||
// the .active class.
|
||||
await test('Live nav-link does not wrap or change nav height when active (#1046)', async () => {
|
||||
// Use the exact viewport width from the issue screenshots.
|
||||
await page.setViewportSize({ width: 1115, height: 800 });
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('a.nav-link[data-route="live"]');
|
||||
|
||||
const measure = await page.evaluate(() => {
|
||||
const link = document.querySelector('a.nav-link[data-route="live"]');
|
||||
const nav = document.querySelector('.top-nav');
|
||||
const ws = getComputedStyle(link).whiteSpace;
|
||||
// Force inactive state.
|
||||
const wasActive = link.classList.contains('active');
|
||||
link.classList.remove('active');
|
||||
const inactive = {
|
||||
navH: nav.getBoundingClientRect().height,
|
||||
lines: link.getClientRects().length,
|
||||
};
|
||||
// Force active state.
|
||||
link.classList.add('active');
|
||||
const active = {
|
||||
navH: nav.getBoundingClientRect().height,
|
||||
lines: link.getClientRects().length,
|
||||
};
|
||||
// Restore.
|
||||
link.classList.toggle('active', wasActive);
|
||||
return { ws, inactive, active };
|
||||
});
|
||||
|
||||
assert(
|
||||
['nowrap', 'pre', 'pre-wrap'].includes(measure.ws),
|
||||
`Live nav-link must not wrap; computed white-space=${measure.ws}`,
|
||||
);
|
||||
assert(
|
||||
measure.inactive.lines === 1,
|
||||
`Live nav-link must render on one line when inactive (got ${measure.inactive.lines})`,
|
||||
);
|
||||
assert(
|
||||
measure.active.lines === 1,
|
||||
`Live nav-link must render on one line when active (got ${measure.active.lines})`,
|
||||
);
|
||||
assert(
|
||||
measure.active.navH === measure.inactive.navH,
|
||||
`Top nav height must not change when Live becomes active (inactive=${measure.inactive.navH}, active=${measure.active.navH})`,
|
||||
);
|
||||
});
|
||||
|
||||
// Test: Live page loads with map and stats
|
||||
await test('Live page loads with map and stats', async () => {
|
||||
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
|
||||
@@ -2327,40 +2382,51 @@ async function run() {
|
||||
assert(hasHslPolyline, 'At least one live-packet-trace polyline should have hsl() stroke color from hash');
|
||||
});
|
||||
|
||||
// --- Roles page (issue #818): renders distribution + per-role skew ---
|
||||
await test('Roles page renders distribution table from /api/analytics/roles', async () => {
|
||||
await page.goto(BASE + '/#/roles', { waitUntil: 'domcontentloaded' });
|
||||
// Wait for roles-page.js to mount and the table to render.
|
||||
await page.waitForSelector('.roles-page[data-page="roles"]', { timeout: 10000 });
|
||||
await page.waitForFunction(() => {
|
||||
var el = document.querySelector('#rolesContent');
|
||||
if (!el) return false;
|
||||
// Either the table renders, or the empty-state message appears.
|
||||
return !!el.querySelector('#rolesTable') || /No roles to show|Failed to load/.test(el.textContent);
|
||||
}, { timeout: 10000 });
|
||||
var hasTable = await page.$('#rolesTable');
|
||||
if (!hasTable) {
|
||||
// Empty fixture is acceptable; at least the page must NOT show the
|
||||
// generic "Page not yet implemented" placeholder (the bug we fixed).
|
||||
var bodyText = await page.evaluate(() => document.body.innerText);
|
||||
assert(!/Page not yet implemented/i.test(bodyText), 'Roles page must not show "Page not yet implemented" placeholder');
|
||||
return;
|
||||
}
|
||||
// With data: header columns and at least one body row must be present.
|
||||
var headers = await page.$$eval('#rolesTable thead th', ths => ths.map(t => t.textContent.trim()));
|
||||
assert(headers.includes('Role'), 'Roles table must have a Role column, got ' + JSON.stringify(headers));
|
||||
assert(headers.some(h => /Median/.test(h)), 'Roles table must have a Median |skew| column, got ' + JSON.stringify(headers));
|
||||
var rowCount = await page.$$eval('#rolesTable tbody tr', rs => rs.length);
|
||||
assert(rowCount > 0, 'Roles table should have at least one row when API returns data');
|
||||
// API contract sanity check: shape matches the page's expectations.
|
||||
var apiOk = await page.evaluate(async () => {
|
||||
var r = await fetch('/api/analytics/roles');
|
||||
if (!r.ok) return { ok: false, status: r.status };
|
||||
var j = await r.json();
|
||||
return { ok: true, hasRoles: Array.isArray(j.roles), hasTotal: typeof j.totalNodes === 'number' };
|
||||
// --- Roles folded into Analytics (issue #1085) ---
|
||||
// Acceptance criteria:
|
||||
// 1. "Roles" link does NOT exist in top nav
|
||||
// 2. Analytics page has a "Roles" tab with the same content
|
||||
// 3. Old #/roles URL redirects to #/analytics?tab=roles
|
||||
await test('Roles fold-in (#1085): no "Roles" link in top nav', async () => {
|
||||
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav.top-nav .nav-links', { timeout: 10000 });
|
||||
var hasRolesLink = await page.evaluate(() => {
|
||||
var links = document.querySelectorAll('nav.top-nav .nav-links a.nav-link[data-route="roles"]');
|
||||
return links.length > 0;
|
||||
});
|
||||
assert(apiOk.ok, '/api/analytics/roles must return 200, got ' + JSON.stringify(apiOk));
|
||||
assert(apiOk.hasRoles && apiOk.hasTotal, '/api/analytics/roles response must have {roles:[], totalNodes:n}, got ' + JSON.stringify(apiOk));
|
||||
assert(!hasRolesLink, 'Top nav must NOT contain a "Roles" link (data-route="roles")');
|
||||
});
|
||||
|
||||
await test('Roles fold-in (#1085): Analytics page has a "Roles" tab', async () => {
|
||||
await page.goto(BASE + '/#/analytics', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#analyticsTabs', { timeout: 10000 });
|
||||
var rolesTab = await page.$('#analyticsTabs .tab-btn[data-tab="roles"]');
|
||||
assert(rolesTab, 'Analytics tabs must include a [data-tab="roles"] button');
|
||||
var label = await page.evaluate(el => el.textContent.trim(), rolesTab);
|
||||
assert(/roles/i.test(label), 'Roles tab label must say "Roles", got ' + JSON.stringify(label));
|
||||
// Click the tab and verify the same Roles content renders.
|
||||
await page.click('#analyticsTabs [data-tab="roles"]');
|
||||
await page.waitForFunction(() => {
|
||||
var el = document.getElementById('analyticsContent');
|
||||
if (!el) return false;
|
||||
return !!el.querySelector('#rolesTable') || /No roles to show|Failed to load|Loading/i.test(el.textContent);
|
||||
}, { timeout: 10000 });
|
||||
// After settle, must show table or empty-state — never the SPA placeholder.
|
||||
await page.waitForFunction(() => {
|
||||
var el = document.getElementById('analyticsContent');
|
||||
return el && !/Loading…/.test(el.textContent);
|
||||
}, { timeout: 10000 });
|
||||
var bodyText = await page.evaluate(() => document.getElementById('analyticsContent').innerText);
|
||||
assert(!/Page not yet implemented/i.test(bodyText), 'Roles tab must not show SPA placeholder');
|
||||
});
|
||||
|
||||
await test('Roles fold-in (#1085): old #/roles URL redirects to #/analytics?tab=roles', async () => {
|
||||
await page.goto(BASE + '/#/roles', { waitUntil: 'domcontentloaded' });
|
||||
// Allow router to process the redirect.
|
||||
await page.waitForFunction(() => /^#\/analytics(\?|$)/.test(location.hash), { timeout: 5000 });
|
||||
var hash = await page.evaluate(() => location.hash);
|
||||
assert(/^#\/analytics\?/.test(hash), 'After visiting #/roles, hash must redirect to #/analytics?…, got ' + hash);
|
||||
assert(/[?&]tab=roles(&|$)/.test(hash), 'Redirect must carry tab=roles, got ' + hash);
|
||||
});
|
||||
|
||||
// --- Geofilter draft: save/load/download buttons (issue #819, rule 18) ---
|
||||
@@ -2443,6 +2509,126 @@ async function run() {
|
||||
await page.evaluate(() => localStorage.removeItem('geofilter-draft'));
|
||||
});
|
||||
|
||||
// --- Group: Fluid scaffolding (#1054) — no horizontal overflow at any viewport ---
|
||||
// Asserts document.documentElement.scrollWidth <= clientWidth across breakpoints.
|
||||
// Deterministic: pure layout assertion, no timing/network dependencies beyond domcontentloaded.
|
||||
{
|
||||
const viewports = [768, 1080, 1440, 1920, 2560];
|
||||
const HEIGHT = 900;
|
||||
|
||||
async function assertNoHOverflow(page, label) {
|
||||
// Wait for layout to settle: ensure body is rendered and any web fonts/CSS applied.
|
||||
await page.waitForSelector('body', { timeout: 10000 });
|
||||
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
|
||||
const m = await page.evaluate(() => ({
|
||||
sw: document.documentElement.scrollWidth,
|
||||
cw: document.documentElement.clientWidth,
|
||||
bsw: document.body.scrollWidth,
|
||||
bcw: document.body.clientWidth,
|
||||
}));
|
||||
assert(m.sw <= m.cw,
|
||||
`${label}: documentElement horizontal overflow — scrollWidth=${m.sw} > clientWidth=${m.cw}`);
|
||||
assert(m.bsw <= m.cw,
|
||||
`${label}: body horizontal overflow — body.scrollWidth=${m.bsw} > documentElement.clientWidth=${m.cw}`);
|
||||
}
|
||||
|
||||
for (const w of viewports) {
|
||||
await test(`No horizontal overflow at ${w}px (home)`, async () => {
|
||||
await page.setViewportSize({ width: w, height: HEIGHT });
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await assertNoHOverflow(page, `home @ ${w}x${HEIGHT}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── #1034 PR3: QR generate + scan wiring (channel modal) ──
|
||||
await test('#1034 PR3: Generate & Show QR renders QR + Copy Key into #qr-output', async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#chAddChannelBtn', { timeout: 8000 });
|
||||
await page.click('#chAddChannelBtn');
|
||||
await page.waitForSelector('#chAddChannelModal:not(.hidden)', { timeout: 3000 });
|
||||
await page.fill('#chGenerateName', 'wiring-e2e');
|
||||
// Sanity: pre-click, qr-output should be empty.
|
||||
const before = await page.evaluate(() =>
|
||||
(document.getElementById('qr-output').innerHTML || '').trim()
|
||||
);
|
||||
assert(before === '', `#qr-output should start empty, got: ${before.slice(0,60)}`);
|
||||
await page.click('#chGenerateBtn');
|
||||
// ChannelQR.generate writes the meshcore:// URL line + a Copy Key
|
||||
// button regardless of whether QRCode renders as <canvas> or <img>.
|
||||
// Wait for the URL line which is always populated.
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.getElementById('qr-output');
|
||||
return el && /meshcore:\/\/channel\/add/.test(el.textContent || '');
|
||||
}, { timeout: 4000 });
|
||||
const html = await page.innerHTML('#qr-output');
|
||||
assert(/meshcore:\/\/channel\/add/.test(html),
|
||||
'#qr-output must contain meshcore://channel/add URL');
|
||||
assert(/canvas|<img|qr/i.test(html),
|
||||
'#qr-output must contain a QR rendering (canvas/img/QR table)');
|
||||
assert(/Copy Key/.test(html),
|
||||
'#qr-output must contain a Copy Key button');
|
||||
// Close modal for next test.
|
||||
const close = await page.$('[data-action="ch-modal-close"], #chModalClose');
|
||||
if (close) await close.click().catch(() => {});
|
||||
});
|
||||
|
||||
await test('#1034 PR3: scan-qr-btn is enabled (no longer placeholder)', async () => {
|
||||
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#chAddChannelBtn', { timeout: 8000 });
|
||||
await page.click('#chAddChannelBtn');
|
||||
await page.waitForSelector('#scan-qr-btn', { timeout: 3000 });
|
||||
const disabled = await page.$eval('#scan-qr-btn', (b) => b.hasAttribute('disabled'));
|
||||
assert(!disabled, '#scan-qr-btn must be enabled (wired to ChannelQR.scan)');
|
||||
await page.keyboard.press('Escape').catch(() => {});
|
||||
});
|
||||
|
||||
await test('#1034 PR3: scan handler populates #chPskKey + #chPskName from result', async () => {
|
||||
// Stub ChannelQR.scan to return a deterministic result, then click
|
||||
// the scan button and assert the form fields are populated.
|
||||
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#chAddChannelBtn', { timeout: 8000 });
|
||||
await page.click('#chAddChannelBtn');
|
||||
await page.waitForSelector('#scan-qr-btn', { timeout: 3000 });
|
||||
await page.evaluate(() => {
|
||||
window.ChannelQR = window.ChannelQR || {};
|
||||
window.ChannelQR.scan = function () {
|
||||
return Promise.resolve({
|
||||
name: 'scanned-e2e',
|
||||
secret: 'a'.repeat(32),
|
||||
});
|
||||
};
|
||||
});
|
||||
await page.click('#scan-qr-btn');
|
||||
// Give the async handler a tick.
|
||||
await page.waitForFunction(() => {
|
||||
const k = document.getElementById('chPskKey');
|
||||
return k && k.value && k.value.length === 32;
|
||||
}, { timeout: 3000 });
|
||||
const key = await page.$eval('#chPskKey', (el) => el.value);
|
||||
const name = await page.$eval('#chPskName', (el) => el.value);
|
||||
assert(key === 'a'.repeat(32), `#chPskKey populated, got: ${key}`);
|
||||
assert(name === 'scanned-e2e', `#chPskName populated, got: ${name}`);
|
||||
});
|
||||
|
||||
// Spot-check a couple other pages at the smallest and largest viewports.
|
||||
const otherPages = [
|
||||
{ name: 'packets', hash: '#/packets' },
|
||||
{ name: 'nodes', hash: '#/nodes' },
|
||||
{ name: 'analytics', hash: '#/analytics' },
|
||||
];
|
||||
for (const w of [768, 2560]) {
|
||||
for (const p of otherPages) {
|
||||
await test(`No horizontal overflow at ${w}px (${p.name})`, async () => {
|
||||
await page.setViewportSize({ width: w, height: HEIGHT });
|
||||
await page.goto(BASE + '/' + p.hash, { waitUntil: 'domcontentloaded' });
|
||||
await assertNoHOverflow(page, `${p.name} @ ${w}x${HEIGHT}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Summary
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* E2E (#966): Wireshark-style filter UX.
|
||||
*
|
||||
* Boots Chromium against a local corescope-server (defaults to fixture instance
|
||||
* on :39966) and exercises:
|
||||
* - Help button opens popover with field/operator reference
|
||||
* - Autocomplete dropdown appears as user types and accepts on Enter
|
||||
* - Right-click on a packet table cell opens "Filter by this value" menu
|
||||
* and clicking populates the filter input
|
||||
* - Saved-filter dropdown lists default starter filters
|
||||
*
|
||||
* Usage: BASE_URL=http://localhost:39966 node test-filter-ux-e2e.js
|
||||
*/
|
||||
'use strict';
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:39966';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
||||
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
console.log(`\n=== #966 filter UX E2E against ${BASE} ===`);
|
||||
|
||||
await step('navigate to /packets', async () => {
|
||||
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#packetFilterInput', { timeout: 8000 });
|
||||
await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 });
|
||||
});
|
||||
|
||||
await step('PacketFilter metadata is exposed in window', async () => {
|
||||
const meta = await page.evaluate(() => ({
|
||||
fields: window.PacketFilter && Array.isArray(window.PacketFilter.FIELDS) && window.PacketFilter.FIELDS.length,
|
||||
ops: window.PacketFilter && Array.isArray(window.PacketFilter.OPERATORS) && window.PacketFilter.OPERATORS.length,
|
||||
types: window.PacketFilter && Array.isArray(window.PacketFilter.TYPE_VALUES) && window.PacketFilter.TYPE_VALUES.length,
|
||||
hasSuggest: typeof window.PacketFilter.suggest === 'function',
|
||||
}));
|
||||
assert(meta.fields >= 10, 'FIELDS not populated: ' + JSON.stringify(meta));
|
||||
assert(meta.ops >= 8, 'OPERATORS not populated');
|
||||
assert(meta.types >= 5, 'TYPE_VALUES not populated');
|
||||
assert(meta.hasSuggest, 'suggest() missing');
|
||||
});
|
||||
|
||||
await step('Help button opens popover with field reference', async () => {
|
||||
await page.click('#filterHelpBtn');
|
||||
await page.waitForSelector('#filterHelpPopover', { timeout: 3000 });
|
||||
const txt = await page.textContent('#filterHelpPopover');
|
||||
assert(/Filter syntax/i.test(txt), 'header missing');
|
||||
assert(/payload\.name/.test(txt), 'fields table missing payload.name');
|
||||
assert(/contains/.test(txt), 'operators missing');
|
||||
assert(/ADVERT/.test(txt), 'examples missing');
|
||||
// Close it
|
||||
await page.click('#filterHelpPopover .fux-popover-close');
|
||||
await page.waitForFunction(() => !document.getElementById('filterHelpPopover'), { timeout: 3000 });
|
||||
});
|
||||
|
||||
await step('Autocomplete dropdown appears on focus and filters by prefix', async () => {
|
||||
await page.click('#packetFilterInput');
|
||||
await page.fill('#packetFilterInput', '');
|
||||
await page.keyboard.type('pay');
|
||||
await page.waitForSelector('#filterAcDropdown .fux-ac-item', { timeout: 3000 });
|
||||
const items = await page.$$eval('#filterAcDropdown .fux-ac-item .fux-ac-val', els => els.map(e => e.textContent));
|
||||
assert(items.some(v => v.startsWith('payload')), 'no payload* in dropdown: ' + items.join(','));
|
||||
});
|
||||
|
||||
await step('Autocomplete accepts on Enter and updates input', async () => {
|
||||
await page.fill('#packetFilterInput', '');
|
||||
await page.click('#packetFilterInput');
|
||||
await page.keyboard.type('typ');
|
||||
await page.waitForSelector('#filterAcDropdown .fux-ac-item.active', { timeout: 3000 });
|
||||
await page.keyboard.press('Enter');
|
||||
const val = await page.inputValue('#packetFilterInput');
|
||||
assert(/^type/.test(val), 'expected `type` after accept, got: ' + val);
|
||||
});
|
||||
|
||||
await step('Saved-filter dropdown lists default starters', async () => {
|
||||
// Reset LS so defaults are unmodified
|
||||
await page.evaluate(() => { try { localStorage.removeItem('corescope_saved_filters_v1'); } catch (e) {} });
|
||||
await page.click('#filterSavedTrigger');
|
||||
await page.waitForSelector('#filterSavedMenu:not(.hidden)', { timeout: 3000 });
|
||||
const names = await page.$$eval('#filterSavedMenu .fux-saved-name', els => els.map(e => e.textContent));
|
||||
assert(names.length >= 5, 'expected ≥ 5 default filters, got: ' + names.length);
|
||||
assert(names.some(n => /Adverts only/i.test(n)), 'Adverts only missing: ' + names.join('|'));
|
||||
assert(names.some(n => /Strong signal/i.test(n)), 'Strong signal missing: ' + names.join('|'));
|
||||
});
|
||||
|
||||
await step('Clicking a saved filter populates the input and applies it', async () => {
|
||||
// Click the "Adverts only" entry
|
||||
await page.evaluate(() => {
|
||||
const items = document.querySelectorAll('#filterSavedMenu .fux-saved-item');
|
||||
for (const it of items) { if (/Adverts only/i.test(it.textContent)) { it.click(); break; } }
|
||||
});
|
||||
await page.waitForFunction(() => /type\s*==\s*ADVERT/i.test(document.getElementById('packetFilterInput').value), { timeout: 3000 });
|
||||
const val = await page.inputValue('#packetFilterInput');
|
||||
assert(/type\s*==\s*ADVERT/i.test(val), 'expected Adverts expr, got: ' + val);
|
||||
});
|
||||
|
||||
await step('Right-click on a type cell opens context menu and appends a clause', async () => {
|
||||
// Reset filter
|
||||
await page.fill('#packetFilterInput', '');
|
||||
await page.evaluate(() => document.getElementById('packetFilterInput').dispatchEvent(new Event('input', { bubbles: true })));
|
||||
// Widen time window so fixture rows render
|
||||
await page.evaluate(() => {
|
||||
const sel = document.getElementById('fTimeWindow');
|
||||
if (sel) {
|
||||
sel.value = '0';
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
// Wait for the table to populate with cells that have a real value
|
||||
await page.waitForFunction(() => {
|
||||
const cells = document.querySelectorAll('#pktBody td[data-filter-field="type"]');
|
||||
for (const c of cells) {
|
||||
const v = c.getAttribute('data-filter-value');
|
||||
if (v && v !== '—' && v !== '') return true;
|
||||
}
|
||||
return false;
|
||||
}, { timeout: 8000 });
|
||||
// Dispatch contextmenu event programmatically (Playwright headless mouse
|
||||
// right-click does not reliably trigger 'contextmenu' DOM events).
|
||||
const result = await page.evaluate(() => {
|
||||
const cell = Array.from(document.querySelectorAll('#pktBody td[data-filter-field="type"]'))
|
||||
.find(c => {
|
||||
const v = c.getAttribute('data-filter-value');
|
||||
return v && v !== '—' && v !== '';
|
||||
});
|
||||
if (!cell) return { error: 'no type cell with value' };
|
||||
const rect = cell.getBoundingClientRect();
|
||||
const ev = new MouseEvent('contextmenu', {
|
||||
bubbles: true, cancelable: true, button: 2,
|
||||
clientX: rect.left + 5, clientY: rect.top + 5,
|
||||
});
|
||||
cell.dispatchEvent(ev);
|
||||
const menu = document.getElementById('filterContextMenu');
|
||||
if (!menu) return { error: 'context menu not opened' };
|
||||
const items = Array.from(menu.querySelectorAll('.fux-ctx-item')).map(i => i.textContent);
|
||||
// Click the first item (== filter)
|
||||
menu.querySelector('.fux-ctx-item').click();
|
||||
return { items, inputAfter: document.getElementById('packetFilterInput').value };
|
||||
});
|
||||
assert(!result.error, 'menu open failed: ' + (result.error || ''));
|
||||
assert(result.items.length === 3, 'expected 3 menu items, got: ' + result.items.length);
|
||||
assert(/type\s*==\s*/.test(result.inputAfter), 'expected type clause appended, got: ' + result.inputAfter);
|
||||
});
|
||||
|
||||
await step('Save current expression persists to localStorage', async () => {
|
||||
await page.fill('#packetFilterInput', 'snr > 7');
|
||||
await page.evaluate(() => document.getElementById('packetFilterInput').dispatchEvent(new Event('input', { bubbles: true })));
|
||||
await page.click('#filterSavedTrigger');
|
||||
await page.waitForSelector('#filterSavedMenu:not(.hidden)');
|
||||
// Stub prompt
|
||||
await page.evaluate(() => { window.prompt = () => 'E2E test filter'; });
|
||||
await page.click('#filterSaveCurrent');
|
||||
await page.waitForFunction(() => {
|
||||
const raw = localStorage.getItem('corescope_saved_filters_v1') || '';
|
||||
return /E2E test filter/.test(raw) && /snr > 7/.test(raw);
|
||||
}, { timeout: 3000 });
|
||||
// Cleanup
|
||||
await page.evaluate(() => localStorage.removeItem('corescope_saved_filters_v1'));
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Tests for fluid CSS scaffolding (issue #1054).
|
||||
* Ensures `public/style.css` declares fluid spacing/type/container tokens
|
||||
* via clamp() and that base selectors consume them instead of hardcoded px.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const css = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------
|
||||
|
||||
// Extract the :root { ... } block (first occurrence — the light/default one).
|
||||
function rootBlock() {
|
||||
const m = css.match(/:root\s*\{([\s\S]*?)\}/);
|
||||
if (!m) throw new Error(':root block not found in style.css');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
// Find the value of a custom property declared in :root.
|
||||
function rootVar(name) {
|
||||
const re = new RegExp(`${name}\\s*:\\s*([^;]+);`);
|
||||
const m = rootBlock().match(re);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
function assertClamp(name) {
|
||||
const v = rootVar(name);
|
||||
assert.ok(v, `expected :root to declare ${name}`);
|
||||
assert.ok(/clamp\s*\(/.test(v), `${name} should use clamp(); got: ${v}`);
|
||||
}
|
||||
|
||||
// --- Fluid spacing tokens --------------------------------------------------
|
||||
|
||||
const SPACE_TOKENS = ['--space-xs', '--space-sm', '--space-md',
|
||||
'--space-lg', '--space-xl', '--space-2xl'];
|
||||
|
||||
SPACE_TOKENS.forEach(t => {
|
||||
test(`spacing token ${t} declared with clamp()`, () => assertClamp(t));
|
||||
});
|
||||
|
||||
// --- Fluid type scale ------------------------------------------------------
|
||||
|
||||
const TYPE_TOKENS = ['--fs-sm', '--fs-md', '--fs-lg', '--fs-xl', '--fs-2xl'];
|
||||
|
||||
TYPE_TOKENS.forEach(t => {
|
||||
test(`type token ${t} declared with clamp()`, () => assertClamp(t));
|
||||
});
|
||||
|
||||
// --- Container tokens ------------------------------------------------------
|
||||
|
||||
test('container token --content-max declared', () => {
|
||||
const v = rootVar('--content-max');
|
||||
assert.ok(v, 'expected --content-max in :root');
|
||||
assert.ok(/min\s*\(|clamp\s*\(/.test(v),
|
||||
`--content-max should use min()/clamp(); got: ${v}`);
|
||||
});
|
||||
|
||||
test('container token --gutter declared with clamp()', () => assertClamp('--gutter'));
|
||||
|
||||
// --- Base selectors must consume fluid tokens ------------------------------
|
||||
|
||||
test('html/body rule references fluid font-size token', () => {
|
||||
// Look at the html, body { ... } rule (first one).
|
||||
const m = css.match(/html\s*,\s*body\s*\{([^}]*)\}/);
|
||||
assert.ok(m, 'html, body rule not found');
|
||||
const block = m[1];
|
||||
assert.ok(/font-size\s*:\s*var\(--fs-/.test(block),
|
||||
`html/body should set font-size via var(--fs-*); block was: ${block.trim()}`);
|
||||
});
|
||||
|
||||
// --- Section markers -------------------------------------------------------
|
||||
|
||||
test('style.css contains FLUID SCAFFOLDING section marker', () => {
|
||||
assert.ok(/FLUID SCAFFOLDING/i.test(css),
|
||||
'expected a "FLUID SCAFFOLDING" section marker comment');
|
||||
});
|
||||
|
||||
// --- Summary ---------------------------------------------------------------
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,140 @@
|
||||
/* Unit tests for live.js region filter (#1045)
|
||||
* Tests packetMatchesRegion helper using observer_id → IATA mapping.
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
function makeSandbox() {
|
||||
const ctx = {
|
||||
window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: () => ({ style:{}, classList:{add(){},remove(){},contains(){return false;}}, setAttribute(){}, addEventListener(){}, getContext: () => ({clearRect(){},fillRect(){},beginPath(){},arc(){},fill(){},scale(){},fillText(){}}) }),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [], querySelector: () => null,
|
||||
createElementNS: () => ({ setAttribute(){} }),
|
||||
documentElement: { getAttribute: () => null, setAttribute: () => {}, dataset: {} },
|
||||
body: { appendChild: () => {}, removeChild: () => {}, contains: () => false },
|
||||
hidden: false,
|
||||
},
|
||||
console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
|
||||
Error, TypeError, Map, Set, Promise, URLSearchParams,
|
||||
parseInt, parseFloat, isNaN, isFinite,
|
||||
encodeURIComponent, decodeURIComponent,
|
||||
setTimeout: () => 0, clearTimeout: () => {},
|
||||
setInterval: () => 0, clearInterval: () => {},
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
||||
performance: { now: () => Date.now() },
|
||||
requestAnimationFrame: (cb) => 0,
|
||||
cancelAnimationFrame: () => {},
|
||||
localStorage: (() => {
|
||||
const s = {};
|
||||
return {
|
||||
getItem: k => s[k] !== undefined ? s[k] : null,
|
||||
setItem: (k, v) => { s[k] = String(v); },
|
||||
removeItem: k => { delete s[k]; },
|
||||
};
|
||||
})(),
|
||||
location: { hash: '', protocol: 'https:', host: 'localhost' },
|
||||
CustomEvent: class CustomEvent {},
|
||||
addEventListener: () => {}, dispatchEvent: () => {},
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
|
||||
navigator: {}, visualViewport: null,
|
||||
MutationObserver: function() { this.observe=()=>{}; this.disconnect=()=>{}; },
|
||||
WebSocket: function() { this.close=()=>{}; },
|
||||
IATA_COORDS_GEO: {},
|
||||
L: {
|
||||
circleMarker: () => ({addTo(){return this;},bindTooltip(){return this;},on(){return this;},setRadius(){},setStyle(){},setLatLng(){},getLatLng(){return{lat:0,lng:0};},remove(){}}),
|
||||
polyline: () => ({addTo(){return this;},setStyle(){},remove(){}}),
|
||||
polygon: () => ({addTo(){return this;},remove(){}}),
|
||||
map: () => ({setView(){return this;},addLayer(){return this;},on(){return this;},getZoom(){return 11;},getCenter(){return{lat:0,lng:0};},getBounds(){return{contains:()=>true};},fitBounds(){return this;},invalidateSize(){},remove(){},hasLayer(){return false;},removeLayer(){}}),
|
||||
layerGroup: () => ({addTo(){return this;},addLayer(){},removeLayer(){},clearLayers(){},hasLayer(){return true;},eachLayer(){}}),
|
||||
tileLayer: () => ({addTo(){return this;}}),
|
||||
control: { attribution: () => ({addTo(){}}) },
|
||||
DomUtil: { addClass(){}, removeClass(){} },
|
||||
},
|
||||
registerPage: () => {}, onWS: () => {}, offWS: () => {}, connectWS: () => {},
|
||||
api: () => Promise.resolve([]), invalidateApiCache: () => {},
|
||||
favStar: () => '', bindFavStars: () => {},
|
||||
getFavorites: () => [], isFavorite: () => false,
|
||||
HopResolver: { init(){}, resolve: () => ({}), ready: () => false },
|
||||
MeshAudio: null,
|
||||
RegionFilter: { init(){}, getSelected: () => null, onChange: () => {}, offChange: () => {}, regionQueryString: () => '', getRegionParam: () => '' },
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function load(ctx, file) {
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
|
||||
console.log('\n=== live.js: region filter (#1045) ===');
|
||||
const ctx = makeSandbox();
|
||||
load(ctx, 'public/roles.js');
|
||||
load(ctx, 'public/live.js');
|
||||
|
||||
const fn = ctx.window._livePacketMatchesRegion;
|
||||
assert.ok(fn, '_livePacketMatchesRegion must be exposed');
|
||||
|
||||
const obsMap = { 'obs1': 'SJC', 'obs2': 'SFO', 'obs3': 'PDX' };
|
||||
|
||||
test('returns true when no regions selected (filter inactive)', () => {
|
||||
assert.strictEqual(fn([{observer_id:'obs1'}], obsMap, null), true);
|
||||
assert.strictEqual(fn([{observer_id:'obs1'}], obsMap, []), true);
|
||||
});
|
||||
|
||||
test('matches when single observation observer is in selected region', () => {
|
||||
assert.strictEqual(fn([{observer_id:'obs1'}], obsMap, ['SJC']), true);
|
||||
});
|
||||
|
||||
test('does not match when observer is in different region', () => {
|
||||
assert.strictEqual(fn([{observer_id:'obs1'}], obsMap, ['PDX']), false);
|
||||
});
|
||||
|
||||
test('matches if ANY observation is in selected region (OR across observations)', () => {
|
||||
assert.strictEqual(fn([{observer_id:'obs1'}, {observer_id:'obs3'}], obsMap, ['PDX']), true);
|
||||
});
|
||||
|
||||
test('matches if observer iata is in any of multiple selected regions', () => {
|
||||
assert.strictEqual(fn([{observer_id:'obs2'}], obsMap, ['SJC','SFO']), true);
|
||||
});
|
||||
|
||||
test('does not match when observer_id is unknown', () => {
|
||||
assert.strictEqual(fn([{observer_id:'ghost'}], obsMap, ['SJC']), false);
|
||||
});
|
||||
|
||||
test('does not match when packet has no observer_id', () => {
|
||||
assert.strictEqual(fn([{}], obsMap, ['SJC']), false);
|
||||
});
|
||||
|
||||
test('region match is case-insensitive', () => {
|
||||
assert.strictEqual(fn([{observer_id:'obs1'}], obsMap, ['sjc']), true);
|
||||
});
|
||||
|
||||
const setMap = ctx.window._liveSetObserverIataMap;
|
||||
assert.ok(setMap, '_liveSetObserverIataMap must be exposed');
|
||||
|
||||
test('observer iata map can be updated and used by filter', () => {
|
||||
setMap({ 'newobs': 'LAX' });
|
||||
// Now the live module should consult the new map. The helper accepts the map
|
||||
// explicitly (pure), so we test the same function with the new mapping:
|
||||
assert.strictEqual(fn([{observer_id:'newobs'}], { 'newobs': 'LAX' }, ['LAX']), true);
|
||||
});
|
||||
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
console.log(` live region filter tests: ${passed} passed, ${failed} failed`);
|
||||
console.log(`${'═'.repeat(40)}\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,142 @@
|
||||
/* Unit tests for map.js clustering integration (issue #1036)
|
||||
*
|
||||
* Verifies:
|
||||
* - makeClusterIcon produces a divIcon HTML containing the total + per-role pills
|
||||
* - createClusterGroup instantiates an L.MarkerClusterGroup with the required options
|
||||
* - The cluster group accepts markers via addLayer
|
||||
*
|
||||
* Tests run in a jsdom-free vm sandbox with a tiny Leaflet/Leaflet.markercluster
|
||||
* shim so we exercise our integration code (not the library itself).
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
// ---- Tiny Leaflet shim ----
|
||||
function makeLeafletShim() {
|
||||
const L = {};
|
||||
L.point = (x, y) => ({ x, y });
|
||||
L.latLng = (a, b) => ({ lat: a, lng: b });
|
||||
L.divIcon = (opts) => ({ _isDivIcon: true, options: opts, html: opts.html, className: opts.className });
|
||||
L.layerGroup = () => {
|
||||
const g = { _layers: [], addLayer(m){ this._layers.push(m); return this; }, removeLayer(m){ const i=this._layers.indexOf(m); if(i>=0) this._layers.splice(i,1); return this; }, clearLayers(){ this._layers=[]; return this; }, eachLayer(fn){ this._layers.forEach(fn); }, addTo(){ return this; }, hasLayer(m){ return this._layers.includes(m); } };
|
||||
return g;
|
||||
};
|
||||
L.marker = (latlng, opts) => ({ _isMarker: true, _latlng: latlng, options: opts || {}, getLatLng(){ return this._latlng; }, bindPopup(){ return this; }, bindTooltip(){ return this; } });
|
||||
// markercluster shim
|
||||
function MarkerClusterGroup(opts) {
|
||||
this.options = opts || {};
|
||||
this._layers = [];
|
||||
this._isClusterGroup = true;
|
||||
}
|
||||
MarkerClusterGroup.prototype.addLayer = function (m) { this._layers.push(m); return this; };
|
||||
MarkerClusterGroup.prototype.addLayers = function (ms) { ms.forEach(m => this._layers.push(m)); return this; };
|
||||
MarkerClusterGroup.prototype.removeLayer = function (m) { const i=this._layers.indexOf(m); if(i>=0) this._layers.splice(i,1); return this; };
|
||||
MarkerClusterGroup.prototype.clearLayers = function () { this._layers = []; return this; };
|
||||
MarkerClusterGroup.prototype.eachLayer = function (fn) { this._layers.forEach(fn); };
|
||||
MarkerClusterGroup.prototype.hasLayer = function (m) { return this._layers.includes(m); };
|
||||
MarkerClusterGroup.prototype.addTo = function () { return this; };
|
||||
MarkerClusterGroup.prototype.getLayers = function () { return this._layers.slice(); };
|
||||
L.MarkerClusterGroup = MarkerClusterGroup;
|
||||
L.markerClusterGroup = (opts) => new MarkerClusterGroup(opts);
|
||||
return L;
|
||||
}
|
||||
|
||||
function makeSandbox() {
|
||||
const ctx = {
|
||||
window: {},
|
||||
document: { addEventListener(){}, getElementById(){ return null; }, querySelector(){ return null; }, querySelectorAll(){ return []; }, createElement(){ return { id:'', textContent:'', innerHTML:'', appendChild(){}, addEventListener(){}, setAttribute(){}, classList:{add(){},remove(){},toggle(){}} }; }, head: { appendChild(){} }, body: { appendChild(){} } },
|
||||
console, Date, Math, Array, Object, String, Number, JSON, RegExp, Error,
|
||||
parseInt, parseFloat, isFinite, isNaN, Map, Set, Promise,
|
||||
setTimeout: ()=>{}, clearTimeout: ()=>{}, setInterval: ()=>{}, clearInterval: ()=>{},
|
||||
registerPage: () => {}, esc: (s) => s, onWS: () => {}, offWS: () => {},
|
||||
localStorage: (() => { const s={}; return { getItem:k=>s[k]||null, setItem:(k,v)=>{s[k]=String(v);}, removeItem:k=>{delete s[k];} }; })(),
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
||||
addEventListener(){}, dispatchEvent(){},
|
||||
L: makeLeafletShim(),
|
||||
};
|
||||
ctx.window.L = ctx.L;
|
||||
vm.createContext(ctx);
|
||||
// Load roles for ROLE_COLORS palette
|
||||
vm.runInContext(fs.readFileSync('public/roles.js','utf8'), ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
// Load map.js (IIFE — exposes test hooks via window.__meshcoreMapInternals)
|
||||
vm.runInContext(fs.readFileSync('public/map.js','utf8'), ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
return ctx;
|
||||
}
|
||||
|
||||
console.log('\n=== map.js: clustering ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
const internals = ctx.window.__meshcoreMapInternals;
|
||||
|
||||
test('exposes test hooks (__meshcoreMapInternals)', () => {
|
||||
assert.ok(internals, 'window.__meshcoreMapInternals not exposed by map.js');
|
||||
assert.ok(typeof internals.makeClusterIcon === 'function', 'makeClusterIcon not exported');
|
||||
assert.ok(typeof internals.createClusterGroup === 'function', 'createClusterGroup not exported');
|
||||
});
|
||||
|
||||
test('createClusterGroup returns an L.MarkerClusterGroup with required options', () => {
|
||||
const g = internals.createClusterGroup();
|
||||
assert.ok(g, 'createClusterGroup returned falsy');
|
||||
assert.ok(g instanceof ctx.L.MarkerClusterGroup, 'expected L.MarkerClusterGroup instance');
|
||||
assert.strictEqual(g.options.chunkedLoading, true, 'chunkedLoading should be true');
|
||||
assert.strictEqual(g.options.removeOutsideVisibleBounds, true, 'removeOutsideVisibleBounds should be true');
|
||||
assert.strictEqual(g.options.disableClusteringAtZoom, 16, 'disableClusteringAtZoom should be 16');
|
||||
assert.strictEqual(g.options.spiderfyOnMaxZoom, true, 'spiderfyOnMaxZoom should be true');
|
||||
assert.strictEqual(typeof g.options.iconCreateFunction, 'function', 'iconCreateFunction should be set');
|
||||
});
|
||||
|
||||
test('cluster group accepts markers via addLayer', () => {
|
||||
const g = internals.createClusterGroup();
|
||||
const m1 = ctx.L.marker(ctx.L.latLng(37.7, -122.4));
|
||||
const m2 = ctx.L.marker(ctx.L.latLng(37.8, -122.5));
|
||||
g.addLayer(m1);
|
||||
g.addLayer(m2);
|
||||
assert.strictEqual(g.getLayers().length, 2, 'cluster group should hold added markers');
|
||||
});
|
||||
|
||||
test('makeClusterIcon: includes total count and role-pill counts', () => {
|
||||
const markers = [
|
||||
{ _role: 'repeater' }, { _role: 'repeater' }, { _role: 'repeater' },
|
||||
{ _role: 'companion' }, { _role: 'companion' },
|
||||
{ _role: 'room' },
|
||||
];
|
||||
const cluster = { getAllChildMarkers: () => markers, getChildCount: () => markers.length };
|
||||
const icon = internals.makeClusterIcon(cluster);
|
||||
assert.ok(icon && icon._isDivIcon, 'expected an L.divIcon');
|
||||
const html = icon.html || '';
|
||||
assert.ok(/>6</.test(html) || html.indexOf('>6<') >= 0, `total count 6 not in html: ${html}`);
|
||||
// Role pill counts should appear
|
||||
assert.ok(html.indexOf('>3<') >= 0, `repeater pill (3) not in html: ${html}`);
|
||||
assert.ok(html.indexOf('>2<') >= 0, `companion pill (2) not in html: ${html}`);
|
||||
assert.ok(html.indexOf('>1<') >= 0, `room pill (1) not in html: ${html}`);
|
||||
// CoreScope-themed wrapper class
|
||||
assert.ok((icon.className || '').indexOf('mc-cluster') >= 0, `expected mc-cluster class, got: ${icon.className}`);
|
||||
});
|
||||
|
||||
test('makeClusterIcon: bucket sm/md/lg by total', () => {
|
||||
const mk = (n, role='companion') => Array.from({length:n}, () => ({ _role: role }));
|
||||
function clusterOf(n) { const ms = mk(n); return { getAllChildMarkers: () => ms, getChildCount: () => n }; }
|
||||
const small = internals.makeClusterIcon(clusterOf(5));
|
||||
const med = internals.makeClusterIcon(clusterOf(40));
|
||||
const large = internals.makeClusterIcon(clusterOf(150));
|
||||
assert.ok(/mc-sm/.test(small.html || small.className || ''), 'small bucket missing');
|
||||
assert.ok(/mc-md/.test(med.html || med.className || ''), 'medium bucket missing');
|
||||
assert.ok(/mc-lg/.test(large.html || large.className || ''), 'large bucket missing');
|
||||
});
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.log(`\n${failed} test(s) failed, ${passed} passed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nAll ${passed} test(s) passed`);
|
||||
@@ -0,0 +1,67 @@
|
||||
/* test-observers-headings.js — Issue #1039 regression test.
|
||||
* Asserts observer table thead column count matches tbody row column count.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✓ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ✗ ${name}\n ${e.message}`); }
|
||||
}
|
||||
|
||||
function extractBlock(s, openRe, closeRe) {
|
||||
const m = s.match(openRe);
|
||||
if (!m) throw new Error('open marker not found');
|
||||
const start = m.index + m[0].length;
|
||||
const rest = s.slice(start);
|
||||
const cm = rest.match(closeRe);
|
||||
if (!cm) throw new Error('close marker not found');
|
||||
return rest.slice(0, cm.index);
|
||||
}
|
||||
|
||||
console.log('── Observers table headings (#1039) ──');
|
||||
|
||||
test('thead column count equals tbody row column count', () => {
|
||||
const thead = extractBlock(src, /<thead><tr>/, /<\/tr><\/thead>/);
|
||||
const thCount = (thead.match(/<th\b/g) || []).length;
|
||||
|
||||
// tbody row template lives inside a backtick-template `<tr ...>...</tr>`.
|
||||
// Grab from the first `<tr ` after `tbody>` up to the first `</tr>`.
|
||||
const tbodyStart = src.indexOf('<tbody>');
|
||||
assert.ok(tbodyStart > 0, '<tbody> not found in observers.js');
|
||||
const after = src.slice(tbodyStart);
|
||||
const trOpen = after.search(/`<tr\b/);
|
||||
assert.ok(trOpen > 0, 'row template `<tr` not found');
|
||||
const rowStart = trOpen;
|
||||
const rowEnd = after.indexOf('</tr>', rowStart);
|
||||
assert.ok(rowEnd > rowStart, '</tr> not found in row template');
|
||||
const row = after.slice(rowStart, rowEnd);
|
||||
const tdCount = (row.match(/<td\b/g) || []).length;
|
||||
|
||||
assert.strictEqual(
|
||||
tdCount, thCount,
|
||||
`Observer table column mismatch: ${thCount} <th> headings vs ${tdCount} <td> cells per row. ` +
|
||||
`Headings drift after "Last Packet" — see issue #1039.`
|
||||
);
|
||||
});
|
||||
|
||||
test('expected headings present and ordered', () => {
|
||||
const thead = extractBlock(src, /<thead><tr>/, /<\/tr><\/thead>/);
|
||||
const labels = [];
|
||||
const re = /<th[^>]*>([^<]+)<\/th>/g;
|
||||
let m;
|
||||
while ((m = re.exec(thead)) !== null) labels.push(m[1].trim());
|
||||
const expected = ['Status', 'Name', 'Region', 'Last Status', 'Last Packet',
|
||||
'Packet Health', 'Total Packets', 'Packets/Hour', 'Clock Offset', 'Uptime'];
|
||||
assert.deepStrictEqual(labels, expected,
|
||||
`Headings out of sync.\nGot: ${JSON.stringify(labels)}\nExpected: ${JSON.stringify(expected)}`);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,116 @@
|
||||
/* Unit tests for packet filter timestamp predicates (issue #289) */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
|
||||
const code = fs.readFileSync('public/packet-filter.js', 'utf8');
|
||||
const ctx = { window: {}, console };
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(code, ctx);
|
||||
const PF = ctx.window.PacketFilter;
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); pass++; }
|
||||
catch (e) { console.log(`FAIL: ${name} — ${e.message}`); fail++; }
|
||||
}
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); }
|
||||
|
||||
const NOW = Date.now();
|
||||
function isoOffset(ms) { return new Date(NOW - ms).toISOString(); }
|
||||
|
||||
// Packets at known offsets
|
||||
const pkt30m = { payload_type: 4, timestamp: isoOffset(30 * 60 * 1000) }; // 30 minutes ago
|
||||
const pkt2h = { payload_type: 4, timestamp: isoOffset(2 * 60 * 60 * 1000) }; // 2 hours ago
|
||||
const pkt2d = { payload_type: 4, timestamp: isoOffset(2 * 24 * 60 * 60 * 1000) }; // 2 days ago
|
||||
const pkt2024 = { payload_type: 4, timestamp: '2024-06-15T12:00:00Z' };
|
||||
const pkt2023 = { payload_type: 4, timestamp: '2023-06-15T12:00:00Z' };
|
||||
const pkt2025 = { payload_type: 4, timestamp: '2025-06-15T12:00:00Z' };
|
||||
|
||||
// --- after / before on time field ---
|
||||
test('time after 2024-01-01 matches 2024 packet', () => {
|
||||
assert(PF.compile('time after "2024-01-01"').filter(pkt2024));
|
||||
});
|
||||
test('time after 2024-01-01 rejects 2023 packet', () => {
|
||||
assert(!PF.compile('time after "2024-01-01"').filter(pkt2023));
|
||||
});
|
||||
test('time before 2024-12-31 matches 2024 packet', () => {
|
||||
assert(PF.compile('time before "2024-12-31"').filter(pkt2024));
|
||||
});
|
||||
test('time before 2024-12-31 rejects 2025 packet', () => {
|
||||
assert(!PF.compile('time before "2024-12-31"').filter(pkt2025));
|
||||
});
|
||||
|
||||
// --- between on time field ---
|
||||
test('time between matches packet inside range', () => {
|
||||
assert(PF.compile('time between "2024-01-01" "2024-12-31"').filter(pkt2024));
|
||||
});
|
||||
test('time between rejects packet outside range', () => {
|
||||
assert(!PF.compile('time between "2024-01-01" "2024-12-31"').filter(pkt2023));
|
||||
assert(!PF.compile('time between "2024-01-01" "2024-12-31"').filter(pkt2025));
|
||||
});
|
||||
|
||||
// --- ISO datetime with time component ---
|
||||
test('time after with full ISO datetime', () => {
|
||||
assert(PF.compile('time after "2024-06-15T00:00:00Z"').filter(pkt2024));
|
||||
assert(!PF.compile('time after "2024-06-16T00:00:00Z"').filter(pkt2024));
|
||||
});
|
||||
|
||||
// --- age with relative durations ---
|
||||
test('age < 1h matches 30-min-old packet', () => {
|
||||
assert(PF.compile('age < 1h').filter(pkt30m));
|
||||
});
|
||||
test('age < 1h rejects 2-hour-old packet', () => {
|
||||
assert(!PF.compile('age < 1h').filter(pkt2h));
|
||||
});
|
||||
test('age > 1h matches 2-hour-old packet', () => {
|
||||
assert(PF.compile('age > 1h').filter(pkt2h));
|
||||
});
|
||||
test('age > 24h matches 2-day-old packet', () => {
|
||||
assert(PF.compile('age > 24h').filter(pkt2d));
|
||||
});
|
||||
test('age > 24h rejects 30-min-old packet', () => {
|
||||
assert(!PF.compile('age > 24h').filter(pkt30m));
|
||||
});
|
||||
test('age < 7d matches 2-day-old packet', () => {
|
||||
assert(PF.compile('age < 7d').filter(pkt2d));
|
||||
});
|
||||
test('age units: m (minutes)', () => {
|
||||
assert(PF.compile('age > 15m').filter(pkt30m));
|
||||
assert(!PF.compile('age > 60m').filter(pkt30m));
|
||||
});
|
||||
test('age units: s (seconds)', () => {
|
||||
assert(PF.compile('age > 60s').filter(pkt30m));
|
||||
});
|
||||
|
||||
// --- combining time predicates with logic ---
|
||||
test('age < 1h && type == ADVERT', () => {
|
||||
assert(PF.compile('age < 1h && type == ADVERT').filter(pkt30m));
|
||||
assert(!PF.compile('age < 1h && type == ADVERT').filter(pkt2h));
|
||||
});
|
||||
|
||||
// --- null timestamp safety ---
|
||||
test('time predicate on packet without timestamp → false', () => {
|
||||
assert(!PF.compile('time after "2024-01-01"').filter({ payload_type: 4 }));
|
||||
assert(!PF.compile('age < 1h').filter({ payload_type: 4 }));
|
||||
assert(!PF.compile('time between "2024-01-01" "2024-12-31"').filter({ payload_type: 4 }));
|
||||
});
|
||||
|
||||
// --- error handling ---
|
||||
test('invalid datetime → error', () => {
|
||||
const c = PF.compile('time after "not-a-date"');
|
||||
assert(c.error !== null, 'should error on invalid date');
|
||||
});
|
||||
test('invalid duration → error', () => {
|
||||
const c = PF.compile('age < 1xyz');
|
||||
assert(c.error !== null, 'should error on invalid duration');
|
||||
});
|
||||
|
||||
// --- packets.first_seen fallback ---
|
||||
test('time field falls back to first_seen when timestamp missing', () => {
|
||||
const p = { payload_type: 4, first_seen: '2024-06-15T12:00:00Z' };
|
||||
assert(PF.compile('time after "2024-01-01"').filter(p));
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,190 @@
|
||||
/* Unit tests for filter UX helpers: PacketFilter metadata + autocomplete +
|
||||
* SavedFilters store (issue #966). Pure-logic only — DOM exercised by E2E.
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
|
||||
function loadInCtx(files, ctx) {
|
||||
for (const f of files) vm.runInContext(fs.readFileSync(f, 'utf8'), ctx);
|
||||
}
|
||||
|
||||
// Fake DOM-less window with a localStorage shim so filter-ux.js can be loaded
|
||||
// in Node without touching the document object model.
|
||||
function makeCtx() {
|
||||
const store = {};
|
||||
const ls = {
|
||||
getItem: k => Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null,
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: k => { delete store[k]; },
|
||||
clear: () => { for (const k of Object.keys(store)) delete store[k]; },
|
||||
};
|
||||
// Minimal document stub — filter-ux.js init() must early-exit when DOM missing.
|
||||
const doc = { getElementById: () => null, addEventListener: () => {}, body: null };
|
||||
const win = { localStorage: ls, document: doc, addEventListener: () => {} };
|
||||
const ctx = { window: win, document: doc, localStorage: ls, console };
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); pass++; console.log(' ✓ ' + name); }
|
||||
catch (e) { console.log(' ✗ ' + name + ' — ' + e.message); fail++; }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
const ctx = makeCtx();
|
||||
loadInCtx(['public/packet-filter.js', 'public/filter-ux.js'], ctx);
|
||||
const PF = ctx.window.PacketFilter;
|
||||
const UX = ctx.window.FilterUX;
|
||||
|
||||
console.log('\n=== #966 filter-UX unit tests ===');
|
||||
|
||||
// ── Metadata exposed by PacketFilter ──────────────────────────────────────
|
||||
test('PacketFilter.FIELDS exposes top-level fields', () => {
|
||||
assert(Array.isArray(PF.FIELDS), 'FIELDS is array');
|
||||
const names = PF.FIELDS.map(f => f.name);
|
||||
for (const want of ['type', 'route', 'snr', 'rssi', 'hops', 'observer', 'hash', 'size', 'age']) {
|
||||
assert(names.includes(want), 'FIELDS missing ' + want);
|
||||
}
|
||||
for (const f of PF.FIELDS) { assert(typeof f.desc === 'string' && f.desc.length, 'desc required for ' + f.name); }
|
||||
});
|
||||
|
||||
test('PacketFilter.OPERATORS lists comparison operators with examples', () => {
|
||||
assert(Array.isArray(PF.OPERATORS), 'OPERATORS array');
|
||||
const ops = PF.OPERATORS.map(o => o.op);
|
||||
for (const want of ['==', '!=', '>', '<', '>=', '<=', 'contains', 'starts_with']) {
|
||||
assert(ops.includes(want), 'OPERATORS missing ' + want);
|
||||
}
|
||||
for (const o of PF.OPERATORS) { assert(typeof o.example === 'string' && o.example.length, 'example required for ' + o.op); }
|
||||
});
|
||||
|
||||
test('PacketFilter.TYPE_VALUES exposes canonical type names', () => {
|
||||
assert(Array.isArray(PF.TYPE_VALUES));
|
||||
for (const want of ['ADVERT', 'GRP_TXT', 'GRP_DATA', 'TXT_MSG', 'ACK']) {
|
||||
assert(PF.TYPE_VALUES.includes(want), 'TYPE_VALUES missing ' + want);
|
||||
}
|
||||
});
|
||||
|
||||
test('PacketFilter.ROUTE_VALUES exposes route names', () => {
|
||||
assert(Array.isArray(PF.ROUTE_VALUES));
|
||||
for (const want of ['FLOOD', 'DIRECT', 'TRANSPORT_FLOOD', 'TRANSPORT_DIRECT']) {
|
||||
assert(PF.ROUTE_VALUES.includes(want), 'ROUTE_VALUES missing ' + want);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Autocomplete suggestions ──────────────────────────────────────────────
|
||||
test('suggest() on empty input returns top-level fields', () => {
|
||||
const r = PF.suggest('', 0);
|
||||
assert(r && Array.isArray(r.suggestions), 'returns object with suggestions');
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('type'), 'suggests type');
|
||||
assert(vals.includes('snr'), 'suggests snr');
|
||||
});
|
||||
|
||||
test('suggest() prefix-matches field names', () => {
|
||||
const r = PF.suggest('pay', 3);
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
// payload.* aliases or payload_bytes/payload_hex should surface
|
||||
assert(vals.some(v => v.startsWith('payload')), 'no payload* suggestion: ' + vals.join(','));
|
||||
assert(r.replaceStart === 0 && r.replaceEnd === 3, 'replace range covers prefix');
|
||||
});
|
||||
|
||||
test('suggest() after `type ==` lists type values', () => {
|
||||
const r = PF.suggest('type == ', 8);
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('ADVERT'), 'ADVERT in type values');
|
||||
assert(vals.includes('GRP_TXT'), 'GRP_TXT in type values');
|
||||
});
|
||||
|
||||
test('suggest() after `type == AD` filters type values', () => {
|
||||
const r = PF.suggest('type == AD', 10);
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('ADVERT'), 'ADVERT matches AD prefix');
|
||||
assert(!vals.includes('GRP_TXT'), 'GRP_TXT filtered out');
|
||||
});
|
||||
|
||||
test('suggest() after `route ==` lists route values', () => {
|
||||
const r = PF.suggest('route == ', 9);
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('FLOOD'), 'FLOOD route');
|
||||
assert(vals.includes('DIRECT'), 'DIRECT route');
|
||||
});
|
||||
|
||||
test('suggest() after operator suggests operators when no field given yet (no crash)', () => {
|
||||
const r = PF.suggest('snr ', 4);
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('>') || vals.includes('==') || vals.includes('<'), 'op suggested: ' + vals.join(','));
|
||||
});
|
||||
|
||||
test('suggest() includes payload.* keys from dynamic discovery', () => {
|
||||
const r = PF.suggest('payload.', 8, { payloadKeys: ['name', 'lat', 'channelHash'] });
|
||||
const vals = r.suggestions.map(s => s.value);
|
||||
assert(vals.includes('payload.name'), 'payload.name from dynamic keys');
|
||||
assert(vals.includes('payload.lat'), 'payload.lat from dynamic keys');
|
||||
});
|
||||
|
||||
// ── Improved parse-error positioning ──────────────────────────────────────
|
||||
test('error message cites position for unknown character', () => {
|
||||
const r = PF.parse('snr @ 5');
|
||||
assert(r.error && /position/i.test(r.error), 'error should cite position: ' + r.error);
|
||||
});
|
||||
|
||||
// ── Saved filters store ───────────────────────────────────────────────────
|
||||
test('SavedFilters.defaults() returns at least 5 starter filters', () => {
|
||||
const d = UX.SavedFilters.defaults();
|
||||
assert(Array.isArray(d) && d.length >= 5, 'defaults length ≥ 5: ' + (d && d.length));
|
||||
for (const f of d) { assert(f.name && f.expr, 'each default has name + expr'); }
|
||||
});
|
||||
|
||||
test('SavedFilters.list() includes defaults when nothing saved', () => {
|
||||
ctx.window.localStorage.clear();
|
||||
const list = UX.SavedFilters.list();
|
||||
assert(list.length >= 5, 'list seeded with defaults');
|
||||
});
|
||||
|
||||
test('SavedFilters.save() persists to localStorage and survives list()', () => {
|
||||
ctx.window.localStorage.clear();
|
||||
UX.SavedFilters.save('my filter', 'snr > 10');
|
||||
const list = UX.SavedFilters.list();
|
||||
const found = list.find(f => f.name === 'my filter' && f.expr === 'snr > 10');
|
||||
assert(found, 'saved filter present in list');
|
||||
// Must persist to LS, not memory
|
||||
const raw = ctx.window.localStorage.getItem('corescope_saved_filters_v1');
|
||||
assert(raw && raw.includes('snr > 10'), 'persisted to LS: ' + raw);
|
||||
});
|
||||
|
||||
test('SavedFilters.delete() removes user filter but keeps defaults', () => {
|
||||
ctx.window.localStorage.clear();
|
||||
UX.SavedFilters.save('temp', 'hops > 0');
|
||||
UX.SavedFilters.delete('temp');
|
||||
const list = UX.SavedFilters.list();
|
||||
assert(!list.find(f => f.name === 'temp'), 'temp removed');
|
||||
assert(list.length >= 5, 'defaults still present');
|
||||
});
|
||||
|
||||
test('SavedFilters.save() overwrites existing user filter with same name', () => {
|
||||
ctx.window.localStorage.clear();
|
||||
UX.SavedFilters.save('x', 'snr > 1');
|
||||
UX.SavedFilters.save('x', 'snr > 99');
|
||||
const list = UX.SavedFilters.list();
|
||||
const matches = list.filter(f => f.name === 'x');
|
||||
assert(matches.length === 1, 'no duplicate name');
|
||||
assert(matches[0].expr === 'snr > 99', 'overwritten to latest');
|
||||
});
|
||||
|
||||
// ── Filter-by-cell helper (for right-click) ───────────────────────────────
|
||||
test('buildCellFilterClause() emits field == "value" with quoting for strings', () => {
|
||||
assert(UX.buildCellFilterClause('observer', 'Dorrington', '==') === 'observer == "Dorrington"');
|
||||
assert(UX.buildCellFilterClause('snr', '8.5', '==') === 'snr == 8.5');
|
||||
assert(UX.buildCellFilterClause('type', 'ADVERT', '==') === 'type == ADVERT');
|
||||
});
|
||||
|
||||
test('appendClauseToExpr() appends with && when expr present', () => {
|
||||
assert(UX.appendClauseToExpr('', 'snr > 5') === 'snr > 5');
|
||||
assert(UX.appendClauseToExpr('type == ADVERT', 'snr > 5') === 'type == ADVERT && snr > 5');
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,225 @@
|
||||
/* test-pull-to-reconnect.js — behavioral tests for pull-to-reconnect (#1063)
|
||||
* Loads app.js in a vm sandbox, stubs WebSocket + DOM, asserts that:
|
||||
* - pullReconnect() exists as a global helper
|
||||
* - calling it closes the existing WS (which triggers the existing
|
||||
* auto-reconnect path)
|
||||
* - setupPullToReconnect() exists and wires touchstart/touchmove/touchend
|
||||
* listeners on the document
|
||||
* - a pull-down gesture at scrollTop=0 over the threshold triggers
|
||||
* pullReconnect
|
||||
* - a touch when scrollTop > 0 does NOT trigger pullReconnect (don't
|
||||
* hijack normal scrolling)
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
console.log('--- test-pull-to-reconnect.js ---');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}\n ${e.stack.split('\n').slice(1, 3).join('\n ')}`); }
|
||||
}
|
||||
|
||||
function makeSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const listeners = {}; // event name -> [fn]
|
||||
const elements = {};
|
||||
function makeEl(id) {
|
||||
const el = {
|
||||
id, textContent: '', innerHTML: '', value: '',
|
||||
style: {}, dataset: {},
|
||||
_classes: new Set(),
|
||||
classList: {
|
||||
add: function() { for (const a of arguments) el._classes.add(a); },
|
||||
remove: function() { for (const a of arguments) el._classes.delete(a); },
|
||||
toggle: function(c) { if (el._classes.has(c)) el._classes.delete(c); else el._classes.add(c); },
|
||||
contains: function(c) { return el._classes.has(c); },
|
||||
},
|
||||
addEventListener: function(ev, fn) { (el['_on_' + ev] = el['_on_' + ev] || []).push(fn); },
|
||||
removeEventListener: function() {},
|
||||
setAttribute: function() {}, getAttribute: function() { return null; },
|
||||
appendChild: function(child) { (el._children = el._children || []).push(child); return child; },
|
||||
remove: function() {},
|
||||
querySelector: function() { return null; },
|
||||
querySelectorAll: function() { return []; },
|
||||
};
|
||||
elements[id] = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
// Pre-create elements app.js touches at WS time
|
||||
makeEl('liveDot');
|
||||
|
||||
// Stub WebSocket — track instances + close calls
|
||||
const wsInstances = [];
|
||||
function FakeWS(url) {
|
||||
this.url = url;
|
||||
this.readyState = 1; // OPEN
|
||||
this.closed = false;
|
||||
this.onopen = null; this.onclose = null; this.onerror = null; this.onmessage = null;
|
||||
wsInstances.push(this);
|
||||
// simulate immediate open so onopen fires synchronously isn't required;
|
||||
// tests will invoke handlers directly when needed.
|
||||
}
|
||||
FakeWS.prototype.close = function() {
|
||||
this.closed = true;
|
||||
if (typeof this.onclose === 'function') this.onclose({});
|
||||
};
|
||||
FakeWS.prototype.send = function() {};
|
||||
|
||||
const body = makeEl('body');
|
||||
|
||||
const ctx = {
|
||||
console,
|
||||
setTimeout: function(fn, ms) { return 0; }, // suppress reconnect loop
|
||||
clearTimeout: function() {},
|
||||
setInterval: function() { return 0; },
|
||||
clearInterval: function() {},
|
||||
Date, Math, JSON, Object, Array, String, Number, Boolean,
|
||||
Error, RegExp, Map, Set, Symbol, Promise,
|
||||
requestAnimationFrame: function(fn) { return 0; },
|
||||
performance: { now: function() { return 0; } },
|
||||
location: { protocol: 'http:', host: 'localhost', hash: '' },
|
||||
navigator: { userAgent: 'test' },
|
||||
WebSocket: FakeWS,
|
||||
fetch: function() { return Promise.resolve({ ok: true, json: function() { return Promise.resolve({}); } }); },
|
||||
localStorage: {
|
||||
_data: {},
|
||||
getItem: function(k) { return this._data[k] || null; },
|
||||
setItem: function(k, v) { this._data[k] = String(v); },
|
||||
removeItem: function(k) { delete this._data[k]; },
|
||||
},
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
documentElement: { scrollTop: opts.scrollTop || 0, style: { setProperty: function() {} }, setAttribute: function() {}, getAttribute: function() { return null; } },
|
||||
body: body,
|
||||
head: { appendChild: function() {} },
|
||||
createElement: function(tag) { return makeEl(tag); },
|
||||
getElementById: function(id) { return elements[id] || null; },
|
||||
querySelector: function() { return null; },
|
||||
querySelectorAll: function() { return []; },
|
||||
addEventListener: function(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); },
|
||||
removeEventListener: function() {},
|
||||
dispatchEvent: function(e) { (listeners[e.type] || []).forEach(function(fn) { fn(e); }); return true; },
|
||||
},
|
||||
window: {
|
||||
addEventListener: function() {}, removeEventListener: function() {}, dispatchEvent: function() {},
|
||||
matchMedia: function() { return { matches: false, addEventListener: function() {} }; },
|
||||
ontouchstart: opts.touch === false ? undefined : null,
|
||||
},
|
||||
CustomEvent: function(type, init) { this.type = type; this.detail = (init || {}).detail; },
|
||||
};
|
||||
ctx.window.location = ctx.location;
|
||||
ctx.window.localStorage = ctx.localStorage;
|
||||
ctx.window.document = ctx.document;
|
||||
ctx.self = ctx.window;
|
||||
ctx.globalThis = ctx;
|
||||
|
||||
vm.createContext(ctx);
|
||||
return { ctx, elements, wsInstances, listeners };
|
||||
}
|
||||
|
||||
function loadApp(box) {
|
||||
const src = fs.readFileSync('public/app.js', 'utf8');
|
||||
vm.runInContext(src, box.ctx);
|
||||
}
|
||||
|
||||
console.log('\n=== pullReconnect helper exists ===');
|
||||
test('pullReconnect is exposed on window', () => {
|
||||
const box = makeSandbox();
|
||||
loadApp(box);
|
||||
assert.strictEqual(typeof box.ctx.window.pullReconnect, 'function',
|
||||
'window.pullReconnect must be a function');
|
||||
});
|
||||
|
||||
console.log('\n=== setupPullToReconnect exists ===');
|
||||
test('setupPullToReconnect is exposed on window', () => {
|
||||
const box = makeSandbox();
|
||||
loadApp(box);
|
||||
assert.strictEqual(typeof box.ctx.window.setupPullToReconnect, 'function',
|
||||
'window.setupPullToReconnect must be a function');
|
||||
});
|
||||
|
||||
console.log('\n=== pullReconnect closes existing WS ===');
|
||||
test('calling pullReconnect() closes the current WebSocket', () => {
|
||||
const box = makeSandbox();
|
||||
loadApp(box);
|
||||
// app.js does NOT call connectWS until DOMContentLoaded. Force one:
|
||||
box.ctx.window.connectWS && box.ctx.window.connectWS();
|
||||
// If app.js doesn't expose connectWS, fall back to invoking pullReconnect
|
||||
// and checking that something tries to open a new socket.
|
||||
const beforeCount = box.wsInstances.length;
|
||||
box.ctx.window.pullReconnect();
|
||||
// Either: existing WS got closed, OR a new WS was opened (reconnect)
|
||||
const closed = box.wsInstances.some(function(w) { return w.closed; });
|
||||
const opened = box.wsInstances.length > beforeCount;
|
||||
assert.ok(closed || opened,
|
||||
'pullReconnect must close the WS or open a new one (got closed=' + closed + ', opened=' + opened + ')');
|
||||
});
|
||||
|
||||
console.log('\n=== setupPullToReconnect wires document touch listeners ===');
|
||||
test('setupPullToReconnect attaches touchstart listener', () => {
|
||||
const box = makeSandbox();
|
||||
loadApp(box);
|
||||
box.ctx.window.setupPullToReconnect();
|
||||
assert.ok((box.listeners['touchstart'] || []).length > 0,
|
||||
'touchstart listener must be attached to document');
|
||||
assert.ok((box.listeners['touchmove'] || []).length > 0,
|
||||
'touchmove listener must be attached to document');
|
||||
assert.ok((box.listeners['touchend'] || []).length > 0,
|
||||
'touchend listener must be attached to document');
|
||||
});
|
||||
|
||||
console.log('\n=== Pull gesture at scrollTop=0 triggers reconnect ===');
|
||||
test('pull-down past threshold at scrollTop=0 triggers pullReconnect', () => {
|
||||
const box = makeSandbox({ scrollTop: 0 });
|
||||
loadApp(box);
|
||||
box.ctx.window.connectWS && box.ctx.window.connectWS();
|
||||
box.ctx.window.setupPullToReconnect();
|
||||
|
||||
let triggered = false;
|
||||
const orig = box.ctx.window.pullReconnect;
|
||||
box.ctx.window.pullReconnect = function() { triggered = true; return orig.apply(this, arguments); };
|
||||
|
||||
function fire(name, y) {
|
||||
(box.listeners[name] || []).forEach(function(fn) {
|
||||
fn({ touches: [{ clientY: y }], changedTouches: [{ clientY: y }], preventDefault: function() {}, type: name });
|
||||
});
|
||||
}
|
||||
fire('touchstart', 10);
|
||||
fire('touchmove', 100);
|
||||
fire('touchmove', 200);
|
||||
fire('touchend', 200);
|
||||
|
||||
assert.ok(triggered, 'pullReconnect must be called after pull > threshold at scrollTop=0');
|
||||
});
|
||||
|
||||
console.log('\n=== Pull gesture when scrolled DOWN does NOT trigger ===');
|
||||
test('pull when scrollTop > 0 does NOT trigger pullReconnect', () => {
|
||||
const box = makeSandbox({ scrollTop: 500 });
|
||||
loadApp(box);
|
||||
box.ctx.window.connectWS && box.ctx.window.connectWS();
|
||||
box.ctx.window.setupPullToReconnect();
|
||||
|
||||
let triggered = false;
|
||||
box.ctx.window.pullReconnect = function() { triggered = true; };
|
||||
|
||||
function fire(name, y) {
|
||||
(box.listeners[name] || []).forEach(function(fn) {
|
||||
fn({ touches: [{ clientY: y }], changedTouches: [{ clientY: y }], preventDefault: function() {}, type: name });
|
||||
});
|
||||
}
|
||||
fire('touchstart', 10);
|
||||
fire('touchmove', 200);
|
||||
fire('touchend', 200);
|
||||
|
||||
assert.strictEqual(triggered, false,
|
||||
'pullReconnect must NOT fire when page is scrolled (scrollTop > 0)');
|
||||
});
|
||||
|
||||
console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===\n');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1060 / PR #1067 follow-up — touch targets behavior test.
|
||||
*
|
||||
* MAJOR-2 from pr-polish review: the previous version of this file
|
||||
* grep'd CSS strings, which is tautological — it asserted that the
|
||||
* source contained the literal characters that were just edited in.
|
||||
* It would have passed even if the CSS was syntactically broken or
|
||||
* if selectors didn't match any element on the real page.
|
||||
*
|
||||
* This rewrite loads public/style.css into a real Chromium page via
|
||||
* Playwright with an iPhone-class touch emulation context, renders
|
||||
* representative DOM samples for every selector we claim to harden,
|
||||
* and reads getBoundingClientRect()/getComputedStyle() to assert the
|
||||
* 48x48 minimum hit area. It also exercises the .sort-help tap-to-
|
||||
* reveal flow (focus event must un-hide the .sort-help-tip) since
|
||||
* MAJOR-1 is enforced both in markup (tabindex="0" in packets.js) and
|
||||
* in CSS (:focus / :focus-within rule in the Touch Targets section).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
const { chromium, devices } = require('playwright');
|
||||
|
||||
const REPO = __dirname;
|
||||
const CSS = fs.readFileSync(path.join(REPO, 'public/style.css'), 'utf8');
|
||||
|
||||
// Selectors we claim to make 48x48. Each entry: [selector, tag, classes,
|
||||
// optional inner-html]. Tag matters because some rules are scoped to
|
||||
// `button.ch-item` and some only apply to specific input[type=...].
|
||||
const BUTTON_SELECTORS = [
|
||||
['.btn', 'button', 'btn'],
|
||||
['.btn-icon', 'button', 'btn-icon'],
|
||||
['.nav-btn', 'button', 'nav-btn'],
|
||||
['.ch-icon-btn', 'button', 'ch-icon-btn'],
|
||||
['.ch-remove-btn', 'button', 'ch-remove-btn'],
|
||||
['.ch-share-btn', 'button', 'ch-share-btn'],
|
||||
['.ch-gear-btn', 'button', 'ch-gear-btn'],
|
||||
['.panel-close-btn', 'button', 'panel-close-btn'],
|
||||
['.mc-jump-btn', 'button', 'mc-jump-btn'],
|
||||
['button.ch-item', 'button', 'ch-item'],
|
||||
['.btn-link', 'button', 'btn-link'],
|
||||
['.col-toggle-btn', 'button', 'col-toggle-btn'],
|
||||
['.filter-toggle-btn', 'button', 'filter-toggle-btn'],
|
||||
['.ch-add-channel-btn', 'button', 'ch-add-channel-btn'],
|
||||
['.ch-back-btn', 'button', 'ch-back-btn'],
|
||||
['.ch-modal-btn-secondary','button', 'ch-modal-btn-secondary'],
|
||||
['.ch-scroll-btn', 'button', 'ch-scroll-btn'],
|
||||
['.chooser-btn', 'button', 'chooser-btn'],
|
||||
['.clock-filter-btn', 'button', 'clock-filter-btn'],
|
||||
['.compare-btn', 'button', 'compare-btn'],
|
||||
['.copy-link-btn', 'button', 'copy-link-btn'],
|
||||
['.alab-btn', 'button', 'alab-btn'],
|
||||
];
|
||||
|
||||
// Form controls. min-WIDTH is not enforced on these (text fields legitimately
|
||||
// span a wide column); we only require min-height: 48px.
|
||||
const FIELD_SELECTORS = [
|
||||
['select', 'select', '', '<option>x</option>'],
|
||||
['input[type=text]', 'input', '', null, { type: 'text' }],
|
||||
['input[type=search]', 'input', '', null, { type: 'search' }],
|
||||
['input[type=number]', 'input', '', null, { type: 'number' }],
|
||||
['input[type=email]', 'input', '', null, { type: 'email' }],
|
||||
['input[type=password]', 'input', '', null, { type: 'password' }],
|
||||
['input[type=tel]', 'input', '', null, { type: 'tel' }],
|
||||
['input[type=url]', 'input', '', null, { type: 'url' }],
|
||||
['input[type=date]', 'input', '', null, { type: 'date' }],
|
||||
['input[type=time]', 'input', '', null, { type: 'time' }],
|
||||
];
|
||||
|
||||
function buildSampleHtml() {
|
||||
const buttons = BUTTON_SELECTORS
|
||||
.map(([_, tag, cls]) => `<${tag} class="${cls}" data-sel="${cls}">x</${tag}>`)
|
||||
.join('\n ');
|
||||
const fields = FIELD_SELECTORS
|
||||
.map(([sel, tag, cls, inner, attrs]) => {
|
||||
const attrStr = attrs
|
||||
? Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(' ')
|
||||
: '';
|
||||
const open = `<${tag} ${attrStr} data-sel="${sel.replace(/[\[\]=]/g, '_')}">`;
|
||||
const close = tag === 'input' ? '' : `${inner || ''}</${tag}>`;
|
||||
return open + close;
|
||||
})
|
||||
.join('\n ');
|
||||
|
||||
// .sort-help sample mirrors the markup the JS produces (post-fix):
|
||||
// tabindex="0" so :focus-within can fire on touch tap.
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<style>${CSS}</style>
|
||||
</head><body>
|
||||
<div id="harness" style="padding: 16px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start;">
|
||||
${buttons}
|
||||
${fields}
|
||||
<span class="sort-help" id="sortHelp" tabindex="0" role="button" aria-label="Sort help">ⓘ
|
||||
<span class="sort-help-tip">Tip body</span>
|
||||
</span>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
} catch (err) {
|
||||
// Allow the test to be skipped on hosts where Chromium cannot launch
|
||||
// (e.g. some musl-libc dev boxes). CI uses standard glibc Ubuntu runners
|
||||
// where this path is never taken. Set TOUCH_TARGETS_REQUIRE=1 to force
|
||||
// a hard failure even when Chromium is unavailable.
|
||||
if (process.env.TOUCH_TARGETS_REQUIRE === '1') throw err;
|
||||
console.log(`test-touch-targets.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// iPhone 13 has hasTouch:true, isMobile:true, no hover. Exactly the
|
||||
// capability matrix that the @media (hover: hover) gate and 48px
|
||||
// minimums are designed for.
|
||||
const iPhone = devices['iPhone 13'];
|
||||
const context = await browser.newContext({ ...iPhone });
|
||||
const page = await context.newPage();
|
||||
|
||||
// Load the harness via a data: URL so we don't need a running server.
|
||||
const html = buildSampleHtml();
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
if (page.evaluate) {
|
||||
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function record(name, ok, detail) {
|
||||
if (ok) {
|
||||
console.log(` \u2705 ${name}`);
|
||||
} else {
|
||||
console.log(` \u274c ${name}: ${detail}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Buttons: rendered hit area must be at least 48x48 CSS px.
|
||||
for (const [selector, , cls] of BUTTON_SELECTORS) {
|
||||
const dim = await page.$eval(`[data-sel="${cls}"]`, (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
return { w: r.width, h: r.height, mh: cs.minHeight, mw: cs.minWidth };
|
||||
});
|
||||
const okH = dim.h >= 48;
|
||||
const okW = dim.w >= 48;
|
||||
record(`${selector}: rendered ${dim.w.toFixed(1)}x${dim.h.toFixed(1)} (min ${dim.mw}/${dim.mh})`,
|
||||
okH && okW,
|
||||
`expected >=48x48, got ${dim.w}x${dim.h}`);
|
||||
}
|
||||
|
||||
// --- Form controls: rendered height must be at least 48 CSS px.
|
||||
for (const [selector, , , , attrs] of FIELD_SELECTORS) {
|
||||
const dataKey = selector.replace(/[\[\]=]/g, '_');
|
||||
const dim = await page.$eval(`[data-sel="${dataKey}"]`, (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
return { h: r.height, mh: cs.minHeight };
|
||||
});
|
||||
record(`${selector}: rendered height ${dim.h.toFixed(1)} (min ${dim.mh})`,
|
||||
dim.h >= 48,
|
||||
`expected height >=48, got ${dim.h}`);
|
||||
}
|
||||
|
||||
// --- MAJOR-1 verification: .sort-help is keyboard/tap focusable AND the
|
||||
// tooltip becomes visible on focus (tap-to-reveal works without hover).
|
||||
const tabIndex = await page.$eval('#sortHelp', (el) => el.getAttribute('tabindex'));
|
||||
record('.sort-help has tabindex="0" in markup', tabIndex === '0',
|
||||
`expected "0", got ${JSON.stringify(tabIndex)}`);
|
||||
|
||||
const tipBeforeFocus = await page.$eval('#sortHelp .sort-help-tip',
|
||||
(el) => getComputedStyle(el).display);
|
||||
// CSS rule on touch-only viewport: hover-rule is gated, focus-rule reveals.
|
||||
record('.sort-help-tip is hidden by default on touch', tipBeforeFocus === 'none',
|
||||
`expected display:none initially, got ${tipBeforeFocus}`);
|
||||
|
||||
await page.focus('#sortHelp');
|
||||
const tipAfterFocus = await page.$eval('#sortHelp .sort-help-tip',
|
||||
(el) => getComputedStyle(el).display);
|
||||
record('.sort-help-tip becomes visible on focus (tap-to-reveal)',
|
||||
tipAfterFocus === 'block',
|
||||
`expected display:block after focus, got ${tipAfterFocus}`);
|
||||
|
||||
// --- Hover-only rule must be gated behind @media (hover: hover) so that on
|
||||
// touch the iPhone context never enters a "stuck hover" state when a tap
|
||||
// toggles :hover. We assert this by reading the matchMedia value the page
|
||||
// sees and confirming :hover did NOT take effect on tap.
|
||||
const hoverCapable = await page.evaluate(() => matchMedia('(hover: hover)').matches);
|
||||
record('iPhone context reports (hover: hover) = false', hoverCapable === false,
|
||||
`expected false on touch device, got ${hoverCapable}`);
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (failures > 0) {
|
||||
console.log(`\ntest-touch-targets.js: FAIL (${failures} assertion(s))`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\ntest-touch-targets.js: OK');
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error('test-touch-targets.js: fatal', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/* Unit tests for URL state helpers (issue #749) */
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const URLState = require('./public/url-state.js');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(' ✅ ' + name); }
|
||||
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
|
||||
}
|
||||
|
||||
console.log('── URL State Helpers ──');
|
||||
|
||||
// ------- parseSort -------
|
||||
test('parseSort: column only defaults to desc', function () {
|
||||
assert.deepStrictEqual(URLState.parseSort('time'), { column: 'time', direction: 'desc' });
|
||||
});
|
||||
test('parseSort: column:asc', function () {
|
||||
assert.deepStrictEqual(URLState.parseSort('lastSeen:asc'), { column: 'lastSeen', direction: 'asc' });
|
||||
});
|
||||
test('parseSort: column:desc', function () {
|
||||
assert.deepStrictEqual(URLState.parseSort('time:desc'), { column: 'time', direction: 'desc' });
|
||||
});
|
||||
test('parseSort: invalid direction → desc', function () {
|
||||
assert.deepStrictEqual(URLState.parseSort('time:weird'), { column: 'time', direction: 'desc' });
|
||||
});
|
||||
test('parseSort: empty/null → null', function () {
|
||||
assert.strictEqual(URLState.parseSort(''), null);
|
||||
assert.strictEqual(URLState.parseSort(null), null);
|
||||
assert.strictEqual(URLState.parseSort(undefined), null);
|
||||
});
|
||||
|
||||
// ------- serializeSort -------
|
||||
test('serializeSort: desc default omitted', function () {
|
||||
assert.strictEqual(URLState.serializeSort('time', 'desc'), 'time');
|
||||
});
|
||||
test('serializeSort: asc included', function () {
|
||||
assert.strictEqual(URLState.serializeSort('lastSeen', 'asc'), 'lastSeen:asc');
|
||||
});
|
||||
test('serializeSort: empty column → empty string', function () {
|
||||
assert.strictEqual(URLState.serializeSort('', 'desc'), '');
|
||||
assert.strictEqual(URLState.serializeSort(null, 'asc'), '');
|
||||
});
|
||||
|
||||
// ------- parseHash -------
|
||||
test('parseHash: bare route', function () {
|
||||
assert.deepStrictEqual(URLState.parseHash('#/packets'), { route: 'packets', params: {} });
|
||||
});
|
||||
test('parseHash: route with params', function () {
|
||||
var r = URLState.parseHash('#/packets?filter=type%3D%3DADVERT&sort=time');
|
||||
assert.strictEqual(r.route, 'packets');
|
||||
assert.strictEqual(r.params.filter, 'type==ADVERT');
|
||||
assert.strictEqual(r.params.sort, 'time');
|
||||
});
|
||||
test('parseHash: route with subpath kept (existing deep links)', function () {
|
||||
var r = URLState.parseHash('#/nodes/abc123def?tab=repeaters');
|
||||
assert.strictEqual(r.route, 'nodes/abc123def');
|
||||
assert.strictEqual(r.params.tab, 'repeaters');
|
||||
});
|
||||
test('parseHash: empty hash', function () {
|
||||
assert.deepStrictEqual(URLState.parseHash(''), { route: '', params: {} });
|
||||
assert.deepStrictEqual(URLState.parseHash('#/'), { route: '', params: {} });
|
||||
});
|
||||
|
||||
// ------- buildHash -------
|
||||
test('buildHash: bare route', function () {
|
||||
assert.strictEqual(URLState.buildHash('packets', {}), '#/packets');
|
||||
});
|
||||
test('buildHash: with params, omits empty values', function () {
|
||||
var h = URLState.buildHash('packets', { filter: 'type==ADVERT', sort: '', empty: null, blank: undefined });
|
||||
assert.strictEqual(h, '#/packets?filter=type%3D%3DADVERT');
|
||||
});
|
||||
test('buildHash: encodes special chars', function () {
|
||||
var h = URLState.buildHash('analytics', { tab: 'topology', window: '7d' });
|
||||
// Order is preserved in object iteration
|
||||
assert.ok(h === '#/analytics?tab=topology&window=7d' || h === '#/analytics?window=7d&tab=topology');
|
||||
});
|
||||
test('buildHash: leading "#/" is OK on route, normalized', function () {
|
||||
assert.strictEqual(URLState.buildHash('#/packets', { sort: 'time' }), '#/packets?sort=time');
|
||||
});
|
||||
|
||||
// ------- updateHashParams -------
|
||||
test('updateHashParams: round-trip preserves route subpath', function () {
|
||||
// Simulate location.hash environment
|
||||
var fakeLocation = { hash: '#/nodes/abcdef?tab=repeaters' };
|
||||
var newHash = URLState.updateHashParams({ sort: 'lastSeen:asc' }, fakeLocation.hash);
|
||||
// Must keep the nodes/abcdef subpath
|
||||
var r = URLState.parseHash(newHash);
|
||||
assert.strictEqual(r.route, 'nodes/abcdef');
|
||||
assert.strictEqual(r.params.tab, 'repeaters');
|
||||
assert.strictEqual(r.params.sort, 'lastSeen:asc');
|
||||
});
|
||||
test('updateHashParams: setting empty/null removes key', function () {
|
||||
var newHash = URLState.updateHashParams({ tab: '' }, '#/nodes?tab=repeaters&search=foo');
|
||||
var r = URLState.parseHash(newHash);
|
||||
assert.strictEqual(r.params.tab, undefined);
|
||||
assert.strictEqual(r.params.search, 'foo');
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log(passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user