Compare commits

..

1 Commits

Author SHA1 Message Date
you dbf89b12af feat(packets): clear-filters button (#964)
Add a "✕ Clear" button in the packets filter bar that appears when any
filter is active. Clicking it resets all filters (hash, node, observer,
type, channel, wireshark expression, region, My Nodes), clears
localStorage filter entries, resets all DOM inputs/multi-selects, updates
the URL hash to plain #/packets, and triggers a table re-render.

The button visibility is managed by updatePacketsUrl() which runs after
every filter change, so it automatically appears/disappears as filters
are toggled.

Closes #964
2026-05-02 18:27:04 +00:00
88 changed files with 297 additions and 7081 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"93 passed","color":"brightgreen"}
{"schemaVersion":1,"label":"e2e tests","message":"89 passed","color":"brightgreen"}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"40.01%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"40.21%","color":"red"}
-6
View File
@@ -79,12 +79,6 @@ jobs:
go test ./...
echo "--- Decrypt CLI tests passed ---"
- name: Run JS unit tests (packet-filter)
run: |
set -e
node test-packet-filter.js
node test-channel-decrypt-insecure-context.js
- name: Verify proto syntax
run: |
set -e
-7
View File
@@ -1,12 +1,5 @@
# Changelog
## [3.7.2] — 2026-05-06
Hotfix release branched from `v3.7.1`. Cherry-picks PR #1121 only — no other changes.
### 🐛 Bug Fixes
- **Ingestor: backfill infinite loop on `path_json='[]'` rows** (#1119, #1121) — `BackfillPathJSONAsync` re-selected observations whose `path_json` was already `'[]'`, rewrote them to `'[]'`, and looped forever. The migration marker was never recorded and the ingestor sustained 23 MB/s WAL writes at idle (~76% CPU in `sqlite.Exec`). Fix: drop `'[]'` from the WHERE clause so the loop terminates after one full pass and the `backfill_path_json_from_raw_hex_v1` marker is written.
## [2.5.0] "Digital Rain" — 2026-03-22
### ✨ Matrix Mode — Full Cyberpunk Map Theme
-2
View File
@@ -15,7 +15,6 @@ COPY cmd/server/go.mod cmd/server/go.sum ./
COPY internal/geofilter/ ../../internal/geofilter/
COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
COPY internal/dbconfig/ ../../internal/dbconfig/
RUN go mod download
COPY cmd/server/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
@@ -27,7 +26,6 @@ COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./
COPY internal/geofilter/ ../../internal/geofilter/
COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
COPY internal/dbconfig/ ../../internal/dbconfig/
RUN go mod download
COPY cmd/ingestor/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
+5 -43
View File
@@ -9,7 +9,6 @@ import (
"strings"
"sync"
"github.com/meshcore-analyzer/dbconfig"
"github.com/meshcore-analyzer/geofilter"
)
@@ -22,17 +21,6 @@ type MQTTSource struct {
RejectUnauthorized *bool `json:"rejectUnauthorized,omitempty"`
Topics []string `json:"topics"`
IATAFilter []string `json:"iataFilter,omitempty"`
ConnectTimeoutSec int `json:"connectTimeoutSec,omitempty"`
Region string `json:"region,omitempty"`
}
// ConnectTimeoutOrDefault returns the per-source connect timeout in seconds,
// or 30 if not set (matching the WaitTimeout default from #926).
func (s MQTTSource) ConnectTimeoutOrDefault() int {
if s.ConnectTimeoutSec > 0 {
return s.ConnectTimeoutSec
}
return 30
}
// MQTTLegacy is the old single-broker config format.
@@ -56,16 +44,6 @@ type Config struct {
ValidateSignatures *bool `json:"validateSignatures,omitempty"`
DB *DBConfig `json:"db,omitempty"`
// ObserverIATAWhitelist restricts which observer IATA regions are processed.
// When non-empty, only observers whose IATA code (from the MQTT topic) matches
// one of these entries are accepted. Case-insensitive. An empty list means all
// IATA codes are allowed. This applies globally, unlike the per-source iataFilter.
ObserverIATAWhitelist []string `json:"observerIATAWhitelist,omitempty"`
// obsIATAWhitelistCached is the lazily-built uppercase set for O(1) lookups.
obsIATAWhitelistCached map[string]bool
obsIATAWhitelistOnce sync.Once
// ObserverBlacklist is a list of observer public keys to drop at ingest.
// Messages from blacklisted observers are silently discarded — no DB writes,
// no UpsertObserver, no observations, no metrics.
@@ -91,8 +69,11 @@ type MetricsConfig struct {
SampleIntervalSec int `json:"sampleIntervalSec"`
}
// DBConfig is the shared SQLite vacuum/maintenance config (#919, #921).
type DBConfig = dbconfig.DBConfig
// DBConfig controls SQLite vacuum and maintenance behavior (#919).
type DBConfig struct {
VacuumOnStartup bool `json:"vacuumOnStartup"` // one-time full VACUUM on startup if auto_vacuum is not INCREMENTAL
IncrementalVacuumPages int `json:"incrementalVacuumPages"` // pages returned to OS per reaper cycle (default 1024)
}
// IncrementalVacuumPages returns the configured pages per vacuum or 1024 default.
func (c *Config) IncrementalVacuumPages() int {
@@ -161,25 +142,6 @@ func (c *Config) IsObserverBlacklisted(id string) bool {
return c.obsBlacklistSetCached[strings.ToLower(strings.TrimSpace(id))]
}
// IsObserverIATAAllowed returns true if the given IATA code is permitted.
// When ObserverIATAWhitelist is empty, all codes are allowed.
func (c *Config) IsObserverIATAAllowed(iata string) bool {
if c == nil || len(c.ObserverIATAWhitelist) == 0 {
return true
}
c.obsIATAWhitelistOnce.Do(func() {
m := make(map[string]bool, len(c.ObserverIATAWhitelist))
for _, code := range c.ObserverIATAWhitelist {
trimmed := strings.ToUpper(strings.TrimSpace(code))
if trimmed != "" {
m[trimmed] = true
}
}
c.obsIATAWhitelistCached = m
})
return c.obsIATAWhitelistCached[strings.ToUpper(strings.TrimSpace(iata))]
}
// LoadConfig reads configuration from a JSON file, with env var overrides.
// If the config file does not exist, sensible defaults are used (zero-config startup).
func LoadConfig(path string) (*Config, error) {
-110
View File
@@ -284,113 +284,3 @@ func TestLoadConfigWithAllFields(t *testing.T) {
t.Errorf("iataFilter=%v", src.IATAFilter)
}
}
func TestConnectTimeoutOrDefault(t *testing.T) {
// Default when unset
s := MQTTSource{}
if got := s.ConnectTimeoutOrDefault(); got != 30 {
t.Errorf("default: got %d, want 30", got)
}
// Custom value
s.ConnectTimeoutSec = 5
if got := s.ConnectTimeoutOrDefault(); got != 5 {
t.Errorf("custom: got %d, want 5", got)
}
// Zero treated as unset
s.ConnectTimeoutSec = 0
if got := s.ConnectTimeoutOrDefault(); got != 30 {
t.Errorf("zero: got %d, want 30", got)
}
}
func TestConnectTimeoutFromJSON(t *testing.T) {
dir := t.TempDir()
cfgPath := dir + "/config.json"
os.WriteFile(cfgPath, []byte(`{"mqttSources":[{"name":"s1","broker":"tcp://b:1883","topics":["#"],"connectTimeoutSec":5}]}`), 0644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if got := cfg.MQTTSources[0].ConnectTimeoutOrDefault(); got != 5 {
t.Errorf("from JSON: got %d, want 5", got)
}
}
func TestObserverIATAWhitelist(t *testing.T) {
// Config with whitelist set
cfg := Config{
ObserverIATAWhitelist: []string{"ARN", "got"},
}
// Matching (case-insensitive)
if !cfg.IsObserverIATAAllowed("ARN") {
t.Error("ARN should be allowed")
}
if !cfg.IsObserverIATAAllowed("arn") {
t.Error("arn (lowercase) should be allowed")
}
if !cfg.IsObserverIATAAllowed("GOT") {
t.Error("GOT should be allowed")
}
// Non-matching
if cfg.IsObserverIATAAllowed("SJC") {
t.Error("SJC should NOT be allowed")
}
// Empty string not allowed
if cfg.IsObserverIATAAllowed("") {
t.Error("empty IATA should NOT be allowed")
}
}
func TestObserverIATAWhitelistEmpty(t *testing.T) {
// No whitelist = allow all
cfg := Config{}
if !cfg.IsObserverIATAAllowed("SJC") {
t.Error("with no whitelist, all IATAs should be allowed")
}
if !cfg.IsObserverIATAAllowed("") {
t.Error("with no whitelist, even empty IATA should be allowed")
}
}
func TestObserverIATAWhitelistJSON(t *testing.T) {
json := `{
"dbPath": "test.db",
"observerIATAWhitelist": ["ARN", "GOT"]
}`
tmp := t.TempDir() + "/config.json"
os.WriteFile(tmp, []byte(json), 0644)
cfg, err := LoadConfig(tmp)
if err != nil {
t.Fatal(err)
}
if len(cfg.ObserverIATAWhitelist) != 2 {
t.Fatalf("expected 2 entries, got %d", len(cfg.ObserverIATAWhitelist))
}
if !cfg.IsObserverIATAAllowed("ARN") {
t.Error("ARN should be allowed after loading from JSON")
}
}
func TestMQTTSourceRegionField(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{
"dbPath": "/tmp/test.db",
"mqttSources": [
{"name": "cascadia", "broker": "tcp://localhost:1883", "topics": ["meshcore/#"], "region": "PDX"}
]
}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.MQTTSources[0].Region != "PDX" {
t.Fatalf("expected region PDX, got %q", cfg.MQTTSources[0].Region)
}
}
+4 -144
View File
@@ -8,7 +8,6 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
@@ -45,7 +44,6 @@ type Store struct {
stmtUpsertMetrics *sql.Stmt
sampleIntervalSec int
backfillWg sync.WaitGroup
}
// OpenStore opens or creates a SQLite DB at the given path, applying the
@@ -118,8 +116,7 @@ func applySchema(db *sql.DB) error {
battery_mv INTEGER,
uptime_secs INTEGER,
noise_floor REAL,
inactive INTEGER DEFAULT 0,
last_packet_at TEXT DEFAULT NULL
inactive INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen);
@@ -424,45 +421,6 @@ func applySchema(db *sql.DB) error {
log.Println("[migration] observations.raw_hex column added")
}
// Migration: add last_packet_at column to observers (#last-packet-at)
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'observers_last_packet_at_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Adding last_packet_at column to observers...")
_, alterErr := db.Exec(`ALTER TABLE observers ADD COLUMN last_packet_at TEXT DEFAULT NULL`)
if alterErr != nil && !strings.Contains(alterErr.Error(), "duplicate column") {
return fmt.Errorf("observers last_packet_at ALTER: %w", alterErr)
}
// Backfill: set last_packet_at = last_seen only for observers that actually have
// observation rows (packet_count alone is unreliable — UpsertObserver sets it to 1
// on INSERT even for status-only observers).
res, err := db.Exec(`UPDATE observers SET last_packet_at = last_seen
WHERE last_packet_at IS NULL
AND rowid IN (SELECT DISTINCT observer_idx FROM observations WHERE observer_idx IS NOT NULL)`)
if err == nil {
n, _ := res.RowsAffected()
log.Printf("[migration] Backfilled last_packet_at for %d observers with packets", n)
}
db.Exec(`INSERT INTO _migrations (name) VALUES ('observers_last_packet_at_v1')`)
log.Println("[migration] observers.last_packet_at column added")
}
// Migration: backfill observations.path_json from raw_hex (#888)
// NOTE: This runs ASYNC via BackfillPathJSONAsync() to avoid blocking MQTT startup.
// See staging outage where ~502K rows blocked ingest for 15+ hours.
// One-time cleanup: delete legacy packets with empty hash or empty first_seen (#994)
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'cleanup_legacy_null_hash_ts'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Cleaning up legacy packets with empty hash/timestamp...")
db.Exec(`DELETE FROM observations WHERE transmission_id IN (SELECT id FROM transmissions WHERE hash = '' OR first_seen = '')`)
res, err := db.Exec(`DELETE FROM transmissions WHERE hash = '' OR first_seen = ''`)
if err == nil {
deleted, _ := res.RowsAffected()
log.Printf("[migration] deleted %d legacy packets with empty hash/timestamp", deleted)
}
db.Exec(`INSERT INTO _migrations (name) VALUES ('cleanup_legacy_null_hash_ts')`)
}
return nil
}
@@ -546,7 +504,7 @@ func (s *Store) prepareStatements() error {
return err
}
s.stmtUpdateObserverLastSeen, err = s.db.Prepare("UPDATE observers SET last_seen = ?, last_packet_at = ? WHERE rowid = ?")
s.stmtUpdateObserverLastSeen, err = s.db.Prepare("UPDATE observers SET last_seen = ? WHERE rowid = ?")
if err != nil {
return err
}
@@ -625,9 +583,9 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
err := s.stmtGetObserverRowid.QueryRow(data.ObserverID).Scan(&rowid)
if err == nil {
observerIdx = &rowid
// Update observer last_seen and last_packet_at on every packet to prevent
// Update observer last_seen on every packet to prevent
// low-traffic observers from appearing offline (#463)
_, _ = s.stmtUpdateObserverLastSeen.Exec(now, now, rowid)
_, _ = s.stmtUpdateObserverLastSeen.Exec(now, rowid)
}
}
@@ -756,7 +714,6 @@ func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error
// Close checkpoints the WAL and closes the database.
func (s *Store) Close() error {
s.backfillWg.Wait()
s.Checkpoint()
return s.db.Close()
}
@@ -896,94 +853,6 @@ func (s *Store) Checkpoint() {
}
}
// BackfillPathJSONAsync launches the path_json backfill in a background goroutine.
// It processes observations with NULL/empty path_json that have raw_hex available,
// decoding hop paths and updating the column. Safe to run concurrently with ingest
// because new observations get path_json at write time; this only touches NULL rows.
// Idempotent: skips if migration already recorded.
func (s *Store) BackfillPathJSONAsync() {
s.backfillWg.Add(1)
go func() {
defer s.backfillWg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[backfill] path_json async panic recovered: %v", r)
}
}()
var migDone int
row := s.db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'backfill_path_json_from_raw_hex_v1'")
if row.Scan(&migDone) == nil {
return // already done
}
log.Println("[backfill] Starting async path_json backfill from raw_hex...")
updated := 0
errored := false
const batchSize = 1000
batchNum := 0
for {
rows, err := s.db.Query(`
SELECT o.id, o.raw_hex
FROM observations o
JOIN transmissions t ON o.transmission_id = t.id
WHERE o.raw_hex IS NOT NULL AND o.raw_hex != ''
-- NB: '[]' is the "already attempted, no hops" sentinel; excluded
-- to prevent the infinite re-UPDATE loop fixed in #1119.
AND (o.path_json IS NULL OR o.path_json = '')
AND t.payload_type != 9
LIMIT ?`, batchSize)
if err != nil {
log.Printf("[backfill] path_json query error: %v", err)
errored = true
break
}
type pendingRow struct {
id int64
rawHex string
}
var batch []pendingRow
for rows.Next() {
var r pendingRow
if err := rows.Scan(&r.id, &r.rawHex); err == nil {
batch = append(batch, r)
}
}
rows.Close()
if len(batch) == 0 {
break
}
for _, r := range batch {
hops, err := packetpath.DecodePathFromRawHex(r.rawHex)
if err != nil || len(hops) == 0 {
if _, execErr := s.db.Exec(`UPDATE observations SET path_json = '[]' WHERE id = ?`, r.id); execErr != nil {
log.Printf("[backfill] write error (id=%d): %v", r.id, execErr)
}
continue
}
b, _ := json.Marshal(hops)
if _, execErr := s.db.Exec(`UPDATE observations SET path_json = ? WHERE id = ?`, string(b), r.id); execErr != nil {
log.Printf("[backfill] write error (id=%d): %v", r.id, execErr)
} else {
updated++
}
}
batchNum++
if batchNum%50 == 0 {
log.Printf("[backfill] progress: %d observations updated so far (%d batches)", updated, batchNum)
}
// Throttle: yield to ingest writers between batches
time.Sleep(50 * time.Millisecond)
}
log.Printf("[backfill] Async path_json backfill complete: %d observations updated", updated)
if !errored {
s.db.Exec(`INSERT INTO _migrations (name) VALUES ('backfill_path_json_from_raw_hex_v1')`)
} else {
log.Printf("[backfill] NOT recording migration due to errors — will retry on next restart")
}
}()
}
// LogStats logs current operational metrics.
func (s *Store) LogStats() {
log.Printf("[stats] tx_inserted=%d tx_dupes=%d obs_inserted=%d node_upserts=%d observer_upserts=%d write_errors=%d sig_drops=%d",
@@ -1107,7 +976,6 @@ type PacketData struct {
PathJSON string
DecodedJSON string
ChannelHash string // grouping key for channel queries (#762)
Region string // observer region: payload > topic > source config (#788)
}
// nilIfEmpty returns nil for empty strings (for nullable DB columns).
@@ -1126,7 +994,6 @@ type MQTTPacketMessage struct {
Score *float64 `json:"score"`
Direction *string `json:"direction"`
Origin string `json:"origin"`
Region string `json:"region,omitempty"` // optional region override (#788)
}
// BuildPacketData constructs a PacketData from a decoded packet and MQTT message.
@@ -1166,13 +1033,6 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
DecodedJSON: PayloadJSON(&decoded.Payload),
}
// Region priority: payload field > topic-derived parameter (#788)
if msg.Region != "" {
pd.Region = msg.Region
} else {
pd.Region = region
}
// Populate channel_hash for fast channel queries (#762)
if decoded.Header.PayloadType == PayloadGRP_TXT {
if decoded.Payload.Type == "CHAN" && decoded.Payload.Channel != "" {
-595
View File
@@ -569,61 +569,6 @@ func TestInsertTransmissionUpdatesObserverLastSeen(t *testing.T) {
}
}
func TestLastPacketAtUpdatedOnPacketOnly(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
// Insert observer via status path — last_packet_at should be NULL
if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil {
t.Fatal(err)
}
var lastPacketAt sql.NullString
s.db.QueryRow("SELECT last_packet_at FROM observers WHERE id = ?", "obs1").Scan(&lastPacketAt)
if lastPacketAt.Valid {
t.Fatalf("expected last_packet_at to be NULL after UpsertObserver, got %s", lastPacketAt.String)
}
// Insert a packet from this observer — last_packet_at should be set
data := &PacketData{
RawHex: "0A00D69F",
Timestamp: "2026-04-24T12:00:00Z",
ObserverID: "obs1",
Hash: "lastpackettest123456",
RouteType: 2,
PayloadType: 2,
PathJSON: "[]",
DecodedJSON: `{"type":"TXT_MSG"}`,
}
if _, err := s.InsertTransmission(data); err != nil {
t.Fatal(err)
}
s.db.QueryRow("SELECT last_packet_at FROM observers WHERE id = ?", "obs1").Scan(&lastPacketAt)
if !lastPacketAt.Valid {
t.Fatal("expected last_packet_at to be non-NULL after InsertTransmission")
}
// InsertTransmission uses `now = data.Timestamp || time.Now()`, so last_packet_at
// should match the packet's Timestamp when provided (same source-of-truth as last_seen).
if lastPacketAt.String != "2026-04-24T12:00:00Z" {
t.Errorf("expected last_packet_at=2026-04-24T12:00:00Z, got %s", lastPacketAt.String)
}
// UpsertObserver again (status path) — last_packet_at should NOT change
if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil {
t.Fatal(err)
}
var lastPacketAtAfterStatus sql.NullString
s.db.QueryRow("SELECT last_packet_at FROM observers WHERE id = ?", "obs1").Scan(&lastPacketAtAfterStatus)
if !lastPacketAtAfterStatus.Valid || lastPacketAtAfterStatus.String != lastPacketAt.String {
t.Errorf("UpsertObserver should not change last_packet_at; expected %s, got %v", lastPacketAt.String, lastPacketAtAfterStatus)
}
}
func TestEndToEndIngest(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
@@ -2178,543 +2123,3 @@ func TestBuildPacketData_NonTracePathJSON(t *testing.T) {
t.Errorf("path_json = %s, want %s", pd.PathJSON, expectedPathJSON)
}
}
// --- Issue #888: Backfill path_json from raw_hex ---
func TestBackfillPathJsonFromRawHex(t *testing.T) {
dbPath := tempDBPath(t)
s, err := OpenStore(dbPath)
if err != nil {
t.Fatal(err)
}
// Insert a transmission with payload_type != TRACE (e.g. 0x01)
// raw_hex: header 0x05 (route FLOOD, payload 0x01), path byte 0x42 (hash_size=2, count=2),
// hops: AABB, CCDD, then some payload bytes
rawHex := "0542AABBCCDD0000000000000000000000000000"
s.db.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type) VALUES (?, 'h1', '2025-01-01T00:00:00Z', 1)`, rawHex)
// Insert observation with raw_hex but empty path_json
s.db.Exec(`INSERT INTO observations (transmission_id, timestamp, raw_hex, path_json) VALUES (1, 1000, ?, '[]')`, rawHex)
// Insert observation with raw_hex and NULL path_json
s.db.Exec(`INSERT INTO observations (transmission_id, timestamp, raw_hex, path_json) VALUES (1, 1001, ?, NULL)`, rawHex)
// Insert observation with existing path_json (should NOT be overwritten)
s.db.Exec(`INSERT INTO observations (transmission_id, timestamp, raw_hex, path_json) VALUES (1, 1002, ?, '["XX","YY"]')`, rawHex)
// Insert a TRACE transmission (payload_type = 0x09) — should be skipped
traceRaw := "2604302D0D2359FEE7B100000000006733D63367"
s.db.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type) VALUES (?, 'h2', '2025-01-01T00:00:00Z', 9)`, traceRaw)
s.db.Exec(`INSERT INTO observations (transmission_id, timestamp, raw_hex, path_json) VALUES (2, 1003, ?, '[]')`, traceRaw)
// Remove the migration marker so it runs again on reopen
s.db.Exec(`DELETE FROM _migrations WHERE name = 'backfill_path_json_from_raw_hex_v1'`)
s.Close()
// Reopen — backfill is now async, must trigger explicitly
s2, err := OpenStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
// Trigger async backfill and wait for completion
s2.BackfillPathJSONAsync()
deadline := time.Now().Add(10 * time.Second)
var migCount int
for time.Now().Before(deadline) {
s2.db.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = 'backfill_path_json_from_raw_hex_v1'").Scan(&migCount)
if migCount == 1 {
break
}
time.Sleep(50 * time.Millisecond)
}
if migCount != 1 {
t.Fatalf("migration not recorded")
}
// Row 1 (was '[]') is NOT re-processed by the backfill — '[]' means
// "already attempted, no hops" and is excluded by the WHERE to avoid the
// infinite-loop bug fixed in #1119. It must remain '[]'.
var pj1 string
s2.db.QueryRow("SELECT path_json FROM observations WHERE id = 1").Scan(&pj1)
if pj1 != "[]" {
t.Errorf("row 1 path_json = %q, want %q (must not re-process '[]' rows after #1119)", pj1, "[]")
}
// Row 2 (was NULL) should now have decoded hops
var pj2 string
s2.db.QueryRow("SELECT path_json FROM observations WHERE id = 2").Scan(&pj2)
if pj2 != `["AABB","CCDD"]` {
t.Errorf("row 2 path_json = %q, want %q", pj2, `["AABB","CCDD"]`)
}
// Row 3 (had existing data) should NOT be overwritten
var pj3 string
s2.db.QueryRow("SELECT path_json FROM observations WHERE id = 3").Scan(&pj3)
if pj3 != `["XX","YY"]` {
t.Errorf("row 3 path_json = %q, want %q (should not be overwritten)", pj3, `["XX","YY"]`)
}
// Row 4 (TRACE) should NOT be updated
var pj4 string
s2.db.QueryRow("SELECT path_json FROM observations WHERE id = 4").Scan(&pj4)
if pj4 != "[]" {
t.Errorf("row 4 (TRACE) path_json = %q, want %q (should be skipped)", pj4, "[]")
}
}
func TestCleanupLegacyNullHashTimestamp(t *testing.T) {
path := tempDBPath(t)
// Create a bare-bones DB with legacy bad data
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatal(err)
}
db.Exec(`CREATE TABLE IF NOT EXISTS transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
channel_hash TEXT DEFAULT NULL
)`)
db.Exec(`CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_idx INTEGER,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL
)`)
db.Exec(`CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY)`)
db.Exec(`CREATE TABLE IF NOT EXISTS nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL)`)
db.Exec(`CREATE TABLE IF NOT EXISTS observers (id TEXT PRIMARY KEY, name TEXT, iata TEXT, last_seen TEXT, first_seen TEXT, packet_count INTEGER DEFAULT 0, model TEXT, firmware TEXT, client_version TEXT, radio TEXT, battery_mv INTEGER, uptime_secs INTEGER, noise_floor REAL, inactive INTEGER DEFAULT 0, last_packet_at TEXT DEFAULT NULL)`)
// Insert good transmission
db.Exec(`INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (1, 'aabb', 'abc123', '2024-01-01T00:00:00Z')`)
db.Exec(`INSERT INTO observations (transmission_id, observer_idx, timestamp) VALUES (1, 1, 1704067200)`)
// Insert bad: empty hash
db.Exec(`INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (2, 'ccdd', '', '2024-01-01T00:00:00Z')`)
db.Exec(`INSERT INTO observations (transmission_id, observer_idx, timestamp) VALUES (2, 1, 1704067200)`)
// Insert bad: empty first_seen
db.Exec(`INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (3, 'eeff', 'def456', '')`)
db.Exec(`INSERT INTO observations (transmission_id, observer_idx, timestamp) VALUES (3, 2, 1704067200)`)
db.Close()
// Now open via OpenStore which should run the migration
s, err := OpenStore(path)
if err != nil {
t.Fatal(err)
}
defer s.Close()
// Good transmission should remain
var count int
s.db.QueryRow("SELECT COUNT(*) FROM transmissions WHERE id = 1").Scan(&count)
if count != 1 {
t.Error("good transmission should not be deleted")
}
// Bad transmissions should be gone
s.db.QueryRow("SELECT COUNT(*) FROM transmissions WHERE id = 2").Scan(&count)
if count != 0 {
t.Errorf("transmission with empty hash should be deleted, got count=%d", count)
}
s.db.QueryRow("SELECT COUNT(*) FROM transmissions WHERE id = 3").Scan(&count)
if count != 0 {
t.Errorf("transmission with empty first_seen should be deleted, got count=%d", count)
}
// Observations for bad transmissions should be gone
s.db.QueryRow("SELECT COUNT(*) FROM observations WHERE transmission_id IN (2, 3)").Scan(&count)
if count != 0 {
t.Errorf("observations for bad transmissions should be deleted, got count=%d", count)
}
// Observation for good transmission should remain
s.db.QueryRow("SELECT COUNT(*) FROM observations WHERE transmission_id = 1").Scan(&count)
if count != 1 {
t.Error("observation for good transmission should remain")
}
// Migration marker should exist
var migCount int
s.db.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = 'cleanup_legacy_null_hash_ts'").Scan(&migCount)
if migCount != 1 {
t.Error("migration marker cleanup_legacy_null_hash_ts should be recorded")
}
// Idempotent: opening again should not error
s.Close()
s2, err := OpenStore(path)
if err != nil {
t.Fatal("second open should not fail:", err)
}
s2.Close()
}
func TestBuildPacketDataRegionFromPayload(t *testing.T) {
msg := &MQTTPacketMessage{Raw: "0102030405060708", Region: "PDX"}
decoded := &DecodedPacket{
Header: Header{RouteType: 1, PayloadType: 3},
}
pkt := BuildPacketData(msg, decoded, "obs1", "SJC")
// When payload has region, it should override the topic-derived region
if pkt.Region != "PDX" {
t.Fatalf("expected region PDX from payload, got %q", pkt.Region)
}
}
func TestBuildPacketDataRegionFallsBackToTopic(t *testing.T) {
msg := &MQTTPacketMessage{Raw: "0102030405060708"}
decoded := &DecodedPacket{
Header: Header{RouteType: 1, PayloadType: 3},
}
pkt := BuildPacketData(msg, decoded, "obs1", "SJC")
if pkt.Region != "SJC" {
t.Fatalf("expected region SJC from topic, got %q", pkt.Region)
}
}
// TestBackfillPathJSONAsync verifies that the path_json backfill does NOT block
// OpenStore from returning. MQTT connect happens immediately after OpenStore;
// if the backfill is synchronous, MQTT would be delayed indefinitely on large DBs.
// This test creates pending backfill rows, opens the store, and asserts that
// OpenStore returns before the migration is recorded — proving async execution.
func TestBackfillPathJSONAsync(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "async_test.db")
// Bootstrap schema manually so we can insert test data BEFORE OpenStore
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatal(err)
}
// Create tables manually (minimal schema for this test)
_, err = db.Exec(`
CREATE TABLE _migrations (name TEXT PRIMARY KEY);
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
channel_hash TEXT
);
CREATE TABLE observers (
id TEXT PRIMARY KEY,
name TEXT,
iata TEXT,
last_seen TEXT,
first_seen TEXT,
packet_count INTEGER DEFAULT 0,
model TEXT,
firmware TEXT,
client_version TEXT,
radio TEXT,
battery_mv INTEGER,
uptime_secs INTEGER,
noise_floor REAL,
inactive INTEGER DEFAULT 0,
last_packet_at TEXT
);
CREATE TABLE nodes (
public_key TEXT PRIMARY KEY,
name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, first_seen TEXT, advert_count INTEGER DEFAULT 0,
battery_mv INTEGER, temperature_c REAL
);
CREATE TABLE inactive_nodes (
public_key TEXT PRIMARY KEY,
name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, first_seen TEXT, advert_count INTEGER DEFAULT 0,
battery_mv INTEGER, temperature_c REAL
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_idx INTEGER,
direction TEXT,
snr REAL, rssi REAL, score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL,
raw_hex TEXT
);
CREATE UNIQUE INDEX idx_observations_dedup ON observations(transmission_id, observer_idx, COALESCE(path_json, ''));
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX idx_observations_observer_idx ON observations(observer_idx);
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
CREATE TABLE observer_metrics (
observer_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
noise_floor REAL, tx_air_secs INTEGER, rx_air_secs INTEGER,
recv_errors INTEGER, battery_mv INTEGER,
packets_sent INTEGER, packets_recv INTEGER,
PRIMARY KEY (observer_id, timestamp)
);
CREATE TABLE dropped_packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT, raw_hex TEXT, reason TEXT NOT NULL,
observer_id TEXT, observer_name TEXT,
node_pubkey TEXT, node_name TEXT,
dropped_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`)
if err != nil {
t.Fatal("bootstrap schema:", err)
}
// Mark all migrations as done EXCEPT the path_json backfill
for _, m := range []string{
"advert_count_unique_v1", "noise_floor_real_v1", "node_telemetry_v1",
"obs_timestamp_index_v1", "observer_metrics_v1", "observer_metrics_ts_idx",
"observers_inactive_v1", "observer_metrics_packets_v1", "channel_hash_v1",
"dropped_packets_v1", "observations_raw_hex_v1", "observers_last_packet_at_v1",
"cleanup_legacy_null_hash_ts",
} {
db.Exec(`INSERT INTO _migrations (name) VALUES (?)`, m)
}
// Insert a transmission + observations with NULL path_json and valid raw_hex
// raw_hex "0102AABBCCDD0000" has 2-hop path decodable by packetpath
rawHex := "41020304AABBCCDD05060708"
_, err = db.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type) VALUES (?, 'hash1', '2025-01-01T00:00:00Z', 4)`, rawHex)
if err != nil {
t.Fatal("insert tx:", err)
}
// Insert 100 observations needing backfill
for i := 0; i < 100; i++ {
_, err = db.Exec(`INSERT INTO observations (transmission_id, observer_idx, timestamp, raw_hex, path_json) VALUES (1, ?, ?, ?, NULL)`,
i+1, 1700000000+i, rawHex)
if err != nil {
// dedup index might fire — use unique observer_idx
t.Fatalf("insert obs %d: %v", i, err)
}
}
db.Close()
// Now open store via OpenStore — this must return QUICKLY (non-blocking)
start := time.Now()
store, err := OpenStoreWithInterval(dbPath, 300)
elapsed := time.Since(start)
if err != nil {
t.Fatal("OpenStore:", err)
}
defer store.Close()
// OpenStore must return in under 2 seconds (backfill is no longer in applySchema)
if elapsed > 2*time.Second {
t.Fatalf("OpenStore blocked for %v — backfill must not run in applySchema", elapsed)
}
// Backfill must NOT be recorded yet — it hasn't been triggered
var done int
err = store.db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'backfill_path_json_from_raw_hex_v1'").Scan(&done)
if err == nil {
t.Fatal("migration recorded during OpenStore — backfill must be async via BackfillPathJSONAsync()")
}
// Now trigger the async backfill (simulates what main.go does after OpenStore)
store.BackfillPathJSONAsync()
// Wait for backfill to complete (should be very fast with 100 rows)
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
err = store.db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'backfill_path_json_from_raw_hex_v1'").Scan(&done)
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
if err != nil {
t.Fatal("backfill never completed within 10s")
}
// Verify backfill actually worked — observations should have non-NULL path_json
var nullCount int
store.db.QueryRow("SELECT COUNT(*) FROM observations WHERE path_json IS NULL").Scan(&nullCount)
if nullCount > 0 {
t.Errorf("backfill left %d observations with NULL path_json", nullCount)
}
}
// TestBackfillPathJSONAsyncMethodExists verifies the async backfill API surface
// exists — BackfillPathJSONAsync must be callable independently from OpenStore.
func TestBackfillPathJSONAsyncMethodExists(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "method_test.db")
store, err := OpenStoreWithInterval(dbPath, 300)
if err != nil {
t.Fatal(err)
}
defer store.Close()
// BackfillPathJSONAsync must exist as a method on *Store
// This is a compile-time check — if the method doesn't exist, the test won't compile.
store.BackfillPathJSONAsync()
}
// TestBackfillPathJSONAsync_BracketRowsTerminate exercises the infinite-loop bug
// from issue #1119. Observations whose path_json is already '[]' (meaning a prior
// backfill pass attempted to decode them and found no hops) must NOT be re-selected
// by the WHERE clause — otherwise the loop rewrites the same '[]' value forever
// and never records the migration marker.
//
// This test seeds N rows with path_json='[]' and a raw_hex that DecodePathFromRawHex
// resolves to zero hops. With the bug, the backfill loops infinitely re-UPDATEing
// the same rows back to '[]', batch is never empty, migration marker is never
// written. With the fix, no rows match → the very first batch is empty → migration
// is recorded immediately.
func TestBackfillPathJSONAsync_BracketRowsTerminate(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "bracket_terminate.db")
// Bootstrap a minimal schema directly so we can seed pre-existing '[]' rows
// before OpenStore runs.
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE _migrations (name TEXT PRIMARY KEY);
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
channel_hash TEXT
);
CREATE TABLE observers (
id TEXT PRIMARY KEY, name TEXT, iata TEXT,
last_seen TEXT, first_seen TEXT, packet_count INTEGER DEFAULT 0,
model TEXT, firmware TEXT, client_version TEXT, radio TEXT,
battery_mv INTEGER, uptime_secs INTEGER, noise_floor REAL,
inactive INTEGER DEFAULT 0, last_packet_at TEXT
);
CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL
);
CREATE TABLE inactive_nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_idx INTEGER, direction TEXT,
snr REAL, rssi REAL, score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL,
raw_hex TEXT
);
CREATE UNIQUE INDEX idx_observations_dedup ON observations(transmission_id, observer_idx, COALESCE(path_json, ''));
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX idx_observations_observer_idx ON observations(observer_idx);
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
CREATE TABLE observer_metrics (
observer_id TEXT NOT NULL, timestamp TEXT NOT NULL,
noise_floor REAL, tx_air_secs INTEGER, rx_air_secs INTEGER,
recv_errors INTEGER, battery_mv INTEGER,
packets_sent INTEGER, packets_recv INTEGER,
PRIMARY KEY (observer_id, timestamp)
);
CREATE TABLE dropped_packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT, raw_hex TEXT, reason TEXT NOT NULL,
observer_id TEXT, observer_name TEXT,
node_pubkey TEXT, node_name TEXT,
dropped_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`)
if err != nil {
t.Fatal("bootstrap schema:", err)
}
// Mark all migrations done EXCEPT backfill_path_json_from_raw_hex_v1.
for _, m := range []string{
"advert_count_unique_v1", "noise_floor_real_v1", "node_telemetry_v1",
"obs_timestamp_index_v1", "observer_metrics_v1", "observer_metrics_ts_idx",
"observers_inactive_v1", "observer_metrics_packets_v1", "channel_hash_v1",
"dropped_packets_v1", "observations_raw_hex_v1", "observers_last_packet_at_v1",
"cleanup_legacy_null_hash_ts",
} {
db.Exec(`INSERT INTO _migrations (name) VALUES (?)`, m)
}
// raw_hex producing ZERO hops via DecodePathFromRawHex:
// DIRECT route (type=2), payload_type=2, version=0 → header 0x0A; path byte 0x00.
// (See internal/packetpath/path_test.go: TestDecodePathFromRawHex_ZeroHops.)
rawHex := "0A00DEADBEEF"
_, err = db.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type) VALUES (?, 'h_brackets', '2025-01-01T00:00:00Z', 2)`, rawHex)
if err != nil {
t.Fatal("insert tx:", err)
}
const seedCount = 100
for i := 0; i < seedCount; i++ {
_, err = db.Exec(`INSERT INTO observations (transmission_id, observer_idx, timestamp, raw_hex, path_json) VALUES (1, ?, ?, ?, '[]')`,
i+1, 1700000000+i, rawHex)
if err != nil {
t.Fatalf("insert obs %d: %v", i, err)
}
}
db.Close()
store, err := OpenStoreWithInterval(dbPath, 300)
if err != nil {
t.Fatal("OpenStore:", err)
}
defer store.Close()
// Trigger backfill. With the bug, every iteration re-fetches all 100 rows
// (because '[]' matches the WHERE), rewrites them to '[]', sleeps 50ms, repeats.
// The loop never terminates and the migration marker is never written.
store.BackfillPathJSONAsync()
// Generous deadline: with the fix the marker is written essentially immediately.
// With the bug the marker is never written within any bounded time.
deadline := time.Now().Add(5 * time.Second)
var done int
for time.Now().Before(deadline) {
err = store.db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'backfill_path_json_from_raw_hex_v1'").Scan(&done)
if err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err != nil {
t.Fatalf("issue #1119: backfill never recorded migration marker within 5s — infinite loop on path_json='[]' rows")
}
// Verify the seeded '[]' rows still have '[]' (sanity — neither bug nor fix
// should change their value), and that there are no NULL/empty path_json rows
// the backfill should have processed.
var bracketCount int
store.db.QueryRow("SELECT COUNT(*) FROM observations WHERE path_json = '[]'").Scan(&bracketCount)
if bracketCount != seedCount {
t.Errorf("expected %d rows with path_json='[]', got %d", seedCount, bracketCount)
}
}
-19
View File
@@ -131,7 +131,6 @@ type Payload struct {
SenderTimestamp uint32 `json:"sender_timestamp,omitempty"`
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
PathData string `json:"pathData,omitempty"`
SNRValues []float64 `json:"snrValues,omitempty"`
Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
TraceFlags *int `json:"traceFlags,omitempty"`
@@ -600,9 +599,6 @@ func DecodePacket(hexString string, channelKeys map[string]string, validateSigna
// We expose hopsCompleted (count of SNR bytes) so consumers can distinguish
// how far the trace got vs the full intended route.
var anomaly string
if header.PayloadType == PayloadTRACE && payload.Error != "" {
anomaly = fmt.Sprintf("TRACE payload decode failed: %s", payload.Error)
}
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
// Flag anomalous routing — firmware only sends TRACE as DIRECT
if header.RouteType != RouteDirect && header.RouteType != RouteTransportDirect {
@@ -610,21 +606,6 @@ func DecodePacket(hexString string, channelKeys map[string]string, validateSigna
}
// The header path hops count represents SNR entries = completed hops
hopsCompleted := path.HashCount
// Extract per-hop SNR from header path bytes (int8, quarter-dB encoding).
// Mirrors cmd/server/decoder.go — must be done at ingest time so SNR
// values are persisted in decoded_json (server endpoint serves DB as-is).
if hopsCompleted > 0 && len(path.Hops) >= hopsCompleted {
snrVals := make([]float64, 0, hopsCompleted)
for i := 0; i < hopsCompleted; i++ {
b, err := hex.DecodeString(path.Hops[i])
if err == nil && len(b) == 1 {
snrVals = append(snrVals, float64(int8(b[0]))/4.0)
}
}
if len(snrVals) > 0 {
payload.SNRValues = snrVals
}
}
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && payload.TraceFlags != nil {
// path_sz from flags byte is a power-of-two exponent per firmware:
-50
View File
@@ -1926,53 +1926,3 @@ func TestDecodePathFromRawHex_Transport(t *testing.T) {
}
}
}
func TestDecodeTracePayloadFailSetsAnomaly(t *testing.T) {
// Issue #889: TRACE packet with payload too short to decode (< 9 bytes)
// should still return a DecodedPacket (observation stored) but with Anomaly
// set to warn operators that the decode was degraded.
// Packet: header 0x26 (TRACE+DIRECT), pathByte 0x00, payload 4 bytes (too short).
pkt, err := DecodePacket("2600aabbccdd", nil, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if pkt.Payload.Type != "TRACE" {
t.Fatalf("payload type=%s, want TRACE", pkt.Payload.Type)
}
if pkt.Payload.Error == "" {
t.Fatal("expected payload.Error to indicate decode failure")
}
// The key assertion: Anomaly must be set when TRACE decode fails
if pkt.Anomaly == "" {
t.Error("expected Anomaly to be set when TRACE payload decode fails but observation is stored")
}
}
// TestDecodeTraceExtractsSNRValues verifies that for TRACE packets, the header
// path bytes are interpreted as int8 SNR values (quarter-dB) and exposed via
// payload.SNRValues. Mirrors logic in cmd/server/decoder.go (issue: SNR values
// extracted by server but never written into decoded_json by ingestor).
//
// Packet 26022FF8116A23A80000000001C0DE1000DEDE:
// header 0x26 → TRACE (pt=9), DIRECT (rt=2)
// pathByte 0x02 → hash_size=1, hash_count=2
// header path: 2F F8 → SNR = [int8(0x2F)/4, int8(0xF8)/4] = [11.75, -2.0]
// payload (15B): tag=116A23A8 auth=00000000 flags=0x01 pathData=C0DE1000DEDE
func TestDecodeTraceExtractsSNRValues(t *testing.T) {
pkt, err := DecodePacket("26022FF8116A23A80000000001C0DE1000DEDE", nil, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if pkt.Payload.Type != "TRACE" {
t.Fatalf("payload type=%s, want TRACE", pkt.Payload.Type)
}
if len(pkt.Payload.SNRValues) != 2 {
t.Fatalf("len(SNRValues)=%d, want 2 (got %v)", len(pkt.Payload.SNRValues), pkt.Payload.SNRValues)
}
if pkt.Payload.SNRValues[0] != 11.75 {
t.Errorf("SNRValues[0]=%v, want 11.75", pkt.Payload.SNRValues[0])
}
if pkt.Payload.SNRValues[1] != -2.0 {
t.Errorf("SNRValues[1]=%v, want -2.0", pkt.Payload.SNRValues[1])
}
}
-4
View File
@@ -17,10 +17,6 @@ require github.com/meshcore-analyzer/packetpath v0.0.0
replace github.com/meshcore-analyzer/packetpath => ../../internal/packetpath
require github.com/meshcore-analyzer/dbconfig v0.0.0
replace github.com/meshcore-analyzer/dbconfig => ../../internal/dbconfig
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
+6 -55
View File
@@ -57,9 +57,6 @@ func main() {
defer store.Close()
log.Printf("SQLite opened: %s", cfg.DBPath)
// Async backfill: path_json from raw_hex (#888) — must not block MQTT startup
store.BackfillPathJSONAsync()
// Check auto_vacuum mode and optionally migrate (#919)
store.CheckAutoVacuum(cfg)
@@ -126,7 +123,6 @@ func main() {
// Connect to each MQTT source
var clients []mqtt.Client
connectedCount := 0
for _, source := range sources {
tag := source.Name
if tag == "" {
@@ -134,8 +130,6 @@ func main() {
}
opts := buildMQTTOpts(source)
connectTimeout := source.ConnectTimeoutOrDefault()
log.Printf("MQTT [%s] connect timeout: %ds", tag, connectTimeout)
opts.SetOnConnectHandler(func(c mqtt.Client) {
log.Printf("MQTT [%s] connected to %s", tag, source.Broker)
@@ -170,43 +164,19 @@ func main() {
client := mqtt.NewClient(opts)
token := client.Connect()
// With ConnectRetry=true, token.Wait() blocks forever for unreachable brokers.
// WaitTimeout lets startup proceed; the client keeps retrying in the background
// and OnConnect fires (subscribing) when it eventually connects (#910).
if !token.WaitTimeout(time.Duration(connectTimeout) * time.Second) {
log.Printf("MQTT [%s] initial connection timed out — retrying in background", tag)
clients = append(clients, client)
continue
}
token.Wait()
if token.Error() != nil {
log.Printf("MQTT [%s] connection failed (non-fatal): %v", tag, token.Error())
// BL1 fix: Disconnect to stop Paho's internal retry goroutines.
// With ConnectRetry=true, Connect() spawns background goroutines
// that leak if the client is simply discarded.
client.Disconnect(0)
continue
}
connectedCount++
clients = append(clients, client)
}
// BL2 fix: require at least one immediately-connected source. Timed-out
// clients are retrying in background (tracked in clients) but don't count
// as "connected" — a single unreachable broker must not silently run with
// zero active connections.
if connectedCount == 0 {
// Clean up any timed-out clients still retrying
for _, c := range clients {
c.Disconnect(0)
}
log.Fatal("no MQTT sources connected — all timed out or failed. Check broker is running (default: mqtt://localhost:1883). Set MQTT_BROKER env var or configure mqttSources in config.json")
if len(clients) == 0 {
log.Fatal("no MQTT connections established — check broker is running (default: mqtt://localhost:1883). Set MQTT_BROKER env var or configure mqttSources in config.json")
}
if connectedCount < len(clients) {
log.Printf("Running — %d MQTT source(s) connected, %d retrying in background", connectedCount, len(clients)-connectedCount)
} else {
log.Printf("Running — %d MQTT source(s) connected", connectedCount)
}
log.Printf("Running — %d MQTT source(s) connected", len(clients))
// Wait for shutdown signal
sig := make(chan os.Signal, 1)
@@ -277,14 +247,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
return
}
// Global observer IATA whitelist: if configured, drop messages from observers
// in non-whitelisted IATA regions. Applies to ALL message types (status + packets).
if len(parts) > 1 && !cfg.IsObserverIATAAllowed(parts[1]) {
return
}
// Status topic: meshcore/<region>/<observer_id>/status
// Per-source IATA filter does NOT apply here — observer metadata (noise_floor, battery, etc.)
// IATA filter does NOT apply here — observer metadata (noise_floor, battery, etc.)
// is region-independent and should be accepted from all observers regardless of
// which IATA regions are configured for packet ingestion.
if len(parts) >= 4 && parts[3] == "status" {
@@ -348,16 +312,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
if len(parts) > 1 {
region = parts[1]
}
// Fallback to source-level region config when topic has no region (#788)
if region == "" && source.Region != "" {
region = source.Region
}
mqttMsg := &MQTTPacketMessage{Raw: rawHex}
// Parse optional region from JSON payload (#788)
if v, ok := msg["region"].(string); ok && v != "" {
mqttMsg.Region = v
}
if v, ok := msg["SNR"]; ok {
if f, ok := toFloat64(v); ok {
mqttMsg.SNR = &f
@@ -457,12 +413,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
// Upsert observer
if observerID != "" {
origin, _ := msg["origin"].(string)
// Use effective region: payload > topic > source config (#788)
effectiveRegion := region
if mqttMsg.Region != "" {
effectiveRegion = mqttMsg.Region
}
if err := store.UpsertObserver(observerID, origin, effectiveRegion, nil); err != nil {
if err := store.UpsertObserver(observerID, origin, region, nil); err != nil {
log.Printf("MQTT [%s] observer upsert error: %v", tag, err)
}
}
-155
View File
@@ -5,11 +5,8 @@ import (
"math"
"os"
"path/filepath"
"runtime"
"testing"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
func TestToFloat64(t *testing.T) {
@@ -783,155 +780,3 @@ func TestIATAFilterDoesNotDropStatusMessages(t *testing.T) {
t.Error("packet from out-of-region BFL should still be filtered by IATA")
}
}
// TestMQTTConnectRetryTimeoutDoesNotBlock verifies that WaitTimeout returns within
// the deadline for an unreachable broker when ConnectRetry=true (#910). Previously,
// token.Wait() would block forever in this configuration.
func TestMQTTConnectRetryTimeoutDoesNotBlock(t *testing.T) {
opts := mqtt.NewClientOptions().
AddBroker("tcp://127.0.0.1:1"). // port 1 — nothing listening, fast refusal
SetConnectRetry(true).
SetAutoReconnect(true)
client := mqtt.NewClient(opts)
token := client.Connect()
defer client.Disconnect(100)
start := time.Now()
connected := token.WaitTimeout(3 * time.Second)
elapsed := time.Since(start)
if connected {
t.Skip("port 1 unexpectedly accepted a connection — skipping")
}
if elapsed > 4*time.Second {
t.Errorf("WaitTimeout blocked for %v — token.Wait() would block forever with ConnectRetry=true", elapsed)
}
}
// TestBL1_GoroutineLeakOnHardFailure reproduces BLOCKER 1: without Disconnect()
// on the error path, Paho's internal retry goroutines leak when a client is
// discarded after Connect() with ConnectRetry=true.
//
// We prove the leak by creating N clients WITHOUT Disconnect — goroutines grow
// proportionally. The fix (client.Disconnect(0) before continue) prevents this.
func TestBL1_GoroutineLeakOnHardFailure(t *testing.T) {
runtime.GC()
time.Sleep(100 * time.Millisecond)
baseline := runtime.NumGoroutine()
// Create multiple clients connected to unreachable broker, WITHOUT disconnecting.
// Each one spawns Paho retry goroutines that accumulate.
const numClients = 10
clients := make([]mqtt.Client, numClients)
for i := 0; i < numClients; i++ {
opts := mqtt.NewClientOptions().
AddBroker("tcp://127.0.0.1:1").
SetConnectRetry(true).
SetAutoReconnect(true).
SetConnectTimeout(500 * time.Millisecond)
c := mqtt.NewClient(opts)
tok := c.Connect()
tok.WaitTimeout(1 * time.Second)
clients[i] = c
}
time.Sleep(200 * time.Millisecond)
leaked := runtime.NumGoroutine()
goroutineGrowth := leaked - baseline
// Clean up to not actually leak in test
for _, c := range clients {
c.Disconnect(0)
}
t.Logf("baseline=%d, after %d undisconnected clients=%d, growth=%d",
baseline, numClients, leaked, goroutineGrowth)
// With ConnectRetry=true, each Connect() spawns retry goroutines.
// Without Disconnect, these accumulate. Verify growth is meaningful.
if goroutineGrowth < 3 {
t.Skip("Connect didn't spawn enough extra goroutines to measure leak")
}
// The fix: calling client.Disconnect(0) on the error path prevents accumulation.
// Anti-tautology: removing the Disconnect(0) call from main.go's error path
// would cause goroutine accumulation proportional to failed broker count.
t.Logf("CONFIRMED: %d leaked goroutines from %d clients without Disconnect — fix adds Disconnect(0) on error path", goroutineGrowth, numClients)
}
// TestBL2_ZeroConnectedFatals verifies BLOCKER 2: when all brokers are unreachable,
// connectedCount==0 must be detected. We test the logic directly — if only timed-out
// clients exist (appended to clients slice) but connectedCount is 0, the guard triggers.
func TestBL2_ZeroConnectedFatals(t *testing.T) {
// Simulate the connection loop result: 1 timed-out client, 0 connected
var clients []mqtt.Client
connectedCount := 0
// Create a client that times out (unreachable broker)
opts := mqtt.NewClientOptions().
AddBroker("tcp://127.0.0.1:1").
SetConnectRetry(true).
SetAutoReconnect(true)
client := mqtt.NewClient(opts)
token := client.Connect()
if !token.WaitTimeout(2 * time.Second) {
// Timed out — PR #926 appends to clients
clients = append(clients, client)
}
defer func() {
for _, c := range clients {
c.Disconnect(0)
}
}()
// OLD bug: len(clients) == 0 would be false (1 timed-out client in list)
// → ingestor would silently run with zero connections
if len(clients) == 0 {
t.Fatal("expected timed-out client to be in clients slice")
}
// NEW fix: connectedCount == 0 catches this
if connectedCount != 0 {
t.Errorf("connectedCount should be 0, got %d", connectedCount)
}
// The real code does: if connectedCount == 0 { log.Fatal(...) }
// This test proves len(clients) > 0 but connectedCount == 0 — the old guard
// would have missed it.
if len(clients) > 0 && connectedCount == 0 {
t.Log("BL2 confirmed: old guard len(clients)==0 would NOT fatal; new guard connectedCount==0 correctly catches zero-connected state")
}
}
func TestHandleMessageObserverIATAWhitelist(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test"}
cfg := &Config{
ObserverIATAWhitelist: []string{"ARN"},
}
// Message from non-whitelisted region GOT — should be dropped
handleMessage(store, "test", source, &mockMessage{
topic: "meshcore/GOT/obs1/status",
payload: []byte(`{"origin":"node1","noise_floor":-110}`),
}, nil, cfg)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id='obs1'").Scan(&count)
if count != 0 {
t.Error("observer from non-whitelisted IATA GOT should be dropped")
}
// Message from whitelisted region ARN — should be accepted
handleMessage(store, "test", source, &mockMessage{
topic: "meshcore/ARN/obs2/status",
payload: []byte(`{"origin":"node2","noise_floor":-105}`),
}, nil, cfg)
store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id='obs2'").Scan(&count)
if count != 1 {
t.Errorf("observer from whitelisted IATA ARN should be accepted, got count=%d", count)
}
}
-89
View File
@@ -1,89 +0,0 @@
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// handleBackup streams a consistent SQLite snapshot of the analyzer DB.
//
// Requires API-key authentication (mounted via requireAPIKey in routes.go).
//
// Strategy: SQLite's `VACUUM INTO 'path'` produces an atomic, defragmented
// copy of the current database into a new file. It runs at READ ISOLATION
// against the source DB (works on our read-only connection) and never
// blocks concurrent writers — the ingestor keeps writing to the WAL while
// the snapshot is taken from a consistent read transaction.
//
// Response:
//
// 200 OK
// Content-Type: application/octet-stream
// Content-Disposition: attachment; filename="corescope-backup-<unix>.db"
// <body: complete SQLite database file>
//
// The temp file is removed after the response is fully written, regardless
// of whether the client successfully consumed the stream.
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
if s.db == nil || s.db.conn == nil {
writeError(w, http.StatusServiceUnavailable, "database unavailable")
return
}
ts := time.Now().UTC().Unix()
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
log.Printf("[backup] generating backup for client %s", clientIP)
// Stage the snapshot in the OS temp dir so we never touch the live DB
// directory (avoids confusing operators / accidental WAL clobber).
tmpDir, err := os.MkdirTemp("", "corescope-backup-")
if err != nil {
writeError(w, http.StatusInternalServerError, "tempdir failed: "+err.Error())
return
}
defer func() {
if rmErr := os.RemoveAll(tmpDir); rmErr != nil {
log.Printf("[backup] cleanup error: %v", rmErr)
}
}()
snapshotPath := filepath.Join(tmpDir, fmt.Sprintf("corescope-backup-%d.db", ts))
// SQLite parses the path literal — escape any single quotes defensively.
// (mkdtemp output won't contain quotes, but be paranoid for future-proofing.)
escaped := strings.ReplaceAll(snapshotPath, "'", "''")
if _, err := s.db.conn.ExecContext(r.Context(), fmt.Sprintf("VACUUM INTO '%s'", escaped)); err != nil {
writeError(w, http.StatusInternalServerError, "snapshot failed: "+err.Error())
return
}
f, err := os.Open(snapshotPath)
if err != nil {
writeError(w, http.StatusInternalServerError, "open snapshot failed: "+err.Error())
return
}
defer f.Close()
stat, err := f.Stat()
if err == nil {
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"corescope-backup-%d.db\"", ts))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, f); err != nil {
// Headers already flushed; just log. Client will see truncated stream.
log.Printf("[backup] stream error: %v", err)
}
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// sqliteMagic is the 16-byte file header identifying a valid SQLite 3 database.
// See https://www.sqlite.org/fileformat.html#magic_header_string
const sqliteMagic = "SQLite format 3\x00"
func TestBackupRequiresAPIKey(t *testing.T) {
_, router := setupTestServerWithAPIKey(t, "test-secret-key-strong-enough")
req := httptest.NewRequest("GET", "/api/backup", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without API key, got %d (body: %s)", w.Code, w.Body.String())
}
}
func TestBackupReturnsValidSQLiteSnapshot(t *testing.T) {
const apiKey = "test-secret-key-strong-enough"
_, router := setupTestServerWithAPIKey(t, apiKey)
req := httptest.NewRequest("GET", "/api/backup", nil)
req.Header.Set("X-API-Key", apiKey)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if ct != "application/octet-stream" {
t.Errorf("expected Content-Type application/octet-stream, got %q", ct)
}
cd := w.Header().Get("Content-Disposition")
if !strings.HasPrefix(cd, "attachment;") || !strings.Contains(cd, "filename=\"corescope-backup-") || !strings.HasSuffix(cd, ".db\"") {
t.Errorf("expected Content-Disposition attachment with corescope-backup-<ts>.db filename, got %q", cd)
}
body := w.Body.Bytes()
if len(body) < len(sqliteMagic) {
t.Fatalf("backup body too short (%d bytes) — expected SQLite file", len(body))
}
if got := string(body[:len(sqliteMagic)]); got != sqliteMagic {
t.Fatalf("expected SQLite magic header %q, got %q", sqliteMagic, got)
}
}
-168
View File
@@ -1,168 +0,0 @@
package main
import (
"encoding/json"
"testing"
"time"
)
var _ = time.Second // suppress unused import
// Helper to create a minimal PacketStore with GRP_TXT packets for channel analytics testing.
func newChannelTestStore(packets []*StoreTx) *PacketStore {
ps := &PacketStore{
packets: packets,
byHash: make(map[string]*StoreTx),
byTxID: make(map[int]*StoreTx),
byObsID: make(map[int]*StoreObs),
byObserver: make(map[string][]*StoreObs),
byNode: make(map[string][]*StoreTx),
byPathHop: make(map[string][]*StoreTx),
nodeHashes: make(map[string]map[string]bool),
byPayloadType: make(map[int][]*StoreTx),
rfCache: make(map[string]*cachedResult),
topoCache: make(map[string]*cachedResult),
hashCache: make(map[string]*cachedResult),
collisionCache: make(map[string]*cachedResult),
chanCache: make(map[string]*cachedResult),
distCache: make(map[string]*cachedResult),
subpathCache: make(map[string]*cachedResult),
spIndex: make(map[string]int),
spTxIndex: make(map[string][]*StoreTx),
advertPubkeys: make(map[string]int),
lastSeenTouched: make(map[string]time.Time),
clockSkew: NewClockSkewEngine(),
}
ps.byPayloadType[5] = packets
return ps
}
func makeGrpTx(channelHash int, channel, text, sender string) *StoreTx {
decoded := map[string]interface{}{
"type": "CHAN",
"channelHash": float64(channelHash),
"channel": channel,
"text": text,
"sender": sender,
}
b, _ := json.Marshal(decoded)
pt := 5
return &StoreTx{
ID: 1,
DecodedJSON: string(b),
FirstSeen: "2026-05-01T12:00:00Z",
PayloadType: &pt,
}
}
// TestComputeAnalyticsChannels_MergesEncryptedAndDecrypted verifies that packets
// with the same hash byte but different decryption status merge into ONE bucket.
func TestComputeAnalyticsChannels_MergesEncryptedAndDecrypted(t *testing.T) {
// Hash 129 is the real hash for #wardriving: SHA256(SHA256("#wardriving")[:16])[0] = 129
// Some packets are decrypted (have channel name), some are not (encrypted)
packets := []*StoreTx{
makeGrpTx(129, "#wardriving", "hello", "alice"),
makeGrpTx(129, "#wardriving", "world", "bob"),
makeGrpTx(129, "", "", ""), // encrypted — no channel name
makeGrpTx(129, "", "", ""), // encrypted
}
store := newChannelTestStore(packets)
result := store.computeAnalyticsChannels("", TimeWindow{})
channels := result["channels"].([]map[string]interface{})
if len(channels) != 1 {
t.Fatalf("expected 1 channel bucket, got %d: %+v", len(channels), channels)
}
ch := channels[0]
if ch["name"] != "#wardriving" {
t.Errorf("expected name '#wardriving', got %q", ch["name"])
}
if ch["messages"] != 4 {
t.Errorf("expected 4 messages, got %v", ch["messages"])
}
if ch["encrypted"] != false {
t.Errorf("expected encrypted=false (some packets decrypted), got %v", ch["encrypted"])
}
}
// TestComputeAnalyticsChannels_RejectsRainbowTableMismatch verifies that a packet
// with channelHash=72 but channel="#wardriving" (mismatch) does NOT create a
// "#wardriving" bucket — it falls into "ch72" instead.
func TestComputeAnalyticsChannels_RejectsRainbowTableMismatch(t *testing.T) {
// Hash 72 is NOT the correct hash for #wardriving (which is 129).
// This simulates a rainbow-table collision/mismatch.
packets := []*StoreTx{
makeGrpTx(72, "#wardriving", "ghost", "eve"), // mismatch: hash 72 != wardriving's real hash
makeGrpTx(129, "#wardriving", "real", "alice"), // correct match
}
store := newChannelTestStore(packets)
result := store.computeAnalyticsChannels("", TimeWindow{})
channels := result["channels"].([]map[string]interface{})
if len(channels) != 2 {
t.Fatalf("expected 2 channel buckets, got %d: %+v", len(channels), channels)
}
// Find the buckets
var ch72, ch129 map[string]interface{}
for _, ch := range channels {
if ch["hash"] == "72" {
ch72 = ch
} else if ch["hash"] == "129" {
ch129 = ch
}
}
if ch72 == nil {
t.Fatal("expected a bucket for hash 72")
}
if ch129 == nil {
t.Fatal("expected a bucket for hash 129")
}
// ch72 should NOT be named "#wardriving" — it should be the placeholder
if ch72["name"] == "#wardriving" {
t.Errorf("hash 72 bucket should NOT be named '#wardriving' (rainbow-table mismatch rejected)")
}
if ch72["name"] != "ch72" {
t.Errorf("expected hash 72 bucket named 'ch72', got %q", ch72["name"])
}
// ch129 should be named "#wardriving"
if ch129["name"] != "#wardriving" {
t.Errorf("expected hash 129 bucket named '#wardriving', got %q", ch129["name"])
}
}
// TestChannelNameMatchesHash verifies the hash validation function.
func TestChannelNameMatchesHash(t *testing.T) {
// #wardriving hashes to 129
if !channelNameMatchesHash("#wardriving", "129") {
t.Error("expected #wardriving to match hash 129")
}
if channelNameMatchesHash("#wardriving", "72") {
t.Error("expected #wardriving to NOT match hash 72")
}
// Without leading # should also work
if !channelNameMatchesHash("wardriving", "129") {
t.Error("expected wardriving (without #) to match hash 129")
}
}
// TestIsPlaceholderName verifies placeholder detection.
func TestIsPlaceholderName(t *testing.T) {
if !isPlaceholderName("ch129") {
t.Error("ch129 should be placeholder")
}
if !isPlaceholderName("ch0") {
t.Error("ch0 should be placeholder")
}
if isPlaceholderName("#wardriving") {
t.Error("#wardriving should NOT be placeholder")
}
if isPlaceholderName("Public") {
t.Error("Public should NOT be placeholder")
}
}
+5 -8
View File
@@ -8,7 +8,6 @@ import (
"strings"
"sync"
"github.com/meshcore-analyzer/dbconfig"
"github.com/meshcore-analyzer/geofilter"
)
@@ -71,11 +70,6 @@ type Config struct {
Timestamps *TimestampConfig `json:"timestamps,omitempty"`
// CORSAllowedOrigins is the list of origins permitted to make cross-origin
// requests. When empty (default), no Access-Control-* headers are sent,
// so browsers enforce same-origin policy. Set to ["*"] to allow all origins.
CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"`
DebugAffinity bool `json:"debugAffinity,omitempty"`
// ObserverBlacklist is a list of observer public keys to exclude from API
@@ -146,8 +140,11 @@ type RetentionConfig struct {
MetricsDays int `json:"metricsDays"`
}
// DBConfig is the shared SQLite vacuum/maintenance config (#919, #921).
type DBConfig = dbconfig.DBConfig
// DBConfig controls SQLite vacuum and maintenance behavior (#919).
type DBConfig struct {
VacuumOnStartup bool `json:"vacuumOnStartup"` // one-time full VACUUM on startup if auto_vacuum is not INCREMENTAL
IncrementalVacuumPages int `json:"incrementalVacuumPages"` // pages returned to OS per reaper cycle (default 1024)
}
// IncrementalVacuumPages returns the configured pages per vacuum or 1024 default.
func (c *Config) IncrementalVacuumPages() int {
-66
View File
@@ -1,66 +0,0 @@
package main
import "net/http"
// corsMiddleware returns a middleware that sets CORS headers based on the
// configured allowed origins. When CORSAllowedOrigins is empty (default),
// no Access-Control-* headers are added, preserving browser same-origin policy.
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origins := s.cfg.CORSAllowedOrigins
if len(origins) == 0 {
next.ServeHTTP(w, r)
return
}
reqOrigin := r.Header.Get("Origin")
if reqOrigin == "" {
next.ServeHTTP(w, r)
return
}
// Check if origin is allowed
allowed := false
wildcard := false
for _, o := range origins {
if o == "*" {
allowed = true
wildcard = true
break
}
if o == reqOrigin {
allowed = true
break
}
}
if !allowed {
// Origin not in allowlist — don't add CORS headers
if r.Method == http.MethodOptions {
// Still reject preflight with 403
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
return
}
// Set CORS headers
if wildcard {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
w.Header().Set("Access-Control-Allow-Origin", reqOrigin)
w.Header().Set("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
-149
View File
@@ -1,149 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
// newTestServerWithCORS creates a minimal Server with the given CORS config.
func newTestServerWithCORS(origins []string) *Server {
cfg := &Config{CORSAllowedOrigins: origins}
srv := &Server{cfg: cfg}
return srv
}
// dummyHandler is a simple handler that writes 200 OK.
var dummyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
func TestCORS_DefaultNoHeaders(t *testing.T) {
srv := newTestServerWithCORS(nil)
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("GET", "/api/health", nil)
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("expected 200, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" {
t.Fatalf("expected no ACAO header, got %q", v)
}
}
func TestCORS_AllowlistMatch(t *testing.T) {
srv := newTestServerWithCORS([]string{"https://good.example"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("GET", "/api/health", nil)
req.Header.Set("Origin", "https://good.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("expected 200, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "https://good.example" {
t.Fatalf("expected origin echo, got %q", v)
}
if v := rr.Header().Get("Access-Control-Allow-Methods"); v != "GET, POST, OPTIONS" {
t.Fatalf("expected methods header, got %q", v)
}
if v := rr.Header().Get("Access-Control-Allow-Headers"); v != "Content-Type, X-API-Key" {
t.Fatalf("expected headers header, got %q", v)
}
if v := rr.Header().Get("Vary"); v != "Origin" {
t.Fatalf("expected Vary: Origin, got %q", v)
}
}
func TestCORS_AllowlistNoMatch(t *testing.T) {
srv := newTestServerWithCORS([]string{"https://good.example"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("GET", "/api/health", nil)
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("expected 200, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" {
t.Fatalf("expected no ACAO header for non-matching origin, got %q", v)
}
}
func TestCORS_PreflightAllowed(t *testing.T) {
srv := newTestServerWithCORS([]string{"https://good.example"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("OPTIONS", "/api/health", nil)
req.Header.Set("Origin", "https://good.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "https://good.example" {
t.Fatalf("expected origin echo, got %q", v)
}
}
func TestCORS_PreflightRejected(t *testing.T) {
srv := newTestServerWithCORS([]string{"https://good.example"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("OPTIONS", "/api/health", nil)
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", rr.Code)
}
}
func TestCORS_Wildcard(t *testing.T) {
srv := newTestServerWithCORS([]string{"*"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("GET", "/api/health", nil)
req.Header.Set("Origin", "https://anything.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("expected 200, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "*" {
t.Fatalf("expected *, got %q", v)
}
// Wildcard should NOT set Vary: Origin
if v := rr.Header().Get("Vary"); v == "Origin" {
t.Fatalf("wildcard should not set Vary: Origin")
}
}
func TestCORS_NoOriginHeader(t *testing.T) {
srv := newTestServerWithCORS([]string{"https://good.example"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("GET", "/api/health", nil)
// No Origin header
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("expected 200, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" {
t.Fatalf("expected no ACAO without Origin header, got %q", v)
}
}
+7 -7
View File
@@ -2498,9 +2498,9 @@ func TestStoreGetAnalyticsChannelsNumericHash(t *testing.T) {
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (5, 1, 10.0, -90, '[]', ?)`, recentEpoch)
// Also a decrypted CHAN with numeric channelHash — use hash 198 which is the real hash for #general
// Also a decrypted CHAN with numeric channelHash
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('DD03', 'chan_num_hash_3', ?, 1, 5, '{"type":"CHAN","channel":"general","channelHash":198,"channelHashHex":"C6","text":"hello","sender":"Alice"}')`, recent)
VALUES ('DD03', 'chan_num_hash_3', ?, 1, 5, '{"type":"CHAN","channel":"general","channelHash":97,"channelHashHex":"61","text":"hello","sender":"Alice"}')`, recent)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (6, 1, 12.0, -88, '[]', ?)`, recentEpoch)
@@ -2509,8 +2509,8 @@ func TestStoreGetAnalyticsChannelsNumericHash(t *testing.T) {
result := store.GetAnalyticsChannels("")
channels := result["channels"].([]map[string]interface{})
if len(channels) < 3 {
t.Errorf("expected at least 3 channels (hash 97 + hash 42 + hash 198), got %d", len(channels))
if len(channels) < 2 {
t.Errorf("expected at least 2 channels (hash 97 + hash 42), got %d", len(channels))
}
// Verify the numeric-hash channels we inserted have proper hashes (not "?")
@@ -2531,13 +2531,13 @@ func TestStoreGetAnalyticsChannelsNumericHash(t *testing.T) {
t.Error("expected to find channel with hash '42' (numeric channelHash parsing)")
}
// Verify the decrypted CHAN channel has the correct name (now at hash 198)
// Verify the decrypted CHAN channel has the correct name
foundGeneral := false
for _, ch := range channels {
if ch["name"] == "general" {
foundGeneral = true
if ch["hash"] != "198" {
t.Errorf("expected hash '198' for general channel, got %v", ch["hash"])
if ch["hash"] != "97" {
t.Errorf("expected hash '97' for general channel, got %v", ch["hash"])
}
}
}
+12 -74
View File
@@ -170,7 +170,6 @@ type Observer struct {
BatteryMv *int `json:"battery_mv"`
UptimeSecs *int64 `json:"uptime_secs"`
NoiseFloor *float64 `json:"noise_floor"`
LastPacketAt *string `json:"last_packet_at"`
}
// Transmission represents a row from the transmissions table.
@@ -831,55 +830,6 @@ func (db *DB) SearchNodes(query string, limit int) ([]map[string]interface{}, er
return nodes, nil
}
// GetNodeByPrefix resolves a hex prefix (>=8 chars) to a unique node.
// Returns (node, ambiguous, error). When multiple nodes share the prefix,
// returns (nil, true, nil). Used by the short-URL feature (issue #772).
//
// Trade-off vs an opaque ID lookup table: prefixes are stable across
// restarts, self-describing (no allocator needed), and resolve to the
// authoritative pubkey on the server. Cost: ambiguity grows with the
// node directory; we mitigate with a hard 8-hex-char (32-bit) minimum
// and surface 409 Conflict when collisions occur.
func (db *DB) GetNodeByPrefix(prefix string) (map[string]interface{}, bool, error) {
if len(prefix) < 8 {
return nil, false, nil
}
// Validate hex (avoid SQL LIKE wildcards leaking through).
for _, c := range prefix {
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
if !isHex {
return nil, false, nil
}
}
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 LIKE ? LIMIT 2`,
prefix+"%",
)
if err != nil {
return nil, false, err
}
defer rows.Close()
var first map[string]interface{}
count := 0
for rows.Next() {
n := scanNodeRow(rows)
if n == nil {
continue
}
count++
if count == 1 {
first = n
} else {
return nil, true, nil
}
}
if count == 0 {
return nil, false, nil
}
return first, false, nil
}
// 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)
@@ -1022,7 +972,7 @@ func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string]
// GetObservers returns active observers (not soft-deleted) sorted by last_seen DESC.
func (db *DB) GetObservers() ([]Observer, error) {
rows, err := db.conn.Query("SELECT id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, last_packet_at FROM observers WHERE inactive IS NULL OR inactive = 0 ORDER BY last_seen DESC")
rows, err := db.conn.Query("SELECT id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor FROM observers WHERE inactive IS NULL OR inactive = 0 ORDER BY last_seen DESC")
if err != nil {
return nil, err
}
@@ -1033,7 +983,7 @@ func (db *DB) GetObservers() ([]Observer, error) {
var o Observer
var batteryMv, uptimeSecs sql.NullInt64
var noiseFloor sql.NullFloat64
if err := rows.Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor, &o.LastPacketAt); err != nil {
if err := rows.Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor); err != nil {
continue
}
if batteryMv.Valid {
@@ -1056,8 +1006,8 @@ func (db *DB) GetObserverByID(id string) (*Observer, error) {
var o Observer
var batteryMv, uptimeSecs sql.NullInt64
var noiseFloor sql.NullFloat64
err := db.conn.QueryRow("SELECT id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor, last_packet_at FROM observers WHERE id = ?", id).
Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor, &o.LastPacketAt)
err := db.conn.QueryRow("SELECT id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor FROM observers WHERE id = ?", id).
Scan(&o.ID, &o.Name, &o.IATA, &o.LastSeen, &o.FirstSeen, &o.PacketCount, &o.Model, &o.Firmware, &o.ClientVersion, &o.Radio, &batteryMv, &uptimeSecs, &noiseFloor)
if err != nil {
return nil, err
}
@@ -1105,17 +1055,6 @@ func (db *DB) GetObserverIdsForRegion(regionParam string) ([]string, error) {
return ids, nil
}
// normalizeRegionCodes parses a region query parameter into a list of upper-case
// IATA codes. Returns nil to signal "no filter" (match all regions).
//
// Sentinel handling (issue #770): the frontend region filter dropdown labels its
// catch-all option "All". When that option is selected the UI may send
// ?region=All; older code interpreted that literally and tried to match an
// IATA code "ALL", which never exists, returning an empty result set. Treat
// "All" / "ALL" / "all" (case-insensitive, optionally surrounded by whitespace
// or mixed with empty CSV slots) as equivalent to an empty value.
//
// Real IATA codes (e.g. "SJC", "PDX") still pass through unchanged.
func normalizeRegionCodes(regionParam string) []string {
if regionParam == "" {
return nil
@@ -1124,13 +1063,9 @@ func normalizeRegionCodes(regionParam string) []string {
codes := make([]string, 0, len(tokens))
for _, token := range tokens {
code := strings.TrimSpace(strings.ToUpper(token))
if code == "" || code == "ALL" {
continue
if code != "" {
codes = append(codes, code)
}
codes = append(codes, code)
}
if len(codes) == 0 {
return nil
}
return codes
}
@@ -1937,10 +1872,11 @@ func nullInt(ni sql.NullInt64) interface{} {
// Returns the number of transmissions deleted.
// Opens a separate read-write connection since the main connection is read-only.
func (db *DB) PruneOldPackets(days int) (int64, error) {
rw, err := cachedRW(db.path)
rw, err := openRW(db.path)
if err != nil {
return 0, err
}
defer rw.Close()
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
tx, err := rw.Begin()
@@ -2283,10 +2219,11 @@ func (db *DB) GetMetricsSummary(since string) ([]MetricsSummaryRow, error) {
// PruneOldMetrics deletes observer_metrics rows older than retentionDays.
func (db *DB) PruneOldMetrics(retentionDays int) (int64, error) {
rw, err := cachedRW(db.path)
rw, err := openRW(db.path)
if err != nil {
return 0, err
}
defer rw.Close()
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays).Format(time.RFC3339)
res, err := rw.Exec(`DELETE FROM observer_metrics WHERE timestamp < ?`, cutoff)
@@ -2309,10 +2246,11 @@ func (db *DB) RemoveStaleObservers(observerDays int) (int64, error) {
if observerDays <= -1 {
return 0, nil // keep forever
}
rw, err := cachedRW(db.path)
rw, err := openRW(db.path)
if err != nil {
return 0, err
}
defer rw.Close()
cutoff := time.Now().UTC().AddDate(0, 0, -observerDays).Format(time.RFC3339)
res, err := rw.Exec(`UPDATE observers SET inactive = 1 WHERE last_seen < ? AND (inactive IS NULL OR inactive = 0)`, cutoff)
+2 -50
View File
@@ -49,8 +49,7 @@ func setupTestDB(t *testing.T) *DB {
battery_mv INTEGER,
uptime_secs INTEGER,
noise_floor REAL,
inactive INTEGER DEFAULT 0,
last_packet_at TEXT DEFAULT NULL
inactive INTEGER DEFAULT 0
);
CREATE TABLE transmissions (
@@ -357,10 +356,6 @@ func TestGetObservers(t *testing.T) {
if observers[0].ID != "obs1" {
t.Errorf("expected obs1 first (most recent), got %s", observers[0].ID)
}
// last_packet_at should be nil since seedTestData doesn't set it
if observers[0].LastPacketAt != nil {
t.Errorf("expected nil LastPacketAt for obs1 from seed, got %v", *observers[0].LastPacketAt)
}
}
// Regression: GetObservers must exclude soft-deleted (inactive=1) rows.
@@ -400,48 +395,6 @@ func TestGetObserverByID(t *testing.T) {
if obs.ID != "obs1" {
t.Errorf("expected obs1, got %s", obs.ID)
}
// Verify last_packet_at is nil by default
if obs.LastPacketAt != nil {
t.Errorf("expected nil LastPacketAt, got %v", *obs.LastPacketAt)
}
}
func TestGetObserverLastPacketAt(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Set last_packet_at for obs1
ts := "2026-04-24T12:00:00Z"
db.conn.Exec(`UPDATE observers SET last_packet_at = ? WHERE id = ?`, ts, "obs1")
// Verify via GetObservers
observers, err := db.GetObservers()
if err != nil {
t.Fatal(err)
}
var obs1 *Observer
for i := range observers {
if observers[i].ID == "obs1" {
obs1 = &observers[i]
break
}
}
if obs1 == nil {
t.Fatal("obs1 not found")
}
if obs1.LastPacketAt == nil || *obs1.LastPacketAt != ts {
t.Errorf("expected LastPacketAt=%s via GetObservers, got %v", ts, obs1.LastPacketAt)
}
// Verify via GetObserverByID
obs, err := db.GetObserverByID("obs1")
if err != nil {
t.Fatal(err)
}
if obs.LastPacketAt == nil || *obs.LastPacketAt != ts {
t.Errorf("expected LastPacketAt=%s via GetObserverByID, got %v", ts, obs.LastPacketAt)
}
}
func TestGetObserverByIDNotFound(t *testing.T) {
@@ -1182,8 +1135,7 @@ func setupTestDBV2(t *testing.T) *DB {
iata TEXT,
last_seen TEXT,
first_seen TEXT,
packet_count INTEGER DEFAULT 0,
last_packet_at TEXT DEFAULT NULL
packet_count INTEGER DEFAULT 0
);
CREATE TABLE transmissions (
-14
View File
@@ -106,7 +106,6 @@ type Payload struct {
Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
TraceFlags *int `json:"traceFlags,omitempty"`
SNRValues []float64 `json:"snrValues,omitempty"`
RawHex string `json:"raw,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -408,19 +407,6 @@ func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, er
}
// The header path hops count represents SNR entries = completed hops
hopsCompleted := path.HashCount
// Extract per-hop SNR from header path bytes (int8, quarter-dB encoding)
if hopsCompleted > 0 && len(path.Hops) >= hopsCompleted {
snrVals := make([]float64, 0, hopsCompleted)
for i := 0; i < hopsCompleted; i++ {
b, err := hex.DecodeString(path.Hops[i])
if err == nil && len(b) == 1 {
snrVals = append(snrVals, float64(int8(b[0]))/4.0)
}
}
if len(snrVals) > 0 {
payload.SNRValues = snrVals
}
}
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && payload.TraceFlags != nil {
// path_sz from flags byte is a power-of-two exponent per firmware:
-48
View File
@@ -440,51 +440,3 @@ func TestDecodeAdvertSignatureValidation(t *testing.T) {
t.Error("expected SignatureValid to be nil when validation disabled")
}
}
func TestDecodePacket_TraceSNRValues(t *testing.T) {
// TRACE packet with 3 SNR bytes in header path:
// SNR byte 0: 0x14 = int8(20) → 20/4.0 = 5.0 dB
// SNR byte 1: 0xF4 = int8(-12) → -12/4.0 = -3.0 dB
// SNR byte 2: 0x08 = int8(8) → 8/4.0 = 2.0 dB
// header: DIRECT+TRACE = (0<<6)|(9<<2)|2 = 0x26
// path_length: hash_size=0b00 (1-byte), hash_count=3 → 0x03
hex := "2603" + "14F408" + // header + path_byte + 3 SNR bytes
"01000000" + // tag
"02000000" + // authCode
"00" + // flags=0 → path_sz=1
"AABBCCDD" // 4 route hops (1-byte each)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if pkt.Payload.SNRValues == nil {
t.Fatal("expected SNRValues to be populated")
}
if len(pkt.Payload.SNRValues) != 3 {
t.Fatalf("expected 3 SNR values, got %d", len(pkt.Payload.SNRValues))
}
expected := []float64{5.0, -3.0, 2.0}
for i, want := range expected {
if pkt.Payload.SNRValues[i] != want {
t.Errorf("SNRValues[%d] = %v, want %v", i, pkt.Payload.SNRValues[i], want)
}
}
}
func TestDecodePacket_TraceNoSNRValues(t *testing.T) {
// TRACE with 0 SNR bytes → SNRValues should be nil/empty
hex := "2600" + // header + path_byte (0 hops)
"01000000" + // tag
"02000000" + // authCode
"00" + // flags
"AABB" // 2 route hops
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if len(pkt.Payload.SNRValues) != 0 {
t.Errorf("expected empty SNRValues, got %v", pkt.Payload.SNRValues)
}
}
-4
View File
@@ -18,10 +18,6 @@ require github.com/meshcore-analyzer/packetpath v0.0.0
replace github.com/meshcore-analyzer/packetpath => ../../internal/packetpath
require github.com/meshcore-analyzer/dbconfig v0.0.0
replace github.com/meshcore-analyzer/dbconfig => ../../internal/dbconfig
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
-147
View File
@@ -1,147 +0,0 @@
package main
import (
"testing"
"time"
)
// TestIssue804_AnalyticsAttributesByRepeaterRegion verifies that analytics
// (specifically GetAnalyticsHashSizes) attribute multi-byte nodes to the
// REPEATER's home region, not the observer that happened to hear the relay.
//
// Scenario from #804:
// - PDX-Repeater is a multi-byte (hashSize=2) repeater whose ZERO-HOP direct
// adverts are only heard by obs-PDX (a PDX observer). That zero-hop direct
// advert is the most reliable home-region signal — it cannot have been
// relayed.
// - A flood advert from PDX-Repeater (hashSize=2) propagates and is heard by
// obs-SJC (a SJC observer) via a multi-hop relay path.
// - When the user asks for region=SJC analytics, the PDX-Repeater MUST NOT
// pollute SJC's multiByteNodes — it lives in PDX.
// - The result should also expose attributionMethod="repeater" so the API
// consumer knows which method was used.
//
// Pre-fix behavior: PDX-Repeater appears in SJC's multiByteNodes because the
// filter is observer-based. This test fails on the pre-fix code at the
// "want PDX-Repeater EXCLUDED" assertion.
func TestIssue804_AnalyticsAttributesByRepeaterRegion(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()
// Observers: one in PDX, one in SJC
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs-pdx', 'Obs PDX', 'PDX', ?, '2026-01-01T00:00:00Z', 100)`, recent)
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)
// PDX-Repeater node (lives in Portland)
pdxPK := "pdx0000000000001"
db.conn.Exec(`INSERT INTO nodes (public_key, name, role)
VALUES (?, 'PDX-Repeater', 'repeater')`, pdxPK)
// SJC-Repeater node (lives in San Jose) — sanity baseline
sjcPK := "sjc0000000000001"
db.conn.Exec(`INSERT INTO nodes (public_key, name, role)
VALUES (?, 'SJC-Repeater', 'repeater')`, sjcPK)
pdxDecoded := `{"pubKey":"` + pdxPK + `","name":"PDX-Repeater","type":"ADVERT","flags":{"isRepeater":true}}`
sjcDecoded := `{"pubKey":"` + sjcPK + `","name":"SJC-Repeater","type":"ADVERT","flags":{"isRepeater":true}}`
// 1) PDX-Repeater zero-hop DIRECT advert heard only by obs-PDX.
// Establishes PDX as the repeater's home region.
// raw_hex header 0x12 = route_type 2 (direct), payload_type 4
// pathByte 0x40 (hashSize bits=01 → 2, hop_count=0)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1240aabbccdd', 'pdx_zh_direct', ?, 2, 4, ?)`, recent, pdxDecoded)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 12.0, -85, '[]', ?)`, recentEpoch)
// 2) PDX-Repeater FLOOD advert with hashSize=2 (reliable).
// Heard ONLY by obs-SJC via a relay path (this is the polluting case).
// raw_hex header 0x11 = route_type 1 (flood), payload_type 4
// pathByte 0x41 (hashSize bits=01 → 2, hop_count=1)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1141aabbccdd', 'pdx_flood', ?, 1, 4, ?)`, recent, pdxDecoded)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 2, 8.0, -95, '["aa11"]', ?)`, recentEpoch)
// 3) SJC-Repeater zero-hop DIRECT advert heard only by obs-SJC.
// Establishes SJC as the repeater's home region.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1240ccddeeff', 'sjc_zh_direct', ?, 2, 4, ?)`, recent, sjcDecoded)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (3, 2, 14.0, -82, '[]', ?)`, recentEpoch)
// 4) SJC-Repeater FLOOD advert with hashSize=2, heard by obs-SJC.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1141ccddeeff', 'sjc_flood', ?, 1, 4, ?)`, recent, sjcDecoded)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (4, 2, 11.0, -88, '["cc22"]', ?)`, recentEpoch)
store := NewPacketStore(db, nil)
store.Load()
t.Run("region=SJC excludes PDX-Repeater (heard but not home)", func(t *testing.T) {
result := store.GetAnalyticsHashSizes("SJC")
mb, ok := result["multiByteNodes"].([]map[string]interface{})
if !ok {
t.Fatal("expected multiByteNodes slice")
}
var foundPDX, foundSJC bool
for _, n := range mb {
pk, _ := n["pubkey"].(string)
if pk == pdxPK {
foundPDX = true
}
if pk == sjcPK {
foundSJC = true
}
}
if foundPDX {
t.Errorf("PDX-Repeater leaked into SJC analytics — region attribution still observer-based (#804 not fixed)")
}
if !foundSJC {
t.Errorf("SJC-Repeater missing from SJC analytics — fix over-filtered")
}
})
t.Run("API exposes attributionMethod", func(t *testing.T) {
result := store.GetAnalyticsHashSizes("SJC")
method, ok := result["attributionMethod"].(string)
if !ok {
t.Fatal("expected attributionMethod string field on result")
}
if method != "repeater" {
t.Errorf("attributionMethod = %q, want %q", method, "repeater")
}
})
t.Run("region=PDX excludes SJC-Repeater", func(t *testing.T) {
result := store.GetAnalyticsHashSizes("PDX")
mb, _ := result["multiByteNodes"].([]map[string]interface{})
var foundPDX, foundSJC bool
for _, n := range mb {
pk, _ := n["pubkey"].(string)
if pk == pdxPK {
foundPDX = true
}
if pk == sjcPK {
foundSJC = true
}
}
if !foundPDX {
t.Errorf("PDX-Repeater missing from PDX analytics")
}
if foundSJC {
t.Errorf("SJC-Repeater leaked into PDX analytics")
}
})
}
-63
View File
@@ -1,63 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
)
// TestIssue871_NoNullHashOrTimestamp verifies that /api/packets never returns
// packets with null/empty hash or null timestamp (issue #871).
func TestIssue871_NoNullHashOrTimestamp(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
// Insert bad legacy data: packet with empty hash
now := time.Now().UTC().Add(-30 * time.Minute).Format(time.RFC3339)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('DEAD', '', ?, 1, 4, '{}')`, now)
// Insert bad legacy data: packet with NULL first_seen (timestamp)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('BEEF', 'aa11bb22cc33dd44', NULL, 1, 4, '{}')`)
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 failed: %v", err)
}
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest(http.MethodGet, "/api/packets?limit=200", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Packets []map[string]interface{} `json:"packets"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode error: %v", err)
}
for i, p := range resp.Packets {
hash, _ := p["hash"]
ts, _ := p["timestamp"]
if hash == nil || hash == "" {
t.Errorf("packet[%d] has null/empty hash: %v", i, p)
}
if ts == nil || ts == "" {
t.Errorf("packet[%d] has null/empty timestamp: %v", i, p)
}
}
}
+2 -7
View File
@@ -180,12 +180,6 @@ func main() {
log.Printf("[store] warning: could not add observers.inactive column: %v", err)
}
// Ensure observers.last_packet_at column exists (PR #905 reads it; ingestor migration
// adds it but server may run against DBs ingestor never touched, e.g. e2e fixture).
if err := ensureLastPacketAtColumn(dbPath); err != nil {
log.Printf("[store] warning: could not add observers.last_packet_at 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 {
@@ -210,9 +204,10 @@ func main() {
log.Printf("[neighbor] graph build panic recovered: %v", r)
}
}()
rw, rwErr := cachedRW(dbPath)
rw, rwErr := openRW(dbPath)
if rwErr == nil {
edgeCount := buildAndPersistEdges(store, rw)
rw.Close()
log.Printf("[neighbor] persisted %d edges", edgeCount)
}
built := BuildFromStore(store)
-57
View File
@@ -1,57 +0,0 @@
package main
import "testing"
func TestEnrichNodeWithMultiByte(t *testing.T) {
t.Run("nil entry leaves no fields", func(t *testing.T) {
node := map[string]interface{}{"public_key": "abc123"}
EnrichNodeWithMultiByte(node, nil)
if _, ok := node["multi_byte_status"]; ok {
t.Error("expected no multi_byte_status with nil entry")
}
})
t.Run("confirmed entry sets fields", func(t *testing.T) {
node := map[string]interface{}{"public_key": "abc123"}
entry := &MultiByteCapEntry{
Status: "confirmed",
Evidence: "advert",
MaxHashSize: 2,
}
EnrichNodeWithMultiByte(node, entry)
if node["multi_byte_status"] != "confirmed" {
t.Errorf("expected confirmed, got %v", node["multi_byte_status"])
}
if node["multi_byte_evidence"] != "advert" {
t.Errorf("expected advert, got %v", node["multi_byte_evidence"])
}
if node["multi_byte_max_hash_size"] != 2 {
t.Errorf("expected 2, got %v", node["multi_byte_max_hash_size"])
}
})
t.Run("suspected entry sets fields", func(t *testing.T) {
node := map[string]interface{}{"public_key": "abc123"}
entry := &MultiByteCapEntry{
Status: "suspected",
Evidence: "path",
MaxHashSize: 2,
}
EnrichNodeWithMultiByte(node, entry)
if node["multi_byte_status"] != "suspected" {
t.Errorf("expected suspected, got %v", node["multi_byte_status"])
}
})
t.Run("unknown entry sets status unknown", func(t *testing.T) {
node := map[string]interface{}{"public_key": "abc123"}
entry := &MultiByteCapEntry{
Status: "unknown",
MaxHashSize: 1,
}
EnrichNodeWithMultiByte(node, entry)
if node["multi_byte_status"] != "unknown" {
t.Errorf("expected unknown, got %v", node["multi_byte_status"])
}
})
}
+18 -45
View File
@@ -20,10 +20,11 @@ var persistSem = make(chan struct{}, 1)
// ensureNeighborEdgesTable creates the neighbor_edges table if it doesn't exist.
// Uses a separate read-write connection since the main DB is read-only.
func ensureNeighborEdgesTable(dbPath string) error {
rw, err := cachedRW(dbPath)
rw, err := openRW(dbPath)
if err != nil {
return fmt.Errorf("open rw for neighbor_edges: %w", err)
}
defer rw.Close()
_, err = rw.Exec(`CREATE TABLE IF NOT EXISTS neighbor_edges (
node_a TEXT NOT NULL,
@@ -128,11 +129,12 @@ func asyncPersistResolvedPathsAndEdges(dbPath string, obsUpdates []persistObsUpd
go func() {
defer func() { <-persistSem }()
rw, err := cachedRW(dbPath)
rw, err := openRW(dbPath)
if err != nil {
log.Printf("[store] %s rw open error: %v", logPrefix, err)
return
}
defer rw.Close()
if len(obsUpdates) > 0 {
sqlTx, err := rw.Begin()
@@ -247,10 +249,11 @@ func buildAndPersistEdges(store *PacketStore, rw *sql.DB) int {
// ensureResolvedPathColumn adds the resolved_path column to observations if missing.
func ensureResolvedPathColumn(dbPath string) error {
rw, err := cachedRW(dbPath)
rw, err := openRW(dbPath)
if err != nil {
return err
}
defer rw.Close()
// Check if column already exists
rows, err := rw.Query("PRAGMA table_info(observations)")
@@ -286,10 +289,11 @@ func ensureResolvedPathColumn(dbPath string) error {
// GetStats) silently fail with "no such column: inactive" — leaving /api/observers
// returning empty.
func ensureObserverInactiveColumn(dbPath string) error {
rw, err := cachedRW(dbPath)
rw, err := openRW(dbPath)
if err != nil {
return err
}
defer rw.Close()
rows, err := rw.Query("PRAGMA table_info(observers)")
if err != nil {
@@ -316,51 +320,15 @@ func ensureObserverInactiveColumn(dbPath string) error {
return nil
}
// ensureLastPacketAtColumn adds the last_packet_at column to observers if missing.
// The column was originally added by ingestor migration (observers_last_packet_at_v1)
// to track the most recent packet observation time separately from status updates.
// When the server starts against a DB that was never touched by the ingestor (e.g.
// the e2e fixture), the column is missing and read queries that reference it
// (GetObservers, GetObserverByID) fail with "no such column: last_packet_at".
func ensureLastPacketAtColumn(dbPath string) error {
rw, err := cachedRW(dbPath)
if err != nil {
return err
}
rows, err := rw.Query("PRAGMA table_info(observers)")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var colName string
var colType sql.NullString
var notNull, pk int
var dflt sql.NullString
if rows.Scan(&cid, &colName, &colType, &notNull, &dflt, &pk) == nil && colName == "last_packet_at" {
return nil // already exists
}
}
_, err = rw.Exec("ALTER TABLE observers ADD COLUMN last_packet_at TEXT")
if err != nil {
return fmt.Errorf("add last_packet_at column: %w", err)
}
log.Println("[store] Added last_packet_at column to observers")
return 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) {
rw, err := cachedRW(dbPath)
rw, err := openRW(dbPath)
if err != nil {
log.Printf("[observer-blacklist] warning: could not open DB for soft-delete: %v", err)
return
}
defer rw.Close()
placeholders := make([]string, 0, len(blacklist))
args := make([]interface{}, 0, len(blacklist))
@@ -522,12 +490,16 @@ func backfillResolvedPathsAsync(store *PacketStore, dbPath string, chunkSize int
var rw *sql.DB
if dbPath != "" {
var err error
rw, err = cachedRW(dbPath)
rw, err = openRW(dbPath)
if err != nil {
log.Printf("[store] async backfill: open rw error: %v", err)
}
}
// rw is cached process-wide; do not close
defer func() {
if rw != nil {
rw.Close()
}
}()
totalProcessed := 0
for totalProcessed < totalPending {
@@ -752,10 +724,11 @@ func PruneNeighborEdges(dbPath string, graph *NeighborGraph, maxAgeDays int) (in
// 1. Prune from SQLite using a read-write connection
var dbPruned int64
rw, err := cachedRW(dbPath)
rw, err := openRW(dbPath)
if err != nil {
return 0, fmt.Errorf("prune neighbor_edges: open rw: %w", err)
}
defer rw.Close()
res, err := rw.Exec("DELETE FROM neighbor_edges WHERE last_seen < ?", cutoff.Format(time.RFC3339))
if err != nil {
return 0, fmt.Errorf("prune neighbor_edges: %w", err)
-59
View File
@@ -538,62 +538,3 @@ func TestOpenRW_BusyTimeout(t *testing.T) {
t.Errorf("expected busy_timeout=5000, got %d", timeout)
}
}
func TestEnsureLastPacketAtColumn(t *testing.T) {
// Create a temp DB with observers table missing last_packet_at
dir := t.TempDir()
dbPath := dir + "/test.db"
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`CREATE TABLE observers (
id TEXT PRIMARY KEY,
name TEXT,
last_seen TEXT,
lat REAL,
lon REAL,
inactive INTEGER DEFAULT 0
)`)
if err != nil {
t.Fatal(err)
}
db.Close()
// First call: should add the column
if err := ensureLastPacketAtColumn(dbPath); err != nil {
t.Fatalf("first call failed: %v", err)
}
// Verify column exists
db2, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
defer db2.Close()
var found bool
rows, err := db2.Query("PRAGMA table_info(observers)")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var cid int
var colName string
var colType sql.NullString
var notNull, pk int
var dflt sql.NullString
if rows.Scan(&cid, &colName, &colType, &notNull, &dflt, &pk) == nil && colName == "last_packet_at" {
found = true
}
}
if !found {
t.Fatal("last_packet_at column not found after migration")
}
// Idempotency: second call should succeed without error
if err := ensureLastPacketAtColumn(dbPath); err != nil {
t.Fatalf("idempotent call failed: %v", err)
}
}
-1
View File
@@ -45,7 +45,6 @@ func routeDescriptions() map[string]routeMeta {
"POST /api/perf/reset": {Summary: "Reset performance stats", Tag: "admin", Auth: true},
"POST /api/admin/prune": {Summary: "Prune old data", Description: "Deletes packets and nodes older than the configured retention period.", Tag: "admin", Auth: true},
"GET /api/debug/affinity": {Summary: "Debug neighbor affinity scores", Tag: "admin", Auth: true},
"GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-<unix>.db.", Tag: "admin", Auth: true},
// Packets
"GET /api/packets": {Summary: "List packets", Description: "Returns decoded packets with filtering, sorting, and pagination.", Tag: "packets",
-41
View File
@@ -1,41 +0,0 @@
package main
import (
"testing"
)
// Issue #770: the region filter dropdown's "All" option was being sent to the
// backend as ?region=All. The backend then tried to match observers with IATA
// code "ALL", which never exists, producing an empty channel/packet list.
//
// "All" / "ALL" / "all" / "" must all be treated as "no region filter".
func TestNormalizeRegionCodes_AllIsNoFilter(t *testing.T) {
cases := []struct {
name string
in string
}{
{"empty", ""},
{"literal All (frontend dropdown label)", "All"},
{"upper ALL", "ALL"},
{"lower all", "all"},
{"All with whitespace", " All "},
{"All in csv with empty siblings", "All,"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := normalizeRegionCodes(tc.in)
if got != nil {
t.Errorf("normalizeRegionCodes(%q) = %v, want nil (no filter)", tc.in, got)
}
})
}
}
// Real region codes must still pass through unchanged (case-folded to upper).
// This locks in that the "All" handling does not regress legitimate filters.
func TestNormalizeRegionCodes_RealCodesPreserved(t *testing.T) {
got := normalizeRegionCodes("sjc,PDX")
if len(got) != 2 || got[0] != "SJC" || got[1] != "PDX" {
t.Errorf("normalizeRegionCodes(\"sjc,PDX\") = %v, want [SJC PDX]", got)
}
}
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"math"
"net/http"
"sort"
"strings"
)
// RoleStats summarises one role's population and clock-skew posture.
type RoleStats struct {
Role string `json:"role"`
NodeCount int `json:"nodeCount"`
WithSkew int `json:"withSkew"`
MeanAbsSkewSec float64 `json:"meanAbsSkewSec"`
MedianAbsSkewSec float64 `json:"medianAbsSkewSec"`
OkCount int `json:"okCount"`
WarningCount int `json:"warningCount"`
CriticalCount int `json:"criticalCount"`
AbsurdCount int `json:"absurdCount"`
NoClockCount int `json:"noClockCount"`
}
// RoleAnalyticsResponse is the payload returned by /api/analytics/roles.
type RoleAnalyticsResponse struct {
TotalNodes int `json:"totalNodes"`
Roles []RoleStats `json:"roles"`
}
// normalizeRole canonicalises a role string so empty/unknown roles bucket
// together and case differences don't fragment the distribution.
func normalizeRole(r string) string {
r = strings.ToLower(strings.TrimSpace(r))
if r == "" {
return "unknown"
}
return r
}
// computeRoleAnalytics groups nodes by role and aggregates clock-skew per
// role. Pure function: takes the node roster and the per-pubkey skew map and
// returns the response — no store / lock dependencies, easy to unit test.
//
// `nodesByPubkey` lists every known node (pubkey → role). `skewByPubkey`
// is the subset of pubkeys that have clock-skew data with their severity and
// most-recent corrected skew (in seconds, signed — we take |x| for averages).
func computeRoleAnalytics(nodesByPubkey map[string]string, skewByPubkey map[string]*NodeClockSkew) RoleAnalyticsResponse {
type bucket struct {
stats RoleStats
absSkews []float64
}
buckets := make(map[string]*bucket)
for pk, rawRole := range nodesByPubkey {
role := normalizeRole(rawRole)
b, ok := buckets[role]
if !ok {
b = &bucket{stats: RoleStats{Role: role}}
buckets[role] = b
}
b.stats.NodeCount++
cs, has := skewByPubkey[pk]
if !has || cs == nil {
continue
}
b.stats.WithSkew++
abs := math.Abs(cs.RecentMedianSkewSec)
if abs == 0 {
abs = math.Abs(cs.LastSkewSec)
}
b.absSkews = append(b.absSkews, abs)
switch cs.Severity {
case SkewOK:
b.stats.OkCount++
case SkewWarning:
b.stats.WarningCount++
case SkewCritical:
b.stats.CriticalCount++
case SkewAbsurd:
b.stats.AbsurdCount++
case SkewNoClock:
b.stats.NoClockCount++
}
}
resp := RoleAnalyticsResponse{Roles: make([]RoleStats, 0, len(buckets))}
for _, b := range buckets {
if n := len(b.absSkews); n > 0 {
sum := 0.0
for _, v := range b.absSkews {
sum += v
}
b.stats.MeanAbsSkewSec = round(sum/float64(n), 2)
sorted := make([]float64, n)
copy(sorted, b.absSkews)
sort.Float64s(sorted)
if n%2 == 1 {
b.stats.MedianAbsSkewSec = round(sorted[n/2], 2)
} else {
b.stats.MedianAbsSkewSec = round((sorted[n/2-1]+sorted[n/2])/2, 2)
}
}
resp.TotalNodes += b.stats.NodeCount
resp.Roles = append(resp.Roles, b.stats)
}
// Sort: largest population first, then role name for stable output.
sort.Slice(resp.Roles, func(i, j int) bool {
if resp.Roles[i].NodeCount != resp.Roles[j].NodeCount {
return resp.Roles[i].NodeCount > resp.Roles[j].NodeCount
}
return resp.Roles[i].Role < resp.Roles[j].Role
})
return resp
}
// handleAnalyticsRoles serves /api/analytics/roles.
func (s *Server) handleAnalyticsRoles(w http.ResponseWriter, r *http.Request) {
if s.store == nil {
writeJSON(w, RoleAnalyticsResponse{Roles: []RoleStats{}})
return
}
nodes, _ := s.store.getCachedNodesAndPM()
roles := make(map[string]string, len(nodes))
for _, n := range nodes {
roles[n.PublicKey] = n.Role
}
skewMap := make(map[string]*NodeClockSkew)
for _, cs := range s.store.GetFleetClockSkew() {
if cs == nil {
continue
}
skewMap[cs.Pubkey] = cs
}
writeJSON(w, computeRoleAnalytics(roles, skewMap))
}
-77
View File
@@ -1,77 +0,0 @@
package main
import (
"testing"
)
// TestComputeRoleAnalytics_Distribution verifies that computeRoleAnalytics
// groups nodes by role, normalises empty/case-different roles, and sorts the
// output largest-population first. Asserts on the public RoleAnalyticsResponse
// shape so the bar is "behaviour", not "compiles".
func TestComputeRoleAnalytics_Distribution(t *testing.T) {
nodes := map[string]string{
"pk_a": "Repeater",
"pk_b": "repeater",
"pk_c": "companion",
"pk_d": "",
"pk_e": "ROOM_SERVER",
}
got := computeRoleAnalytics(nodes, nil)
if got.TotalNodes != 5 {
t.Fatalf("TotalNodes = %d, want 5", got.TotalNodes)
}
if len(got.Roles) != 4 {
t.Fatalf("len(Roles) = %d, want 4 (repeater, companion, room_server, unknown), got %+v", len(got.Roles), got.Roles)
}
if got.Roles[0].Role != "repeater" || got.Roles[0].NodeCount != 2 {
t.Errorf("Roles[0] = %+v, want {repeater,2}", got.Roles[0])
}
// Empty roles should bucket as "unknown".
foundUnknown := false
for _, r := range got.Roles {
if r.Role == "unknown" {
foundUnknown = true
if r.NodeCount != 1 {
t.Errorf("unknown bucket NodeCount = %d, want 1", r.NodeCount)
}
}
}
if !foundUnknown {
t.Errorf("no 'unknown' bucket for empty roles in %+v", got.Roles)
}
}
// TestComputeRoleAnalytics_SkewAggregation verifies per-role clock-skew
// aggregation: counts by severity, mean and median absolute skew.
func TestComputeRoleAnalytics_SkewAggregation(t *testing.T) {
nodes := map[string]string{
"pk_1": "repeater",
"pk_2": "repeater",
"pk_3": "repeater",
}
skews := map[string]*NodeClockSkew{
"pk_1": {Pubkey: "pk_1", RecentMedianSkewSec: 10, Severity: SkewOK},
"pk_2": {Pubkey: "pk_2", RecentMedianSkewSec: -400, Severity: SkewWarning},
"pk_3": {Pubkey: "pk_3", RecentMedianSkewSec: 7200, Severity: SkewCritical},
}
got := computeRoleAnalytics(nodes, skews)
if len(got.Roles) != 1 {
t.Fatalf("len(Roles) = %d, want 1; got %+v", len(got.Roles), got.Roles)
}
r := got.Roles[0]
if r.WithSkew != 3 {
t.Errorf("WithSkew = %d, want 3", r.WithSkew)
}
if r.OkCount != 1 || r.WarningCount != 1 || r.CriticalCount != 1 {
t.Errorf("severity counts = ok %d, warn %d, crit %d; want 1/1/1", r.OkCount, r.WarningCount, r.CriticalCount)
}
// mean(|10|, |400|, |7200|) = 7610/3 ≈ 2536.67
if r.MeanAbsSkewSec < 2536 || r.MeanAbsSkewSec > 2537 {
t.Errorf("MeanAbsSkewSec = %v, want ~2536.67", r.MeanAbsSkewSec)
}
// median(10, 400, 7200) = 400
if r.MedianAbsSkewSec != 400 {
t.Errorf("MedianAbsSkewSec = %v, want 400", r.MedianAbsSkewSec)
}
}
+4 -46
View File
@@ -104,9 +104,6 @@ func (s *Server) getMemStats() runtime.MemStats {
// RegisterRoutes sets up all HTTP routes on the given router.
func (s *Server) RegisterRoutes(r *mux.Router) {
s.router = r
// CORS middleware (must run before route handlers)
r.Use(s.corsMiddleware)
// Performance instrumentation middleware
r.Use(s.perfMiddleware)
@@ -132,7 +129,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.Handle("/api/admin/prune", s.requireAPIKey(http.HandlerFunc(s.handleAdminPrune))).Methods("POST")
r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
r.Handle("/api/dropped-packets", s.requireAPIKey(http.HandlerFunc(s.handleDroppedPackets))).Methods("GET")
r.Handle("/api/backup", s.requireAPIKey(http.HandlerFunc(s.handleBackup))).Methods("GET")
// Packet endpoints
r.HandleFunc("/api/packets/observations", s.handleBatchObservations).Methods("POST")
@@ -159,7 +155,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/nodes", s.handleNodes).Methods("GET")
// Analytics endpoints
r.HandleFunc("/api/analytics/roles", s.handleAnalyticsRoles).Methods("GET")
r.HandleFunc("/api/analytics/rf", s.handleAnalyticsRF).Methods("GET")
r.HandleFunc("/api/analytics/topology", s.handleAnalyticsTopology).Methods("GET")
r.HandleFunc("/api/analytics/channels", s.handleAnalyticsChannels).Methods("GET")
@@ -1096,11 +1091,9 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
}
if s.store != nil {
hashInfo := s.store.GetNodeHashSizeInfo()
mbCap := s.store.GetMultiByteCapMap()
for _, node := range nodes {
if pk, ok := node["public_key"].(string); ok {
EnrichNodeWithHashSize(node, hashInfo[pk])
EnrichNodeWithMultiByte(node, mbCap[pk])
}
}
}
@@ -1159,44 +1152,14 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
return
}
node, err := s.db.GetNodeByPubkey(pubkey)
if err != nil {
writeError(w, 500, err.Error())
return
}
// Issue #772: short-URL fallback. If exact pubkey lookup misses and the
// path looks like a hex prefix (>=8 chars, <64), try prefix resolution.
if node == nil && len(pubkey) >= 8 && len(pubkey) < 64 {
resolved, ambiguous, perr := s.db.GetNodeByPrefix(pubkey)
if perr != nil {
writeError(w, 500, perr.Error())
return
}
if ambiguous {
writeError(w, http.StatusConflict, "Ambiguous prefix: multiple nodes match. Use a longer prefix.")
return
}
if resolved != nil {
if pk, _ := resolved["public_key"].(string); pk != "" && s.cfg.IsBlacklisted(pk) {
writeError(w, 404, "Not found")
return
}
node = resolved
}
}
if node == nil {
if err != nil || node == nil {
writeError(w, 404, "Not found")
return
}
// From here on use the canonical pubkey for downstream lookups.
if pk, _ := node["public_key"].(string); pk != "" {
pubkey = pk
}
if s.store != nil {
hashInfo := s.store.GetNodeHashSizeInfo()
EnrichNodeWithHashSize(node, hashInfo[pubkey])
mbCap := s.store.GetMultiByteCapMap()
EnrichNodeWithMultiByte(node, mbCap[pubkey])
}
name := ""
@@ -1555,9 +1518,8 @@ func (s *Server) handleFleetClockSkew(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAnalyticsRF(w http.ResponseWriter, r *http.Request) {
region := r.URL.Query().Get("region")
window := ParseTimeWindow(r)
if s.store != nil {
writeJSON(w, s.store.GetAnalyticsRFWithWindow(region, window))
writeJSON(w, s.store.GetAnalyticsRF(region))
return
}
writeJSON(w, RFAnalyticsResponse{
@@ -1576,9 +1538,8 @@ func (s *Server) handleAnalyticsRF(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAnalyticsTopology(w http.ResponseWriter, r *http.Request) {
region := r.URL.Query().Get("region")
window := ParseTimeWindow(r)
if s.store != nil {
data := s.store.GetAnalyticsTopologyWithWindow(region, window)
data := s.store.GetAnalyticsTopology(region)
if s.cfg != nil && len(s.cfg.NodeBlacklist) > 0 {
data = s.filterBlacklistedFromTopology(data)
}
@@ -1600,8 +1561,7 @@ func (s *Server) handleAnalyticsTopology(w http.ResponseWriter, r *http.Request)
func (s *Server) handleAnalyticsChannels(w http.ResponseWriter, r *http.Request) {
if s.store != nil {
region := r.URL.Query().Get("region")
window := ParseTimeWindow(r)
writeJSON(w, s.store.GetAnalyticsChannelsWithWindow(region, window))
writeJSON(w, s.store.GetAnalyticsChannels(region))
return
}
channels, _ := s.db.GetChannels()
@@ -2018,7 +1978,6 @@ func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) {
ClientVersion: o.ClientVersion, Radio: o.Radio,
BatteryMv: o.BatteryMv, UptimeSecs: o.UptimeSecs,
NoiseFloor: o.NoiseFloor,
LastPacketAt: o.LastPacketAt,
PacketsLastHour: plh,
Lat: lat, Lon: lon, NodeRole: nodeRole,
})
@@ -2060,7 +2019,6 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) {
ClientVersion: obs.ClientVersion, Radio: obs.Radio,
BatteryMv: obs.BatteryMv, UptimeSecs: obs.UptimeSecs,
NoiseFloor: obs.NoiseFloor,
LastPacketAt: obs.LastPacketAt,
PacketsLastHour: plh,
})
}
-59
View File
@@ -1,59 +0,0 @@
package main
import (
"database/sql"
"fmt"
"sync"
)
// rwCache holds a process-wide cached RW connection per database path.
// Instead of opening and closing a new RW connection on every call to openRW,
// we cache a single *sql.DB (which internally manages one connection due to
// SetMaxOpenConns(1)). This eliminates repeated open/close overhead for
// vacuum, prune, persist operations that run frequently (#921).
var rwCache = struct {
mu sync.Mutex
conns map[string]*sql.DB
}{conns: make(map[string]*sql.DB)}
// cachedRW returns a cached read-write connection for the given dbPath.
// The connection is created on first call and reused thereafter.
// Callers MUST NOT call Close() on the returned *sql.DB.
func cachedRW(dbPath string) (*sql.DB, error) {
rwCache.mu.Lock()
defer rwCache.mu.Unlock()
if db, ok := rwCache.conns[dbPath]; ok {
return db, nil
}
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL", dbPath)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
db.Close()
return nil, fmt.Errorf("set busy_timeout: %w", err)
}
rwCache.conns[dbPath] = db
return db, nil
}
// closeRWCache closes all cached RW connections (for tests/shutdown).
func closeRWCache() {
rwCache.mu.Lock()
defer rwCache.mu.Unlock()
for k, db := range rwCache.conns {
db.Close()
delete(rwCache.conns, k)
}
}
// rwCacheLen returns the number of cached connections (for testing).
func rwCacheLen() int {
rwCache.mu.Lock()
defer rwCache.mu.Unlock()
return len(rwCache.conns)
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestCachedRW_ReturnsSameHandle(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
// Create the DB file
f, _ := os.Create(dbPath)
f.Close()
defer closeRWCache()
db1, err := cachedRW(dbPath)
if err != nil {
t.Fatalf("first cachedRW: %v", err)
}
db2, err := cachedRW(dbPath)
if err != nil {
t.Fatalf("second cachedRW: %v", err)
}
if db1 != db2 {
t.Fatalf("cachedRW returned different handles: %p vs %p", db1, db2)
}
}
func TestCachedRW_100Calls_SingleConnection(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
f, _ := os.Create(dbPath)
f.Close()
defer closeRWCache()
var first interface{}
for i := 0; i < 100; i++ {
db, err := cachedRW(dbPath)
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
if i == 0 {
first = db
} else if db != first {
t.Fatalf("call %d returned different handle", i)
}
}
if rwCacheLen() != 1 {
t.Fatalf("expected 1 cached connection, got %d", rwCacheLen())
}
}
-109
View File
@@ -1,109 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// Issue #772 — shortened URL for easier sending over the mesh.
//
// Public keys are 64 hex chars. Operators want to share node URLs over a
// mesh radio link where every byte counts. We allow truncating the pubkey
// in the URL down to a minimum 8-hex-char prefix; the server resolves the
// prefix back to the full pubkey when (and only when) it is unambiguous.
func TestResolveNodePrefix_Unique(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// "aabbccdd" uniquely identifies the seeded TestRepeater (pubkey aabbccdd11223344).
node, ambiguous, err := db.GetNodeByPrefix("aabbccdd")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if ambiguous {
t.Fatalf("expected unambiguous match, got ambiguous=true")
}
if node == nil {
t.Fatalf("expected node, got nil")
}
if got, _ := node["public_key"].(string); got != "aabbccdd11223344" {
t.Errorf("expected public_key aabbccdd11223344, got %q", got)
}
}
func TestResolveNodePrefix_Ambiguous(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Insert a second node sharing the 8-char prefix "aabbccdd".
if _, err := db.conn.Exec(`INSERT INTO nodes (public_key, name, role, advert_count)
VALUES ('aabbccdd99887766', 'OtherNode', 'companion', 1)`); err != nil {
t.Fatal(err)
}
node, ambiguous, err := db.GetNodeByPrefix("aabbccdd")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !ambiguous {
t.Fatalf("expected ambiguous=true for shared prefix, got false (node=%v)", node)
}
if node != nil {
t.Errorf("expected nil node when ambiguous, got %v", node["public_key"])
}
}
func TestResolveNodePrefix_TooShort(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// <8 hex chars must NOT resolve, even if it would be unique.
node, _, err := db.GetNodeByPrefix("aabbccd")
if err == nil && node != nil {
t.Errorf("expected nil/error for 7-char prefix, got node %v", node["public_key"])
}
}
// Route-level: GET /api/nodes/<8-char-prefix> resolves to the full node.
func TestNodeDetailRoute_PrefixResolves(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for unique 8-char prefix, got %d body=%s", w.Code, w.Body.String())
}
var body NodeDetailResponse
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
pk, _ := body.Node["public_key"].(string)
if pk != "aabbccdd11223344" {
t.Errorf("expected resolved pubkey aabbccdd11223344, got %q", pk)
}
}
// Route-level: GET /api/nodes/<ambiguous-prefix> returns 409 with a hint.
func TestNodeDetailRoute_PrefixAmbiguous(t *testing.T) {
srv, router := setupTestServer(t)
if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key, name, role, advert_count)
VALUES ('aabbccdd99887766', 'OtherNode', 'companion', 1)`); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409 for ambiguous prefix, got %d body=%s", w.Code, w.Body.String())
}
}
+67 -485
View File
@@ -1,7 +1,6 @@
package main
import (
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
@@ -189,10 +188,6 @@ type PacketStore struct {
hashSizeInfoCache map[string]*hashSizeNodeInfo
hashSizeInfoAt time.Time
// Cached multi-byte capability map (pubkey → entry), recomputed every 15s.
multiByteCapCache map[string]*MultiByteCapEntry
multiByteCapAt time.Time
// Precomputed distinct advert pubkey count (refcounted for eviction correctness).
// Updated incrementally during Load/Ingest/Evict — avoids JSON parsing in GetPerfStoreStats.
advertPubkeys map[string]int // pubkey → number of advert packets referencing it
@@ -2276,10 +2271,6 @@ func (s *PacketStore) filterPackets(q PacketQuery) []*StoreTx {
}
// Single-pass filter: apply all predicates in one scan.
results := filterTxSlice(source, func(tx *StoreTx) bool {
// Data integrity: exclude legacy rows missing hash or timestamp (#871)
if tx.Hash == "" || tx.FirstSeen == "" {
return false
}
if hasType && (tx.PayloadType == nil || *tx.PayloadType != filterType) {
return false
}
@@ -2441,145 +2432,6 @@ func (s *PacketStore) fetchAndCacheRegionObs(region string) map[string]bool {
return m
}
// iataMatchesRegion returns true if iata matches any of the comma-separated
// region codes in regionParam. Comparison is case-insensitive and trim-tolerant.
// Empty iata never matches; empty regionParam never matches.
//
// #804: shared helper used by analytics to attribute transmissions to a node's
// HOME region (derived from observers that hear its zero-hop direct adverts)
// rather than to the observer that happened to relay a packet.
func iataMatchesRegion(iata, regionParam string) bool {
if iata == "" || regionParam == "" {
return false
}
codes := normalizeRegionCodes(regionParam)
if len(codes) == 0 {
return false
}
got := strings.TrimSpace(strings.ToUpper(iata))
if got == "" {
return false
}
for _, c := range codes {
if c == got {
return true
}
}
return false
}
// computeNodeHomeRegions returns a pubkey → IATA map deriving each node's
// HOME region from zero-hop DIRECT adverts. A zero-hop direct advert is the
// most authoritative location signal because the path byte is set locally on
// the originating radio and the packet has not been relayed: the observer
// that hears it is necessarily within direct RF range of the originator.
//
// When a node has zero-hop direct adverts heard by observers from multiple
// regions, the most-frequently-observed region wins (geographic plurality).
//
// Caller must hold s.mu (read or write). Returns empty map (not nil) if no
// observers are loaded or no zero-hop direct adverts have been seen.
//
// #804: feeds analytics region-attribution so a multi-byte repeater whose
// flood adverts get relayed across regions is still attributed to its home.
func (s *PacketStore) computeNodeHomeRegions() map[string]string {
// Build observer → IATA map. observers table is small (≪ packets), so a
// single DB read here is acceptable; resolveRegionObservers does similar.
obsIATA := make(map[string]string, 64)
if s.db != nil {
if observers, err := s.db.GetObservers(); err == nil {
for _, o := range observers {
if o.IATA != nil && *o.IATA != "" {
obsIATA[o.ID] = strings.TrimSpace(strings.ToUpper(*o.IATA))
}
}
}
}
if len(obsIATA) == 0 {
return map[string]string{}
}
// Tally zero-hop direct ADVERT region observations per pubkey.
type tally struct {
counts map[string]int
}
per := make(map[string]*tally, 256)
for _, tx := range s.packets {
if tx.RawHex == "" || len(tx.RawHex) < 4 {
continue
}
if tx.PayloadType == nil || *tx.PayloadType != PayloadADVERT {
continue
}
if tx.DecodedJSON == "" {
continue
}
header, err := strconv.ParseUint(tx.RawHex[:2], 16, 8)
if err != nil {
continue
}
routeType := header & 0x03
if routeType != uint64(RouteDirect) && routeType != uint64(RouteTransportDirect) {
continue
}
// Path byte index — for direct/transport-direct it's at offset 1
// (matches the analytics decoder's pathByteIdx logic).
if len(tx.RawHex) < 4 {
continue
}
pathByte, err := strconv.ParseUint(tx.RawHex[2:4], 16, 8)
if err != nil {
continue
}
hopCount := pathByte & 0x3F
if hopCount != 0 {
continue
}
var d map[string]interface{}
if json.Unmarshal([]byte(tx.DecodedJSON), &d) != nil {
continue
}
pk, _ := d["pubKey"].(string)
if pk == "" {
pk, _ = d["public_key"].(string)
}
if pk == "" {
continue
}
for _, obs := range tx.Observations {
iata := obsIATA[obs.ObserverID]
if iata == "" {
continue
}
t := per[pk]
if t == nil {
t = &tally{counts: map[string]int{}}
per[pk] = t
}
t.counts[iata]++
}
}
out := make(map[string]string, len(per))
for pk, t := range per {
var bestIATA string
bestCount := 0
for iata, n := range t.counts {
if n > bestCount || (n == bestCount && iata < bestIATA) {
bestCount = n
bestIATA = iata
}
}
if bestIATA != "" {
out[pk] = bestIATA
}
}
return out
}
// enrichObs returns a map with observation fields + transmission fields.
func (s *PacketStore) enrichObs(obs *StoreObs) map[string]interface{} {
tx := s.byTxID[obs.TransmissionID]
@@ -3930,18 +3782,8 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int,
// GetAnalyticsChannels returns full channel analytics computed from in-memory packets.
func (s *PacketStore) GetAnalyticsChannels(region string) map[string]interface{} {
return s.GetAnalyticsChannelsWithWindow(region, TimeWindow{})
}
// GetAnalyticsChannelsWithWindow returns channel analytics for the given region,
// optionally bounded to a time window (issue #842). Zero TimeWindow = all data.
func (s *PacketStore) GetAnalyticsChannelsWithWindow(region string, window TimeWindow) map[string]interface{} {
cacheKey := region
if !window.IsZero() {
cacheKey = region + "|" + window.CacheKey()
}
s.cacheMu.Lock()
if cached, ok := s.chanCache[cacheKey]; ok && time.Now().Before(cached.expiresAt) {
if cached, ok := s.chanCache[region]; ok && time.Now().Before(cached.expiresAt) {
s.cacheHits++
s.cacheMu.Unlock()
return cached.data
@@ -3949,43 +3791,16 @@ func (s *PacketStore) GetAnalyticsChannelsWithWindow(region string, window TimeW
s.cacheMisses++
s.cacheMu.Unlock()
result := s.computeAnalyticsChannels(region, window)
result := s.computeAnalyticsChannels(region)
s.cacheMu.Lock()
s.chanCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
s.chanCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
s.cacheMu.Unlock()
return result
}
// channelNameMatchesHash validates that a decrypted channel name hashes to the
// observed single-byte channel hash. This rejects rainbow-table mismatches where
// an observer's lookup table incorrectly maps a hash byte to the wrong name.
// Firmware invariant: channelHash = SHA256(SHA256("#name")[:16])[0]
func channelNameMatchesHash(name string, hashStr string) bool {
expected, err := strconv.Atoi(hashStr)
if err != nil {
return false
}
chanName := name
if !strings.HasPrefix(chanName, "#") {
chanName = "#" + chanName
}
h1 := sha256.Sum256([]byte(chanName))
h2 := sha256.Sum256(h1[:16])
return int(h2[0]) == expected
}
// isPlaceholderName returns true if the name is a "chN" placeholder (not a real decrypted name).
func isPlaceholderName(name string) bool {
if !strings.HasPrefix(name, "ch") {
return false
}
_, err := strconv.Atoi(name[2:])
return err == nil
}
func (s *PacketStore) computeAnalyticsChannels(region string, window TimeWindow) map[string]interface{} {
func (s *PacketStore) computeAnalyticsChannels(region string) map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -4034,9 +3849,6 @@ func (s *PacketStore) computeAnalyticsChannels(region string, window TimeWindow)
grpTxts := s.byPayloadType[5]
for _, tx := range grpTxts {
if !window.Includes(tx.FirstSeen) {
continue
}
if regionObs != nil {
match := false
for _, obs := range tx.Observations {
@@ -4067,27 +3879,16 @@ func (s *PacketStore) computeAnalyticsChannels(region string, window TimeWindow)
name = "ch" + hash
}
encrypted := decoded.Text == "" && decoded.Sender == ""
// Bug #978 fix: validate channel name against hash to reject rainbow-table mismatches.
// If the claimed channel name doesn't hash to the observed channelHash byte, discard it.
if name != "" && name != "ch"+hash && !channelNameMatchesHash(name, hash) {
name = "ch" + hash
encrypted = true
}
// Bug #978 fix: always group by hash byte alone — same physical channel,
// regardless of which observer decrypted it.
// Use hash as key for grouping (matches Node.js String(hash))
chKey := hash
if decoded.Type == "CHAN" && decoded.Channel != "" {
chKey = hash + "_" + decoded.Channel
}
ch := channelMap[chKey]
if ch == nil {
ch = &chanInfo{Hash: hash, Name: name, Senders: map[string]bool{}, LastActivity: tx.FirstSeen, Encrypted: encrypted}
channelMap[chKey] = ch
} else {
// Upgrade bucket name: if current is placeholder and we have a validated decrypted name
if isPlaceholderName(ch.Name) && !isPlaceholderName(name) {
ch.Name = name
}
}
ch.Messages++
ch.LastActivity = tx.FirstSeen
@@ -4177,18 +3978,8 @@ func (s *PacketStore) computeAnalyticsChannels(region string, window TimeWindow)
// GetAnalyticsRF returns full RF analytics computed from in-memory observations.
func (s *PacketStore) GetAnalyticsRF(region string) map[string]interface{} {
return s.GetAnalyticsRFWithWindow(region, TimeWindow{})
}
// GetAnalyticsRFWithWindow returns RF analytics bounded by an optional
// time window (issue #842). Zero TimeWindow = all data (backwards compatible).
func (s *PacketStore) GetAnalyticsRFWithWindow(region string, window TimeWindow) map[string]interface{} {
cacheKey := region
if !window.IsZero() {
cacheKey = region + "|" + window.CacheKey()
}
s.cacheMu.Lock()
if cached, ok := s.rfCache[cacheKey]; ok && time.Now().Before(cached.expiresAt) {
if cached, ok := s.rfCache[region]; ok && time.Now().Before(cached.expiresAt) {
s.cacheHits++
s.cacheMu.Unlock()
return cached.data
@@ -4196,16 +3987,16 @@ func (s *PacketStore) GetAnalyticsRFWithWindow(region string, window TimeWindow)
s.cacheMisses++
s.cacheMu.Unlock()
result := s.computeAnalyticsRF(region, window)
result := s.computeAnalyticsRF(region)
s.cacheMu.Lock()
s.rfCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
s.rfCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
s.cacheMu.Unlock()
return result
}
func (s *PacketStore) computeAnalyticsRF(region string, window TimeWindow) map[string]interface{} {
func (s *PacketStore) computeAnalyticsRF(region string) map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -4244,9 +4035,6 @@ func (s *PacketStore) computeAnalyticsRF(region string, window TimeWindow) map[s
for obsID := range regionObs {
obsList := s.byObserver[obsID]
for _, obs := range obsList {
if !window.Includes(obs.Timestamp) {
continue
}
totalObs++
tx := s.byTxID[obs.TransmissionID]
hash := ""
@@ -4332,12 +4120,6 @@ func (s *PacketStore) computeAnalyticsRF(region string, window TimeWindow) map[s
} else {
// No region: iterate all transmissions and their observations
for _, tx := range s.packets {
// Window filter: skip transmissions outside the requested window.
// We use tx.FirstSeen as the bounding timestamp; per-obs window
// filter below handles cases where individual obs timestamps differ.
if !window.Includes(tx.FirstSeen) {
continue
}
hash := tx.Hash
if hash != "" {
regionalHashes[hash] = true
@@ -5032,17 +4814,8 @@ func parsePathJSON(pathJSON string) []string {
}
func (s *PacketStore) GetAnalyticsTopology(region string) map[string]interface{} {
return s.GetAnalyticsTopologyWithWindow(region, TimeWindow{})
}
// GetAnalyticsTopologyWithWindow — see issue #842.
func (s *PacketStore) GetAnalyticsTopologyWithWindow(region string, window TimeWindow) map[string]interface{} {
cacheKey := region
if !window.IsZero() {
cacheKey = region + "|" + window.CacheKey()
}
s.cacheMu.Lock()
if cached, ok := s.topoCache[cacheKey]; ok && time.Now().Before(cached.expiresAt) {
if cached, ok := s.topoCache[region]; ok && time.Now().Before(cached.expiresAt) {
s.cacheHits++
s.cacheMu.Unlock()
return cached.data
@@ -5050,16 +4823,16 @@ func (s *PacketStore) GetAnalyticsTopologyWithWindow(region string, window TimeW
s.cacheMisses++
s.cacheMu.Unlock()
result := s.computeAnalyticsTopology(region, window)
result := s.computeAnalyticsTopology(region)
s.cacheMu.Lock()
s.topoCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
s.topoCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
s.cacheMu.Unlock()
return result
}
func (s *PacketStore) computeAnalyticsTopology(region string, window TimeWindow) map[string]interface{} {
func (s *PacketStore) computeAnalyticsTopology(region string) map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -5090,9 +4863,6 @@ func (s *PacketStore) computeAnalyticsTopology(region string, window TimeWindow)
perObserver := map[string]map[string]*struct{ minDist, maxDist, count int }{}
for _, tx := range s.packets {
if !window.Includes(tx.FirstSeen) {
continue
}
hops := txGetParsedPath(tx)
if len(hops) == 0 {
continue
@@ -5184,103 +4954,6 @@ func (s *PacketStore) computeAnalyticsTopology(region string, window TimeWindow)
}
}
// pmLookup resolves a hop hex string to its prefix-map candidates,
// applying the same truncation used during map construction.
pmLookup := func(hop string) []nodeInfo {
key := strings.ToLower(hop)
if len(key) > maxPrefixLen {
key = key[:maxPrefixLen]
}
return pm.m[key]
}
// --- Dedup pass: merge hop prefixes that resolve unambiguously to the same node ---
// Only merge when pm.m[hop] has exactly 1 candidate (unique_prefix).
// Ambiguous short prefixes (efiten's concern: 1-byte collisions) stay separate.
{
type dedupInfo struct {
totalCount int
longestHop string
}
byPubkey := map[string]*dedupInfo{} // pubkey → merged info
ambiguous := map[string]int{} // hop → count (kept as-is)
for h, c := range hopFreq {
candidates := pmLookup(h)
if len(candidates) == 1 {
pk := strings.ToLower(candidates[0].PublicKey)
if info, ok := byPubkey[pk]; ok {
info.totalCount += c
if len(h) > len(info.longestHop) {
info.longestHop = h
}
} else {
byPubkey[pk] = &dedupInfo{totalCount: c, longestHop: h}
}
} else {
ambiguous[h] = c
}
}
// Rebuild hopFreq
hopFreq = make(map[string]int, len(byPubkey)+len(ambiguous))
for _, info := range byPubkey {
hopFreq[info.longestHop] = info.totalCount
}
for h, c := range ambiguous {
hopFreq[h] = c
}
}
// --- Dedup pass for pairs: merge by resolved pubkey pair ---
{
type pairDedupInfo struct {
totalCount int
longestA string
longestB string
}
byPubkeyPair := map[string]*pairDedupInfo{} // "pkA|pkB" (sorted) → merged info
ambiguousPairs := map[string]int{}
for p, c := range pairFreq {
parts := strings.SplitN(p, "|", 2)
candA := pmLookup(parts[0])
candB := pmLookup(parts[1])
if len(candA) == 1 && len(candB) == 1 {
pkA := strings.ToLower(candA[0].PublicKey)
pkB := strings.ToLower(candB[0].PublicKey)
// Canonicalize by sorted pubkey
if pkA > pkB {
pkA, pkB = pkB, pkA
parts[0], parts[1] = parts[1], parts[0]
}
key := pkA + "|" + pkB
if info, ok := byPubkeyPair[key]; ok {
info.totalCount += c
if len(parts[0]) > len(info.longestA) {
info.longestA = parts[0]
}
if len(parts[1]) > len(info.longestB) {
info.longestB = parts[1]
}
} else {
byPubkeyPair[key] = &pairDedupInfo{totalCount: c, longestA: parts[0], longestB: parts[1]}
}
} else {
ambiguousPairs[p] = c
}
}
// Rebuild pairFreq
pairFreq = make(map[string]int, len(byPubkeyPair)+len(ambiguousPairs))
for _, info := range byPubkeyPair {
a, b := info.longestA, info.longestB
if a > b {
a, b = b, a
}
pairFreq[a+"|"+b] = info.totalCount
}
for p, c := range ambiguousPairs {
pairFreq[p] = c
}
}
// Top repeaters
type freqEntry struct {
hop string
@@ -5805,16 +5478,6 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
regionObs = s.resolveRegionObservers(region)
}
// #804: derive each node's HOME region from zero-hop direct adverts (the
// most authoritative location signal — those packets cannot have been
// relayed). When non-empty, multi-byte node attribution prefers this
// over observer-region. Falls back to observer-region when unknown.
nodeHomeRegion := s.computeNodeHomeRegions()
attributionMethod := "observer"
if region != "" && len(nodeHomeRegion) > 0 {
attributionMethod = "repeater"
}
allNodes, pm := s.getCachedNodesAndPM()
// Build pubkey→role map for filtering by node type.
@@ -5833,6 +5496,18 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
if tx.RawHex == "" {
continue
}
if regionObs != nil {
match := false
for _, obs := range tx.Observations {
if regionObs[obs.ObserverID] {
match = true
break
}
}
if !match {
continue
}
}
// Parse header and path byte
if len(tx.RawHex) < 4 {
@@ -5862,84 +5537,52 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
continue
}
// #804: pre-extract originator pubkey for ADVERT packets so we can
// (a) relax observer-region filter when the originator's HOME region
// matches the requested region (a flood relay heard outside the
// home region must still attribute to the home), and
// (b) reuse the parsed values below without re-parsing.
var advertPK, advertName string
var advertParsed bool
// Track originator from advert packets (including zero-hop adverts,
// keyed by pubKey so same-name nodes don't merge).
if tx.PayloadType != nil && *tx.PayloadType == PayloadADVERT && tx.DecodedJSON != "" {
var d map[string]interface{}
if json.Unmarshal([]byte(tx.DecodedJSON), &d) == nil {
pk := ""
if v, ok := d["pubKey"].(string); ok {
advertPK = v
pk = v
} else if v, ok := d["public_key"].(string); ok {
advertPK = v
pk = v
}
if n, ok := d["name"].(string); ok {
advertName = n
}
advertParsed = advertPK != ""
}
}
if regionObs != nil {
match := false
for _, obs := range tx.Observations {
if regionObs[obs.ObserverID] {
match = true
break
if pk != "" {
name := ""
if n, ok := d["name"].(string); ok {
name = n
}
if name == "" {
if len(pk) >= 8 {
name = pk[:8]
} else {
name = pk
}
}
// Skip zero-hop direct adverts for hash_size — the
// path byte is locally generated and unreliable.
// Still count the packet and update lastSeen.
isZeroHop := (routeType == uint64(RouteDirect) || routeType == uint64(RouteTransportDirect)) && (actualPathByte&0x3F) == 0
if byNode[pk] == nil {
role := nodeRoleByPK[pk] // empty if unknown
initHS := hashSize
if isZeroHop {
initHS = 0
}
byNode[pk] = map[string]interface{}{
"hashSize": initHS, "packets": 0,
"lastSeen": tx.FirstSeen, "name": name,
"role": role,
}
}
byNode[pk]["packets"] = byNode[pk]["packets"].(int) + 1
if !isZeroHop {
byNode[pk]["hashSize"] = hashSize
}
byNode[pk]["lastSeen"] = tx.FirstSeen
}
}
// #804: allow ADVERTs from a node whose HOME region matches the
// requested region even if no observer in that region heard this
// particular packet (e.g. flood relay heard only by an out-of-
// region observer). Conservative: only ADVERTs (the source is
// known by pubkey) and only when home is established.
if !match && advertParsed {
if home, ok := nodeHomeRegion[advertPK]; ok && iataMatchesRegion(home, region) {
match = true
}
}
if !match {
continue
}
}
// Track originator from advert packets (including zero-hop adverts,
// keyed by pubKey so same-name nodes don't merge).
if advertParsed {
pk := advertPK
name := advertName
if name == "" {
if len(pk) >= 8 {
name = pk[:8]
} else {
name = pk
}
}
// Skip zero-hop direct adverts for hash_size — the
// path byte is locally generated and unreliable.
// Still count the packet and update lastSeen.
isZeroHop := (routeType == uint64(RouteDirect) || routeType == uint64(RouteTransportDirect)) && (actualPathByte&0x3F) == 0
if byNode[pk] == nil {
role := nodeRoleByPK[pk] // empty if unknown
initHS := hashSize
if isZeroHop {
initHS = 0
}
byNode[pk] = map[string]interface{}{
"hashSize": initHS, "packets": 0,
"lastSeen": tx.FirstSeen, "name": name,
"role": role,
}
}
byNode[pk]["packets"] = byNode[pk]["packets"].(int) + 1
if !isZeroHop {
byNode[pk]["hashSize"] = hashSize
}
byNode[pk]["lastSeen"] = tx.FirstSeen
}
// Distribution/hourly/uniqueHops only for packets with relay hops
@@ -6020,15 +5663,6 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
// Multi-byte nodes
multiByteNodes := make([]map[string]interface{}, 0)
for pk, data := range byNode {
// #804: when a region filter is active, prefer the repeater's HOME
// region over the observer that happened to relay it. Falls back to
// the (already-applied) observer-region filter when the node's home
// region is unknown.
if region != "" {
if home, ok := nodeHomeRegion[pk]; ok && !iataMatchesRegion(home, region) {
continue
}
}
if data["hashSize"].(int) > 1 {
multiByteNodes = append(multiByteNodes, map[string]interface{}{
"name": data["name"], "hashSize": data["hashSize"],
@@ -6043,17 +5677,11 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
// Distribution by repeaters: count unique REPEATER nodes per hash size
distributionByRepeaters := map[string]int{"1": 0, "2": 0, "3": 0}
for pk, data := range byNode {
for _, data := range byNode {
role, _ := data["role"].(string)
if !strings.Contains(strings.ToLower(role), "repeater") {
continue
}
// #804: same repeater-region preference as multiByteNodes.
if region != "" {
if home, ok := nodeHomeRegion[pk]; ok && !iataMatchesRegion(home, region) {
continue
}
}
hs := data["hashSize"].(int)
key := strconv.Itoa(hs)
distributionByRepeaters[key]++
@@ -6066,7 +5694,6 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf
"hourly": hourly,
"topHops": topHops,
"multiByteNodes": multiByteNodes,
"attributionMethod": attributionMethod,
}
}
@@ -6543,51 +6170,6 @@ func EnrichNodeWithHashSize(node map[string]interface{}, info *hashSizeNodeInfo)
}
}
// EnrichNodeWithMultiByte adds multi-byte capability fields to a node map.
func EnrichNodeWithMultiByte(node map[string]interface{}, entry *MultiByteCapEntry) {
if entry == nil {
return
}
node["multi_byte_status"] = entry.Status
node["multi_byte_evidence"] = entry.Evidence
node["multi_byte_max_hash_size"] = entry.MaxHashSize
}
// GetMultiByteCapMap returns a cached pubkey → MultiByteCapEntry map.
// Reuses the same 15s TTL cache pattern as hash size info.
func (s *PacketStore) GetMultiByteCapMap() map[string]*MultiByteCapEntry {
s.hashSizeInfoMu.Lock()
if s.multiByteCapCache != nil && time.Since(s.multiByteCapAt) < 15*time.Second {
cached := s.multiByteCapCache
s.hashSizeInfoMu.Unlock()
return cached
}
s.hashSizeInfoMu.Unlock()
// Get adopter hash sizes from analytics for cross-referencing
analyticsData := s.GetAnalyticsHashSizes("")
adopterSizes := make(map[string]int)
if nodes, ok := analyticsData["nodes"].(map[string]map[string]interface{}); ok {
for pk, data := range nodes {
if hs, ok := data["hashSize"].(int); ok {
adopterSizes[pk] = hs
}
}
}
caps := s.computeMultiByteCapability(adopterSizes)
result := make(map[string]*MultiByteCapEntry, len(caps))
for i := range caps {
result[caps[i].PublicKey] = &caps[i]
}
s.hashSizeInfoMu.Lock()
s.multiByteCapCache = result
s.multiByteCapAt = time.Now()
s.hashSizeInfoMu.Unlock()
return result
}
// --- Multi-Byte Capability Inference ---
// MultiByteCapEntry represents a node's inferred multi-byte capability.
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"net/http"
"time"
)
// TimeWindow is a half-open time range used to bound analytics queries.
// Empty Since/Until means unbounded on that end (backwards compatible).
type TimeWindow struct {
Since string // RFC3339, empty = unbounded
Until string // RFC3339, empty = unbounded
// Label is a stable identifier for the user-requested window
// (e.g. "24h"). For relative windows it is the original alias; for
// absolute ranges it is empty (Since/Until are already stable).
// Used only for cache keying so that "?window=24h" produces a single
// cache entry instead of one per second.
Label string
}
// IsZero reports whether the window imposes no bounds at all.
func (w TimeWindow) IsZero() bool {
return w.Since == "" && w.Until == ""
}
// CacheKey returns a deterministic key suitable for analytics caches.
// For relative windows the key is the alias label so that the cache
// remains stable across the wall-clock advancing.
func (w TimeWindow) CacheKey() string {
if w.IsZero() {
return ""
}
if w.Label != "" {
return "rel:" + w.Label
}
return w.Since + "|" + w.Until
}
// Includes reports whether ts (an RFC3339-style string) falls within the
// window. Empty ts is treated as included (for callers that don't have a
// timestamp on every observation).
//
// Comparison is done by parsing both sides into time.Time. Lex compare is
// unsafe here because stored timestamps carry millisecond precision
// ("...HH:MM:SS.000Z") while bounds emitted by ParseTimeWindow do not
// ("...HH:MM:SSZ"), and '.' (0x2e) sorts before 'Z' (0x5a). If a timestamp
// fails to parse we fall back to lex compare to preserve old behavior.
func (w TimeWindow) Includes(ts string) bool {
if ts == "" {
return true
}
tt, terr := parseAnyRFC3339(ts)
if w.Since != "" {
if s, err := parseAnyRFC3339(w.Since); err == nil && terr == nil {
if tt.Before(s) {
return false
}
} else if ts < w.Since {
return false
}
}
if w.Until != "" {
if u, err := parseAnyRFC3339(w.Until); err == nil && terr == nil {
if tt.After(u) {
return false
}
} else if ts > w.Until {
return false
}
}
return true
}
// parseAnyRFC3339 accepts both fractional-second ("...000Z") and second-
// precision ("...Z") RFC3339 timestamps. time.RFC3339Nano handles both.
func parseAnyRFC3339(s string) (time.Time, error) {
return time.Parse(time.RFC3339Nano, s)
}
// ParseTimeWindow extracts a TimeWindow from query params.
//
// Supported parameters:
//
// ?window=1h | 24h | 7d | 30d — relative window ending "now"
// ?from=<RFC3339>&to=<RFC3339> — absolute custom range (either bound optional)
//
// When neither is set, returns the zero TimeWindow (unbounded; original behavior).
// Invalid values are silently ignored to preserve backwards compatibility.
func ParseTimeWindow(r *http.Request) TimeWindow {
q := r.URL.Query()
// Absolute range takes precedence if either bound is set.
from := q.Get("from")
to := q.Get("to")
if from != "" || to != "" {
w := TimeWindow{}
if from != "" {
if t, err := time.Parse(time.RFC3339, from); err == nil {
w.Since = t.UTC().Format(time.RFC3339)
}
}
if to != "" {
if t, err := time.Parse(time.RFC3339, to); err == nil {
w.Until = t.UTC().Format(time.RFC3339)
}
}
return w
}
// Relative window.
if win := q.Get("window"); win != "" {
var d time.Duration
switch win {
case "1h":
d = 1 * time.Hour
case "24h", "1d":
d = 24 * time.Hour
case "3d":
d = 3 * 24 * time.Hour
case "7d", "1w":
d = 7 * 24 * time.Hour
case "30d":
d = 30 * 24 * time.Hour
default:
// Unknown values are silently ignored — backwards compatible.
return TimeWindow{}
}
since := time.Now().UTC().Add(-d).Format(time.RFC3339)
return TimeWindow{Since: since, Label: win}
}
return TimeWindow{}
}
-144
View File
@@ -1,144 +0,0 @@
package main
import (
"net/http/httptest"
"strings"
"testing"
"time"
)
// Issue #842 — selectable analytics timeframes.
// Backend must accept ?window=1h|24h|7d|30d and ?from=/?to= and yield a
// TimeWindow that correctly bounds analytics queries.
func TestParseTimeWindow_Window24h(t *testing.T) {
r := httptest.NewRequest("GET", "/api/analytics/rf?window=24h", nil)
w := ParseTimeWindow(r)
if w.Since == "" {
t.Fatalf("window=24h: expected non-empty Since, got %q", w.Since)
}
since, err := time.Parse(time.RFC3339, w.Since)
if err != nil {
t.Fatalf("window=24h: Since %q is not RFC3339: %v", w.Since, err)
}
delta := time.Since(since)
if delta < 23*time.Hour || delta > 25*time.Hour {
t.Fatalf("window=24h: Since should be ~24h ago, got delta=%v", delta)
}
}
func TestParseTimeWindow_WindowAliases(t *testing.T) {
cases := map[string]time.Duration{
"1h": 1 * time.Hour,
"24h": 24 * time.Hour,
"7d": 7 * 24 * time.Hour,
"30d": 30 * 24 * time.Hour,
}
for q, want := range cases {
r := httptest.NewRequest("GET", "/api/analytics/rf?window="+q, nil)
got := ParseTimeWindow(r)
if got.Since == "" {
t.Errorf("window=%s: empty Since", q)
continue
}
since, err := time.Parse(time.RFC3339, got.Since)
if err != nil {
t.Errorf("window=%s: bad RFC3339 %q", q, got.Since)
continue
}
delta := time.Since(since)
// allow 5 minutes of slack
if delta < want-5*time.Minute || delta > want+5*time.Minute {
t.Errorf("window=%s: expected ~%v, got %v", q, want, delta)
}
}
}
func TestParseTimeWindow_FromTo(t *testing.T) {
from := "2026-04-01T00:00:00Z"
to := "2026-04-08T00:00:00Z"
r := httptest.NewRequest("GET", "/api/analytics/rf?from="+from+"&to="+to, nil)
w := ParseTimeWindow(r)
if w.Since != from {
t.Errorf("expected Since=%q, got %q", from, w.Since)
}
if w.Until != to {
t.Errorf("expected Until=%q, got %q", to, w.Until)
}
}
func TestParseTimeWindow_NoParams_BackwardsCompatible(t *testing.T) {
r := httptest.NewRequest("GET", "/api/analytics/rf", nil)
w := ParseTimeWindow(r)
if !w.IsZero() {
t.Errorf("no params should yield zero window, got %+v", w)
}
}
func TestTimeWindow_Includes(t *testing.T) {
w := TimeWindow{Since: "2026-04-01T00:00:00Z", Until: "2026-04-08T00:00:00Z"}
if !w.Includes("2026-04-05T12:00:00Z") {
t.Error("mid-range ts should be included")
}
if w.Includes("2026-03-31T23:59:59Z") {
t.Error("ts before Since should be excluded")
}
if w.Includes("2026-04-08T00:00:01Z") {
t.Error("ts after Until should be excluded")
}
// Empty ts always included (some observations lack timestamps)
if !w.Includes("") {
t.Error("empty ts should be included")
}
}
func TestTimeWindow_CacheKey_DistinctPerWindow(t *testing.T) {
a := TimeWindow{Since: "2026-04-01T00:00:00Z"}
b := TimeWindow{Since: "2026-04-02T00:00:00Z"}
z := TimeWindow{}
if a.CacheKey() == b.CacheKey() {
t.Error("different windows must produce different cache keys")
}
if z.CacheKey() != "" {
t.Errorf("zero window cache key must be empty, got %q", z.CacheKey())
}
if !strings.Contains(a.CacheKey(), "2026-04-01") {
t.Errorf("cache key should encode Since, got %q", a.CacheKey())
}
}
// Self-review fixes (#1018 polish).
// B1: a relative window must produce a STABLE cache key across calls,
// otherwise the analytics cache thrashes (one entry per second).
func TestTimeWindow_RelativeWindow_StableCacheKey(t *testing.T) {
r1 := httptest.NewRequest("GET", "/api/analytics/rf?window=24h", nil)
w1 := ParseTimeWindow(r1)
time.Sleep(1100 * time.Millisecond)
r2 := httptest.NewRequest("GET", "/api/analytics/rf?window=24h", nil)
w2 := ParseTimeWindow(r2)
if w1.CacheKey() != w2.CacheKey() {
t.Fatalf("relative window cache key must be stable across calls, got %q vs %q", w1.CacheKey(), w2.CacheKey())
}
}
// B2: stored timestamps use millisecond precision (".000Z") while RFC3339
// bounds have none. Includes() must use time-based compare, not lex compare,
// so tx past Until are correctly excluded regardless of fractional digits.
func TestTimeWindow_Includes_FractionalSecondsBoundary(t *testing.T) {
w := TimeWindow{Until: "2026-04-08T00:00:00Z"}
// A tx 1ms past Until should NOT be included.
if w.Includes("2026-04-08T00:00:00.001Z") {
t.Error("ts 1ms past Until must be excluded; lex compare against fractional ts is wrong")
}
// A tx well inside the window must be included.
if !w.Includes("2026-04-07T23:59:59.999Z") {
t.Error("ts just before Until must be included")
}
w2 := TimeWindow{Since: "2026-04-01T00:00:00Z"}
// A tx at exactly Since should be included.
if !w2.Includes("2026-04-01T00:00:00.000Z") {
t.Error("ts exactly at Since must be included; lex compare excludes it because '.' < 'Z'")
}
}
-338
View File
@@ -1,338 +0,0 @@
package main
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
)
// TestTopologyDedup_RepeatersMergeByPubkey verifies that topRepeaters
// merges entries whose hop prefixes resolve unambiguously to the same node.
func TestTopologyDedup_RepeatersMergeByPubkey(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
if err != nil {
t.Fatal(err)
}
defer conn.Close()
exec := func(s string) {
if _, err := conn.Exec(s); err != nil {
t.Fatalf("SQL exec failed: %v\nSQL: %s", err, s)
}
}
exec(`CREATE TABLE transmissions (
id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT,
route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT
)`)
exec(`CREATE TABLE observations (
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
)`)
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
exec(`CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, frequency REAL
)`)
exec(`CREATE TABLE schema_version (version INTEGER)`)
exec(`INSERT INTO schema_version (version) VALUES (1)`)
exec(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`)
// Insert two repeater nodes with distinct pubkeys.
// AQUA: pubkey starts with 0735bc...
// BETA: pubkey starts with 99aabb...
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('0735bc6dda4d1122aabbccdd', 'AQUA', 'Repeater')`)
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('99aabb001122334455667788', 'BETA', 'Repeater')`)
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// Create packets:
// - 10 packets with path ["07", "99aa"] (short prefix for AQUA, medium for BETA)
// - 5 packets with path ["0735bc", "99"] (medium prefix for AQUA, short for BETA)
// - 3 packets with path ["0735bc6dda4d", "99aabb"] (long prefix for both)
txID := 1
obsID := 1
insertTx := func(path string, count int) {
for i := 0; i < count; i++ {
ts := base.Add(time.Duration(txID) * time.Minute).Format(time.RFC3339)
hash := fmt.Sprintf("h%04d", txID)
conn.Exec("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?, ?, ?, ?, 0, 4, 1, ?)",
txID, "aabb", hash, ts, fmt.Sprintf(`{"pubKey":"pk%04d"}`, txID))
conn.Exec("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
obsID, txID, "obs1", "Obs1", "RX", -10.0, -80.0, 5, path, ts)
txID++
obsID++
}
}
insertTx(`["07","99aa"]`, 10)
insertTx(`["0735bc","99"]`, 5)
insertTx(`["0735bc6d","99aabb"]`, 3)
// Total: AQUA appears as "07" (10×), "0735bc" (5×), "0735bc6d" (3×) = 18 total
// Total: BETA appears as "99aa" (10×), "99" (5×), "99aabb" (3×) = 18 total
// After dedup, each should appear ONCE with count=18.
db, err := OpenDB(dbPath)
if err != nil {
t.Fatal(err)
}
defer db.conn.Close()
store := NewPacketStore(db, &PacketStoreConfig{MaxMemoryMB: 100})
if err := store.Load(); err != nil {
t.Fatal(err)
}
result := store.computeAnalyticsTopology("", TimeWindow{})
topRepeaters := result["topRepeaters"].([]map[string]interface{})
// Build a map of pubkey → total count from topRepeaters
pubkeyCounts := map[string]int{}
for _, entry := range topRepeaters {
pk, _ := entry["pubkey"].(string)
if pk == "" {
continue
}
pubkeyCounts[pk] += entry["count"].(int)
}
// Each pubkey should appear exactly once in topRepeaters
aquaEntries := 0
betaEntries := 0
for _, entry := range topRepeaters {
pk, _ := entry["pubkey"].(string)
if pk == "0735bc6dda4d1122aabbccdd" {
aquaEntries++
}
if pk == "99aabb001122334455667788" {
betaEntries++
}
}
if aquaEntries != 1 {
t.Errorf("AQUA should appear exactly once in topRepeaters after dedup, got %d entries", aquaEntries)
for _, e := range topRepeaters {
t.Logf(" entry: hop=%v name=%v pubkey=%v count=%v", e["hop"], e["name"], e["pubkey"], e["count"])
}
}
if betaEntries != 1 {
t.Errorf("BETA should appear exactly once in topRepeaters after dedup, got %d entries", betaEntries)
}
// Check that the merged count is correct (18 each)
if c := pubkeyCounts["0735bc6dda4d1122aabbccdd"]; c != 18 {
t.Errorf("AQUA total count should be 18, got %d", c)
}
if c := pubkeyCounts["99aabb001122334455667788"]; c != 18 {
t.Errorf("BETA total count should be 18, got %d", c)
}
}
// TestTopologyDedup_AmbiguousPrefixNotMerged verifies that ambiguous short
// prefixes (matching multiple nodes) are NOT merged — they stay separate.
func TestTopologyDedup_AmbiguousPrefixNotMerged(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
if err != nil {
t.Fatal(err)
}
defer conn.Close()
exec := func(s string) {
if _, err := conn.Exec(s); err != nil {
t.Fatalf("SQL exec failed: %v\nSQL: %s", err, s)
}
}
exec(`CREATE TABLE transmissions (
id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT,
route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT
)`)
exec(`CREATE TABLE observations (
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
)`)
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
exec(`CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, frequency REAL
)`)
exec(`CREATE TABLE schema_version (version INTEGER)`)
exec(`INSERT INTO schema_version (version) VALUES (1)`)
exec(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`)
// Two nodes whose pubkeys share the prefix "ab" — collision!
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('ab11223344556677aabbccdd', 'NODE_A', 'Repeater')`)
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('ab99887766554433aabbccdd', 'NODE_B', 'Repeater')`)
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
txID := 1
obsID := 1
// 10 packets with hop "ab" — ambiguous (matches both NODE_A and NODE_B)
for i := 0; i < 10; i++ {
ts := base.Add(time.Duration(txID) * time.Minute).Format(time.RFC3339)
hash := fmt.Sprintf("h%04d", txID)
conn.Exec("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?, ?, ?, ?, 0, 4, 1, ?)",
txID, "aabb", hash, ts, fmt.Sprintf(`{"pubKey":"pk%04d"}`, txID))
conn.Exec("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
obsID, txID, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `["ab"]`, ts)
txID++
obsID++
}
// 5 packets with hop "ab1122" — unambiguous (only NODE_A)
for i := 0; i < 5; i++ {
ts := base.Add(time.Duration(txID) * time.Minute).Format(time.RFC3339)
hash := fmt.Sprintf("h%04d", txID)
conn.Exec("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?, ?, ?, ?, 0, 4, 1, ?)",
txID, "aabb", hash, ts, fmt.Sprintf(`{"pubKey":"pk%04d"}`, txID))
conn.Exec("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
obsID, txID, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `["ab1122"]`, ts)
txID++
obsID++
}
db, err := OpenDB(dbPath)
if err != nil {
t.Fatal(err)
}
defer db.conn.Close()
store := NewPacketStore(db, &PacketStoreConfig{MaxMemoryMB: 100})
if err := store.Load(); err != nil {
t.Fatal(err)
}
result := store.computeAnalyticsTopology("", TimeWindow{})
topRepeaters := result["topRepeaters"].([]map[string]interface{})
// "ab" is ambiguous — should NOT be merged with "ab1122"
// We expect two separate entries: one for "ab" (count=10) and one for "ab1122" (count=5)
foundAb := false
foundAb1122 := false
for _, entry := range topRepeaters {
hop := entry["hop"].(string)
count := entry["count"].(int)
if hop == "ab" {
foundAb = true
if count != 10 {
t.Errorf("ambiguous hop 'ab' should have count=10, got %d", count)
}
}
if hop == "ab1122" {
foundAb1122 = true
if count != 5 {
t.Errorf("unambiguous hop 'ab1122' should have count=5, got %d", count)
}
}
}
if !foundAb {
t.Error("ambiguous hop 'ab' should remain as separate entry")
}
if !foundAb1122 {
t.Error("unambiguous hop 'ab1122' should remain as separate entry (not merged with ambiguous 'ab')")
}
}
// TestTopologyDedup_PairsMergeByPubkey verifies that topPairs merges
// pair entries whose hops resolve unambiguously to the same node pair.
func TestTopologyDedup_PairsMergeByPubkey(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
if err != nil {
t.Fatal(err)
}
defer conn.Close()
exec := func(s string) {
if _, err := conn.Exec(s); err != nil {
t.Fatalf("SQL exec failed: %v\nSQL: %s", err, s)
}
}
exec(`CREATE TABLE transmissions (
id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT,
route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT
)`)
exec(`CREATE TABLE observations (
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
)`)
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
exec(`CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, frequency REAL
)`)
exec(`CREATE TABLE schema_version (version INTEGER)`)
exec(`INSERT INTO schema_version (version) VALUES (1)`)
exec(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`)
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('0735bc6dda4d1122aabbccdd', 'AQUA', 'Repeater')`)
exec(`INSERT INTO nodes (public_key, name, role) VALUES ('99aabb001122334455667788', 'BETA', 'Repeater')`)
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
txID := 1
obsID := 1
insertTx := func(path string, count int) {
for i := 0; i < count; i++ {
ts := base.Add(time.Duration(txID) * time.Minute).Format(time.RFC3339)
hash := fmt.Sprintf("h%04d", txID)
conn.Exec("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?, ?, ?, ?, 0, 4, 1, ?)",
txID, "aabb", hash, ts, fmt.Sprintf(`{"pubKey":"pk%04d"}`, txID))
conn.Exec("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
obsID, txID, "obs1", "Obs1", "RX", -10.0, -80.0, 5, path, ts)
txID++
obsID++
}
}
// Path ["07","99aa"] → pair "07|99aa", 10 times
// Path ["0735bc","99"] → pair "0735bc|99" but sorted = "0735bc|99", 5 times
// Wait: pair sorting is by string comparison: "07" < "99aa", "0735bc" < "99"
// After dedup both should merge to AQUA|BETA pair with count=15
insertTx(`["07","99aa"]`, 10)
insertTx(`["0735bc","99"]`, 5)
db, err := OpenDB(dbPath)
if err != nil {
t.Fatal(err)
}
defer db.conn.Close()
store := NewPacketStore(db, &PacketStoreConfig{MaxMemoryMB: 100})
if err := store.Load(); err != nil {
t.Fatal(err)
}
result := store.computeAnalyticsTopology("", TimeWindow{})
topPairs := result["topPairs"].([]map[string]interface{})
// Should have exactly 1 pair entry for AQUA-BETA with count=15
aquaBetaPairs := 0
totalCount := 0
for _, entry := range topPairs {
pkA, _ := entry["pubkeyA"].(string)
pkB, _ := entry["pubkeyB"].(string)
if (pkA == "0735bc6dda4d1122aabbccdd" && pkB == "99aabb001122334455667788") ||
(pkA == "99aabb001122334455667788" && pkB == "0735bc6dda4d1122aabbccdd") {
aquaBetaPairs++
totalCount += entry["count"].(int)
}
}
if aquaBetaPairs != 1 {
t.Errorf("AQUA-BETA pair should appear exactly once after dedup, got %d entries", aquaBetaPairs)
for _, e := range topPairs {
t.Logf(" pair: hopA=%v hopB=%v count=%v pkA=%v pkB=%v", e["hopA"], e["hopB"], e["count"], e["pubkeyA"], e["pubkeyB"])
}
}
if totalCount != 15 {
t.Errorf("AQUA-BETA pair total count should be 15, got %d", totalCount)
}
}
-1
View File
@@ -859,7 +859,6 @@ type ObserverResp struct {
BatteryMv interface{} `json:"battery_mv"`
UptimeSecs interface{} `json:"uptime_secs"`
NoiseFloor interface{} `json:"noise_floor"`
LastPacketAt interface{} `json:"last_packet_at"`
PacketsLastHour int `json:"packetsLastHour"`
Lat interface{} `json:"lat"`
Lon interface{} `json:"lon"`
+4 -2
View File
@@ -37,11 +37,12 @@ func checkAutoVacuum(db *DB, cfg *Config, dbPath string) {
log.Printf("[db] vacuumOnStartup=true — starting one-time full VACUUM (ensure 2x DB size free disk space)...")
start := time.Now()
rw, err := cachedRW(dbPath)
rw, err := openRW(dbPath)
if err != nil {
log.Printf("[db] VACUUM failed: could not open RW connection: %v", err)
return
}
defer rw.Close()
if _, err := rw.Exec("PRAGMA auto_vacuum = INCREMENTAL"); err != nil {
log.Printf("[db] VACUUM failed: could not set auto_vacuum: %v", err)
@@ -70,11 +71,12 @@ func checkAutoVacuum(db *DB, cfg *Config, dbPath string) {
// runIncrementalVacuum runs PRAGMA incremental_vacuum(N) on a read-write
// connection. Safe to call on auto_vacuum=NONE databases (noop).
func runIncrementalVacuum(dbPath string, pages int) {
rw, err := cachedRW(dbPath)
rw, err := openRW(dbPath)
if err != nil {
log.Printf("[vacuum] could not open RW connection: %v", err)
return
}
defer rw.Close()
if _, err := rw.Exec(fmt.Sprintf("PRAGMA incremental_vacuum(%d)", pages)); err != nil {
log.Printf("[vacuum] incremental_vacuum error: %v", err)
+4 -8
View File
@@ -3,8 +3,6 @@
"apiKey": "your-secret-api-key-here",
"nodeBlacklist": [],
"_comment_nodeBlacklist": "Public keys of nodes to hide from all API responses. Use for trolls, offensive names, or nodes reporting false data that operators refuse to fix.",
"observerIATAWhitelist": [],
"_comment_observerIATAWhitelist": "Global IATA region whitelist. When non-empty, only observers whose IATA code (from MQTT topic) matches are processed. Case-insensitive. Empty = allow all. Unlike per-source iataFilter, this applies across all MQTT sources.",
"retention": {
"nodeDays": 7,
"observerDays": 14,
@@ -131,9 +129,7 @@
"SFO",
"OAK",
"MRY"
],
"region": "SJC",
"connectTimeoutSec": 45
]
}
],
"channelKeys": {
@@ -173,7 +169,7 @@
[37.20, -122.52]
],
"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."
"_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use tools/geofilter-builder.html to draw a polygon visually. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through."
},
"regions": {
"SJC": "San Jose, US",
@@ -228,10 +224,10 @@
"maxAgeDays": 5,
"_comment": "Neighbor edges older than this many days are pruned on startup and daily. Default: 5."
},
"_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_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional).",
"_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.",
"_comment_defaultRegion": "IATA code shown by default in region filters.",
"_comment_mapDefaults": "Initial map center [lat, lon] and zoom level.",
"_comment_regions": "IATA code display name mapping for the region filter UI. Each key is a 3-letter IATA code that an observer is tagged with (resolved priority: MQTT payload `region` field > topic-derived region > mqttSources.region). Observers without an IATA tag will not appear under any region filter — only under 'All Regions'. The region filter dropdown shows one entry per code listed here PLUS any extra IATA codes the server discovers from observers at runtime (so you can omit codes here and they will still be selectable, just labelled with the bare IATA code instead of a friendly name). Selecting 'All Regions' (or no region) returns results from every observer including those with no IATA tag; selecting one or more codes restricts results to packets observed by observers tagged with those codes. The reserved value 'All' (case-insensitive) is treated as 'no filter' on the server, so the URL ?region=All behaves identically to omitting the param. Issue #770."
"_comment_regions": "IATA code to display name mapping. Packets are tagged with region codes by MQTT topic structure."
}
-17
View File
@@ -1,17 +0,0 @@
// Package dbconfig provides the shared DBConfig struct used by both the server
// and ingestor binaries for SQLite vacuum and maintenance settings (#919, #921).
package dbconfig
// DBConfig controls SQLite vacuum and maintenance behavior (#919).
type DBConfig struct {
VacuumOnStartup bool `json:"vacuumOnStartup"` // one-time full VACUUM on startup if auto_vacuum is not INCREMENTAL
IncrementalVacuumPages int `json:"incrementalVacuumPages"` // pages returned to OS per reaper cycle (default 1024)
}
// GetIncrementalVacuumPages returns the configured pages or 1024 default.
func (c *DBConfig) GetIncrementalVacuumPages() int {
if c != nil && c.IncrementalVacuumPages > 0 {
return c.IncrementalVacuumPages
}
return 1024
}
-21
View File
@@ -1,21 +0,0 @@
package dbconfig
import "testing"
func TestGetIncrementalVacuumPages_Default(t *testing.T) {
var c *DBConfig
if got := c.GetIncrementalVacuumPages(); got != 1024 {
t.Fatalf("nil DBConfig: got %d, want 1024", got)
}
c = &DBConfig{}
if got := c.GetIncrementalVacuumPages(); got != 1024 {
t.Fatalf("zero DBConfig: got %d, want 1024", got)
}
}
func TestGetIncrementalVacuumPages_Configured(t *testing.T) {
c := &DBConfig{IncrementalVacuumPages: 512}
if got := c.GetIncrementalVacuumPages(); got != 512 {
t.Fatalf("got %d, want 512", got)
}
}
-3
View File
@@ -1,3 +0,0 @@
module github.com/meshcore-analyzer/dbconfig
go 1.22
+12 -38
View File
@@ -75,16 +75,6 @@
<h2>📊 Mesh Analytics</h2>
<p class="text-muted">Deep dive into your mesh network data</p>
<div id="analyticsRegionFilter" class="region-filter-container"></div>
<div class="time-window-filter" style="margin:8px 0">
<label for="analyticsTimeWindow" style="font-size:0.9em;color:var(--text-muted);margin-right:6px">Time window:</label>
<select id="analyticsTimeWindow" data-testid="analytics-time-window" aria-label="Time window">
<option value="">All data</option>
<option value="1h">Last 1 hour</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
</select>
</div>
<div class="analytics-tabs" id="analyticsTabs" role="tablist" aria-label="Analytics tabs">
<button class="tab-btn active" data-tab="overview">Overview</button>
<button class="tab-btn" data-tab="rf">RF / Signal</button>
@@ -133,12 +123,6 @@
RegionFilter.init(document.getElementById('analyticsRegionFilter'));
RegionFilter.onChange(function () { loadAnalytics(); });
// Time-window picker (#842) — refresh analytics on change.
const tw = document.getElementById('analyticsTimeWindow');
if (tw) {
tw.addEventListener('change', function () { loadAnalytics(); });
}
// Delegated click/keyboard handler for clickable table rows
const analyticsContent = document.getElementById('analyticsContent');
if (analyticsContent) {
@@ -166,24 +150,14 @@
async function loadAnalytics() {
try {
_analyticsData = {};
const rqs = RegionFilter.regionQueryString(); // "&region=..." or ""
// Time window picker (#842) — append &window=… when set.
// NOTE: only the three window-aware endpoints (rf/topology/channels)
// receive ?window=…; hash-sizes and hash-collisions are about node
// identity / hash-byte distribution and intentionally span all data.
const twEl = document.getElementById('analyticsTimeWindow');
const twVal = twEl ? twEl.value : '';
const tws = twVal ? '&window=' + encodeURIComponent(twVal) : '';
const baseQS = rqs.slice(1); // drop leading '&', "" or "region=…"
const sepBase = baseQS ? '?' + baseQS : '';
const windowedQS = (rqs + tws).slice(1);
const sepWin = windowedQS ? '?' + windowedQS : '';
const rqs = RegionFilter.regionQueryString();
const sep = rqs ? '?' + rqs.slice(1) : '';
const [hashData, rfData, topoData, chanData, collisionData] = await Promise.all([
api('/analytics/hash-sizes' + sepBase, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/rf' + sepWin, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/topology' + sepWin, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/channels' + sepWin, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/hash-collisions' + sepBase, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/hash-sizes' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/rf' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/topology' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/channels' + sep, { ttl: CLIENT_TTL.analyticsRF }),
api('/analytics/hash-collisions' + sep, { ttl: CLIENT_TTL.analyticsRF }),
]);
_analyticsData = { hashData, rfData, topoData, chanData, collisionData };
renderTab(_currentTab);
@@ -1758,8 +1732,8 @@
<div class="subpath-section">
<h5> Timeline</h5>
<div>First seen: ${data.firstSeen ? (typeof formatAbsoluteTimestamp === 'function' ? formatAbsoluteTimestamp(data.firstSeen) : new Date(data.firstSeen).toLocaleString()) : '—'}</div>
<div>Last seen: ${data.lastSeen ? (typeof formatAbsoluteTimestamp === 'function' ? formatAbsoluteTimestamp(data.lastSeen) : new Date(data.lastSeen).toLocaleString()) : '—'}</div>
<div>First seen: ${data.firstSeen ? new Date(data.firstSeen).toLocaleString() : '—'}</div>
<div>Last seen: ${data.lastSeen ? new Date(data.lastSeen).toLocaleString() : '—'}</div>
</div>
${data.observers.length ? `
@@ -2686,7 +2660,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
const name = esc(n.name || n.public_key.slice(0, 12));
const role = n.role ? `<span class="text-muted" style="font-size:0.82em">${esc(n.role)}</span>` : '';
const hs = n.hash_size ? ` <span class="text-muted" style="font-size:0.78em;opacity:0.7">${n.hash_size}B hash</span>` : '';
const when = n.last_seen ? ` <span class="text-muted" style="font-size:0.8em">${(typeof formatAbsoluteTimestamp === 'function') ? formatAbsoluteTimestamp(n.last_seen) : new Date(n.last_seen).toLocaleDateString()}</span>` : '';
const when = n.last_seen ? ` <span class="text-muted" style="font-size:0.8em">${new Date(n.last_seen).toLocaleDateString()}</span>` : '';
return `<div style="padding:3px 0"><a href="#/nodes/${encodeURIComponent(n.public_key)}" class="analytics-link">${name}</a> ${role}${hs}${when}</div>`;
}
@@ -3184,7 +3158,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
const t = new Date(d.t);
const x = sx(t.getTime());
const y = sy(d.v);
const ts = (typeof formatAbsoluteTimestamp === 'function') ? formatAbsoluteTimestamp(d.t) : t.toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC');
const ts = t.toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC');
const tip = `${label}: ${formatV(d.v)}${unit}\n${ts}`;
svg += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="8" fill="transparent" stroke="none" pointer-events="all"><title>${tip}</title></circle>`;
});
@@ -3198,7 +3172,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
const idx = Math.floor(i * (data.length - 1) / Math.max(xTicks - 1, 1));
const t = new Date(data[idx].t);
const x = sx(t.getTime());
const label = (typeof formatChartAxisLabel === 'function') ? formatChartAxisLabel(t, true) : t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const label = t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
svg += `<text x="${x.toFixed(1)}" y="${h - 5}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${label}</text>`;
}
return svg;
-33
View File
@@ -309,39 +309,6 @@ function formatTimestampWithTooltip(isoString, mode) {
return { text, tooltip, isFuture };
}
// Format a Date for chart axis labels, respecting customizer timestamp settings.
// shortForm: true = time only (for intra-day), false = date+time (multi-day).
function formatChartAxisLabel(d, shortForm) {
if (!(d instanceof Date) || !isFinite(d.getTime())) return '—';
var timezone = (typeof getTimestampTimezone === 'function') ? getTimestampTimezone() : 'local';
var preset = (typeof getTimestampFormatPreset === 'function') ? getTimestampFormatPreset() : 'iso';
var useUtc = timezone === 'utc';
if (preset === 'locale') {
if (shortForm) {
var opts = { hour: '2-digit', minute: '2-digit' };
if (useUtc) opts.timeZone = 'UTC';
return d.toLocaleTimeString([], opts);
}
var opts2 = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
if (useUtc) opts2.timeZone = 'UTC';
return d.toLocaleString([], opts2);
}
// ISO-style (iso or iso-seconds)
var hour = useUtc ? d.getUTCHours() : d.getHours();
var minute = useUtc ? d.getUTCMinutes() : d.getMinutes();
var timeStr = pad2(hour) + ':' + pad2(minute);
if (preset === 'iso-seconds') {
var sec = useUtc ? d.getUTCSeconds() : d.getSeconds();
timeStr += ':' + pad2(sec);
}
if (shortForm) return timeStr;
var month = useUtc ? d.getUTCMonth() + 1 : d.getMonth() + 1;
var day = useUtc ? d.getUTCDate() : d.getDate();
return pad2(month) + '-' + pad2(day) + ' ' + timeStr;
}
function truncate(str, len) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
+8 -2
View File
@@ -120,8 +120,8 @@
var ph = rect.height;
var vw = window.innerWidth;
var vh = window.innerHeight;
var finalX = x + pw > vw ? Math.max(0, vw - pw - 14) : x;
var finalY = y + ph > vh ? Math.max(0, vh - ph - 14) : y;
var finalX = x + pw > vw ? Math.max(0, vw - pw - 8) : x;
var finalY = y + ph > vh ? Math.max(0, vh - ph - 8) : y;
el.style.left = finalX + 'px';
el.style.top = finalY + 'px';
}
@@ -228,6 +228,12 @@
if (ch) showPopover(ch, e.clientX, e.clientY);
});
feed.addEventListener('contextmenu', function(e) {
var item = e.target.closest('.live-feed-item');
if (!item || !item._ccChannel) return;
e.preventDefault();
showPopover(item._ccChannel, e.clientX, e.clientY);
});
}
/**
+41 -185
View File
@@ -15,7 +15,6 @@ window.ChannelDecrypt = (function () {
'use strict';
var STORAGE_KEY = 'corescope_channel_keys';
var LABELS_KEY = 'corescope_channel_labels';
var CACHE_KEY = 'corescope_channel_cache';
// ---- Hex utilities ----
@@ -38,25 +37,6 @@ window.ChannelDecrypt = (function () {
// ---- Key derivation ----
// Detect whether SubtleCrypto is available. SubtleCrypto is only exposed
// in **secure contexts** (HTTPS or localhost) — when CoreScope is served
// over plain HTTP, `crypto.subtle` is undefined and any digest/HMAC call
// throws. We fall back to the vendored pure-JS implementation in
// public/vendor/sha256-hmac.js. PR #1021 did the same for AES-ECB.
function hasSubtle() {
return typeof crypto !== 'undefined' && crypto && crypto.subtle && typeof crypto.subtle.digest === 'function';
}
function pureCryptoOrThrow() {
var host = (typeof window !== 'undefined') ? window
: (typeof self !== 'undefined') ? self : null;
if (!host || !host.PureCrypto || !host.PureCrypto.sha256 || !host.PureCrypto.hmacSha256) {
throw new Error('PureCrypto vendor module not loaded (public/vendor/sha256-hmac.js). ' +
'crypto.subtle is unavailable (HTTP context) and no fallback present.');
}
return host.PureCrypto;
}
/**
* Derive AES-128 key from channel name: SHA-256("#channelname")[:16].
* @param {string} channelName - e.g. "#LongFast"
@@ -64,12 +44,8 @@ window.ChannelDecrypt = (function () {
*/
async function deriveKey(channelName) {
var enc = new TextEncoder();
var data = enc.encode(channelName);
if (hasSubtle()) {
var hash = await crypto.subtle.digest('SHA-256', data);
return new Uint8Array(hash).slice(0, 16);
}
return pureCryptoOrThrow().sha256(data).slice(0, 16);
var hash = await crypto.subtle.digest('SHA-256', enc.encode(channelName));
return new Uint8Array(hash).slice(0, 16);
}
/**
@@ -78,41 +54,46 @@ window.ChannelDecrypt = (function () {
* @returns {Promise<number>} single byte (0-255)
*/
async function computeChannelHash(key) {
if (hasSubtle()) {
var hash = await crypto.subtle.digest('SHA-256', key);
return new Uint8Array(hash)[0];
}
return pureCryptoOrThrow().sha256(key)[0];
var hash = await crypto.subtle.digest('SHA-256', key);
return new Uint8Array(hash)[0];
}
// ---- AES-128-ECB via vendored pure-JS implementation ----
//
// Web Crypto exposes AES-CBC/CTR/GCM but NOT raw AES-ECB. The previous
// implementation simulated ECB with AES-CBC + zero IV + a dummy PKCS7
// padding block; that hack throws OperationError on real ciphertext
// because Web Crypto validates PKCS7 padding on the decrypted output
// and the dummy padding bytes rarely form a valid PKCS7 sequence
// after decryption. We use a pure-JS AES-128 ECB core
// (public/vendor/aes-ecb.js, MIT, derived from aes-js by Richard
// Moore) so decryption is deterministic across browsers and works in
// HTTP contexts.
// ---- AES-128-ECB via Web Crypto (CBC with zero IV, block-by-block) ----
/**
* Decrypt AES-128-ECB.
* Decrypt AES-128-ECB by decrypting each 16-byte block independently
* using AES-CBC with a zero IV (equivalent to ECB for single blocks).
* @param {Uint8Array} key - 16-byte AES key
* @param {Uint8Array} ciphertext - must be a non-zero multiple of 16 bytes
* @returns {Promise<Uint8Array|null>} plaintext, or null on invalid input
* @param {Uint8Array} ciphertext - must be multiple of 16 bytes
* @returns {Promise<Uint8Array>} plaintext
*/
async function decryptECB(key, ciphertext) {
if (!ciphertext || ciphertext.length === 0 || ciphertext.length % 16 !== 0) {
if (ciphertext.length === 0 || ciphertext.length % 16 !== 0) {
return null;
}
var host = (typeof window !== 'undefined') ? window
: (typeof self !== 'undefined') ? self : null;
if (!host || !host.AES_ECB || !host.AES_ECB.decrypt) {
throw new Error('AES_ECB vendor module not loaded (public/vendor/aes-ecb.js)');
var cryptoKey = await crypto.subtle.importKey(
'raw', key, { name: 'AES-CBC' }, false, ['decrypt']
);
var zeroIV = new Uint8Array(16);
var plaintext = new Uint8Array(ciphertext.length);
for (var i = 0; i < ciphertext.length; i += 16) {
var block = ciphertext.slice(i, i + 16);
// Append a dummy block (16 bytes of 0x10 = PKCS7 padding for empty next block)
// so Web Crypto doesn't complain about padding
var padded = new Uint8Array(32);
padded.set(block, 0);
// Second block is PKCS7 padding: 16 bytes of 0x10
for (var j = 16; j < 32; j++) padded[j] = 16;
var decrypted = await crypto.subtle.decrypt(
{ name: 'AES-CBC', iv: zeroIV }, cryptoKey, padded
);
var decBytes = new Uint8Array(decrypted);
plaintext.set(decBytes.slice(0, 16), i);
}
return host.AES_ECB.decrypt(key, ciphertext);
return plaintext;
}
// ---- MAC verification ----
@@ -130,17 +111,13 @@ window.ChannelDecrypt = (function () {
secret.set(key, 0);
// remaining 16 bytes are already 0
var cryptoKey = await crypto.subtle.importKey(
'raw', secret, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
var sig = await crypto.subtle.sign('HMAC', cryptoKey, ciphertext);
var sigBytes = new Uint8Array(sig);
var macBytes = hexToBytes(macHex);
var sigBytes;
if (hasSubtle() && typeof crypto.subtle.importKey === 'function' && typeof crypto.subtle.sign === 'function') {
var cryptoKey = await crypto.subtle.importKey(
'raw', secret, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
var sig = await crypto.subtle.sign('HMAC', cryptoKey, ciphertext);
sigBytes = new Uint8Array(sig);
} else {
sigBytes = pureCryptoOrThrow().hmacSha256(secret, ciphertext);
}
return sigBytes[0] === macBytes[0] && sigBytes[1] === macBytes[1];
}
@@ -210,96 +187,12 @@ window.ChannelDecrypt = (function () {
// Alias used by channels.js
var decryptPacket = decrypt;
// ---- Live PSK decrypt (WS path) ----
//
// Build a Map<channelHashByte, { channelName, keyBytes, keyHex }> from all
// stored PSK keys so the WebSocket handler can do an O(1) lookup on each
// incoming GRP_TXT packet. Hash byte derivation is async, so we cache the
// map between calls and only rebuild when the stored-keys set changes.
var _keyMapCache = null;
var _keyMapSig = '';
function _keysSignature(keys) {
var names = Object.keys(keys).sort();
var sig = '';
for (var i = 0; i < names.length; i++) {
sig += names[i] + '=' + keys[names[i]] + ';';
}
return sig;
}
async function buildKeyMap() {
var keys = getKeys();
var sig = _keysSignature(keys);
if (_keyMapCache && _keyMapSig === sig) return _keyMapCache;
var map = new Map();
var names = Object.keys(keys);
for (var i = 0; i < names.length; i++) {
var channelName = names[i];
var keyHex = keys[channelName];
if (!keyHex || typeof keyHex !== 'string') continue;
var keyBytes;
try { keyBytes = hexToBytes(keyHex); } catch (e) { continue; }
if (keyBytes.length !== 16) continue;
var hashByte;
try { hashByte = await computeChannelHash(keyBytes); } catch (e) { continue; }
// First-write-wins on collision (rare): different channel names can
// hash to the same byte. The downstream MAC check still gates rendering.
if (!map.has(hashByte)) {
map.set(hashByte, { channelName: channelName, keyBytes: keyBytes, keyHex: keyHex });
}
}
_keyMapCache = map;
_keyMapSig = sig;
return map;
}
/**
* Attempt to decrypt a live GRP_TXT payload using a prebuilt key map.
* Returns { sender, text, channelName, channelHashByte } on success,
* or null when no key matches, MAC verification fails, or the payload
* is not an encrypted GRP_TXT.
*/
async function tryDecryptLive(payload, keyMap) {
if (!payload || payload.type !== 'GRP_TXT') return null;
if (!payload.encryptedData || !payload.mac) return null;
if (!keyMap || typeof keyMap.get !== 'function') return null;
var hashByte = payload.channelHash;
// channelHash arrives as either a number or a hex string in some paths;
// normalize to number so Map.get hits.
if (typeof hashByte === 'string') {
var n = parseInt(hashByte, 16);
if (!isFinite(n)) return null;
hashByte = n;
}
if (typeof hashByte !== 'number') return null;
var entry = keyMap.get(hashByte);
if (!entry) return null;
var result;
try {
result = await decrypt(entry.keyBytes, payload.mac, payload.encryptedData);
} catch (e) { return null; }
if (!result) return null;
return {
sender: result.sender || 'Unknown',
text: result.message || '',
channelName: entry.channelName,
channelHashByte: hashByte,
timestamp: result.timestamp || null
};
}
// ---- Key storage (localStorage) ----
function saveKey(channelName, keyHex, label) {
function saveKey(channelName, keyHex) {
var keys = getKeys();
keys[channelName] = keyHex;
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); } catch (e) { /* quota */ }
_keyMapCache = null; // invalidate live-decrypt index
if (typeof label === 'string' && label.trim()) {
saveLabel(channelName, label.trim());
}
}
// Alias used by channels.js
@@ -319,39 +212,8 @@ window.ChannelDecrypt = (function () {
var keys = getKeys();
delete keys[channelName];
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); } catch (e) { /* quota */ }
_keyMapCache = null; // invalidate live-decrypt index
// Also clear cached messages and any label for this channel (#1020)
// Also clear cached messages for this channel
clearChannelCache(channelName);
var labels = getLabels();
if (labels[channelName]) {
delete labels[channelName];
try { localStorage.setItem(LABELS_KEY, JSON.stringify(labels)); } catch (e) { /* quota */ }
}
}
// ---- User-supplied display labels (#1020) ----
// Stored separately from keys so we can display friendly names instead of
// psk:<hex8> for user-added PSK channels.
function getLabels() {
try {
var raw = localStorage.getItem(LABELS_KEY);
return raw ? JSON.parse(raw) : {};
} catch (e) { return {}; }
}
function getLabel(channelName) {
var labels = getLabels();
return labels[channelName] || '';
}
function saveLabel(channelName, label) {
var labels = getLabels();
if (typeof label === 'string' && label.trim()) {
labels[channelName] = label.trim();
} else {
delete labels[channelName];
}
try { localStorage.setItem(LABELS_KEY, JSON.stringify(labels)); } catch (e) { /* quota */ }
}
/** Remove cached messages for a specific channel (by name or hash). */
@@ -424,16 +286,10 @@ window.ChannelDecrypt = (function () {
getKeys: getKeys,
getStoredKeys: getStoredKeys,
removeKey: removeKey,
// #1020: optional user-friendly display labels for stored keys
saveLabel: saveLabel,
getLabel: getLabel,
getLabels: getLabels,
clearChannelCache: clearChannelCache,
cacheMessages: cacheMessages,
getCachedMessages: getCachedMessages,
setCache: setCache,
getCache: getCache,
buildKeyMap: buildKeyMap,
tryDecryptLive: tryDecryptLive
getCache: getCache
};
})();
+37 -168
View File
@@ -339,10 +339,8 @@
}
}
// Add a user channel by name (#channelname) or hex key.
// `label` (#1020) is an optional friendly name shown in the sidebar instead
// of "psk:<hex8>" — stored alongside the key in localStorage.
async function addUserChannel(val, label) {
// Add a user channel by name (#channelname) or hex key
async function addUserChannel(val) {
var displayName = val.startsWith('#') ? val : (isHexKey(val) ? val.substring(0, 8) + '…' : '#' + val);
showAddStatus('Decrypting ' + displayName + ' messages…', 'loading');
var channelName, keyHex;
@@ -361,8 +359,7 @@
keyHex = ChannelDecrypt.bytesToHex(keyBytes2);
}
// #1020: persist optional user-supplied label alongside the key
ChannelDecrypt.storeKey(channelName, keyHex, label);
ChannelDecrypt.storeKey(channelName, keyHex);
// Compute channel hash byte to find matching encrypted channels
var keyBytes3 = ChannelDecrypt.hexToBytes(keyHex);
@@ -381,21 +378,15 @@
if (existingEncrypted) {
targetHash = existingEncrypted.hash;
}
var selectResult = await selectChannel(targetHash, { userKey: keyHex, channelHashByte: hashByte, channelName: channelName });
await selectChannel(targetHash, { userKey: keyHex, channelHashByte: hashByte, channelName: channelName });
// #1020: derive count from selectChannel's reported result, not from a
// DOM scrape that can race with rendering.
var msgCount = (selectResult && typeof selectResult.messageCount === 'number')
? selectResult.messageCount
: (Array.isArray(messages) ? messages.length : 0);
var displayLabel = (typeof label === 'string' && label.trim()) ? label.trim() :
(channelName.startsWith('psk:') ? 'Custom channel (' + channelName.substring(4) + ')' : channelName);
if (selectResult && selectResult.wrongKey) {
showAddStatus('Key does not match any packets for ' + displayLabel, 'error');
} else if (msgCount > 0) {
showAddStatus('Added ' + displayLabel + ' — ' + msgCount + ' messages decrypted', 'success');
// Show success feedback (#759)
var msgCount = document.querySelectorAll('#chMessages .ch-msg').length;
var userDisplay = channelName.startsWith('psk:') ? 'Custom channel (' + channelName.substring(4) + ')' : channelName;
if (msgCount > 0) {
showAddStatus('Added ' + userDisplay + ' — ' + msgCount + ' messages decrypted', 'success');
} else {
showAddStatus('Added ' + displayLabel + ' — no messages found yet', 'warn');
showAddStatus('No messages found for ' + userDisplay, 'warn');
}
} catch (err) {
showAddStatus('Failed to decrypt', 'error');
@@ -408,17 +399,14 @@
// remove a key they added but that the server already knows about.
function mergeUserChannels() {
var keys = ChannelDecrypt.getStoredKeys();
var labels = (typeof ChannelDecrypt.getLabels === 'function') ? ChannelDecrypt.getLabels() : {};
var names = Object.keys(keys);
for (var i = 0; i < names.length; i++) {
var name = names[i];
var label = labels[name] || '';
var matched = false;
for (var j = 0; j < channels.length; j++) {
var ch = channels[j];
if (ch.name === name || ch.hash === name || ch.hash === ('user:' + name)) {
ch.userAdded = true;
if (label) ch.userLabel = label;
matched = true;
break;
}
@@ -427,7 +415,6 @@
channels.push({
hash: 'user:' + name,
name: name,
userLabel: label,
messageCount: 0,
lastActivityMs: 0,
lastSender: '',
@@ -643,11 +630,6 @@
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>
@@ -696,13 +678,10 @@
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);
await addUserChannel(val);
};
chKeyForm.addEventListener('submit', submitHandler);
var chKeyInput = document.getElementById('chKeyInput');
@@ -814,14 +793,6 @@
renderChannelList();
return;
}
// Color clear button — remove color without opening picker (#681)
const clearBtn = e.target.closest('.ch-color-clear');
if (clearBtn && window.ChannelColors) {
e.stopPropagation();
var clearCh = clearBtn.getAttribute('data-channel');
if (clearCh) { window.ChannelColors.remove(clearCh); renderChannelList(); }
return;
}
// Color dot click — open picker, don't select channel
const dot = e.target.closest('.ch-color-dot');
if (dot && window.ChannelColorPicker) {
@@ -929,11 +900,6 @@
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;
@@ -960,10 +926,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 + ':' + channelKey);
if (pktHash) seenHashes.add(pktHash + ':' + channelKey);
var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelName);
if (pktHash) seenHashes.add(pktHash + ':' + channelName);
var ch = channels.find(function (c) { return c.hash === channelKey; });
var ch = channels.find(function (c) { return c.hash === channelName; });
if (ch) {
if (isFirstObservation) ch.messageCount = (ch.messageCount || 0) + 1;
ch.lastActivityMs = Date.now();
@@ -973,7 +939,7 @@
} else if (isFirstObservation) {
// New channel we haven't seen
channels.push({
hash: channelKey,
hash: channelName,
name: channelName,
messageCount: 1,
lastActivityMs: Date.now(),
@@ -984,7 +950,7 @@
}
// If this message is for the selected channel, append to messages
if (selectedHash && channelKey === selectedHash) {
if (selectedHash && channelName === 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) {
@@ -1037,83 +1003,8 @@
processWSBatch(msgs, selectedRegions);
}
// Pre-pass: rewrite encrypted GRP_TXT live packets into decrypted form
// when a stored PSK key matches their channel hash byte (#1029 — live
// PSK decrypt). Without this, users viewing a PSK-decrypted channel
// had to refresh the page to see new messages.
async function decryptLivePSKBatch(msgs) {
if (typeof ChannelDecrypt === 'undefined' ||
typeof ChannelDecrypt.tryDecryptLive !== 'function') {
return;
}
// Quick scan: do any messages look like encrypted GRP_TXT?
var anyEncrypted = false;
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.type === 'GRP_TXT' && p.encryptedData && p.mac) { anyEncrypted = true; break; }
}
if (!anyEncrypted) return;
var keyMap;
try { keyMap = await ChannelDecrypt.buildKeyMap(); } catch (e) { return; }
if (!keyMap || keyMap.size === 0) return;
for (var j = 0; j < msgs.length; j++) {
var m = msgs[j];
var payload = m && m.data && m.data.decoded && m.data.decoded.payload;
if (!payload || payload.type !== 'GRP_TXT' || !payload.encryptedData || !payload.mac) continue;
var dec;
try { dec = await ChannelDecrypt.tryDecryptLive(payload, keyMap); } catch (e) { dec = null; }
if (!dec) continue;
// Rewrite payload into a CHAN-like shape so processWSBatch picks it
// 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;
if (m.data.decoded.header) {
// Leave payloadTypeName as GRP_TXT — processWSBatch already
// accepts both 'message' and GRP_TXT-typed packet messages.
}
}
}
wsHandler = debouncedOnWS(function (msgs) {
var selectedRegions = getSelectedRegionsSnapshot();
var prior = selectedHash;
decryptLivePSKBatch(msgs).then(function () {
// Bump unread for live-decrypted channels the user is NOT viewing.
// Done here (not inside processWSBatch) so the count reflects ONLY
// newly-decrypted live packets, not historical-fetch path.
var bumped = false;
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;
// 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;
}
}
processWSBatch(msgs, selectedRegions);
if (bumped) renderChannelList();
});
handleWSBatch(msgs);
});
window._channelsHandleWSBatchForTest = handleWSBatch;
window._channelsProcessWSBatchForTest = processWSBatch;
@@ -1183,51 +1074,31 @@
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 name = isEncrypted ? (ch.name || 'Unknown') : (ch.name || `Channel ${formatHashHex(ch.hash)}`);
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 preview = 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());
const encClass = isEncrypted ? ' ch-encrypted' : '';
const abbr = isEncrypted ? '🔒' : (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>'
: '';
// M4: Remove button for user-added channels
const removeBtn = ch.userAdded ? ' <button class="ch-remove-btn" data-remove-channel="' + escapeHtml(ch.hash) + '" title="Remove channel" aria-label="Remove ' + escapeHtml(name) + '">✕</button>' : '';
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>
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"' : ''}>
<div class="ch-badge" style="background:${color}" aria-hidden="true">${isEncrypted ? '🔒' : 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-name">${escapeHtml(name)}</span>
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>${removeBtn}
</div>
<div class="ch-item-preview">${escapeHtml(preview)}</div>
@@ -1240,9 +1111,6 @@
const rp = RegionFilter.getRegionParam() || '';
const request = beginMessageRequest(hash, rp);
selectedHash = hash;
// Clear unread badge on the channel we're about to view (#1029).
var __selCh = channels.find(function (c) { return c.hash === hash; });
if (__selCh && __selCh.unread) { __selCh.unread = 0; }
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
renderChannelList();
const ch = channels.find(c => c.hash === hash);
@@ -1269,14 +1137,14 @@
}
}
});
if (isStaleMessageRequest(request)) return { stale: true };
if (isStaleMessageRequest(request)) return true;
if (result.wrongKey) {
msgEl.innerHTML = '<div class="ch-empty ch-wrong-key">🔒 Key does not match — no messages could be decrypted</div>';
return { wrongKey: true, messageCount: 0 };
return true;
}
if (result.error) {
msgEl.innerHTML = '<div class="ch-empty">' + escapeHtml(result.error) + '</div>';
return { error: result.error, messageCount: 0 };
return true;
}
messages = result.messages || [];
if (messages.length === 0) {
@@ -1286,12 +1154,13 @@
renderMessages();
scrollToBottom();
}
return { messageCount: messages.length };
return true;
}
// Client-side decryption path (#725 M2)
if (decryptOpts && decryptOpts.userKey) {
return await decryptAndRender(decryptOpts.userKey, decryptOpts.channelHashByte, decryptOpts.channelName);
await decryptAndRender(decryptOpts.userKey, decryptOpts.channelHashByte, decryptOpts.channelName);
return;
}
// Check if this is a user-added channel that needs decryption
+5 -47
View File
@@ -23,28 +23,8 @@ function comparePacketSets(hashesA, hashesB) {
return { onlyA: onlyA, onlyB: onlyB, both: both };
}
/**
* Filter packets by route type.
* mode: 'all' | 'flood' | 'direct'
* Flood = route_type 0 (TransportFlood) or 1 (Flood)
* Direct = route_type 2 (Direct) or 3 (TransportDirect)
*/
function filterPacketsByRoute(packets, mode) {
if (!packets || mode === 'all') return packets || [];
if (mode === 'flood') {
return packets.filter(function (p) { return p.route_type === 0 || p.route_type === 1; });
}
if (mode === 'direct') {
return packets.filter(function (p) { return p.route_type === 2 || p.route_type === 3; });
}
return packets;
}
// Expose for testing
if (typeof window !== 'undefined') {
window.comparePacketSets = comparePacketSets;
window.filterPacketsByRoute = filterPacketsByRoute;
}
if (typeof window !== 'undefined') window.comparePacketSets = comparePacketSets;
(function () {
var PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' };
@@ -56,7 +36,6 @@ if (typeof window !== 'undefined') {
var packetsA = [];
var packetsB = [];
var currentView = 'summary';
var routeFilter = 'all';
function init(app, routeParam) {
// Parse preselected observers from URL: #/compare?a=ID1&b=ID2
@@ -68,7 +47,6 @@ if (typeof window !== 'undefined') {
packetsA = [];
packetsB = [];
currentView = 'summary';
routeFilter = 'all';
app.innerHTML = '<div class="compare-page" style="padding:16px">' +
'<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:16px">' +
@@ -98,7 +76,6 @@ if (typeof window !== 'undefined') {
comparisonResult = null;
packetsA = [];
packetsB = [];
routeFilter = 'all';
}
async function loadObservers() {
@@ -138,14 +115,6 @@ if (typeof window !== 'undefined') {
'<select id="compareObsB" class="compare-select">' + optionsHtml + '</select>' +
'</div>' +
'<button id="compareBtn" class="compare-btn" disabled>Compare</button>' +
'<div class="compare-select-group">' +
'<label for="compareRouteFilter">Packet Type</label>' +
'<select id="compareRouteFilter" class="compare-select">' +
'<option value="all">All packets</option>' +
'<option value="flood">Flood only</option>' +
'<option value="direct">Direct only</option>' +
'</select>' +
'</div>' +
'</div>';
var ddA = document.getElementById('compareObsA');
@@ -155,13 +124,6 @@ if (typeof window !== 'undefined') {
if (selA) ddA.value = selA;
if (selB) ddB.value = selB;
var ddRoute = document.getElementById('compareRouteFilter');
ddRoute.value = routeFilter;
ddRoute.addEventListener('change', function () {
routeFilter = ddRoute.value;
if (comparisonResult) runComparison();
});
function updateBtn() {
selA = ddA.value || null;
selB = ddB.value || null;
@@ -200,20 +162,16 @@ if (typeof window !== 'undefined') {
packetsA = results[0].packets || [];
packetsB = results[1].packets || [];
// Apply flood/direct filter (#928)
var filteredA = filterPacketsByRoute(packetsA, routeFilter);
var filteredB = filterPacketsByRoute(packetsB, routeFilter);
var hashesA = new Set(filteredA.map(function (p) { return p.hash; }));
var hashesB = new Set(filteredB.map(function (p) { return p.hash; }));
var hashesA = new Set(packetsA.map(function (p) { return p.hash; }));
var hashesB = new Set(packetsB.map(function (p) { return p.hash; }));
comparisonResult = comparePacketSets(hashesA, hashesB);
// Build hash→packet lookups for detail rendering
comparisonResult.packetMapA = new Map();
comparisonResult.packetMapB = new Map();
filteredA.forEach(function (p) { comparisonResult.packetMapA.set(p.hash, p); });
filteredB.forEach(function (p) { comparisonResult.packetMapB.set(p.hash, p); });
packetsA.forEach(function (p) { comparisonResult.packetMapA.set(p.hash, p); });
packetsB.forEach(function (p) { comparisonResult.packetMapB.set(p.hash, p); });
currentView = 'summary';
renderComparison();
+6 -39
View File
@@ -33,7 +33,7 @@
'meshcore-live-heatmap-opacity'
];
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit', 'favorites', 'myNodes'];
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit'];
var OBJECT_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps'];
var SCALAR_SECTIONS = ['heatmapOpacity', 'liveHeatmapOpacity'];
var DISTANCE_UNIT_VALUES = ['km', 'mi', 'auto'];
@@ -313,17 +313,9 @@
function readOverrides() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
var parsed = (raw != null) ? JSON.parse(raw) : {};
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
// Include favorites and claimed nodes from their own localStorage keys
try {
var favs = JSON.parse(localStorage.getItem('meshcore-favorites') || '[]');
if (Array.isArray(favs) && favs.length) parsed.favorites = favs;
} catch (e) { /* ignore */ }
try {
var myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
if (Array.isArray(myNodes) && myNodes.length) parsed.myNodes = myNodes;
} catch (e) { /* ignore */ }
if (raw == null) return {};
var parsed = JSON.parse(raw);
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed;
} catch (e) {
return {};
@@ -394,28 +386,14 @@
function writeOverrides(delta) {
if (delta == null || typeof delta !== 'object') return;
// Extract favorites/myNodes and store in their own localStorage keys
if (Array.isArray(delta.favorites)) {
try { localStorage.setItem('meshcore-favorites', JSON.stringify(delta.favorites)); } catch (e) { /* ignore */ }
}
if (Array.isArray(delta.myNodes)) {
try { localStorage.setItem('meshcore-my-nodes', JSON.stringify(delta.myNodes)); } catch (e) { /* ignore */ }
}
// Build theme-only delta (without favorites/myNodes)
var themeDelta = {};
for (var k in delta) {
if (delta.hasOwnProperty(k) && k !== 'favorites' && k !== 'myNodes') {
themeDelta[k] = delta[k];
}
}
// If empty, remove key entirely
var keys = Object.keys(themeDelta);
var keys = Object.keys(delta);
if (keys.length === 0) {
try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ }
_updateSaveStatus('saved');
return;
}
var validated = _validateDelta(themeDelta);
var validated = _validateDelta(delta);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(validated));
_updateSaveStatus('saved');
@@ -780,17 +758,6 @@
if (key === 'distanceUnit' && DISTANCE_UNIT_VALUES.indexOf(obj[key]) === -1) {
errors.push('Invalid distanceUnit: "' + obj[key] + '" — must be km, mi, or auto');
}
// Validate favorites and myNodes arrays
if (key === 'favorites') {
if (!Array.isArray(obj[key])) {
errors.push('"favorites" must be an array of public key strings');
}
}
if (key === 'myNodes') {
if (!Array.isArray(obj[key])) {
errors.push('"myNodes" must be an array of node objects');
}
}
}
return { valid: errors.length === 0, errors: errors };
}
+1 -45
View File
@@ -26,12 +26,6 @@
#btnCopy { padding: 6px 14px; background: #1a4a7a; color: #7ec8e3; border-radius: 6px; border: none; cursor: pointer; font-size: 0.85rem; white-space: nowrap; align-self: flex-end; }
#btnCopy:hover { background: #2a6aaa; }
#btnCopy.copied { background: #1a6a3a; color: #7effa0; }
#btnSaveDraft { background: #1a5a3a; color: #7effa0; }
#btnSaveDraft:hover { background: #2a7a4a; }
#btnLoadDraft { background: #3a3a1a; color: #ffe07e; }
#btnLoadDraft:hover { background: #5a5a2a; }
#btnDownload { background: #1a4a7a; color: #7ec8e3; }
#btnDownload:hover { background: #2a6aaa; }
#counter { font-size: 0.8rem; color: #888; padding-top: 6px; white-space: nowrap; }
.bufferRow { display: flex; align-items: center; gap: 8px; }
.bufferRow label { font-size: 0.85rem; color: #aaa; }
@@ -51,8 +45,6 @@
<div class="controls">
<button id="btnUndo">↩ Undo</button>
<button id="btnClear">✕ Clear</button>
<button id="btnSaveDraft">💾 Save Draft</button>
<button id="btnLoadDraft">📂 Load Draft</button>
</div>
<div class="bufferRow">
<label for="bufferKm">Buffer km:</label>
@@ -71,18 +63,16 @@
<div style="display:flex;flex-direction:column;gap:8px;align-items:flex-end">
<span id="counter">0 points</span>
<button id="btnCopy">Copy</button>
<button id="btnDownload">⬇ Download</button>
</div>
</div>
<!-- Instructions: paste the output into config.json as a top-level "geo_filter" key, then restart the server -->
<div id="help-bar">
<strong>Save Draft</strong> preserves your polygon across sessions. <strong>Download</strong> exports a JSON snippet → paste as a top-level key in <code>config.json</code> → restart the server.
Copy the JSON above → paste as a top-level key in <code>config.json</code> → restart the server.
Nodes with no GPS fix always pass through. Remove the <code>geo_filter</code> block to disable filtering.
&nbsp;·&nbsp; <a href="/geofilter-docs.html">Documentation</a>
</div>
<script src="geofilter-draft.js"></script>
<script>
const map = L.map('map').setView([50.5, 4.4], 8);
@@ -176,40 +166,6 @@ document.getElementById('btnCopy').addEventListener('click', function() {
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
});
});
document.getElementById('btnSaveDraft').addEventListener('click', function() {
if (points.length < 3) return;
const bufferKm = parseFloat(document.getElementById('bufferKm').value) || 0;
GeofilterDraft.saveDraft(points, bufferKm);
const btn = document.getElementById('btnSaveDraft');
btn.textContent = '✓ Saved';
setTimeout(() => { btn.textContent = '💾 Save Draft'; }, 2000);
});
document.getElementById('btnLoadDraft').addEventListener('click', function() {
const draft = GeofilterDraft.loadDraft();
if (!draft || !draft.polygon || draft.polygon.length < 3) return;
// Clear current
markers.forEach(m => map.removeLayer(m));
markers = [];
points = draft.polygon.slice();
if (draft.bufferKm != null) document.getElementById('bufferKm').value = draft.bufferKm;
// Recreate markers
points.forEach(function(pt, i) {
const marker = L.circleMarker([pt[0], pt[1]], {
radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9
}).addTo(map).bindTooltip(String(i + 1), { permanent: true, direction: 'top', offset: [0, -8], className: 'pt-label' });
markers.push(marker);
});
render();
map.fitBounds(L.polygon(points).getBounds().pad(0.2));
});
document.getElementById('btnDownload').addEventListener('click', function() {
if (points.length < 3) return;
const bufferKm = parseFloat(document.getElementById('bufferKm').value) || 0;
GeofilterDraft.downloadConfig(points, bufferKm);
});
</script>
</body>
</html>
-10
View File
@@ -69,16 +69,6 @@
<p>Both the server and the ingestor read <code>geo_filter</code> from <code>config.json</code>. Restart both after changing this section.</p>
<p>To disable filtering entirely, remove the <code>geo_filter</code> block.</p>
<h2>Builder workflow: Save Draft, Load Draft, Download</h2>
<p>The <a href="/geofilter-builder.html">GeoFilter Builder</a> lets you draw a polygon on a map and produce the <code>geo_filter</code> snippet without hand-editing JSON. Three buttons drive the workflow:</p>
<ul>
<li><strong>💾 Save Draft</strong> — writes the current polygon and <code>bufferKm</code> to your browser's <code>localStorage</code> under the key <code>geofilter-draft</code>. Drafts persist across page reloads and browser restarts so you can iterate on a shape over multiple sessions.</li>
<li><strong>📂 Load Draft</strong> — restores the most recently saved draft into the builder. The current polygon is replaced. If no draft exists the button is a no-op.</li>
<li><strong>⬇ Download</strong> — exports the current polygon and <code>bufferKm</code> as <code>geofilter-config-snippet.json</code> — a single JSON object containing a top-level <code>geo_filter</code> block. Open the file, copy the <code>geo_filter</code> entry, and paste it into your <code>config.json</code>.</li>
</ul>
<div class="note"><p>Drafts are stored locally in your browser only — they are not uploaded anywhere. Clearing site data or switching browsers will lose the draft. Use <strong>Download</strong> to keep a portable copy.</p></div>
<p>After pasting the snippet into <code>config.json</code>, restart the server and ingestor for the new filter to take effect.</p>
<h2>Coordinate ordering</h2>
<div class="warn"><p><strong>Important:</strong> Coordinates are <code>[lat, lon]</code> — latitude first, longitude second. This is the opposite of GeoJSON, which uses <code>[lon, lat]</code>. Swapping them will place your polygon in the wrong location.</p></div>
-46
View File
@@ -1,46 +0,0 @@
// Geofilter draft save/load/download helpers.
// Exposes GeofilterDraft global with: saveDraft, loadDraft, clearDraft, buildConfigSnippet, downloadConfig
(function () {
'use strict';
var STORAGE_KEY = 'geofilter-draft';
function saveDraft(polygon, bufferKm) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ polygon: polygon, bufferKm: bufferKm }));
}
function loadDraft() {
var raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try { return JSON.parse(raw); } catch (e) { return null; }
}
function clearDraft() {
localStorage.removeItem(STORAGE_KEY);
}
function buildConfigSnippet(polygon, bufferKm) {
return JSON.stringify({ geo_filter: { bufferKm: bufferKm, polygon: polygon } }, null, 2);
}
function downloadConfig(polygon, bufferKm) {
var snippet = buildConfigSnippet(polygon, bufferKm);
var blob = new Blob([snippet], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'geofilter-config-snippet.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Export
(typeof window !== 'undefined' ? window : this).GeofilterDraft = {
saveDraft: saveDraft,
loadDraft: loadDraft,
clearDraft: clearDraft,
buildConfigSnippet: buildConfigSnippet,
downloadConfig: downloadConfig
};
})();
-4
View File
@@ -50,7 +50,6 @@
<a href="#/live" class="nav-link" data-route="live" data-priority="high">🔴 Live</a>
<a href="#/channels" class="nav-link" data-route="channels">Channels</a>
<a href="#/nodes" class="nav-link" data-route="nodes" data-priority="high">Nodes</a>
<a href="#/roles" class="nav-link" data-route="roles">Roles</a>
<a href="#/tools" class="nav-link" data-route="tools">Tools</a>
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
@@ -97,8 +96,6 @@
<script src="packet-filter.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="channel-colors.js?v=__BUST__"></script>
<script src="channel-color-picker.js?v=__BUST__"></script>
@@ -119,7 +116,6 @@
<script src="live.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles-page.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
+1 -1
View File
@@ -2844,7 +2844,7 @@
var style = c
? 'background:' + bg + ';border:1px solid ' + border
: 'background:transparent;border:1px dashed ' + border;
return '<span class="feed-color-dot" data-channel="' + escapeHtml(channel) + '" style="display:inline-block;width:18px;height:18px;border-radius:50%;' + style + ';cursor:pointer;vertical-align:middle;margin-left:4px;flex-shrink:0" title="Set color for ' + escapeHtml(channel) + '"></span>';
return '<span class="feed-color-dot" data-channel="' + escapeHtml(channel) + '" style="display:inline-block;width:12px;height:12px;border-radius:50%;' + style + ';cursor:pointer;vertical-align:middle;margin-left:4px;flex-shrink:0" title="Set color for ' + escapeHtml(channel) + '"></span>';
}
function addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed) {
+10 -34
View File
@@ -9,7 +9,7 @@
let nodes = [];
let targetNodeKey = null;
let observers = [];
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all', byteSize: localStorage.getItem('meshcore-map-byte-filter') || 'all', multiByteOverlay: localStorage.getItem('meshcore-map-multibyte-overlay') === 'true' };
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, 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' };
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;
@@ -25,24 +25,20 @@
// Roles loaded from shared roles.js (ROLE_STYLE, ROLE_LABELS, ROLE_COLORS globals)
// Multi-byte support overlay colors
var MB_COLORS = { confirmed: '#27ae60', suspected: '#f39c12', unknown: '#e74c3c' };
function makeMarkerIcon(role, isStale, isAlsoObserver, colorOverride) {
function makeMarkerIcon(role, isStale, isAlsoObserver) {
const s = ROLE_STYLE[role] || ROLE_STYLE.companion;
const fillColor = colorOverride || s.color;
const size = s.radius * 2 + 4;
const c = size / 2;
let path;
switch (s.shape) {
case 'diamond':
path = `<polygon points="${c},2 ${size-2},${c} ${c},${size-2} 2,${c}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
path = `<polygon points="${c},2 ${size-2},${c} ${c},${size-2} 2,${c}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
break;
case 'square':
path = `<rect x="3" y="3" width="${size-6}" height="${size-6}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
path = `<rect x="3" y="3" width="${size-6}" height="${size-6}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
break;
case 'triangle':
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
break;
case 'star': {
// 5-pointed star
@@ -54,11 +50,11 @@
pts += `${cx + outer * Math.cos(aOuter)},${cy + outer * Math.sin(aOuter)} `;
pts += `${cx + inner * Math.cos(aInner)},${cy + inner * Math.sin(aInner)} `;
}
path = `<polygon points="${pts.trim()}" fill="${fillColor}" stroke="#fff" stroke-width="1.5"/>`;
path = `<polygon points="${pts.trim()}" fill="${s.color}" stroke="#fff" stroke-width="1.5"/>`;
break;
}
default: // circle
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${s.color}" stroke="#fff" stroke-width="2"/>`;
}
// If this node is also an observer, add a small star overlay
let obsOverlay = '';
@@ -85,12 +81,12 @@
});
}
function makeRepeaterLabelIcon(node, isStale, isAlsoObserver, colorOverride) {
function makeRepeaterLabelIcon(node, isStale, isAlsoObserver) {
var s = ROLE_STYLE['repeater'] || ROLE_STYLE.companion;
var hs = node.hash_size || 1;
// Show the short mesh hash ID (first N bytes of pubkey, uppercased)
var shortHash = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '??';
var bgColor = colorOverride || s.color;
var bgColor = s.color;
// If this repeater is also an observer, show a star indicator inside the label
var obsIndicator = isAlsoObserver ? ' <span style="color:' + (ROLE_COLORS.observer || '#f1c40f') + ';font-size:13px;line-height:1;" title="Also an observer">★</span>' : '';
var html = '<div style="background:' + bgColor + ';color:#fff;font-weight:bold;font-size:11px;padding:2px 5px;border-radius:3px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.4);text-align:center;line-height:1.2;white-space:nowrap;">' +
@@ -142,7 +138,6 @@
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</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>
<label id="mcGeoFilterLabel" for="mcGeoFilter" style="display:none"><input type="checkbox" id="mcGeoFilter"> Mesh live area</label>
</fieldset>
<fieldset class="mc-section">
@@ -300,11 +295,6 @@
hashLabelEl.checked = filters.hashLabels;
hashLabelEl.addEventListener('change', e => { filters.hashLabels = e.target.checked; localStorage.setItem('meshcore-map-hash-labels', filters.hashLabels); renderMarkers(); });
}
const multiByteEl = document.getElementById('mcMultiByte');
if (multiByteEl) {
multiByteEl.checked = filters.multiByteOverlay;
multiByteEl.addEventListener('change', e => { filters.multiByteOverlay = e.target.checked; localStorage.setItem('meshcore-map-multibyte-overlay', e.target.checked); renderMarkers(); });
}
document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); });
// Status filter buttons
@@ -864,12 +854,7 @@
const pk = (node.public_key || '').toLowerCase();
const isAlsoObserver = _observerByPubkey.has(pk);
const useLabel = node.role === 'repeater' && filters.hashLabels;
// Multi-byte overlay: color repeaters by multi_byte_status
var mbColor = null;
if (filters.multiByteOverlay && node.role === 'repeater') {
mbColor = MB_COLORS[node.multi_byte_status] || MB_COLORS.unknown;
}
const icon = useLabel ? makeRepeaterLabelIcon(node, isStale, isAlsoObserver, mbColor) : makeMarkerIcon(node.role || 'companion', isStale, isAlsoObserver, mbColor);
const icon = useLabel ? makeRepeaterLabelIcon(node, isStale, isAlsoObserver) : makeMarkerIcon(node.role || 'companion', isStale, isAlsoObserver);
const latLng = L.latLng(node.lat, node.lon);
allMarkers.push({ latLng, node, icon, isLabel: useLabel, popupFn: function() { return buildPopup(node); }, alt: (node.name || 'Unknown') + ' (' + (node.role || 'node') + (isAlsoObserver ? ' + observer' : '') + ')' });
}
@@ -1005,14 +990,6 @@
const hashPrefix = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '—';
const hashPrefixRow = `<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Hash Prefix</dt>
<dd style="font-family:var(--mono);font-size:11px;font-weight:700;margin-left:88px;padding:2px 0;">${safeEsc(hashPrefix)} <span style="font-weight:400;color:var(--text-muted);">(${hs}B)</span></dd>`;
// Multi-byte support indicator for repeaters
var mbRow = '';
if (node.role === 'repeater' && node.multi_byte_status) {
var mbLabel = { confirmed: '✅ Confirmed', suspected: '⚠️ Suspected', unknown: '❌ Unknown' }[node.multi_byte_status] || node.multi_byte_status;
var mbEvidence = node.multi_byte_evidence ? ' (' + node.multi_byte_evidence + ')' : '';
mbRow = '<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Multi-byte</dt>' +
'<dd style="margin-left:88px;padding:2px 0;font-size:12px;">' + mbLabel + mbEvidence + '</dd>';
}
return `
<div class="map-popup" style="font-family:var(--font);min-width:180px;">
@@ -1020,7 +997,6 @@
${roleBadge}${obsBadge}
<dl style="margin-top:8px;font-size:12px;">
${hashPrefixRow}
${mbRow}
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Key</dt>
<dd style="font-family:var(--mono);font-size:11px;margin-left:88px;padding:2px 0;">${safeEsc(key)}</dd>
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Location</dt>
+2 -2
View File
@@ -170,7 +170,7 @@
data: {
labels: tl.map(b => {
const d = new Date(b.bucket);
return (typeof formatChartAxisLabel === 'function') ? formatChartAxisLabel(d, currentDays <= 3) : (currentDays <= 3 ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' }));
return currentDays <= 3 ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}),
datasets: [{ label: 'Packets', data: tl.map(b => b.count), backgroundColor: 'rgba(74,158,255,0.5)', borderColor: '#4a9eff', borderWidth: 1 }]
},
@@ -197,7 +197,7 @@
const longestObs = Object.values(byObs).sort((a, b) => b.points.length - a.points.length)[0];
const labels = longestObs ? longestObs.points.map(p => {
const d = p.x;
return (typeof formatChartAxisLabel === 'function') ? formatChartAxisLabel(d, false) : d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}) : [];
const c = new Chart(ctx, {
type: 'line',
-12
View File
@@ -492,7 +492,6 @@
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:6px">${n.public_key}</div>
<div>
<button class="btn-primary" id="copyUrlBtn" style="font-size:12px;padding:4px 10px">📋 Copy URL</button>
<button class="btn-primary" id="copyShortUrlBtn" title="Short URL using an 8-char pubkey prefix — easier to send over the mesh (issue #772)" style="font-size:12px;padding:4px 10px;margin-left:6px">📡 Copy short URL</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:6px;text-decoration:none;font-size:12px;padding:4px 10px">📊 Analytics</a>
</div>
</div>
@@ -613,17 +612,6 @@
});
});
// Copy short URL — issue #772. Uses an 8-char pubkey prefix; the
// backend resolves it to the canonical pubkey when unambiguous.
const shortUrl = location.origin + '#/nodes/' + n.public_key.slice(0, 8);
document.getElementById('copyShortUrlBtn')?.addEventListener('click', () => {
const btn = document.getElementById('copyShortUrlBtn');
window.copyToClipboard(shortUrl, () => {
btn.textContent = '✅ Copied!';
setTimeout(() => btn.textContent = '📡 Copy short URL', 2000);
});
});
// Deep-link scroll: ?section=node-packets or ?section=node-packets
const hashParams = location.hash.split('?')[1] || '';
const urlParams = new URLSearchParams(hashParams);
-8
View File
@@ -156,14 +156,6 @@
<div class="stat-label">First Seen</div>
<div class="stat-value" style="font-size:0.85em">${obs.first_seen ? new Date(obs.first_seen).toLocaleDateString() : '—'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Last Status Update</div>
<div class="stat-value" style="font-size:0.85em">${obs.last_seen ? timeAgo(obs.last_seen) + '<br><span style="font-size:0.8em;color:var(--text-muted)">' + new Date(obs.last_seen).toLocaleString() + '</span>' : '—'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Last Packet Observation</div>
<div class="stat-value" style="font-size:0.85em">${obs.last_packet_at ? timeAgo(obs.last_packet_at) + '<br><span style="font-size:0.8em;color:var(--text-muted)">' + new Date(obs.last_packet_at).toLocaleString() + '</span>' : '<span style="color:var(--text-muted)">never</span>'}</div>
</div>
</div>
<div class="mono" style="font-size:0.75em;color:var(--text-muted);margin-bottom:20px;word-break:break-all">
ID: ${obs.id}
+1 -14
View File
@@ -84,17 +84,6 @@
return { cls: 'health-red', label: 'Offline' };
}
function packetBadge(o) {
if (!o.last_packet_at) return '<span title="No packets ever observed">📡⚠ never</span>';
const pktAgo = Date.now() - new Date(o.last_packet_at).getTime();
const statusAgo = o.last_seen ? Date.now() - new Date(o.last_seen).getTime() : Infinity;
const gap = pktAgo - statusAgo;
if (gap > 600000) {
return `<span title="Last packet ${timeAgo(o.last_packet_at)} — status is newer by ${Math.round(gap/60000)}min. Observer may be alive but not forwarding packets.">📡⚠ ${timeAgo(o.last_packet_at)}</span>`;
}
return timeAgo(o.last_packet_at);
}
function uptimeStr(firstSeen) {
if (!firstSeen) return '—';
const ms = Date.now() - new Date(firstSeen).getTime();
@@ -143,7 +132,7 @@
<div class="obs-table-scroll"><table class="data-table obs-table" id="obsTable">
<caption class="sr-only">Observer status and statistics</caption>
<thead><tr>
<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Region</th><th scope="col">Last Status</th><th scope="col">Last Packet</th>
<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Region</th><th scope="col">Last Seen</th>
<th scope="col">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 => {
@@ -154,8 +143,6 @@
<td class="mono">${o.name || o.id}</td>
<td>${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
<td>${timeAgo(o.last_seen)}</td>
<td>${o.last_packet_at ? timeAgo(o.last_packet_at) : '<span class="text-muted">—</span>'}</td>
<td>${packetBadge(o)}</td>
<td>${(o.packet_count || 0).toLocaleString()}</td>
<td>${sparkBar(o.packetsLastHour || 0, maxPktsHr)}</td>
<td>${(function() {
-10
View File
@@ -10,11 +10,6 @@
// Aliases: display names → firmware names (for user convenience)
var TYPE_ALIASES = { 'request': 'REQ', 'response': 'RESPONSE', 'direct msg': 'TXT_MSG', 'dm': 'TXT_MSG', 'ack': 'ACK', 'advert': 'ADVERT', 'channel msg': 'GRP_TXT', 'channel': 'GRP_TXT', 'group data': 'GRP_DATA', 'anon req': 'ANON_REQ', 'path': 'PATH', 'trace': 'TRACE', 'multipart': 'MULTIPART', 'control': 'CONTROL', 'raw': 'RAW_CUSTOM', 'custom': 'RAW_CUSTOM' };
var ROUTE_TYPES = { 0: 'TRANSPORT_FLOOD', 1: 'FLOOD', 2: 'DIRECT', 3: 'TRANSPORT_DIRECT' };
// Aliases: shorthand → canonical route name (issue #339)
var ROUTE_ALIASES = { 't_flood': 'TRANSPORT_FLOOD', 't_direct': 'TRANSPORT_DIRECT' };
// Transport route_type values: TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3).
// Mirrors isTransportRoute() in cmd/server/decoder.go.
function isTransportRouteType(rt) { return rt === 0 || rt === 3; }
// Use window globals if available (they may have more types)
function getRT() { return window.ROUTE_TYPES || ROUTE_TYPES; }
@@ -185,7 +180,6 @@
function resolveField(packet, field) {
if (field === 'type') return FW_PAYLOAD_TYPES[packet.payload_type] || '';
if (field === 'route') return getRT()[packet.route_type] || '';
if (field === 'transport') return isTransportRouteType(packet.route_type);
if (field === 'hash') return packet.hash || '';
if (field === 'raw') return packet.raw_hex || '';
if (field === 'size') return packet.raw_hex ? packet.raw_hex.length / 2 : 0;
@@ -261,10 +255,6 @@
var alias = TYPE_ALIASES[String(target).toLowerCase()];
if (alias) resolvedTarget = alias;
}
if (ast.field === 'route' && typeof target === 'string') {
var rAlias = ROUTE_ALIASES[String(target).toLowerCase()];
if (rAlias) resolvedTarget = rAlias;
}
if (typeof fieldVal === 'number' && typeof resolvedTarget === 'number') {
eq = fieldVal === resolvedTarget;
} else if (typeof fieldVal === 'boolean' || typeof resolvedTarget === 'boolean') {
+1 -36
View File
@@ -62,7 +62,7 @@
// Update clear-filters button visibility
var cb = document.getElementById('clearFiltersBtn');
if (cb) {
var active = !!(filters.hash || filters.node || filters.observer || filters.channel || filters.type || filters._filterExpr || filters.myNodes) || !!RegionFilter.getRegionParam() || savedTimeWindowMin !== DEFAULT_TIME_WINDOW;
var active = !!(filters.hash || filters.node || filters.observer || filters.channel || filters.type || filters._filterExpr || filters.myNodes) || !!RegionFilter.getRegionParam();
cb.style.display = active ? '' : 'none';
}
}
@@ -1113,12 +1113,6 @@
if (typeMenu) typeMenu.querySelectorAll('input[type=checkbox]').forEach(function(cb) { cb.checked = false; });
document.getElementById('typeTrigger').textContent = 'All Types ▾';
// Reset time window to default
savedTimeWindowMin = DEFAULT_TIME_WINDOW;
var fTW = document.getElementById('fTimeWindow');
if (fTW) fTW.value = String(DEFAULT_TIME_WINDOW);
localStorage.removeItem('meshcore-time-window');
// Reset region filter
RegionFilter.setSelected([]);
@@ -1743,10 +1737,6 @@
const tbody = document.getElementById('pktBody');
if (!tbody) return;
// Preserve scroll position across re-render (#431)
const scrollContainer = document.getElementById('pktLeft');
const savedScrollTop = scrollContainer ? scrollContainer.scrollTop : 0;
// Update dynamic parts of the header
const countEl = document.querySelector('#pktLeft .count');
const groupBtn = document.getElementById('fGroup');
@@ -1816,8 +1806,6 @@
detachVScrollListener();
const colCount = _getColCount();
tbody.innerHTML = '<tr><td colspan="' + colCount + '" class="text-center text-muted" style="padding:24px">' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + '</td></tr>';
// Restore scroll position after DOM rebuild (#431)
if (scrollContainer) scrollContainer.scrollTop = savedScrollTop;
return;
}
@@ -1835,9 +1823,6 @@
attachVScrollListener();
renderVisibleRows();
// Restore scroll position after re-render (#431)
if (scrollContainer) scrollContainer.scrollTop = savedScrollTop;
}
function getDetailPreview(decoded) {
@@ -2336,16 +2321,6 @@
off += hashSize * pathHops.length;
}
// TRACE SNR values (from header path bytes, decoded by backend)
if (decoded.type === 'TRACE' && decoded.snrValues && decoded.snrValues.length > 0) {
rows += sectionRow('SNR Path (' + decoded.snrValues.length + ' hops completed)', 'section-path');
for (let i = 0; i < decoded.snrValues.length; i++) {
const snr = decoded.snrValues[i];
const snrStr = (snr >= 0 ? '+' : '') + snr.toFixed(2) + ' dB';
rows += fieldRow('', 'SNR (hop ' + i + ')', snrStr, '');
}
}
// Payload
rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type), 'section-payload');
@@ -2382,13 +2357,6 @@
if (decoded.sender_timestamp) rows += fieldRow(off + 2, 'Sender Time', decoded.sender_timestamp, '');
} else if (decoded.type === 'ACK') {
rows += fieldRow(off, 'Checksum (4B)', decoded.ackChecksum || '', '');
} else if (decoded.type === 'TRACE') {
rows += fieldRow(off, 'Trace Tag (4B)', decoded.tag ? '0x' + decoded.tag.toString(16).toUpperCase().padStart(8, '0') : '—', '');
rows += fieldRow(off + 4, 'Auth Code (4B)', decoded.authCode ? '0x' + decoded.authCode.toString(16).toUpperCase().padStart(8, '0') : '—', '');
rows += fieldRow(off + 8, 'Flags', decoded.traceFlags != null ? '0x' + decoded.traceFlags.toString(16).padStart(2, '0') : '—', decoded.traceFlags != null ? 'hash_size=' + (1 << (decoded.traceFlags & 0x03)) + ' byte(s)' : '');
if (decoded.pathData) {
rows += fieldRow(off + 9, 'Route Hops', decoded.pathData.toUpperCase(), pathHops.length + ' hop(s)');
}
} else if (decoded.destHash !== undefined) {
rows += fieldRow(off, 'Dest Hash (1B)', decoded.destHash || '', '');
rows += fieldRow(off + 1, 'Src Hash (1B)', decoded.srcHash || '', '');
@@ -2711,9 +2679,6 @@
buildFlatRowHtml,
_calcVisibleRange,
buildPacketsParams,
renderTableRows,
_setPackets: function(p) { packets = p; },
_setFilter: function(k, v) { filters[k] = v; },
};
}
-119
View File
@@ -1,119 +0,0 @@
/* === CoreScope — roles-page.js === */
'use strict';
(function () {
let refreshTimer = null;
function init(app) {
app.innerHTML =
'<div class="roles-page" data-page="roles">' +
' <div class="page-header">' +
' <h2>Roles</h2>' +
' <button class="btn-icon" data-action="roles-refresh" title="Refresh" aria-label="Refresh roles">🔄</button>' +
' </div>' +
' <p class="text-muted" style="margin:0 0 12px 0">Distribution of node roles across the mesh, with per-role clock-skew posture.</p>' +
' <div id="rolesContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>' +
'</div>';
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action="roles-refresh"]');
if (btn) load();
});
load();
refreshTimer = setInterval(load, 60000);
}
function destroy() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
}
async function load() {
var container = document.getElementById('rolesContent');
if (!container) return;
try {
var resp = await fetch('/api/analytics/roles');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
render(container, data);
} catch (err) {
container.innerHTML = '<div class="text-center" style="padding:40px;color:var(--color-error,#c00)">Failed to load roles: ' + escapeHtml(String(err.message || err)) + '</div>';
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function fmtSec(v) {
if (!v && v !== 0) return '—';
var abs = Math.abs(v);
if (abs < 1) return v.toFixed(2) + 's';
if (abs < 60) return v.toFixed(1) + 's';
if (abs < 3600) return (v / 60).toFixed(1) + 'm';
if (abs < 86400) return (v / 3600).toFixed(1) + 'h';
return (v / 86400).toFixed(1) + 'd';
}
function roleEmoji(role) {
if (window.ROLE_EMOJI && window.ROLE_EMOJI[role]) return window.ROLE_EMOJI[role];
return '•';
}
function render(container, data) {
var roles = (data && data.roles) || [];
var total = (data && data.totalNodes) || 0;
if (roles.length === 0) {
container.innerHTML = '<div class="text-center text-muted" style="padding:40px">No roles to show.</div>';
return;
}
var maxCount = roles.reduce(function (m, r) { return Math.max(m, r.nodeCount || 0); }, 0) || 1;
var rows = roles.map(function (r) {
var pct = total > 0 ? ((r.nodeCount / total) * 100).toFixed(1) : '0.0';
var barW = Math.round((r.nodeCount / maxCount) * 100);
var sevCells =
'<span title="OK (skew &lt; 5min)" style="color:var(--color-success,#0a0)">' + (r.okCount || 0) + '</span> / ' +
'<span title="Warning (5min 1h)" style="color:var(--color-warning,#e80)">' + (r.warningCount || 0) + '</span> / ' +
'<span title="Critical (1h 30d)" style="color:var(--color-error,#c00)">' + (r.criticalCount || 0) + '</span> / ' +
'<span title="Absurd (&gt; 30d)" style="color:#a0a">' + (r.absurdCount || 0) + '</span> / ' +
'<span title="No clock (&gt; 365d)" style="color:#888">' + (r.noClockCount || 0) + '</span>';
return '' +
'<tr data-role="' + escapeHtml(r.role) + '">' +
'<td>' + roleEmoji(r.role) + ' <strong>' + escapeHtml(r.role) + '</strong></td>' +
'<td style="text-align:right">' + r.nodeCount + '</td>' +
'<td style="text-align:right">' + pct + '%</td>' +
'<td style="min-width:140px">' +
'<div style="background:var(--color-surface-2,#eee);height:10px;border-radius:5px;overflow:hidden">' +
'<div style="background:var(--color-accent,#06c);width:' + barW + '%;height:100%"></div>' +
'</div>' +
'</td>' +
'<td style="text-align:right">' + (r.withSkew || 0) + '</td>' +
'<td style="text-align:right">' + fmtSec(r.medianAbsSkewSec || 0) + '</td>' +
'<td style="text-align:right">' + fmtSec(r.meanAbsSkewSec || 0) + '</td>' +
'<td style="white-space:nowrap">' + sevCells + '</td>' +
'</tr>';
}).join('');
container.innerHTML =
'<div class="roles-summary" style="margin-bottom:12px;color:var(--color-text-muted,#666)">' +
'<strong>' + total + '</strong> nodes across <strong>' + roles.length + '</strong> roles' +
'</div>' +
'<table id="rolesTable" class="data-table" style="width:100%">' +
'<thead><tr>' +
'<th>Role</th>' +
'<th style="text-align:right">Count</th>' +
'<th style="text-align:right">Share</th>' +
'<th>Distribution</th>' +
'<th style="text-align:right" title="Nodes with clock-skew samples">w/ Skew</th>' +
'<th style="text-align:right" title="Median absolute skew">Median |skew|</th>' +
'<th style="text-align:right" title="Mean absolute skew">Mean |skew|</th>' +
'<th title="OK / Warning / Critical / Absurd / No-clock">Severity</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
}
registerPage('roles', { init: init, destroy: destroy });
})();
+1 -24
View File
@@ -524,19 +524,6 @@ button.ch-item.selected { background: var(--selected-bg); }
.ch-item-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; }
.ch-item-name { font-weight: 600; font-size: 14px; }
.ch-item-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
.ch-unread-badge {
display: inline-block;
min-width: 18px;
padding: 1px 6px;
margin-left: 4px;
background: var(--accent, #3b82f6);
color: #fff;
font-size: 10px;
font-weight: 600;
border-radius: 9px;
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); }
@@ -1571,7 +1558,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
/* #20 — Observers table horizontal scroll on mobile */
.obs-table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.obs-table-scroll .obs-table { min-width: 720px; }
.obs-table-scroll .obs-table { min-width: 640px; }
/* #206 — Analytics/Compare tables scroll wrappers on mobile */
.analytics-table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
@@ -2188,16 +2175,6 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
margin-left: 6px;
flex-shrink: 0;
}
.ch-color-clear {
display: inline-block;
font-size: 10px;
line-height: 1;
color: var(--text-muted, #888);
cursor: pointer;
margin-left: 3px;
vertical-align: middle;
}
.ch-color-clear:hover { color: var(--text-primary, #e0e0e0); }
.ch-color-dot:not([style*="background"]) {
background: transparent;
border-style: dashed;
-155
View File
@@ -1,155 +0,0 @@
/* SPDX-License-Identifier: MIT
*
* Minimal pure-JS AES-128 ECB implementation (decrypt only).
*
* Adapted from aes-js by Richard Moore (MIT License,
* https://github.com/ricmoo/aes-js, copyright 2015-2018), trimmed to
* the minimum needed for AES-128-ECB decryption: S-box + inverse S-box,
* Rcon, key expansion (FIPS-197 §5.2), inverse cipher (FIPS-197 §5.3).
* Only the inverse-direction T-tables (T5..T8) and key-expansion U-tables
* (U1..U4) are vendored; the forward-direction tables (T1..T4) and
* encrypt path are intentionally omitted we never encrypt on the
* client.
*
* Why pure-JS instead of Web Crypto? Web Crypto exposes AES-CBC/CTR/GCM
* but NOT raw AES-ECB. Simulating ECB via "AES-CBC with zero IV +
* dummy PKCS7 padding block" is unreliable: Web Crypto validates PKCS7
* padding on the decrypted output and throws OperationError whenever the
* padding bytes don't form a valid PKCS7 sequence (the common case for
* real ciphertext). MeshCore channel encryption uses single-block
* AES-128-ECB per packet, so we need true ECB, not a CBC hack.
*
* API: window.AES_ECB.decrypt(key, ciphertext) -> Uint8Array
* - key: Uint8Array (16 bytes; AES-128 only)
* - ciphertext: Uint8Array (length must be a non-zero multiple of 16)
*/
/* eslint-disable no-var */
(function (root) {
'use strict';
// --- S-boxes ---
var Si = [
0x52,0x09,0x6a,0xd5,0x30,0x36,0xa5,0x38,0xbf,0x40,0xa3,0x9e,0x81,0xf3,0xd7,0xfb,
0x7c,0xe3,0x39,0x82,0x9b,0x2f,0xff,0x87,0x34,0x8e,0x43,0x44,0xc4,0xde,0xe9,0xcb,
0x54,0x7b,0x94,0x32,0xa6,0xc2,0x23,0x3d,0xee,0x4c,0x95,0x0b,0x42,0xfa,0xc3,0x4e,
0x08,0x2e,0xa1,0x66,0x28,0xd9,0x24,0xb2,0x76,0x5b,0xa2,0x49,0x6d,0x8b,0xd1,0x25,
0x72,0xf8,0xf6,0x64,0x86,0x68,0x98,0x16,0xd4,0xa4,0x5c,0xcc,0x5d,0x65,0xb6,0x92,
0x6c,0x70,0x48,0x50,0xfd,0xed,0xb9,0xda,0x5e,0x15,0x46,0x57,0xa7,0x8d,0x9d,0x84,
0x90,0xd8,0xab,0x00,0x8c,0xbc,0xd3,0x0a,0xf7,0xe4,0x58,0x05,0xb8,0xb3,0x45,0x06,
0xd0,0x2c,0x1e,0x8f,0xca,0x3f,0x0f,0x02,0xc1,0xaf,0xbd,0x03,0x01,0x13,0x8a,0x6b,
0x3a,0x91,0x11,0x41,0x4f,0x67,0xdc,0xea,0x97,0xf2,0xcf,0xce,0xf0,0xb4,0xe6,0x73,
0x96,0xac,0x74,0x22,0xe7,0xad,0x35,0x85,0xe2,0xf9,0x37,0xe8,0x1c,0x75,0xdf,0x6e,
0x47,0xf1,0x1a,0x71,0x1d,0x29,0xc5,0x89,0x6f,0xb7,0x62,0x0e,0xaa,0x18,0xbe,0x1b,
0xfc,0x56,0x3e,0x4b,0xc6,0xd2,0x79,0x20,0x9a,0xdb,0xc0,0xfe,0x78,0xcd,0x5a,0xf4,
0x1f,0xdd,0xa8,0x33,0x88,0x07,0xc7,0x31,0xb1,0x12,0x10,0x59,0x27,0x80,0xec,0x5f,
0x60,0x51,0x7f,0xa9,0x19,0xb5,0x4a,0x0d,0x2d,0xe5,0x7a,0x9f,0x93,0xc9,0x9c,0xef,
0xa0,0xe0,0x3b,0x4d,0xae,0x2a,0xf5,0xb0,0xc8,0xeb,0xbb,0x3c,0x83,0x53,0x99,0x61,
0x17,0x2b,0x04,0x7e,0xba,0x77,0xd6,0x26,0xe1,0x69,0x14,0x63,0x55,0x21,0x0c,0x7d
];
// --- GF(2^8) multiplications used by InvMixColumns ---
// xtime: multiply by {02} in GF(2^8)
function xt(b) { return ((b << 1) ^ ((b & 0x80) ? 0x1b : 0)) & 0xff; }
function mul(a, b) {
// Generic GF(2^8) multiply for small constants 9, 0xb, 0xd, 0xe.
var p = 0;
for (var i = 0; i < 8; i++) {
if (b & 1) p ^= a;
var hi = a & 0x80;
a = (a << 1) & 0xff;
if (hi) a ^= 0x1b;
b >>= 1;
}
return p & 0xff;
}
// --- Key expansion: AES-128 produces 11 round keys (44 words × 4 bytes) ---
function expandKey(key) {
if (key.length !== 16) throw new Error('AES-ECB: key must be 16 bytes (AES-128)');
var Rcon = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36];
// S-box derived as the inverse of Si: build it once.
var S = new Uint8Array(256);
for (var x = 0; x < 256; x++) S[Si[x]] = x;
var w = new Uint8Array(176); // 11 round keys × 16 bytes
for (var i = 0; i < 16; i++) w[i] = key[i];
for (var idx = 16, rcon = 1; idx < 176; idx += 4) {
var t0 = w[idx - 4], t1 = w[idx - 3], t2 = w[idx - 2], t3 = w[idx - 1];
if (idx % 16 === 0) {
// RotWord + SubWord + Rcon
var s0 = S[t1], s1 = S[t2], s2 = S[t3], s3 = S[t0];
t0 = s0 ^ Rcon[rcon]; t1 = s1; t2 = s2; t3 = s3;
rcon++;
}
w[idx ] = w[idx - 16] ^ t0;
w[idx + 1] = w[idx - 15] ^ t1;
w[idx + 2] = w[idx - 14] ^ t2;
w[idx + 3] = w[idx - 13] ^ t3;
}
return w;
}
// --- AES-128 single-block decrypt (FIPS-197 §5.3 InvCipher) ---
function decryptBlock(state, w, out, outOff) {
// state is a 16-byte block. Work on a local 16-byte buffer.
var s = new Uint8Array(16);
// AddRoundKey with last round key (round 10)
for (var i = 0; i < 16; i++) s[i] = state[i] ^ w[160 + i];
for (var round = 9; round >= 1; round--) {
// InvShiftRows
var t = new Uint8Array(16);
// Row 0: no shift
t[0] = s[0]; t[4] = s[4]; t[8] = s[8]; t[12] = s[12];
// Row 1: shift right by 1 -> source col offset -1 mod 4
t[1] = s[13]; t[5] = s[1]; t[9] = s[5]; t[13] = s[9];
// Row 2: shift right by 2
t[2] = s[10]; t[6] = s[14]; t[10] = s[2]; t[14] = s[6];
// Row 3: shift right by 3
t[3] = s[7]; t[7] = s[11]; t[11] = s[15]; t[15] = s[3];
// InvSubBytes
for (var k = 0; k < 16; k++) t[k] = Si[t[k]];
// AddRoundKey
for (var k2 = 0; k2 < 16; k2++) t[k2] ^= w[round * 16 + k2];
// InvMixColumns: each column [c0,c1,c2,c3] -> M^-1 * column
// M^-1 = [[0e,0b,0d,09],[09,0e,0b,0d],[0d,09,0e,0b],[0b,0d,09,0e]]
for (var c = 0; c < 4; c++) {
var b0 = t[4 * c], b1 = t[4 * c + 1], b2 = t[4 * c + 2], b3 = t[4 * c + 3];
s[4 * c ] = mul(b0, 0x0e) ^ mul(b1, 0x0b) ^ mul(b2, 0x0d) ^ mul(b3, 0x09);
s[4 * c + 1] = mul(b0, 0x09) ^ mul(b1, 0x0e) ^ mul(b2, 0x0b) ^ mul(b3, 0x0d);
s[4 * c + 2] = mul(b0, 0x0d) ^ mul(b1, 0x09) ^ mul(b2, 0x0e) ^ mul(b3, 0x0b);
s[4 * c + 3] = mul(b0, 0x0b) ^ mul(b1, 0x0d) ^ mul(b2, 0x09) ^ mul(b3, 0x0e);
}
}
// Final round (no InvMixColumns): InvShiftRows + InvSubBytes + AddRoundKey(w0)
var f = new Uint8Array(16);
f[0] = s[0]; f[4] = s[4]; f[8] = s[8]; f[12] = s[12];
f[1] = s[13]; f[5] = s[1]; f[9] = s[5]; f[13] = s[9];
f[2] = s[10]; f[6] = s[14]; f[10] = s[2]; f[14] = s[6];
f[3] = s[7]; f[7] = s[11]; f[11] = s[15]; f[15] = s[3];
for (var j = 0; j < 16; j++) out[outOff + j] = Si[f[j]] ^ w[j];
}
function decrypt(key, ciphertext) {
if (!(ciphertext instanceof Uint8Array)) {
throw new Error('AES-ECB: ciphertext must be a Uint8Array');
}
if (ciphertext.length === 0 || ciphertext.length % 16 !== 0) {
throw new Error('AES-ECB: ciphertext length must be a non-zero multiple of 16');
}
var w = expandKey(key instanceof Uint8Array ? key : new Uint8Array(key));
var out = new Uint8Array(ciphertext.length);
var block = new Uint8Array(16);
for (var i = 0; i < ciphertext.length; i += 16) {
for (var b = 0; b < 16; b++) block[b] = ciphertext[i + b];
decryptBlock(block, w, out, i);
}
return out;
}
// Suppress lint by referencing xt (we kept it for clarity in case future
// code wants it; the compiled `mul` function is fully self-contained).
void xt;
root.AES_ECB = { decrypt: decrypt };
})(typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : this));
-152
View File
@@ -1,152 +0,0 @@
/* SPDX-License-Identifier: MIT
*
* Minimal pure-JS SHA-256 + HMAC-SHA256.
*
* Why: Web Crypto's SubtleCrypto (`window.crypto.subtle`) is only exposed
* in **secure contexts** (HTTPS or localhost). When CoreScope is served
* over plain HTTP common for self-hosted instances and LAN-side
* deployments `crypto.subtle` is undefined and any
* `crypto.subtle.digest(...)` / `crypto.subtle.importKey(...)` call
* throws `Cannot read properties of undefined`. PR #1021 fixed the
* AES-ECB path for the same reason; this module does the same for the
* SHA-256 / HMAC paths used by `computeChannelHash` and `verifyMAC`.
*
* Implementation: textbook FIPS-180-4 SHA-256 + RFC 2104 HMAC. Operates
* on Uint8Array inputs; returns Uint8Array outputs. ~120 LOC, no deps.
*
* API:
* window.PureCrypto.sha256(bytes: Uint8Array) -> Uint8Array(32)
* window.PureCrypto.hmacSha256(key: Uint8Array, msg: Uint8Array) -> Uint8Array(32)
*/
/* eslint-disable no-var */
(function (root) {
'use strict';
// SHA-256 round constants (FIPS-180-4 §4.2.2).
var K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
]);
function ror(x, n) { return (x >>> n) | (x << (32 - n)); }
// Process a single 64-byte block, mutating `H` (8 × uint32 state).
function processBlock(H, M) {
var W = new Uint32Array(64);
for (var i = 0; i < 16; i++) {
W[i] = (M[i * 4] << 24) | (M[i * 4 + 1] << 16) | (M[i * 4 + 2] << 8) | M[i * 4 + 3];
}
for (var t = 16; t < 64; t++) {
var s0 = ror(W[t - 15], 7) ^ ror(W[t - 15], 18) ^ (W[t - 15] >>> 3);
var s1 = ror(W[t - 2], 17) ^ ror(W[t - 2], 19) ^ (W[t - 2] >>> 10);
W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0;
}
var a = H[0], b = H[1], c = H[2], d = H[3];
var e = H[4], f = H[5], g = H[6], h = H[7];
for (var j = 0; j < 64; j++) {
var S1 = ror(e, 6) ^ ror(e, 11) ^ ror(e, 25);
var ch = (e & f) ^ ((~e) & g);
var temp1 = (h + S1 + ch + K[j] + W[j]) >>> 0;
var S0 = ror(a, 2) ^ ror(a, 13) ^ ror(a, 22);
var maj = (a & b) ^ (a & c) ^ (b & c);
var temp2 = (S0 + maj) >>> 0;
h = g; g = f; f = e;
e = (d + temp1) >>> 0;
d = c; c = b; b = a;
a = (temp1 + temp2) >>> 0;
}
H[0] = (H[0] + a) >>> 0;
H[1] = (H[1] + b) >>> 0;
H[2] = (H[2] + c) >>> 0;
H[3] = (H[3] + d) >>> 0;
H[4] = (H[4] + e) >>> 0;
H[5] = (H[5] + f) >>> 0;
H[6] = (H[6] + g) >>> 0;
H[7] = (H[7] + h) >>> 0;
}
function sha256(bytes) {
if (!(bytes instanceof Uint8Array)) {
throw new Error('sha256: input must be a Uint8Array');
}
var bitLen = bytes.length * 8;
// Padding: 0x80 then zeros until length ≡ 56 (mod 64), then 8-byte big-endian bit-length.
var padLen = ((bytes.length + 9 + 63) & ~63) - bytes.length;
var padded = new Uint8Array(bytes.length + padLen);
padded.set(bytes, 0);
padded[bytes.length] = 0x80;
// 64-bit big-endian bit length. JS bitwise ops are 32-bit, so split.
var hi = Math.floor(bitLen / 0x100000000);
var lo = bitLen >>> 0;
var off = padded.length - 8;
padded[off] = (hi >>> 24) & 0xff;
padded[off + 1] = (hi >>> 16) & 0xff;
padded[off + 2] = (hi >>> 8) & 0xff;
padded[off + 3] = hi & 0xff;
padded[off + 4] = (lo >>> 24) & 0xff;
padded[off + 5] = (lo >>> 16) & 0xff;
padded[off + 6] = (lo >>> 8) & 0xff;
padded[off + 7] = lo & 0xff;
var H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
]);
for (var i = 0; i < padded.length; i += 64) {
processBlock(H, padded.subarray(i, i + 64));
}
var out = new Uint8Array(32);
for (var k = 0; k < 8; k++) {
out[k * 4] = (H[k] >>> 24) & 0xff;
out[k * 4 + 1] = (H[k] >>> 16) & 0xff;
out[k * 4 + 2] = (H[k] >>> 8) & 0xff;
out[k * 4 + 3] = H[k] & 0xff;
}
return out;
}
// RFC 2104 HMAC.
function hmacSha256(key, msg) {
if (!(key instanceof Uint8Array) || !(msg instanceof Uint8Array)) {
throw new Error('hmacSha256: key and msg must be Uint8Array');
}
var blockSize = 64;
var k = key;
if (k.length > blockSize) k = sha256(k);
if (k.length < blockSize) {
var padded = new Uint8Array(blockSize);
padded.set(k, 0);
k = padded;
}
var oKeyPad = new Uint8Array(blockSize);
var iKeyPad = new Uint8Array(blockSize);
for (var i = 0; i < blockSize; i++) {
oKeyPad[i] = k[i] ^ 0x5c;
iKeyPad[i] = k[i] ^ 0x36;
}
var inner = new Uint8Array(blockSize + msg.length);
inner.set(iKeyPad, 0);
inner.set(msg, blockSize);
var innerHash = sha256(inner);
var outer = new Uint8Array(blockSize + innerHash.length);
outer.set(oKeyPad, 0);
outer.set(innerHash, blockSize);
return sha256(outer);
}
root.PureCrypto = { sha256: sha256, hmacSha256: hmacSha256 };
})(typeof window !== 'undefined' ? window
: typeof self !== 'undefined' ? self
: this);
-2
View File
@@ -13,8 +13,6 @@ node test-packet-filter.js
node test-aging.js
node test-frontend-helpers.js
node test-perf-go-runtime.js
node test-channel-psk-ux.js
node test-channel-decrypt-insecure-context.js
echo ""
echo "═══════════════════════════════════════"
-112
View File
@@ -1,112 +0,0 @@
/**
* Tests for AES-128-ECB decryption in public/channel-decrypt.js.
*
* Background: the original implementation simulated ECB via Web Crypto
* AES-CBC with a zero IV and a dummy PKCS7 padding block. Web Crypto
* validates PKCS7 padding on the decrypted output and throws an
* `OperationError` whenever the last 16 bytes of the (CBC-decrypted)
* output don't form a valid PKCS7 padding sequence which is the
* common case here, since the input is real ciphertext, not a padded
* second block. This test pins decryptECB() to the FIPS-197 NIST
* AES-128-ECB known-answer vector (Appendix B / C.1) so that the
* implementation cannot regress to any Web Crypto + ECB hack.
*
* Vector (FIPS-197 Appendix C.1, single-block AES-128 ECB):
* key = 000102030405060708090a0b0c0d0e0f
* plaintext = 00112233445566778899aabbccddeeff
* ciphertext = 69c4e0d86a7b0430d8cdb78070b4c55a
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const { subtle } = require('crypto').webcrypto;
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
function loadChannelDecrypt() {
const storage = {};
const localStorage = {
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; },
};
const sandbox = {
window: {}, crypto: { subtle }, TextEncoder, TextDecoder, Uint8Array,
localStorage, console, Date, JSON, parseInt, Math, String, Number,
Object, Array, RegExp, Error, Promise, setTimeout,
};
sandbox.window = sandbox; sandbox.self = sandbox;
vm.createContext(sandbox);
// Load vendored AES (if present) before channel-decrypt.js.
const vendorPath = path.join(__dirname, 'public/vendor/aes-ecb.js');
if (fs.existsSync(vendorPath)) {
vm.runInContext(fs.readFileSync(vendorPath, 'utf8'), sandbox);
}
vm.runInContext(
fs.readFileSync(path.join(__dirname, 'public/channel-decrypt.js'), 'utf8'),
sandbox
);
return sandbox.window.ChannelDecrypt;
}
async function runTests() {
console.log('\n=== AES-128-ECB known-answer vector (FIPS-197 C.1) ===');
const CD = loadChannelDecrypt();
const key = CD.hexToBytes('000102030405060708090a0b0c0d0e0f');
const ct = CD.hexToBytes('69c4e0d86a7b0430d8cdb78070b4c55a');
const expectedPlaintextHex = '00112233445566778899aabbccddeeff';
let result, threw = null;
try {
result = await CD.decryptECB(key, ct);
} catch (e) {
threw = e;
}
assert(threw === null, 'decryptECB does not throw on valid ciphertext (got: ' + (threw && threw.message) + ')');
assert(result instanceof Uint8Array, 'decryptECB returns a Uint8Array');
assert(
result && CD.bytesToHex(result) === expectedPlaintextHex,
'decryptECB matches FIPS-197 vector (got ' + (result ? CD.bytesToHex(result) : 'null') + ')'
);
// Multi-block: two copies of the same block must produce two copies
// of the same plaintext (true ECB property — no chaining).
console.log('\n=== AES-128-ECB multi-block (no chaining) ===');
const ct2 = new Uint8Array(32);
ct2.set(ct, 0); ct2.set(ct, 16);
let result2, threw2 = null;
try { result2 = await CD.decryptECB(key, ct2); }
catch (e) { threw2 = e; }
assert(threw2 === null, 'decryptECB does not throw on 2-block ciphertext');
assert(
result2 &&
CD.bytesToHex(result2.slice(0, 16)) === expectedPlaintextHex &&
CD.bytesToHex(result2.slice(16, 32)) === expectedPlaintextHex,
'decryptECB on duplicated block yields duplicated plaintext (ECB, no chaining)'
);
// Empty / misaligned input must return null (existing contract).
console.log('\n=== Edge cases ===');
const empty = await CD.decryptECB(key, new Uint8Array(0));
assert(empty === null, 'empty ciphertext returns null');
const misaligned = await CD.decryptECB(key, new Uint8Array(15));
assert(misaligned === null, 'misaligned ciphertext returns null');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(e => { console.error(e); process.exit(1); });
-181
View File
@@ -1,181 +0,0 @@
/**
* Tests that channel decryption works in an "insecure context" i.e. when
* `window.crypto.subtle` is undefined.
*
* Why: when CoreScope is served over plain HTTP (or accessed via a non-https
* origin like `http://<lan-ip>:8080`), browsers refuse to expose
* `crypto.subtle` (it requires a secure context). The original
* `channel-decrypt.js` used `crypto.subtle.digest('SHA-256', …)` for
* `computeChannelHash` and `crypto.subtle.importKey(…)` +
* `crypto.subtle.sign('HMAC', …)` for `verifyMAC`. PR #1021 fixed only the
* AES-ECB path with a pure-JS vendor module, but left SHA-256 and HMAC paths
* pinned to `crypto.subtle`. Result on HTTP origins:
*
* addUserChannel("372a9c93260507adcbf36a84bec0f33d")
* -> computeChannelHash(key) throws "Cannot read properties of undefined
* (reading 'digest')"
* -> caught silently by addUserChannel's try/catch
* -> user sees "Failed to decrypt"
*
* This test sandboxes channel-decrypt.js with `crypto.subtle === undefined`
* and asserts both `computeChannelHash` and `verifyMAC` still work, using
* a pure-JS SHA-256 / HMAC-SHA256 fallback.
*
* Reference vectors:
* key bytes = 0x37,0x2a,0x9c,0x93,0x26,0x05,0x07,0xad,0xcb,0xf3,0x6a,0x84,0xbe,0xc0,0xf3,0x3d
* SHA256(key) = b7ce04f7d9019788b69e709ffb796a36d00225818b444ad4f8979bc1d1445f47
* -> first byte (channel hash) = 0xb7 = 183
*
* HMAC-SHA256 KAT (RFC 4231 Test Case 1):
* key = 0x0b * 20
* data = "Hi There"
* mac = b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7
*/
'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 loadChannelDecryptInsecureContext() {
const storage = {};
const localStorage = {
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; },
};
// CRITICAL: crypto present, but no .subtle. Mirrors browser HTTP context.
const insecureCrypto = {};
const sandbox = {
window: {}, crypto: insecureCrypto, TextEncoder, TextDecoder, Uint8Array,
localStorage, console, Date, JSON, parseInt, Math, String, Number,
Object, Array, RegExp, Error, Promise, setTimeout,
};
sandbox.window = sandbox; sandbox.self = sandbox;
vm.createContext(sandbox);
// Vendored AES (must load before channel-decrypt.js — same as index.html).
const vendorAesPath = path.join(__dirname, 'public/vendor/aes-ecb.js');
if (fs.existsSync(vendorAesPath)) {
vm.runInContext(fs.readFileSync(vendorAesPath, 'utf8'), sandbox);
}
// Optional vendored SHA-256 / HMAC (the fix). Load if present so the test
// works whether the fix vendors it as a separate file OR inlines it into
// channel-decrypt.js.
const vendorShaPath = path.join(__dirname, 'public/vendor/sha256-hmac.js');
if (fs.existsSync(vendorShaPath)) {
vm.runInContext(fs.readFileSync(vendorShaPath, 'utf8'), sandbox);
}
vm.runInContext(
fs.readFileSync(path.join(__dirname, 'public/channel-decrypt.js'), 'utf8'),
sandbox
);
return sandbox.window.ChannelDecrypt;
}
async function runTests() {
console.log('\n=== channel-decrypt.js works without crypto.subtle (HTTP-context) ===');
const CD = loadChannelDecryptInsecureContext();
// 1) computeChannelHash() — pure SHA-256 of 16-byte key, take byte 0.
const KEY_HEX = '372a9c93260507adcbf36a84bec0f33d';
const keyBytes = CD.hexToBytes(KEY_HEX);
let hashByte, threwHash = null;
try {
hashByte = await CD.computeChannelHash(keyBytes);
} catch (e) {
threwHash = e;
}
assert(threwHash === null,
'computeChannelHash does not throw without crypto.subtle (got: ' +
(threwHash && threwHash.message) + ')');
assert(hashByte === 0xb7,
'computeChannelHash returns 0xb7 for known PSK key (got: ' + hashByte + ')');
// 2) verifyMAC() — RFC 4231 HMAC-SHA256 Test Case 1.
// We feed a hand-built scenario:
// verifyMAC's HMAC key is `aesKey ++ 16 zero bytes` (32 bytes).
// To exercise RFC 4231 TC1 we set aesKey = 16 * 0x0b and pad another 4
// bytes of 0x0b in the second half (since verifyMAC zero-fills bytes
// 16..31, we instead use the channel-decrypt API directly here only to
// prove HMAC-SHA256 is computed correctly with the standard secret).
//
// We construct the secret manually and call verifyMAC on a synthetic
// ciphertext whose HMAC-SHA256 first 2 bytes we precompute with Node's
// crypto module (independent oracle).
const nodeCrypto = require('crypto');
const aesKey = new Uint8Array(16); for (let i = 0; i < 16; i++) aesKey[i] = 0xab;
const ct = new Uint8Array(16); for (let i = 0; i < 16; i++) ct[i] = i;
const secret = Buffer.alloc(32); Buffer.from(aesKey).copy(secret, 0);
const fullMac = nodeCrypto.createHmac('sha256', secret).update(Buffer.from(ct)).digest();
const expectedMacHex = fullMac.slice(0, 2).toString('hex');
let macOk, threwMac = null;
try {
macOk = await CD.verifyMAC(aesKey, ct, expectedMacHex);
} catch (e) {
threwMac = e;
}
assert(threwMac === null,
'verifyMAC does not throw without crypto.subtle (got: ' +
(threwMac && threwMac.message) + ')');
assert(macOk === true,
'verifyMAC returns true for valid 2-byte MAC (got: ' + macOk + ')');
// 3) verifyMAC must still REJECT a wrong MAC.
let macBad, threwMacBad = null;
try {
macBad = await CD.verifyMAC(aesKey, ct, '0000');
} catch (e) {
threwMacBad = e;
}
assert(threwMacBad === null,
'verifyMAC does not throw on wrong MAC (got: ' + (threwMacBad && threwMacBad.message) + ')');
assert(macBad === false,
'verifyMAC returns false for wrong 2-byte MAC (got: ' + macBad + ')');
// 4) End-to-end: decrypt() must work with subtle absent — exercises
// SHA-256 (key derivation already done) + HMAC + AES-ECB together.
// Build a synthetic encrypted packet from a known plaintext.
const aesKey2 = nodeCrypto.randomBytes(16);
const plaintext = Buffer.alloc(16);
// timestamp(4 LE) + flags(1) + "alice: hi\0" then padded
plaintext.writeUInt32LE(0x12345678, 0);
plaintext[4] = 0x00;
Buffer.from('alice: hi\0', 'utf8').copy(plaintext, 5);
const cipher = nodeCrypto.createCipheriv('aes-128-ecb', aesKey2, null);
cipher.setAutoPadding(false);
const ct2 = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const secret2 = Buffer.alloc(32); aesKey2.copy(secret2, 0);
const macHex2 = nodeCrypto.createHmac('sha256', secret2).update(ct2).digest().slice(0, 2).toString('hex');
let decResult = null, threwDec = null;
try {
decResult = await CD.decrypt(new Uint8Array(aesKey2), macHex2, ct2.toString('hex'));
} catch (e) {
threwDec = e;
}
assert(threwDec === null,
'decrypt() does not throw without crypto.subtle (got: ' +
(threwDec && threwDec.message) + ')');
assert(decResult && decResult.sender === 'alice' && decResult.message === 'hi',
'decrypt() recovers sender + message in HTTP context (got: ' +
JSON.stringify(decResult) + ')');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(e => { console.error(e); process.exit(1); });
-286
View File
@@ -1,286 +0,0 @@
/**
* 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); });
-159
View File
@@ -1,159 +0,0 @@
/**
* Tests for live PSK decrypt on WebSocket-delivered GRP_TXT packets.
*
* Bug: when a user has a stored PSK key for a channel and a new encrypted
* GRP_TXT packet arrives via the WebSocket feed, the existing UI path
* leaves it as an encrypted blob and only renders sender="Unknown" with
* empty text. The user has to refresh the page to get the message decrypted
* via the REST fetch path.
*
* Fix:
* - ChannelDecrypt.buildKeyMap() -> Map<hashByte, { channelName, keyBytes, keyHex }>
* - ChannelDecrypt.tryDecryptLive(payload, keyMap)
* For GRP_TXT payloads with encryptedData/mac/channelHash matching
* a stored key, returns { sender, text, channelName, channelHashByte }.
* Returns null when no key matches or when MAC verification fails.
* - channels.js processWSBatch() uses these to upgrade encrypted live
* packets in-place before rendering, and bumps an unread badge for
* channels the user is not currently viewing.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const { subtle } = require('crypto').webcrypto;
const { createCipheriv, createHmac, createHash } = require('crypto');
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
function createSandbox() {
const storage = {};
const localStorage = {
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; },
_data: storage,
};
const ctx = {
window: {},
crypto: { subtle },
TextEncoder, TextDecoder, Uint8Array, Map, Set,
localStorage,
console, Date, JSON, parseInt, Math, String, Number, Object, Array, RegExp, Error, Promise, setTimeout,
btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
atob: (s) => Buffer.from(s, 'base64').toString('binary'),
};
ctx.window = ctx;
ctx.self = ctx;
return ctx;
}
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; // ts(4)+flags(1)+text+null
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');
// remaining bytes already 0 (includes null terminator + ECB padding)
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'),
channelHash,
};
}
async function run() {
console.log('\n=== Live PSK decrypt: ChannelDecrypt helpers ===');
const cdSrc = fs.readFileSync(path.join(__dirname, 'public/channel-decrypt.js'), 'utf8');
const aesSrc = fs.readFileSync(path.join(__dirname, 'public/vendor/aes-ecb.js'), 'utf8');
const sandbox = createSandbox();
const ctx = vm.createContext(sandbox);
vm.runInContext(aesSrc, ctx);
vm.runInContext(cdSrc, ctx);
const CD = sandbox.window.ChannelDecrypt;
assert(typeof CD.buildKeyMap === 'function',
'ChannelDecrypt.buildKeyMap exists');
assert(typeof CD.tryDecryptLive === 'function',
'ChannelDecrypt.tryDecryptLive exists');
// Store a key for #LiveTest
const channelName = '#LiveTest';
const keyBytes = await CD.deriveKey(channelName);
const keyHex = CD.bytesToHex(keyBytes);
CD.storeKey(channelName, keyHex);
const map = await CD.buildKeyMap();
const expectedHashByte = await CD.computeChannelHash(keyBytes);
assert(map && typeof map.get === 'function',
'buildKeyMap returns a Map');
assert(map.get(expectedHashByte) && map.get(expectedHashByte).channelName === channelName,
'buildKeyMap entry indexed by channel hash byte → channelName');
// Fabricate a live encrypted GRP_TXT packet on this channel
const fixture = buildEncryptedGrpTxt(channelName, 'Alice', 'hello world');
const decrypted = await CD.tryDecryptLive(fixture.payload, map);
assert(decrypted && decrypted.sender === 'Alice',
'tryDecryptLive recovers sender from matching stored key');
assert(decrypted && decrypted.text === 'hello world',
'tryDecryptLive recovers message text');
assert(decrypted && decrypted.channelName === channelName,
'tryDecryptLive returns the matching channelName');
assert(decrypted && decrypted.channelHashByte === expectedHashByte,
'tryDecryptLive returns channelHashByte for unread bookkeeping');
// No match → null (different channel hash)
const otherFixture = buildEncryptedGrpTxt('#NotStored', 'Bob', 'silent');
const noMatch = await CD.tryDecryptLive(otherFixture.payload, map);
assert(noMatch === null,
'tryDecryptLive returns null when no stored key matches the channel hash');
// Non-GRP_TXT payload → null (defensive)
const skip = await CD.tryDecryptLive({ type: 'CHAN', channel: channelName, text: 'already decrypted' }, map);
assert(skip === null,
'tryDecryptLive returns null for non-GRP_TXT payloads (already-decrypted CHAN)');
// Empty/missing fields → null (no crash)
const empty = await CD.tryDecryptLive({ type: 'GRP_TXT' }, map);
assert(empty === null,
'tryDecryptLive returns null when encryptedData/mac missing');
console.log('\n=== Live PSK decrypt: channels.js integration contract ===');
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
assert(/tryDecryptLive\s*\(/.test(chSrc),
'channels.js calls ChannelDecrypt.tryDecryptLive() in the WS path');
assert(/buildKeyMap\s*\(/.test(chSrc),
'channels.js calls ChannelDecrypt.buildKeyMap() to refresh the lookup index');
assert(/unread/i.test(chSrc),
'channels.js tracks an unread counter for live-decrypted channels');
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); });
-157
View File
@@ -1,157 +0,0 @@
/**
* Tests for #1020 PSK channel UX:
* - Optional label stored alongside key in localStorage
* - removeKey clears both key and label
* - channels.js form has an optional label input
* - User-added rows render with a distinct badge marker in the DOM
* - Status feedback reports decrypt count from result (not DOM scrape)
*
* Runs in Node.js via vm.createContext to simulate the browser.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const { subtle } = require('crypto').webcrypto;
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
function createSandbox() {
const storage = {};
const localStorage = {
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; },
_data: storage,
};
const ctx = {
window: {},
crypto: { subtle },
TextEncoder, TextDecoder, Uint8Array,
localStorage,
console, Date, JSON, parseInt, Math, String, Number, Object, Array, RegExp, Error, Promise, setTimeout,
btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
atob: (s) => Buffer.from(s, 'base64').toString('binary'),
};
ctx.window = ctx;
ctx.self = ctx;
return ctx;
}
async function run() {
console.log('\n=== #1020 PSK UX: ChannelDecrypt label storage ===');
const cdSrc = fs.readFileSync(path.join(__dirname, 'public/channel-decrypt.js'), 'utf8');
const sandbox = createSandbox();
vm.runInContext(cdSrc, vm.createContext(sandbox));
const CD = sandbox.window.ChannelDecrypt;
// saveLabel/getLabel API exists
assert(typeof CD.saveLabel === 'function', 'ChannelDecrypt.saveLabel exists');
assert(typeof CD.getLabel === 'function', 'ChannelDecrypt.getLabel exists');
assert(typeof CD.getLabels === 'function', 'ChannelDecrypt.getLabels exists');
// saveKey overload with label argument
CD.storeKey('psk:aabbccdd', 'aabbccdd11223344aabbccdd11223344', 'My Secret Channel');
assert(CD.getLabel('psk:aabbccdd') === 'My Secret Channel',
'storeKey(name, hex, label) persists label retrievable via getLabel');
// saveLabel updates an existing key's label
CD.saveLabel('psk:aabbccdd', 'Renamed');
assert(CD.getLabel('psk:aabbccdd') === 'Renamed', 'saveLabel updates label');
// removeKey clears label too
CD.removeKey('psk:aabbccdd');
assert(!CD.getLabel('psk:aabbccdd'), 'removeKey clears stored label');
// No-label storage stays valid
CD.storeKey('#LongFast', 'deadbeefdeadbeefdeadbeefdeadbeef');
const keys = CD.getStoredKeys();
assert(keys['#LongFast'] === 'deadbeefdeadbeefdeadbeefdeadbeef',
'storeKey without label still persists key');
assert(!CD.getLabel('#LongFast'), 'no label means getLabel returns falsy');
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'),
'label input has a name-related placeholder');
// E2E DOM: distinct badge class/marker for user-added channels
assert(chSrc.includes('ch-user-added'),
'renderChannelList emits ch-user-added marker for keyed channels');
// Distinct icon
assert(chSrc.includes('🔓'),
'user-added rows use a distinct unlocked icon (🔓) from server-encrypted (🔒)');
// addUserChannel accepts label
assert(/addUserChannel\s*\(\s*val\s*,\s*\w*label/i.test(chSrc) ||
/addUserChannel\([^)]*\blabel\b[^)]*\)/.test(chSrc),
'addUserChannel signature accepts a label parameter');
// mergeUserChannels reads labels
assert(/getLabels?\s*\(/.test(chSrc),
'channels.js queries ChannelDecrypt.getLabels()/getLabel()');
// Toast count comes from result.messages, not from #chMessages DOM scrape
assert(!/querySelectorAll\('#chMessages \.ch-msg'\)\.length/.test(chSrc),
'addUserChannel must not scrape #chMessages DOM for count (use decrypt result)');
console.log('\n=== #1020 PSK UX: end-to-end label flow via mergeUserChannels ===');
// Reset sandbox storage and re-run the module so the userLabel propagation
// through mergeUserChannels is exercised end-to-end (not just by string-grep).
const sandbox2 = createSandbox();
vm.runInContext(cdSrc, vm.createContext(sandbox2));
const CD2 = sandbox2.window.ChannelDecrypt;
CD2.storeKey('psk:cafebabe', 'cafebabecafebabecafebabecafebabe', 'Crew Channel');
CD2.storeKey('#NoLabel', 'deadbeefdeadbeefdeadbeefdeadbeef');
// Lift the IIFE-internal mergeUserChannels behavior into a tiny harness:
// simulate the relevant slice of channels.js using the public API.
const channelsArr = [];
function mergeUserChannels(channels, CDref) {
const keys = CDref.getStoredKeys();
const labels = CDref.getLabels();
Object.keys(keys).forEach(name => {
const label = labels[name] || '';
const existing = channels.find(c => c.name === name || c.hash === name || c.hash === ('user:' + name));
if (existing) {
existing.userAdded = true;
if (label) existing.userLabel = label;
} else {
channels.push({
hash: 'user:' + name, name, userLabel: label,
messageCount: 0, encrypted: true, userAdded: true,
});
}
});
}
mergeUserChannels(channelsArr, CD2);
const labeled = channelsArr.find(c => c.name === 'psk:cafebabe');
const unlabeled = channelsArr.find(c => c.name === '#NoLabel');
assert(labeled && labeled.userLabel === 'Crew Channel',
'mergeUserChannels propagates user label onto channel object');
assert(unlabeled && unlabeled.userAdded === true && !unlabeled.userLabel,
'mergeUserChannels marks unlabeled channels userAdded with no label');
// Removal path clears both
CD2.removeKey('psk:cafebabe');
assert(!CD2.getStoredKeys()['psk:cafebabe'], 'after removeKey, key gone');
assert(!CD2.getLabel('psk:cafebabe'), 'after removeKey, label gone');
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); });
+29 -283
View File
@@ -1,296 +1,42 @@
/* test-clear-filters.js behavioral tests for clear-filters button (#964)
* Uses vm.createContext to exercise the actual clear handler logic,
* not source-grep tautology.
*/
/* test-clear-filters.js — unit test for clear-filters button (#964) */
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
const fs = require('fs');
const path = require('path');
console.log('--- test-clear-filters.js ---');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
const src = fs.readFileSync(path.join(__dirname, 'public', 'packets.js'), 'utf-8');
// Test 1: button HTML present
assert(src.includes('clearFiltersBtn'), 'clearFiltersBtn ID should exist');
assert(src.includes('✕ Clear'), 'Clear button label should exist');
// Test 2: clear handler resets all filter keys
for (const k of ['hash', 'node', 'observer', 'channel', 'type', '_filterExpr']) {
assert(src.includes(`filters.${k} = undefined`), `should reset filters.${k}`);
}
assert(src.includes('filters._packetFilter = null'), 'should reset _packetFilter');
assert(src.includes('filters.myNodes = false'), 'should reset myNodes');
/**
* Build a minimal sandbox that stubs DOM/localStorage/RegionFilter
* enough for the clear handler and updatePacketsUrl to run.
*/
function makeSandbox() {
const storage = {};
const elements = {};
const checkboxes = {};
// Test 3: clears localStorage
assert(src.includes("localStorage.removeItem('meshcore-observer-filter')"), 'clear observer localStorage');
assert(src.includes("localStorage.removeItem('meshcore-type-filter')"), 'clear type localStorage');
function makeEl(id, tag) {
const el = {
id, value: '', textContent: '', style: { display: '' },
classList: { remove: function() { el._classes = el._classes.filter(c => !Array.from(arguments).includes(c)); }, _list: [] },
_classes: [],
addEventListener: (ev, fn) => { el['_on_' + ev] = fn; },
querySelectorAll: () => checkboxes[id] || [],
};
el.classList.add = function() { el._classes.push(...arguments); };
elements[id] = el;
return el;
}
// Test 4: resets RegionFilter
assert(src.includes('RegionFilter.setSelected([])'), 'reset RegionFilter');
// Pre-create all elements the clear handler touches
for (const id of [
'clearFiltersBtn', 'fHash', 'fNode', 'fChannel', 'fTimeWindow',
'packetFilterInput', 'packetFilterError', 'packetFilterCount',
'fMyNodes', 'observerMenu', 'typeMenu', 'observerTrigger', 'typeTrigger'
]) {
makeEl(id);
}
// Test 5: visibility toggle in updatePacketsUrl
assert(src.includes("cb.style.display = active ?"), 'toggle clear btn visibility');
// Create mock checkboxes for observer/type menus
for (const menuId of ['observerMenu', 'typeMenu']) {
const cb1 = { checked: true }, cb2 = { checked: true };
checkboxes[menuId] = [cb1, cb2];
}
// Test 6: resets DOM inputs
assert(src.includes("getElementById('fHash').value = ''"), 'clear hash input');
assert(src.includes("getElementById('fNode').value = ''"), 'clear node input');
assert(src.includes("getElementById('fChannel').value = ''"), 'clear channel select');
const regionState = { selected: ['US-W'] };
// Test 7: resets multi-select checkboxes
assert(src.includes('observerMenu'), 'clear observer checkboxes');
assert(src.includes('typeMenu'), 'clear type checkboxes');
const ctx = {
console,
window: {
addEventListener: () => {},
dispatchEvent: () => {},
matchMedia: () => ({ matches: false }),
HashColor: null,
buildPacketsQuery: null,
},
document: {
readyState: 'complete',
getElementById: (id) => elements[id] || null,
createElement: (tag) => makeEl('_dynamic_' + Math.random(), tag),
documentElement: { dataset: { theme: 'light' } },
querySelectorAll: () => [],
head: { appendChild: () => {} },
addEventListener: () => {},
},
localStorage: {
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; },
},
history: { replaceState: () => {} },
location: { hash: '#/packets' },
setTimeout: (fn) => fn(),
clearTimeout: () => {},
Number, String, Map, Set, Array, Object, JSON, Math,
isNaN, isFinite, parseInt, parseFloat, encodeURIComponent, decodeURIComponent,
RegExp, Error, TypeError, Date,
// Stubs for globals packets.js references
observerMap: new Map(),
HashColor: null,
RegionFilter: {
getRegionParam: () => regionState.selected.length ? regionState.selected.join(',') : '',
setSelected: (arr) => { regionState.selected = arr; },
onUpdate: () => {},
init: () => {},
},
// Provide isMobile
navigator: { userAgent: 'node-test' },
};
ctx.window.RegionFilter = ctx.RegionFilter;
ctx.globalThis = ctx;
ctx.self = ctx;
return { ctx, elements, storage, checkboxes, regionState };
}
/**
* Extract the clear handler body from packets.js and wrap it as a callable function.
* This is more robust than loading the entire IIFE (which needs full DOM).
*/
function extractClearHandler() {
const src = fs.readFileSync(__dirname + '/public/packets.js', 'utf-8');
// Find the clear handler
const marker = "if (clearBtn) clearBtn.addEventListener('click', function()";
const idx = src.indexOf(marker);
assert(idx !== -1, 'clear handler not found in packets.js');
// Find its opening brace and matching close
const fnStart = src.indexOf('{', idx + marker.length);
let depth = 0, fnEnd = -1;
for (let i = fnStart; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) { fnEnd = i; break; } }
}
assert(fnEnd > fnStart, 'could not find end of clear handler');
return src.substring(fnStart + 1, fnEnd);
}
/**
* Extract updatePacketsUrl function body
*/
function extractUpdatePacketsUrl() {
const src = fs.readFileSync(__dirname + '/public/packets.js', 'utf-8');
const marker = 'function updatePacketsUrl()';
const idx = src.indexOf(marker);
assert(idx !== -1, 'updatePacketsUrl not found');
const fnStart = src.indexOf('{', idx);
let depth = 0, fnEnd = -1;
for (let i = fnStart; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) { fnEnd = i; break; } }
}
return src.substring(fnStart + 1, fnEnd);
}
const clearBody = extractClearHandler();
const updateUrlBody = extractUpdatePacketsUrl();
// ---- Tests ----
test('clear handler resets all filter keys to undefined/null/false', () => {
const { ctx, elements } = makeSandbox();
const filters = {
hash: 'abc123', node: 42, nodeName: 'Test', observer: 'obs1',
channel: 3, type: 'IDENT', _filterExpr: 'src==5', _packetFilter: () => true,
myNodes: true,
};
let savedTimeWindowMin = 60;
const DEFAULT_TIME_WINDOW = 15;
let _observerFilterSet = new Set([1, 2]);
// Build a function with the handler body and needed locals in scope
const fn = new Function(
'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet',
'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets',
`${clearBody}; return { savedTimeWindowMin, _observerFilterSet };`
);
const result = fn(
filters, savedTimeWindowMin, DEFAULT_TIME_WINDOW, _observerFilterSet,
ctx.localStorage, ctx.document, ctx.RegionFilter,
() => {}, () => {} // stubs for updatePacketsUrl and loadPackets
);
assert.strictEqual(filters.hash, undefined, 'hash not cleared');
assert.strictEqual(filters.node, undefined, 'node not cleared');
assert.strictEqual(filters.nodeName, undefined, 'nodeName not cleared');
assert.strictEqual(filters.observer, undefined, 'observer not cleared');
assert.strictEqual(filters.channel, undefined, 'channel not cleared');
assert.strictEqual(filters.type, undefined, 'type not cleared');
assert.strictEqual(filters._filterExpr, undefined, '_filterExpr not cleared');
assert.strictEqual(filters._packetFilter, null, '_packetFilter not cleared');
assert.strictEqual(filters.myNodes, false, 'myNodes not cleared');
});
test('clear handler resets savedTimeWindowMin to DEFAULT_TIME_WINDOW', () => {
const { ctx } = makeSandbox();
ctx.localStorage.setItem('meshcore-time-window', '120');
const filters = { myNodes: false };
const DEFAULT_TIME_WINDOW = 15;
let _observerFilterSet = null;
// The handler assigns to savedTimeWindowMin — we need to check the returned value
const fn = new Function(
'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet',
'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets',
`${clearBody}; return { savedTimeWindowMin };`
);
const result = fn(
filters, 120, DEFAULT_TIME_WINDOW, _observerFilterSet,
ctx.localStorage, ctx.document, ctx.RegionFilter,
() => {}, () => {}
);
assert.strictEqual(result.savedTimeWindowMin, 15, 'savedTimeWindowMin not reset to default');
assert.strictEqual(ctx.localStorage.getItem('meshcore-time-window'), null, 'time-window localStorage not cleared');
});
test('clear handler resets fTimeWindow dropdown value', () => {
const { ctx, elements } = makeSandbox();
elements['fTimeWindow'].value = '120';
const filters = { myNodes: false };
const fn = new Function(
'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet',
'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets',
`${clearBody}; return { savedTimeWindowMin };`
);
fn(filters, 120, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {});
assert.strictEqual(elements['fTimeWindow'].value, '15', 'fTimeWindow DOM not reset');
});
test('clear handler clears observer and type localStorage', () => {
const { ctx } = makeSandbox();
ctx.localStorage.setItem('meshcore-observer-filter', 'obs1');
ctx.localStorage.setItem('meshcore-type-filter', 'IDENT');
const filters = { myNodes: false };
const fn = new Function(
'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet',
'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets',
`${clearBody};`
);
fn(filters, 15, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {});
assert.strictEqual(ctx.localStorage.getItem('meshcore-observer-filter'), null);
assert.strictEqual(ctx.localStorage.getItem('meshcore-type-filter'), null);
});
test('clear handler unchecks observer/type multi-select checkboxes', () => {
const { ctx, checkboxes } = makeSandbox();
const filters = { myNodes: false };
const fn = new Function(
'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet',
'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets',
`${clearBody};`
);
fn(filters, 15, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {});
for (const cb of checkboxes['observerMenu']) assert.strictEqual(cb.checked, false, 'observer checkbox still checked');
for (const cb of checkboxes['typeMenu']) assert.strictEqual(cb.checked, false, 'type checkbox still checked');
});
test('clear handler resets RegionFilter', () => {
const { ctx, regionState } = makeSandbox();
regionState.selected = ['US-W', 'EU'];
const filters = { myNodes: false };
const fn = new Function(
'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet',
'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets',
`${clearBody};`
);
fn(filters, 15, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {});
assert.deepStrictEqual(regionState.selected, [], 'RegionFilter not cleared');
});
test('updatePacketsUrl shows clear button when time window != default', () => {
const { ctx, elements } = makeSandbox();
// No other filters active, but time window is non-default
const filters = {};
let savedTimeWindowMin = 60;
const DEFAULT_TIME_WINDOW = 15;
const fn = new Function(
'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW',
'document', 'history', 'RegionFilter', 'buildPacketsQuery',
updateUrlBody
);
fn(filters, savedTimeWindowMin, DEFAULT_TIME_WINDOW,
ctx.document, ctx.history, ctx.RegionFilter, () => '');
assert.strictEqual(elements['clearFiltersBtn'].style.display, '', 'clear button should be visible when time window != default');
});
test('updatePacketsUrl hides clear button when all filters default', () => {
const { ctx, elements, regionState } = makeSandbox();
regionState.selected = [];
const filters = {};
const fn = new Function(
'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW',
'document', 'history', 'RegionFilter', 'buildPacketsQuery',
updateUrlBody
);
fn(filters, 15, 15, ctx.document, ctx.history, ctx.RegionFilter, () => '');
assert.strictEqual(elements['clearFiltersBtn'].style.display, 'none', 'clear button should be hidden');
});
// Summary
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
console.log('All tests passed ✅');
console.log('All 7 tests passed ✅');
-99
View File
@@ -1,99 +0,0 @@
/**
* Tests for channel color picker UX fixes (#681)
*
* Verifies:
* 1. Live feed color dots are >= 16px (not tiny 12px)
* 2. No contextmenu handler on live feed that hijacks right-click
* 3. Channels page color dots with assigned color show clear affordance
* 4. Popover positioning respects viewport bounds with margin
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
let passed = 0;
let failed = 0;
function assert(condition, msg) {
if (condition) {
passed++;
console.log(`${msg}`);
} else {
failed++;
console.error(` ✗ FAIL: ${msg}`);
}
}
// --- Test 1: Live feed dot size ---
console.log('\n=== Live feed color dot size (#681) ===');
const liveSource = fs.readFileSync(path.join(__dirname, 'public/live.js'), 'utf8');
// The feed-color-dot inline style should use width >= 16px
const dotMatch = liveSource.match(/feed-color-dot.*?width:(\d+)px/);
assert(dotMatch !== null, 'feed-color-dot has width in inline style');
if (dotMatch) {
const dotWidth = parseInt(dotMatch[1], 10);
assert(dotWidth >= 16, `feed-color-dot width is ${dotWidth}px (should be >= 16px)`);
}
// Height should match
const dotHeightMatch = liveSource.match(/feed-color-dot.*?height:(\d+)px/);
if (dotHeightMatch) {
const dotHeight = parseInt(dotHeightMatch[1], 10);
assert(dotHeight >= 16, `feed-color-dot height is ${dotHeight}px (should be >= 16px)`);
}
// --- Test 2: No contextmenu hijack on live feed ---
console.log('\n=== No right-click hijack on live feed (#681) ===');
const pickerSource = fs.readFileSync(path.join(__dirname, 'public/channel-color-picker.js'), 'utf8');
// The picker should NOT install a contextmenu listener on the live feed
// Look for the installLiveFeedHandlers function and check it doesn't add contextmenu
const liveFeedHandlerMatch = pickerSource.match(/function installLiveFeedHandlers\(\)[\s\S]*?^ \}/m);
if (liveFeedHandlerMatch) {
const handlerBody = liveFeedHandlerMatch[0];
assert(!handlerBody.includes("'contextmenu'") && !handlerBody.includes('"contextmenu"'),
'installLiveFeedHandlers does NOT add contextmenu listener');
} else {
// Alternative: check the entire picker source for liveFeed + contextmenu combo
// The feed variable + contextmenu listener pattern
const hasLiveFeedContextMenu = /feed\.addEventListener\(['"]contextmenu['"]/.test(pickerSource);
assert(!hasLiveFeedContextMenu, 'No contextmenu listener on liveFeed element');
}
// --- Test 3: Channels page clear affordance ---
console.log('\n=== Channels page clear affordance (#681) ===');
const channelsSource = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
// Channels page should render a clear button/icon next to colored dots
// without requiring the picker to be opened
const hasClearAffordance = channelsSource.includes('ch-color-clear') ||
channelsSource.includes('color-clear');
assert(hasClearAffordance, 'Channels page has inline clear affordance for colored dots');
// --- Test 4: Popover positioning margin ---
console.log('\n=== Popover positioning margin (#681) ===');
// The popover positioning should use a margin of at least 12px from edges
// (not just 8px which causes overlap with panel borders)
const posMatch = pickerSource.match(/vw - pw - (\d+)/);
assert(posMatch !== null, 'Popover has horizontal edge margin');
if (posMatch) {
const margin = parseInt(posMatch[1], 10);
assert(margin >= 12, `Popover edge margin is ${margin}px (should be >= 12px)`);
}
const posMatchV = pickerSource.match(/vh - ph - (\d+)/);
if (posMatchV) {
const marginV = parseInt(posMatchV[1], 10);
assert(marginV >= 12, `Popover vertical margin is ${marginV}px (should be >= 12px)`);
}
// --- Summary ---
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
-94
View File
@@ -1,94 +0,0 @@
/* Unit tests for compare.js flood/direct packet filter — #928 */
'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}`); }
}
// Build minimal sandbox and load compare.js
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.window.comparePacketSets = undefined;
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);
// --- Tests ---
console.log('\ncompare.js flood/direct filter tests:');
test('filterPacketsByRoute is exposed on window', () => {
assert.strictEqual(typeof sandbox.window.filterPacketsByRoute, 'function',
'filterPacketsByRoute should be exposed on window');
});
const packets = [
{ hash: 'a1', route_type: 0 }, // TransportFlood
{ hash: 'a2', route_type: 1 }, // Flood
{ hash: 'a3', route_type: 2 }, // Direct
{ hash: 'a4', route_type: 3 }, // TransportDirect
{ hash: 'a5', route_type: null }, // unknown
];
test('mode "all" returns all packets', () => {
const result = sandbox.window.filterPacketsByRoute(packets, 'all');
assert.strictEqual(result.length, 5);
});
test('mode "flood" returns only route_type 0 and 1', () => {
const result = sandbox.window.filterPacketsByRoute(packets, 'flood');
assert.strictEqual(result.length, 2);
assert.deepStrictEqual(result.map(p => p.hash), ['a1', 'a2']);
});
test('mode "direct" returns only route_type 2 and 3', () => {
const result = sandbox.window.filterPacketsByRoute(packets, 'direct');
assert.strictEqual(result.length, 2);
assert.deepStrictEqual(result.map(p => p.hash), ['a3', 'a4']);
});
test('mode "flood" excludes null route_type', () => {
const result = sandbox.window.filterPacketsByRoute(packets, 'flood');
assert.ok(!result.some(p => p.route_type === null));
});
test('empty array returns empty', () => {
const result = sandbox.window.filterPacketsByRoute([], 'flood');
assert.strictEqual(result.length, 0);
});
// --- Summary ---
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
-63
View File
@@ -512,69 +512,6 @@ test('existing user overrides are NOT pruned by setOverride on other keys', () =
assert.strictEqual(delta.theme.border, '#00ff00', 'new non-matching override should be stored');
});
// ── Fix #895: export/import includes favorites and claimed nodes ──
test('readOverrides includes favorites from localStorage', () => {
const { api, ls } = loadCustomizer();
api.init({});
ls.setItem('meshcore-favorites', JSON.stringify(['abc123', 'def456']));
const data = api.readOverrides();
assert.deepStrictEqual(data.favorites, ['abc123', 'def456'], 'favorites should be included in export');
});
test('readOverrides includes myNodes from localStorage', () => {
const { api, ls } = loadCustomizer();
api.init({});
ls.setItem('meshcore-my-nodes', JSON.stringify([{pubkey: 'abc123', name: 'Node1', addedAt: 1000}]));
const data = api.readOverrides();
assert.deepStrictEqual(data.myNodes, [{pubkey: 'abc123', name: 'Node1', addedAt: 1000}], 'myNodes should be included in export');
});
test('writeOverrides restores favorites to localStorage', () => {
const { api, ls } = loadCustomizer();
api.init({});
api.writeOverrides({ favorites: ['abc123', 'def456'] });
const favs = JSON.parse(ls.getItem('meshcore-favorites') || '[]');
assert.deepStrictEqual(favs, ['abc123', 'def456'], 'favorites should be written to meshcore-favorites');
});
test('writeOverrides restores myNodes to localStorage', () => {
const { api, ls } = loadCustomizer();
api.init({});
const nodes = [{pubkey: 'abc123', name: 'Node1', addedAt: 1000}];
api.writeOverrides({ myNodes: nodes });
const stored = JSON.parse(ls.getItem('meshcore-my-nodes') || '[]');
assert.deepStrictEqual(stored, nodes, 'myNodes should be written to meshcore-my-nodes');
});
test('validateShape accepts favorites array', () => {
const { api } = loadCustomizer();
api.init({});
const result = api.validateShape({ favorites: ['abc123'] });
assert.ok(result.valid, 'favorites array should be valid');
});
test('validateShape accepts myNodes array', () => {
const { api } = loadCustomizer();
api.init({});
const result = api.validateShape({ myNodes: [{pubkey: 'abc', name: 'N', addedAt: 1}] });
assert.ok(result.valid, 'myNodes array should be valid');
});
test('validateShape rejects non-array favorites', () => {
const { api } = loadCustomizer();
api.init({});
const result = api.validateShape({ favorites: 'not-an-array' });
assert.ok(!result.valid, 'non-array favorites should be invalid');
});
test('validateShape rejects non-array myNodes', () => {
const { api } = loadCustomizer();
api.init({});
const result = api.validateShape({ myNodes: 'not-an-array' });
assert.ok(!result.valid, 'non-array myNodes should be invalid');
});
// ── Summary ──
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
-183
View File
@@ -590,47 +590,6 @@ async function run() {
assert(cards.length >= 3, `Expected >=3 overview stat cards, got ${cards.length}`);
});
// Test 8b (#842): time-window picker triggers requests with ?window=… param.
await test('Analytics time-window picker refetches with window param', async () => {
// Picker must be rendered.
await page.waitForSelector('#analyticsTimeWindow', { timeout: 5000 });
const opts = await page.$$eval('#analyticsTimeWindow option', els => els.map(e => e.value));
assert(opts.includes('24h'), `picker must offer 24h, got ${JSON.stringify(opts)}`);
// Capture all analytics requests fired after we change the picker.
const seen = [];
const onReq = r => {
const u = r.url();
if (/\/api\/analytics\/(rf|topology|channels|hash-sizes|hash-collisions)(\?|$)/.test(u)) {
seen.push(u);
}
};
page.on('request', onReq);
const reqPromise = page.waitForRequest(
r => /\/api\/analytics\/rf(\?|$)/.test(r.url()),
{ timeout: 8000 }
);
await page.selectOption('#analyticsTimeWindow', '24h');
const req = await reqPromise;
assert(
/[?&]window=24h(&|$)/.test(req.url()),
`analytics/rf request should carry window=24h, got ${req.url()}`
);
// Drain the rest of the parallel fetches.
await page.waitForTimeout(500);
page.off('request', onReq);
// Window must be scoped to rf/topology/channels only — not to
// hash-sizes / hash-collisions, whose semantics are time-independent.
const winFor = pat => seen.filter(u => pat.test(u)).some(u => /[?&]window=24h(&|$)/.test(u));
const noWinFor = pat => seen.filter(u => pat.test(u)).every(u => !/[?&]window=/.test(u));
assert(winFor(/\/api\/analytics\/rf/), `expected window=24h on rf, saw: ${seen.join(', ')}`);
assert(winFor(/\/api\/analytics\/topology/), `expected window=24h on topology, saw: ${seen.join(', ')}`);
assert(winFor(/\/api\/analytics\/channels/), `expected window=24h on channels, saw: ${seen.join(', ')}`);
assert(noWinFor(/\/api\/analytics\/hash-sizes/), `hash-sizes must NOT carry window param, saw: ${seen.join(', ')}`);
assert(noWinFor(/\/api\/analytics\/hash-collisions/), `hash-collisions must NOT carry window param, saw: ${seen.join(', ')}`);
});
// Analytics sub-tab tests
await test('Analytics RF tab renders content', async () => {
await page.click('[data-tab="rf"]');
@@ -1794,32 +1753,6 @@ async function run() {
assert(hasFullScreen, 'Full-screen detail view should be open on desktop deep link (#823)');
});
// Test: short URL prefix resolves AND copy short URL button is rendered (#772)
await test('Short URL: 8-char prefix resolves and Copy short URL button is present', async () => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto(BASE + '#/nodes', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 10000 });
const pubkey = await page.$eval('#nodesBody tr[data-key]', el => el.dataset.key);
const prefix = pubkey.slice(0, 8);
// Navigate via the SHORT URL only.
await page.goto(BASE + '#/nodes/' + prefix, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.node-fullscreen', { timeout: 10000 });
// Either the prefix resolved unambiguously (button exists) or the prod
// fixture has multiple matching prefixes; in the latter case the page
// shows an error rather than a detail card. Accept either, but require
// detail surface (button) when it does resolve.
const btn = await page.$('#copyShortUrlBtn');
if (btn) {
const txt = await btn.evaluate(el => el.textContent);
assert(txt.includes('Copy short URL'), `expected button text to include 'Copy short URL', got: ${txt}`);
} else {
// Skip silently if fixture has prefix collisions — main assertion below covers backend.
const e = new Error('Prefix collision in fixture; backend behavior covered by Go tests');
e.skip = true;
throw e;
}
});
// Test: packets timeWindow deep link
await test('Packets timeWindow deep link restores dropdown', async () => {
await page.goto(BASE + '#/packets?timeWindow=60', { waitUntil: 'domcontentloaded' });
@@ -2327,122 +2260,6 @@ 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' };
});
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));
});
// --- Geofilter draft: save/load/download buttons (issue #819, rule 18) ---
await test('Geofilter draft: save → reload → load → download round-trip', async () => {
// Open the geofilter builder page and clear any prior draft.
await page.goto(BASE + '/geofilter-builder.html', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#map', { timeout: 10000 });
await page.evaluate(() => localStorage.removeItem('geofilter-draft'));
// Wait for leaflet to finish initial render so click handlers are bound.
await page.waitForFunction(() => window.L && document.querySelector('#map.leaflet-container'), { timeout: 10000 });
await page.waitForTimeout(300);
// Click 3 distinct points on the map to form a polygon.
const mapBox = await page.$eval('#map', el => {
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const clicks = [
{ x: mapBox.x + mapBox.w * 0.30, y: mapBox.y + mapBox.h * 0.30 },
{ x: mapBox.x + mapBox.w * 0.70, y: mapBox.y + mapBox.h * 0.30 },
{ x: mapBox.x + mapBox.w * 0.50, y: mapBox.y + mapBox.h * 0.70 },
];
for (const c of clicks) {
await page.mouse.click(c.x, c.y);
await page.waitForTimeout(120);
}
// Verify the page registered 3 points before we save.
await page.waitForFunction(() => {
const txt = (document.getElementById('counter') || {}).textContent || '';
return /^3 points?/.test(txt);
}, { timeout: 5000 });
// Save draft → assert localStorage populated with the polygon.
await page.click('#btnSaveDraft');
const draftRaw = await page.evaluate(() => localStorage.getItem('geofilter-draft'));
assert(draftRaw, 'localStorage geofilter-draft should be populated after Save Draft click');
const draft = JSON.parse(draftRaw);
assert(Array.isArray(draft.polygon) && draft.polygon.length === 3,
`draft.polygon should contain exactly 3 points, got ${draft.polygon && draft.polygon.length}`);
assert(typeof draft.polygon[0][0] === 'number' && typeof draft.polygon[0][1] === 'number',
'draft.polygon points should be [lat, lon] number pairs');
// Reload the page (draft persists in localStorage), then Load Draft.
await page.goto(BASE + '/geofilter-builder.html', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#btnLoadDraft', { timeout: 10000 });
await page.waitForFunction(() => window.GeofilterDraft && typeof window.GeofilterDraft.loadDraft === 'function', { timeout: 5000 });
// Counter should start at 0 after reload (before Load Draft click).
const counterBefore = await page.$eval('#counter', el => el.textContent);
assert(/^0 points?/.test(counterBefore),
`Counter should be "0 points" right after reload, got "${counterBefore}"`);
await page.click('#btnLoadDraft');
await page.waitForFunction(() => {
const txt = (document.getElementById('counter') || {}).textContent || '';
return /^3 points?/.test(txt);
}, { timeout: 5000 });
// Output should now contain a populated geo_filter snippet (not the empty placeholder).
const outputAfterLoad = await page.$eval('#output', el => el.textContent);
assert(outputAfterLoad.includes('"geo_filter"') && outputAfterLoad.includes('"polygon"'),
`#output should contain geo_filter+polygon after Load Draft, got: ${outputAfterLoad.slice(0, 120)}`);
// Download → intercept the blob, parse it, assert valid geo_filter snippet.
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 5000 }),
page.click('#btnDownload'),
]);
const dlPath = await download.path();
assert(dlPath, 'Download should produce a file path');
const fs = require('fs');
const downloaded = fs.readFileSync(dlPath, 'utf8');
let parsed;
try { parsed = JSON.parse(downloaded); }
catch (e) { throw new Error('Downloaded file is not valid JSON: ' + e.message); }
assert(parsed.geo_filter, 'Downloaded JSON must have a top-level "geo_filter" key');
assert(Array.isArray(parsed.geo_filter.polygon) && parsed.geo_filter.polygon.length === 3,
`Downloaded geo_filter.polygon should contain 3 points, got ${parsed.geo_filter.polygon && parsed.geo_filter.polygon.length}`);
assert(typeof parsed.geo_filter.bufferKm === 'number',
'Downloaded geo_filter.bufferKm should be a number');
// Cleanup: remove the draft so we leave no test data behind.
await page.evaluate(() => localStorage.removeItem('geofilter-draft'));
});
await browser.close();
// Summary
-75
View File
@@ -2287,14 +2287,6 @@ console.log('\n=== analytics.js: sortChannels ===');
});
}
// === analytics.js: hash prefix helpers (removed — moved server-side) ===
// 15 tests for buildOneBytePrefixMap, buildTwoBytePrefixInfo, and
// buildCollisionHops were removed in PR #415 when collision analysis moved to
// the Go backend. The equivalent logic is now covered by server-side Go tests:
// cmd/server/collision_details_test.go — collision prefix + node-pair assertions
// cmd/server/routes_test.go — hash-collision endpoint integration
// See issue #437 for the full accounting.
// ===== analytics.js: rfNFColumnChart =====
console.log('\n=== analytics.js: rfNFColumnChart ===');
{
@@ -6459,73 +6451,6 @@ console.log('\n=== analytics.js: renderCollisionsFromServer collision table ==='
});
}
// ===== APP.JS: formatChartAxisLabel =====
console.log('\n=== app.js: formatChartAxisLabel ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
const formatChartAxisLabel = ctx.formatChartAxisLabel;
test('formatChartAxisLabel returns dash for invalid date', () => {
assert.strictEqual(formatChartAxisLabel(new Date('invalid'), true), '—');
});
test('formatChartAxisLabel returns dash for non-Date', () => {
assert.strictEqual(formatChartAxisLabel('not a date', true), '—');
});
test('formatChartAxisLabel ISO short form returns HH:MM', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:00Z');
assert.strictEqual(formatChartAxisLabel(d, true), '14:30');
});
test('formatChartAxisLabel ISO long form returns MM-DD HH:MM', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:00Z');
assert.strictEqual(formatChartAxisLabel(d, false), '06-15 14:30');
});
test('formatChartAxisLabel ISO-seconds short form includes seconds', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso-seconds');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:05Z');
assert.strictEqual(formatChartAxisLabel(d, true), '14:30:05');
});
test('formatChartAxisLabel ISO-seconds long form includes seconds', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso-seconds');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:05Z');
assert.strictEqual(formatChartAxisLabel(d, false), '06-15 14:30:05');
});
test('formatChartAxisLabel locale short form returns localized time', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'locale');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:00Z');
const result = formatChartAxisLabel(d, true);
// Locale output varies by env, but should contain hour digits
assert.ok(result.includes('14') || result.includes('2:'), 'short locale should contain hour: ' + result);
});
test('formatChartAxisLabel locale long form returns date+time', () => {
ctx.localStorage.setItem('meshcore-timestamp-format', 'locale');
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
const d = new Date('2024-06-15T14:30:00Z');
const result = formatChartAxisLabel(d, false);
// Should contain day reference and time
assert.ok(result.length > 5, 'long locale should be non-trivial: ' + result);
});
// Clean up
ctx.localStorage.removeItem('meshcore-timestamp-format');
ctx.localStorage.removeItem('meshcore-timestamp-timezone');
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);
-106
View File
@@ -1,106 +0,0 @@
/* Unit tests for geofilter-builder draft save/load + download config */
'use strict';
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}`); }
}
// --- Mock localStorage ---
function makeStorage() {
const store = {};
return {
getItem(k) { return store[k] || null; },
setItem(k, v) { store[k] = String(v); },
removeItem(k) { delete store[k]; },
_store: store
};
}
// --- Mock DOM helpers ---
function makeDoc() {
const els = {};
const listeners = {};
return {
getElementById(id) {
if (!els[id]) els[id] = { value: '', textContent: '', classList: { add(){}, remove(){} }, style: {}, click() { (listeners[id] || []).forEach(fn => fn()); } };
return els[id];
},
createElement(tag) {
const el = { setAttribute(){}, click(){}, style: {}, href: '', download: '' };
return el;
},
body: { appendChild(el) {}, removeChild(el) {} },
_els: els,
_on(id, fn) { (listeners[id] = listeners[id] || []).push(fn); }
};
}
// --- Tests for the draft module (public/geofilter-draft.js) ---
// The module should export: saveDraft, loadDraft, clearDraft, buildConfigSnippet
const fs = require('fs');
const vm = require('vm');
const path = require('path');
function loadModule(localStorage, document) {
const code = fs.readFileSync(path.join(__dirname, 'public', 'geofilter-draft.js'), 'utf8');
const sandbox = { localStorage, document, window: {}, URL: { createObjectURL() { return 'blob:mock'; }, revokeObjectURL() {} }, Blob: class { constructor(parts, opts) { this.parts = parts; this.opts = opts; } } };
vm.runInNewContext(code, sandbox);
sandbox.GeofilterDraft = sandbox.window.GeofilterDraft;
return sandbox;
}
console.log('geofilter-draft tests:');
test('saveDraft stores polygon + bufferKm to localStorage', () => {
const ls = makeStorage();
const doc = makeDoc();
const ctx = loadModule(ls, doc);
const polygon = [[50.1, 4.2], [50.3, 4.5], [49.9, 4.8]];
ctx.GeofilterDraft.saveDraft(polygon, 20);
const stored = JSON.parse(ls.getItem('geofilter-draft'));
assert.strictEqual(JSON.stringify(stored.polygon), JSON.stringify(polygon));
assert.strictEqual(stored.bufferKm, 20);
});
test('loadDraft returns null when nothing saved', () => {
const ls = makeStorage();
const doc = makeDoc();
const ctx = loadModule(ls, doc);
assert.strictEqual(ctx.GeofilterDraft.loadDraft(), null);
});
test('loadDraft returns saved draft', () => {
const ls = makeStorage();
ls.setItem('geofilter-draft', JSON.stringify({ polygon: [[1,2],[3,4],[5,6]], bufferKm: 10 }));
const doc = makeDoc();
const ctx = loadModule(ls, doc);
const draft = ctx.GeofilterDraft.loadDraft();
assert.strictEqual(JSON.stringify(draft.polygon), JSON.stringify([[1,2],[3,4],[5,6]]));
assert.strictEqual(draft.bufferKm, 10);
});
test('clearDraft removes from localStorage', () => {
const ls = makeStorage();
ls.setItem('geofilter-draft', '{}');
const doc = makeDoc();
const ctx = loadModule(ls, doc);
ctx.GeofilterDraft.clearDraft();
assert.strictEqual(ls.getItem('geofilter-draft'), null);
});
test('buildConfigSnippet returns correct JSON structure', () => {
const ls = makeStorage();
const doc = makeDoc();
const ctx = loadModule(ls, doc);
const polygon = [[50.1, 4.2], [50.3, 4.5], [49.9, 4.8]];
const snippet = ctx.GeofilterDraft.buildConfigSnippet(polygon, 15);
const parsed = JSON.parse(snippet);
assert.strictEqual(JSON.stringify(parsed), JSON.stringify({ geo_filter: { bufferKm: 15, polygon: polygon } }));
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
-43
View File
@@ -49,49 +49,6 @@ test('type == request is false', () => { assert(!PF.compile('type == request').f
test('route == FLOOD', () => { assert(PF.compile('route == FLOOD').filter(pkt)); });
test('route == DIRECT is false', () => { assert(!PF.compile('route == DIRECT').filter(pkt)); });
// --- Transport route filters (issue #339) ---
const tFloodPkt = { ...pkt, route_type: 0 }; // TRANSPORT_FLOOD
const floodPkt = { ...pkt, route_type: 1 }; // FLOOD
const directPkt = { ...pkt, route_type: 2 }; // DIRECT
const tDirectPkt = { ...pkt, route_type: 3 }; // TRANSPORT_DIRECT
test('route == TRANSPORT_FLOOD matches route_type 0', () => {
assert(PF.compile('route == TRANSPORT_FLOOD').filter(tFloodPkt));
assert(!PF.compile('route == TRANSPORT_FLOOD').filter(floodPkt));
});
test('route == TRANSPORT_DIRECT matches route_type 3', () => {
assert(PF.compile('route == TRANSPORT_DIRECT').filter(tDirectPkt));
assert(!PF.compile('route == TRANSPORT_DIRECT').filter(directPkt));
});
test('route == T_FLOOD alias matches route_type 0', () => {
assert(PF.compile('route == T_FLOOD').filter(tFloodPkt));
assert(!PF.compile('route == T_FLOOD').filter(floodPkt));
assert(!PF.compile('route == T_FLOOD').filter(directPkt));
});
test('route == T_DIRECT alias matches route_type 3', () => {
assert(PF.compile('route == T_DIRECT').filter(tDirectPkt));
assert(!PF.compile('route == T_DIRECT').filter(directPkt));
assert(!PF.compile('route == T_DIRECT').filter(tFloodPkt));
});
test('transport == true matches TRANSPORT_FLOOD and TRANSPORT_DIRECT', () => {
assert(PF.compile('transport == true').filter(tFloodPkt));
assert(PF.compile('transport == true').filter(tDirectPkt));
assert(!PF.compile('transport == true').filter(floodPkt));
assert(!PF.compile('transport == true').filter(directPkt));
});
test('transport == false matches non-transported FLOOD and DIRECT', () => {
assert(PF.compile('transport == false').filter(floodPkt));
assert(PF.compile('transport == false').filter(directPkt));
assert(!PF.compile('transport == false').filter(tFloodPkt));
assert(!PF.compile('transport == false').filter(tDirectPkt));
});
test('bare transport (truthy) matches transported packets', () => {
assert(PF.compile('transport').filter(tFloodPkt));
assert(PF.compile('transport').filter(tDirectPkt));
assert(!PF.compile('transport').filter(floodPkt));
assert(!PF.compile('transport').filter(directPkt));
});
// --- Hash ---
test('hash == abc123def456', () => { assert(PF.compile('hash == abc123def456').filter(pkt)); });
test('hash contains abc', () => { assert(PF.compile('hash contains abc').filter(pkt)); });
-79
View File
@@ -958,85 +958,6 @@ console.log('\n=== packets.js: buildPacketsParams ===');
});
}
console.log('\n=== packets.js: scroll position preserved across renderTableRows (#431) ===');
{
// Build a richer sandbox with DOM elements that renderTableRows needs
const ctx = makeSandbox();
// Mock DOM elements needed by renderTableRows and renderVisibleRows
let pktLeftScrollTop = 500;
const pktBody = {
tagName: 'TBODY', id: 'pktBody', _innerHTML: '', children: [],
get innerHTML() { return this._innerHTML; },
set innerHTML(v) { this._innerHTML = v; pktLeftScrollTop = 0; }, // Simulate browser scroll reset on DOM rebuild
appendChild: () => {}, insertBefore: () => {}, removeChild: () => {},
querySelectorAll: () => [], querySelector: () => null,
style: {},
};
const pktLeft = {
tagName: 'DIV', id: 'pktLeft', className: '',
get scrollTop() { return pktLeftScrollTop; },
set scrollTop(v) { pktLeftScrollTop = v; },
clientHeight: 800,
offsetHeight: 800,
querySelector: (sel) => {
if (sel === 'thead') return { offsetHeight: 40 };
if (sel === '.count' || sel === '#pktLeft .count') return { textContent: '' };
return null;
},
querySelectorAll: () => [],
addEventListener: () => {},
removeEventListener: () => {},
style: {},
};
const origGetById = ctx.document.getElementById;
ctx.document.getElementById = (id) => {
if (id === 'pktBody') return pktBody;
if (id === 'pktLeft') return pktLeft;
if (id === 'fGroup') return { classList: { toggle: () => {}, add: () => {}, remove: () => {}, contains: () => false } };
if (id === 'packetFilterCount') return { style: {}, textContent: '' };
if (id === 'vscroll-top') return null;
if (id === 'vscroll-bottom') return null;
return null;
};
ctx.document.querySelector = (sel) => {
if (sel === '#pktLeft .count') return { textContent: '', set textContent(v) {} };
if (sel === '#pktLeft') return pktLeft;
return null;
};
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
loadInCtx(ctx, 'public/packet-helpers.js');
vm.runInContext(`
window.HopDisplay = {
renderHop: function(h, entry, opts) { return '<span>' + h + '</span>'; },
_showFromBtn: function() {}
};
`, ctx);
loadInCtx(ctx, 'public/packets.js');
const api = ctx._packetsTestAPI;
test('scroll position preserved after renderTableRows (#431)', () => {
// Inject packets that will ALL be filtered out by type filter,
// triggering the empty-state path which sets tbody.innerHTML (resetting scroll in browser)
api._setPackets([
{ id: 1, hash: 'aaa', payload_type: 4, timestamp: '2024-01-01T00:00:00Z', observer_id: 'obs1', path_len: 2, decoded_json: '{}' },
{ id: 2, hash: 'bbb', payload_type: 4, timestamp: '2024-01-01T00:01:00Z', observer_id: 'obs1', path_len: 1, decoded_json: '{}' },
]);
// Set scroll position to 500
pktLeftScrollTop = 500;
// Filter by type 99 (no packets match) — this triggers tbody.innerHTML assignment
api._setFilter('type', '99');
try { api.renderTableRows(); } catch(e) { /* swallow DOM stub errors */ }
// scrollTop must be preserved (not reset to 0)
assert.strictEqual(pktLeftScrollTop, 500, 'scrollTop should be preserved after renderTableRows, got ' + pktLeftScrollTop);
});
}
// ===== SUMMARY =====
console.log(`\n${'='.repeat(40)}`);
console.log(`packets.js tests: ${passed} passed, ${failed} failed`);