mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-16 19:42:10 +00:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8c76ca47a | ||
|
|
813b424ca1 | ||
|
|
e66085092e | ||
|
|
4a56be0b48 | ||
|
|
64745f89b1 | ||
|
|
c9c473279e | ||
|
|
ad97c0fdd1 | ||
|
|
c7f655e419 | ||
|
|
b1d89d7d9f | ||
|
|
c173ab7e80 | ||
|
|
4664c90db4 | ||
|
|
2755dc3875 | ||
|
|
5228e67604 | ||
|
|
698514e5e6 | ||
|
|
cf3a383bb2 | ||
|
|
a45ac71508 | ||
|
|
016b87b33c | ||
|
|
889107a5e1 | ||
|
|
50f94603c1 | ||
|
|
b799f54700 | ||
|
|
d5b300a8ba | ||
|
|
2af4259eca | ||
|
|
bf2e721dd7 | ||
|
|
f20431d816 | ||
|
|
f9cfad9cd4 | ||
|
|
96d0bbe487 | ||
|
|
6712da7d7c | ||
|
|
6aef83c82a | ||
|
|
9f14c74b3e | ||
|
|
0b8b1e91a6 | ||
|
|
c678555e75 | ||
|
|
623ebc879b | ||
|
|
0b1924d401 | ||
|
|
0f502370c5 | ||
|
|
e47c39ffda | ||
|
|
1499a55ba7 | ||
|
|
f71e117cdd | ||
|
|
75f1295a06 | ||
|
|
b1b76acb77 | ||
|
|
f87eb3601c | ||
|
|
ec4dd58cb6 | ||
|
|
044a5387af | ||
|
|
01ca843309 | ||
|
|
5f50e80931 | ||
|
|
8f3d12eca5 | ||
|
|
357f7952f7 | ||
|
|
47d081c705 | ||
|
|
be313f60cb | ||
|
|
8a0862523d | ||
|
|
7e8b30aa1f | ||
|
|
b2279b230b | ||
|
|
d1cb84b596 | ||
|
|
711889c823 | ||
|
|
738d5fef39 | ||
|
|
8e6fc9602f | ||
|
|
e2556eaaff |
@@ -246,6 +246,12 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Free disk space
|
||||
run: |
|
||||
docker system prune -af 2>/dev/null || true
|
||||
docker builder prune -af 2>/dev/null || true
|
||||
df -h /
|
||||
|
||||
- name: Build Go Docker image
|
||||
run: |
|
||||
echo "${GITHUB_SHA::7}" > .git-commit
|
||||
|
||||
@@ -30,3 +30,4 @@ cmd/ingestor/ingestor.exe
|
||||
# CI trigger
|
||||
!test-fixtures/e2e-fixture.db
|
||||
corescope-server
|
||||
cmd/server/server
|
||||
|
||||
@@ -33,7 +33,7 @@ public/ — Frontend (vanilla JS, one file per page) — ACTIVE, NOT
|
||||
style.css — Main styles, CSS variables for theming
|
||||
live.css — Live page styles
|
||||
home.css — Home page styles
|
||||
index.html — SPA shell, script/style tags with cache busters
|
||||
index.html — SPA shell, script/style tags with __BUST__ placeholder (auto-replaced at server startup)
|
||||
test-fixtures/ — Real data SQLite fixture from staging (used for E2E tests)
|
||||
scripts/ — Tooling (coverage collector, fixture capture, frontend instrumentation)
|
||||
```
|
||||
@@ -51,18 +51,41 @@ The following were part of the old Node.js backend and have been removed:
|
||||
|
||||
## Rules — Read These First
|
||||
|
||||
### 0. Performance is a feature — not an afterthought
|
||||
Every change must consider performance impact BEFORE implementation. This codebase handles 30K+ packets, 2K+ nodes, and real-time WebSocket updates. A single O(n²) loop or per-item API call can freeze the UI or stall the server.
|
||||
|
||||
**Before writing code, ask:**
|
||||
- What's the worst-case data size this code will process?
|
||||
- Am I adding work inside a hot loop (render, ingest, WS broadcast)?
|
||||
- Am I fetching from the server what I could compute client-side?
|
||||
- Am I recomputing something that could be cached/incremental?
|
||||
- Does my change invalidate caches more broadly than necessary?
|
||||
|
||||
**Hard rules:**
|
||||
- **No per-item API calls.** Fetch bulk, filter client-side.
|
||||
- **No O(n²) in hot paths.** Use Maps/Sets for lookups, not nested array scans.
|
||||
- **No full DOM rebuilds.** Diff or virtualize — never innerHTML entire tables.
|
||||
- **No unbounded data structures.** Every map/slice/array must have eviction or size limits.
|
||||
- **No expensive work under locks.** Copy data under lock, process outside.
|
||||
- **Cache expensive computations.** Invalidate surgically, not globally.
|
||||
- **Debounce/coalesce rapid events.** WebSocket messages, scroll, resize — never fire raw.
|
||||
|
||||
**If your change touches a hot path (packet rendering, ingest, analytics), include a perf justification in the PR description:** what the complexity is, what the expected scale is, and why it won't degrade.
|
||||
|
||||
**Perf claims require proof.** "This is faster" without data is not acceptable. Every PR claiming to fix or improve performance MUST include one of:
|
||||
- A benchmark test (before/after timings with realistic data sizes)
|
||||
- Profile output or timing measurements (e.g. "renderTableRows: 450ms → 12ms on 30K packets")
|
||||
- A test assertion that enforces the perf characteristic (e.g. "filters 30K packets in <50ms")
|
||||
No proof = no merge.
|
||||
|
||||
### 1. No commit without tests
|
||||
Every change that touches logic MUST have tests. For Go backend: `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test ./...`. For frontend: `node test-packet-filter.js && node test-aging.js && node test-frontend-helpers.js`. If you add new logic, add tests. No exceptions.
|
||||
|
||||
### 2. No commit without browser validation
|
||||
After pushing, verify the change works in an actual browser. Use `browser profile=openclaw` against the running instance. Take a screenshot if the change is visual. If you can't validate it, say so — don't claim it works.
|
||||
|
||||
### 3. Cache busters — ALWAYS bump them
|
||||
Every time you change a `.js` or `.css` file in `public/`, bump the cache buster in `index.html`. This has caused 7 separate production regressions. Use:
|
||||
```bash
|
||||
NEWV=$(date +%s) && sed -i "s/v=[0-9]*/v=$NEWV/g" public/index.html
|
||||
```
|
||||
Do this in the SAME commit as the code change, not as a follow-up.
|
||||
### 3. Cache busters are automatic — do NOT manually edit them
|
||||
Cache busters are injected automatically by the Go server at startup. The `__BUST__` placeholder in `index.html` is replaced with a Unix timestamp when the server reads the file. No manual bumping needed — every server restart picks up new asset versions. Do NOT replace `__BUST__` with hardcoded timestamps.
|
||||
|
||||
### 4. Verify API response shape before building UI
|
||||
Before writing client code that consumes an API endpoint, check what the endpoint ACTUALLY returns. Use `curl` or check the server code. Don't assume fields exist — grouped packets (`groupByHash=true`) have different fields than raw packets. This has caused multiple breakages.
|
||||
@@ -324,7 +347,7 @@ One logical change per commit. Each commit is deployable. Each commit has its te
|
||||
|
||||
| Pitfall | Times it happened | Prevention |
|
||||
|---------|-------------------|------------|
|
||||
| Forgot cache busters | 7 | Always bump in same commit |
|
||||
| Forgot cache busters | 7 | Now automatic — `__BUST__` replaced at server startup |
|
||||
| Grouped packets missing fields | 3 | curl the actual API first |
|
||||
| last_seen vs last_heard mismatch | 4 | Always use `last_heard \|\| last_seen` |
|
||||
| CSS selectors don't match SVG | 2 | Manipulate SVG in JS after generation |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+47
-10
@@ -36,8 +36,9 @@ type Store struct {
|
||||
stmtUpsertNode *sql.Stmt
|
||||
stmtIncrementAdvertCount *sql.Stmt
|
||||
stmtUpsertObserver *sql.Stmt
|
||||
stmtGetObserverRowid *sql.Stmt
|
||||
stmtUpdateNodeTelemetry *sql.Stmt
|
||||
stmtGetObserverRowid *sql.Stmt
|
||||
stmtUpdateObserverLastSeen *sql.Stmt
|
||||
stmtUpdateNodeTelemetry *sql.Stmt
|
||||
}
|
||||
|
||||
// OpenStore opens or creates a SQLite DB at the given path, applying the
|
||||
@@ -280,6 +281,17 @@ func applySchema(db *sql.DB) error {
|
||||
log.Println("[migration] node telemetry columns added")
|
||||
}
|
||||
|
||||
// One-time migration: add timestamp index on observations for fast stats queries.
|
||||
// Older databases created before this index was added suffer from full table scans
|
||||
// on COUNT(*) WHERE timestamp > ?, causing /api/stats to take 30s+.
|
||||
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'obs_timestamp_index_v1'")
|
||||
if row.Scan(&migDone) != nil {
|
||||
log.Println("[migration] Adding timestamp index on observations...")
|
||||
db.Exec(`CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp)`)
|
||||
db.Exec(`INSERT INTO _migrations (name) VALUES ('obs_timestamp_index_v1')`)
|
||||
log.Println("[migration] observations timestamp index created")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -358,6 +370,11 @@ func (s *Store) prepareStatements() error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.stmtUpdateObserverLastSeen, err = s.db.Prepare("UPDATE observers SET last_seen = ? WHERE rowid = ?")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.stmtUpdateNodeTelemetry, err = s.db.Prepare(`
|
||||
UPDATE nodes SET
|
||||
battery_mv = COALESCE(?, battery_mv),
|
||||
@@ -417,13 +434,16 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
s.Stats.DuplicateTransmissions.Add(1)
|
||||
}
|
||||
|
||||
// Resolve observer_idx
|
||||
// Resolve observer_idx and update last_seen
|
||||
var observerIdx *int64
|
||||
if data.ObserverID != "" {
|
||||
var rowid int64
|
||||
err := s.stmtGetObserverRowid.QueryRow(data.ObserverID).Scan(&rowid)
|
||||
if err == nil {
|
||||
observerIdx = &rowid
|
||||
// Update observer last_seen on every packet to prevent
|
||||
// low-traffic observers from appearing offline (#463)
|
||||
_, _ = s.stmtUpdateObserverLastSeen.Exec(now, rowid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,8 +454,8 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
}
|
||||
|
||||
_, err = s.stmtInsertObservation.Exec(
|
||||
txID, observerIdx, nil, // direction
|
||||
data.SNR, data.RSSI, nil, // score
|
||||
txID, observerIdx, data.Direction,
|
||||
data.SNR, data.RSSI, data.Score,
|
||||
data.PathJSON, epochTs,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -542,11 +562,22 @@ func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error
|
||||
return err
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
// Close checkpoints the WAL and closes the database.
|
||||
func (s *Store) Close() error {
|
||||
s.Checkpoint()
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// Checkpoint forces a WAL checkpoint to release the WAL lock file,
|
||||
// preventing lock contention with a new process starting up.
|
||||
func (s *Store) Checkpoint() {
|
||||
if _, err := s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
log.Printf("[db] WAL checkpoint error: %v", err)
|
||||
} else {
|
||||
log.Println("[db] WAL checkpoint complete")
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
@@ -595,6 +626,8 @@ type PacketData struct {
|
||||
ObserverName string
|
||||
SNR *float64
|
||||
RSSI *float64
|
||||
Score *float64
|
||||
Direction *string
|
||||
Hash string
|
||||
RouteType int
|
||||
PayloadType int
|
||||
@@ -605,10 +638,12 @@ type PacketData struct {
|
||||
|
||||
// MQTTPacketMessage is the JSON payload from an MQTT raw packet message.
|
||||
type MQTTPacketMessage struct {
|
||||
Raw string `json:"raw"`
|
||||
SNR *float64 `json:"SNR"`
|
||||
RSSI *float64 `json:"RSSI"`
|
||||
Origin string `json:"origin"`
|
||||
Raw string `json:"raw"`
|
||||
SNR *float64 `json:"SNR"`
|
||||
RSSI *float64 `json:"RSSI"`
|
||||
Score *float64 `json:"score"`
|
||||
Direction *string `json:"direction"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
||||
// BuildPacketData constructs a PacketData from a decoded packet and MQTT message.
|
||||
@@ -627,6 +662,8 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
|
||||
ObserverName: msg.Origin,
|
||||
SNR: msg.SNR,
|
||||
RSSI: msg.RSSI,
|
||||
Score: msg.Score,
|
||||
Direction: msg.Direction,
|
||||
Hash: ComputeContentHash(msg.Raw),
|
||||
RouteType: decoded.Header.RouteType,
|
||||
PayloadType: decoded.Header.PayloadType,
|
||||
|
||||
@@ -516,6 +516,56 @@ func TestInsertTransmissionWithObserver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// #463: Verify that inserting a packet updates the observer's last_seen,
|
||||
// so low-traffic observers don't incorrectly appear offline.
|
||||
func TestInsertTransmissionUpdatesObserverLastSeen(t *testing.T) {
|
||||
s, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
// Insert observer with an old last_seen
|
||||
if err := s.UpsertObserver("obs1", "Observer1", "SJC", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Backdate last_seen to 2 hours ago
|
||||
oldTime := "2026-03-24T22:00:00Z"
|
||||
s.db.Exec("UPDATE observers SET last_seen = ? WHERE id = ?", oldTime, "obs1")
|
||||
|
||||
// Verify it was backdated
|
||||
var lastSeenBefore string
|
||||
s.db.QueryRow("SELECT last_seen FROM observers WHERE id = ?", "obs1").Scan(&lastSeenBefore)
|
||||
if lastSeenBefore != oldTime {
|
||||
t.Fatalf("expected last_seen=%s, got %s", oldTime, lastSeenBefore)
|
||||
}
|
||||
|
||||
// Insert a packet from this observer
|
||||
data := &PacketData{
|
||||
RawHex: "0A00D69F",
|
||||
Timestamp: "2026-03-25T01:00:00Z",
|
||||
ObserverID: "obs1",
|
||||
Hash: "lastseentest123456",
|
||||
RouteType: 2,
|
||||
PayloadType: 2,
|
||||
PathJSON: "[]",
|
||||
DecodedJSON: `{"type":"TXT_MSG"}`,
|
||||
}
|
||||
if _, err := s.InsertTransmission(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify last_seen was updated
|
||||
var lastSeenAfter string
|
||||
s.db.QueryRow("SELECT last_seen FROM observers WHERE id = ?", "obs1").Scan(&lastSeenAfter)
|
||||
if lastSeenAfter == oldTime {
|
||||
t.Error("observer last_seen was NOT updated after packet insertion — low-traffic observers will appear offline")
|
||||
}
|
||||
if lastSeenAfter != "2026-03-25T01:00:00Z" {
|
||||
t.Errorf("expected last_seen=2026-03-25T01:00:00Z, got %s", lastSeenAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndToEndIngest(t *testing.T) {
|
||||
s, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
@@ -1457,3 +1507,199 @@ func TestExtractObserverMetaNestedNilSkipsTopLevel(t *testing.T) {
|
||||
t.Error("nested nil should suppress top-level fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestObsTimestampIndexMigration(t *testing.T) {
|
||||
// Case 1: new DB — OpenStore should create idx_observations_timestamp as part
|
||||
// of the observations table schema.
|
||||
t.Run("NewDB", func(t *testing.T) {
|
||||
s, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
var count int
|
||||
err = s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_observations_timestamp'",
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Error("idx_observations_timestamp should exist on a new DB")
|
||||
}
|
||||
|
||||
var migCount int
|
||||
err = s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM _migrations WHERE name='obs_timestamp_index_v1'",
|
||||
).Scan(&migCount)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// On a new DB the index is created inline (not via migration), so the
|
||||
// migration row may or may not be recorded — just verify the index exists.
|
||||
_ = migCount
|
||||
})
|
||||
|
||||
// Case 2: existing DB that has the observations table but lacks the index
|
||||
// and lacks the _migrations entry — simulates an older installation.
|
||||
t.Run("MigrationPath", func(t *testing.T) {
|
||||
path := tempDBPath(t)
|
||||
|
||||
// Build a bare-bones DB that mimics an old installation:
|
||||
// observations table exists but idx_observations_timestamp does NOT.
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY);
|
||||
CREATE TABLE IF NOT EXISTS 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'))
|
||||
);
|
||||
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
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Confirm the index is absent before OpenStore runs.
|
||||
var preCount int
|
||||
db.QueryRow(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_observations_timestamp'",
|
||||
).Scan(&preCount)
|
||||
db.Close()
|
||||
if preCount != 0 {
|
||||
t.Fatalf("pre-condition failed: idx_observations_timestamp should not exist yet, got count=%d", preCount)
|
||||
}
|
||||
|
||||
// Now open via OpenStore — the migration should add the index.
|
||||
s, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
var idxCount int
|
||||
err = s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_observations_timestamp'",
|
||||
).Scan(&idxCount)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idxCount != 1 {
|
||||
t.Error("idx_observations_timestamp should exist after migration on old DB")
|
||||
}
|
||||
|
||||
var migCount int
|
||||
err = s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM _migrations WHERE name='obs_timestamp_index_v1'",
|
||||
).Scan(&migCount)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if migCount != 1 {
|
||||
t.Errorf("migration obs_timestamp_index_v1 should be recorded, got count=%d", migCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPacketDataScoreAndDirection(t *testing.T) {
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
decoded, err := DecodePacket(rawHex, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
score := 42.0
|
||||
dir := "incoming"
|
||||
msg := &MQTTPacketMessage{
|
||||
Raw: rawHex,
|
||||
Score: &score,
|
||||
Direction: &dir,
|
||||
}
|
||||
|
||||
pkt := BuildPacketData(msg, decoded, "obs1", "SJC")
|
||||
if pkt.Score == nil || *pkt.Score != 42.0 {
|
||||
t.Errorf("Score=%v, want 42.0", pkt.Score)
|
||||
}
|
||||
if pkt.Direction == nil || *pkt.Direction != "incoming" {
|
||||
t.Errorf("Direction=%v, want incoming", pkt.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPacketDataNilScoreDirection(t *testing.T) {
|
||||
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil)
|
||||
msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)}
|
||||
pkt := BuildPacketData(msg, decoded, "", "")
|
||||
|
||||
if pkt.Score != nil {
|
||||
t.Errorf("Score should be nil, got %v", *pkt.Score)
|
||||
}
|
||||
if pkt.Direction != nil {
|
||||
t.Errorf("Direction should be nil, got %v", *pkt.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertTransmissionWithScoreAndDirection(t *testing.T) {
|
||||
s, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
score := 7.5
|
||||
dir := "outgoing"
|
||||
data := &PacketData{
|
||||
RawHex: "AABB",
|
||||
Timestamp: "2025-01-01T00:00:00Z",
|
||||
SNR: ptrFloat(5.0),
|
||||
RSSI: ptrFloat(-90.0),
|
||||
Score: &score,
|
||||
Direction: &dir,
|
||||
Hash: "abc123",
|
||||
PathJSON: "[]",
|
||||
}
|
||||
|
||||
isNew, err := s.InsertTransmission(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !isNew {
|
||||
t.Error("expected new transmission")
|
||||
}
|
||||
|
||||
// Verify the observation was stored with score and direction
|
||||
var gotDir sql.NullString
|
||||
var gotScore sql.NullFloat64
|
||||
err = s.db.QueryRow("SELECT direction, score FROM observations LIMIT 1").Scan(&gotDir, &gotScore)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !gotDir.Valid || gotDir.String != "outgoing" {
|
||||
t.Errorf("direction=%v, want outgoing", gotDir)
|
||||
}
|
||||
if !gotScore.Valid || gotScore.Float64 != 7.5 {
|
||||
t.Errorf("score=%v, want 7.5", gotScore)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrFloat(f float64) *float64 { return &f }
|
||||
|
||||
+76
-3
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -165,7 +166,7 @@ func main() {
|
||||
statsTicker.Stop()
|
||||
store.LogStats() // final stats on shutdown
|
||||
for _, c := range clients {
|
||||
c.Disconnect(1000)
|
||||
c.Disconnect(5000) // 5s to allow in-flight messages to drain
|
||||
}
|
||||
log.Println("Done.")
|
||||
}
|
||||
@@ -255,6 +256,20 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
mqttMsg.RSSI = &f
|
||||
}
|
||||
}
|
||||
if v, ok := msg["score"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
mqttMsg.Score = &f
|
||||
}
|
||||
} else if v, ok := msg["Score"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
mqttMsg.Score = &f
|
||||
}
|
||||
}
|
||||
if v, ok := msg["direction"].(string); ok {
|
||||
mqttMsg.Direction = &v
|
||||
} else if v, ok := msg["Direction"].(string); ok {
|
||||
mqttMsg.Direction = &v
|
||||
}
|
||||
if v, ok := msg["origin"].(string); ok {
|
||||
mqttMsg.Origin = v
|
||||
}
|
||||
@@ -351,7 +366,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
h := sha256.Sum256([]byte(hashInput))
|
||||
hash := hex.EncodeToString(h[:])[:16]
|
||||
|
||||
var snr, rssi *float64
|
||||
var snr, rssi, score *float64
|
||||
var direction *string
|
||||
if v, ok := msg["SNR"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
snr = &f
|
||||
@@ -370,6 +386,20 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
rssi = &f
|
||||
}
|
||||
}
|
||||
if v, ok := msg["score"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
score = &f
|
||||
}
|
||||
} else if v, ok := msg["Score"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
score = &f
|
||||
}
|
||||
}
|
||||
if v, ok := msg["direction"].(string); ok {
|
||||
direction = &v
|
||||
} else if v, ok := msg["Direction"].(string); ok {
|
||||
direction = &v
|
||||
}
|
||||
|
||||
pktData := &PacketData{
|
||||
Timestamp: now,
|
||||
@@ -377,6 +407,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
ObserverName: "L1 Pro (BLE)",
|
||||
SNR: snr,
|
||||
RSSI: rssi,
|
||||
Score: score,
|
||||
Direction: direction,
|
||||
Hash: hash,
|
||||
RouteType: 1, // FLOOD
|
||||
PayloadType: 5, // GRP_TXT
|
||||
@@ -428,7 +460,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
h := sha256.Sum256([]byte(hashInput))
|
||||
hash := hex.EncodeToString(h[:])[:16]
|
||||
|
||||
var snr, rssi *float64
|
||||
var snr, rssi, score *float64
|
||||
var direction *string
|
||||
if v, ok := msg["SNR"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
snr = &f
|
||||
@@ -447,6 +480,20 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
rssi = &f
|
||||
}
|
||||
}
|
||||
if v, ok := msg["score"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
score = &f
|
||||
}
|
||||
} else if v, ok := msg["Score"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
score = &f
|
||||
}
|
||||
}
|
||||
if v, ok := msg["direction"].(string); ok {
|
||||
direction = &v
|
||||
} else if v, ok := msg["Direction"].(string); ok {
|
||||
direction = &v
|
||||
}
|
||||
|
||||
pktData := &PacketData{
|
||||
Timestamp: now,
|
||||
@@ -454,6 +501,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
ObserverName: "L1 Pro (BLE)",
|
||||
SNR: snr,
|
||||
RSSI: rssi,
|
||||
Score: score,
|
||||
Direction: direction,
|
||||
Hash: hash,
|
||||
RouteType: 1, // FLOOD
|
||||
PayloadType: 2, // TXT_MSG
|
||||
@@ -483,11 +532,35 @@ func toFloat64(v interface{}) (float64, bool) {
|
||||
case json.Number:
|
||||
f, err := n.Float64()
|
||||
return f, err == nil
|
||||
case string:
|
||||
s := strings.TrimSpace(n)
|
||||
s = stripUnitSuffix(s)
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return f, err == nil
|
||||
case uint:
|
||||
return float64(n), true
|
||||
case uint64:
|
||||
return float64(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// unitSuffixes lists common RF/signal unit suffixes to strip before parsing.
|
||||
var unitSuffixes = []string{"dBm", "dB", "mW", "km", "mi", "m"}
|
||||
|
||||
// stripUnitSuffix removes a trailing unit suffix (case-insensitive) from a
|
||||
// numeric string so that values like "-110dBm" can be parsed as float64.
|
||||
func stripUnitSuffix(s string) string {
|
||||
lower := strings.ToLower(s)
|
||||
for _, suffix := range unitSuffixes {
|
||||
if strings.HasSuffix(lower, strings.ToLower(suffix)) {
|
||||
return strings.TrimSpace(s[:len(s)-len(suffix)])
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// extractObserverMeta extracts hardware metadata from an MQTT status message.
|
||||
// Casts battery_mv and uptime_secs to integers (they're always whole numbers).
|
||||
func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
|
||||
|
||||
@@ -22,7 +22,13 @@ func TestToFloat64(t *testing.T) {
|
||||
{"int64", int64(100), 100.0, true},
|
||||
{"json.Number valid", json.Number("9.5"), 9.5, true},
|
||||
{"json.Number invalid", json.Number("not_a_number"), 0, false},
|
||||
{"string unsupported", "hello", 0, false},
|
||||
{"string valid", "3.14", 3.14, true},
|
||||
{"string with spaces", " -7.5 ", -7.5, true},
|
||||
{"string integer", "42", 42.0, true},
|
||||
{"string invalid", "hello", 0, false},
|
||||
{"string empty", "", 0, false},
|
||||
{"uint", uint(10), 10.0, true},
|
||||
{"uint64", uint64(999), 999.0, true},
|
||||
{"bool unsupported", true, 0, false},
|
||||
{"nil unsupported", nil, 0, false},
|
||||
{"slice unsupported", []int{1}, 0, false},
|
||||
@@ -686,3 +692,50 @@ func TestHandleMessageNoSNRRSSI(t *testing.T) {
|
||||
t.Errorf("rssi should be nil when not present, got %v", *rssi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUnitSuffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"-110dBm", "-110"},
|
||||
{"-110DBM", "-110"},
|
||||
{"5.5dB", "5.5"},
|
||||
{"100mW", "100"},
|
||||
{"1.5km", "1.5"},
|
||||
{"500m", "500"},
|
||||
{"10mi", "10"},
|
||||
{"42", "42"},
|
||||
{"", ""},
|
||||
{"hello", "hello"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := stripUnitSuffix(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripUnitSuffix(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFloat64WithUnits(t *testing.T) {
|
||||
tests := []struct {
|
||||
input interface{}
|
||||
want float64
|
||||
ok bool
|
||||
}{
|
||||
{"-110dBm", -110.0, true},
|
||||
{"5.5dB", 5.5, true},
|
||||
{"100mW", 100.0, true},
|
||||
{"-85.3dBm", -85.3, true},
|
||||
{"42", 42.0, true},
|
||||
{"not_a_number", 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, ok := toFloat64(tt.input)
|
||||
if ok != tt.ok {
|
||||
t.Errorf("toFloat64(%v) ok=%v, want %v", tt.input, ok, tt.ok)
|
||||
}
|
||||
if ok && got != tt.want {
|
||||
t.Errorf("toFloat64(%v) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestStore creates a minimal PacketStore for cache invalidation testing.
|
||||
func newTestStore(t *testing.T) *PacketStore {
|
||||
t.Helper()
|
||||
return &PacketStore{
|
||||
rfCache: make(map[string]*cachedResult),
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// populateAllCaches fills every analytics cache with a dummy entry so tests
|
||||
// can verify which caches are cleared and which are preserved.
|
||||
func populateAllCaches(s *PacketStore) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
dummy := &cachedResult{data: map[string]interface{}{"test": true}, expiresAt: time.Now().Add(time.Hour)}
|
||||
s.rfCache["global"] = dummy
|
||||
s.topoCache["global"] = dummy
|
||||
s.hashCache["global"] = dummy
|
||||
s.chanCache["global"] = dummy
|
||||
s.distCache["global"] = dummy
|
||||
s.subpathCache["global"] = dummy
|
||||
}
|
||||
|
||||
// cachePopulated returns which caches still have their "global" entry.
|
||||
func cachePopulated(s *PacketStore) map[string]bool {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
return map[string]bool{
|
||||
"rf": len(s.rfCache) > 0,
|
||||
"topo": len(s.topoCache) > 0,
|
||||
"hash": len(s.hashCache) > 0,
|
||||
"chan": len(s.chanCache) > 0,
|
||||
"dist": len(s.distCache) > 0,
|
||||
"subpath": len(s.subpathCache) > 0,
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateCachesFor_Eviction(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
populateAllCaches(s)
|
||||
|
||||
s.invalidateCachesFor(cacheInvalidation{eviction: true})
|
||||
|
||||
pop := cachePopulated(s)
|
||||
for name, has := range pop {
|
||||
if has {
|
||||
t.Errorf("eviction should clear %s cache", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateCachesFor_NewObservationsOnly(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
populateAllCaches(s)
|
||||
|
||||
s.invalidateCachesFor(cacheInvalidation{hasNewObservations: true})
|
||||
|
||||
pop := cachePopulated(s)
|
||||
if pop["rf"] {
|
||||
t.Error("rf cache should be cleared on new observations")
|
||||
}
|
||||
// These should be preserved
|
||||
for _, name := range []string{"topo", "hash", "chan", "dist", "subpath"} {
|
||||
if !pop[name] {
|
||||
t.Errorf("%s cache should NOT be cleared on observation-only ingest", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateCachesFor_NewTransmissionsOnly(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
populateAllCaches(s)
|
||||
|
||||
s.invalidateCachesFor(cacheInvalidation{hasNewTransmissions: true})
|
||||
|
||||
pop := cachePopulated(s)
|
||||
if pop["hash"] {
|
||||
t.Error("hash cache should be cleared on new transmissions")
|
||||
}
|
||||
for _, name := range []string{"rf", "topo", "chan", "dist", "subpath"} {
|
||||
if !pop[name] {
|
||||
t.Errorf("%s cache should NOT be cleared on transmission-only ingest", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateCachesFor_ChannelDataOnly(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
populateAllCaches(s)
|
||||
|
||||
s.invalidateCachesFor(cacheInvalidation{hasChannelData: true})
|
||||
|
||||
pop := cachePopulated(s)
|
||||
if pop["chan"] {
|
||||
t.Error("chan cache should be cleared on channel data")
|
||||
}
|
||||
for _, name := range []string{"rf", "topo", "hash", "dist", "subpath"} {
|
||||
if !pop[name] {
|
||||
t.Errorf("%s cache should NOT be cleared on channel-data-only ingest", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateCachesFor_NewPaths(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
populateAllCaches(s)
|
||||
|
||||
s.invalidateCachesFor(cacheInvalidation{hasNewPaths: true})
|
||||
|
||||
pop := cachePopulated(s)
|
||||
for _, name := range []string{"topo", "dist", "subpath"} {
|
||||
if pop[name] {
|
||||
t.Errorf("%s cache should be cleared on new paths", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"rf", "hash", "chan"} {
|
||||
if !pop[name] {
|
||||
t.Errorf("%s cache should NOT be cleared on path-only ingest", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateCachesFor_CombinedFlags(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
populateAllCaches(s)
|
||||
|
||||
// Simulate a typical ingest: new transmissions with observations but no GRP_TXT
|
||||
s.invalidateCachesFor(cacheInvalidation{
|
||||
hasNewObservations: true,
|
||||
hasNewTransmissions: true,
|
||||
hasNewPaths: true,
|
||||
})
|
||||
|
||||
pop := cachePopulated(s)
|
||||
// rf, topo, hash, dist, subpath should all be cleared
|
||||
for _, name := range []string{"rf", "topo", "hash", "dist", "subpath"} {
|
||||
if pop[name] {
|
||||
t.Errorf("%s cache should be cleared with combined flags", name)
|
||||
}
|
||||
}
|
||||
// chan should be preserved (no GRP_TXT)
|
||||
if !pop["chan"] {
|
||||
t.Error("chan cache should NOT be cleared without hasChannelData flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateCachesFor_NoFlags(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
populateAllCaches(s)
|
||||
|
||||
s.invalidateCachesFor(cacheInvalidation{})
|
||||
|
||||
pop := cachePopulated(s)
|
||||
for name, has := range pop {
|
||||
if !has {
|
||||
t.Errorf("%s cache should be preserved when no flags are set", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3715,3 +3715,99 @@ func TestGetChannelMessagesAfterIngest(t *testing.T) {
|
||||
t.Errorf("newest message should be 'brand new message', got %q", lastMsg["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexByNodePreCheck(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
}
|
||||
|
||||
t.Run("indexes ADVERT with pubKey", func(t *testing.T) {
|
||||
tx := &StoreTx{Hash: "h1", DecodedJSON: `{"pubKey":"AABBCC","type":"ADVERT"}`}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode["AABBCC"]) != 1 {
|
||||
t.Errorf("expected 1 entry for pubKey AABBCC, got %d", len(store.byNode["AABBCC"]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("indexes destPubKey", func(t *testing.T) {
|
||||
tx := &StoreTx{Hash: "h2", DecodedJSON: `{"destPubKey":"DDEEFF","type":"MSG"}`}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode["DDEEFF"]) != 1 {
|
||||
t.Errorf("expected 1 entry for destPubKey DDEEFF, got %d", len(store.byNode["DDEEFF"]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("indexes srcPubKey", func(t *testing.T) {
|
||||
tx := &StoreTx{Hash: "h2b", DecodedJSON: `{"srcPubKey":"112233","type":"TXT_MSG"}`}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode["112233"]) != 1 {
|
||||
t.Errorf("expected 1 entry for srcPubKey 112233, got %d", len(store.byNode["112233"]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips channel message without pubKey", func(t *testing.T) {
|
||||
beforeLen := len(store.byNode)
|
||||
tx := &StoreTx{Hash: "h3", DecodedJSON: `{"type":"CHAN","channel":"#test","text":"hello"}`}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode) != beforeLen {
|
||||
t.Errorf("expected byNode unchanged for channel packet, got %d new entries", len(store.byNode)-beforeLen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips empty DecodedJSON", func(t *testing.T) {
|
||||
beforeLen := len(store.byNode)
|
||||
tx := &StoreTx{Hash: "h4", DecodedJSON: ""}
|
||||
store.indexByNode(tx)
|
||||
if len(store.byNode) != beforeLen {
|
||||
t.Error("expected byNode unchanged for empty DecodedJSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deduplicates same hash", func(t *testing.T) {
|
||||
tx := &StoreTx{Hash: "h1", DecodedJSON: `{"pubKey":"AABBCC","type":"ADVERT"}`}
|
||||
store.indexByNode(tx) // second call for same hash
|
||||
if len(store.byNode["AABBCC"]) != 1 {
|
||||
t.Errorf("expected dedup to keep 1 entry, got %d", len(store.byNode["AABBCC"]))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkIndexByNode measures indexByNode performance with and without pubkey
|
||||
// fields to demonstrate the strings.Contains pre-check optimization.
|
||||
func BenchmarkIndexByNode(b *testing.B) {
|
||||
// Payload WITHOUT any pubkey fields — should be skipped via pre-check
|
||||
noPubkey := `{"type":1,"msgId":42,"sender":"node1","data":"hello world"}`
|
||||
// Payload WITH a pubkey field — requires JSON parse
|
||||
withPubkey := `{"type":1,"msgId":42,"pubKey":"AABB","sender":"node1","data":"hello world"}`
|
||||
|
||||
b.Run("no_pubkey_skip", func(b *testing.B) {
|
||||
store := &PacketStore{
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tx := &StoreTx{
|
||||
Hash: fmt.Sprintf("hash-%d", i),
|
||||
DecodedJSON: noPubkey,
|
||||
}
|
||||
store.indexByNode(tx)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("with_pubkey_parse", func(b *testing.B) {
|
||||
store := &PacketStore{
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tx := &StoreTx{
|
||||
Hash: fmt.Sprintf("hash-%d", i),
|
||||
DecodedJSON: withPubkey,
|
||||
}
|
||||
store.indexByNode(tx)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -38,6 +39,12 @@ func OpenDB(path string) (*DB, error) {
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
// Checkpoint WAL before closing to release lock cleanly for new processes
|
||||
if _, err := db.conn.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
log.Printf("[db] WAL checkpoint error: %v", err)
|
||||
} else {
|
||||
log.Println("[db] WAL checkpoint complete")
|
||||
}
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
@@ -691,6 +698,32 @@ func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortB
|
||||
}
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
codes := normalizeRegionCodes(region)
|
||||
if len(codes) > 0 {
|
||||
placeholders := make([]string, len(codes))
|
||||
regionArgs := make([]interface{}, len(codes))
|
||||
for i, c := range codes {
|
||||
placeholders[i] = "?"
|
||||
regionArgs[i] = c
|
||||
}
|
||||
joinCond := "obs.rowid = o.observer_idx"
|
||||
if !db.isV3 {
|
||||
joinCond = "obs.id = o.observer_id"
|
||||
}
|
||||
subq := fmt.Sprintf(`public_key IN (
|
||||
SELECT DISTINCT JSON_EXTRACT(t.decoded_json, '$.pubKey')
|
||||
FROM transmissions t
|
||||
JOIN observations o ON o.transmission_id = t.id
|
||||
JOIN observers obs ON %s
|
||||
WHERE t.payload_type = 4
|
||||
AND UPPER(TRIM(obs.iata)) IN (%s)
|
||||
)`, joinCond, strings.Join(placeholders, ","))
|
||||
where = append(where, subq)
|
||||
args = append(args, regionArgs...)
|
||||
}
|
||||
}
|
||||
|
||||
w := ""
|
||||
if len(where) > 0 {
|
||||
w = "WHERE " + strings.Join(where, " AND ")
|
||||
|
||||
@@ -1012,6 +1012,168 @@ func TestGetNodesFiltering(t *testing.T) {
|
||||
t.Errorf("expected 1 node with offset, got %d", len(nodes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("region filter SJC", func(t *testing.T) {
|
||||
nodes, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SJC")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected 1 node for SJC region, got %d", total)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0]["public_key"] != "aabbccdd11223344" {
|
||||
t.Errorf("expected TestRepeater, got %v", nodes[0]["public_key"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("region filter SFO", func(t *testing.T) {
|
||||
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SFO")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected 1 node for SFO region, got %d", total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("region filter multi", func(t *testing.T) {
|
||||
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SJC,SFO")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected 1 node for SJC,SFO region, got %d", total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("region filter unknown", func(t *testing.T) {
|
||||
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "AMS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Errorf("expected 0 nodes for unknown region, got %d", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// setupTestDBV2 creates an in-memory SQLite database with the v2 schema
|
||||
// where observations use observer_id TEXT instead of observer_idx INTEGER.
|
||||
func setupTestDBV2(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.SetMaxOpenConns(1)
|
||||
|
||||
schema := `
|
||||
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 observers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
iata TEXT,
|
||||
last_seen TEXT,
|
||||
first_seen TEXT,
|
||||
packet_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
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'))
|
||||
);
|
||||
|
||||
CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_id TEXT,
|
||||
observer_name TEXT,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &DB{conn: conn, isV3: false}
|
||||
}
|
||||
|
||||
func TestGetNodesRegionFilterV2(t *testing.T) {
|
||||
db := setupTestDBV2(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := now.Add(-1 * time.Hour).Unix()
|
||||
|
||||
// Seed observer with IATA code
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obs-v2-1', 'V2 Observer', 'LAX', ?, '2026-01-01T00:00:00Z', 10)`, recent)
|
||||
|
||||
// Seed a node
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES ('v2pubkey11223344', 'V2Node', 'repeater', 34.0, -118.0, ?, '2026-01-01T00:00:00Z', 5)`, recent)
|
||||
|
||||
// Seed an ADVERT transmission for the node
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('AABB', 'v2hash0001', ?, 1, 4, '{"pubKey":"v2pubkey11223344","name":"V2Node","type":"ADVERT"}')`, recent)
|
||||
|
||||
// Seed v2-style observation: observer_id references observers.id directly
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_id, observer_name, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 'obs-v2-1', 'V2 Observer', 10.0, -90, '[]', ?)`, recentEpoch)
|
||||
|
||||
t.Run("v2 region filter match", func(t *testing.T) {
|
||||
nodes, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "LAX")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("expected 1 node for LAX region (v2 schema), got %d", total)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0]["public_key"] != "v2pubkey11223344" {
|
||||
t.Errorf("expected V2Node, got %v", nodes[0]["public_key"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("v2 region filter no match", func(t *testing.T) {
|
||||
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "JFK")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Errorf("expected 0 nodes for JFK region (v2 schema), got %d", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetChannelMessagesDedup(t *testing.T) {
|
||||
|
||||
@@ -397,6 +397,106 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HexRange represents a labeled byte range for the hex breakdown visualization.
|
||||
type HexRange struct {
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// Breakdown holds colored byte ranges returned by the packet detail endpoint.
|
||||
type Breakdown struct {
|
||||
Ranges []HexRange `json:"ranges"`
|
||||
}
|
||||
|
||||
// BuildBreakdown computes labeled byte ranges for each section of a MeshCore packet.
|
||||
// The returned ranges are consumed by createColoredHexDump() and buildHexLegend()
|
||||
// in the frontend (public/app.js).
|
||||
func BuildBreakdown(hexString string) *Breakdown {
|
||||
hexString = strings.ReplaceAll(hexString, " ", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\n", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\r", "")
|
||||
buf, err := hex.DecodeString(hexString)
|
||||
if err != nil || len(buf) < 2 {
|
||||
return &Breakdown{Ranges: []HexRange{}}
|
||||
}
|
||||
|
||||
var ranges []HexRange
|
||||
offset := 0
|
||||
|
||||
// Byte 0: Header
|
||||
ranges = append(ranges, HexRange{Start: 0, End: 0, Label: "Header"})
|
||||
offset = 1
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
|
||||
// Bytes 1-4: Transport Codes (TRANSPORT_FLOOD / TRANSPORT_DIRECT only)
|
||||
if isTransportRoute(header.RouteType) {
|
||||
if len(buf) < offset+4 {
|
||||
return &Breakdown{Ranges: ranges}
|
||||
}
|
||||
ranges = append(ranges, HexRange{Start: offset, End: offset + 3, Label: "Transport Codes"})
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return &Breakdown{Ranges: ranges}
|
||||
}
|
||||
|
||||
// Next byte: Path Length (bits 7-6 = hashSize-1, bits 5-0 = hashCount)
|
||||
ranges = append(ranges, HexRange{Start: offset, End: offset, Label: "Path Length"})
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
pathBytes := hashSize * hashCount
|
||||
|
||||
// Path hops
|
||||
if hashCount > 0 && offset+pathBytes <= len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: offset, End: offset + pathBytes - 1, Label: "Path"})
|
||||
}
|
||||
offset += pathBytes
|
||||
|
||||
if offset >= len(buf) {
|
||||
return &Breakdown{Ranges: ranges}
|
||||
}
|
||||
|
||||
payloadStart := offset
|
||||
|
||||
// Payload — break ADVERT into named sub-fields; everything else is one Payload range
|
||||
if header.PayloadType == PayloadADVERT && len(buf)-payloadStart >= 100 {
|
||||
ranges = append(ranges, HexRange{Start: payloadStart, End: payloadStart + 31, Label: "PubKey"})
|
||||
ranges = append(ranges, HexRange{Start: payloadStart + 32, End: payloadStart + 35, Label: "Timestamp"})
|
||||
ranges = append(ranges, HexRange{Start: payloadStart + 36, End: payloadStart + 99, Label: "Signature"})
|
||||
|
||||
appStart := payloadStart + 100
|
||||
if appStart < len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: appStart, End: appStart, Label: "Flags"})
|
||||
appFlags := buf[appStart]
|
||||
fOff := appStart + 1
|
||||
if appFlags&0x10 != 0 && fOff+8 <= len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 3, Label: "Latitude"})
|
||||
ranges = append(ranges, HexRange{Start: fOff + 4, End: fOff + 7, Label: "Longitude"})
|
||||
fOff += 8
|
||||
}
|
||||
if appFlags&0x20 != 0 && fOff+2 <= len(buf) {
|
||||
fOff += 2
|
||||
}
|
||||
if appFlags&0x40 != 0 && fOff+2 <= len(buf) {
|
||||
fOff += 2
|
||||
}
|
||||
if appFlags&0x80 != 0 && fOff < len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "Name"})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ranges = append(ranges, HexRange{Start: payloadStart, End: len(buf) - 1, Label: "Payload"})
|
||||
}
|
||||
|
||||
return &Breakdown{Ranges: ranges}
|
||||
}
|
||||
|
||||
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
|
||||
func ComputeContentHash(rawHex string) string {
|
||||
buf, err := hex.DecodeString(rawHex)
|
||||
|
||||
@@ -93,3 +93,152 @@ func TestDecodePacket_FloodHasNoCodes(t *testing.T) {
|
||||
t.Error("expected no transport codes for FLOOD route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_InvalidHex(t *testing.T) {
|
||||
b := BuildBreakdown("not-hex!")
|
||||
if len(b.Ranges) != 0 {
|
||||
t.Errorf("expected empty ranges for invalid hex, got %d", len(b.Ranges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_TooShort(t *testing.T) {
|
||||
b := BuildBreakdown("11") // 1 byte — no path byte
|
||||
if len(b.Ranges) != 0 {
|
||||
t.Errorf("expected empty ranges for too-short packet, got %d", len(b.Ranges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_FloodNonAdvert(t *testing.T) {
|
||||
// Header 0x15: route=1/FLOOD, payload=5/GRP_TXT
|
||||
// PathByte 0x01: 1 hop, 1-byte hash
|
||||
// PathHop: AA
|
||||
// Payload: FF0011
|
||||
b := BuildBreakdown("1501AAFFFF00")
|
||||
labels := rangeLabels(b.Ranges)
|
||||
expect := []string{"Header", "Path Length", "Path", "Payload"}
|
||||
if !equalLabels(labels, expect) {
|
||||
t.Errorf("expected labels %v, got %v", expect, labels)
|
||||
}
|
||||
// Verify byte positions
|
||||
assertRange(t, b.Ranges, "Header", 0, 0)
|
||||
assertRange(t, b.Ranges, "Path Length", 1, 1)
|
||||
assertRange(t, b.Ranges, "Path", 2, 2)
|
||||
assertRange(t, b.Ranges, "Payload", 3, 5)
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_TransportFlood(t *testing.T) {
|
||||
// Header 0x14: route=0/TRANSPORT_FLOOD, payload=5/GRP_TXT
|
||||
// TransportCodes: AABBCCDD (4 bytes)
|
||||
// PathByte 0x01: 1 hop, 1-byte hash
|
||||
// PathHop: EE
|
||||
// Payload: FF00
|
||||
b := BuildBreakdown("14AABBCCDD01EEFF00")
|
||||
assertRange(t, b.Ranges, "Header", 0, 0)
|
||||
assertRange(t, b.Ranges, "Transport Codes", 1, 4)
|
||||
assertRange(t, b.Ranges, "Path Length", 5, 5)
|
||||
assertRange(t, b.Ranges, "Path", 6, 6)
|
||||
assertRange(t, b.Ranges, "Payload", 7, 8)
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_FloodNoHops(t *testing.T) {
|
||||
// Header 0x15: FLOOD/GRP_TXT; PathByte 0x00: 0 hops; Payload: AABB
|
||||
b := BuildBreakdown("150000AABB")
|
||||
assertRange(t, b.Ranges, "Header", 0, 0)
|
||||
assertRange(t, b.Ranges, "Path Length", 1, 1)
|
||||
// No Path range since hashCount=0
|
||||
for _, r := range b.Ranges {
|
||||
if r.Label == "Path" {
|
||||
t.Error("expected no Path range for zero-hop packet")
|
||||
}
|
||||
}
|
||||
assertRange(t, b.Ranges, "Payload", 2, 4)
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertBasic(t *testing.T) {
|
||||
// Header 0x11: FLOOD/ADVERT
|
||||
// PathByte 0x01: 1 hop, 1-byte hash
|
||||
// PathHop: AA
|
||||
// Payload: 100 bytes (PubKey32 + Timestamp4 + Signature64) + Flags=0x02 (repeater, no extras)
|
||||
pubkey := repeatHex("AB", 32)
|
||||
ts := "00000000" // 4 bytes
|
||||
sig := repeatHex("CD", 64)
|
||||
flags := "02"
|
||||
hex := "1101AA" + pubkey + ts + sig + flags
|
||||
b := BuildBreakdown(hex)
|
||||
assertRange(t, b.Ranges, "Header", 0, 0)
|
||||
assertRange(t, b.Ranges, "Path Length", 1, 1)
|
||||
assertRange(t, b.Ranges, "Path", 2, 2)
|
||||
assertRange(t, b.Ranges, "PubKey", 3, 34)
|
||||
assertRange(t, b.Ranges, "Timestamp", 35, 38)
|
||||
assertRange(t, b.Ranges, "Signature", 39, 102)
|
||||
assertRange(t, b.Ranges, "Flags", 103, 103)
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertWithLocation(t *testing.T) {
|
||||
// flags=0x12: hasLocation bit set
|
||||
pubkey := repeatHex("00", 32)
|
||||
ts := "00000000"
|
||||
sig := repeatHex("00", 64)
|
||||
flags := "12" // 0x10 = hasLocation
|
||||
latBytes := "00000000"
|
||||
lonBytes := "00000000"
|
||||
hex := "1101AA" + pubkey + ts + sig + flags + latBytes + lonBytes
|
||||
b := BuildBreakdown(hex)
|
||||
assertRange(t, b.Ranges, "Latitude", 104, 107)
|
||||
assertRange(t, b.Ranges, "Longitude", 108, 111)
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertWithName(t *testing.T) {
|
||||
// flags=0x82: hasName bit set
|
||||
pubkey := repeatHex("00", 32)
|
||||
ts := "00000000"
|
||||
sig := repeatHex("00", 64)
|
||||
flags := "82" // 0x80 = hasName
|
||||
name := "4E6F6465" // "Node" in hex
|
||||
hex := "1101AA" + pubkey + ts + sig + flags + name
|
||||
b := BuildBreakdown(hex)
|
||||
assertRange(t, b.Ranges, "Name", 104, 107)
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func rangeLabels(ranges []HexRange) []string {
|
||||
out := make([]string, len(ranges))
|
||||
for i, r := range ranges {
|
||||
out[i] = r.Label
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func equalLabels(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantEnd int) {
|
||||
t.Helper()
|
||||
for _, r := range ranges {
|
||||
if r.Label == label {
|
||||
if r.Start != wantStart || r.End != wantEnd {
|
||||
t.Errorf("range %q: want [%d,%d], got [%d,%d]", label, wantStart, wantEnd, r.Start, r.End)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("range %q not found in %v", label, rangeLabels(ranges))
|
||||
}
|
||||
|
||||
func repeatHex(byteHex string, n int) string {
|
||||
s := ""
|
||||
for i := 0; i < n; i++ {
|
||||
s += byteHex
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -326,6 +327,84 @@ func TestSpaHandler(t *testing.T) {
|
||||
t.Errorf("expected no-cache header for .html, got %s", cc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("root path serves index.html", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if body != "<html>SPA</html>" {
|
||||
t.Errorf("expected SPA index.html content, got %s", body)
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("expected text/html content type, got %s", ct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("/index.html serves pre-processed content", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if body != "<html>SPA</html>" {
|
||||
t.Errorf("expected SPA index.html content, got %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpaHandlerCacheBust(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
htmlWithBust := `<html><script src="app.js?v=__BUST__"></script><link href="style.css?v=__BUST__"></html>`
|
||||
os.WriteFile(filepath.Join(dir, "index.html"), []byte(htmlWithBust), 0644)
|
||||
|
||||
fs := http.FileServer(http.Dir(dir))
|
||||
handler := spaHandler(dir, fs)
|
||||
|
||||
t.Run("__BUST__ is replaced with a Unix timestamp", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "__BUST__") {
|
||||
t.Errorf("__BUST__ placeholder was not replaced in response: %s", body)
|
||||
}
|
||||
// Verify it was replaced with digits (Unix timestamp)
|
||||
if !strings.Contains(body, "v=") {
|
||||
t.Errorf("expected v= query params in response, got: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SPA fallback also has busted values", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/nonexistent/route", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "__BUST__") {
|
||||
t.Errorf("__BUST__ placeholder was not replaced in SPA fallback: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("/index.html also has busted values", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "__BUST__") {
|
||||
t.Errorf("__BUST__ placeholder was not replaced for /index.html: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
@@ -345,3 +424,29 @@ func TestWriteJSON(t *testing.T) {
|
||||
t.Errorf("expected 'value', got %v", body["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHaversineKm(t *testing.T) {
|
||||
// Same point should be 0
|
||||
if d := haversineKm(37.0, -122.0, 37.0, -122.0); d != 0 {
|
||||
t.Errorf("same point: expected 0, got %f", d)
|
||||
}
|
||||
|
||||
// SF to LA ~559km
|
||||
d := haversineKm(37.7749, -122.4194, 34.0522, -118.2437)
|
||||
if d < 550 || d > 570 {
|
||||
t.Errorf("SF to LA: expected ~559km, got %f", d)
|
||||
}
|
||||
|
||||
// Symmetry
|
||||
d1 := haversineKm(37.7749, -122.4194, 34.0522, -118.2437)
|
||||
d2 := haversineKm(34.0522, -118.2437, 37.7749, -122.4194)
|
||||
if d1 != d2 {
|
||||
t.Errorf("not symmetric: %f vs %f", d1, d2)
|
||||
}
|
||||
|
||||
// Oslo to Stockholm ~415km (old Euclidean dLat*111, dLon*85 would give ~627km)
|
||||
d = haversineKm(59.9, 10.7, 59.3, 18.0)
|
||||
if d < 400 || d > 430 {
|
||||
t.Errorf("Oslo to Stockholm: expected ~415km, got %f", d)
|
||||
}
|
||||
}
|
||||
|
||||
+54
-5
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -113,7 +115,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("[db] failed to open %s: %v", resolvedDB, err)
|
||||
}
|
||||
defer database.Close()
|
||||
var dbCloseOnce sync.Once
|
||||
dbClose := func() error {
|
||||
var err error
|
||||
dbCloseOnce.Do(func() { err = database.Close() })
|
||||
return err
|
||||
}
|
||||
defer dbClose()
|
||||
|
||||
// Verify DB has expected tables
|
||||
var tableName string
|
||||
@@ -204,10 +212,27 @@ func main() {
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
log.Println("[server] shutting down...")
|
||||
sig := <-sigCh
|
||||
log.Printf("[server] received %v, shutting down...", sig)
|
||||
|
||||
// 1. Stop accepting new WebSocket/poll data
|
||||
poller.Stop()
|
||||
httpServer.Close()
|
||||
|
||||
// 2. Gracefully drain HTTP connections (up to 15s)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("[server] HTTP shutdown error: %v", err)
|
||||
}
|
||||
|
||||
// 3. Close WebSocket hub
|
||||
hub.Close()
|
||||
|
||||
// 4. Close database (release SQLite WAL lock)
|
||||
if err := dbClose(); err != nil {
|
||||
log.Printf("[server] DB close error: %v", err)
|
||||
}
|
||||
log.Println("[server] shutdown complete")
|
||||
}()
|
||||
|
||||
log.Printf("[server] CoreScope (Go) listening on http://localhost:%d", cfg.Port)
|
||||
@@ -217,11 +242,35 @@ func main() {
|
||||
}
|
||||
|
||||
// spaHandler serves static files, falling back to index.html for SPA routes.
|
||||
// It reads index.html once at creation time and replaces the __BUST__ placeholder
|
||||
// with a Unix timestamp so browsers fetch fresh JS/CSS after each server restart.
|
||||
func spaHandler(root string, fs http.Handler) http.Handler {
|
||||
// Pre-process index.html: replace __BUST__ with a cache-bust timestamp
|
||||
indexPath := filepath.Join(root, "index.html")
|
||||
rawHTML, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
log.Printf("[static] warning: could not read index.html for cache-bust: %v", err)
|
||||
rawHTML = []byte("<!DOCTYPE html><html><body><h1>CoreScope</h1><p>index.html not found</p></body></html>")
|
||||
}
|
||||
bustValue := fmt.Sprintf("%d", time.Now().Unix())
|
||||
indexHTML := []byte(strings.ReplaceAll(string(rawHTML), "__BUST__", bustValue))
|
||||
log.Printf("[static] cache-bust value: %s", bustValue)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve pre-processed index.html for root and /index.html
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Write(indexHTML)
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(root, r.URL.Path)
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
http.ServeFile(w, r, filepath.Join(root, "index.html"))
|
||||
// SPA fallback — serve pre-processed index.html
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Write(indexHTML)
|
||||
return
|
||||
}
|
||||
// Disable caching for JS/CSS/HTML
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ─── Neighbor API response types ───────────────────────────────────────────────
|
||||
|
||||
type NeighborResponse struct {
|
||||
Node string `json:"node"`
|
||||
Neighbors []NeighborEntry `json:"neighbors"`
|
||||
TotalObservations int `json:"total_observations"`
|
||||
}
|
||||
|
||||
type NeighborEntry struct {
|
||||
Pubkey *string `json:"pubkey"`
|
||||
Prefix string `json:"prefix"`
|
||||
Name *string `json:"name"`
|
||||
Role *string `json:"role"`
|
||||
Count int `json:"count"`
|
||||
Score float64 `json:"score"`
|
||||
FirstSeen string `json:"first_seen"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
AvgSNR *float64 `json:"avg_snr"`
|
||||
Observers []string `json:"observers"`
|
||||
Ambiguous bool `json:"ambiguous"`
|
||||
Unresolved bool `json:"unresolved,omitempty"`
|
||||
Candidates []CandidateEntry `json:"candidates,omitempty"`
|
||||
}
|
||||
|
||||
type CandidateEntry struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type NeighborGraphResponse struct {
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Edges []GraphEdge `json:"edges"`
|
||||
Stats GraphStats `json:"stats"`
|
||||
}
|
||||
|
||||
type GraphNode struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
NeighborCount int `json:"neighbor_count"`
|
||||
}
|
||||
|
||||
type GraphEdge struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Weight int `json:"weight"`
|
||||
Score float64 `json:"score"`
|
||||
Bidirectional bool `json:"bidirectional"`
|
||||
AvgSNR *float64 `json:"avg_snr"`
|
||||
Ambiguous bool `json:"ambiguous"`
|
||||
}
|
||||
|
||||
type GraphStats struct {
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalEdges int `json:"total_edges"`
|
||||
AmbiguousEdges int `json:"ambiguous_edges"`
|
||||
AvgClusterSize float64 `json:"avg_cluster_size"`
|
||||
}
|
||||
|
||||
// ─── Graph accessor on Server ──────────────────────────────────────────────────
|
||||
|
||||
// getNeighborGraph returns the current neighbor graph, rebuilding if stale.
|
||||
func (s *Server) getNeighborGraph() *NeighborGraph {
|
||||
s.neighborMu.Lock()
|
||||
defer s.neighborMu.Unlock()
|
||||
|
||||
if s.neighborGraph == nil || s.neighborGraph.IsStale() {
|
||||
if s.store != nil {
|
||||
s.neighborGraph = BuildFromStore(s.store)
|
||||
} else {
|
||||
s.neighborGraph = NewNeighborGraph()
|
||||
}
|
||||
}
|
||||
return s.neighborGraph
|
||||
}
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) handleNodeNeighbors(w http.ResponseWriter, r *http.Request) {
|
||||
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
|
||||
|
||||
minCount := 1
|
||||
if v := r.URL.Query().Get("min_count"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
minCount = n
|
||||
}
|
||||
}
|
||||
minScore := 0.0
|
||||
if v := r.URL.Query().Get("min_score"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
minScore = f
|
||||
}
|
||||
}
|
||||
includeAmbiguous := true
|
||||
if v := r.URL.Query().Get("include_ambiguous"); v == "false" {
|
||||
includeAmbiguous = false
|
||||
}
|
||||
|
||||
graph := s.getNeighborGraph()
|
||||
edges := graph.Neighbors(pubkey)
|
||||
now := time.Now()
|
||||
|
||||
// Build node info lookup for names/roles.
|
||||
nodeMap := s.buildNodeInfoMap()
|
||||
|
||||
var entries []NeighborEntry
|
||||
totalObs := 0
|
||||
|
||||
for _, e := range edges {
|
||||
score := e.Score(now)
|
||||
if e.Count < minCount || score < minScore {
|
||||
continue
|
||||
}
|
||||
if e.Ambiguous && !includeAmbiguous {
|
||||
continue
|
||||
}
|
||||
|
||||
totalObs += e.Count
|
||||
|
||||
// Determine the "other" node (neighbor of the queried pubkey).
|
||||
neighborPK := e.NodeA
|
||||
if strings.EqualFold(neighborPK, pubkey) {
|
||||
neighborPK = e.NodeB
|
||||
}
|
||||
|
||||
entry := NeighborEntry{
|
||||
Prefix: e.Prefix,
|
||||
Count: e.Count,
|
||||
Score: score,
|
||||
FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339),
|
||||
LastSeen: e.LastSeen.UTC().Format(time.RFC3339),
|
||||
Ambiguous: e.Ambiguous,
|
||||
Observers: observerList(e.Observers),
|
||||
}
|
||||
|
||||
if e.SNRCount > 0 {
|
||||
avg := e.AvgSNR()
|
||||
entry.AvgSNR = &avg
|
||||
}
|
||||
|
||||
if e.Ambiguous {
|
||||
if len(e.Candidates) == 0 {
|
||||
entry.Unresolved = true
|
||||
}
|
||||
for _, cpk := range e.Candidates {
|
||||
ce := CandidateEntry{Pubkey: cpk}
|
||||
if info, ok := nodeMap[strings.ToLower(cpk)]; ok {
|
||||
ce.Name = info.Name
|
||||
ce.Role = info.Role
|
||||
}
|
||||
entry.Candidates = append(entry.Candidates, ce)
|
||||
}
|
||||
} else if neighborPK != "" {
|
||||
entry.Pubkey = &neighborPK
|
||||
if info, ok := nodeMap[strings.ToLower(neighborPK)]; ok {
|
||||
entry.Name = &info.Name
|
||||
entry.Role = &info.Role
|
||||
}
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
// Sort by score descending.
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Score > entries[j].Score
|
||||
})
|
||||
|
||||
if entries == nil {
|
||||
entries = []NeighborEntry{}
|
||||
}
|
||||
|
||||
resp := NeighborResponse{
|
||||
Node: pubkey,
|
||||
Neighbors: entries,
|
||||
TotalObservations: totalObs,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleNeighborGraph(w http.ResponseWriter, r *http.Request) {
|
||||
minCount := 5
|
||||
if v := r.URL.Query().Get("min_count"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
minCount = n
|
||||
}
|
||||
}
|
||||
minScore := 0.1
|
||||
if v := r.URL.Query().Get("min_score"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
minScore = f
|
||||
}
|
||||
}
|
||||
region := r.URL.Query().Get("region")
|
||||
roleFilter := strings.ToLower(r.URL.Query().Get("role"))
|
||||
|
||||
graph := s.getNeighborGraph()
|
||||
allEdges := graph.AllEdges()
|
||||
now := time.Now()
|
||||
|
||||
// Resolve region observers if filtering.
|
||||
var regionObs map[string]bool
|
||||
if region != "" && s.store != nil {
|
||||
regionObs = s.store.resolveRegionObservers(region)
|
||||
}
|
||||
|
||||
nodeMap := s.buildNodeInfoMap()
|
||||
nodeSet := make(map[string]bool)
|
||||
var filteredEdges []GraphEdge
|
||||
ambiguousCount := 0
|
||||
|
||||
for _, e := range allEdges {
|
||||
score := e.Score(now)
|
||||
if e.Count < minCount || score < minScore {
|
||||
continue
|
||||
}
|
||||
|
||||
// Role filter: at least one endpoint must match the role.
|
||||
if roleFilter != "" && nodeMap != nil {
|
||||
aInfo, aOK := nodeMap[strings.ToLower(e.NodeA)]
|
||||
bInfo, bOK := nodeMap[strings.ToLower(e.NodeB)]
|
||||
aMatch := aOK && strings.EqualFold(aInfo.Role, roleFilter)
|
||||
bMatch := bOK && strings.EqualFold(bInfo.Role, roleFilter)
|
||||
if !aMatch && !bMatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Region filter: at least one observer must be in the region.
|
||||
if regionObs != nil {
|
||||
match := false
|
||||
for obs := range e.Observers {
|
||||
if regionObs[obs] {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
ge := GraphEdge{
|
||||
Source: e.NodeA,
|
||||
Target: e.NodeB,
|
||||
Weight: e.Count,
|
||||
Score: score,
|
||||
Bidirectional: true,
|
||||
Ambiguous: e.Ambiguous,
|
||||
}
|
||||
if e.SNRCount > 0 {
|
||||
avg := e.AvgSNR()
|
||||
ge.AvgSNR = &avg
|
||||
}
|
||||
|
||||
if e.Ambiguous {
|
||||
ambiguousCount++
|
||||
// For ambiguous edges, use prefix as target.
|
||||
if e.NodeB == "" {
|
||||
ge.Target = "prefix:" + e.Prefix
|
||||
}
|
||||
}
|
||||
|
||||
filteredEdges = append(filteredEdges, ge)
|
||||
|
||||
// Track nodes.
|
||||
if e.NodeA != "" && !strings.HasPrefix(e.NodeA, "prefix:") {
|
||||
nodeSet[e.NodeA] = true
|
||||
}
|
||||
if e.NodeB != "" && !strings.HasPrefix(e.NodeB, "prefix:") {
|
||||
nodeSet[e.NodeB] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build node list.
|
||||
// Count neighbors per node from filtered edges.
|
||||
neighborCounts := make(map[string]int)
|
||||
for _, ge := range filteredEdges {
|
||||
neighborCounts[ge.Source]++
|
||||
neighborCounts[ge.Target]++
|
||||
}
|
||||
|
||||
var nodes []GraphNode
|
||||
for pk := range nodeSet {
|
||||
gn := GraphNode{Pubkey: pk, NeighborCount: neighborCounts[pk]}
|
||||
if info, ok := nodeMap[strings.ToLower(pk)]; ok {
|
||||
gn.Name = info.Name
|
||||
gn.Role = info.Role
|
||||
}
|
||||
nodes = append(nodes, gn)
|
||||
}
|
||||
|
||||
if filteredEdges == nil {
|
||||
filteredEdges = []GraphEdge{}
|
||||
}
|
||||
if nodes == nil {
|
||||
nodes = []GraphNode{}
|
||||
}
|
||||
|
||||
avgCluster := 0.0
|
||||
if len(nodes) > 0 {
|
||||
avgCluster = float64(len(filteredEdges)*2) / float64(len(nodes))
|
||||
}
|
||||
|
||||
resp := NeighborGraphResponse{
|
||||
Nodes: nodes,
|
||||
Edges: filteredEdges,
|
||||
Stats: GraphStats{
|
||||
TotalNodes: len(nodes),
|
||||
TotalEdges: len(filteredEdges),
|
||||
AmbiguousEdges: ambiguousCount,
|
||||
AvgClusterSize: avgCluster,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func observerList(m map[string]bool) []string {
|
||||
if len(m) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// buildNodeInfoMap returns a map of lowercase pubkey → nodeInfo for name/role lookups.
|
||||
func (s *Server) buildNodeInfoMap() map[string]nodeInfo {
|
||||
if s.store == nil {
|
||||
return nil
|
||||
}
|
||||
nodes, _ := s.store.getCachedNodesAndPM()
|
||||
m := make(map[string]nodeInfo, len(nodes))
|
||||
for _, n := range nodes {
|
||||
m[strings.ToLower(n.PublicKey)] = n
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// makeTestServer creates a Server with a pre-built neighbor graph for testing.
|
||||
func makeTestServer(graph *NeighborGraph) *Server {
|
||||
srv := &Server{
|
||||
perfStats: NewPerfStats(),
|
||||
}
|
||||
srv.neighborGraph = graph
|
||||
return srv
|
||||
}
|
||||
|
||||
// makeTestGraph creates a graph with given edges for testing.
|
||||
func makeTestGraph(edges ...*NeighborEdge) *NeighborGraph {
|
||||
g := NewNeighborGraph()
|
||||
g.mu.Lock()
|
||||
for _, e := range edges {
|
||||
key := makeEdgeKey(e.NodeA, e.NodeB)
|
||||
if e.NodeB == "" {
|
||||
key = makeEdgeKey(e.NodeA, "prefix:"+e.Prefix)
|
||||
}
|
||||
e.NodeA = key.A
|
||||
if e.NodeB != "" {
|
||||
e.NodeB = key.B
|
||||
}
|
||||
g.edges[key] = e
|
||||
g.byNode[key.A] = append(g.byNode[key.A], e)
|
||||
if key.B != "" && key.B != key.A {
|
||||
g.byNode[key.B] = append(g.byNode[key.B], e)
|
||||
}
|
||||
}
|
||||
g.builtAt = time.Now()
|
||||
g.mu.Unlock()
|
||||
return g
|
||||
}
|
||||
|
||||
func newEdge(a, b, prefix string, count int, lastSeen time.Time) *NeighborEdge {
|
||||
return &NeighborEdge{
|
||||
NodeA: a,
|
||||
NodeB: b,
|
||||
Prefix: prefix,
|
||||
Count: count,
|
||||
FirstSeen: lastSeen.Add(-24 * time.Hour),
|
||||
LastSeen: lastSeen,
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
SNRSum: -8.0,
|
||||
SNRCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func newAmbiguousEdge(knownPK, prefix string, candidates []string, count int, lastSeen time.Time) *NeighborEdge {
|
||||
return &NeighborEdge{
|
||||
NodeA: knownPK,
|
||||
NodeB: "",
|
||||
Prefix: prefix,
|
||||
Count: count,
|
||||
FirstSeen: lastSeen.Add(-24 * time.Hour),
|
||||
LastSeen: lastSeen,
|
||||
Observers: map[string]bool{"obs1": true},
|
||||
Ambiguous: true,
|
||||
Candidates: candidates,
|
||||
}
|
||||
}
|
||||
|
||||
func serveRequest(srv *Server, method, path string) *httptest.ResponseRecorder {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/api/nodes/{pubkey}/neighbors", srv.handleNodeNeighbors).Methods("GET")
|
||||
router.HandleFunc("/api/analytics/neighbor-graph", srv.handleNeighborGraph).Methods("GET")
|
||||
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// ─── Tests: /api/nodes/{pubkey}/neighbors ──────────────────────────────────────
|
||||
|
||||
func TestNeighborAPI_EmptyGraph(t *testing.T) {
|
||||
srv := makeTestServer(makeTestGraph())
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/deadbeef/neighbors")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
if resp.Node != "deadbeef" {
|
||||
t.Errorf("node = %q, want deadbeef", resp.Node)
|
||||
}
|
||||
if len(resp.Neighbors) != 0 {
|
||||
t.Errorf("expected 0 neighbors, got %d", len(resp.Neighbors))
|
||||
}
|
||||
if resp.TotalObservations != 0 {
|
||||
t.Errorf("expected 0 observations, got %d", resp.TotalObservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_SingleNeighbor(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newEdge("aaaa", "bbbb", "bb", 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
n := resp.Neighbors[0]
|
||||
if n.Pubkey == nil || *n.Pubkey != "bbbb" {
|
||||
t.Errorf("expected pubkey bbbb, got %v", n.Pubkey)
|
||||
}
|
||||
if n.Count != 50 {
|
||||
t.Errorf("expected count 50, got %d", n.Count)
|
||||
}
|
||||
if n.Score <= 0 {
|
||||
t.Errorf("expected positive score, got %f", n.Score)
|
||||
}
|
||||
if n.Ambiguous {
|
||||
t.Error("expected not ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_MultipleNeighbors(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 10, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 2 {
|
||||
t.Fatalf("expected 2 neighbors, got %d", len(resp.Neighbors))
|
||||
}
|
||||
// Should be sorted by score descending.
|
||||
if resp.Neighbors[0].Score < resp.Neighbors[1].Score {
|
||||
t.Error("expected sorted by score descending")
|
||||
}
|
||||
if resp.TotalObservations != 110 {
|
||||
t.Errorf("expected 110 total observations, got %d", resp.TotalObservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_AmbiguousCandidates(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 12, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
n := resp.Neighbors[0]
|
||||
if !n.Ambiguous {
|
||||
t.Error("expected ambiguous")
|
||||
}
|
||||
if n.Pubkey != nil {
|
||||
t.Errorf("expected nil pubkey for ambiguous, got %v", n.Pubkey)
|
||||
}
|
||||
if len(n.Candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(n.Candidates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_UnresolvedPrefix(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newAmbiguousEdge("aaaa", "ff", []string{}, 3, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
n := resp.Neighbors[0]
|
||||
if !n.Unresolved {
|
||||
t.Error("expected unresolved=true")
|
||||
}
|
||||
if len(n.Candidates) != 0 {
|
||||
t.Error("expected empty candidates for unresolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_MinCountFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 2, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_count=10")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor after min_count filter, got %d", len(resp.Neighbors))
|
||||
}
|
||||
if *resp.Neighbors[0].Pubkey != "bbbb" {
|
||||
t.Error("expected bbbb to survive filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_MinScoreFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // score ~1.0
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 1, now.Add(-30*24*time.Hour)) // very low score
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_score=0.5")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 neighbor after min_score filter, got %d", len(resp.Neighbors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_ExcludeAmbiguous(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 50, now)
|
||||
e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01"}, 10, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?include_ambiguous=false")
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Neighbors) != 1 {
|
||||
t.Fatalf("expected 1 non-ambiguous neighbor, got %d", len(resp.Neighbors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborAPI_UnknownNode(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newEdge("aaaa", "bbbb", "bb", 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/nodes/unknown1234/neighbors")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for unknown node, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
if len(resp.Neighbors) != 0 {
|
||||
t.Errorf("expected 0 neighbors for unknown node, got %d", len(resp.Neighbors))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests: /api/analytics/neighbor-graph ──────────────────────────────────────
|
||||
|
||||
func TestNeighborGraphAPI_EmptyGraph(t *testing.T) {
|
||||
srv := makeTestServer(makeTestGraph())
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Edges) != 0 {
|
||||
t.Errorf("expected 0 edges, got %d", len(resp.Edges))
|
||||
}
|
||||
if resp.Stats.TotalEdges != 0 {
|
||||
t.Errorf("expected 0 total edges, got %d", resp.Stats.TotalEdges)
|
||||
}
|
||||
if resp.Stats.TotalNodes != 0 {
|
||||
t.Errorf("expected 0 total nodes, got %d", resp.Stats.TotalNodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_WithEdges(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newEdge("bbbb", "cccc", "cc", 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(resp.Edges))
|
||||
}
|
||||
if resp.Stats.TotalNodes != 3 {
|
||||
t.Errorf("expected 3 nodes, got %d", resp.Stats.TotalNodes)
|
||||
}
|
||||
if resp.Stats.TotalEdges != 2 {
|
||||
t.Errorf("expected 2 total edges, got %d", resp.Stats.TotalEdges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_MinCountDefault(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // passes default min_count=5
|
||||
e2 := newEdge("aaaa", "cccc", "cc", 2, now) // fails default min_count=5
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if len(resp.Edges) != 1 {
|
||||
t.Fatalf("expected 1 edge with default min_count=5, got %d", len(resp.Edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_AmbiguousEdgesCount(t *testing.T) {
|
||||
now := time.Now()
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 50, now)
|
||||
srv := makeTestServer(makeTestGraph(e1, e2))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
if resp.Stats.AmbiguousEdges != 1 {
|
||||
t.Errorf("expected 1 ambiguous edge, got %d", resp.Stats.AmbiguousEdges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_RegionFilter(t *testing.T) {
|
||||
now := time.Now()
|
||||
// Edge with observer "obs-sjc" — would match region SJC if we had region resolution.
|
||||
// Without a store, region filtering returns nothing (no observers match).
|
||||
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
srv := makeTestServer(makeTestGraph(e1))
|
||||
// No store → region filter has no observers → filters everything out.
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?region=SJC&min_count=1&min_score=0")
|
||||
var resp NeighborGraphResponse
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
|
||||
// With no store, regionObs is nil so filter is skipped → all edges returned.
|
||||
// Actually: region="" when store is nil → regionObs stays nil → no filtering.
|
||||
// Wait, we set region=SJC and store is nil → resolveRegionObservers won't be called
|
||||
// because s.store is nil. So regionObs is nil → filter not applied.
|
||||
// Let's just check it doesn't crash.
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraphAPI_ResponseShape(t *testing.T) {
|
||||
now := time.Now()
|
||||
e := newEdge("aaaa", "bbbb", "bb", 100, now)
|
||||
srv := makeTestServer(makeTestGraph(e))
|
||||
|
||||
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify top-level keys.
|
||||
for _, key := range []string{"nodes", "edges", "stats"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Errorf("missing key %q in response", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify stats keys.
|
||||
stats := raw["stats"].(map[string]interface{})
|
||||
for _, key := range []string{"total_nodes", "total_edges", "ambiguous_edges", "avg_cluster_size"} {
|
||||
if _, ok := stats[key]; !ok {
|
||||
t.Errorf("missing stats key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// After this many observations, count contributes max weight to the score.
|
||||
affinitySaturationCount = 100
|
||||
// Time-decay half-life: 7 days.
|
||||
affinityHalfLifeHours = 168.0
|
||||
// Cache TTL for the built graph.
|
||||
neighborGraphTTL = 60 * time.Second
|
||||
// Auto-resolve confidence: best must be >= this factor × second-best.
|
||||
affinityConfidenceRatio = 3.0
|
||||
// Minimum observation count to auto-resolve.
|
||||
affinityMinObservations = 3
|
||||
)
|
||||
|
||||
// affinityLambda = ln(2) / half-life-hours, precomputed.
|
||||
var affinityLambda = math.Ln2 / affinityHalfLifeHours
|
||||
|
||||
// ─── Data model ────────────────────────────────────────────────────────────────
|
||||
|
||||
// edgeKey is the canonical key for an undirected edge (A < B lexicographically).
|
||||
// For ambiguous edges where NodeB is unknown, B is the raw prefix prefixed with "prefix:".
|
||||
type edgeKey struct {
|
||||
A, B string
|
||||
}
|
||||
|
||||
func makeEdgeKey(a, b string) edgeKey {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return edgeKey{A: a, B: b}
|
||||
}
|
||||
|
||||
// NeighborEdge represents a weighted, undirected first-hop neighbor relationship.
|
||||
type NeighborEdge struct {
|
||||
NodeA string // full pubkey
|
||||
NodeB string // full pubkey, or "" if unresolved/ambiguous
|
||||
Prefix string // raw hop prefix that established this edge
|
||||
Count int // total observations
|
||||
FirstSeen time.Time //
|
||||
LastSeen time.Time //
|
||||
SNRSum float64 // running sum for average
|
||||
SNRCount int // how many SNR samples
|
||||
Observers map[string]bool // observer pubkeys that witnessed
|
||||
Ambiguous bool // multiple candidates or zero candidates
|
||||
Candidates []string // candidate pubkeys when ambiguous
|
||||
Resolved bool // true if auto-resolved via Jaccard
|
||||
}
|
||||
|
||||
// Score computes the affinity score at query time with time decay.
|
||||
func (e *NeighborEdge) Score(now time.Time) float64 {
|
||||
countFactor := math.Min(1.0, float64(e.Count)/float64(affinitySaturationCount))
|
||||
hoursSince := now.Sub(e.LastSeen).Hours()
|
||||
if hoursSince < 0 {
|
||||
hoursSince = 0
|
||||
}
|
||||
decay := math.Exp(-affinityLambda * hoursSince)
|
||||
return countFactor * decay
|
||||
}
|
||||
|
||||
// AvgSNR returns the average SNR, or 0 if no samples.
|
||||
func (e *NeighborEdge) AvgSNR() float64 {
|
||||
if e.SNRCount == 0 {
|
||||
return 0
|
||||
}
|
||||
return e.SNRSum / float64(e.SNRCount)
|
||||
}
|
||||
|
||||
// ─── NeighborGraph ─────────────────────────────────────────────────────────────
|
||||
|
||||
// NeighborGraph is a cached, in-memory first-hop neighbor affinity graph.
|
||||
type NeighborGraph struct {
|
||||
mu sync.RWMutex
|
||||
edges map[edgeKey]*NeighborEdge
|
||||
byNode map[string][]*NeighborEdge // pubkey → edges involving this node
|
||||
builtAt time.Time
|
||||
}
|
||||
|
||||
// NewNeighborGraph creates an empty graph.
|
||||
func NewNeighborGraph() *NeighborGraph {
|
||||
return &NeighborGraph{
|
||||
edges: make(map[edgeKey]*NeighborEdge),
|
||||
byNode: make(map[string][]*NeighborEdge),
|
||||
}
|
||||
}
|
||||
|
||||
// Neighbors returns all edges for a given node pubkey.
|
||||
func (g *NeighborGraph) Neighbors(pubkey string) []*NeighborEdge {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.byNode[strings.ToLower(pubkey)]
|
||||
}
|
||||
|
||||
// AllEdges returns all edges in the graph.
|
||||
func (g *NeighborGraph) AllEdges() []*NeighborEdge {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
out := make([]*NeighborEdge, 0, len(g.edges))
|
||||
for _, e := range g.edges {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsStale returns true if the graph cache has expired.
|
||||
func (g *NeighborGraph) IsStale() bool {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.builtAt.IsZero() || time.Since(g.builtAt) > neighborGraphTTL
|
||||
}
|
||||
|
||||
// ─── Builder ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// BuildFromStore constructs the neighbor graph from all packets in the store.
|
||||
// The store's read-lock must NOT be held by the caller.
|
||||
func BuildFromStore(store *PacketStore) *NeighborGraph {
|
||||
g := NewNeighborGraph()
|
||||
|
||||
store.mu.RLock()
|
||||
// Snapshot what we need under lock.
|
||||
packets := make([]*StoreTx, len(store.packets))
|
||||
copy(packets, store.packets)
|
||||
store.mu.RUnlock()
|
||||
|
||||
// Build prefix map for candidate resolution.
|
||||
// Use cached nodes+PM (avoids DB call if cache is fresh).
|
||||
_, pm := store.getCachedNodesAndPM()
|
||||
|
||||
// Phase 1: Extract edges from every transmission + observation.
|
||||
for _, tx := range packets {
|
||||
isAdvert := tx.PayloadType != nil && *tx.PayloadType == 4
|
||||
fromNode := "" // originator pubkey (from byNode index key)
|
||||
// Find the originator pubkey — it's the key in store.byNode.
|
||||
// StoreTx doesn't store from_node directly; we find it via decoded JSON
|
||||
// or the byNode index. However, iterating byNode is expensive.
|
||||
// The originator pubkey is in the decoded JSON "from_node" field,
|
||||
// but parsing JSON per tx is expensive too.
|
||||
// Actually, let's look at how byNode is keyed.
|
||||
// Looking at store.go, byNode maps pubkey → transmissions where that
|
||||
// pubkey is the "from" node. We need the reverse: tx → from_node.
|
||||
// The from_node is embedded in DecodedJSON.
|
||||
// For efficiency, let's extract it once.
|
||||
fromNode = extractFromNode(tx)
|
||||
|
||||
for _, obs := range tx.Observations {
|
||||
path := parsePathJSON(obs.PathJSON)
|
||||
observerPK := strings.ToLower(obs.ObserverID)
|
||||
|
||||
if len(path) == 0 {
|
||||
// Zero-hop
|
||||
if isAdvert && fromNode != "" {
|
||||
fromLower := strings.ToLower(fromNode)
|
||||
if fromLower != observerPK { // self-edge guard
|
||||
g.upsertEdge(fromLower, observerPK, "", observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Edge 1: originator ↔ path[0] — ADVERTs only
|
||||
if isAdvert && fromNode != "" {
|
||||
firstHop := strings.ToLower(path[0])
|
||||
fromLower := strings.ToLower(fromNode)
|
||||
if fromLower != firstHop { // self-edge guard (shouldn't happen but spec says check)
|
||||
candidates := pm.m[firstHop]
|
||||
g.upsertEdgeWithCandidates(fromLower, firstHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
// Edge 2: observer ↔ path[last] — ALL packet types
|
||||
lastHop := strings.ToLower(path[len(path)-1])
|
||||
if observerPK != lastHop { // self-edge guard
|
||||
candidates := pm.m[lastHop]
|
||||
g.upsertEdgeWithCandidates(observerPK, lastHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Disambiguation via Jaccard similarity.
|
||||
g.disambiguate()
|
||||
|
||||
g.mu.Lock()
|
||||
g.builtAt = time.Now()
|
||||
g.mu.Unlock()
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// extractFromNode pulls the from_node pubkey from a StoreTx.
|
||||
// It looks in DecodedJSON for "from_node" or "from".
|
||||
func extractFromNode(tx *StoreTx) string {
|
||||
if tx.DecodedJSON == "" {
|
||||
return ""
|
||||
}
|
||||
// Fast path: look for "from_node" key.
|
||||
var decoded map[string]interface{}
|
||||
if err := jsonUnmarshalFast(tx.DecodedJSON, &decoded); err != nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := decoded["from_node"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if v, ok := decoded["from"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// jsonUnmarshalFast is a thin wrapper; could be optimized later.
|
||||
func jsonUnmarshalFast(data string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(data), v)
|
||||
}
|
||||
|
||||
// upsertEdge adds/updates an edge between two fully-known pubkeys.
|
||||
func (g *NeighborGraph) upsertEdge(pubkeyA, pubkeyB, prefix, observer string, snr *float64, ts time.Time) {
|
||||
key := makeEdgeKey(pubkeyA, pubkeyB)
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
e, exists := g.edges[key]
|
||||
if !exists {
|
||||
e = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: key.B,
|
||||
Prefix: prefix,
|
||||
Observers: make(map[string]bool),
|
||||
FirstSeen: ts,
|
||||
LastSeen: ts,
|
||||
}
|
||||
g.edges[key] = e
|
||||
g.byNode[key.A] = append(g.byNode[key.A], e)
|
||||
g.byNode[key.B] = append(g.byNode[key.B], e)
|
||||
}
|
||||
|
||||
e.Count++
|
||||
if ts.After(e.LastSeen) {
|
||||
e.LastSeen = ts
|
||||
}
|
||||
if ts.Before(e.FirstSeen) {
|
||||
e.FirstSeen = ts
|
||||
}
|
||||
if snr != nil {
|
||||
e.SNRSum += *snr
|
||||
e.SNRCount++
|
||||
}
|
||||
if observer != "" {
|
||||
e.Observers[observer] = true
|
||||
}
|
||||
}
|
||||
|
||||
// upsertEdgeWithCandidates handles prefix-based edges that may be ambiguous.
|
||||
func (g *NeighborGraph) upsertEdgeWithCandidates(knownPK, prefix string, candidates []nodeInfo, observer string, snr *float64, ts time.Time) {
|
||||
if len(candidates) == 1 {
|
||||
resolved := strings.ToLower(candidates[0].PublicKey)
|
||||
if resolved == knownPK {
|
||||
return // self-edge guard
|
||||
}
|
||||
g.upsertEdge(knownPK, resolved, prefix, observer, snr, ts)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out self from candidates
|
||||
filtered := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
pk := strings.ToLower(c.PublicKey)
|
||||
if pk != knownPK {
|
||||
filtered = append(filtered, pk)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 1 {
|
||||
g.upsertEdge(knownPK, filtered[0], prefix, observer, snr, ts)
|
||||
return
|
||||
}
|
||||
|
||||
// Ambiguous or orphan: use prefix-based key
|
||||
pseudoB := "prefix:" + prefix
|
||||
key := makeEdgeKey(knownPK, pseudoB)
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
e, exists := g.edges[key]
|
||||
if !exists {
|
||||
e = &NeighborEdge{
|
||||
NodeA: key.A,
|
||||
NodeB: "",
|
||||
Prefix: prefix,
|
||||
Observers: make(map[string]bool),
|
||||
Ambiguous: true,
|
||||
Candidates: filtered,
|
||||
FirstSeen: ts,
|
||||
LastSeen: ts,
|
||||
}
|
||||
g.edges[key] = e
|
||||
g.byNode[knownPK] = append(g.byNode[knownPK], e)
|
||||
}
|
||||
|
||||
e.Count++
|
||||
if ts.After(e.LastSeen) {
|
||||
e.LastSeen = ts
|
||||
}
|
||||
if ts.Before(e.FirstSeen) {
|
||||
e.FirstSeen = ts
|
||||
}
|
||||
if snr != nil {
|
||||
e.SNRSum += *snr
|
||||
e.SNRCount++
|
||||
}
|
||||
if observer != "" {
|
||||
e.Observers[observer] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Disambiguation ────────────────────────────────────────────────────────────
|
||||
|
||||
// disambiguate resolves ambiguous edges using Jaccard similarity of neighbor sets.
|
||||
// Only fully-resolved edges are used as evidence (transitivity poisoning guard).
|
||||
func (g *NeighborGraph) disambiguate() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
// Build resolved neighbor sets: for each node, collect the set of nodes
|
||||
// it has fully-resolved (non-ambiguous) edges with.
|
||||
resolvedNeighbors := make(map[string]map[string]bool)
|
||||
for _, e := range g.edges {
|
||||
if e.Ambiguous || e.NodeB == "" {
|
||||
continue
|
||||
}
|
||||
if resolvedNeighbors[e.NodeA] == nil {
|
||||
resolvedNeighbors[e.NodeA] = make(map[string]bool)
|
||||
}
|
||||
if resolvedNeighbors[e.NodeB] == nil {
|
||||
resolvedNeighbors[e.NodeB] = make(map[string]bool)
|
||||
}
|
||||
resolvedNeighbors[e.NodeA][e.NodeB] = true
|
||||
resolvedNeighbors[e.NodeB][e.NodeA] = true
|
||||
}
|
||||
|
||||
// Try to resolve each ambiguous edge.
|
||||
for key, e := range g.edges {
|
||||
if !e.Ambiguous || len(e.Candidates) < 2 {
|
||||
continue
|
||||
}
|
||||
if e.Count < affinityMinObservations {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine the known node (the one that's a real pubkey, not the prefix side).
|
||||
knownNode := e.NodeA
|
||||
if strings.HasPrefix(e.NodeA, "prefix:") {
|
||||
knownNode = e.NodeB
|
||||
}
|
||||
// If knownNode is empty (shouldn't happen for ambiguous edges with candidates), skip.
|
||||
if knownNode == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
knownNeighbors := resolvedNeighbors[knownNode]
|
||||
|
||||
type scored struct {
|
||||
pubkey string
|
||||
jaccard float64
|
||||
}
|
||||
var scores []scored
|
||||
|
||||
for _, cand := range e.Candidates {
|
||||
candNeighbors := resolvedNeighbors[cand]
|
||||
j := jaccardSimilarity(knownNeighbors, candNeighbors)
|
||||
scores = append(scores, scored{cand, j})
|
||||
}
|
||||
|
||||
if len(scores) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find best and second-best.
|
||||
best, secondBest := scores[0], scores[1]
|
||||
if secondBest.jaccard > best.jaccard {
|
||||
best, secondBest = secondBest, best
|
||||
}
|
||||
for i := 2; i < len(scores); i++ {
|
||||
if scores[i].jaccard > best.jaccard {
|
||||
secondBest = best
|
||||
best = scores[i]
|
||||
} else if scores[i].jaccard > secondBest.jaccard {
|
||||
secondBest = scores[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resolve only if best >= 3× second-best AND enough observations.
|
||||
if secondBest.jaccard == 0 {
|
||||
// If second-best is 0 and best > 0, ratio is infinite → resolve.
|
||||
if best.jaccard > 0 {
|
||||
g.resolveEdge(key, e, knownNode, best.pubkey)
|
||||
}
|
||||
} else if best.jaccard/secondBest.jaccard >= affinityConfidenceRatio {
|
||||
g.resolveEdge(key, e, knownNode, best.pubkey)
|
||||
}
|
||||
// Otherwise remain ambiguous.
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEdge converts an ambiguous edge to a resolved one.
|
||||
// Must be called with g.mu held.
|
||||
func (g *NeighborGraph) resolveEdge(oldKey edgeKey, e *NeighborEdge, knownNode, resolvedPK string) {
|
||||
// Remove old edge.
|
||||
delete(g.edges, oldKey)
|
||||
g.removeFromByNode(oldKey.A, e)
|
||||
g.removeFromByNode(oldKey.B, e)
|
||||
|
||||
// Update edge.
|
||||
newKey := makeEdgeKey(knownNode, resolvedPK)
|
||||
e.NodeA = newKey.A
|
||||
e.NodeB = newKey.B
|
||||
e.Ambiguous = false
|
||||
e.Resolved = true
|
||||
|
||||
// Merge with existing edge if any.
|
||||
if existing, ok := g.edges[newKey]; ok {
|
||||
existing.Count += e.Count
|
||||
if e.LastSeen.After(existing.LastSeen) {
|
||||
existing.LastSeen = e.LastSeen
|
||||
}
|
||||
if e.FirstSeen.Before(existing.FirstSeen) {
|
||||
existing.FirstSeen = e.FirstSeen
|
||||
}
|
||||
existing.SNRSum += e.SNRSum
|
||||
existing.SNRCount += e.SNRCount
|
||||
for obs := range e.Observers {
|
||||
existing.Observers[obs] = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
g.edges[newKey] = e
|
||||
g.byNode[newKey.A] = append(g.byNode[newKey.A], e)
|
||||
g.byNode[newKey.B] = append(g.byNode[newKey.B], e)
|
||||
}
|
||||
|
||||
// removeFromByNode removes an edge from the byNode index for the given key.
|
||||
func (g *NeighborGraph) removeFromByNode(nodeKey string, edge *NeighborEdge) {
|
||||
edges := g.byNode[nodeKey]
|
||||
for i, e := range edges {
|
||||
if e == edge {
|
||||
g.byNode[nodeKey] = append(edges[:i], edges[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jaccardSimilarity computes |A ∩ B| / |A ∪ B|.
|
||||
func jaccardSimilarity(a, b map[string]bool) float64 {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
intersection := 0
|
||||
for k := range a {
|
||||
if b[k] {
|
||||
intersection++
|
||||
}
|
||||
}
|
||||
union := len(a) + len(b) - intersection
|
||||
if union == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(intersection) / float64(union)
|
||||
}
|
||||
|
||||
// parseTimestamp parses a timestamp string into time.Time.
|
||||
func parseTimestamp(s string) time.Time {
|
||||
// Try common formats.
|
||||
for _, fmt := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05.000Z",
|
||||
} {
|
||||
if t, err := time.Parse(fmt, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// ngTestStore creates a minimal PacketStore with injected nodes and packets.
|
||||
func ngTestStore(nodes []nodeInfo, packets []*StoreTx) *PacketStore {
|
||||
if nodes == nil {
|
||||
nodes = []nodeInfo{}
|
||||
}
|
||||
if packets == nil {
|
||||
packets = []*StoreTx{}
|
||||
}
|
||||
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),
|
||||
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),
|
||||
}
|
||||
ps.nodeCache = nodes
|
||||
ps.nodePM = buildPrefixMap(nodes)
|
||||
ps.nodeCacheTime = time.Now().Add(1 * time.Hour)
|
||||
return ps
|
||||
}
|
||||
|
||||
func ngIntPtr(v int) *int { return &v }
|
||||
func ngFloatPtr(v float64) *float64 { return &v }
|
||||
|
||||
func ngMakeTx(id int, payloadType int, decodedJSON string, obs []*StoreObs) *StoreTx {
|
||||
tx := &StoreTx{
|
||||
ID: id,
|
||||
PayloadType: ngIntPtr(payloadType),
|
||||
DecodedJSON: decodedJSON,
|
||||
Observations: obs,
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func ngMakeObs(observerID, pathJSON, timestamp string, snr *float64) *StoreObs {
|
||||
return &StoreObs{
|
||||
ObserverID: observerID,
|
||||
PathJSON: pathJSON,
|
||||
Timestamp: timestamp,
|
||||
SNR: snr,
|
||||
}
|
||||
}
|
||||
|
||||
func ngFromNodeJSON(pubkey string) string {
|
||||
b, _ := json.Marshal(map[string]string{"from_node": pubkey})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var now = time.Now()
|
||||
var nowStr = now.UTC().Format(time.RFC3339)
|
||||
var weekAgoStr = now.Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
var monthAgoStr = now.Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildNeighborGraph_EmptyStore(t *testing.T) {
|
||||
store := ngTestStore(nil, nil)
|
||||
g := BuildFromStore(store)
|
||||
if len(g.edges) != 0 {
|
||||
t.Errorf("expected 0 edges, got %d", len(g.edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_AdvertSingleHopPath(t *testing.T) {
|
||||
// ADVERT from X, path=["R1_prefix"] → edges: X↔R1 and Observer↔R1
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, ngFloatPtr(-10)),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should have 2 edges: X↔R1 and Observer↔R1
|
||||
// But since path has 1 element, path[0]==path[last], so for ADVERTs
|
||||
// both edge types point to the same hop. X↔R1 and Obs↔R1 = 2 edges.
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
|
||||
// Check X↔R1 exists
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") ||
|
||||
(e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing originator↔path[0] edge (X↔R1)")
|
||||
}
|
||||
|
||||
// Check Observer↔R1 exists
|
||||
found = false
|
||||
for _, e := range edges {
|
||||
if (e.NodeA == "obs00001" && e.NodeB == "r1aabbcc") ||
|
||||
(e.NodeA == "r1aabbcc" && e.NodeB == "obs00001") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing observer↔path[last] edge (Observer↔R1)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_AdvertMultiHopPath(t *testing.T) {
|
||||
// ADVERT from X, path=["R1","R2"] → X↔R1 and Observer↔R2
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "r2ddeeff", Name: "R2"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
|
||||
// X↔R1
|
||||
hasXR1 := false
|
||||
hasObsR2 := false
|
||||
for _, e := range edges {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
hasXR1 = true
|
||||
}
|
||||
if (e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001") {
|
||||
hasObsR2 = true
|
||||
}
|
||||
}
|
||||
if !hasXR1 {
|
||||
t.Error("missing X↔R1 edge")
|
||||
}
|
||||
if !hasObsR2 {
|
||||
t.Error("missing Observer↔R2 edge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_AdvertZeroHop(t *testing.T) {
|
||||
// ADVERT from X, path=[] → X↔Observer direct edge
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `[]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge, got %d", len(edges))
|
||||
}
|
||||
e := edges[0]
|
||||
if !((e.NodeA == "aaaa1111" && e.NodeB == "obs00001") || (e.NodeA == "obs00001" && e.NodeB == "aaaa1111")) {
|
||||
t.Errorf("expected X↔Observer edge, got %s↔%s", e.NodeA, e.NodeB)
|
||||
}
|
||||
if e.Ambiguous {
|
||||
t.Error("zero-hop edge should not be ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_NonAdvertEmptyPath(t *testing.T) {
|
||||
// Non-ADVERT, path=[] → no edges
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `[]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
if len(g.edges) != 0 {
|
||||
t.Errorf("expected 0 edges for non-ADVERT empty path, got %d", len(g.edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_NonAdvertOnlyObserverEdge(t *testing.T) {
|
||||
// Non-ADVERT with path=["R1","R2"] → only Observer↔R2, NO originator edge
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "r2ddeeff", Name: "R2"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge, got %d", len(edges))
|
||||
}
|
||||
e := edges[0]
|
||||
if !((e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001")) {
|
||||
t.Errorf("expected Observer↔R2 edge, got %s↔%s", e.NodeA, e.NodeB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_NonAdvertSingleHop(t *testing.T) {
|
||||
// Non-ADVERT with path=["R1"] → Observer↔R1 only
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge, got %d", len(edges))
|
||||
}
|
||||
e := edges[0]
|
||||
if !((e.NodeA == "obs00001" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "obs00001")) {
|
||||
t.Errorf("expected Observer↔R1, got %s↔%s", e.NodeA, e.NodeB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_HashCollision(t *testing.T) {
|
||||
// Two nodes share prefix "a3" → ambiguous edge
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "a3bb1111", Name: "CandidateA"},
|
||||
{PublicKey: "a3bb2222", Name: "CandidateB"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["a3bb"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should have ambiguous edges
|
||||
var ambigCount int
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Ambiguous {
|
||||
ambigCount++
|
||||
if len(e.Candidates) < 2 {
|
||||
t.Errorf("expected >=2 candidates, got %d", len(e.Candidates))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ambigCount == 0 {
|
||||
t.Error("expected at least one ambiguous edge for hash collision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_JaccardScoring(t *testing.T) {
|
||||
// Test Jaccard similarity computation directly
|
||||
a := map[string]bool{"x": true, "y": true, "z": true}
|
||||
b := map[string]bool{"y": true, "z": true, "w": true}
|
||||
j := jaccardSimilarity(a, b)
|
||||
// intersection = {y, z} = 2, union = {x, y, z, w} = 4 → 0.5
|
||||
if math.Abs(j-0.5) > 0.001 {
|
||||
t.Errorf("expected Jaccard 0.5, got %f", j)
|
||||
}
|
||||
|
||||
// Empty sets
|
||||
j = jaccardSimilarity(nil, nil)
|
||||
if j != 0 {
|
||||
t.Errorf("expected 0 for empty sets, got %f", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_ConfidenceAutoResolve(t *testing.T) {
|
||||
// Setup: NodeX has known neighbors N1, N2, N3 (resolved edges).
|
||||
// CandidateA also has known neighbors N1, N2, N3 (high Jaccard with X).
|
||||
// CandidateB has no known neighbors (Jaccard = 0).
|
||||
// An ambiguous edge X↔prefix "a3" with candidates [A, B] should auto-resolve to A.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "n1111111", Name: "N1"},
|
||||
{PublicKey: "n2222222", Name: "N2"},
|
||||
{PublicKey: "n3333333", Name: "N3"},
|
||||
{PublicKey: "a3001111", Name: "CandidateA"},
|
||||
{PublicKey: "a3002222", Name: "CandidateB"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
// Create resolved edges: X↔N1, X↔N2, X↔N3, A↔N1, A↔N2, A↔N3
|
||||
// Then an ambiguous edge X↔"a300" prefix with 3+ observations.
|
||||
var txs []*StoreTx
|
||||
txID := 1
|
||||
|
||||
// X sends ADVERTs through N1, N2, N3
|
||||
for _, nhop := range []string{"n111", "n222", "n333"} {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["`+nhop+`"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
// CandidateA sends ADVERTs through N1, N2, N3
|
||||
for _, nhop := range []string{"n111", "n222", "n333"} {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3001111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["`+nhop+`"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
// Ambiguous edge: X sends ADVERTs with path[0]="a300" (matches both candidates)
|
||||
// Need 3+ observations for confidence threshold.
|
||||
for i := 0; i < 3; i++ {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["a300"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
store := ngTestStore(nodes, txs)
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// The ambiguous edge X↔a300 should have been resolved to CandidateA
|
||||
neighbors := g.Neighbors("aaaa1111")
|
||||
foundA := false
|
||||
for _, e := range neighbors {
|
||||
other := e.NodeB
|
||||
if e.NodeA != "aaaa1111" {
|
||||
other = e.NodeA
|
||||
}
|
||||
if other == "a3001111" {
|
||||
foundA = true
|
||||
if e.Ambiguous {
|
||||
t.Error("edge should have been resolved (not ambiguous)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundA {
|
||||
t.Error("expected edge X↔CandidateA to be auto-resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_EqualScoresAmbiguous(t *testing.T) {
|
||||
// Two candidates with identical neighbor sets → should NOT auto-resolve.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "n1111111", Name: "N1"},
|
||||
{PublicKey: "a3001111", Name: "CandidateA"},
|
||||
{PublicKey: "a3002222", Name: "CandidateB"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
var txs []*StoreTx
|
||||
txID := 1
|
||||
|
||||
// X↔N1
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
|
||||
// Both candidates have same neighbor (N1)
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3001111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3002222"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
|
||||
// Ambiguous edge with 3+ observations
|
||||
for i := 0; i < 3; i++ {
|
||||
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["a300"]`, nowStr, nil),
|
||||
}))
|
||||
txID++
|
||||
}
|
||||
|
||||
store := ngTestStore(nodes, txs)
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should remain ambiguous
|
||||
var ambigFound bool
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Ambiguous && e.Prefix == "a300" {
|
||||
ambigFound = true
|
||||
}
|
||||
}
|
||||
if !ambigFound {
|
||||
t.Error("expected ambiguous edge to remain unresolved with equal scores")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_ObserverSelfEdgeGuard(t *testing.T) {
|
||||
// Observer's own prefix in path → should NOT create self-edge.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["obs0"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Check no self-edge for observer
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.NodeA == e.NodeB && e.NodeA == "obs00001" {
|
||||
t.Error("self-edge created for observer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_OrphanPrefix(t *testing.T) {
|
||||
// Path contains prefix matching zero nodes → edge recorded as unresolved.
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["ff99"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Should have ambiguous edges with empty candidates.
|
||||
var orphanFound bool
|
||||
for _, e := range g.AllEdges() {
|
||||
if e.Ambiguous && len(e.Candidates) == 0 {
|
||||
orphanFound = true
|
||||
if e.Prefix != "ff99" {
|
||||
t.Errorf("expected prefix ff99, got %s", e.Prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !orphanFound {
|
||||
t.Error("expected orphan prefix edge with empty candidates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_Fresh(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 100, LastSeen: time.Now()}
|
||||
s := e.Score(time.Now())
|
||||
if s < 0.99 || s > 1.0 {
|
||||
t.Errorf("expected score ≈ 1.0, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_Decayed(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 100, LastSeen: time.Now().Add(-7 * 24 * time.Hour)}
|
||||
s := e.Score(time.Now())
|
||||
// 7 days → half-life → ~0.5
|
||||
if math.Abs(s-0.5) > 0.05 {
|
||||
t.Errorf("expected score ≈ 0.5, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_LowCount(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 5, LastSeen: time.Now()}
|
||||
s := e.Score(time.Now())
|
||||
// 5/100 = 0.05
|
||||
if math.Abs(s-0.05) > 0.01 {
|
||||
t.Errorf("expected score ≈ 0.05, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityScore_StaleAndLow(t *testing.T) {
|
||||
e := &NeighborEdge{Count: 5, LastSeen: time.Now().Add(-30 * 24 * time.Hour)}
|
||||
s := e.Score(time.Now())
|
||||
// Very small
|
||||
if s > 0.01 {
|
||||
t.Errorf("expected score ≈ 0, got %f", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_CountAccumulation(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
var txs []*StoreTx
|
||||
for i := 0; i < 5; i++ {
|
||||
txs = append(txs, ngMakeTx(i+1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
|
||||
}))
|
||||
}
|
||||
|
||||
store := ngTestStore(nodes, txs)
|
||||
g := BuildFromStore(store)
|
||||
|
||||
// Check count on X↔R1 edge
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
if e.Count != 5 {
|
||||
t.Errorf("expected count 5, got %d", e.Count)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("X↔R1 edge not found")
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_MultipleObservers(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Obs1"},
|
||||
{PublicKey: "obs00002", Name: "Obs2"},
|
||||
}
|
||||
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
|
||||
ngMakeObs("obs00002", `["r1aa"]`, nowStr, nil),
|
||||
})
|
||||
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
if len(e.Observers) != 2 {
|
||||
t.Errorf("expected 2 observers, got %d", len(e.Observers))
|
||||
}
|
||||
if !e.Observers["obs00001"] || !e.Observers["obs00002"] {
|
||||
t.Error("missing expected observer")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("X↔R1 edge not found")
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_TimeDecayOldObservations(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa"]`, monthAgoStr, nil),
|
||||
})
|
||||
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
|
||||
score := e.Score(time.Now())
|
||||
if score > 0.05 {
|
||||
t.Errorf("expected decayed score < 0.05, got %f", score)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("X↔R1 edge not found")
|
||||
}
|
||||
|
||||
func TestBuildNeighborGraph_ADVERTOnlyConstraint(t *testing.T) {
|
||||
// Non-ADVERT: should NOT create originator↔path[0] edge, only observer↔path[last].
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aaaa1111", Name: "NodeX"},
|
||||
{PublicKey: "r1aabbcc", Name: "R1"},
|
||||
{PublicKey: "r2ddeeff", Name: "R2"},
|
||||
{PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
for _, e := range g.AllEdges() {
|
||||
a, b := e.NodeA, e.NodeB
|
||||
if (a == "aaaa1111" && b == "r1aabbcc") || (a == "r1aabbcc" && b == "aaaa1111") {
|
||||
t.Error("non-ADVERT should NOT produce originator↔path[0] edge")
|
||||
}
|
||||
}
|
||||
|
||||
// Should have Observer↔R2
|
||||
found := false
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing Observer↔R2 edge from non-ADVERT")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborGraph_CacheTTL(t *testing.T) {
|
||||
g := NewNeighborGraph()
|
||||
if !g.IsStale() {
|
||||
t.Error("new graph should be stale")
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.builtAt = time.Now()
|
||||
g.mu.Unlock()
|
||||
if g.IsStale() {
|
||||
t.Error("just-built graph should not be stale")
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.builtAt = time.Now().Add(-2 * neighborGraphTTL)
|
||||
g.mu.Unlock()
|
||||
if !g.IsStale() {
|
||||
t.Error("old graph should be stale")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPerfStatsConcurrentAccess verifies that concurrent writes and reads
|
||||
// to PerfStats do not trigger data races. Run with: go test -race
|
||||
func TestPerfStatsConcurrentAccess(t *testing.T) {
|
||||
ps := NewPerfStats()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 50
|
||||
const iterations = 200
|
||||
|
||||
// Concurrent writers (simulating perfMiddleware)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
ms := float64(j) * 0.5
|
||||
key := "/api/test"
|
||||
if id%2 == 0 {
|
||||
key = "/api/other"
|
||||
}
|
||||
|
||||
ps.mu.Lock()
|
||||
ps.Requests++
|
||||
ps.TotalMs += ms
|
||||
if _, ok := ps.Endpoints[key]; !ok {
|
||||
ps.Endpoints[key] = &EndpointPerf{Recent: make([]float64, 0, 100)}
|
||||
}
|
||||
ep := ps.Endpoints[key]
|
||||
ep.Count++
|
||||
ep.TotalMs += ms
|
||||
if ms > ep.MaxMs {
|
||||
ep.MaxMs = ms
|
||||
}
|
||||
ep.Recent = append(ep.Recent, ms)
|
||||
if len(ep.Recent) > 100 {
|
||||
ep.Recent = ep.Recent[1:]
|
||||
}
|
||||
if ms > 50 {
|
||||
ps.SlowQueries = append(ps.SlowQueries, SlowQuery{
|
||||
Path: key,
|
||||
Ms: ms,
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if len(ps.SlowQueries) > 50 {
|
||||
ps.SlowQueries = ps.SlowQueries[1:]
|
||||
}
|
||||
}
|
||||
ps.mu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent readers (simulating handlePerf / handleHealth)
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
ps.mu.Lock()
|
||||
_ = ps.Requests
|
||||
_ = ps.TotalMs
|
||||
for _, ep := range ps.Endpoints {
|
||||
_ = ep.Count
|
||||
_ = ep.MaxMs
|
||||
c := make([]float64, len(ep.Recent))
|
||||
copy(c, ep.Recent)
|
||||
}
|
||||
s := make([]SlowQuery, len(ps.SlowQueries))
|
||||
copy(s, ps.SlowQueries)
|
||||
ps.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify consistency
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
expectedRequests := int64(goroutines * iterations)
|
||||
if ps.Requests != expectedRequests {
|
||||
t.Errorf("expected %d requests, got %d", expectedRequests, ps.Requests)
|
||||
}
|
||||
if len(ps.Endpoints) == 0 {
|
||||
t.Error("expected endpoints to be populated")
|
||||
}
|
||||
}
|
||||
+88
-32
@@ -38,10 +38,15 @@ type Server struct {
|
||||
statsMu sync.Mutex
|
||||
statsCache *StatsResponse
|
||||
statsCachedAt time.Time
|
||||
|
||||
// Neighbor affinity graph (lazy-built, cached with TTL)
|
||||
neighborMu sync.Mutex
|
||||
neighborGraph *NeighborGraph
|
||||
}
|
||||
|
||||
// PerfStats tracks request performance.
|
||||
type PerfStats struct {
|
||||
mu sync.Mutex
|
||||
Requests int64
|
||||
TotalMs float64
|
||||
Endpoints map[string]*EndpointPerf
|
||||
@@ -127,6 +132,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/nodes/{pubkey}/health", s.handleNodeHealth).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/paths", s.handleNodePaths).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/analytics", s.handleNodeAnalytics).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}/neighbors", s.handleNodeNeighbors).Methods("GET")
|
||||
r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET")
|
||||
r.HandleFunc("/api/nodes", s.handleNodes).Methods("GET")
|
||||
|
||||
@@ -136,8 +142,10 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/analytics/channels", s.handleAnalyticsChannels).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/distance", s.handleAnalyticsDistance).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/hash-sizes", s.handleAnalyticsHashSizes).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/hash-collisions", s.handleAnalyticsHashCollisions).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/subpaths", s.handleAnalyticsSubpaths).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/subpath-detail", s.handleAnalyticsSubpathDetail).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/neighbor-graph", s.handleNeighborGraph).Methods("GET")
|
||||
|
||||
// Other endpoints
|
||||
r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")
|
||||
@@ -161,10 +169,7 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
ms := float64(time.Since(start).Microseconds()) / 1000.0
|
||||
|
||||
s.perfStats.Requests++
|
||||
s.perfStats.TotalMs += ms
|
||||
|
||||
// Normalize key: prefer mux route template (like Node.js req.route.path)
|
||||
// Normalize key outside lock (no shared state needed)
|
||||
key := r.URL.Path
|
||||
if route := mux.CurrentRoute(r); route != nil {
|
||||
if tmpl, err := route.GetPathTemplate(); err == nil {
|
||||
@@ -174,6 +179,11 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
|
||||
if key == r.URL.Path {
|
||||
key = perfHexFallback.ReplaceAllString(key, ":id")
|
||||
}
|
||||
|
||||
s.perfStats.mu.Lock()
|
||||
s.perfStats.Requests++
|
||||
s.perfStats.TotalMs += ms
|
||||
|
||||
if _, ok := s.perfStats.Endpoints[key]; !ok {
|
||||
s.perfStats.Endpoints[key] = &EndpointPerf{Recent: make([]float64, 0, 100)}
|
||||
}
|
||||
@@ -199,6 +209,7 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
|
||||
s.perfStats.SlowQueries = s.perfStats.SlowQueries[1:]
|
||||
}
|
||||
}
|
||||
s.perfStats.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -282,7 +293,12 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) {
|
||||
}, s.cfg.NodeColors, theme.NodeColors)
|
||||
|
||||
themeDark := mergeMap(map[string]interface{}{}, s.cfg.ThemeDark, theme.ThemeDark)
|
||||
typeColors := mergeMap(map[string]interface{}{}, s.cfg.TypeColors, theme.TypeColors)
|
||||
typeColors := mergeMap(map[string]interface{}{
|
||||
"ADVERT": "#22c55e", "GRP_TXT": "#3b82f6", "TXT_MSG": "#f59e0b",
|
||||
"ACK": "#6b7280", "REQUEST": "#a855f7", "RESPONSE": "#06b6d4",
|
||||
"TRACE": "#ec4899", "PATH": "#14b8a6", "ANON_REQ": "#f43f5e",
|
||||
"UNKNOWN": "#6b7280",
|
||||
}, s.cfg.TypeColors, theme.TypeColors)
|
||||
|
||||
var home interface{}
|
||||
if theme.Home != nil {
|
||||
@@ -364,7 +380,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
lastPauseMs = float64(m.PauseNs[(m.NumGC+255)%256]) / 1e6
|
||||
}
|
||||
|
||||
// Build slow queries list
|
||||
// Build slow queries list (copy under lock)
|
||||
s.perfStats.mu.Lock()
|
||||
recentSlow := make([]SlowQuery, 0)
|
||||
sliceEnd := s.perfStats.SlowQueries
|
||||
if len(sliceEnd) > 5 {
|
||||
@@ -373,6 +390,10 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
for _, sq := range sliceEnd {
|
||||
recentSlow = append(recentSlow, sq)
|
||||
}
|
||||
perfRequests := s.perfStats.Requests
|
||||
perfTotalMs := s.perfStats.TotalMs
|
||||
perfSlowCount := len(s.perfStats.SlowQueries)
|
||||
s.perfStats.mu.Unlock()
|
||||
|
||||
writeJSON(w, HealthResponse{
|
||||
Status: "ok",
|
||||
@@ -402,9 +423,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
EstimatedMB: pktEstMB,
|
||||
},
|
||||
Perf: HealthPerfStats{
|
||||
TotalRequests: int(s.perfStats.Requests),
|
||||
AvgMs: safeAvg(s.perfStats.TotalMs, float64(s.perfStats.Requests)),
|
||||
SlowQueries: len(s.perfStats.SlowQueries),
|
||||
TotalRequests: int(perfRequests),
|
||||
AvgMs: safeAvg(perfTotalMs, float64(perfRequests)),
|
||||
SlowQueries: perfSlowCount,
|
||||
RecentSlow: recentSlow,
|
||||
},
|
||||
})
|
||||
@@ -464,22 +485,50 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handlePerf(w http.ResponseWriter, r *http.Request) {
|
||||
// Endpoint performance summary
|
||||
// Copy perfStats under lock to avoid data races
|
||||
s.perfStats.mu.Lock()
|
||||
type epSnapshot struct {
|
||||
path string
|
||||
count int
|
||||
totalMs float64
|
||||
maxMs float64
|
||||
recent []float64
|
||||
}
|
||||
epSnapshots := make([]epSnapshot, 0, len(s.perfStats.Endpoints))
|
||||
for path, ep := range s.perfStats.Endpoints {
|
||||
recentCopy := make([]float64, len(ep.Recent))
|
||||
copy(recentCopy, ep.Recent)
|
||||
epSnapshots = append(epSnapshots, epSnapshot{path, ep.Count, ep.TotalMs, ep.MaxMs, recentCopy})
|
||||
}
|
||||
uptimeSec := int(time.Since(s.perfStats.StartedAt).Seconds())
|
||||
totalRequests := s.perfStats.Requests
|
||||
totalMs := s.perfStats.TotalMs
|
||||
slowQueries := make([]SlowQuery, 0)
|
||||
sliceEnd := s.perfStats.SlowQueries
|
||||
if len(sliceEnd) > 20 {
|
||||
sliceEnd = sliceEnd[len(sliceEnd)-20:]
|
||||
}
|
||||
for _, sq := range sliceEnd {
|
||||
slowQueries = append(slowQueries, sq)
|
||||
}
|
||||
s.perfStats.mu.Unlock()
|
||||
|
||||
// Process snapshots outside lock
|
||||
type epEntry struct {
|
||||
path string
|
||||
data *EndpointStatsResp
|
||||
}
|
||||
var entries []epEntry
|
||||
for path, ep := range s.perfStats.Endpoints {
|
||||
sorted := sortedCopy(ep.Recent)
|
||||
for _, snap := range epSnapshots {
|
||||
sorted := sortedCopy(snap.recent)
|
||||
d := &EndpointStatsResp{
|
||||
Count: ep.Count,
|
||||
AvgMs: safeAvg(ep.TotalMs, float64(ep.Count)),
|
||||
Count: snap.count,
|
||||
AvgMs: safeAvg(snap.totalMs, float64(snap.count)),
|
||||
P50Ms: round(percentile(sorted, 0.5), 1),
|
||||
P95Ms: round(percentile(sorted, 0.95), 1),
|
||||
MaxMs: round(ep.MaxMs, 1),
|
||||
MaxMs: round(snap.maxMs, 1),
|
||||
}
|
||||
entries = append(entries, epEntry{path, d})
|
||||
entries = append(entries, epEntry{snap.path, d})
|
||||
}
|
||||
// Sort by total time spent (count * avg) descending, matching Node.js
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
@@ -520,22 +569,10 @@ func (s *Server) handlePerf(w http.ResponseWriter, r *http.Request) {
|
||||
sqliteStats = &ss
|
||||
}
|
||||
|
||||
uptimeSec := int(time.Since(s.perfStats.StartedAt).Seconds())
|
||||
|
||||
// Convert slow queries
|
||||
slowQueries := make([]SlowQuery, 0)
|
||||
sliceEnd := s.perfStats.SlowQueries
|
||||
if len(sliceEnd) > 20 {
|
||||
sliceEnd = sliceEnd[len(sliceEnd)-20:]
|
||||
}
|
||||
for _, sq := range sliceEnd {
|
||||
slowQueries = append(slowQueries, sq)
|
||||
}
|
||||
|
||||
writeJSON(w, PerfResponse{
|
||||
Uptime: uptimeSec,
|
||||
TotalRequests: s.perfStats.Requests,
|
||||
AvgMs: safeAvg(s.perfStats.TotalMs, float64(s.perfStats.Requests)),
|
||||
TotalRequests: totalRequests,
|
||||
AvgMs: safeAvg(totalMs, float64(totalRequests)),
|
||||
Endpoints: summary,
|
||||
SlowQueries: slowQueries,
|
||||
Cache: perfCS,
|
||||
@@ -559,7 +596,13 @@ func (s *Server) handlePerf(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handlePerfReset(w http.ResponseWriter, r *http.Request) {
|
||||
s.perfStats = NewPerfStats()
|
||||
s.perfStats.mu.Lock()
|
||||
s.perfStats.Requests = 0
|
||||
s.perfStats.TotalMs = 0
|
||||
s.perfStats.Endpoints = make(map[string]*EndpointPerf)
|
||||
s.perfStats.SlowQueries = make([]SlowQuery, 0)
|
||||
s.perfStats.StartedAt = time.Now()
|
||||
s.perfStats.mu.Unlock()
|
||||
writeJSON(w, OkResp{Ok: true})
|
||||
}
|
||||
|
||||
@@ -729,10 +772,11 @@ func (s *Server) handlePacketDetail(w http.ResponseWriter, r *http.Request) {
|
||||
pathHops = []interface{}{}
|
||||
}
|
||||
|
||||
rawHex, _ := packet["raw_hex"].(string)
|
||||
writeJSON(w, PacketDetailResponse{
|
||||
Packet: packet,
|
||||
Path: pathHops,
|
||||
Breakdown: struct{}{},
|
||||
Breakdown: BuildBreakdown(rawHex),
|
||||
ObservationCount: observationCount,
|
||||
Observations: mapSliceToObservations(observations),
|
||||
})
|
||||
@@ -1201,6 +1245,18 @@ func (s *Server) handleAnalyticsHashSizes(w http.ResponseWriter, r *http.Request
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsHashCollisions(w http.ResponseWriter, r *http.Request) {
|
||||
if s.store != nil {
|
||||
region := r.URL.Query().Get("region")
|
||||
writeJSON(w, s.store.GetAnalyticsHashCollisions(region))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"inconsistent_nodes": []interface{}{},
|
||||
"by_size": map[string]interface{}{},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsSubpaths(w http.ResponseWriter, r *http.Request) {
|
||||
if s.store != nil {
|
||||
region := r.URL.Query().Get("region")
|
||||
|
||||
@@ -454,6 +454,35 @@ func TestConfigThemeEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigThemeTypeColorsDefaults(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/config/theme", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
tc, ok := body["typeColors"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected typeColors object in theme response")
|
||||
}
|
||||
expectedTypes := []string{"ADVERT", "GRP_TXT", "TXT_MSG", "ACK", "REQUEST", "RESPONSE", "TRACE", "PATH", "ANON_REQ", "UNKNOWN"}
|
||||
for _, typ := range expectedTypes {
|
||||
val, exists := tc[typ]
|
||||
if !exists {
|
||||
t.Errorf("typeColors missing default for %s", typ)
|
||||
continue
|
||||
}
|
||||
color, ok := val.(string)
|
||||
if !ok || color == "" || color == "#000000" {
|
||||
t.Errorf("typeColors[%s] should be a non-black color, got %v", typ, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigMapEndpoint(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/config/map", nil)
|
||||
@@ -2178,6 +2207,124 @@ func TestGetNodeHashSizeInfoLatestWins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoIgnoreDirectZeroHop(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "dddd111122223333444455556666777788889999aaaabbbbccccddddeeee3333"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'DirIgnore', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"DirIgnore","pubKey":"` + pk + `"}`
|
||||
rawFlood2B := "11" + "40" + "aabb" // FLOOD advert, hashSize=2
|
||||
rawDirect0 := "12" + "00" + "aabb" // DIRECT advert, zero-hop (should be ignored)
|
||||
|
||||
payloadType := 4
|
||||
raws := []string{rawFlood2B, rawDirect0, rawFlood2B, rawDirect0, rawFlood2B}
|
||||
for i, raw := range raws {
|
||||
tx := &StoreTx{
|
||||
ID: 9150 + i,
|
||||
RawHex: raw,
|
||||
Hash: "dirignore" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T0" + strconv.Itoa(i) + ":00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
}
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
ni := info[pk]
|
||||
if ni == nil {
|
||||
t.Fatal("expected hash info for test node")
|
||||
}
|
||||
if ni.HashSize != 2 {
|
||||
t.Errorf("HashSize=%d, want 2 (direct zero-hop adverts should be ignored)", ni.HashSize)
|
||||
}
|
||||
if ni.Inconsistent {
|
||||
t.Error("expected hash_size_inconsistent=false when direct zero-hop adverts are ignored")
|
||||
}
|
||||
if len(ni.AllSizes) != 1 || !ni.AllSizes[2] {
|
||||
t.Errorf("expected only 2-byte size in AllSizes, got %#v", ni.AllSizes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoOnlyDirectZeroHopIgnored(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "eeee111122223333444455556666777788889999aaaabbbbccccddddeeee4444"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'OnlyDirect', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"OnlyDirect","pubKey":"` + pk + `"}`
|
||||
rawDirect0 := "12" + "00" + "aabb"
|
||||
payloadType := 4
|
||||
|
||||
tx := &StoreTx{
|
||||
ID: 9160,
|
||||
RawHex: rawDirect0,
|
||||
Hash: "onlydirect0",
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
if ni := info[pk]; ni != nil {
|
||||
t.Errorf("expected nil hash info for direct zero-hop only node, got HashSize=%d", ni.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoDirectNonZeroHopCounted(t *testing.T) {
|
||||
// A DIRECT advert with non-zero hop count should NOT be skipped —
|
||||
// only zero-hop DIRECT adverts misreport hash size.
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "ffff111122223333444455556666777788889999aaaabbbbccccddddeeee5555"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'DirNonZero', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"DirNonZero","pubKey":"` + pk + `"}`
|
||||
// DIRECT advert (route type 2 = 0x02 in bits 0-1), path byte 0x41:
|
||||
// upper 2 bits = 01 → hash_size = 2, lower 6 bits = 0x01 → hop count 1 (non-zero)
|
||||
rawDirectNonZero := "12" + "41" + "aabb" // header=0x12 (ADVERT|DIRECT), path=0x41
|
||||
payloadType := 4
|
||||
|
||||
tx := &StoreTx{
|
||||
ID: 9170,
|
||||
RawHex: rawDirectNonZero,
|
||||
Hash: "dirnonzero0",
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
ni := info[pk]
|
||||
if ni == nil {
|
||||
t.Fatal("expected hash info for DIRECT non-zero-hop node — it should NOT be skipped")
|
||||
}
|
||||
if ni.HashSize != 2 {
|
||||
t.Errorf("HashSize=%d, want 2 (DIRECT with hop count > 0 should be counted)", ni.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoNoAdverts(t *testing.T) {
|
||||
// A node with no ADVERT packets should not appear in hash size info.
|
||||
db := setupTestDB(t)
|
||||
@@ -2402,3 +2549,628 @@ func min(a, b int) int {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestLatestSeenMaintained verifies that StoreTx.LatestSeen is populated after Load()
|
||||
// and is >= FirstSeen for packets that have observations.
|
||||
func TestLatestSeenMaintained(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
|
||||
if len(store.packets) == 0 {
|
||||
t.Fatal("expected packets in store after Load")
|
||||
}
|
||||
|
||||
for _, tx := range store.packets {
|
||||
if tx.LatestSeen == "" {
|
||||
t.Errorf("packet %s has empty LatestSeen (FirstSeen=%s)", tx.Hash, tx.FirstSeen)
|
||||
continue
|
||||
}
|
||||
// LatestSeen must be >= FirstSeen (string comparison works for RFC3339/ISO8601)
|
||||
if tx.LatestSeen < tx.FirstSeen {
|
||||
t.Errorf("packet %s: LatestSeen %q < FirstSeen %q", tx.Hash, tx.LatestSeen, tx.FirstSeen)
|
||||
}
|
||||
// For packets with observations, LatestSeen must be >= all observation timestamps.
|
||||
for _, obs := range tx.Observations {
|
||||
if obs.Timestamp != "" && obs.Timestamp > tx.LatestSeen {
|
||||
t.Errorf("packet %s: obs.Timestamp %q > LatestSeen %q", tx.Hash, obs.Timestamp, tx.LatestSeen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryGroupedPacketsSortedByLatest verifies that QueryGroupedPackets returns packets
|
||||
// sorted by LatestSeen DESC — i.e. the packet whose most-recent observation is newest
|
||||
// comes first, even if its first_seen is older.
|
||||
func TestQueryGroupedPacketsSortedByLatest(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
// oldFirst: first_seen is old, but observation is very recent.
|
||||
oldFirst := now.Add(-48 * time.Hour).Format(time.RFC3339)
|
||||
// newFirst: first_seen is recent, but observation is old.
|
||||
newFirst := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := now.Add(-5 * time.Minute).Unix()
|
||||
oldEpoch := now.Add(-72 * time.Hour).Unix()
|
||||
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('sortobs', 'Sort Observer', 'TST', ?, '2026-01-01T00:00:00Z', 1)`, now.Format(time.RFC3339))
|
||||
|
||||
// Packet A: old first_seen, but a very recent observation — should sort first.
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('AA01', 'sort_old_first_recent_obs', ?, 1, 2, '{"type":"TXT_MSG","text":"old first"}')`, oldFirst)
|
||||
var idA int64
|
||||
db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='sort_old_first_recent_obs'`).Scan(&idA)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (?, 1, 10.0, -90, '[]', ?)`, idA, recentEpoch)
|
||||
|
||||
// Packet B: newer first_seen, but an old observation — should sort second.
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('BB02', 'sort_new_first_old_obs', ?, 1, 2, '{"type":"TXT_MSG","text":"new first"}')`, newFirst)
|
||||
var idB int64
|
||||
db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='sort_new_first_old_obs'`).Scan(&idB)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (?, 1, 10.0, -90, '[]', ?)`, idB, oldEpoch)
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
result := store.QueryGroupedPackets(PacketQuery{Limit: 50})
|
||||
if result.Total < 2 {
|
||||
t.Fatalf("expected at least 2 packets, got %d", result.Total)
|
||||
}
|
||||
|
||||
// Find the two test packets in the result (may be mixed with other entries).
|
||||
firstHash := ""
|
||||
secondHash := ""
|
||||
for _, p := range result.Packets {
|
||||
h, _ := p["hash"].(string)
|
||||
if h == "sort_old_first_recent_obs" || h == "sort_new_first_old_obs" {
|
||||
if firstHash == "" {
|
||||
firstHash = h
|
||||
} else {
|
||||
secondHash = h
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if firstHash != "sort_old_first_recent_obs" {
|
||||
t.Errorf("expected sort_old_first_recent_obs to appear before sort_new_first_old_obs in sorted results; got first=%q second=%q", firstHash, secondHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryGroupedPacketsCacheReturnsConsistentResult verifies that two rapid successive
|
||||
// calls to QueryGroupedPackets return the same total count and first packet hash.
|
||||
func TestQueryGroupedPacketsCacheReturnsConsistentResult(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
q := PacketQuery{Limit: 50}
|
||||
r1 := store.QueryGroupedPackets(q)
|
||||
r2 := store.QueryGroupedPackets(q)
|
||||
|
||||
if r1.Total != r2.Total {
|
||||
t.Errorf("cache inconsistency: first call total=%d, second call total=%d", r1.Total, r2.Total)
|
||||
}
|
||||
if r1.Total == 0 {
|
||||
t.Fatal("expected non-zero results from QueryGroupedPackets")
|
||||
}
|
||||
h1, _ := r1.Packets[0]["hash"].(string)
|
||||
h2, _ := r2.Packets[0]["hash"].(string)
|
||||
if h1 != h2 {
|
||||
t.Errorf("cache inconsistency: first call first hash=%q, second call first hash=%q", h1, h2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetChannelsCacheReturnsConsistentResult verifies that two rapid successive calls
|
||||
// to GetChannels return the same number of channels with the same names.
|
||||
func TestGetChannelsCacheReturnsConsistentResult(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
r1 := store.GetChannels("")
|
||||
r2 := store.GetChannels("")
|
||||
|
||||
if len(r1) != len(r2) {
|
||||
t.Errorf("cache inconsistency: first call len=%d, second call len=%d", len(r1), len(r2))
|
||||
}
|
||||
if len(r1) == 0 {
|
||||
t.Fatal("expected at least one channel from seedTestData")
|
||||
}
|
||||
|
||||
names1 := make(map[string]bool)
|
||||
for _, ch := range r1 {
|
||||
if n, ok := ch["name"].(string); ok {
|
||||
names1[n] = true
|
||||
}
|
||||
}
|
||||
for _, ch := range r2 {
|
||||
if n, ok := ch["name"].(string); ok {
|
||||
if !names1[n] {
|
||||
t.Errorf("cache inconsistency: channel %q in second result but not first", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetChannelsNotBlockedByLargeLock verifies that GetChannels returns correct channel
|
||||
// data (count and messageCount) after observations have been added — i.e. the lock-copy
|
||||
// pattern works correctly and the JSON unmarshal outside the lock produces valid results.
|
||||
func TestGetChannelsNotBlockedByLargeLock(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
channels := store.GetChannels("")
|
||||
|
||||
// seedTestData inserts one GRP_TXT (payload_type=5) packet with channel "#test".
|
||||
if len(channels) != 1 {
|
||||
t.Fatalf("expected 1 channel, got %d", len(channels))
|
||||
}
|
||||
|
||||
ch := channels[0]
|
||||
name, ok := ch["name"].(string)
|
||||
if !ok || name != "#test" {
|
||||
t.Errorf("expected channel name '#test', got %v", ch["name"])
|
||||
}
|
||||
|
||||
// messageCount should be 1 (one CHAN packet for #test).
|
||||
msgCount, ok := ch["messageCount"].(int)
|
||||
if !ok {
|
||||
// JSON numbers may unmarshal as float64 — but GetChannels returns native Go values.
|
||||
t.Errorf("expected messageCount to be int, got %T (%v)", ch["messageCount"], ch["messageCount"])
|
||||
} else if msgCount != 1 {
|
||||
t.Errorf("expected messageCount=1, got %d", msgCount)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for computeHashCollisions (Issue #416) ---
|
||||
|
||||
func TestAnalyticsHashCollisionsEndpoint(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
// Must have top-level keys
|
||||
if _, ok := body["inconsistent_nodes"]; !ok {
|
||||
t.Error("missing inconsistent_nodes key")
|
||||
}
|
||||
if _, ok := body["by_size"]; !ok {
|
||||
t.Error("missing by_size key")
|
||||
}
|
||||
|
||||
bySize, ok := body["by_size"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("by_size is not a map")
|
||||
}
|
||||
// Must have entries for 1, 2, 3 byte sizes
|
||||
for _, sz := range []string{"1", "2", "3"} {
|
||||
sizeData, ok := bySize[sz].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("by_size[%s] is not a map", sz)
|
||||
continue
|
||||
}
|
||||
stats, ok := sizeData["stats"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("by_size[%s].stats is not a map", sz)
|
||||
continue
|
||||
}
|
||||
if _, ok := stats["total_nodes"]; !ok {
|
||||
t.Errorf("by_size[%s].stats missing total_nodes", sz)
|
||||
}
|
||||
if _, ok := stats["collision_count"]; !ok {
|
||||
t.Errorf("by_size[%s].stats missing collision_count", sz)
|
||||
}
|
||||
// collisions must be an array, not null
|
||||
collisions, ok := sizeData["collisions"].([]interface{})
|
||||
if !ok {
|
||||
t.Errorf("by_size[%s].collisions is not an array", sz)
|
||||
}
|
||||
_ = collisions
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsNoNullArrays(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// JSON must not contain "null" for arrays
|
||||
bodyStr := w.Body.String()
|
||||
if bodyStr == "" {
|
||||
t.Fatal("empty response body")
|
||||
}
|
||||
// inconsistent_nodes should be [] not null
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["inconsistent_nodes"] == nil {
|
||||
t.Error("inconsistent_nodes is null, should be empty array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsRegionParam(t *testing.T) {
|
||||
// Issue #438: region param should be accepted and used for filtering.
|
||||
// With no region observers configured, results should be identical to global.
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
// Request without region
|
||||
req1 := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w1, req1)
|
||||
if w1.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w1.Code)
|
||||
}
|
||||
|
||||
// Request with region param (no observers for this region, so falls back to global)
|
||||
req2 := httptest.NewRequest("GET", "/api/analytics/hash-collisions?region=us-west", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
if w2.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w2.Code)
|
||||
}
|
||||
|
||||
// With no region observers configured, both should return identical results
|
||||
if w1.Body.String() != w2.Body.String() {
|
||||
t.Error("responses differ with/without region param when no region observers configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsOneByteCells(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
bySize := body["by_size"].(map[string]interface{})
|
||||
oneByteData := bySize["1"].(map[string]interface{})
|
||||
|
||||
// 1-byte data should include one_byte_cells for matrix rendering
|
||||
cells, ok := oneByteData["one_byte_cells"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("1-byte data missing one_byte_cells")
|
||||
}
|
||||
// Should have 256 entries (00-FF)
|
||||
if len(cells) != 256 {
|
||||
t.Errorf("expected 256 one_byte_cells entries, got %d", len(cells))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsTwoByteCells(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
bySize := body["by_size"].(map[string]interface{})
|
||||
twoByteData := bySize["2"].(map[string]interface{})
|
||||
|
||||
// 2-byte data should include two_byte_cells for matrix rendering
|
||||
cells, ok := twoByteData["two_byte_cells"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("2-byte data missing two_byte_cells")
|
||||
}
|
||||
// Should have 256 entries (00-FF first-byte groups)
|
||||
if len(cells) != 256 {
|
||||
t.Errorf("expected 256 two_byte_cells entries, got %d", len(cells))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsThreeByteNoMatrix(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
bySize := body["by_size"].(map[string]interface{})
|
||||
threeByteData := bySize["3"].(map[string]interface{})
|
||||
|
||||
// 3-byte data should NOT have one_byte_cells or two_byte_cells
|
||||
if _, ok := threeByteData["one_byte_cells"]; ok {
|
||||
t.Error("3-byte data should not have one_byte_cells")
|
||||
}
|
||||
if _, ok := threeByteData["two_byte_cells"]; ok {
|
||||
t.Error("3-byte data should not have two_byte_cells")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsClassification(t *testing.T) {
|
||||
// Test with seed data — nodes have coordinates, so distance classification should work
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
bySize := body["by_size"].(map[string]interface{})
|
||||
|
||||
// Check that collision entries have required fields
|
||||
for _, sz := range []string{"1", "2", "3"} {
|
||||
sizeData := bySize[sz].(map[string]interface{})
|
||||
collisions := sizeData["collisions"].([]interface{})
|
||||
for i, c := range collisions {
|
||||
entry := c.(map[string]interface{})
|
||||
if _, ok := entry["prefix"]; !ok {
|
||||
t.Errorf("by_size[%s].collisions[%d] missing prefix", sz, i)
|
||||
}
|
||||
if _, ok := entry["classification"]; !ok {
|
||||
t.Errorf("by_size[%s].collisions[%d] missing classification", sz, i)
|
||||
}
|
||||
class := entry["classification"].(string)
|
||||
validClasses := map[string]bool{"local": true, "regional": true, "distant": true, "incomplete": true, "unknown": true}
|
||||
if !validClasses[class] {
|
||||
t.Errorf("by_size[%s].collisions[%d] invalid classification: %s", sz, i, class)
|
||||
}
|
||||
nodes, ok := entry["nodes"].([]interface{})
|
||||
if !ok {
|
||||
t.Errorf("by_size[%s].collisions[%d] missing nodes array", sz, i)
|
||||
}
|
||||
if len(nodes) < 2 {
|
||||
t.Errorf("by_size[%s].collisions[%d] has %d nodes, expected >=2", sz, i, len(nodes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsCacheTTL(t *testing.T) {
|
||||
// Issue #420: collision cache should use dedicated TTL (60s), not rfCacheTTL (15s)
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
if store.collisionCacheTTL != 60*time.Second {
|
||||
t.Errorf("expected collisionCacheTTL=60s, got %v", store.collisionCacheTTL)
|
||||
}
|
||||
if store.rfCacheTTL != 15*time.Second {
|
||||
t.Errorf("expected rfCacheTTL=15s, got %v", store.rfCacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsStatsFields(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
bySize := body["by_size"].(map[string]interface{})
|
||||
|
||||
for _, sz := range []string{"1", "2", "3"} {
|
||||
sizeData := bySize[sz].(map[string]interface{})
|
||||
stats := sizeData["stats"].(map[string]interface{})
|
||||
|
||||
requiredFields := []string{"total_nodes", "nodes_for_byte", "using_this_size", "unique_prefixes", "collision_count", "space_size", "pct_used"}
|
||||
for _, f := range requiredFields {
|
||||
if _, ok := stats[f]; !ok {
|
||||
t.Errorf("by_size[%s].stats missing field: %s", sz, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsEmptyStore(t *testing.T) {
|
||||
// Test with no nodes seeded
|
||||
db := setupTestDB(t)
|
||||
// Don't call seedTestData — empty store
|
||||
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("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
// With no nodes, inconsistent_nodes should be empty array
|
||||
incon := body["inconsistent_nodes"].([]interface{})
|
||||
if len(incon) != 0 {
|
||||
t.Errorf("expected 0 inconsistent nodes, got %d", len(incon))
|
||||
}
|
||||
|
||||
// All collision lists should be empty
|
||||
bySize := body["by_size"].(map[string]interface{})
|
||||
for _, sz := range []string{"1", "2", "3"} {
|
||||
sizeData := bySize[sz].(map[string]interface{})
|
||||
collisions := sizeData["collisions"].([]interface{})
|
||||
if len(collisions) != 0 {
|
||||
t.Errorf("by_size[%s] expected 0 collisions with empty store, got %d", sz, len(collisions))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsWithCollision(t *testing.T) {
|
||||
// Seed two nodes with the same 1-byte prefix to verify collision detection
|
||||
db := setupTestDB(t)
|
||||
// Don't use seedTestData — create minimal data to control hash sizes
|
||||
now := time.Now().UTC()
|
||||
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
// Two nodes with same first byte 'CC', no adverts so hash_size=0 (included in all buckets)
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES ('CC11223344556677', 'Node1', 'repeater', 37.5, -122.0, ?, '2026-01-01T00:00:00Z', 0)`, recent)
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES ('CC99887766554433', 'Node2', 'repeater', 37.51, -122.01, ?, '2026-01-01T00:00:00Z', 0)`, recent)
|
||||
|
||||
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("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
bySize := body["by_size"].(map[string]interface{})
|
||||
oneByteData := bySize["1"].(map[string]interface{})
|
||||
stats := oneByteData["stats"].(map[string]interface{})
|
||||
|
||||
collisionCount := int(stats["collision_count"].(float64))
|
||||
if collisionCount < 1 {
|
||||
t.Errorf("expected at least 1 collision (CC prefix), got %d", collisionCount)
|
||||
}
|
||||
|
||||
// Check the collision entry
|
||||
collisions := oneByteData["collisions"].([]interface{})
|
||||
found := false
|
||||
for _, c := range collisions {
|
||||
entry := c.(map[string]interface{})
|
||||
if entry["prefix"] == "CC" {
|
||||
found = true
|
||||
nodes := entry["nodes"].([]interface{})
|
||||
if len(nodes) < 2 {
|
||||
t.Errorf("expected >=2 nodes for AA collision, got %d", len(nodes))
|
||||
}
|
||||
// Both nodes have coords close together, so classification should be "local"
|
||||
class := entry["classification"].(string)
|
||||
if class != "local" {
|
||||
t.Errorf("expected 'local' classification for nearby nodes, got %s", class)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected collision entry with prefix 'CC'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsShortPublicKey(t *testing.T) {
|
||||
// Nodes with very short public keys should not crash
|
||||
db := setupTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES ('A', 'ShortKey', 'repeater', 0, 0, ?, '2026-01-01T00:00:00Z', 1)`, recent)
|
||||
|
||||
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("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200 even with short public key, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCollisionsMissingCoordinates(t *testing.T) {
|
||||
// Nodes without coordinates should get "incomplete" classification
|
||||
db := setupTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
// Two nodes same prefix, no coordinates
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES ('BB11223344556677', 'NoCoords1', 'repeater', 0, 0, ?, '2026-01-01T00:00:00Z', 1)`, recent)
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES ('BB99887766554433', 'NoCoords2', 'repeater', 0, 0, ?, '2026-01-01T00:00:00Z', 1)`, recent)
|
||||
|
||||
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("GET", "/api/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
bySize := body["by_size"].(map[string]interface{})
|
||||
oneByteData := bySize["1"].(map[string]interface{})
|
||||
collisions := oneByteData["collisions"].([]interface{})
|
||||
|
||||
for _, c := range collisions {
|
||||
entry := c.(map[string]interface{})
|
||||
if entry["prefix"] == "BB" {
|
||||
class := entry["classification"].(string)
|
||||
if class != "incomplete" {
|
||||
t.Errorf("expected 'incomplete' for nodes without coords, got %s", class)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+613
-124
@@ -39,6 +39,7 @@ type StoreTx struct {
|
||||
RSSI *float64
|
||||
PathJSON string
|
||||
Direction string
|
||||
LatestSeen string // max observation timestamp (or FirstSeen if no observations)
|
||||
// Cached parsed fields (set once, read many)
|
||||
parsedPath []string // cached parsePathJSON result
|
||||
pathParsed bool // whether parsedPath has been set
|
||||
@@ -78,13 +79,25 @@ type PacketStore struct {
|
||||
cacheMu sync.Mutex
|
||||
rfCache map[string]*cachedResult // region → cached RF result
|
||||
topoCache map[string]*cachedResult // region → cached topology result
|
||||
hashCache map[string]*cachedResult // region → cached hash-sizes result
|
||||
hashCache map[string]*cachedResult // region → cached hash-sizes result
|
||||
collisionCache map[string]*cachedResult // cached hash-collisions result keyed by region ("" = global)
|
||||
chanCache map[string]*cachedResult // region → cached channels result
|
||||
distCache map[string]*cachedResult // region → cached distance result
|
||||
subpathCache map[string]*cachedResult // params → cached subpaths result
|
||||
rfCacheTTL time.Duration
|
||||
rfCacheTTL time.Duration
|
||||
collisionCacheTTL time.Duration
|
||||
cacheHits int64
|
||||
cacheMisses int64
|
||||
// Short-lived cache for QueryGroupedPackets (avoids repeated full sort)
|
||||
groupedCacheMu sync.Mutex
|
||||
groupedCacheKey string
|
||||
groupedCacheExp time.Time
|
||||
groupedCacheRes *PacketResult
|
||||
// Short-lived cache for GetChannels (avoids repeated full scan + JSON unmarshal)
|
||||
channelsCacheMu sync.Mutex
|
||||
channelsCacheKey string
|
||||
channelsCacheExp time.Time
|
||||
channelsCacheRes []map[string]interface{}
|
||||
// Cached node list + prefix map (rebuilt on demand, shared across analytics)
|
||||
nodeCache []nodeInfo
|
||||
nodePM *prefixMap
|
||||
@@ -118,7 +131,7 @@ type distHopRecord struct {
|
||||
ToPk string
|
||||
Dist float64
|
||||
Type string // "R↔R", "C↔R", "C↔C"
|
||||
SNR interface{}
|
||||
SNR *float64
|
||||
Hash string
|
||||
Timestamp string
|
||||
HourBucket string
|
||||
@@ -161,11 +174,14 @@ func NewPacketStore(db *DB, cfg *PacketStoreConfig) *PacketStore {
|
||||
byPayloadType: make(map[int][]*StoreTx),
|
||||
rfCache: make(map[string]*cachedResult),
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: 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),
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
collisionCacheTTL: 60 * time.Second,
|
||||
spIndex: make(map[string]int, 4096),
|
||||
}
|
||||
if cfg != nil {
|
||||
@@ -233,6 +249,7 @@ func (s *PacketStore) Load() error {
|
||||
RawHex: nullStrVal(rawHex),
|
||||
Hash: hashStr,
|
||||
FirstSeen: nullStrVal(firstSeen),
|
||||
LatestSeen: nullStrVal(firstSeen),
|
||||
RouteType: nullIntPtr(routeType),
|
||||
PayloadType: nullIntPtr(payloadType),
|
||||
DecodedJSON: nullStrVal(decodedJSON),
|
||||
@@ -279,6 +296,9 @@ func (s *PacketStore) Load() error {
|
||||
|
||||
tx.Observations = append(tx.Observations, obs)
|
||||
tx.ObservationCount++
|
||||
if obs.Timestamp > tx.LatestSeen {
|
||||
tx.LatestSeen = obs.Timestamp
|
||||
}
|
||||
|
||||
s.byObsID[oid] = obs
|
||||
|
||||
@@ -349,6 +369,11 @@ func (s *PacketStore) indexByNode(tx *StoreTx) {
|
||||
if tx.DecodedJSON == "" {
|
||||
return
|
||||
}
|
||||
// All three target fields ("pubKey", "destPubKey", "srcPubKey") share the
|
||||
// common suffix "ubKey" — skip JSON parse for packets that have none of them.
|
||||
if !strings.Contains(tx.DecodedJSON, "ubKey") {
|
||||
return
|
||||
}
|
||||
var decoded map[string]interface{}
|
||||
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) != nil {
|
||||
return
|
||||
@@ -416,47 +441,40 @@ func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
|
||||
// QueryGroupedPackets returns transmissions grouped by hash (already 1:1).
|
||||
func (s *PacketStore) QueryGroupedPackets(q PacketQuery) *PacketResult {
|
||||
atomic.AddInt64(&s.queryCount, 1)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if q.Limit <= 0 {
|
||||
q.Limit = 50
|
||||
}
|
||||
|
||||
results := s.filterPackets(q)
|
||||
// Cache key covers all filter dimensions. Empty key = no filters.
|
||||
cacheKey := q.Since + "|" + q.Until + "|" + q.Region + "|" + q.Node + "|" + q.Hash + "|" + q.Observer
|
||||
if q.Type != nil {
|
||||
cacheKey += fmt.Sprintf("|t%d", *q.Type)
|
||||
}
|
||||
if q.Route != nil {
|
||||
cacheKey += fmt.Sprintf("|r%d", *q.Route)
|
||||
}
|
||||
|
||||
// Build grouped output sorted by latest observation DESC
|
||||
// Return cached sorted list if still fresh (3s TTL)
|
||||
s.groupedCacheMu.Lock()
|
||||
if s.groupedCacheRes != nil && s.groupedCacheKey == cacheKey && time.Now().Before(s.groupedCacheExp) {
|
||||
cached := s.groupedCacheRes
|
||||
s.groupedCacheMu.Unlock()
|
||||
return pagePacketResult(cached, q.Offset, q.Limit)
|
||||
}
|
||||
s.groupedCacheMu.Unlock()
|
||||
|
||||
// Build entries under read lock (observer scan needs lock), sort outside it.
|
||||
type groupEntry struct {
|
||||
tx *StoreTx
|
||||
latest string
|
||||
latest map[string]interface{}
|
||||
ts string
|
||||
}
|
||||
entries := make([]groupEntry, len(results))
|
||||
for i, tx := range results {
|
||||
latest := tx.FirstSeen
|
||||
for _, obs := range tx.Observations {
|
||||
if obs.Timestamp > latest {
|
||||
latest = obs.Timestamp
|
||||
}
|
||||
}
|
||||
entries[i] = groupEntry{tx: tx, latest: latest}
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].latest > entries[j].latest
|
||||
})
|
||||
var entries []groupEntry
|
||||
|
||||
total := len(entries)
|
||||
start := q.Offset
|
||||
if start >= total {
|
||||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||||
}
|
||||
end := start + q.Limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
packets := make([]map[string]interface{}, 0, end-start)
|
||||
for _, e := range entries[start:end] {
|
||||
tx := e.tx
|
||||
s.mu.RLock()
|
||||
results := s.filterPackets(q)
|
||||
entries = make([]groupEntry, 0, len(results))
|
||||
for _, tx := range results {
|
||||
observerCount := 0
|
||||
seen := make(map[string]bool)
|
||||
for _, obs := range tx.Observations {
|
||||
@@ -465,26 +483,61 @@ func (s *PacketStore) QueryGroupedPackets(q PacketQuery) *PacketResult {
|
||||
observerCount++
|
||||
}
|
||||
}
|
||||
packets = append(packets, map[string]interface{}{
|
||||
"hash": strOrNil(tx.Hash),
|
||||
"first_seen": strOrNil(tx.FirstSeen),
|
||||
"count": tx.ObservationCount,
|
||||
"observer_count": observerCount,
|
||||
"observation_count": tx.ObservationCount,
|
||||
"latest": strOrNil(e.latest),
|
||||
"observer_id": strOrNil(tx.ObserverID),
|
||||
"observer_name": strOrNil(tx.ObserverName),
|
||||
"path_json": strOrNil(tx.PathJSON),
|
||||
"payload_type": intPtrOrNil(tx.PayloadType),
|
||||
"route_type": intPtrOrNil(tx.RouteType),
|
||||
"raw_hex": strOrNil(tx.RawHex),
|
||||
"decoded_json": strOrNil(tx.DecodedJSON),
|
||||
"snr": floatPtrOrNil(tx.SNR),
|
||||
"rssi": floatPtrOrNil(tx.RSSI),
|
||||
entries = append(entries, groupEntry{
|
||||
ts: tx.LatestSeen,
|
||||
latest: map[string]interface{}{
|
||||
"hash": strOrNil(tx.Hash),
|
||||
"first_seen": strOrNil(tx.FirstSeen),
|
||||
"count": tx.ObservationCount,
|
||||
"observer_count": observerCount,
|
||||
"observation_count": tx.ObservationCount,
|
||||
"latest": strOrNil(tx.LatestSeen),
|
||||
"observer_id": strOrNil(tx.ObserverID),
|
||||
"observer_name": strOrNil(tx.ObserverName),
|
||||
"path_json": strOrNil(tx.PathJSON),
|
||||
"payload_type": intPtrOrNil(tx.PayloadType),
|
||||
"route_type": intPtrOrNil(tx.RouteType),
|
||||
"raw_hex": strOrNil(tx.RawHex),
|
||||
"decoded_json": strOrNil(tx.DecodedJSON),
|
||||
"snr": floatPtrOrNil(tx.SNR),
|
||||
"rssi": floatPtrOrNil(tx.RSSI),
|
||||
},
|
||||
})
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
return &PacketResult{Packets: packets, Total: total}
|
||||
// Sort outside the lock — only touches our local slice.
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].ts > entries[j].ts
|
||||
})
|
||||
|
||||
packets := make([]map[string]interface{}, len(entries))
|
||||
for i, e := range entries {
|
||||
packets[i] = e.latest
|
||||
}
|
||||
|
||||
full := &PacketResult{Packets: packets, Total: len(packets)}
|
||||
|
||||
s.groupedCacheMu.Lock()
|
||||
s.groupedCacheRes = full
|
||||
s.groupedCacheKey = cacheKey
|
||||
s.groupedCacheExp = time.Now().Add(3 * time.Second)
|
||||
s.groupedCacheMu.Unlock()
|
||||
|
||||
return pagePacketResult(full, q.Offset, q.Limit)
|
||||
}
|
||||
|
||||
// pagePacketResult returns a window of a PacketResult without re-allocating the slice.
|
||||
func pagePacketResult(r *PacketResult, offset, limit int) *PacketResult {
|
||||
total := r.Total
|
||||
if offset >= total {
|
||||
return &PacketResult{Packets: []map[string]interface{}{}, Total: total}
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return &PacketResult{Packets: r.Packets[offset:end], Total: total}
|
||||
}
|
||||
|
||||
// GetStoreStats returns aggregate counts (packet data from memory, node/observer from DB).
|
||||
@@ -626,6 +679,60 @@ func (s *PacketStore) GetCacheStatsTyped() CacheStats {
|
||||
}
|
||||
}
|
||||
|
||||
// cacheInvalidation flags indicate what kind of data changed during ingestion.
|
||||
// Used by invalidateCachesFor to selectively clear only affected caches.
|
||||
type cacheInvalidation struct {
|
||||
hasNewObservations bool // new SNR/RSSI data → rfCache
|
||||
hasNewPaths bool // new/changed path data → topoCache, distCache, subpathCache
|
||||
hasNewTransmissions bool // new transmissions → hashCache
|
||||
hasChannelData bool // new GRP_TXT (payload_type 5) → chanCache
|
||||
eviction bool // data removed → all caches
|
||||
}
|
||||
|
||||
// invalidateCachesFor selectively clears only the analytics caches affected
|
||||
// by the kind of data that changed. This avoids the previous behaviour of
|
||||
// wiping every cache on every ingest cycle, which defeated caching under
|
||||
// continuous ingestion (issue #375).
|
||||
func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
if inv.eviction {
|
||||
// Eviction can affect any analytics — clear everything
|
||||
s.rfCache = make(map[string]*cachedResult)
|
||||
s.topoCache = make(map[string]*cachedResult)
|
||||
s.hashCache = make(map[string]*cachedResult)
|
||||
s.collisionCache = make(map[string]*cachedResult)
|
||||
s.chanCache = make(map[string]*cachedResult)
|
||||
s.distCache = make(map[string]*cachedResult)
|
||||
s.subpathCache = make(map[string]*cachedResult)
|
||||
s.channelsCacheMu.Lock()
|
||||
s.channelsCacheRes = nil
|
||||
s.channelsCacheMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if inv.hasNewObservations {
|
||||
s.rfCache = make(map[string]*cachedResult)
|
||||
}
|
||||
if inv.hasNewPaths {
|
||||
s.topoCache = make(map[string]*cachedResult)
|
||||
s.distCache = make(map[string]*cachedResult)
|
||||
s.subpathCache = make(map[string]*cachedResult)
|
||||
}
|
||||
if inv.hasNewTransmissions {
|
||||
s.hashCache = make(map[string]*cachedResult)
|
||||
s.collisionCache = make(map[string]*cachedResult)
|
||||
}
|
||||
if inv.hasChannelData {
|
||||
s.chanCache = make(map[string]*cachedResult)
|
||||
// Also invalidate the separate channels list cache
|
||||
s.channelsCacheMu.Lock()
|
||||
s.channelsCacheRes = nil
|
||||
s.channelsCacheMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// GetPerfStoreStatsTyped returns packet store stats as a typed struct.
|
||||
func (s *PacketStore) GetPerfStoreStatsTyped() PerfPacketStoreStats {
|
||||
s.mu.RLock()
|
||||
@@ -950,6 +1057,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
RawHex: r.rawHex,
|
||||
Hash: r.hash,
|
||||
FirstSeen: r.firstSeen,
|
||||
LatestSeen: r.firstSeen,
|
||||
RouteType: r.routeType,
|
||||
PayloadType: r.payloadType,
|
||||
DecodedJSON: r.decodedJSON,
|
||||
@@ -999,6 +1107,9 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
}
|
||||
tx.Observations = append(tx.Observations, obs)
|
||||
tx.ObservationCount++
|
||||
if obs.Timestamp > tx.LatestSeen {
|
||||
tx.LatestSeen = obs.Timestamp
|
||||
}
|
||||
s.byObsID[oid] = obs
|
||||
if r.observerID != "" {
|
||||
s.byObserver[r.observerID] = append(s.byObserver[r.observerID], obs)
|
||||
@@ -1097,16 +1208,27 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate analytics caches since new data was ingested
|
||||
// Targeted cache invalidation: only clear caches affected by the ingested
|
||||
// data instead of wiping everything on every cycle (fixes #375).
|
||||
if len(result) > 0 {
|
||||
s.cacheMu.Lock()
|
||||
s.rfCache = make(map[string]*cachedResult)
|
||||
s.topoCache = make(map[string]*cachedResult)
|
||||
s.hashCache = make(map[string]*cachedResult)
|
||||
s.chanCache = make(map[string]*cachedResult)
|
||||
s.distCache = make(map[string]*cachedResult)
|
||||
s.subpathCache = make(map[string]*cachedResult)
|
||||
s.cacheMu.Unlock()
|
||||
inv := cacheInvalidation{
|
||||
hasNewTransmissions: len(broadcastTxs) > 0,
|
||||
}
|
||||
for _, tx := range broadcastTxs {
|
||||
if len(tx.Observations) > 0 {
|
||||
inv.hasNewObservations = true
|
||||
}
|
||||
if tx.PayloadType != nil && *tx.PayloadType == 5 {
|
||||
inv.hasChannelData = true
|
||||
}
|
||||
if tx.PathJSON != "" {
|
||||
inv.hasNewPaths = true
|
||||
}
|
||||
if inv.hasNewObservations && inv.hasChannelData && inv.hasNewPaths {
|
||||
break // all flags set, no need to continue
|
||||
}
|
||||
}
|
||||
s.invalidateCachesFor(inv)
|
||||
}
|
||||
|
||||
return result, newMaxID
|
||||
@@ -1230,6 +1352,9 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
}
|
||||
tx.Observations = append(tx.Observations, obs)
|
||||
tx.ObservationCount++
|
||||
if obs.Timestamp > tx.LatestSeen {
|
||||
tx.LatestSeen = obs.Timestamp
|
||||
}
|
||||
s.byObsID[r.obsID] = obs
|
||||
if r.observerID != "" {
|
||||
s.byObserver[r.observerID] = append(s.byObserver[r.observerID], obs)
|
||||
@@ -1314,17 +1439,20 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
}
|
||||
|
||||
if len(updatedTxs) > 0 {
|
||||
// Invalidate analytics caches
|
||||
s.cacheMu.Lock()
|
||||
s.rfCache = make(map[string]*cachedResult)
|
||||
s.topoCache = make(map[string]*cachedResult)
|
||||
s.hashCache = make(map[string]*cachedResult)
|
||||
s.chanCache = make(map[string]*cachedResult)
|
||||
s.distCache = make(map[string]*cachedResult)
|
||||
s.subpathCache = make(map[string]*cachedResult)
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
// analytics caches cleared; no per-cycle log to avoid stdout overhead
|
||||
// Targeted cache invalidation: new observations always affect RF
|
||||
// analytics; topology/distance/subpath caches only if paths changed.
|
||||
// Channel and hash caches are unaffected by observation-only ingestion.
|
||||
hasPathChanges := false
|
||||
for txID, tx := range updatedTxs {
|
||||
if tx.PathJSON != oldPaths[txID] {
|
||||
hasPathChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
s.invalidateCachesFor(cacheInvalidation{
|
||||
hasNewObservations: true,
|
||||
hasNewPaths: hasPathChanges,
|
||||
})
|
||||
}
|
||||
|
||||
return broadcastMaps
|
||||
@@ -1344,6 +1472,20 @@ func (s *PacketStore) MaxTransmissionID() int {
|
||||
return maxID
|
||||
}
|
||||
|
||||
// MaxObservationID returns the highest observation ID in the store.
|
||||
func (s *PacketStore) MaxObservationID() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
maxID := 0
|
||||
for id := range s.byObsID {
|
||||
if id > maxID {
|
||||
maxID = id
|
||||
}
|
||||
}
|
||||
return maxID
|
||||
}
|
||||
|
||||
// --- Internal filter/query helpers ---
|
||||
|
||||
// filterPackets applies PacketQuery filters to the in-memory packet list.
|
||||
@@ -1875,15 +2017,8 @@ func (s *PacketStore) EvictStale() int {
|
||||
log.Printf("[store] Evicted %d packets older than %.0fh (freed ~%.1fMB estimated)",
|
||||
evictCount, s.retentionHours, freedMB)
|
||||
|
||||
// Invalidate analytics caches
|
||||
s.cacheMu.Lock()
|
||||
s.rfCache = make(map[string]*cachedResult)
|
||||
s.topoCache = make(map[string]*cachedResult)
|
||||
s.hashCache = make(map[string]*cachedResult)
|
||||
s.chanCache = make(map[string]*cachedResult)
|
||||
s.distCache = make(map[string]*cachedResult)
|
||||
s.subpathCache = make(map[string]*cachedResult)
|
||||
s.cacheMu.Unlock()
|
||||
// Eviction removes data — all caches may be affected
|
||||
s.invalidateCachesFor(cacheInvalidation{eviction: true})
|
||||
|
||||
// Invalidate hash size cache
|
||||
s.hashSizeInfoMu.Lock()
|
||||
@@ -1986,15 +2121,11 @@ func computeDistancesForTx(tx *StoreTx, nodeByPk map[string]*nodeInfo, repeaterS
|
||||
}
|
||||
|
||||
roundedDist := math.Round(dist*100) / 100
|
||||
var snrVal interface{}
|
||||
if tx.SNR != nil {
|
||||
snrVal = *tx.SNR
|
||||
}
|
||||
hopRecords = append(hopRecords, distHopRecord{
|
||||
FromName: a.Name, FromPk: a.PublicKey,
|
||||
ToName: b.Name, ToPk: b.PublicKey,
|
||||
Dist: roundedDist, Type: hopType,
|
||||
SNR: snrVal, Hash: tx.Hash, Timestamp: tx.FirstSeen,
|
||||
SNR: tx.SNR, Hash: tx.Hash, Timestamp: tx.FirstSeen,
|
||||
HourBucket: hourBucket, tx: tx,
|
||||
})
|
||||
hopDetails = append(hopDetails, distHopDetail{
|
||||
@@ -2048,14 +2179,50 @@ func hasGarbageChars(s string) bool {
|
||||
|
||||
// GetChannels returns channel list from in-memory packets (payload_type 5, decoded type CHAN).
|
||||
func (s *PacketStore) GetChannels(region string) []map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cacheKey := region
|
||||
|
||||
s.channelsCacheMu.Lock()
|
||||
if s.channelsCacheRes != nil && s.channelsCacheKey == cacheKey && time.Now().Before(s.channelsCacheExp) {
|
||||
res := s.channelsCacheRes
|
||||
s.channelsCacheMu.Unlock()
|
||||
return res
|
||||
}
|
||||
s.channelsCacheMu.Unlock()
|
||||
|
||||
type txSnapshot struct {
|
||||
firstSeen string
|
||||
decodedJSON string
|
||||
hasRegion bool
|
||||
}
|
||||
|
||||
// Copy only the fields needed — release the lock before JSON unmarshal.
|
||||
s.mu.RLock()
|
||||
var regionObs map[string]bool
|
||||
if region != "" {
|
||||
regionObs = s.resolveRegionObservers(region)
|
||||
}
|
||||
grpTxts := s.byPayloadType[5]
|
||||
snapshots := make([]txSnapshot, 0, len(grpTxts))
|
||||
for _, tx := range grpTxts {
|
||||
inRegion := true
|
||||
if regionObs != nil {
|
||||
inRegion = false
|
||||
for _, obs := range tx.Observations {
|
||||
if regionObs[obs.ObserverID] {
|
||||
inRegion = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
snapshots = append(snapshots, txSnapshot{
|
||||
firstSeen: tx.FirstSeen,
|
||||
decodedJSON: tx.DecodedJSON,
|
||||
hasRegion: inRegion,
|
||||
})
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// JSON unmarshal outside the lock.
|
||||
type chanInfo struct {
|
||||
Hash string
|
||||
Name string
|
||||
@@ -2071,53 +2238,32 @@ func (s *PacketStore) GetChannels(region string) []map[string]interface{} {
|
||||
Sender string `json:"sender"`
|
||||
}
|
||||
channelMap := map[string]*chanInfo{}
|
||||
|
||||
grpTxts := s.byPayloadType[5]
|
||||
for _, tx := range grpTxts {
|
||||
|
||||
// Region filter: check if any observation is from a regional observer
|
||||
if regionObs != nil {
|
||||
match := false
|
||||
for _, obs := range tx.Observations {
|
||||
if regionObs[obs.ObserverID] {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
for _, snap := range snapshots {
|
||||
if !snap.hasRegion {
|
||||
continue
|
||||
}
|
||||
|
||||
var decoded decodedGrp
|
||||
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) != nil {
|
||||
if json.Unmarshal([]byte(snap.decodedJSON), &decoded) != nil {
|
||||
continue
|
||||
}
|
||||
if decoded.Type != "CHAN" {
|
||||
continue
|
||||
}
|
||||
// Filter out garbage-decrypted channel names/messages (pre-#197 data still in DB)
|
||||
if hasGarbageChars(decoded.Channel) || hasGarbageChars(decoded.Text) {
|
||||
continue
|
||||
}
|
||||
|
||||
channelName := decoded.Channel
|
||||
if channelName == "" {
|
||||
channelName = "unknown"
|
||||
}
|
||||
key := channelName
|
||||
|
||||
ch := channelMap[key]
|
||||
ch := channelMap[channelName]
|
||||
if ch == nil {
|
||||
ch = &chanInfo{
|
||||
Hash: key, Name: channelName,
|
||||
LastActivity: tx.FirstSeen,
|
||||
}
|
||||
channelMap[key] = ch
|
||||
ch = &chanInfo{Hash: channelName, Name: channelName, LastActivity: snap.firstSeen}
|
||||
channelMap[channelName] = ch
|
||||
}
|
||||
ch.MessageCount++
|
||||
if tx.FirstSeen >= ch.LastActivity {
|
||||
ch.LastActivity = tx.FirstSeen
|
||||
if snap.firstSeen >= ch.LastActivity {
|
||||
ch.LastActivity = snap.firstSeen
|
||||
if decoded.Text != "" {
|
||||
idx := strings.Index(decoded.Text, ": ")
|
||||
if idx > 0 {
|
||||
@@ -2140,6 +2286,13 @@ func (s *PacketStore) GetChannels(region string) []map[string]interface{} {
|
||||
"messageCount": ch.MessageCount, "lastActivity": ch.LastActivity,
|
||||
})
|
||||
}
|
||||
|
||||
s.channelsCacheMu.Lock()
|
||||
s.channelsCacheRes = channels
|
||||
s.channelsCacheKey = cacheKey
|
||||
s.channelsCacheExp = time.Now().Add(15 * time.Second)
|
||||
s.channelsCacheMu.Unlock()
|
||||
|
||||
return channels
|
||||
}
|
||||
|
||||
@@ -3633,7 +3786,7 @@ func (s *PacketStore) computeAnalyticsDistance(region string) map[string]interfa
|
||||
"fromName": h.FromName, "fromPk": h.FromPk,
|
||||
"toName": h.ToName, "toPk": h.ToPk,
|
||||
"dist": h.Dist, "type": h.Type,
|
||||
"snr": h.SNR, "hash": h.Hash, "timestamp": h.Timestamp,
|
||||
"snr": floatPtrOrNil(h.SNR), "hash": h.Hash, "timestamp": h.Timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4030,6 +4183,332 @@ type hashSizeNodeInfo struct {
|
||||
Inconsistent bool
|
||||
}
|
||||
|
||||
// --- Hash Collision Analytics ---
|
||||
|
||||
// GetAnalyticsHashCollisions returns pre-computed hash collision analysis.
|
||||
// This moves the O(n²) distance computation from the frontend to the server.
|
||||
func (s *PacketStore) GetAnalyticsHashCollisions(region string) map[string]interface{} {
|
||||
s.cacheMu.Lock()
|
||||
if cached, ok := s.collisionCache[region]; ok && time.Now().Before(cached.expiresAt) {
|
||||
s.cacheHits++
|
||||
s.cacheMu.Unlock()
|
||||
return cached.data
|
||||
}
|
||||
s.cacheMisses++
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
result := s.computeHashCollisions(region)
|
||||
|
||||
s.cacheMu.Lock()
|
||||
s.collisionCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.collisionCacheTTL)}
|
||||
s.cacheMu.Unlock()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// collisionNode is a lightweight node representation for collision analysis.
|
||||
type collisionNode struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
HashSize int `json:"hash_size"`
|
||||
HashSizeInconsistent bool `json:"hash_size_inconsistent"`
|
||||
HashSizesSeen []int `json:"hash_sizes_seen,omitempty"`
|
||||
}
|
||||
|
||||
// collisionEntry represents a prefix collision with pre-computed distances.
|
||||
type collisionEntry struct {
|
||||
Prefix string `json:"prefix"`
|
||||
ByteSize int `json:"byte_size"`
|
||||
Appearances int `json:"appearances"`
|
||||
Nodes []collisionNode `json:"nodes"`
|
||||
MaxDistKm float64 `json:"max_dist_km"`
|
||||
Classification string `json:"classification"`
|
||||
WithCoords int `json:"with_coords"`
|
||||
}
|
||||
|
||||
// prefixCellInfo holds per-prefix-cell data for the matrix view.
|
||||
type prefixCellInfo struct {
|
||||
Nodes []collisionNode `json:"nodes"`
|
||||
}
|
||||
|
||||
// twoByteCellInfo holds per-first-byte-group data for 2-byte matrix.
|
||||
type twoByteCellInfo struct {
|
||||
GroupNodes []collisionNode `json:"group_nodes"`
|
||||
TwoByteMap map[string][]collisionNode `json:"two_byte_map"`
|
||||
MaxCollision int `json:"max_collision"`
|
||||
CollisionCount int `json:"collision_count"`
|
||||
}
|
||||
|
||||
func (s *PacketStore) computeHashCollisions(region string) map[string]interface{} {
|
||||
// Get all nodes from DB
|
||||
nodes := s.getAllNodes()
|
||||
hashInfo := s.GetNodeHashSizeInfo()
|
||||
|
||||
// If region is specified, filter to only nodes seen by regional observers
|
||||
if region != "" {
|
||||
regionObs := s.resolveRegionObservers(region)
|
||||
if regionObs != nil {
|
||||
s.mu.RLock()
|
||||
regionNodePKs := make(map[string]bool)
|
||||
for _, tx := range s.packets {
|
||||
match := false
|
||||
for _, obs := range tx.Observations {
|
||||
if regionObs[obs.ObserverID] {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
// Collect node public keys from advert packets
|
||||
if tx.DecodedJSON != "" {
|
||||
var d map[string]interface{}
|
||||
if json.Unmarshal([]byte(tx.DecodedJSON), &d) == nil {
|
||||
if pk, ok := d["pubKey"].(string); ok && pk != "" {
|
||||
regionNodePKs[pk] = true
|
||||
}
|
||||
if pk, ok := d["public_key"].(string); ok && pk != "" {
|
||||
regionNodePKs[pk] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Include observers themselves as nodes in the region
|
||||
for _, obs := range tx.Observations {
|
||||
if obs.ObserverID != "" {
|
||||
regionNodePKs[obs.ObserverID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Filter nodes to only those seen in the region
|
||||
filtered := make([]nodeInfo, 0, len(regionNodePKs))
|
||||
for _, n := range nodes {
|
||||
if regionNodePKs[n.PublicKey] {
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
}
|
||||
nodes = filtered
|
||||
}
|
||||
}
|
||||
|
||||
// Build collision nodes with hash info
|
||||
var allCNodes []collisionNode
|
||||
for _, n := range nodes {
|
||||
cn := collisionNode{
|
||||
PublicKey: n.PublicKey,
|
||||
Name: n.Name,
|
||||
Role: n.Role,
|
||||
Lat: n.Lat,
|
||||
Lon: n.Lon,
|
||||
}
|
||||
if info, ok := hashInfo[n.PublicKey]; ok && info != nil {
|
||||
cn.HashSize = info.HashSize
|
||||
cn.HashSizeInconsistent = info.Inconsistent
|
||||
if len(info.AllSizes) > 1 {
|
||||
sizes := make([]int, 0, len(info.AllSizes))
|
||||
for sz := range info.AllSizes {
|
||||
sizes = append(sizes, sz)
|
||||
}
|
||||
sort.Ints(sizes)
|
||||
cn.HashSizesSeen = sizes
|
||||
}
|
||||
}
|
||||
allCNodes = append(allCNodes, cn)
|
||||
}
|
||||
|
||||
// Inconsistent nodes
|
||||
var inconsistentNodes []collisionNode
|
||||
for _, cn := range allCNodes {
|
||||
if cn.HashSizeInconsistent {
|
||||
inconsistentNodes = append(inconsistentNodes, cn)
|
||||
}
|
||||
}
|
||||
if inconsistentNodes == nil {
|
||||
inconsistentNodes = make([]collisionNode, 0)
|
||||
}
|
||||
|
||||
// Compute collisions for each byte size (1, 2, 3)
|
||||
collisionsBySize := make(map[string]interface{})
|
||||
for _, bytes := range []int{1, 2, 3} {
|
||||
// Filter nodes relevant to this byte size
|
||||
var nodesForByte []collisionNode
|
||||
for _, cn := range allCNodes {
|
||||
if cn.HashSize == bytes || cn.HashSize == 0 {
|
||||
nodesForByte = append(nodesForByte, cn)
|
||||
}
|
||||
}
|
||||
|
||||
// Build prefix map
|
||||
prefixMap := make(map[string][]collisionNode)
|
||||
for _, cn := range nodesForByte {
|
||||
if len(cn.PublicKey) < bytes*2 {
|
||||
continue
|
||||
}
|
||||
prefix := strings.ToUpper(cn.PublicKey[:bytes*2])
|
||||
prefixMap[prefix] = append(prefixMap[prefix], cn)
|
||||
}
|
||||
|
||||
// Compute collisions with pairwise distances
|
||||
var collisions []collisionEntry
|
||||
for prefix, pnodes := range prefixMap {
|
||||
if len(pnodes) <= 1 {
|
||||
continue
|
||||
}
|
||||
// Pairwise distance
|
||||
var withCoords []collisionNode
|
||||
for _, cn := range pnodes {
|
||||
if cn.Lat != 0 || cn.Lon != 0 {
|
||||
withCoords = append(withCoords, cn)
|
||||
}
|
||||
}
|
||||
var maxDistKm float64
|
||||
classification := "unknown"
|
||||
if len(withCoords) >= 2 {
|
||||
for i := 0; i < len(withCoords); i++ {
|
||||
for j := i + 1; j < len(withCoords); j++ {
|
||||
d := haversineKm(withCoords[i].Lat, withCoords[i].Lon, withCoords[j].Lat, withCoords[j].Lon)
|
||||
if d > maxDistKm {
|
||||
maxDistKm = d
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxDistKm < 50 {
|
||||
classification = "local"
|
||||
} else if maxDistKm < 200 {
|
||||
classification = "regional"
|
||||
} else {
|
||||
classification = "distant"
|
||||
}
|
||||
} else {
|
||||
classification = "incomplete"
|
||||
}
|
||||
collisions = append(collisions, collisionEntry{
|
||||
Prefix: prefix,
|
||||
ByteSize: bytes,
|
||||
Appearances: len(pnodes),
|
||||
Nodes: pnodes,
|
||||
MaxDistKm: maxDistKm,
|
||||
Classification: classification,
|
||||
WithCoords: len(withCoords),
|
||||
})
|
||||
}
|
||||
if collisions == nil {
|
||||
collisions = make([]collisionEntry, 0)
|
||||
}
|
||||
|
||||
// Sort: local first, then regional, distant, incomplete
|
||||
classOrder := map[string]int{"local": 0, "regional": 1, "distant": 2, "incomplete": 3, "unknown": 4}
|
||||
sort.Slice(collisions, func(i, j int) bool {
|
||||
oi, oj := classOrder[collisions[i].Classification], classOrder[collisions[j].Classification]
|
||||
if oi != oj {
|
||||
return oi < oj
|
||||
}
|
||||
return collisions[i].Appearances > collisions[j].Appearances
|
||||
})
|
||||
|
||||
// Stats
|
||||
nodeCount := len(nodesForByte)
|
||||
usingThisSize := 0
|
||||
for _, cn := range allCNodes {
|
||||
if cn.HashSize == bytes {
|
||||
usingThisSize++
|
||||
}
|
||||
}
|
||||
uniquePrefixes := len(prefixMap)
|
||||
collisionCount := len(collisions)
|
||||
var spaceSize int
|
||||
switch bytes {
|
||||
case 1:
|
||||
spaceSize = 256
|
||||
case 2:
|
||||
spaceSize = 65536
|
||||
case 3:
|
||||
spaceSize = 16777216
|
||||
}
|
||||
pctUsed := 0.0
|
||||
if spaceSize > 0 {
|
||||
pctUsed = float64(uniquePrefixes) / float64(spaceSize) * 100
|
||||
}
|
||||
|
||||
// For 1-byte and 2-byte, include the full prefix cell data for matrix rendering
|
||||
var oneByteCells map[string][]collisionNode
|
||||
var twoByteCells map[string]*twoByteCellInfo
|
||||
if bytes == 1 {
|
||||
oneByteCells = make(map[string][]collisionNode)
|
||||
for i := 0; i < 256; i++ {
|
||||
hex := strings.ToUpper(fmt.Sprintf("%02x", i))
|
||||
oneByteCells[hex] = prefixMap[hex]
|
||||
if oneByteCells[hex] == nil {
|
||||
oneByteCells[hex] = make([]collisionNode, 0)
|
||||
}
|
||||
}
|
||||
} else if bytes == 2 {
|
||||
twoByteCells = make(map[string]*twoByteCellInfo)
|
||||
for i := 0; i < 256; i++ {
|
||||
hex := strings.ToUpper(fmt.Sprintf("%02x", i))
|
||||
cell := &twoByteCellInfo{
|
||||
GroupNodes: make([]collisionNode, 0),
|
||||
TwoByteMap: make(map[string][]collisionNode),
|
||||
}
|
||||
twoByteCells[hex] = cell
|
||||
}
|
||||
for _, cn := range nodesForByte {
|
||||
if len(cn.PublicKey) < 4 {
|
||||
continue
|
||||
}
|
||||
firstHex := strings.ToUpper(cn.PublicKey[:2])
|
||||
twoHex := strings.ToUpper(cn.PublicKey[:4])
|
||||
cell := twoByteCells[firstHex]
|
||||
if cell == nil {
|
||||
continue
|
||||
}
|
||||
cell.GroupNodes = append(cell.GroupNodes, cn)
|
||||
cell.TwoByteMap[twoHex] = append(cell.TwoByteMap[twoHex], cn)
|
||||
}
|
||||
for _, cell := range twoByteCells {
|
||||
for _, ns := range cell.TwoByteMap {
|
||||
if len(ns) > 1 {
|
||||
cell.CollisionCount++
|
||||
if len(ns) > cell.MaxCollision {
|
||||
cell.MaxCollision = len(ns)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sizeData := map[string]interface{}{
|
||||
"stats": map[string]interface{}{
|
||||
"total_nodes": len(allCNodes),
|
||||
"nodes_for_byte": nodeCount,
|
||||
"using_this_size": usingThisSize,
|
||||
"unique_prefixes": uniquePrefixes,
|
||||
"collision_count": collisionCount,
|
||||
"space_size": spaceSize,
|
||||
"pct_used": pctUsed,
|
||||
},
|
||||
"collisions": collisions,
|
||||
}
|
||||
if oneByteCells != nil {
|
||||
sizeData["one_byte_cells"] = oneByteCells
|
||||
}
|
||||
if twoByteCells != nil {
|
||||
sizeData["two_byte_cells"] = twoByteCells
|
||||
}
|
||||
collisionsBySize[strconv.Itoa(bytes)] = sizeData
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"inconsistent_nodes": inconsistentNodes,
|
||||
"by_size": collisionsBySize,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNodeHashSizeInfo returns cached per-node hash size data, recomputing at most every 15s.
|
||||
func (s *PacketStore) GetNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||||
const ttl = 15 * time.Second
|
||||
@@ -4063,10 +4542,20 @@ func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||||
if len(tx.RawHex) < 4 {
|
||||
continue
|
||||
}
|
||||
header, err := strconv.ParseUint(tx.RawHex[:2], 16, 8)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
routeType := int(header & 0x03)
|
||||
pathByte, err := strconv.ParseUint(tx.RawHex[2:4], 16, 8)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// DIRECT zero-hop adverts use path byte 0x00 locally and can misreport
|
||||
// multibyte repeater hash mode as 1-byte.
|
||||
if routeType == RouteDirect && (pathByte&0x3F) == 0 {
|
||||
continue
|
||||
}
|
||||
hs := int((pathByte>>6)&0x3) + 1
|
||||
|
||||
var d map[string]interface{}
|
||||
@@ -5003,7 +5492,7 @@ func (s *PacketStore) GetSubpathDetail(rawHops []string) map[string]interface{}
|
||||
observers := map[string]int{}
|
||||
parentPaths := map[string]int{}
|
||||
var matchCount int
|
||||
var firstSeen, lastSeen interface{}
|
||||
var firstSeen, lastSeen string
|
||||
|
||||
for _, tx := range s.packets {
|
||||
hops := txGetParsedPath(tx)
|
||||
@@ -5033,10 +5522,10 @@ func (s *PacketStore) GetSubpathDetail(rawHops []string) map[string]interface{}
|
||||
matchCount++
|
||||
ts := tx.FirstSeen
|
||||
if ts != "" {
|
||||
if firstSeen == nil || ts < firstSeen.(string) {
|
||||
if firstSeen == "" || ts < firstSeen {
|
||||
firstSeen = ts
|
||||
}
|
||||
if lastSeen == nil || ts > lastSeen.(string) {
|
||||
if lastSeen == "" || ts > lastSeen {
|
||||
lastSeen = ts
|
||||
}
|
||||
// Parse hour from timestamp for hourly distribution
|
||||
|
||||
+1
-1
@@ -289,7 +289,7 @@ type PacketTimestampsResponse struct {
|
||||
type PacketDetailResponse struct {
|
||||
Packet interface{} `json:"packet"`
|
||||
Path []interface{} `json:"path"`
|
||||
Breakdown interface{} `json:"breakdown"`
|
||||
Breakdown *Breakdown `json:"breakdown"`
|
||||
ObservationCount int `json:"observation_count"`
|
||||
Observations []ObservationResp `json:"observations,omitempty"`
|
||||
}
|
||||
|
||||
+31
-3
@@ -25,8 +25,9 @@ type Hub struct {
|
||||
|
||||
// Client is a single WebSocket connection.
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
@@ -52,12 +53,28 @@ func (h *Hub) Unregister(c *Client) {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
close(c.send)
|
||||
c.closeOnce.Do(func() { close(c.send) })
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Printf("[ws] client disconnected (%d total)", h.ClientCount())
|
||||
}
|
||||
|
||||
// Close gracefully disconnects all WebSocket clients.
|
||||
func (h *Hub) Close() {
|
||||
h.mu.Lock()
|
||||
for c := range h.clients {
|
||||
c.conn.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseGoingAway, "server shutting down"),
|
||||
time.Now().Add(3*time.Second),
|
||||
)
|
||||
c.closeOnce.Do(func() { close(c.send) })
|
||||
delete(h.clients, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Println("[ws] all clients disconnected")
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all connected clients.
|
||||
func (h *Hub) Broadcast(msg interface{}) {
|
||||
data, err := json.Marshal(msg)
|
||||
@@ -166,6 +183,17 @@ func NewPoller(db *DB, hub *Hub, interval time.Duration) *Poller {
|
||||
func (p *Poller) Start() {
|
||||
lastID := p.db.GetMaxTransmissionID()
|
||||
lastObsID := p.db.GetMaxObservationID()
|
||||
// If the store already loaded data, use its max IDs as a floor.
|
||||
// This prevents replaying the entire DB when the DB query fails
|
||||
// (e.g., corrupted DB returns 0 from COALESCE).
|
||||
if p.store != nil {
|
||||
if storeMax := p.store.MaxTransmissionID(); storeMax > lastID {
|
||||
lastID = storeMax
|
||||
}
|
||||
if storeMaxObs := p.store.MaxObservationID(); storeMaxObs > lastObsID {
|
||||
lastObsID = storeMaxObs
|
||||
}
|
||||
}
|
||||
log.Printf("[poller] starting from transmission ID %d, obs ID %d, interval %v", lastID, lastObsID, p.interval)
|
||||
|
||||
ticker := time.NewTicker(p.interval)
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ git reset --hard origin/master
|
||||
echo "[deploy] Building Docker image..."
|
||||
docker build -t meshcore-analyzer .
|
||||
|
||||
echo "[deploy] Restarting container..."
|
||||
docker stop meshcore-analyzer && docker rm meshcore-analyzer
|
||||
echo "[deploy] Stopping old container (30s grace period)..."
|
||||
docker stop -t 30 meshcore-analyzer && docker rm meshcore-analyzer
|
||||
docker run -d --name meshcore-analyzer \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
|
||||
+4
-2
@@ -15,9 +15,11 @@ git reset --hard "origin/$BRANCH"
|
||||
echo "[staging] Building Docker image..."
|
||||
docker build -t meshcore-analyzer-staging .
|
||||
|
||||
echo "[staging] Restarting container..."
|
||||
docker stop meshcore-staging 2>/dev/null || true
|
||||
echo "[staging] Stopping old container (30s grace period)..."
|
||||
docker stop -t 30 meshcore-staging 2>/dev/null || true
|
||||
docker rm meshcore-staging 2>/dev/null || true
|
||||
|
||||
echo "[staging] Starting new container..."
|
||||
docker run -d --name meshcore-staging \
|
||||
--restart unless-stopped \
|
||||
-p 3001:3000 \
|
||||
|
||||
@@ -9,6 +9,8 @@ services:
|
||||
image: corescope:latest
|
||||
container_name: corescope-prod
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
image: corescope-go:latest
|
||||
container_name: corescope-staging-go
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
@@ -13,6 +13,8 @@ services:
|
||||
image: corescope-go:latest
|
||||
container_name: corescope-staging-go
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
@@ -14,6 +14,8 @@ services:
|
||||
image: corescope:latest
|
||||
container_name: corescope-prod
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
|
||||
@@ -12,6 +12,8 @@ autostart=true
|
||||
autorestart=true
|
||||
startretries=10
|
||||
startsecs=2
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
@@ -24,6 +26,8 @@ autostart=true
|
||||
autorestart=true
|
||||
startretries=10
|
||||
startsecs=2
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
|
||||
@@ -21,6 +21,8 @@ autostart=true
|
||||
autorestart=true
|
||||
startretries=10
|
||||
startsecs=2
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
@@ -33,6 +35,8 @@ autostart=true
|
||||
autorestart=true
|
||||
startretries=10
|
||||
startsecs=2
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
# Customizer Rework Spec
|
||||
|
||||
## Overview
|
||||
|
||||
The current customizer (`public/customize.js`) suffers from fundamental state management issues documented in [issue #284](https://github.com/Kpa-clawbot/CoreScope/issues/284). State is scattered across 7 localStorage keys, CSS updates bypass the data layer, and there's no single source of truth for the effective configuration.
|
||||
|
||||
This spec defines a clean rework based on event-driven state management with a single data flow path. The goal: predictable state, minimal storage footprint, portable config format, and zero ambiguity about which values are active and why.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
These are agreed and final. Do not reinterpret or deviate.
|
||||
|
||||
1. **Three state layers:** server defaults (immutable after fetch), user overrides (delta in localStorage), effective config (computed via merge, never stored directly).
|
||||
2. **Single data flow:** user action → debounce (~300ms) → write delta to localStorage → read back from localStorage → merge with server defaults → apply CSS variables. No shortcuts, no optimistic CSS updates (see Decision #12 for the one exception).
|
||||
3. **One localStorage key:** `cs-theme-overrides` — replaces the current 7 scattered keys (`meshcore-user-theme`, `meshcore-timestamp-mode`, `meshcore-timestamp-timezone`, `meshcore-timestamp-format`, `meshcore-timestamp-custom-format`, `meshcore-heatmap-opacity`, `meshcore-live-heatmap-opacity`).
|
||||
4. **Universal format:** same shape as the server's `ThemeResponse` plus additional keys. Works identically for user export, admin `theme.json`, and user import.
|
||||
5. **User overrides always win** in merge — `merge(serverDefaults, userOverrides)` = effective config.
|
||||
6. **Override indicator:** shown in customizer panel ONLY when override value differs from current server default.
|
||||
7. **No silent pruning:** overrides stay in localStorage until the user explicitly resets them (per-field reset or full reset). The delta may contain values that happen to match current server defaults — that's fine. User intent is preserved; nothing silently disappears.
|
||||
8. **Per-field reset:** remove a single key from the delta → re-merge → re-apply CSS.
|
||||
9. **Full reset:** `localStorage.removeItem('cs-theme-overrides')` → re-merge (effective = server defaults) → re-apply CSS.
|
||||
10. **Export = dump delta object as JSON download. Import = validate shape, write to localStorage, trigger re-merge.**
|
||||
11. **No CSS magic:** CSS variables ONLY update after the localStorage round-trip completes. No optimistic updates (see Decision #12 for the one exception).
|
||||
12. **Color picker optimistic CSS exception:** For continuous inputs (color pickers, sliders), CSS is updated optimistically during `input` events for visual responsiveness. The localStorage write only happens on `change` event (mouseup/blur). On `change`, the full pipeline runs: write → read → merge → apply (which will match the optimistic state). If the user refreshes mid-drag before `change` fires, the change is lost — this is acceptable. This is the ONLY exception to the localStorage-first rule.
|
||||
|
||||
## Dark/Light Mode
|
||||
|
||||
The customizer treats light and dark mode as separate override sections:
|
||||
|
||||
- **`theme`** stores light mode color overrides.
|
||||
- **`themeDark`** stores dark mode color overrides.
|
||||
- When the user changes a color in the customizer, it writes to whichever section matches their current mode: `theme` if light, `themeDark` if dark.
|
||||
- The dark/light mode toggle preference (`meshcore-theme` localStorage key) is **separate** from the delta object. It is a view preference, not a customization — it is not stored in `cs-theme-overrides`.
|
||||
- The customizer UI shows color fields for the currently active mode only. Switching modes re-renders the color fields with values from the matching section.
|
||||
|
||||
## Presets
|
||||
|
||||
The existing preset themes are preserved and flow through the standard pipeline:
|
||||
|
||||
**Available presets:** Default, Ocean, Forest, Sunset, Monochrome.
|
||||
|
||||
**How presets work:**
|
||||
- Clicking a preset writes its values to localStorage via the same pipeline as any other change: preset data → `writeOverrides()` → read back → merge → apply CSS.
|
||||
- Presets are NOT special — they are pre-built delta objects applied through the standard flow.
|
||||
- Each preset contains both `theme` (light) and `themeDark` (dark) sections, plus any other overrides the preset defines (e.g., `nodeColors`).
|
||||
- **"Reset to Default"** = clear all overrides (equivalent to full reset: `localStorage.removeItem('cs-theme-overrides')` → re-merge → apply).
|
||||
|
||||
**Preset data format:** Same shape as the delta object. Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": {
|
||||
"accent": "#0077b6",
|
||||
"navBg": "#03045e",
|
||||
"background": "#f0f7fa"
|
||||
},
|
||||
"themeDark": {
|
||||
"accent": "#48cae4",
|
||||
"navBg": "#03045e",
|
||||
"background": "#0a1929"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Applying a preset **replaces** the entire delta (it's a `writeOverrides(presetData)`, not a merge onto existing overrides). The user can then further customize individual fields on top.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Delta Object Format
|
||||
|
||||
The user override delta is a sparse object — it only contains fields the user has explicitly changed. The shape mirrors the server's `ThemeResponse` (from `/api/config/theme`) plus additional client-only sections:
|
||||
|
||||
```json
|
||||
{
|
||||
"branding": {
|
||||
"siteName": "string — site name override",
|
||||
"tagline": "string — tagline override",
|
||||
"logoUrl": "string — custom logo URL",
|
||||
"faviconUrl": "string — custom favicon URL"
|
||||
},
|
||||
"theme": {
|
||||
"accent": "string — CSS color, light mode accent",
|
||||
"accentHover": "string — CSS color, light mode accent hover",
|
||||
"navBg": "string — CSS color, nav background",
|
||||
"navBg2": "string — CSS color, nav secondary background",
|
||||
"navText": "string — CSS color, nav text",
|
||||
"navTextMuted": "string — CSS color, nav muted text",
|
||||
"background": "string — CSS color, page background",
|
||||
"text": "string — CSS color, body text",
|
||||
"textMuted": "string — CSS color, muted text",
|
||||
"border": "string — CSS color, borders",
|
||||
"surface1": "string — CSS color, surface level 1",
|
||||
"surface2": "string — CSS color, surface level 2",
|
||||
"cardBg": "string — CSS color, card backgrounds",
|
||||
"contentBg": "string — CSS color, content area background",
|
||||
"detailBg": "string — CSS color, detail pane background",
|
||||
"inputBg": "string — CSS color, input backgrounds",
|
||||
"rowStripe": "string — CSS color, alternating row stripe",
|
||||
"rowHover": "string — CSS color, row hover highlight",
|
||||
"selectedBg": "string — CSS color, selected row background",
|
||||
"statusGreen": "string — CSS color, healthy status",
|
||||
"statusYellow": "string — CSS color, degraded status",
|
||||
"statusRed": "string — CSS color, critical status",
|
||||
"font": "string — CSS font-family for body text",
|
||||
"mono": "string — CSS font-family for monospace"
|
||||
},
|
||||
"themeDark": {
|
||||
"/* same keys as theme — dark mode overrides */"
|
||||
},
|
||||
"nodeColors": {
|
||||
"repeater": "string — CSS color",
|
||||
"companion": "string — CSS color",
|
||||
"room": "string — CSS color",
|
||||
"sensor": "string — CSS color",
|
||||
"observer": "string — CSS color"
|
||||
},
|
||||
"typeColors": {
|
||||
"ADVERT": "string — CSS color",
|
||||
"GRP_TXT": "string — CSS color",
|
||||
"TXT_MSG": "string — CSS color",
|
||||
"ACK": "string — CSS color",
|
||||
"REQUEST": "string — CSS color",
|
||||
"RESPONSE": "string — CSS color",
|
||||
"TRACE": "string — CSS color",
|
||||
"PATH": "string — CSS color",
|
||||
"ANON_REQ": "string — CSS color"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "string",
|
||||
"heroSubtitle": "string",
|
||||
"steps": "[array of {emoji, title, description}]",
|
||||
"checklist": "[array of strings]",
|
||||
"footerLinks": "[array of {label, url}]"
|
||||
},
|
||||
"timestamps": {
|
||||
"defaultMode": "string — 'ago' | 'absolute'",
|
||||
"timezone": "string — 'local' | 'utc'",
|
||||
"formatPreset": "string — 'iso' | 'iso-seconds' | 'locale'",
|
||||
"customFormat": "string — custom strftime-style format"
|
||||
},
|
||||
"heatmapOpacity": "number — 0.0 to 1.0",
|
||||
"liveHeatmapOpacity": "number — 0.0 to 1.0"
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- All sections and keys are optional. An empty object `{}` means "no overrides."
|
||||
- The `timestamps`, `heatmapOpacity`, and `liveHeatmapOpacity` keys are client-only extensions — not part of the server's `ThemeResponse`, but included in the universal format for portability.
|
||||
|
||||
### localStorage Key
|
||||
|
||||
**Key:** `cs-theme-overrides`
|
||||
**Value:** JSON string of the delta object above.
|
||||
**Absent key** = no overrides = effective config equals server defaults.
|
||||
|
||||
### Dark/Light Mode Preference
|
||||
|
||||
**Key:** `meshcore-theme`
|
||||
**Value:** `"dark"` or `"light"` (or absent = follow system preference).
|
||||
**This key is NOT part of the delta object.** It controls which mode is active, not which colors are used. The delta stores overrides for both modes independently in `theme` and `themeDark`.
|
||||
|
||||
## Data Flow Diagrams
|
||||
|
||||
### Page Load
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Fetch │ │ Read localStorage │ │ Migration check │
|
||||
│ /api/config/ │ │ cs-theme-overrides│ │ (one-time) │
|
||||
│ theme │ └────────┬─────────┘ └────────┬────────┘
|
||||
└──────┬──────┘ │ │
|
||||
│ │ ┌────────────────────┘
|
||||
▼ ▼ ▼
|
||||
serverDefaults userOverrides (possibly migrated)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ computeEffective(server, userOverrides) │
|
||||
└──────────────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ window.SITE_CONFIG = effective │ ← atomic assignment
|
||||
└──────────────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ applyCSS(effective) │ ← sets CSS vars on :root for current mode
|
||||
└──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ dispatch 'theme-changed' │ ← bare signal, no payload
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### User Change (e.g., picks new accent color)
|
||||
|
||||
```
|
||||
User action (input/click)
|
||||
│
|
||||
▼
|
||||
debounce(300ms)
|
||||
│
|
||||
▼
|
||||
setOverride('theme', 'accent', '#ff0000')
|
||||
│
|
||||
├─► readOverrides() ← read current delta from localStorage
|
||||
│ │
|
||||
│ ▼
|
||||
├─► update delta object ← set delta.theme.accent = '#ff0000'
|
||||
│ │
|
||||
│ ▼
|
||||
├─► writeOverrides(delta) ← serialize & write to localStorage
|
||||
│ │
|
||||
│ ▼
|
||||
├─► readOverrides() ← read BACK from localStorage (round-trip)
|
||||
│ │
|
||||
│ ▼
|
||||
├─► computeEffective(server, delta)
|
||||
│ │
|
||||
│ ▼
|
||||
├─► window.SITE_CONFIG = effective ← atomic assignment
|
||||
│ │
|
||||
│ ▼
|
||||
└─► applyCSS(effective) ← CSS vars updated on :root
|
||||
│
|
||||
▼
|
||||
dispatch 'theme-changed'
|
||||
```
|
||||
|
||||
**Color picker / slider exception:** During continuous `input` events (drag), CSS is updated optimistically (directly setting `--var` on `:root`) without the localStorage round-trip. The full pipeline above only runs on the `change` event (mouseup/blur).
|
||||
|
||||
### Per-Field Reset
|
||||
|
||||
```
|
||||
User clicks reset icon on a field
|
||||
│
|
||||
▼
|
||||
clearOverride('theme', 'accent')
|
||||
│
|
||||
├─► readOverrides()
|
||||
├─► delete delta.theme.accent
|
||||
├─► if delta.theme is empty, delete delta.theme
|
||||
├─► writeOverrides(delta)
|
||||
├─► readOverrides() ← round-trip
|
||||
├─► computeEffective(server, delta)
|
||||
├─► window.SITE_CONFIG = effective
|
||||
└─► applyCSS(effective)
|
||||
│
|
||||
▼
|
||||
dispatch 'theme-changed'
|
||||
```
|
||||
|
||||
### Full Reset
|
||||
|
||||
```
|
||||
User clicks "Reset All"
|
||||
│
|
||||
▼
|
||||
localStorage.removeItem('cs-theme-overrides')
|
||||
│
|
||||
▼
|
||||
computeEffective(server, {}) ← no overrides = server defaults
|
||||
│
|
||||
▼
|
||||
window.SITE_CONFIG = effective
|
||||
│
|
||||
▼
|
||||
applyCSS(effective)
|
||||
│
|
||||
▼
|
||||
dispatch 'theme-changed'
|
||||
```
|
||||
|
||||
### Export
|
||||
|
||||
```
|
||||
User clicks "Export"
|
||||
│
|
||||
▼
|
||||
readOverrides()
|
||||
│
|
||||
▼
|
||||
JSON.stringify(delta, null, 2)
|
||||
│
|
||||
▼
|
||||
trigger download as .json file
|
||||
```
|
||||
|
||||
### Import
|
||||
|
||||
```
|
||||
User selects .json file
|
||||
│
|
||||
▼
|
||||
parse JSON
|
||||
│
|
||||
▼
|
||||
validateShape(parsed) ← check structure, validate values
|
||||
│
|
||||
├─► invalid → show error, abort
|
||||
│
|
||||
▼ valid
|
||||
writeOverrides(parsed)
|
||||
│
|
||||
▼
|
||||
readOverrides() ← round-trip
|
||||
│
|
||||
▼
|
||||
computeEffective(server, delta)
|
||||
│
|
||||
▼
|
||||
window.SITE_CONFIG = effective
|
||||
│
|
||||
▼
|
||||
applyCSS(effective)
|
||||
│
|
||||
▼
|
||||
dispatch 'theme-changed'
|
||||
```
|
||||
|
||||
## Function Signatures
|
||||
|
||||
### `readOverrides() → object`
|
||||
|
||||
Reads `cs-theme-overrides` from localStorage, parses as JSON. Returns empty object `{}` on missing key, parse error, or non-object value. Never throws.
|
||||
|
||||
### `writeOverrides(delta: object) → void`
|
||||
|
||||
Serializes `delta` to JSON and writes to `cs-theme-overrides` in localStorage. If `delta` is empty (`{}`), removes the key entirely.
|
||||
|
||||
**Validation on write:**
|
||||
- Color values must match: `#hex` (3, 4, 6, or 8 digit), `rgb()`, `rgba()`, `hsl()`, `hsla()`, or CSS named colors. Invalid color values are rejected (not written) with `console.warn`.
|
||||
- Numeric values (`heatmapOpacity`, `liveHeatmapOpacity`) must be finite numbers in the range 0–1. Invalid values are rejected with `console.warn`.
|
||||
- Timestamp enum values are validated against known options (`defaultMode`: `'ago'`/`'absolute'`; `timezone`: `'local'`/`'utc'`; `formatPreset`: `'iso'`/`'iso-seconds'`/`'locale'`). Invalid values are rejected with `console.warn`.
|
||||
|
||||
**Quota error handling:**
|
||||
- Wrap `localStorage.setItem` in try/catch.
|
||||
- On `QuotaExceededError`: show a visible warning to the user ("Storage full — changes may not be saved"), log to console.
|
||||
- Do NOT silently swallow the error.
|
||||
|
||||
### `computeEffective(serverConfig: object, userOverrides: object) → object`
|
||||
|
||||
Deep merges `userOverrides` onto `serverConfig`. For each section (e.g., `theme`, `nodeColors`), if `userOverrides` has the section, its keys override the corresponding `serverConfig` keys. Top-level non-object keys (e.g., `heatmapOpacity`) are directly overridden.
|
||||
|
||||
Returns a new object — neither input is mutated.
|
||||
|
||||
**Merge rules:**
|
||||
- Object sections: shallow merge per section (`Object.assign({}, server.theme, user.theme)`)
|
||||
- Array sections (e.g., `home.steps`): full replacement (user array wins entirely, no element-level merge)
|
||||
- Scalar sections (e.g., `heatmapOpacity`): direct replacement
|
||||
|
||||
After computing the effective config, writes it to `window.SITE_CONFIG` atomically (single assignment, not piecemeal mutations).
|
||||
|
||||
### `applyCSS(effectiveConfig: object) → void`
|
||||
|
||||
Maps effective config values to CSS custom properties on `:root`. Behavior:
|
||||
|
||||
1. Reads the current mode (light/dark) from the `meshcore-theme` localStorage key, falling back to system preference (`prefers-color-scheme`).
|
||||
2. Applies the matching section's values: `theme` for light mode, `themeDark` for dark mode.
|
||||
3. Also applies mode-independent values: node colors as `--node-{role}`, type colors as `--type-{name}`, font families as `--font-body` and `--font-mono`.
|
||||
4. Does NOT generate dual CSS rule blocks — only the current mode's values are applied to `:root`.
|
||||
5. On dark/light mode toggle, `applyCSS` is called again to re-apply the correct section.
|
||||
|
||||
Updates the `<style>` element (create if absent, reuse if present). Dispatches a `theme-changed` CustomEvent on `window` after applying.
|
||||
|
||||
### `theme-changed` Event
|
||||
|
||||
- `theme-changed` is a bare `CustomEvent` with no payload (matches current behavior).
|
||||
- After each merge cycle, the effective config is written to `window.SITE_CONFIG` atomically (single assignment).
|
||||
- `window.SITE_CONFIG` is the canonical readable source for effective config throughout the app. All existing listeners that read from `SITE_CONFIG` continue to work without changes.
|
||||
|
||||
### `setOverride(section: string, key: string, value: any) → void`
|
||||
|
||||
Sets a single override. For nested sections (e.g., `section='theme'`, `key='accent'`), sets `delta[section][key] = value`. For top-level scalars (e.g., `section=null`, `key='heatmapOpacity'`), sets `delta[key] = value`.
|
||||
|
||||
Follows the full data flow: read → update → write → read-back → merge → apply CSS → dispatch `theme-changed`. Debounced at ~300ms (the debounce wraps the write-through-to-CSS portion).
|
||||
|
||||
### `clearOverride(section: string, key: string) → void`
|
||||
|
||||
Removes a single key from the delta. If the section becomes empty after removal, removes the section too. Triggers the full data flow (no debounce — resets should feel instant).
|
||||
|
||||
### `migrateOldKeys() → object | null`
|
||||
|
||||
One-time migration. Checks for any of the 7 legacy localStorage keys. If found:
|
||||
1. Reads all legacy values
|
||||
2. Maps them into the new delta format (see Migration Plan)
|
||||
3. Writes the merged delta to `cs-theme-overrides`
|
||||
4. Removes all 7 legacy keys
|
||||
5. Returns the migrated delta
|
||||
|
||||
Returns `null` if no legacy keys found.
|
||||
|
||||
### `validateShape(obj: any) → { valid: boolean, errors: string[] }`
|
||||
|
||||
Validates that an imported object conforms to the expected shape:
|
||||
- Must be a plain object
|
||||
- Top-level keys must be from the known set: `branding`, `theme`, `themeDark`, `nodeColors`, `typeColors`, `home`, `timestamps`, `heatmapOpacity`, `liveHeatmapOpacity`
|
||||
- Section values must be objects (where expected) or correct scalar types
|
||||
- Color values are validated: must match `#hex` (3, 4, 6, or 8 digit), `rgb()`, `rgba()`, `hsl()`, `hsla()`, or CSS named colors
|
||||
- Numeric values (`heatmapOpacity`, `liveHeatmapOpacity`) must be finite numbers in range 0–1
|
||||
- Timestamp enum values validated against known options
|
||||
|
||||
Unknown top-level keys cause a warning but don't fail validation (forward compatibility).
|
||||
|
||||
## Migration Plan
|
||||
|
||||
On first page load, before the normal init flow:
|
||||
|
||||
1. Check if `cs-theme-overrides` already exists → if yes, skip migration.
|
||||
2. Check if ANY of the 7 legacy keys exist in localStorage.
|
||||
3. If legacy keys found, build a delta object using the exact mapping below:
|
||||
|
||||
### Field-by-Field Migration Mapping
|
||||
|
||||
```
|
||||
meshcore-user-theme (JSON) → parse, map directly:
|
||||
.branding → delta.branding
|
||||
.theme → delta.theme
|
||||
.themeDark → delta.themeDark
|
||||
.nodeColors → delta.nodeColors
|
||||
.typeColors → delta.typeColors
|
||||
.home → delta.home
|
||||
(any other keys are dropped)
|
||||
|
||||
meshcore-timestamp-mode → delta.timestamps.defaultMode
|
||||
meshcore-timestamp-timezone → delta.timestamps.timezone
|
||||
meshcore-timestamp-format → delta.timestamps.formatPreset
|
||||
meshcore-timestamp-custom-format → delta.timestamps.customFormat
|
||||
meshcore-heatmap-opacity → delta.heatmapOpacity (parseFloat)
|
||||
meshcore-live-heatmap-opacity → delta.liveHeatmapOpacity (parseFloat)
|
||||
```
|
||||
|
||||
4. Write the assembled delta to `cs-theme-overrides`.
|
||||
5. Delete all 7 legacy keys.
|
||||
6. Continue with normal init.
|
||||
|
||||
**Edge cases:**
|
||||
- If `meshcore-user-theme` contains invalid JSON, skip it (log a warning to console).
|
||||
- If a legacy value is empty string or null, skip that field.
|
||||
- Migration runs exactly once — the presence of `cs-theme-overrides` (even as `{}`) prevents re-migration.
|
||||
|
||||
## `allowCustomFormat` — User Preferences Trump
|
||||
|
||||
The server-side `allowCustomFormat` gate is not enforced client-side. If a user imports a delta with a custom format, it's applied regardless. The server controls what formats are available in the UI (whether the custom format input field is shown), but does not block stored preferences.
|
||||
|
||||
## Override Indicator UX
|
||||
|
||||
In the customizer panel, each field that has an active override (value differs from server default) shows a visual indicator:
|
||||
|
||||
- **Indicator:** A small dot or icon (e.g., `●` or a reset arrow `↺`) adjacent to the field label.
|
||||
- **Color:** Use the accent color to draw attention without being noisy.
|
||||
- **Behavior:** Clicking the indicator resets that single field (calls `clearOverride`).
|
||||
- **Tooltip:** "Reset to server default" or "This value differs from the server default."
|
||||
- **Absence:** Fields matching the server default show no indicator — clean and minimal.
|
||||
|
||||
**Section-level indicator:** If any field in a section (e.g., "Theme Colors") is overridden, the tab/section header shows a count badge (e.g., "Theme Colors (3)").
|
||||
|
||||
**"Reset All" button:** Always visible at bottom of panel. Confirms before executing (`localStorage.removeItem` + re-merge).
|
||||
|
||||
## UX Requirements
|
||||
|
||||
### Browser-Local Banner
|
||||
|
||||
The customizer panel must display a persistent, always-visible notice:
|
||||
|
||||
> **"These settings are saved in your browser only and don't affect other users."**
|
||||
|
||||
This is NOT a tooltip, NOT a dismissible popup — it must be always visible in the panel header or footer area. Users must understand at a glance that their changes are local.
|
||||
|
||||
### Auto-Save Indicator
|
||||
|
||||
Show a persistent status in the customizer panel footer, Google Docs style — subtle but always present:
|
||||
|
||||
- **Default state:** "All changes saved" (muted text)
|
||||
- **During debounce:** "Saving..." (muted text)
|
||||
- **On quota error:** "⚠️ Storage full — changes may not be saved" (red text, persistent until resolved)
|
||||
|
||||
The indicator reflects the actual state of the localStorage write, not just the UI action.
|
||||
|
||||
## Server Compatibility
|
||||
|
||||
The delta format is intentionally shaped to be a valid subset of the server's `theme.json` admin config file. This means:
|
||||
|
||||
- **User export → admin import:** An admin can take a user's exported JSON and drop it into `theme.json` as server defaults. The `timestamps`, `heatmapOpacity`, and `liveHeatmapOpacity` keys are ignored by the current server (it doesn't read them from `theme.json`), but they don't cause errors.
|
||||
- **Admin config → user import:** A `theme.json` file can be imported as user overrides. Unknown server-only keys are ignored by the client.
|
||||
- **Round-trip safe:** Export → import produces identical delta (assuming no server default changes between operations).
|
||||
|
||||
The server's `ThemeResponse` struct currently returns: `branding`, `theme`, `themeDark`, `nodeColors`, `typeColors`, `home`. The client-only extensions (`timestamps`, `heatmapOpacity`, `liveHeatmapOpacity`) are additive — they extend the format without conflicting.
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests (Node.js, no browser required)
|
||||
|
||||
1. **`readOverrides`**
|
||||
- Returns `{}` when key is absent
|
||||
- Returns `{}` when key contains invalid JSON
|
||||
- Returns `{}` when key contains a non-object (string, array, number)
|
||||
- Returns parsed object when key contains valid JSON object
|
||||
|
||||
2. **`writeOverrides`**
|
||||
- Writes serialized JSON to localStorage
|
||||
- Removes key when delta is empty `{}`
|
||||
- Round-trips correctly (write → read = identical object)
|
||||
- Rejects invalid color values with console.warn
|
||||
- Rejects out-of-range numeric values with console.warn
|
||||
- Rejects invalid timestamp enum values with console.warn
|
||||
- Handles QuotaExceededError gracefully (warns user, does not throw)
|
||||
|
||||
3. **`computeEffective`**
|
||||
- Returns server defaults when overrides is `{}`
|
||||
- Overrides a single key in a section
|
||||
- Overrides multiple keys across sections
|
||||
- Does not mutate either input
|
||||
- Handles missing sections in overrides gracefully
|
||||
- Array values (e.g., `home.steps`) are fully replaced, not merged
|
||||
- Top-level scalars (`heatmapOpacity`) are directly replaced
|
||||
|
||||
4. **`setOverride` / `clearOverride`**
|
||||
- Setting a value stores it in the delta
|
||||
- Clearing a key removes it from delta
|
||||
- Clearing the last key in a section removes the section
|
||||
- Full data flow executes (CSS vars updated)
|
||||
|
||||
5. **`migrateOldKeys`**
|
||||
- Migrates all 7 keys correctly using exact field mapping
|
||||
- Handles partial migration (only some keys present)
|
||||
- Handles invalid JSON in `meshcore-user-theme`
|
||||
- Removes all legacy keys after migration
|
||||
- Skips migration if `cs-theme-overrides` already exists
|
||||
- Returns null when no legacy keys found
|
||||
- Drops unknown keys from `meshcore-user-theme`
|
||||
|
||||
6. **`validateShape`**
|
||||
- Accepts valid delta objects
|
||||
- Accepts empty object
|
||||
- Rejects non-objects (string, array, null)
|
||||
- Warns on unknown top-level keys (doesn't reject)
|
||||
- Validates section types (object vs scalar)
|
||||
- Rejects invalid color values
|
||||
- Rejects out-of-range opacity values
|
||||
- Rejects invalid timestamp enum values
|
||||
|
||||
### Browser/E2E Tests (Playwright)
|
||||
|
||||
1. **Customizer opens and shows current values** — fields reflect effective config.
|
||||
2. **Changing a color updates CSS variable** — after debounce, `:root` has new value.
|
||||
3. **Override indicator appears** when value differs from server default.
|
||||
4. **Per-field reset** removes override, reverts to server default, indicator disappears.
|
||||
5. **Full reset** clears all overrides, all fields show server defaults.
|
||||
6. **Export** downloads a JSON file with current delta.
|
||||
7. **Import** applies overrides from uploaded JSON file.
|
||||
8. **Migration** — set legacy keys, reload, verify they're migrated and removed.
|
||||
9. **Preset application** — clicking a preset applies its colors, fields update.
|
||||
10. **Dark/light mode toggle** — switching mode re-applies correct section's CSS vars.
|
||||
11. **Browser-local banner** — verify persistent notice is visible in customizer panel.
|
||||
12. **Auto-save indicator** — verify status text updates during and after changes.
|
||||
|
||||
## What's NOT In Scope
|
||||
|
||||
- **Undo/redo stack** — could be added as P2. For v1, per-field reset to server default is the only revert mechanism.
|
||||
- **Cross-tab synchronization** — two tabs editing simultaneously may clobber each other's changes. Acceptable for v1.
|
||||
- **Server-side timestamp config** (`allowCustomFormat` gate) — remains server-only, not exposed in the customizer delta. The server controls UI availability but does not block stored preferences (see `allowCustomFormat` section above).
|
||||
- **Admin import endpoint** — no server API for uploading `theme.json` via the UI. Admins edit the file directly. Future work.
|
||||
- **Map config overrides** (`mapDefaults.center`, `mapDefaults.zoom`) — separate concern, not part of theme. Future work.
|
||||
- **Geo-filter config** — server-only. Not in scope.
|
||||
- **Per-page layout preferences** (column widths, sort orders) — separate from theming. Future work.
|
||||
@@ -40,7 +40,7 @@ STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
||||
STAGING_COMPOSE_FILE="docker-compose.staging.yml"
|
||||
|
||||
# Build metadata — exported so docker compose build picks them up via args
|
||||
export APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown")
|
||||
export APP_VERSION=$(git describe --tags --match "v*" 2>/dev/null || echo "unknown")
|
||||
export GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
@@ -509,6 +509,24 @@ cmd_setup() {
|
||||
|
||||
log "Docker $(docker --version | grep -oP 'version \K[^ ,]+')"
|
||||
log "Compose: $DC"
|
||||
|
||||
# Default to latest release tag (instead of staying on master)
|
||||
if ! is_done "version_pin"; then
|
||||
git fetch origin --tags --force 2>/dev/null || true
|
||||
local latest_tag
|
||||
latest_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
||||
if [ -n "$latest_tag" ]; then
|
||||
local current_ref
|
||||
current_ref=$(git describe --tags --exact-match 2>/dev/null || echo "")
|
||||
if [ "$current_ref" != "$latest_tag" ]; then
|
||||
info "Pinning to latest release: ${latest_tag}"
|
||||
git checkout "$latest_tag" 2>/dev/null
|
||||
else
|
||||
log "Already on latest release: ${latest_tag}"
|
||||
fi
|
||||
fi
|
||||
mark_done "version_pin"
|
||||
fi
|
||||
|
||||
mark_done "docker"
|
||||
|
||||
@@ -885,14 +903,10 @@ prepare_staging_config() {
|
||||
warn "No production config at ${prod_config} — staging may use defaults."
|
||||
return
|
||||
fi
|
||||
if [ ! -f "$staging_config" ] || [ "$prod_config" -nt "$staging_config" ]; then
|
||||
info "Copying production config to staging..."
|
||||
cp "$prod_config" "$staging_config"
|
||||
sed -i 's/"siteName":\s*"[^"]*"/"siteName": "CoreScope — STAGING"/' "$staging_config"
|
||||
log "Staging config created at ${staging_config} with STAGING site name."
|
||||
else
|
||||
log "Staging config is up to date."
|
||||
fi
|
||||
info "Copying production config to staging..."
|
||||
cp "$prod_config" "$staging_config"
|
||||
sed -i 's/"siteName":\s*"[^"]*"/"siteName": "CoreScope — STAGING"/' "$staging_config"
|
||||
log "Staging config created at ${staging_config} with STAGING site name."
|
||||
# Copy Caddyfile for staging (HTTP-only on staging port)
|
||||
local staging_caddy="$STAGING_DATA/Caddyfile"
|
||||
if [ ! -f "$staging_caddy" ]; then
|
||||
@@ -1167,6 +1181,12 @@ cmd_status() {
|
||||
echo "═══════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Version
|
||||
local current_version
|
||||
current_version=$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
info "Version: ${current_version}"
|
||||
echo ""
|
||||
|
||||
# Production
|
||||
show_container_status "corescope-prod" "Production"
|
||||
echo ""
|
||||
@@ -1294,8 +1314,39 @@ cmd_promote() {
|
||||
# ─── Update ───────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_update() {
|
||||
info "Pulling latest code..."
|
||||
git pull --ff-only
|
||||
local version="${1:-}"
|
||||
|
||||
info "Fetching latest changes and tags..."
|
||||
git fetch origin --tags --force
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
# No arg: checkout latest release tag
|
||||
local latest_tag
|
||||
latest_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
||||
if [ -z "$latest_tag" ]; then
|
||||
err "No release tags found. Use './manage.sh update latest' for tip of master."
|
||||
exit 1
|
||||
fi
|
||||
info "Checking out latest release: ${latest_tag}"
|
||||
git checkout "$latest_tag" || { err "Failed to checkout tag '${latest_tag}'."; exit 1; }
|
||||
elif [ "$version" = "latest" ]; then
|
||||
# Explicit opt-in to bleeding edge (tip of master)
|
||||
# Note: this creates a detached HEAD at origin/master, which is intentional —
|
||||
# we want a read-only snapshot of upstream, not a local tracking branch.
|
||||
info "Checking out tip of master (detached HEAD at origin/master)..."
|
||||
git checkout origin/master || { err "Failed to checkout origin/master."; exit 1; }
|
||||
else
|
||||
# Specific tag requested
|
||||
if ! git tag -l "$version" | grep -q .; then
|
||||
err "Tag '${version}' not found."
|
||||
echo ""
|
||||
echo " Available releases:"
|
||||
git tag -l 'v*' --sort=-v:refname | head -10 | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
info "Checking out version: ${version}"
|
||||
git checkout "$version" || { err "Failed to checkout '${version}'."; exit 1; }
|
||||
fi
|
||||
|
||||
migrate_config auto
|
||||
|
||||
@@ -1306,6 +1357,10 @@ cmd_update() {
|
||||
dc_prod up -d --force-recreate prod
|
||||
|
||||
log "Updated and restarted. Data preserved."
|
||||
# Show current version
|
||||
local current
|
||||
current=$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)
|
||||
log "Running version: ${current}"
|
||||
}
|
||||
|
||||
# ─── Backup ───────────────────────────────────────────────────────────────
|
||||
@@ -1515,7 +1570,7 @@ cmd_help() {
|
||||
echo " logs [prod|staging] [N] Follow logs (default: prod, last 100 lines)"
|
||||
echo ""
|
||||
printf '%b\n' " ${BOLD}Maintain${NC}"
|
||||
echo " update Pull latest code, rebuild, restart (keeps data)"
|
||||
echo " update [version] Update to version (no arg=latest tag, 'latest'=master tip, or e.g. v3.1.0)"
|
||||
echo " promote Promote staging → production (backup + restart)"
|
||||
echo " backup [dir] Full backup: database + config + theme"
|
||||
echo " restore <d> Restore from backup dir or .db file"
|
||||
@@ -1534,7 +1589,7 @@ case "${1:-help}" in
|
||||
restart) cmd_restart "$2" ;;
|
||||
status) cmd_status ;;
|
||||
logs) cmd_logs "$2" "$3" ;;
|
||||
update) cmd_update ;;
|
||||
update) cmd_update "$2" ;;
|
||||
promote) cmd_promote ;;
|
||||
backup) cmd_backup "$2" ;;
|
||||
restore) cmd_restore "$2" ;;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meshcore-analyzer",
|
||||
"version": "3.1.0",
|
||||
"version": "0.0.0-use-git-tags",
|
||||
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
+159
-296
@@ -143,13 +143,14 @@
|
||||
_analyticsData = {};
|
||||
const rqs = RegionFilter.regionQueryString();
|
||||
const sep = rqs ? '?' + rqs.slice(1) : '';
|
||||
const [hashData, rfData, topoData, chanData] = await Promise.all([
|
||||
const [hashData, rfData, topoData, chanData, collisionData] = await Promise.all([
|
||||
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 };
|
||||
_analyticsData = { hashData, rfData, topoData, chanData, collisionData };
|
||||
renderTab(_currentTab);
|
||||
} catch (e) {
|
||||
document.getElementById('analyticsContent').innerHTML =
|
||||
@@ -166,7 +167,7 @@
|
||||
case 'topology': renderTopology(el, d.topoData); break;
|
||||
case 'channels': renderChannels(el, d.chanData); break;
|
||||
case 'hashsizes': renderHashSizes(el, d.hashData); break;
|
||||
case 'collisions': await renderCollisionTab(el, d.hashData); break;
|
||||
case 'collisions': await renderCollisionTab(el, d.hashData, d.collisionData); break;
|
||||
case 'subpaths': await renderSubpaths(el); break;
|
||||
case 'nodes': await renderNodesTab(el); break;
|
||||
case 'distance': await renderDistanceTab(el); break;
|
||||
@@ -943,7 +944,7 @@
|
||||
`;
|
||||
}
|
||||
|
||||
async function renderCollisionTab(el, data) {
|
||||
async function renderCollisionTab(el, data, collisionData) {
|
||||
el.innerHTML = `
|
||||
<nav id="hashIssuesToc" style="display:flex;gap:12px;margin-bottom:12px;font-size:13px;flex-wrap:wrap">
|
||||
<a href="#/analytics?tab=collisions§ion=inconsistentHashSection" style="color:var(--accent)">⚠️ Inconsistent Sizes</a>
|
||||
@@ -980,11 +981,9 @@
|
||||
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading…</div></div>
|
||||
</div>
|
||||
`;
|
||||
let allNodes = [];
|
||||
try { const nd = await api('/nodes?limit=2000' + RegionFilter.regionQueryString(), { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
|
||||
|
||||
// Render inconsistent hash sizes
|
||||
const inconsistent = allNodes.filter(n => n.hash_size_inconsistent);
|
||||
// Use pre-computed collision data from server (no more /nodes?limit=2000 fetch)
|
||||
const cData = collisionData || { inconsistent_nodes: [], by_size: {} };
|
||||
const inconsistent = cData.inconsistent_nodes || [];
|
||||
const ihEl = document.getElementById('inconsistentHashList');
|
||||
if (ihEl) {
|
||||
if (!inconsistent.length) {
|
||||
@@ -1013,10 +1012,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Repeaters are confirmed routing nodes; null-role nodes may also route (possible conflict)
|
||||
const repeaterNodes = allNodes.filter(n => n.role === 'repeater');
|
||||
const nullRoleNodes = allNodes.filter(n => !n.role);
|
||||
const routingNodes = [...repeaterNodes, ...nullRoleNodes];
|
||||
// Repeaters and routing nodes no longer needed — collision data is server-computed
|
||||
|
||||
let currentBytes = 1;
|
||||
function refreshHashViews(bytes) {
|
||||
@@ -1037,11 +1033,11 @@
|
||||
else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.';
|
||||
else matrixDesc.textContent = '3-byte prefix space is too large to visualize as a matrix — collision table is shown below.';
|
||||
}
|
||||
renderHashMatrix(data.topHops, routingNodes, bytes, allNodes);
|
||||
renderHashMatrixFromServer(cData.by_size[String(bytes)], bytes);
|
||||
// Hide collision risk card for 3-byte — stats are shown in the matrix panel
|
||||
const riskCard = document.getElementById('collisionRiskSection');
|
||||
if (riskCard) riskCard.style.display = bytes === 3 ? 'none' : '';
|
||||
if (bytes !== 3) renderCollisions(data.topHops, routingNodes, bytes);
|
||||
if (bytes !== 3) renderCollisionsFromServer(cData.by_size[String(bytes)], bytes);
|
||||
}
|
||||
|
||||
// Wire up selector
|
||||
@@ -1113,92 +1109,65 @@
|
||||
el.addEventListener('mouseleave', hideMatrixTip);
|
||||
}
|
||||
|
||||
// Pure data helpers — extracted for testability
|
||||
// --- Shared helpers for hash matrix rendering ---
|
||||
|
||||
function buildOneBytePrefixMap(nodes) {
|
||||
const map = {};
|
||||
for (let i = 0; i < 256; i++) map[i.toString(16).padStart(2, '0').toUpperCase()] = [];
|
||||
for (const n of nodes) {
|
||||
const hex = n.public_key.slice(0, 2).toUpperCase();
|
||||
if (map[hex]) map[hex].push(n);
|
||||
}
|
||||
return map;
|
||||
function hashStatCardsHtml(totalNodes, usingCount, sizeLabel, spaceSize, usedCount, collisionCount) {
|
||||
const pct = spaceSize > 0 && usedCount > 0 ? ((usedCount / spaceSize) * 100) : 0;
|
||||
const pctStr = spaceSize > 65536 ? pct.toFixed(6) : spaceSize > 256 ? pct.toFixed(3) : pct.toFixed(1);
|
||||
const spaceLabel = spaceSize >= 1e6 ? (spaceSize / 1e6).toFixed(1) + 'M' : spaceSize.toLocaleString();
|
||||
return `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Nodes tracked</div>
|
||||
<div class="analytics-stat-value">${totalNodes.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Using ${sizeLabel} ID</div>
|
||||
<div class="analytics-stat-value">${usingCount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Prefix space used</div>
|
||||
<div class="analytics-stat-value" style="font-size:16px">${pctStr}%</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">${usedCount > 256 ? usedCount + ' of ' : 'of '}${spaceLabel} possible</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${collisionCount > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">Prefix collisions</div>
|
||||
<div class="analytics-stat-value" style="color:${collisionCount > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisionCount}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildTwoBytePrefixInfo(nodes) {
|
||||
const info = {};
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const h = i.toString(16).padStart(2, '0').toUpperCase();
|
||||
info[h] = { groupNodes: [], twoByteMap: {}, maxCollision: 0, collisionCount: 0 };
|
||||
function hashMatrixGridHtml(nibbles, cellSize, headerSize, cellDataFn) {
|
||||
let html = `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||||
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||||
html += '</tr>';
|
||||
for (let hi = 0; hi < 16; hi++) {
|
||||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||||
for (let lo = 0; lo < 16; lo++) {
|
||||
html += cellDataFn(nibbles[hi] + nibbles[lo], cellSize);
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
for (const n of nodes) {
|
||||
const firstHex = n.public_key.slice(0, 2).toUpperCase();
|
||||
const twoHex = n.public_key.slice(0, 4).toUpperCase();
|
||||
const entry = info[firstHex];
|
||||
if (!entry) continue;
|
||||
entry.groupNodes.push(n);
|
||||
if (!entry.twoByteMap[twoHex]) entry.twoByteMap[twoHex] = [];
|
||||
entry.twoByteMap[twoHex].push(n);
|
||||
}
|
||||
for (const entry of Object.values(info)) {
|
||||
const collisions = Object.values(entry.twoByteMap).filter(v => v.length > 1);
|
||||
entry.collisionCount = collisions.length;
|
||||
entry.maxCollision = collisions.length ? Math.max(...collisions.map(v => v.length)) : 0;
|
||||
}
|
||||
return info;
|
||||
html += '</table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildCollisionHops(allNodes, bytes) {
|
||||
const map = {};
|
||||
for (const n of allNodes) {
|
||||
const p = n.public_key.slice(0, bytes * 2).toUpperCase();
|
||||
if (!map[p]) map[p] = { hex: p, count: 0, size: bytes };
|
||||
map[p].count++;
|
||||
}
|
||||
return Object.values(map).filter(h => h.count > 1);
|
||||
function hashMatrixLegendHtml(labels) {
|
||||
return `<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
||||
${labels.map(l => `<span><span class="legend-swatch ${l.cls}"${l.style ? ' style="'+l.style+'"' : ''}></span> ${l.text}</span>`).join('\n')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHashMatrix(topHops, allNodes, bytes, totalNodes) {
|
||||
bytes = bytes || 1;
|
||||
totalNodes = totalNodes || allNodes;
|
||||
function renderHashMatrixFromServer(sizeData, bytes) {
|
||||
const el = document.getElementById('hashMatrix');
|
||||
if (!sizeData) { el.innerHTML = '<div class="text-muted">No data</div>'; return; }
|
||||
const stats = sizeData.stats || {};
|
||||
const totalNodes = stats.total_nodes || 0;
|
||||
|
||||
// 3-byte: show a summary panel instead of a matrix
|
||||
if (bytes === 3) {
|
||||
const total = totalNodes.length;
|
||||
const threeByteNodes = allNodes.filter(n => n.hash_size === 3).length;
|
||||
const nodesForByte = allNodes.filter(n => n.hash_size === 3 || !n.hash_size);
|
||||
const prefixMap = {};
|
||||
for (const n of nodesForByte) {
|
||||
const p = n.public_key.slice(0, 6).toUpperCase();
|
||||
if (!prefixMap[p]) prefixMap[p] = 0;
|
||||
prefixMap[p]++;
|
||||
}
|
||||
const uniquePrefixes = Object.keys(prefixMap).length;
|
||||
const collisions = Object.values(prefixMap).filter(c => c > 1).length;
|
||||
const spaceSize = 16777216; // 2^24
|
||||
const pct = uniquePrefixes > 0 ? ((uniquePrefixes / spaceSize) * 100).toFixed(6) : '0';
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Nodes tracked</div>
|
||||
<div class="analytics-stat-value">${total.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Using 3-byte ID</div>
|
||||
<div class="analytics-stat-value">${threeByteNodes.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Prefix space used</div>
|
||||
<div class="analytics-stat-value" style="font-size:16px">${pct}%</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 16.7M possible</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${collisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">Prefix collisions</div>
|
||||
<div class="analytics-stat-value" style="color:${collisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisions}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>`;
|
||||
el.innerHTML = hashStatCardsHtml(totalNodes, stats.using_this_size || 0, '3-byte', 16777216, stats.unique_prefixes || 0, stats.collision_count || 0) +
|
||||
`<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1207,41 +1176,14 @@
|
||||
const headerSize = 24;
|
||||
|
||||
if (bytes === 1) {
|
||||
const nodesForByte = allNodes.filter(n => n.hash_size === 1 || !n.hash_size);
|
||||
const prefixNodes = buildOneBytePrefixMap(nodesForByte);
|
||||
const oneByteCount = allNodes.filter(n => n.hash_size === 1).length;
|
||||
const oneUsed = Object.values(prefixNodes).filter(v => v.length > 0).length;
|
||||
const oneCollisions = Object.values(prefixNodes).filter(v => v.length > 1).length;
|
||||
const onePct = ((oneUsed / 256) * 100).toFixed(1);
|
||||
const oneByteCells = sizeData.one_byte_cells || {};
|
||||
const oneByteCount = stats.using_this_size || 0;
|
||||
const oneUsed = Object.values(oneByteCells).filter(v => v.length > 0).length;
|
||||
const oneCollisions = Object.values(oneByteCells).filter(v => v.length > 1).length;
|
||||
|
||||
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Nodes tracked</div>
|
||||
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Using 1-byte ID</div>
|
||||
<div class="analytics-stat-value">${oneByteCount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Prefix space used</div>
|
||||
<div class="analytics-stat-value" style="font-size:16px">${onePct}%</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 256 possible</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">Prefix collisions</div>
|
||||
<div class="analytics-stat-value" style="color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${oneCollisions}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||||
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||||
html += '</tr>';
|
||||
for (let hi = 0; hi < 16; hi++) {
|
||||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||||
for (let lo = 0; lo < 16; lo++) {
|
||||
const hex = nibbles[hi] + nibbles[lo];
|
||||
const nodes = prefixNodes[hex] || [];
|
||||
let html = hashStatCardsHtml(totalNodes, oneByteCount, '1-byte', 256, oneUsed, oneCollisions);
|
||||
html += hashMatrixGridHtml(nibbles, cellSize, headerSize, (hex, cs) => {
|
||||
const nodes = oneByteCells[hex] || [];
|
||||
const count = nodes.length;
|
||||
const repeaterCount = nodes.filter(n => n.role === 'repeater').length;
|
||||
const isCollision = count >= 2 && repeaterCount >= 2;
|
||||
@@ -1259,18 +1201,15 @@
|
||||
: isPossible
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — POSSIBLE CONFLICT</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`
|
||||
: `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — COLLISION</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`;
|
||||
html += `<td class="hash-cell ${cellClass}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip1.replace(/"/g,'"')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${count >= 2 ? '700' : '400'}">${hex}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table></div>';
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
|
||||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
||||
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> Available</span>
|
||||
<span><span class="legend-swatch hash-cell-taken"></span> One node</span>
|
||||
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
|
||||
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
|
||||
</div>`;
|
||||
return `<td class="hash-cell ${cellClass}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip1.replace(/"/g,'"')}" style="width:${cs}px;height:${cs}px;text-align:center;${bgStyle}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${count >= 2 ? '700' : '400'}">${hex}</td>`;
|
||||
});
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>`;
|
||||
html += hashMatrixLegendHtml([
|
||||
{cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'Available'},
|
||||
{cls: 'hash-cell-taken', text: 'One node'},
|
||||
{cls: 'hash-cell-possible', text: 'Possible conflict'},
|
||||
{cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'}
|
||||
]);
|
||||
el.innerHTML = html;
|
||||
|
||||
initMatrixTooltip(el);
|
||||
@@ -1278,7 +1217,7 @@
|
||||
el.querySelectorAll('.hash-active').forEach(td => {
|
||||
td.addEventListener('click', () => {
|
||||
const hex = td.dataset.hex.toUpperCase();
|
||||
const matches = prefixNodes[hex] || [];
|
||||
const matches = oneByteCells[hex] || [];
|
||||
const detail = document.getElementById('hashDetail');
|
||||
if (!matches.length) { detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`; return; }
|
||||
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
|
||||
@@ -1293,47 +1232,17 @@
|
||||
});
|
||||
|
||||
} else if (bytes === 2) {
|
||||
// 2-byte mode: 16×16 grid of first-byte groups
|
||||
const nodesForByte = allNodes.filter(n => n.hash_size === 2 || !n.hash_size);
|
||||
const firstByteInfo = buildTwoBytePrefixInfo(nodesForByte);
|
||||
const twoByteCells = sizeData.two_byte_cells || {};
|
||||
const twoByteCount = stats.using_this_size || 0;
|
||||
const uniqueTwoBytePrefixes = stats.unique_prefixes || 0;
|
||||
const twoCollisions = Object.values(twoByteCells).filter(v => v.collision_count > 0).length;
|
||||
|
||||
const twoByteCount = allNodes.filter(n => n.hash_size === 2).length;
|
||||
const uniqueTwoBytePrefixes = new Set(nodesForByte.map(n => n.public_key.slice(0, 4).toUpperCase())).size;
|
||||
const twoCollisions = Object.values(firstByteInfo).filter(v => v.collisionCount > 0).length;
|
||||
const twoPct = ((uniqueTwoBytePrefixes / 65536) * 100).toFixed(3);
|
||||
|
||||
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Nodes tracked</div>
|
||||
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Using 2-byte ID</div>
|
||||
<div class="analytics-stat-value">${twoByteCount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Prefix space used</div>
|
||||
<div class="analytics-stat-value" style="font-size:16px">${twoPct}%</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">${uniqueTwoBytePrefixes} of 65,536 possible</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">Prefix collisions</div>
|
||||
<div class="analytics-stat-value" style="color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${twoCollisions}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||||
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||||
html += '</tr>';
|
||||
for (let hi = 0; hi < 16; hi++) {
|
||||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||||
for (let lo = 0; lo < 16; lo++) {
|
||||
const hex = nibbles[hi] + nibbles[lo];
|
||||
const info = firstByteInfo[hex] || { groupNodes: [], maxCollision: 0, collisionCount: 0 };
|
||||
const nodeCount = info.groupNodes.length;
|
||||
const maxCol = info.maxCollision;
|
||||
// Classify worst overlap in group: confirmed collision (2+ repeaters) or possible (null-role involved)
|
||||
const overlapping = Object.values(info.twoByteMap || {}).filter(v => v.length > 1);
|
||||
let html = hashStatCardsHtml(totalNodes, twoByteCount, '2-byte', 65536, uniqueTwoBytePrefixes, twoCollisions);
|
||||
html += hashMatrixGridHtml(nibbles, cellSize, headerSize, (hex, cs) => {
|
||||
const info = twoByteCells[hex] || { group_nodes: [], max_collision: 0, collision_count: 0, two_byte_map: {} };
|
||||
const nodeCount = (info.group_nodes || []).length;
|
||||
const maxCol = info.max_collision || 0;
|
||||
const overlapping = Object.values(info.two_byte_map || {}).filter(v => v.length > 1);
|
||||
const hasConfirmed = overlapping.some(ns => ns.filter(n => n.role === 'repeater').length >= 2);
|
||||
const hasPossible = !hasConfirmed && overlapping.some(ns => ns.length >= 2);
|
||||
let cellClass2, bgStyle2;
|
||||
@@ -1344,39 +1253,37 @@
|
||||
const nodeLabel2 = m => esc(m.name||m.public_key.slice(0,8)) + (!m.role ? ' (?)' : '');
|
||||
const tip2 = nodeCount === 0
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">No nodes in this group</div>`
|
||||
: info.collisionCount === 0
|
||||
: (info.collision_count || 0) === 0
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions</div>`
|
||||
: `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${hasConfirmed ? info.collisionCount + ' collision' + (info.collisionCount>1?'s':'') : 'Possible conflict'}</div><div class="hash-matrix-tooltip-nodes">${Object.entries(info.twoByteMap).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> — ${ns.map(nodeLabel2).join(', ')}</div>`).join('')}</div>`;
|
||||
html += `<td class="hash-cell ${cellClass2}${nodeCount ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip2.replace(/"/g,'"')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle2}border:1px solid var(--border);cursor:${nodeCount ? 'pointer' : 'default'};font-size:11px;font-weight:${maxCol > 0 ? '700' : '400'}">${hex}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table></div>';
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:420px;font-size:0.85em"></div></div>
|
||||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
||||
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> No nodes in group</span>
|
||||
<span><span class="legend-swatch hash-cell-taken"></span> Nodes present, no collision</span>
|
||||
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
|
||||
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
|
||||
</div>`;
|
||||
: `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${hasConfirmed ? (info.collision_count||0) + ' collision' + ((info.collision_count||0)>1?'s':'') : 'Possible conflict'}</div><div class="hash-matrix-tooltip-nodes">${Object.entries(info.two_byte_map||{}).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> — ${ns.map(nodeLabel2).join(', ')}</div>`).join('')}</div>`;
|
||||
return `<td class="hash-cell ${cellClass2}${nodeCount ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip2.replace(/"/g,'"')}" style="width:${cs}px;height:${cs}px;text-align:center;${bgStyle2}border:1px solid var(--border);cursor:${nodeCount ? 'pointer' : 'default'};font-size:11px;font-weight:${maxCol > 0 ? '700' : '400'}">${hex}</td>`;
|
||||
});
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:420px;font-size:0.85em"></div></div>`;
|
||||
html += hashMatrixLegendHtml([
|
||||
{cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'No nodes in group'},
|
||||
{cls: 'hash-cell-taken', text: 'Nodes present, no collision'},
|
||||
{cls: 'hash-cell-possible', text: 'Possible conflict'},
|
||||
{cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'}
|
||||
]);
|
||||
el.innerHTML = html;
|
||||
|
||||
el.querySelectorAll('.hash-active').forEach(td => {
|
||||
td.addEventListener('click', () => {
|
||||
const hex = td.dataset.hex.toUpperCase();
|
||||
const info = firstByteInfo[hex];
|
||||
const info = twoByteCells[hex];
|
||||
const detail = document.getElementById('hashDetail');
|
||||
if (!info || !info.groupNodes.length) { detail.innerHTML = ''; return; }
|
||||
let dhtml = `<strong class="mono" style="font-size:1.1em">0x${hex}__</strong> — ${info.groupNodes.length} node${info.groupNodes.length !== 1 ? 's' : ''} in group`;
|
||||
if (info.collisionCount === 0) {
|
||||
if (!info || !(info.group_nodes || []).length) { detail.innerHTML = ''; return; }
|
||||
const groupNodes = info.group_nodes || [];
|
||||
let dhtml = `<strong class="mono" style="font-size:1.1em">0x${hex}__</strong> — ${groupNodes.length} node${groupNodes.length !== 1 ? 's' : ''} in group`;
|
||||
if ((info.collision_count || 0) === 0) {
|
||||
dhtml += `<div class="text-muted" style="margin-top:6px;font-size:0.85em">✅ No 2-byte collisions in this group</div>`;
|
||||
dhtml += `<div style="margin-top:8px">${info.groupNodes.map(m => {
|
||||
dhtml += `<div style="margin-top:8px">${groupNodes.map(m => {
|
||||
const prefix = m.public_key.slice(0,4).toUpperCase();
|
||||
return `<div style="padding:2px 0"><code class="mono" style="font-size:0.85em">${prefix}</code> <a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a></div>`;
|
||||
}).join('')}</div>`;
|
||||
} else {
|
||||
dhtml += `<div style="margin-top:8px">`;
|
||||
for (const [twoHex, nodes] of Object.entries(info.twoByteMap).sort()) {
|
||||
for (const [twoHex, nodes] of Object.entries(info.two_byte_map || {}).sort()) {
|
||||
const isCollision = nodes.length > 1;
|
||||
dhtml += `<div style="margin-bottom:6px;padding:4px 6px;border-radius:4px;background:${isCollision ? 'rgba(220,50,30,0.1)' : 'transparent'};border:1px solid ${isCollision ? 'rgba(220,50,30,0.3)' : 'transparent'}">`;
|
||||
dhtml += `<code class="mono" style="font-size:0.9em;font-weight:${isCollision?'700':'400'}">${twoHex}</code>${isCollision ? ' <span style="color:#dc2626;font-size:0.75em;font-weight:700">COLLISION</span>' : ''} `;
|
||||
@@ -1395,106 +1302,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function renderCollisions(topHops, allNodes, bytes) {
|
||||
bytes = bytes || 1;
|
||||
function renderCollisionsFromServer(sizeData, bytes) {
|
||||
const el = document.getElementById('collisionList');
|
||||
const hopsForSize = topHops.filter(h => h.size === bytes);
|
||||
if (!sizeData) { el.innerHTML = '<div class="text-muted">No data</div>'; return; }
|
||||
const collisions = sizeData.collisions || [];
|
||||
|
||||
// For 2-byte and 3-byte, scan nodes directly — topHops only reliably covers 1-byte path hops
|
||||
const hopsToCheck = bytes === 1 ? hopsForSize : buildCollisionHops(allNodes, bytes);
|
||||
|
||||
if (!hopsToCheck.length && bytes === 1) {
|
||||
el.innerHTML = `<div class="text-muted" style="padding:8px">No 1-byte hops observed in recent packets.</div>`;
|
||||
if (!collisions.length) {
|
||||
const cleanMsg = bytes === 3
|
||||
? '✅ No 3-byte prefix collisions detected — all nodes have unique 3-byte prefixes.'
|
||||
: `✅ No ${bytes}-byte collisions detected`;
|
||||
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nodes = allNodes;
|
||||
const collisions = [];
|
||||
for (const hop of hopsToCheck) {
|
||||
const prefix = hop.hex.toLowerCase();
|
||||
const matches = nodes.filter(n => n.public_key.toLowerCase().startsWith(prefix));
|
||||
if (matches.length > 1) {
|
||||
// Calculate pairwise distances for classification
|
||||
const withCoords = matches.filter(m => m.lat && m.lon && !(m.lat === 0 && m.lon === 0));
|
||||
let maxDistKm = 0;
|
||||
let classification = 'unknown';
|
||||
if (withCoords.length >= 2) {
|
||||
for (let i = 0; i < withCoords.length; i++) {
|
||||
for (let j = i + 1; j < withCoords.length; j++) {
|
||||
const dLat = (withCoords[i].lat - withCoords[j].lat) * 111;
|
||||
const dLon = (withCoords[i].lon - withCoords[j].lon) * 85;
|
||||
const d = Math.sqrt(dLat * dLat + dLon * dLon);
|
||||
if (d > maxDistKm) maxDistKm = d;
|
||||
}
|
||||
}
|
||||
if (maxDistKm < 50) classification = 'local';
|
||||
else if (maxDistKm < 200) classification = 'regional';
|
||||
else classification = 'distant';
|
||||
} else if (withCoords.length < 2) {
|
||||
classification = 'incomplete';
|
||||
}
|
||||
collisions.push({ hop: hop.hex, count: hop.count, matches, maxDistKm, classification, withCoords: withCoords.length });
|
||||
|
||||
const showAppearances = bytes < 3;
|
||||
el.innerHTML = `<table class="analytics-table">
|
||||
<thead><tr>
|
||||
<th scope="col">Prefix</th>
|
||||
${showAppearances ? '<th scope="col">Appearances</th>' : ''}
|
||||
<th scope="col">Max Distance</th>
|
||||
<th scope="col">Assessment</th>
|
||||
<th scope="col">Colliding Nodes</th>
|
||||
</tr></thead>
|
||||
<tbody>${collisions.map(c => {
|
||||
let badge, tooltip;
|
||||
if (c.classification === 'local') {
|
||||
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
|
||||
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
|
||||
} else if (c.classification === 'regional') {
|
||||
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50–200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
|
||||
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
|
||||
} else if (c.classification === 'distant') {
|
||||
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
|
||||
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
|
||||
} else {
|
||||
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
|
||||
tooltip = 'Not enough coordinate data to classify';
|
||||
}
|
||||
}
|
||||
if (!collisions.length) {
|
||||
const cleanMsg = bytes === 3
|
||||
? '✅ No 3-byte prefix collisions detected — all nodes have unique 3-byte prefixes.'
|
||||
: `✅ No ${bytes}-byte collisions detected`;
|
||||
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort: local first (most likely to collide), then regional, distant, incomplete
|
||||
const classOrder = { local: 0, regional: 1, distant: 2, incomplete: 3, unknown: 4 };
|
||||
collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
|
||||
|
||||
const showAppearances = bytes < 3;
|
||||
el.innerHTML = `<table class="analytics-table">
|
||||
<thead><tr>
|
||||
<th scope="col">Prefix</th>
|
||||
${showAppearances ? '<th scope="col">Appearances</th>' : ''}
|
||||
<th scope="col">Max Distance</th>
|
||||
<th scope="col">Assessment</th>
|
||||
<th scope="col">Colliding Nodes</th>
|
||||
</tr></thead>
|
||||
<tbody>${collisions.map(c => {
|
||||
let badge, tooltip;
|
||||
if (c.classification === 'local') {
|
||||
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
|
||||
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
|
||||
} else if (c.classification === 'regional') {
|
||||
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50–200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
|
||||
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
|
||||
} else if (c.classification === 'distant') {
|
||||
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
|
||||
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
|
||||
} else {
|
||||
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
|
||||
tooltip = 'Not enough coordinate data to classify';
|
||||
}
|
||||
const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : '<span class="text-muted">—</span>';
|
||||
return `<tr>
|
||||
<td class="mono">${c.hop}</td>
|
||||
${showAppearances ? `<td>${c.count.toLocaleString()}</td>` : ''}
|
||||
<td>${distStr}</td>
|
||||
<td title="${tooltip}">${badge}</td>
|
||||
<td>${c.matches.map(m => {
|
||||
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||||
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||||
: ' <span class="text-muted" style="font-size:0.75em">(no coords)</span>';
|
||||
return `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a>${loc}`;
|
||||
}).join('<br>')}</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>
|
||||
<div class="text-muted" style="padding:8px;font-size:0.8em">
|
||||
<strong>🏘️ Local</strong> <50km: true prefix collision, same mesh area
|
||||
<strong>⚡ Regional</strong> 50–200km: edge of LoRa range, possible atmospheric propagation
|
||||
<strong>🌐 Distant</strong> >200km: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
|
||||
</div>`;
|
||||
} catch { el.innerHTML = '<div class="text-muted">Failed to load</div>'; }
|
||||
const nodes = c.nodes || [];
|
||||
const distStr = c.with_coords >= 2 ? `${Math.round(c.max_dist_km)} km` : '<span class="text-muted">—</span>';
|
||||
return `<tr>
|
||||
<td class="mono">${c.prefix}</td>
|
||||
${showAppearances ? `<td>${(c.appearances || 0).toLocaleString()}</td>` : ''}
|
||||
<td>${distStr}</td>
|
||||
<td title="${tooltip}">${badge}</td>
|
||||
<td>${nodes.map(m => {
|
||||
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||||
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||||
: ' <span class="text-muted" style="font-size:0.75em">(no coords)</span>';
|
||||
return `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a>${loc}`;
|
||||
}).join('<br>')}</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>
|
||||
<div class="text-muted" style="padding:8px;font-size:0.8em">
|
||||
<strong>🏘️ Local</strong> <50km: true prefix collision, same mesh area
|
||||
<strong>⚡ Regional</strong> 50–200km: edge of LoRa range, possible atmospheric propagation
|
||||
<strong>🌐 Distant</strong> >200km: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function renderSubpaths(el) {
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
|
||||
try {
|
||||
@@ -1622,9 +1488,9 @@
|
||||
for (let i = 0; i < data.nodes.length - 1; i++) {
|
||||
const a = data.nodes[i], b = data.nodes[i+1];
|
||||
if (a.lat && a.lon && b.lat && b.lon && !(a.lat===0&&a.lon===0) && !(b.lat===0&&b.lon===0)) {
|
||||
const dLat = (a.lat - b.lat) * 111;
|
||||
const dLon = (a.lon - b.lon) * 85;
|
||||
const km = Math.sqrt(dLat*dLat + dLon*dLon);
|
||||
const km = window.HopResolver && window.HopResolver.haversineKm
|
||||
? window.HopResolver.haversineKm(a.lat, a.lon, b.lat, b.lon)
|
||||
: (() => { const R=6371, dLat=(b.lat-a.lat)*Math.PI/180, dLon=(b.lon-a.lon)*Math.PI/180, h=Math.sin(dLat/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2; return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h)); })();
|
||||
total += km;
|
||||
const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
|
||||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||||
@@ -1942,9 +1808,6 @@ function destroy() { _analyticsData = {}; _channelData = null; }
|
||||
window._analyticsSaveChannelSort = saveChannelSort;
|
||||
window._analyticsChannelTbodyHtml = channelTbodyHtml;
|
||||
window._analyticsChannelTheadHtml = channelTheadHtml;
|
||||
window._analyticsBuildOneBytePrefixMap = buildOneBytePrefixMap;
|
||||
window._analyticsBuildTwoBytePrefixInfo = buildTwoBytePrefixInfo;
|
||||
window._analyticsBuildCollisionHops = buildCollisionHops;
|
||||
}
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
|
||||
+65
-85
@@ -136,13 +136,6 @@ function getTimestampCustomFormat() {
|
||||
function pad2(v) { return String(v).padStart(2, '0'); }
|
||||
function pad3(v) { return String(v).padStart(3, '0'); }
|
||||
|
||||
function mergeUserHomeConfig(siteConfig, userTheme) {
|
||||
if (!siteConfig || !userTheme || !userTheme.home || typeof userTheme.home !== 'object') return siteConfig;
|
||||
const serverHome = (siteConfig.home && typeof siteConfig.home === 'object') ? siteConfig.home : {};
|
||||
siteConfig.home = Object.assign({}, serverHome, userTheme.home);
|
||||
return siteConfig;
|
||||
}
|
||||
|
||||
function formatIsoLike(d, timezone, includeMs) {
|
||||
const useUtc = timezone === 'utc';
|
||||
const year = useUtc ? d.getUTCFullYear() : d.getFullYear();
|
||||
@@ -412,6 +405,14 @@ function closeNav() {
|
||||
document.body.classList.remove('nav-open');
|
||||
var btn = document.getElementById('hamburger');
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
closeMoreMenu();
|
||||
}
|
||||
|
||||
function closeMoreMenu() {
|
||||
var menu = document.getElementById('navMoreMenu');
|
||||
var btn = document.getElementById('navMoreBtn');
|
||||
if (menu) menu.classList.remove('open');
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function navigate() {
|
||||
@@ -448,6 +449,13 @@ function navigate() {
|
||||
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.route === basePage);
|
||||
});
|
||||
// Update "More" button to show active state if a low-priority page is selected
|
||||
var moreBtn = document.getElementById('navMoreBtn');
|
||||
if (moreBtn) {
|
||||
var moreMenu = document.getElementById('navMoreMenu');
|
||||
var hasActiveMore = moreMenu && moreMenu.querySelector('.nav-link.active');
|
||||
moreBtn.classList.toggle('active', !!hasActiveMore);
|
||||
}
|
||||
|
||||
if (currentPage && pages[currentPage]?.destroy) {
|
||||
pages[currentPage].destroy();
|
||||
@@ -551,8 +559,36 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
navLinks.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', closeNav);
|
||||
});
|
||||
|
||||
// --- "More" dropdown (tablet Priority+ nav) ---
|
||||
const navMoreBtn = document.getElementById('navMoreBtn');
|
||||
const navMoreMenu = document.getElementById('navMoreMenu');
|
||||
if (navMoreBtn && navMoreMenu) {
|
||||
// Build More menu dynamically from non-priority nav links (DRY)
|
||||
navMoreMenu.innerHTML = '';
|
||||
document.querySelectorAll('.nav-links a:not([data-priority="high"])').forEach(function(link) {
|
||||
var clone = link.cloneNode(true);
|
||||
clone.setAttribute('role', 'menuitem');
|
||||
clone.addEventListener('click', closeMoreMenu);
|
||||
navMoreMenu.appendChild(clone);
|
||||
});
|
||||
navMoreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const opening = !navMoreMenu.classList.contains('open');
|
||||
navMoreMenu.classList.toggle('open');
|
||||
navMoreBtn.setAttribute('aria-expanded', String(opening));
|
||||
if (opening) {
|
||||
var firstLink = navMoreMenu.querySelector('.nav-link');
|
||||
if (firstLink) firstLink.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && navLinks.classList.contains('open')) closeNav();
|
||||
if (e.key === 'Escape') {
|
||||
if (navMoreMenu && navMoreMenu.classList.contains('open')) closeMoreMenu();
|
||||
if (navLinks.classList.contains('open')) closeNav();
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (navLinks.classList.contains('open') &&
|
||||
@@ -560,6 +596,11 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
!hamburger.contains(e.target)) {
|
||||
closeNav();
|
||||
}
|
||||
if (navMoreMenu && navMoreMenu.classList.contains('open') &&
|
||||
!navMoreMenu.contains(e.target) &&
|
||||
!navMoreBtn.contains(e.target)) {
|
||||
closeMoreMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Favorites dropdown ---
|
||||
@@ -746,91 +787,30 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
debouncedOnWS(function () { updateNavStats(); });
|
||||
|
||||
// --- Theme Customization ---
|
||||
// Fetch theme config and apply branding/colors before first render
|
||||
// Fetch theme config and apply via customizer v2 pipeline
|
||||
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
|
||||
window.SITE_CONFIG = cfg || {};
|
||||
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
|
||||
const tsCfg = window.SITE_CONFIG.timestamps;
|
||||
// Normalize timestamp defaults
|
||||
cfg = cfg || {};
|
||||
if (!cfg.timestamps) cfg.timestamps = {};
|
||||
const tsCfg = cfg.timestamps;
|
||||
if (tsCfg.defaultMode !== 'absolute' && tsCfg.defaultMode !== 'ago') tsCfg.defaultMode = 'ago';
|
||||
if (tsCfg.timezone !== 'utc' && tsCfg.timezone !== 'local') tsCfg.timezone = 'local';
|
||||
if (tsCfg.formatPreset !== 'iso' && tsCfg.formatPreset !== 'iso-seconds' && tsCfg.formatPreset !== 'locale') tsCfg.formatPreset = 'iso';
|
||||
if (typeof tsCfg.customFormat !== 'string') tsCfg.customFormat = '';
|
||||
tsCfg.allowCustomFormat = tsCfg.allowCustomFormat === true;
|
||||
|
||||
// User's localStorage preferences take priority over server config
|
||||
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
||||
mergeUserHomeConfig(window.SITE_CONFIG, userTheme);
|
||||
|
||||
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
|
||||
if (!userTheme.theme && !userTheme.themeDark) {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const themeData = dark ? { ...(cfg.theme || {}), ...(cfg.themeDark || {}) } : (cfg.theme || {});
|
||||
const root = document.documentElement.style;
|
||||
const varMap = {
|
||||
accent: '--accent', accentHover: '--accent-hover',
|
||||
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
|
||||
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
|
||||
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
|
||||
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
|
||||
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
|
||||
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
|
||||
selectedBg: '--selected-bg', sectionBg: '--section-bg',
|
||||
font: '--font', mono: '--mono'
|
||||
};
|
||||
for (const [key, cssVar] of Object.entries(varMap)) {
|
||||
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
|
||||
}
|
||||
// Derived vars
|
||||
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
|
||||
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
|
||||
// Nav gradient
|
||||
if (themeData.navBg) {
|
||||
const nav = document.querySelector('.top-nav');
|
||||
if (nav) nav.style.background = `linear-gradient(135deg, ${themeData.navBg} 0%, ${themeData.navBg2 || themeData.navBg} 50%, ${themeData.navBg} 100%)`;
|
||||
}
|
||||
// Customizer v2: set server defaults and run full pipeline
|
||||
// (reads localStorage overrides → merges → sets SITE_CONFIG → applies CSS → dispatches theme-changed)
|
||||
if (window._customizerV2) {
|
||||
window._customizerV2.init(cfg);
|
||||
} else {
|
||||
// Fallback if customize-v2.js didn't load
|
||||
window.SITE_CONFIG = cfg;
|
||||
}
|
||||
|
||||
// Apply node color overrides (skip if user has local preferences)
|
||||
if (cfg.nodeColors && !userTheme.nodeColors) {
|
||||
for (const [role, color] of Object.entries(cfg.nodeColors)) {
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = color;
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply type color overrides (skip if user has local preferences)
|
||||
if (cfg.typeColors && !userTheme.typeColors) {
|
||||
for (const [type, color] of Object.entries(cfg.typeColors)) {
|
||||
if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = color;
|
||||
}
|
||||
if (window.syncBadgeColors) window.syncBadgeColors();
|
||||
}
|
||||
|
||||
// Apply branding (skip if user has local preferences)
|
||||
if (cfg.branding && !userTheme.branding) {
|
||||
if (cfg.branding.siteName) {
|
||||
document.title = cfg.branding.siteName;
|
||||
const brandText = document.querySelector('.brand-text');
|
||||
if (brandText) brandText.textContent = cfg.branding.siteName;
|
||||
}
|
||||
if (cfg.branding.logoUrl) {
|
||||
const brandIcon = document.querySelector('.brand-icon');
|
||||
if (brandIcon) {
|
||||
const img = document.createElement('img');
|
||||
img.src = cfg.branding.logoUrl;
|
||||
img.alt = cfg.branding.siteName || 'Logo';
|
||||
img.style.height = '24px';
|
||||
img.style.width = 'auto';
|
||||
brandIcon.replaceWith(img);
|
||||
}
|
||||
}
|
||||
if (cfg.branding.faviconUrl) {
|
||||
const favicon = document.querySelector('link[rel="icon"]');
|
||||
if (favicon) favicon.href = cfg.branding.faviconUrl;
|
||||
}
|
||||
}
|
||||
}).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } }; }).finally(() => {
|
||||
}).catch(() => {
|
||||
window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } };
|
||||
if (window._customizerV2) window._customizerV2.init(window.SITE_CONFIG);
|
||||
}).finally(() => {
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
});
|
||||
|
||||
+5
-2
@@ -274,6 +274,9 @@
|
||||
for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
function formatHashHex(hash) {
|
||||
return typeof hash === 'number' ? '0x' + hash.toString(16).toUpperCase().padStart(2, '0') : hash;
|
||||
}
|
||||
function getChannelColor(hash) { return CHANNEL_COLORS[hashCode(String(hash)) % CHANNEL_COLORS.length]; }
|
||||
function getSenderColor(name) {
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
@@ -659,7 +662,7 @@
|
||||
});
|
||||
|
||||
el.innerHTML = sorted.map(ch => {
|
||||
const name = ch.name || `Channel ${ch.hash}`;
|
||||
const name = ch.name || `Channel ${formatHashHex(ch.hash)}`;
|
||||
const color = getChannelColor(ch.hash);
|
||||
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
|
||||
const preview = ch.lastSender && ch.lastMessage
|
||||
@@ -688,7 +691,7 @@
|
||||
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
|
||||
renderChannelList();
|
||||
const ch = channels.find(c => c.hash === hash);
|
||||
const name = ch?.name || `Channel ${hash}`;
|
||||
const name = ch?.name || `Channel ${formatHashHex(hash)}`;
|
||||
const header = document.getElementById('chHeader');
|
||||
header.querySelector('.ch-header-text').textContent = `${name} — ${ch?.messageCount || 0} messages`;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+9
-8
@@ -450,7 +450,8 @@
|
||||
function mergeSection(key) {
|
||||
return Object.assign({}, DEFAULTS[key], cfg[key] || {}, local[key] || {});
|
||||
}
|
||||
var mergedHome = mergeSection('home');
|
||||
var serverHome = window._SITE_CONFIG_ORIGINAL_HOME || cfg.home || {};
|
||||
var mergedHome = Object.assign({}, DEFAULTS.home, serverHome, local.home || {});
|
||||
var localTsMode = localStorage.getItem('meshcore-timestamp-mode');
|
||||
var localTsTimezone = localStorage.getItem('meshcore-timestamp-timezone');
|
||||
var localTsFormat = localStorage.getItem('meshcore-timestamp-format');
|
||||
@@ -1202,19 +1203,19 @@
|
||||
var tmp = state.home.steps[i];
|
||||
state.home.steps[i] = state.home.steps[j];
|
||||
state.home.steps[j] = tmp;
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('[data-rm-step]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
state.home.steps.splice(parseInt(btn.dataset.rmStep), 1);
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
});
|
||||
var addStepBtn = document.getElementById('addStep');
|
||||
if (addStepBtn) addStepBtn.addEventListener('click', function () {
|
||||
state.home.steps.push({ emoji: '📌', title: '', description: '' });
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
|
||||
// Checklist
|
||||
@@ -1227,13 +1228,13 @@
|
||||
container.querySelectorAll('[data-rm-check]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
state.home.checklist.splice(parseInt(btn.dataset.rmCheck), 1);
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
});
|
||||
var addCheckBtn = document.getElementById('addCheck');
|
||||
if (addCheckBtn) addCheckBtn.addEventListener('click', function () {
|
||||
state.home.checklist.push({ question: '', answer: '' });
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
|
||||
// Footer links
|
||||
@@ -1246,13 +1247,13 @@
|
||||
container.querySelectorAll('[data-rm-link]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
state.home.footerLinks.splice(parseInt(btn.dataset.rmLink), 1);
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
});
|
||||
var addLinkBtn = document.getElementById('addLink');
|
||||
if (addLinkBtn) addLinkBtn.addEventListener('click', function () {
|
||||
state.home.footerLinks.push({ label: '', url: '' });
|
||||
render(container);
|
||||
render(container); autoSave();
|
||||
});
|
||||
|
||||
// Export copy
|
||||
|
||||
@@ -203,5 +203,5 @@ window.HopResolver = (function() {
|
||||
return nodesList.length > 0;
|
||||
}
|
||||
|
||||
return { init: init, resolve: resolve, ready: ready };
|
||||
return { init: init, resolve: resolve, ready: ready, haversineKm: haversineKm };
|
||||
})();
|
||||
|
||||
+38
-33
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="CoreScope">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1775023114">
|
||||
<link rel="stylesheet" href="home.css?v=1775023114">
|
||||
<link rel="stylesheet" href="live.css?v=1775023114">
|
||||
<link rel="stylesheet" href="style.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="home.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="live.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -44,18 +44,22 @@
|
||||
<span class="live-dot" id="liveDot" title="WebSocket connected" aria-label="WebSocket connected"></span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="#/home" class="nav-link" data-route="home">Home</a>
|
||||
<a href="#/packets" class="nav-link" data-route="packets">Packets</a>
|
||||
<a href="#/map" class="nav-link" data-route="map">Map</a>
|
||||
<a href="#/live" class="nav-link" data-route="live">🔴 Live</a>
|
||||
<a href="#/home" class="nav-link" data-route="home" data-priority="high">Home</a>
|
||||
<a href="#/packets" class="nav-link" data-route="packets" data-priority="high">Packets</a>
|
||||
<a href="#/map" class="nav-link" data-route="map" data-priority="high">Map</a>
|
||||
<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">Nodes</a>
|
||||
<a href="#/nodes" class="nav-link" data-route="nodes" data-priority="high">Nodes</a>
|
||||
<a href="#/traces" class="nav-link" data-route="traces">Traces</a>
|
||||
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
|
||||
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
|
||||
<a href="#/perf" class="nav-link" data-route="perf">⚡ Perf</a>
|
||||
<a href="#/audio-lab" class="nav-link" data-route="audio-lab">🎵 Lab</a>
|
||||
</div>
|
||||
<div class="nav-more-wrap">
|
||||
<button class="nav-btn nav-more-btn" id="navMoreBtn" aria-haspopup="true" aria-expanded="false" aria-controls="navMoreMenu" title="More pages">More ▾</button>
|
||||
<div class="nav-more-menu" id="navMoreMenu" role="menu"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="nav-stats" id="navStats" title="Live stats"></div>
|
||||
@@ -81,30 +85,31 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1775023114"></script>
|
||||
<script src="customize.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1775023114"></script>
|
||||
<script src="hop-resolver.js?v=1775023114"></script>
|
||||
<script src="hop-display.js?v=1775023114"></script>
|
||||
<script src="app.js?v=1775023114"></script>
|
||||
<script src="home.js?v=1775023114"></script>
|
||||
<script src="packet-filter.js?v=1775023114"></script>
|
||||
<script src="packets.js?v=1775023114"></script>
|
||||
<script src="geo-filter-overlay.js?v=1775023114"></script>
|
||||
<script src="map.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=__BUST__"></script>
|
||||
<script src="customize-v2.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=__BUST__"></script>
|
||||
<script src="hop-resolver.js?v=__BUST__"></script>
|
||||
<script src="hop-display.js?v=__BUST__"></script>
|
||||
<script src="app.js?v=__BUST__"></script>
|
||||
<script src="home.js?v=__BUST__"></script>
|
||||
<script src="packet-filter.js?v=__BUST__"></script>
|
||||
<script src="packet-helpers.js?v=__BUST__"></script>
|
||||
<script src="packets.js?v=__BUST__"></script>
|
||||
<script src="geo-filter-overlay.js?v=__BUST__"></script>
|
||||
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<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="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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+152
-68
@@ -1,6 +1,10 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file)
|
||||
var getParsedPath = window.getParsedPath;
|
||||
var getParsedDecoded = window.getParsedDecoded;
|
||||
|
||||
// Status color helpers (read from CSS variables for theme support)
|
||||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
|
||||
@@ -10,6 +14,7 @@
|
||||
let nodeData = {};
|
||||
let packetCount = 0;
|
||||
let activeAnims = 0;
|
||||
const MAX_CONCURRENT_ANIMS = 20;
|
||||
let nodeActivity = {};
|
||||
let recentPaths = [];
|
||||
let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false';
|
||||
@@ -368,12 +373,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateVCRClock(tsMs) {
|
||||
function vcrFormatTime(tsMs) {
|
||||
const d = new Date(tsMs);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
drawLcdText(`${hh}:${mm}:${ss}`, statusGreen());
|
||||
const utc = typeof getTimestampTimezone === 'function' && getTimestampTimezone() === 'utc';
|
||||
const hh = String(utc ? d.getUTCHours() : d.getHours()).padStart(2, '0');
|
||||
const mm = String(utc ? d.getUTCMinutes() : d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(utc ? d.getUTCSeconds() : d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function updateVCRClock(tsMs) {
|
||||
drawLcdText(vcrFormatTime(tsMs), statusGreen());
|
||||
}
|
||||
|
||||
function updateVCRLcd() {
|
||||
@@ -425,8 +435,8 @@
|
||||
}
|
||||
|
||||
function dbPacketToLive(pkt) {
|
||||
const raw = JSON.parse(pkt.decoded_json || '{}');
|
||||
const hops = JSON.parse(pkt.path_json || '[]');
|
||||
const raw = getParsedDecoded(pkt);
|
||||
const hops = getParsedPath(pkt);
|
||||
const typeName = raw.type || pkt.payload_type_name || 'UNKNOWN';
|
||||
return {
|
||||
id: pkt.id, hash: pkt.hash,
|
||||
@@ -475,8 +485,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
function packetTimestamp(pkt) {
|
||||
return new Date(pkt.timestamp || pkt.created_at || Date.now()).getTime();
|
||||
}
|
||||
if (typeof window !== 'undefined') window._live_packetTimestamp = packetTimestamp;
|
||||
|
||||
function bufferPacket(pkt) {
|
||||
pkt._ts = Date.now();
|
||||
pkt._ts = packetTimestamp(pkt);
|
||||
const entry = { ts: pkt._ts, pkt };
|
||||
VCR.buffer.push(entry);
|
||||
// Keep buffer capped at ~2000 — adjust playhead to avoid stale indices (#63)
|
||||
@@ -1060,8 +1075,7 @@
|
||||
const rect = timelineEl.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
const ts = Date.now() - VCR.timelineScope + pct * VCR.timelineScope;
|
||||
const d = new Date(ts);
|
||||
timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
timeTooltip.textContent = vcrFormatTime(ts);
|
||||
timeTooltip.style.left = (e.clientX - rect.left) + 'px';
|
||||
timeTooltip.classList.remove('hidden');
|
||||
});
|
||||
@@ -1074,8 +1088,7 @@
|
||||
const rect = timelineEl.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||
const ts = Date.now() - VCR.timelineScope + pct * VCR.timelineScope;
|
||||
const d = new Date(ts);
|
||||
timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
timeTooltip.textContent = vcrFormatTime(ts);
|
||||
timeTooltip.style.left = (touch.clientX - rect.left) + 'px';
|
||||
timeTooltip.classList.remove('hidden');
|
||||
});
|
||||
@@ -1436,7 +1449,7 @@
|
||||
for (const op of group.packets) {
|
||||
let opHops = [];
|
||||
if (op.path_json) {
|
||||
try { opHops = typeof op.path_json === 'string' ? JSON.parse(op.path_json) : op.path_json; } catch {}
|
||||
try { opHops = getParsedPath(op); } catch {}
|
||||
} else if (op.decoded?.path?.hops) {
|
||||
opHops = op.decoded.path.hops;
|
||||
}
|
||||
@@ -1581,6 +1594,21 @@
|
||||
window._livePruneStaleNodes = pruneStaleNodes;
|
||||
window._liveNodeMarkers = function() { return nodeMarkers; };
|
||||
window._liveNodeData = function() { return nodeData; };
|
||||
window._vcrFormatTime = vcrFormatTime;
|
||||
window._liveDbPacketToLive = dbPacketToLive;
|
||||
window._liveExpandToBufferEntries = expandToBufferEntries;
|
||||
window._liveSEG_MAP = SEG_MAP;
|
||||
window._liveBufferPacket = bufferPacket;
|
||||
window._liveVCR = function() { return VCR; };
|
||||
window._liveGetFavoritePubkeys = getFavoritePubkeys;
|
||||
window._livePacketInvolvesFavorite = packetInvolvesFavorite;
|
||||
window._liveIsNodeFavorited = isNodeFavorited;
|
||||
window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml;
|
||||
window._liveResolveHopPositions = resolveHopPositions;
|
||||
window._liveVcrSpeedCycle = vcrSpeedCycle;
|
||||
window._liveVcrPause = vcrPause;
|
||||
window._liveVcrResumeLive = vcrResumeLive;
|
||||
window._liveVcrSetMode = vcrSetMode;
|
||||
|
||||
async function replayRecent() {
|
||||
try {
|
||||
@@ -1705,7 +1733,7 @@
|
||||
for (const fp of packets) {
|
||||
let fpHops = [];
|
||||
if (fp.path_json) {
|
||||
try { fpHops = typeof fp.path_json === 'string' ? JSON.parse(fp.path_json) : fp.path_json; } catch {}
|
||||
try { fpHops = getParsedPath(fp); } catch {}
|
||||
} else if (fp.decoded?.path?.hops) {
|
||||
fpHops = fp.decoded.path.hops;
|
||||
}
|
||||
@@ -1742,7 +1770,7 @@
|
||||
var qp = qd.payload || {};
|
||||
var hops;
|
||||
if (qpkt.path_json) {
|
||||
try { hops = typeof qpkt.path_json === 'string' ? JSON.parse(qpkt.path_json) : qpkt.path_json; } catch (e) { hops = qd.path?.hops || []; }
|
||||
try { hops = getParsedPath(qpkt); } catch (e) { hops = qd.path?.hops || []; }
|
||||
} else {
|
||||
hops = qd.path?.hops || [];
|
||||
}
|
||||
@@ -1843,6 +1871,7 @@
|
||||
|
||||
function animatePath(hopPositions, typeName, color, rawHex, onHop) {
|
||||
if (!animLayer || !pathsLayer) return;
|
||||
if (activeAnims >= MAX_CONCURRENT_ANIMS) return;
|
||||
activeAnims++;
|
||||
document.getElementById('liveAnimCount').textContent = activeAnims;
|
||||
let hopIndex = 0;
|
||||
@@ -1850,9 +1879,11 @@
|
||||
function nextHop() {
|
||||
if (hopIndex >= hopPositions.length) {
|
||||
activeAnims = Math.max(0, activeAnims - 1);
|
||||
document.getElementById('liveAnimCount').textContent = activeAnims;
|
||||
const countEl = document.getElementById('liveAnimCount');
|
||||
if (countEl) countEl.textContent = activeAnims;
|
||||
return;
|
||||
}
|
||||
if (!animLayer) return;
|
||||
// Audio hook: notify per-hop callback
|
||||
if (onHop) try { onHop(hopIndex, hopPositions.length, hopPositions[hopIndex]); } catch (e) {}
|
||||
const hp = hopPositions[hopIndex];
|
||||
@@ -1864,12 +1895,22 @@
|
||||
radius: 3, fillColor: '#94a3b8', fillOpacity: 0.35, color: '#94a3b8', weight: 1, opacity: 0.5
|
||||
}).addTo(animLayer);
|
||||
let pulseUp = true;
|
||||
const pulseTimer = setInterval(() => {
|
||||
if (!animLayer.hasLayer(ghost)) { clearInterval(pulseTimer); return; }
|
||||
ghost.setStyle({ fillOpacity: pulseUp ? 0.6 : 0.25, opacity: pulseUp ? 0.7 : 0.4 });
|
||||
pulseUp = !pulseUp;
|
||||
}, 600);
|
||||
setTimeout(() => { clearInterval(pulseTimer); if (animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost); }, 3000);
|
||||
let lastPulseTime = performance.now();
|
||||
const pulseExpiry = lastPulseTime + 3000;
|
||||
function ghostPulse(now) {
|
||||
if (!animLayer || !animLayer.hasLayer(ghost)) return;
|
||||
if (now >= pulseExpiry) {
|
||||
if (animLayer && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost);
|
||||
return;
|
||||
}
|
||||
if (now - lastPulseTime >= 600) {
|
||||
lastPulseTime = now;
|
||||
ghost.setStyle({ fillOpacity: pulseUp ? 0.6 : 0.25, opacity: pulseUp ? 0.7 : 0.4 });
|
||||
pulseUp = !pulseUp;
|
||||
}
|
||||
requestAnimationFrame(ghostPulse);
|
||||
}
|
||||
requestAnimationFrame(ghostPulse);
|
||||
}
|
||||
} else {
|
||||
pulseNode(hp.key, hp.pos, typeName);
|
||||
@@ -1913,20 +1954,31 @@
|
||||
}).addTo(animLayer);
|
||||
|
||||
let r = 2, op = 0.9;
|
||||
const iv = setInterval(() => {
|
||||
r += 1.5; op -= 0.03;
|
||||
if (op <= 0) {
|
||||
clearInterval(iv);
|
||||
let lastPulse = performance.now();
|
||||
const pulseStart = lastPulse;
|
||||
function animatePulse(now) {
|
||||
if (!animLayer) return;
|
||||
if (now - pulseStart > 2000) {
|
||||
try { animLayer.removeLayer(ring); } catch {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ring.setRadius(r);
|
||||
ring.setStyle({ opacity: op, weight: Math.max(0.3, 3 - r * 0.04) });
|
||||
} catch { clearInterval(iv); }
|
||||
}, 26);
|
||||
// Safety cleanup — never let a ring live longer than 2s
|
||||
setTimeout(() => { clearInterval(iv); try { animLayer.removeLayer(ring); } catch {} }, 2000);
|
||||
const elapsed = now - lastPulse;
|
||||
if (elapsed >= 26) {
|
||||
const ticks = Math.min(Math.floor(elapsed / 26), 4);
|
||||
r += 1.5 * ticks; op -= 0.03 * ticks;
|
||||
lastPulse = now;
|
||||
if (op <= 0) {
|
||||
try { animLayer.removeLayer(ring); } catch {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ring.setRadius(r);
|
||||
ring.setStyle({ opacity: op, weight: Math.max(0.3, 3 - r * 0.04) });
|
||||
} catch { return; }
|
||||
}
|
||||
requestAnimationFrame(animatePulse);
|
||||
}
|
||||
requestAnimationFrame(animatePulse);
|
||||
|
||||
const baseColor = marker._baseColor || '#6b7280';
|
||||
const baseSize = marker._baseSize || 6;
|
||||
@@ -2150,6 +2202,10 @@
|
||||
const startTime = performance.now();
|
||||
|
||||
function tick(now) {
|
||||
if (!animLayer || !pathsLayer) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
const elapsed = now - startTime;
|
||||
const t = Math.min(1, elapsed / DURATION_MS);
|
||||
const lat = from[0] + (to[0] - from[0]) * t;
|
||||
@@ -2194,6 +2250,11 @@
|
||||
// Fade out
|
||||
const fadeStart = performance.now();
|
||||
function fadeOut(now) {
|
||||
if (!animLayer || !pathsLayer) {
|
||||
charMarkers.length = 0;
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
const ft = Math.min(1, (now - fadeStart) / 300);
|
||||
if (ft >= 1) {
|
||||
for (const cm of charMarkers) try { animLayer.removeLayer(cm.marker); } catch {}
|
||||
@@ -2239,43 +2300,66 @@
|
||||
radius: 3.5, fillColor: '#fff', fillOpacity: 1, color: color, weight: 1.5
|
||||
}).addTo(animLayer);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
step++;
|
||||
const lat = from[0] + latStep * step;
|
||||
const lon = from[1] + lonStep * step;
|
||||
currentCoords.push([lat, lon]);
|
||||
line.setLatLngs(currentCoords);
|
||||
contrail.setLatLngs(currentCoords);
|
||||
dot.setLatLng([lat, lon]);
|
||||
|
||||
if (step >= steps) {
|
||||
clearInterval(interval);
|
||||
if (animLayer) animLayer.removeLayer(dot);
|
||||
|
||||
recentPaths.push({ line, glowLine: contrail, time: Date.now() });
|
||||
while (recentPaths.length > 5) {
|
||||
const old = recentPaths.shift();
|
||||
if (pathsLayer) { pathsLayer.removeLayer(old.line); pathsLayer.removeLayer(old.glowLine); }
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
let fadeOp = mainOpacity;
|
||||
const fi = setInterval(() => {
|
||||
fadeOp -= 0.1;
|
||||
if (fadeOp <= 0) {
|
||||
clearInterval(fi);
|
||||
if (pathsLayer) { pathsLayer.removeLayer(line); pathsLayer.removeLayer(contrail); }
|
||||
recentPaths = recentPaths.filter(p => p.line !== line);
|
||||
} else {
|
||||
line.setStyle({ opacity: fadeOp });
|
||||
contrail.setStyle({ opacity: fadeOp * 0.15 });
|
||||
}
|
||||
}, 52);
|
||||
}, 800);
|
||||
|
||||
let lastStep = performance.now();
|
||||
function animateLine(now) {
|
||||
if (!animLayer || !pathsLayer) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
}, 33);
|
||||
const elapsed = now - lastStep;
|
||||
if (elapsed >= 33) {
|
||||
const ticks = Math.min(Math.floor(elapsed / 33), 4);
|
||||
lastStep = now;
|
||||
for (let t = 0; t < ticks && step < steps; t++) {
|
||||
step++;
|
||||
const lat = from[0] + latStep * step;
|
||||
const lon = from[1] + lonStep * step;
|
||||
currentCoords.push([lat, lon]);
|
||||
}
|
||||
const lastPt = currentCoords[currentCoords.length - 1];
|
||||
line.setLatLngs(currentCoords);
|
||||
contrail.setLatLngs(currentCoords);
|
||||
dot.setLatLng(lastPt);
|
||||
|
||||
if (step >= steps) {
|
||||
if (animLayer) animLayer.removeLayer(dot);
|
||||
|
||||
recentPaths.push({ line, glowLine: contrail, time: Date.now() });
|
||||
while (recentPaths.length > 5) {
|
||||
const old = recentPaths.shift();
|
||||
if (pathsLayer) { pathsLayer.removeLayer(old.line); pathsLayer.removeLayer(old.glowLine); }
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
let fadeOp = mainOpacity;
|
||||
let lastFade = performance.now();
|
||||
function animateFade(now) {
|
||||
if (!pathsLayer) return;
|
||||
const fadeElapsed = now - lastFade;
|
||||
if (fadeElapsed >= 52) {
|
||||
const fadeTicks = Math.min(Math.floor(fadeElapsed / 52), 4);
|
||||
lastFade = now;
|
||||
fadeOp -= 0.1 * fadeTicks;
|
||||
if (fadeOp <= 0) {
|
||||
if (pathsLayer) { pathsLayer.removeLayer(line); pathsLayer.removeLayer(contrail); }
|
||||
recentPaths = recentPaths.filter(p => p.line !== line);
|
||||
return;
|
||||
}
|
||||
line.setStyle({ opacity: fadeOp });
|
||||
contrail.setStyle({ opacity: fadeOp * 0.15 });
|
||||
}
|
||||
requestAnimationFrame(animateFade);
|
||||
}
|
||||
requestAnimationFrame(animateFade);
|
||||
}, 800);
|
||||
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(animateLine);
|
||||
}
|
||||
requestAnimationFrame(animateLine);
|
||||
}
|
||||
|
||||
function showHeatMap() {
|
||||
|
||||
+77
-2
@@ -10,6 +10,8 @@
|
||||
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' };
|
||||
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;
|
||||
let heatLayer = null;
|
||||
let geoFilterLayer = null;
|
||||
@@ -108,6 +110,8 @@
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Filters</legend>
|
||||
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
|
||||
<div id="mcNeighborRef" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Ref: <span id="mcNeighborRefName">—</span></div>
|
||||
<div id="mcNeighborHint" style="display:none;font-size:11px;color:var(--text-muted);margin-top:2px;padding-left:20px;">Click a node marker to set the reference node</div>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Last Heard</legend>
|
||||
@@ -207,7 +211,19 @@
|
||||
const heatEl = document.getElementById('mcHeatmap');
|
||||
if (localStorage.getItem('meshcore-map-heatmap') === 'true') { heatEl.checked = true; }
|
||||
heatEl.addEventListener('change', e => { localStorage.setItem('meshcore-map-heatmap', e.target.checked); toggleHeatmap(e.target.checked); });
|
||||
document.getElementById('mcNeighbors').addEventListener('change', e => { filters.neighbors = e.target.checked; renderMarkers(); });
|
||||
document.getElementById('mcNeighbors').addEventListener('change', e => {
|
||||
filters.neighbors = e.target.checked;
|
||||
const hintEl = document.getElementById('mcNeighborHint');
|
||||
const refEl = document.getElementById('mcNeighborRef');
|
||||
if (e.target.checked && !selectedReferenceNode) {
|
||||
hintEl.style.display = 'block';
|
||||
refEl.style.display = 'none';
|
||||
} else {
|
||||
hintEl.style.display = 'none';
|
||||
refEl.style.display = selectedReferenceNode ? 'block' : 'none';
|
||||
}
|
||||
renderMarkers();
|
||||
});
|
||||
|
||||
// Hash Labels toggle
|
||||
const hashLabelEl = document.getElementById('mcHashLabels');
|
||||
@@ -646,6 +662,11 @@
|
||||
const status = getNodeStatus(role, lastMs);
|
||||
if (status !== filters.statusFilter) return false;
|
||||
}
|
||||
// Neighbor filter: show only the reference node and its direct neighbors
|
||||
if (filters.neighbors && selectedReferenceNode && neighborPubkeys) {
|
||||
const pk = n.public_key;
|
||||
if (pk !== selectedReferenceNode && !neighborPubkeys.has(pk)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -724,6 +745,53 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function selectReferenceNode(pubkey, name) {
|
||||
selectedReferenceNode = pubkey;
|
||||
neighborPubkeys = new Set();
|
||||
try {
|
||||
// Use affinity-based neighbor API (server-side disambiguation) instead of
|
||||
// client-side path walking which fails on hash collisions (#484)
|
||||
const data = await api('/nodes/' + pubkey + '/neighbors?min_count=3');
|
||||
for (const n of (data.neighbors || [])) {
|
||||
if (n.pubkey) neighborPubkeys.add(n.pubkey);
|
||||
// For ambiguous edges, include all candidates (better to show extra than miss)
|
||||
if (n.candidates) n.candidates.forEach(function(c) { if (c.pubkey) neighborPubkeys.add(c.pubkey); });
|
||||
}
|
||||
// If affinity data is insufficient, fall back to client-side path walking
|
||||
if (neighborPubkeys.size === 0) {
|
||||
const pathData = await api('/nodes/' + pubkey + '/paths');
|
||||
const paths = pathData.paths || [];
|
||||
for (const p of paths) {
|
||||
const hops = p.hops || [];
|
||||
for (var i = 0; i < hops.length; i++) {
|
||||
if (hops[i].pubkey === pubkey) {
|
||||
if (i > 0 && hops[i - 1].pubkey) neighborPubkeys.add(hops[i - 1].pubkey);
|
||||
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborPubkeys.add(hops[i + 1].pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch neighbors for', pubkey, ':', e);
|
||||
neighborPubkeys = new Set();
|
||||
}
|
||||
// Update sidebar UI
|
||||
const refEl = document.getElementById('mcNeighborRef');
|
||||
const refNameEl = document.getElementById('mcNeighborRefName');
|
||||
const hintEl = document.getElementById('mcNeighborHint');
|
||||
if (refEl) { refEl.style.display = 'block'; }
|
||||
if (refNameEl) { refNameEl.textContent = name || pubkey.slice(0, 8); }
|
||||
if (hintEl) { hintEl.style.display = 'none'; }
|
||||
// Auto-enable the neighbors filter
|
||||
filters.neighbors = true;
|
||||
const cb = document.getElementById('mcNeighbors');
|
||||
if (cb) cb.checked = true;
|
||||
renderMarkers();
|
||||
}
|
||||
// Expose for popup onclick and testing
|
||||
window._mapSelectRefNode = selectReferenceNode;
|
||||
window._mapGetNeighborPubkeys = function() { return neighborPubkeys ? Array.from(neighborPubkeys) : []; };
|
||||
|
||||
function buildPopup(node) {
|
||||
const key = node.public_key ? truncate(node.public_key, 16) : '—';
|
||||
const loc = (node.lat && node.lon) ? `${node.lat.toFixed(5)}, ${node.lon.toFixed(5)}` : '—';
|
||||
@@ -749,7 +817,10 @@
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Adverts</dt>
|
||||
<dd style="margin-left:88px;padding:2px 0;">${node.advert_count || 0}</dd>
|
||||
</dl>
|
||||
<div style="margin-top:8px;clear:both;"><a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node →</a></div>
|
||||
<div style="margin-top:8px;clear:both;">
|
||||
<a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node →</a>
|
||||
${node.public_key ? ` · <a href="#" onclick="event.preventDefault();window._mapSelectRefNode('${safeEsc(node.public_key.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/</g, '\\x3c'))}','${safeEsc((node.name || 'Unknown').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/</g, '\\x3c'))}')" style="color:var(--accent);font-size:12px;">Show Neighbors</a>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -775,6 +846,10 @@
|
||||
routeLayer = null;
|
||||
if (heatLayer) { heatLayer = null; }
|
||||
geoFilterLayer = null;
|
||||
selectedReferenceNode = null;
|
||||
neighborPubkeys = null;
|
||||
delete window._mapSelectRefNode;
|
||||
delete window._mapGetNeighborPubkeys;
|
||||
}
|
||||
|
||||
function toggleHeatmap(on) {
|
||||
|
||||
+42
-2
@@ -228,11 +228,39 @@
|
||||
loadNodes();
|
||||
// Auto-refresh when ADVERT packets arrive via WebSocket (fixes #131)
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (msgs.some(isAdvertMessage)) {
|
||||
_allNodes = null;
|
||||
const advertMsgs = msgs.filter(isAdvertMessage);
|
||||
if (!advertMsgs.length) return;
|
||||
|
||||
if (!_allNodes) {
|
||||
invalidateApiCache('/nodes');
|
||||
loadNodes(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let needReload = false;
|
||||
for (const m of advertMsgs) {
|
||||
const payload = m.data && m.data.decoded && m.data.decoded.payload;
|
||||
const pubKey = payload && (payload.pubKey || payload.public_key);
|
||||
if (!pubKey) { needReload = true; break; }
|
||||
|
||||
const existing = _allNodes.find(n => n.public_key === pubKey);
|
||||
if (existing) {
|
||||
if (payload.name) existing.name = payload.name;
|
||||
if (payload.lat != null) existing.lat = payload.lat;
|
||||
if (payload.lon != null) existing.lon = payload.lon;
|
||||
const ts = m.data.packet && (m.data.packet.timestamp || m.data.packet.first_seen);
|
||||
if (ts) existing.last_seen = ts;
|
||||
} else {
|
||||
needReload = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needReload) {
|
||||
_allNodes = null;
|
||||
invalidateApiCache('/nodes');
|
||||
}
|
||||
loadNodes(true);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
@@ -929,4 +957,16 @@
|
||||
|
||||
// Test hooks
|
||||
window._nodesIsAdvertMessage = isAdvertMessage;
|
||||
window._nodesGetAllNodes = function() { return _allNodes; };
|
||||
window._nodesSetAllNodes = function(n) { _allNodes = n; };
|
||||
window._nodesToggleSort = toggleSort;
|
||||
window._nodesSortNodes = sortNodes;
|
||||
window._nodesSortArrow = sortArrow;
|
||||
window._nodesGetSortState = function() { return sortState; };
|
||||
window._nodesSetSortState = function(s) { sortState = s; };
|
||||
window._nodesSyncClaimedToFavorites = syncClaimedToFavorites;
|
||||
window._nodesRenderNodeTimestampHtml = renderNodeTimestampHtml;
|
||||
window._nodesRenderNodeTimestampText = renderNodeTimestampText;
|
||||
window._nodesGetStatusInfo = getStatusInfo;
|
||||
window._nodesGetStatusTooltip = getStatusTooltip;
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/* === CoreScope — packet-helpers.js (shared packet utilities) === */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Cached JSON.parse helpers for packet data (issue #387).
|
||||
* Avoids repeated parsing of path_json / decoded_json on the same packet object.
|
||||
* Results are cached as _parsedPath / _parsedDecoded properties on the packet.
|
||||
*
|
||||
* Handles pre-parsed objects (non-string values) gracefully — returns them as-is.
|
||||
*/
|
||||
|
||||
window.getParsedPath = function getParsedPath(p) {
|
||||
if (p._parsedPath !== undefined) return p._parsedPath;
|
||||
var raw = p.path_json;
|
||||
if (typeof raw !== 'string') {
|
||||
p._parsedPath = Array.isArray(raw) ? raw : [];
|
||||
return p._parsedPath;
|
||||
}
|
||||
try { p._parsedPath = JSON.parse(raw) || []; } catch (e) { p._parsedPath = []; }
|
||||
return p._parsedPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear cached _parsedPath/_parsedDecoded from a packet object.
|
||||
* Must be called after spreading a parent packet into an observation/child,
|
||||
* otherwise the child inherits stale cached values from the parent (issue #504).
|
||||
*/
|
||||
window.clearParsedCache = function clearParsedCache(p) {
|
||||
delete p._parsedPath;
|
||||
delete p._parsedDecoded;
|
||||
return p;
|
||||
};
|
||||
|
||||
window.getParsedDecoded = function getParsedDecoded(p) {
|
||||
if (p._parsedDecoded !== undefined) return p._parsedDecoded;
|
||||
var raw = p.decoded_json;
|
||||
if (typeof raw !== 'string') {
|
||||
p._parsedDecoded = (raw && typeof raw === 'object') ? raw : {};
|
||||
return p._parsedDecoded;
|
||||
}
|
||||
try { p._parsedDecoded = JSON.parse(raw) || {}; } catch (e) { p._parsedDecoded = {}; }
|
||||
return p._parsedDecoded;
|
||||
};
|
||||
+353
-135
@@ -8,7 +8,7 @@
|
||||
// Resolve observer_id to friendly name from loaded observers list
|
||||
function obsName(id) {
|
||||
if (!id) return '—';
|
||||
const o = observers.find(ob => ob.id === id);
|
||||
const o = observerMap.get(id);
|
||||
if (!o) return id;
|
||||
return o.iata ? `${o.name} (${o.iata})` : o.name;
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
let packetsPaused = false;
|
||||
let pauseBuffer = [];
|
||||
let observers = [];
|
||||
let observerMap = new Map(); // id → observer for O(1) lookups (#383)
|
||||
let regionMap = {};
|
||||
const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 7:'Anon Req', 8:'Path', 9:'Trace', 11:'Control' };
|
||||
function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; }
|
||||
@@ -34,9 +35,31 @@
|
||||
let hopNameCache = {};
|
||||
let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true';
|
||||
let filtersBuilt = false;
|
||||
let _renderTimer = null;
|
||||
function scheduleRender() {
|
||||
clearTimeout(_renderTimer);
|
||||
_renderTimer = setTimeout(() => renderTableRows(), 200);
|
||||
}
|
||||
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
|
||||
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
|
||||
|
||||
// getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file)
|
||||
const getParsedPath = window.getParsedPath;
|
||||
const getParsedDecoded = window.getParsedDecoded;
|
||||
|
||||
// --- Virtual scroll state ---
|
||||
const VSCROLL_ROW_HEIGHT = 36; // estimated row height in px
|
||||
const VSCROLL_BUFFER = 30; // extra rows above/below viewport
|
||||
let _displayPackets = []; // filtered packets for current view
|
||||
let _displayGrouped = false; // whether _displayPackets is in grouped mode
|
||||
let _rowCounts = []; // per-entry DOM row counts (1 for flat, 1+children for expanded groups)
|
||||
let _cumulativeOffsetsCache = null; // cached cumulative offsets, invalidated on _rowCounts change
|
||||
let _lastVisibleStart = -1; // last rendered start index (for dirty checking)
|
||||
let _lastVisibleEnd = -1; // last rendered end index (for dirty checking)
|
||||
let _vsScrollHandler = null; // scroll listener reference
|
||||
let _wsRenderTimer = null; // debounce timer for WS-triggered renders
|
||||
let _observerFilterSet = null; // cached Set from filters.observer, hoisted above loops (#427)
|
||||
|
||||
function closeDetailPanel() {
|
||||
var panel = document.getElementById('pktRight');
|
||||
if (panel) {
|
||||
@@ -246,6 +269,7 @@
|
||||
if (obs) {
|
||||
expandedHashes.add(h);
|
||||
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp};
|
||||
clearParsedCache(obsPacket);
|
||||
selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id);
|
||||
} else {
|
||||
selectPacket(data.packet.id, h, data);
|
||||
@@ -301,7 +325,7 @@
|
||||
panel.appendChild(content);
|
||||
const pkt = data.packet;
|
||||
try {
|
||||
const hops = JSON.parse(pkt.path_json || '[]');
|
||||
const hops = getParsedPath(pkt);
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
} catch {}
|
||||
@@ -313,6 +337,7 @@
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (packetsPaused) {
|
||||
pauseBuffer.push(...msgs);
|
||||
if (pauseBuffer.length > 2000) pauseBuffer = pauseBuffer.slice(-2000);
|
||||
const btn = document.getElementById('pktPauseBtn');
|
||||
if (btn) btn.textContent = '▶ ' + pauseBuffer.length;
|
||||
return;
|
||||
@@ -324,12 +349,19 @@
|
||||
|
||||
// Check if new packets pass current filters
|
||||
const filtered = newPkts.filter(p => {
|
||||
// Respect time window filter — drop packets outside the selected window
|
||||
const windowMin = savedTimeWindowMin;
|
||||
if (windowMin > 0) {
|
||||
const cutoff = new Date(Date.now() - windowMin * 60000).toISOString();
|
||||
const pktTime = p.latest || p.timestamp || p.first_seen;
|
||||
if (pktTime && pktTime < cutoff) return false;
|
||||
}
|
||||
if (filters.type) { const types = filters.type.split(',').map(Number); if (!types.includes(p.payload_type)) return false; }
|
||||
if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id)) return false; }
|
||||
if (filters.hash && p.hash !== filters.hash) return false;
|
||||
if (RegionFilter.getRegionParam()) {
|
||||
const selectedRegions = RegionFilter.getRegionParam().split(',');
|
||||
const obs = observers.find(o => o.id === p.observer_id);
|
||||
const obs = observerMap.get(p.observer_id);
|
||||
if (!obs || !selectedRegions.includes(obs.iata)) return false;
|
||||
}
|
||||
if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false;
|
||||
@@ -340,7 +372,7 @@
|
||||
// Resolve any new hops, then update and re-render
|
||||
const newHops = new Set();
|
||||
for (const p of filtered) {
|
||||
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
try { getParsedPath(p).forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
}
|
||||
(newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => {
|
||||
if (groupByHash) {
|
||||
@@ -362,6 +394,7 @@
|
||||
// Update expanded children if this group is expanded
|
||||
if (expandedHashes.has(h) && existing._children) {
|
||||
existing._children.unshift(p);
|
||||
if (existing._children.length > 200) existing._children.length = 200;
|
||||
sortGroupChildren(existing);
|
||||
}
|
||||
} else {
|
||||
@@ -382,21 +415,37 @@
|
||||
if (h) hashIndex.set(h, newGroup);
|
||||
}
|
||||
}
|
||||
// Re-sort by latest DESC
|
||||
// Re-sort by latest DESC, then evict oldest beyond the limit
|
||||
packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || ''));
|
||||
if (packets.length > PACKET_LIMIT) {
|
||||
const evicted = packets.splice(PACKET_LIMIT);
|
||||
for (const p of evicted) { if (p.hash) hashIndex.delete(p.hash); }
|
||||
}
|
||||
} else {
|
||||
// Flat mode: prepend
|
||||
// Flat mode: prepend, then evict oldest beyond the limit
|
||||
packets = filtered.concat(packets);
|
||||
if (packets.length > PACKET_LIMIT) packets.length = PACKET_LIMIT;
|
||||
}
|
||||
totalCount += filtered.length;
|
||||
renderTableRows();
|
||||
// Debounce WS-triggered renders to avoid rapid full rebuilds
|
||||
clearTimeout(_wsRenderTimer);
|
||||
_wsRenderTimer = setTimeout(function () { renderTableRows(); }, 200);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
clearTimeout(_renderTimer);
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
detachVScrollListener();
|
||||
clearTimeout(_wsRenderTimer);
|
||||
_displayPackets = [];
|
||||
_rowCounts = [];
|
||||
_cumulativeOffsetsCache = null;
|
||||
_observerFilterSet = null;
|
||||
_lastVisibleStart = -1;
|
||||
_lastVisibleEnd = -1;
|
||||
if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; }
|
||||
if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; }
|
||||
if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; }
|
||||
@@ -409,6 +458,7 @@
|
||||
hopNameCache = {};
|
||||
totalCount = 0;
|
||||
observers = [];
|
||||
observerMap = new Map();
|
||||
directPacketId = null;
|
||||
directPacketHash = null;
|
||||
groupByHash = true;
|
||||
@@ -420,6 +470,7 @@
|
||||
try {
|
||||
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||||
observers = data.observers || [];
|
||||
observerMap = new Map(observers.map(o => [o.id, o]));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -451,7 +502,7 @@
|
||||
await Promise.all(multiObs.map(async (p) => {
|
||||
try {
|
||||
const d = await api(`/packets/${p.hash}`);
|
||||
if (d?.observations) p._children = d.observations.map(o => ({...d.packet, ...o, _isObservation: true}));
|
||||
if (d?.observations) p._children = d.observations.map(o => clearParsedCache({...d.packet, ...o, _isObservation: true}));
|
||||
} catch {}
|
||||
}));
|
||||
// Flatten: replace grouped packets with individual observations
|
||||
@@ -470,7 +521,7 @@
|
||||
// Pre-resolve all path hops to node names
|
||||
const allHops = new Set();
|
||||
for (const p of packets) {
|
||||
try { const path = JSON.parse(p.path_json || '[]'); path.forEach(h => allHops.add(h)); } catch {}
|
||||
try { getParsedPath(p).forEach(h => allHops.add(h)); } catch {}
|
||||
}
|
||||
if (allHops.size) await resolveHops([...allHops]);
|
||||
|
||||
@@ -479,7 +530,7 @@
|
||||
for (const p of packets) {
|
||||
if (!p.observer_id) continue;
|
||||
try {
|
||||
const path = JSON.parse(p.path_json || '[]');
|
||||
const path = getParsedPath(p);
|
||||
const ambiguous = path.filter(h => hopNameCache[h]?.ambiguous);
|
||||
if (ambiguous.length) {
|
||||
if (!hopsByObserver[p.observer_id]) hopsByObserver[p.observer_id] = new Set();
|
||||
@@ -666,7 +717,7 @@
|
||||
obsTrigger.textContent = 'All Observers ▾';
|
||||
} else if (selectedObservers.size === 1) {
|
||||
const id = [...selectedObservers][0];
|
||||
const o = observers.find(x => String(x.id) === id);
|
||||
const o = observerMap.get(id) || observerMap.get(Number(id));
|
||||
obsTrigger.textContent = (o ? (o.name || o.id) : id) + ' ▾';
|
||||
} else {
|
||||
obsTrigger.textContent = selectedObservers.size + ' Observers ▾';
|
||||
@@ -787,7 +838,7 @@
|
||||
try {
|
||||
const data = await api(`/packets/${p.hash}`);
|
||||
if (data?.packet && data.observations) {
|
||||
p._children = data.observations.map(o => ({...data.packet, ...o, _isObservation: true}));
|
||||
p._children = data.observations.map(o => clearParsedCache({...data.packet, ...o, _isObservation: true}));
|
||||
p._fetchedData = data;
|
||||
}
|
||||
} catch {}
|
||||
@@ -800,7 +851,7 @@
|
||||
// Resolve any new hops from updated header paths
|
||||
const newHops = new Set();
|
||||
for (const p of packets) {
|
||||
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
try { getParsedPath(p).forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
|
||||
}
|
||||
if (newHops.size) await resolveHops([...newHops]);
|
||||
renderTableRows();
|
||||
@@ -960,6 +1011,7 @@
|
||||
if (child) {
|
||||
const parentData = group._fetchedData;
|
||||
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child;
|
||||
if (parentData) { clearParsedCache(obsPacket); }
|
||||
selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id);
|
||||
}
|
||||
}
|
||||
@@ -981,6 +1033,231 @@
|
||||
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
|
||||
}
|
||||
|
||||
// Build HTML for a single grouped packet row
|
||||
function buildGroupRowHtml(p) {
|
||||
const isExpanded = expandedHashes.has(p.hash);
|
||||
let headerObserverId = p.observer_id;
|
||||
let headerPathJson = p.path_json;
|
||||
if (_observerFilterSet && p._children?.length) {
|
||||
const match = p._children.find(c => _observerFilterSet.has(String(c.observer_id)));
|
||||
if (match) {
|
||||
headerObserverId = match.observer_id;
|
||||
headerPathJson = match.path_json;
|
||||
}
|
||||
}
|
||||
const groupRegion = headerObserverId ? (observerMap.get(headerObserverId)?.iata || '') : '';
|
||||
let groupPath = [];
|
||||
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
|
||||
const groupPathStr = renderPath(groupPath, headerObserverId);
|
||||
const groupTypeName = payloadTypeName(p.payload_type);
|
||||
const groupTypeClass = payloadTypeColor(p.payload_type);
|
||||
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const isSingle = p.count <= 1;
|
||||
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
|
||||
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.latest)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="col-hashsize mono">${groupHashBytes}</td>
|
||||
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
|
||||
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
|
||||
<td class="col-details">${getDetailPreview(getParsedDecoded(p))}</td>
|
||||
</tr>`;
|
||||
if (isExpanded && p._children) {
|
||||
let visibleChildren = p._children;
|
||||
if (_observerFilterSet) {
|
||||
visibleChildren = visibleChildren.filter(c => _observerFilterSet.has(String(c.observer_id)));
|
||||
}
|
||||
for (const c of visibleChildren) {
|
||||
const typeName = payloadTypeName(c.payload_type);
|
||||
const typeClass = payloadTypeColor(c.payload_type);
|
||||
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
|
||||
const childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const childRegion = c.observer_id ? (observerMap.get(c.observer_id)?.iata || '') : '';
|
||||
const childPath = getParsedPath(c);
|
||||
const childPathStr = renderPath(childPath, c.observer_id);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-hashsize mono">${childHashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
|
||||
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${getDetailPreview(getParsedDecoded(c))}</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// Build HTML for a single flat (ungrouped) packet row
|
||||
function buildFlatRowHtml(p) {
|
||||
const decoded = getParsedDecoded(p);
|
||||
const pathHops = getParsedPath(p);
|
||||
const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : '';
|
||||
const typeName = payloadTypeName(p.payload_type);
|
||||
const typeClass = payloadTypeColor(p.payload_type);
|
||||
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const pathStr = renderPath(pathHops, p.observer_id);
|
||||
const detail = getDetailPreview(decoded);
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-hashsize mono">${hashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
|
||||
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${detail}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// Compute the number of DOM <tr> rows a single entry produces.
|
||||
// Used by both row counting and renderVisibleRows to avoid divergence (#424).
|
||||
function _getRowCount(p) {
|
||||
if (!_displayGrouped) return 1;
|
||||
if (!expandedHashes.has(p.hash) || !p._children) return 1;
|
||||
let childCount = p._children.length;
|
||||
if (_observerFilterSet) {
|
||||
childCount = p._children.filter(c => _observerFilterSet.has(String(c.observer_id))).length;
|
||||
}
|
||||
return 1 + childCount;
|
||||
}
|
||||
|
||||
// Get the column count from the thead (dynamic, avoids hardcoded colspan — #426)
|
||||
function _getColCount() {
|
||||
const thead = document.querySelector('#pktLeft thead tr');
|
||||
return thead ? thead.children.length : 11;
|
||||
}
|
||||
|
||||
// Compute cumulative DOM row offsets from per-entry row counts.
|
||||
// Returns array where cumulativeOffsets[i] = total <tr> rows before entry i.
|
||||
function _cumulativeRowOffsets() {
|
||||
if (_cumulativeOffsetsCache) return _cumulativeOffsetsCache;
|
||||
const offsets = new Array(_rowCounts.length + 1);
|
||||
offsets[0] = 0;
|
||||
for (let i = 0; i < _rowCounts.length; i++) {
|
||||
offsets[i + 1] = offsets[i] + _rowCounts[i];
|
||||
}
|
||||
_cumulativeOffsetsCache = offsets;
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function renderVisibleRows() {
|
||||
const tbody = document.getElementById('pktBody');
|
||||
if (!tbody || !_displayPackets.length) return;
|
||||
|
||||
const scrollContainer = document.getElementById('pktLeft');
|
||||
if (!scrollContainer) return;
|
||||
|
||||
// Compute total DOM rows accounting for expanded groups
|
||||
const offsets = _cumulativeRowOffsets();
|
||||
const totalDomRows = offsets[offsets.length - 1];
|
||||
const totalHeight = totalDomRows * VSCROLL_ROW_HEIGHT;
|
||||
const colCount = _getColCount();
|
||||
|
||||
// Get or create spacer elements
|
||||
let topSpacer = document.getElementById('vscroll-top');
|
||||
let bottomSpacer = document.getElementById('vscroll-bottom');
|
||||
if (!topSpacer) {
|
||||
topSpacer = document.createElement('tr');
|
||||
topSpacer.id = 'vscroll-top';
|
||||
topSpacer.innerHTML = '<td colspan="' + colCount + '" style="padding:0;border:0"></td>';
|
||||
}
|
||||
if (!bottomSpacer) {
|
||||
bottomSpacer = document.createElement('tr');
|
||||
bottomSpacer.id = 'vscroll-bottom';
|
||||
bottomSpacer.innerHTML = '<td colspan="' + colCount + '" style="padding:0;border:0"></td>';
|
||||
}
|
||||
|
||||
// Calculate visible range based on scroll position
|
||||
const scrollTop = scrollContainer.scrollTop;
|
||||
const viewportHeight = scrollContainer.clientHeight;
|
||||
// Account for thead height (~40px)
|
||||
const theadHeight = 40;
|
||||
const adjustedScrollTop = Math.max(0, scrollTop - theadHeight);
|
||||
|
||||
// Find the first entry whose cumulative row offset covers the scroll position
|
||||
const firstDomRow = Math.floor(adjustedScrollTop / VSCROLL_ROW_HEIGHT);
|
||||
const visibleDomCount = Math.ceil(viewportHeight / VSCROLL_ROW_HEIGHT);
|
||||
|
||||
// Binary search for entry index containing firstDomRow
|
||||
let lo = 0, hi = _displayPackets.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (offsets[mid + 1] <= firstDomRow) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const firstEntry = lo;
|
||||
|
||||
// Find entry index covering last visible DOM row
|
||||
const lastDomRow = firstDomRow + visibleDomCount;
|
||||
lo = firstEntry; hi = _displayPackets.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (offsets[mid + 1] <= lastDomRow) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const lastEntry = Math.min(lo + 1, _displayPackets.length);
|
||||
|
||||
const startIdx = Math.max(0, firstEntry - VSCROLL_BUFFER);
|
||||
const endIdx = Math.min(_displayPackets.length, lastEntry + VSCROLL_BUFFER);
|
||||
|
||||
// Skip DOM rebuild if visible range hasn't changed
|
||||
if (startIdx === _lastVisibleStart && endIdx === _lastVisibleEnd) return;
|
||||
_lastVisibleStart = startIdx;
|
||||
_lastVisibleEnd = endIdx;
|
||||
|
||||
// Compute padding using cumulative row counts
|
||||
const topPad = offsets[startIdx] * VSCROLL_ROW_HEIGHT;
|
||||
const bottomPad = (totalDomRows - offsets[endIdx]) * VSCROLL_ROW_HEIGHT;
|
||||
|
||||
topSpacer.firstChild.style.height = topPad + 'px';
|
||||
bottomSpacer.firstChild.style.height = bottomPad + 'px';
|
||||
|
||||
// LAZY ROW GENERATION: only build HTML for the visible slice (#422)
|
||||
const builder = _displayGrouped ? buildGroupRowHtml : buildFlatRowHtml;
|
||||
const visibleSlice = _displayPackets.slice(startIdx, endIdx);
|
||||
const visibleHtml = visibleSlice.map(p => builder(p)).join('');
|
||||
tbody.innerHTML = '';
|
||||
tbody.appendChild(topSpacer);
|
||||
tbody.insertAdjacentHTML('beforeend', visibleHtml);
|
||||
tbody.appendChild(bottomSpacer);
|
||||
}
|
||||
|
||||
// Attach/detach scroll listener for virtual scrolling
|
||||
function attachVScrollListener() {
|
||||
const scrollContainer = document.getElementById('pktLeft');
|
||||
if (!scrollContainer) return;
|
||||
if (_vsScrollHandler) return; // already attached
|
||||
let scrollRaf = null;
|
||||
_vsScrollHandler = function () {
|
||||
if (scrollRaf) return;
|
||||
scrollRaf = requestAnimationFrame(function () {
|
||||
scrollRaf = null;
|
||||
renderVisibleRows();
|
||||
});
|
||||
};
|
||||
scrollContainer.addEventListener('scroll', _vsScrollHandler, { passive: true });
|
||||
}
|
||||
|
||||
function detachVScrollListener() {
|
||||
if (!_vsScrollHandler) return;
|
||||
const scrollContainer = document.getElementById('pktLeft');
|
||||
if (scrollContainer) scrollContainer.removeEventListener('scroll', _vsScrollHandler);
|
||||
_vsScrollHandler = null;
|
||||
}
|
||||
|
||||
async function renderTableRows() {
|
||||
const tbody = document.getElementById('pktBody');
|
||||
if (!tbody) return;
|
||||
@@ -990,7 +1267,7 @@
|
||||
const groupBtn = document.getElementById('fGroup');
|
||||
if (groupBtn) groupBtn.classList.toggle('active', groupByHash);
|
||||
|
||||
// Filter to claimed/favorited nodes if toggle is on — use server-side multi-node lookup
|
||||
// Filter to claimed/favorited nodes — pure client-side filter (no server round-trip)
|
||||
let displayPackets = packets;
|
||||
if (filters.myNodes) {
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
@@ -998,10 +1275,10 @@
|
||||
const favs = getFavorites();
|
||||
const allKeys = [...new Set([...myKeys, ...favs])];
|
||||
if (allKeys.length > 0) {
|
||||
try {
|
||||
const myData = await api('/packets?nodes=' + allKeys.join(',') + '&limit=500');
|
||||
displayPackets = myData.packets || [];
|
||||
} catch { displayPackets = []; }
|
||||
displayPackets = displayPackets.filter(p => {
|
||||
const dj = p.decoded_json || '';
|
||||
return allKeys.some(k => dj.includes(k));
|
||||
});
|
||||
} else {
|
||||
displayPackets = [];
|
||||
}
|
||||
@@ -1033,108 +1310,31 @@
|
||||
if (countEl) countEl.textContent = `(${displayPackets.length})`;
|
||||
|
||||
if (!displayPackets.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted" style="padding:24px">' + (filters.myNodes ? 'No packets from your claimed/favorited nodes' : 'No packets found') + '</td></tr>';
|
||||
_displayPackets = [];
|
||||
_rowCounts = [];
|
||||
_cumulativeOffsetsCache = null;
|
||||
_observerFilterSet = null;
|
||||
_lastVisibleStart = -1;
|
||||
_lastVisibleEnd = -1;
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupByHash) {
|
||||
let html = '';
|
||||
for (const p of displayPackets) {
|
||||
const isExpanded = expandedHashes.has(p.hash);
|
||||
// When observer filter is active, use first matching child's data for header
|
||||
let headerObserverId = p.observer_id;
|
||||
let headerPathJson = p.path_json;
|
||||
if (filters.observer && p._children?.length) {
|
||||
const obsIds = new Set(filters.observer.split(','));
|
||||
const match = p._children.find(c => obsIds.has(String(c.observer_id)));
|
||||
if (match) {
|
||||
headerObserverId = match.observer_id;
|
||||
headerPathJson = match.path_json;
|
||||
}
|
||||
}
|
||||
const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : '';
|
||||
let groupPath = [];
|
||||
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
|
||||
const groupPathStr = renderPath(groupPath, headerObserverId);
|
||||
const groupTypeName = payloadTypeName(p.payload_type);
|
||||
const groupTypeClass = payloadTypeColor(p.payload_type);
|
||||
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const isSingle = p.count <= 1;
|
||||
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
|
||||
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.latest)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="col-hashsize mono">${groupHashBytes}</td>
|
||||
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
|
||||
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
|
||||
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
|
||||
</tr>`;
|
||||
// Child rows (loaded async when expanded)
|
||||
if (isExpanded && p._children) {
|
||||
let visibleChildren = p._children;
|
||||
// Filter children by selected observers
|
||||
if (filters.observer) {
|
||||
const obsSet = new Set(filters.observer.split(','));
|
||||
visibleChildren = visibleChildren.filter(c => obsSet.has(String(c.observer_id)));
|
||||
}
|
||||
for (const c of visibleChildren) {
|
||||
const typeName = payloadTypeName(c.payload_type);
|
||||
const typeClass = payloadTypeColor(c.payload_type);
|
||||
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
|
||||
const childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
|
||||
let childPath = [];
|
||||
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
|
||||
const childPathStr = renderPath(childPath, c.observer_id);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-hashsize mono">${childHashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
|
||||
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())}</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
// Lazy virtual scroll: store display packets and row counts, but do NOT
|
||||
// pre-generate HTML strings. HTML is built on-demand in renderVisibleRows()
|
||||
// for only the visible slice + buffer (#422).
|
||||
_lastVisibleStart = -1;
|
||||
_lastVisibleEnd = -1;
|
||||
_displayPackets = displayPackets;
|
||||
_displayGrouped = groupByHash;
|
||||
_observerFilterSet = filters.observer ? new Set(filters.observer.split(',')) : null;
|
||||
_rowCounts = displayPackets.map(p => _getRowCount(p));
|
||||
_cumulativeOffsetsCache = null;
|
||||
|
||||
tbody.innerHTML = displayPackets.map(p => {
|
||||
let decoded, pathHops = [];
|
||||
try { decoded = JSON.parse(p.decoded_json); } catch {}
|
||||
try { pathHops = JSON.parse(p.path_json || '[]'); } catch {}
|
||||
|
||||
const region = p.observer_id ? (observers.find(o => o.id === p.observer_id)?.iata || '') : '';
|
||||
const typeName = payloadTypeName(p.payload_type);
|
||||
const typeClass = payloadTypeColor(p.payload_type);
|
||||
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded);
|
||||
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-hashsize mono">${hashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
|
||||
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${detail}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
attachVScrollListener();
|
||||
renderVisibleRows();
|
||||
}
|
||||
|
||||
function getDetailPreview(decoded) {
|
||||
@@ -1218,7 +1418,7 @@
|
||||
// Resolve path hops for detail view
|
||||
const pkt = data.packet;
|
||||
try {
|
||||
const hops = JSON.parse(pkt.path_json || '[]');
|
||||
const hops = getParsedPath(pkt);
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
} catch {}
|
||||
@@ -1236,10 +1436,8 @@
|
||||
const pkt = data.packet;
|
||||
const breakdown = data.breakdown || {};
|
||||
const ranges = breakdown.ranges || [];
|
||||
let decoded;
|
||||
try { decoded = JSON.parse(pkt.decoded_json); } catch { decoded = {}; }
|
||||
let pathHops;
|
||||
try { pathHops = JSON.parse(pkt.path_json || '[]'); } catch { pathHops = []; }
|
||||
const decoded = getParsedDecoded(pkt);
|
||||
const pathHops = getParsedPath(pkt);
|
||||
|
||||
// Resolve sender GPS — from packet directly, or from known node in DB
|
||||
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
|
||||
@@ -1415,10 +1613,8 @@
|
||||
const replayPackets = [];
|
||||
if (obs.length > 1) {
|
||||
for (const o of obs) {
|
||||
let oPath;
|
||||
try { oPath = JSON.parse(o.path_json || '[]'); } catch { oPath = pathHops; }
|
||||
let oDec;
|
||||
try { oDec = JSON.parse(o.decoded_json || '{}'); } catch { oDec = decoded; }
|
||||
const oPath = getParsedPath(o);
|
||||
const oDec = getParsedDecoded(o);
|
||||
replayPackets.push({
|
||||
id: o.id, hash: pkt.hash, raw: o.raw_hex || pkt.raw_hex,
|
||||
_ts: new Date(o.timestamp).getTime(),
|
||||
@@ -1493,7 +1689,7 @@
|
||||
let rows = '';
|
||||
|
||||
// Header section
|
||||
rows += sectionRow('Header');
|
||||
rows += sectionRow('Header', 'section-header');
|
||||
rows += fieldRow(0, 'Header Byte', '0x' + (buf.slice(0, 2) || '??'), `Route: ${routeTypeName(pkt.route_type)}, Payload: ${payloadTypeName(pkt.payload_type)}`);
|
||||
const pathByte0 = parseInt(buf.slice(2, 4), 16);
|
||||
const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1);
|
||||
@@ -1503,7 +1699,7 @@
|
||||
// Transport codes
|
||||
let off = 2;
|
||||
if (pkt.route_type === 0 || pkt.route_type === 3) {
|
||||
rows += sectionRow('Transport Codes');
|
||||
rows += sectionRow('Transport Codes', 'section-transport');
|
||||
rows += fieldRow(off, 'Next Hop', buf.slice(off * 2, (off + 2) * 2), '');
|
||||
rows += fieldRow(off + 2, 'Last Hop', buf.slice((off + 2) * 2, (off + 4) * 2), '');
|
||||
off += 4;
|
||||
@@ -1511,7 +1707,7 @@
|
||||
|
||||
// Path
|
||||
if (pathHops.length > 0) {
|
||||
rows += sectionRow('Path (' + pathHops.length + ' hops)');
|
||||
rows += sectionRow('Path (' + pathHops.length + ' hops)', 'section-path');
|
||||
const pathByte = parseInt(buf.slice(2, 4), 16);
|
||||
const hashSize = (pathByte >> 6) + 1;
|
||||
for (let i = 0; i < pathHops.length; i++) {
|
||||
@@ -1523,7 +1719,7 @@
|
||||
}
|
||||
|
||||
// Payload
|
||||
rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type));
|
||||
rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type), 'section-payload');
|
||||
|
||||
if (decoded.type === 'ADVERT') {
|
||||
rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1));
|
||||
@@ -1573,8 +1769,8 @@
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function sectionRow(label) {
|
||||
return `<tr class="section-row"><td colspan="4">${label}</td></tr>`;
|
||||
function sectionRow(label, cls) {
|
||||
return `<tr class="section-row${cls ? ' ' + cls : ''}"><td colspan="4">${label}</td></tr>`;
|
||||
}
|
||||
function fieldRow(offset, name, value, desc) {
|
||||
return `<tr><td class="mono">${offset}</td><td>${name}</td><td class="mono">${value}</td><td class="text-muted">${desc || ''}</td></tr>`;
|
||||
@@ -1720,7 +1916,7 @@
|
||||
let obsSortMode = localStorage.getItem('meshcore-obs-sort') || SORT_OBSERVER;
|
||||
|
||||
function getPathHopCount(c) {
|
||||
try { return JSON.parse(c.path_json || '[]').length; } catch { return 0; }
|
||||
try { return getParsedPath(c).length; } catch { return 0; }
|
||||
}
|
||||
|
||||
function sortGroupChildren(group) {
|
||||
@@ -1785,7 +1981,7 @@
|
||||
if (!pkt) return;
|
||||
const group = packets.find(p => p.hash === hash);
|
||||
if (group && data.observations) {
|
||||
group._children = data.observations.map(o => ({...pkt, ...o, _isObservation: true}));
|
||||
group._children = data.observations.map(o => clearParsedCache({...pkt, ...o, _isObservation: true}));
|
||||
group._fetchedData = data;
|
||||
// Sort children based on current sort mode
|
||||
sortGroupChildren(group);
|
||||
@@ -1793,7 +1989,7 @@
|
||||
// Resolve any new hops from children
|
||||
const childHops = new Set();
|
||||
for (const c of (group?._children || [])) {
|
||||
try { JSON.parse(c.path_json || '[]').forEach(h => childHops.add(h)); } catch {}
|
||||
try { getParsedPath(c).forEach(h => childHops.add(h)); } catch {}
|
||||
}
|
||||
const newHops = [...childHops].filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
@@ -1826,6 +2022,28 @@
|
||||
});
|
||||
|
||||
// Standalone packet detail page: #/packet/123 or #/packet/HASH
|
||||
// Expose pure functions for unit testing (vm.createContext pattern)
|
||||
if (typeof window !== 'undefined') {
|
||||
window._packetsTestAPI = {
|
||||
typeName,
|
||||
obsName,
|
||||
getDetailPreview,
|
||||
sortGroupChildren,
|
||||
getPathHopCount,
|
||||
renderDecodedPacket,
|
||||
kv,
|
||||
buildFieldTable,
|
||||
sectionRow,
|
||||
fieldRow,
|
||||
renderTimestampCell,
|
||||
renderPath,
|
||||
_getRowCount,
|
||||
_cumulativeRowOffsets,
|
||||
buildGroupRowHtml,
|
||||
buildFlatRowHtml,
|
||||
};
|
||||
}
|
||||
|
||||
registerPage('packet-detail', {
|
||||
init: async (app, routeParam) => {
|
||||
const param = routeParam;
|
||||
@@ -1835,7 +2053,7 @@
|
||||
const data = await api(`/packets/${param}`);
|
||||
if (!data?.packet) { app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Packet not found</h2><p>Packet ${param} doesn't exist.</p><a href="#/packets">← Back to packets</a></div>`; return; }
|
||||
const hops = [];
|
||||
try { const ph = JSON.parse(data.packet.path_json || '[]'); hops.push(...ph); } catch {}
|
||||
try { hops.push(...getParsedPath(data.packet)); } catch {}
|
||||
const newHops = hops.filter(h => !(h in hopNameCache));
|
||||
if (newHops.length) await resolveHops(newHops);
|
||||
const container = document.createElement('div');
|
||||
|
||||
+38
-4
@@ -5,6 +5,7 @@
|
||||
--nav-bg2: #1a1a2e;
|
||||
--nav-text: #ffffff;
|
||||
--nav-text-muted: #cbd5e1;
|
||||
--nav-active-bg: rgba(74, 158, 255, 0.15);
|
||||
--accent: #4a9eff;
|
||||
--geo-filter-color: #3b82f6;
|
||||
--status-green: #22c55e;
|
||||
@@ -128,7 +129,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
.nav-link.active {
|
||||
color: var(--nav-text);
|
||||
border-bottom-color: transparent;
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
background: var(--nav-active-bg);
|
||||
border-radius: 6px;
|
||||
margin: 4px 0;
|
||||
padding: 10px 12px;
|
||||
@@ -374,6 +375,10 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
background: var(--section-bg, #eef2ff); font-weight: 700; font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: .5px; color: var(--accent);
|
||||
}
|
||||
.field-table .section-header td { background: rgba(243,139,168,0.18); }
|
||||
.field-table .section-transport td { background: rgba(137,180,250,0.18); }
|
||||
.field-table .section-path td { background: rgba(166,227,161,0.18); }
|
||||
.field-table .section-payload td { background: rgba(249,226,175,0.18); }
|
||||
|
||||
/* === Path display === */
|
||||
.path-hops {
|
||||
@@ -837,6 +842,22 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
|
||||
/* === Hamburger (hidden on desktop) === */
|
||||
.hamburger { display: none; }
|
||||
/* "More" button (hidden on desktop) */
|
||||
.nav-more-wrap { display: none; position: relative; }
|
||||
.nav-more-btn { display: inline-flex; }
|
||||
.nav-more-menu {
|
||||
display: none; position: absolute; top: calc(var(--top-nav-h, 52px) - 4px); right: 0;
|
||||
background: var(--nav-bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15); flex-direction: column;
|
||||
min-width: 160px; padding: 4px 0; z-index: 1200;
|
||||
}
|
||||
.nav-more-menu.open { display: flex; }
|
||||
.nav-more-menu .nav-link {
|
||||
padding: 10px 16px; border-bottom: none; border-radius: 0; margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-more-menu .nav-link:hover { background: var(--nav-bg2); color: var(--nav-text); }
|
||||
.nav-more-menu .nav-link.active { background: var(--nav-active-bg); }
|
||||
/* Ensure nav stays above Leaflet map */
|
||||
.nav-links.open { z-index: 1100; }
|
||||
#map-wrap .leaflet-container { z-index: 1; }
|
||||
@@ -851,18 +872,31 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.map-controls { width: 180px; font-size: 12px; }
|
||||
}
|
||||
|
||||
/* === Responsive — Hamburger nav (≤1023px) === */
|
||||
@media (max-width: 1023px) {
|
||||
/* === Responsive — Tablet Priority+ nav (768–1023px) === */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.nav-links { display: flex !important; flex-direction: row; gap: 2px; }
|
||||
.nav-links a:not([data-priority="high"]) { display: none; }
|
||||
.nav-more-wrap { display: flex; align-items: center; }
|
||||
.hamburger { display: none; }
|
||||
.nav-link { padding: 14px 8px; font-size: 13px; }
|
||||
.nav-links a[data-priority="high"] { order: -1; }
|
||||
.nav-link.active { background: var(--nav-active-bg); border-radius: 6px; margin: 4px 0; padding: 10px 8px; }
|
||||
}
|
||||
|
||||
/* === Responsive — Hamburger nav (<768px) === */
|
||||
@media (max-width: 767px) {
|
||||
.hamburger { display: inline-flex; }
|
||||
.nav-more-wrap { display: none !important; }
|
||||
.nav-links {
|
||||
display: none; position: absolute; top: 52px; left: 0; right: 0;
|
||||
background: var(--nav-bg); flex-direction: column; padding: 8px 0;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 1100;
|
||||
max-height: calc(100dvh - 52px); overflow-y: auto;
|
||||
}
|
||||
.nav-links a:not([data-priority="high"]) { display: flex; }
|
||||
.nav-links.open { display: flex; }
|
||||
.nav-link { padding: 12px 20px; border-bottom: none; }
|
||||
.nav-link.active { background: rgba(74,158,255,0.15); border-radius: 0; margin: 0; padding: 12px 20px; }
|
||||
.nav-link.active { background: var(--nav-active-bg); border-radius: 0; margin: 0; padding: 12px 20px; }
|
||||
.nav-left { gap: 12px; }
|
||||
body.nav-open { overflow: hidden; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* test-anim-perf.js — Performance benchmark for animation timer management
|
||||
*
|
||||
* Demonstrates that the rAF + concurrency-cap approach keeps active animation
|
||||
* count bounded, whereas the old setInterval approach accumulated without limit.
|
||||
*
|
||||
* Run: node test-anim-perf.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { console.log(` ✅ ${msg}`); passed++; }
|
||||
else { console.log(` ❌ ${msg}`); failed++; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulate OLD behaviour: setInterval-based, no concurrency cap
|
||||
// ---------------------------------------------------------------------------
|
||||
function simulateOldModel(packetsPerSec, hopsPerPacket, durationSec) {
|
||||
// Each hop spawns 3 intervals (pulse 26ms, line 33ms, fade 52ms).
|
||||
// Pulse lasts ~2s, line ~0.66s, fade ~0.8s+0.4s ≈ 1.2s
|
||||
// At any moment, timers from the last ~2s of packets are still alive.
|
||||
const intervalLifetimes = [2.0, 0.66, 1.2]; // seconds each interval lives
|
||||
let maxConcurrent = 0;
|
||||
// Walk through time in 0.1s steps
|
||||
const dt = 0.1;
|
||||
const spawns = []; // {time, lifetime}
|
||||
for (let t = 0; t < durationSec; t += dt) {
|
||||
// Spawn timers for packets arriving in this window
|
||||
const pktsInWindow = packetsPerSec * dt;
|
||||
for (let p = 0; p < pktsInWindow; p++) {
|
||||
for (let h = 0; h < hopsPerPacket; h++) {
|
||||
for (const lt of intervalLifetimes) {
|
||||
spawns.push({ time: t, lifetime: lt });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Count alive timers
|
||||
const alive = spawns.filter(s => t < s.time + s.lifetime).length;
|
||||
if (alive > maxConcurrent) maxConcurrent = alive;
|
||||
}
|
||||
return maxConcurrent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulate NEW behaviour: rAF + MAX_CONCURRENT_ANIMS cap
|
||||
// ---------------------------------------------------------------------------
|
||||
function simulateNewModel(packetsPerSec, hopsPerPacket, durationSec) {
|
||||
const MAX_CONCURRENT_ANIMS = 20;
|
||||
let activeAnims = 0;
|
||||
let maxConcurrent = 0;
|
||||
const anims = []; // {endTime}
|
||||
const dt = 0.1;
|
||||
for (let t = 0; t < durationSec; t += dt) {
|
||||
// Expire finished animations
|
||||
while (anims.length && anims[0].endTime <= t) {
|
||||
anims.shift();
|
||||
activeAnims--;
|
||||
}
|
||||
// Try to start new animations
|
||||
const pktsInWindow = packetsPerSec * dt;
|
||||
for (let p = 0; p < pktsInWindow; p++) {
|
||||
if (activeAnims >= MAX_CONCURRENT_ANIMS) break; // cap reached — drop
|
||||
activeAnims++;
|
||||
// rAF animation lifetime: longest is pulse ~2s
|
||||
anims.push({ endTime: t + 2.0 });
|
||||
}
|
||||
// Sort by endTime so expiry works
|
||||
anims.sort((a, b) => a.endTime - b.endTime);
|
||||
if (activeAnims > maxConcurrent) maxConcurrent = activeAnims;
|
||||
}
|
||||
return maxConcurrent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('\n=== Animation timer accumulation: old vs new ===');
|
||||
|
||||
// Scenario: 5 pkts/sec, 3 hops each, 30 seconds
|
||||
const oldPeak30s = simulateOldModel(5, 3, 30);
|
||||
const newPeak30s = simulateNewModel(5, 3, 30);
|
||||
console.log(` Old model (30s @ 5pkt/s×3hops): peak ${oldPeak30s} concurrent timers`);
|
||||
console.log(` New model (30s @ 5pkt/s×3hops): peak ${newPeak30s} concurrent animations`);
|
||||
assert(oldPeak30s > 100, `old model accumulates >100 timers (got ${oldPeak30s})`);
|
||||
assert(newPeak30s <= 20, `new model stays ≤20 (got ${newPeak30s})`);
|
||||
|
||||
// Scenario: 5 minutes sustained
|
||||
const oldPeak5m = simulateOldModel(5, 3, 300);
|
||||
const newPeak5m = simulateNewModel(5, 3, 300);
|
||||
console.log(` Old model (5min @ 5pkt/s×3hops): peak ${oldPeak5m} concurrent timers`);
|
||||
console.log(` New model (5min @ 5pkt/s×3hops): peak ${newPeak5m} concurrent animations`);
|
||||
assert(oldPeak5m > 100, `old model at 5min still unbounded (got ${oldPeak5m})`);
|
||||
assert(newPeak5m <= 20, `new model at 5min still ≤20 (got ${newPeak5m})`);
|
||||
|
||||
// Scenario: burst — 20 pkts/sec for 10s
|
||||
const oldBurst = simulateOldModel(20, 3, 10);
|
||||
const newBurst = simulateNewModel(20, 3, 10);
|
||||
console.log(` Old model (burst 20pkt/s×3hops, 10s): peak ${oldBurst} concurrent timers`);
|
||||
console.log(` New model (burst 20pkt/s×3hops, 10s): peak ${newBurst} concurrent animations`);
|
||||
assert(oldBurst > 200, `old model under burst >200 timers (got ${oldBurst})`);
|
||||
assert(newBurst <= 20, `new model under burst stays ≤20 (got ${newBurst})`);
|
||||
|
||||
console.log('\n=== drawAnimatedLine frame-drop catch-up ===');
|
||||
|
||||
// Read the source and verify catch-up logic exists
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(__dirname + '/public/live.js', 'utf8');
|
||||
|
||||
// Extract the animateLine function body
|
||||
const lineMatch = src.match(/function animateLine\(now\)\s*\{[\s\S]*?requestAnimationFrame\(animateLine\)/);
|
||||
assert(lineMatch && /Math\.min\(Math\.floor\(elapsed\s*\/\s*33\)/.test(lineMatch[0]),
|
||||
'drawAnimatedLine catches up on frame drops (multi-tick per frame)');
|
||||
|
||||
const fadeMatch = src.match(/function animateFade\(now\)\s*\{[\s\S]*?requestAnimationFrame\(animateFade\)/);
|
||||
assert(fadeMatch && /Math\.min\(Math\.floor\(fadeElapsed\s*\/\s*52\)/.test(fadeMatch[0]),
|
||||
'animateFade catches up on frame drops (multi-tick per frame)');
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,408 @@
|
||||
/* Unit tests for customizer v2 core functions */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
function makeSandbox() {
|
||||
const storage = {};
|
||||
const localStorage = {
|
||||
_data: storage,
|
||||
getItem(k) { return k in storage ? storage[k] : null; },
|
||||
setItem(k, v) { storage[k] = String(v); },
|
||||
removeItem(k) { delete storage[k]; },
|
||||
clear() { for (const k in storage) delete storage[k]; }
|
||||
};
|
||||
const ctx = {
|
||||
window: {
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
SITE_CONFIG: {},
|
||||
_SITE_CONFIG_ORIGINAL_HOME: null,
|
||||
},
|
||||
document: {
|
||||
readyState: 'loading',
|
||||
createElement: (tag) => ({
|
||||
id: '', textContent: '', innerHTML: '', className: '',
|
||||
setAttribute: () => {}, appendChild: () => {},
|
||||
style: {}, addEventListener: () => {},
|
||||
querySelectorAll: () => [], querySelector: () => null,
|
||||
}),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
documentElement: {
|
||||
style: { setProperty: () => {}, removeProperty: () => {}, getPropertyValue: () => '' },
|
||||
dataset: { theme: 'dark' },
|
||||
getAttribute: () => 'dark',
|
||||
},
|
||||
},
|
||||
console,
|
||||
localStorage,
|
||||
setTimeout: (fn) => fn(),
|
||||
clearTimeout: () => {},
|
||||
Date, Math, Array, Object, JSON, String, Number, Boolean,
|
||||
parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
|
||||
MutationObserver: class { observe() {} },
|
||||
HashChangeEvent: class {},
|
||||
CustomEvent: class CustomEvent { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } },
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
};
|
||||
ctx.window.localStorage = localStorage;
|
||||
ctx.self = ctx.window;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadCustomizer() {
|
||||
const ctx = makeSandbox();
|
||||
const code = fs.readFileSync('public/customize-v2.js', 'utf8');
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(code, ctx, { filename: 'customize-v2.js' });
|
||||
return { ctx, api: ctx.window._customizerV2, ls: ctx.localStorage };
|
||||
}
|
||||
|
||||
console.log('\n📋 Customizer V2 — Core Function Tests\n');
|
||||
|
||||
// ── readOverrides ──
|
||||
console.log('readOverrides:');
|
||||
test('returns {} when key is absent', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.readOverrides();
|
||||
assert.strictEqual(JSON.stringify(result), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains invalid JSON', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', 'not json{{{');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains a non-object (string)', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '"just a string"');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains an array', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '[1,2,3]');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns {} when key contains a number', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '42');
|
||||
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
||||
});
|
||||
|
||||
test('returns parsed object when valid', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const data = { theme: { accent: '#ff0000' } };
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify(data));
|
||||
assert.deepStrictEqual(api.readOverrides(), data);
|
||||
});
|
||||
|
||||
// ── writeOverrides ──
|
||||
console.log('\nwriteOverrides:');
|
||||
test('writes serialized JSON to localStorage', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
const data = { theme: { accent: '#ff0000' } };
|
||||
api.writeOverrides(data);
|
||||
assert.deepStrictEqual(JSON.parse(ls.getItem('cs-theme-overrides')), data);
|
||||
});
|
||||
|
||||
test('removes key when delta is empty {}', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '{"theme":{}}');
|
||||
api.writeOverrides({});
|
||||
assert.strictEqual(ls.getItem('cs-theme-overrides'), null);
|
||||
});
|
||||
|
||||
test('round-trips correctly (write → read = identical)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const data = { theme: { accent: '#abc', text: '#def' }, nodeColors: { repeater: '#111' } };
|
||||
api.writeOverrides(data);
|
||||
assert.deepStrictEqual(api.readOverrides(), data);
|
||||
});
|
||||
|
||||
test('strips invalid color values silently', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
api.writeOverrides({ theme: { accent: 'not-a-color' } });
|
||||
// Invalid color is stripped by _validateDelta; remaining empty object is stored as '{}'
|
||||
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored.theme, undefined);
|
||||
});
|
||||
|
||||
test('strips out-of-range opacity', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
api.writeOverrides({ heatmapOpacity: 1.5 });
|
||||
const stored1 = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored1.heatmapOpacity, undefined);
|
||||
api.writeOverrides({ heatmapOpacity: -0.1 });
|
||||
const stored2 = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored2.heatmapOpacity, undefined);
|
||||
});
|
||||
|
||||
test('accepts valid opacity', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
api.writeOverrides({ heatmapOpacity: 0.5 });
|
||||
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
|
||||
assert.strictEqual(stored.heatmapOpacity, 0.5);
|
||||
});
|
||||
|
||||
// ── computeEffective ──
|
||||
console.log('\ncomputeEffective:');
|
||||
test('returns server defaults when overrides is {}', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa', text: '#bbb' }, nodeColors: { repeater: '#ccc' } };
|
||||
const result = api.computeEffective(defaults, {});
|
||||
assert.deepStrictEqual(result, defaults);
|
||||
});
|
||||
|
||||
test('overrides a single key in a section', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa', text: '#bbb' } };
|
||||
const result = api.computeEffective(defaults, { theme: { accent: '#ff0000' } });
|
||||
assert.strictEqual(result.theme.accent, '#ff0000');
|
||||
assert.strictEqual(result.theme.text, '#bbb');
|
||||
});
|
||||
|
||||
test('overrides multiple keys across sections', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
|
||||
const result = api.computeEffective(defaults, { theme: { accent: '#111' }, nodeColors: { repeater: '#222' } });
|
||||
assert.strictEqual(result.theme.accent, '#111');
|
||||
assert.strictEqual(result.nodeColors.repeater, '#222');
|
||||
});
|
||||
|
||||
test('does not mutate either input', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa' } };
|
||||
const overrides = { theme: { accent: '#bbb' } };
|
||||
const defCopy = JSON.stringify(defaults);
|
||||
const ovrCopy = JSON.stringify(overrides);
|
||||
api.computeEffective(defaults, overrides);
|
||||
assert.strictEqual(JSON.stringify(defaults), defCopy);
|
||||
assert.strictEqual(JSON.stringify(overrides), ovrCopy);
|
||||
});
|
||||
|
||||
test('handles missing sections in overrides gracefully', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
|
||||
const result = api.computeEffective(defaults, { theme: { accent: '#ccc' } });
|
||||
assert.strictEqual(result.nodeColors.repeater, '#bbb');
|
||||
});
|
||||
|
||||
test('array values in home are fully replaced, not merged', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { home: { steps: [{ emoji: '1', title: 'a', description: 'b' }], heroTitle: 'X' } };
|
||||
const overrides = { home: { steps: [{ emoji: '2', title: 'c', description: 'd' }, { emoji: '3', title: 'e', description: 'f' }] } };
|
||||
const result = api.computeEffective(defaults, overrides);
|
||||
assert.strictEqual(result.home.steps.length, 2);
|
||||
assert.strictEqual(result.home.steps[0].emoji, '2');
|
||||
assert.strictEqual(result.home.heroTitle, 'X'); // untouched
|
||||
});
|
||||
|
||||
test('top-level scalars are directly replaced', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const defaults = { heatmapOpacity: 0.5 };
|
||||
const result = api.computeEffective(defaults, { heatmapOpacity: 0.8 });
|
||||
assert.strictEqual(result.heatmapOpacity, 0.8);
|
||||
});
|
||||
|
||||
// ── validateShape ──
|
||||
console.log('\nvalidateShape:');
|
||||
test('accepts valid delta objects', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ theme: { accent: '#fff' }, heatmapOpacity: 0.5 });
|
||||
assert.strictEqual(result.valid, true);
|
||||
});
|
||||
|
||||
test('accepts empty object', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape({}).valid, true);
|
||||
});
|
||||
|
||||
test('rejects non-objects (string)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape('hello').valid, false);
|
||||
});
|
||||
|
||||
test('rejects non-objects (array)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape([1, 2]).valid, false);
|
||||
});
|
||||
|
||||
test('rejects non-objects (null)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape(null).valid, false);
|
||||
});
|
||||
|
||||
test('warns on unknown top-level keys', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ unknownKey: {} });
|
||||
// Unknown keys produce a console.warn but validateShape still returns valid
|
||||
assert.strictEqual(result.valid, true);
|
||||
assert.strictEqual(result.errors.length, 0);
|
||||
});
|
||||
|
||||
test('validates section types (rejects non-object section)', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ theme: 'not an object' });
|
||||
assert.strictEqual(result.valid, false);
|
||||
});
|
||||
|
||||
test('accepts valid rgb() color values in theme', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const result = api.validateShape({ theme: { accent: 'rgb(1,2,3)' } });
|
||||
assert.strictEqual(result.valid, true);
|
||||
});
|
||||
|
||||
test('rejects out-of-range opacity values', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.validateShape({ heatmapOpacity: 2.0 }).valid, false);
|
||||
assert.strictEqual(api.validateShape({ liveHeatmapOpacity: -1 }).valid, false);
|
||||
});
|
||||
|
||||
// ── migrateOldKeys ──
|
||||
console.log('\nmigrateOldKeys:');
|
||||
test('migrates all 7 keys correctly', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, branding: { siteName: 'Test' } }));
|
||||
ls.setItem('meshcore-timestamp-mode', 'absolute');
|
||||
ls.setItem('meshcore-timestamp-timezone', 'utc');
|
||||
ls.setItem('meshcore-timestamp-format', 'iso-seconds');
|
||||
ls.setItem('meshcore-timestamp-custom-format', 'YYYY-MM-DD');
|
||||
ls.setItem('meshcore-heatmap-opacity', '0.7');
|
||||
ls.setItem('meshcore-live-heatmap-opacity', '0.3');
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result.theme.accent, '#f00');
|
||||
assert.strictEqual(result.branding.siteName, 'Test');
|
||||
assert.strictEqual(result.timestamps.defaultMode, 'absolute');
|
||||
assert.strictEqual(result.timestamps.timezone, 'utc');
|
||||
assert.strictEqual(result.heatmapOpacity, 0.7);
|
||||
assert.strictEqual(result.liveHeatmapOpacity, 0.3);
|
||||
// Legacy keys removed
|
||||
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
|
||||
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
|
||||
// New key written
|
||||
assert.notStrictEqual(ls.getItem('cs-theme-overrides'), null);
|
||||
});
|
||||
|
||||
test('handles partial migration (only some keys)', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-timestamp-mode', 'ago');
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result.timestamps.defaultMode, 'ago');
|
||||
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
|
||||
});
|
||||
|
||||
test('handles invalid JSON in meshcore-user-theme', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-user-theme', '{bad json');
|
||||
const result = api.migrateOldKeys();
|
||||
// Should not crash, returns delta (possibly empty besides what was valid)
|
||||
assert(result !== null);
|
||||
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
|
||||
});
|
||||
|
||||
test('skips migration if cs-theme-overrides already exists', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', '{"theme":{}}');
|
||||
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' } }));
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result, null);
|
||||
// Legacy key NOT removed (migration skipped entirely)
|
||||
assert.notStrictEqual(ls.getItem('meshcore-user-theme'), null);
|
||||
});
|
||||
|
||||
test('returns null when no legacy keys found', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.migrateOldKeys(), null);
|
||||
});
|
||||
|
||||
test('drops unknown keys from meshcore-user-theme', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, unknownStuff: 'hi' }));
|
||||
const result = api.migrateOldKeys();
|
||||
assert.strictEqual(result.theme.accent, '#f00');
|
||||
assert.strictEqual(result.unknownStuff, undefined);
|
||||
});
|
||||
|
||||
// ── THEME_CSS_MAP completeness ──
|
||||
console.log('\nTHEME_CSS_MAP:');
|
||||
test('includes surface3 mapping', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.THEME_CSS_MAP.surface3, '--surface-3');
|
||||
});
|
||||
|
||||
test('includes sectionBg mapping', () => {
|
||||
const { api } = loadCustomizer();
|
||||
assert.strictEqual(api.THEME_CSS_MAP.sectionBg, '--section-bg');
|
||||
});
|
||||
|
||||
test('matches all keys from old app.js varMap', () => {
|
||||
const { api } = loadCustomizer();
|
||||
const expectedKeys = [
|
||||
'accent', 'accentHover', 'navBg', 'navBg2', 'navText', 'navTextMuted',
|
||||
'background', 'text', 'textMuted', 'border',
|
||||
'statusGreen', 'statusYellow', 'statusRed',
|
||||
'surface1', 'surface2', 'surface3',
|
||||
'cardBg', 'contentBg', 'inputBg',
|
||||
'rowStripe', 'rowHover', 'detailBg',
|
||||
'selectedBg', 'sectionBg',
|
||||
'font', 'mono'
|
||||
];
|
||||
for (const key of expectedKeys) {
|
||||
assert(key in api.THEME_CSS_MAP, `Missing key: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── _isOverridden tests ──
|
||||
console.log('\n_isOverridden (value comparison):');
|
||||
|
||||
test('returns false when no overrides exist', () => {
|
||||
const { api } = loadCustomizer();
|
||||
api.init({ theme: { accent: '#aaa' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
|
||||
});
|
||||
|
||||
test('returns false when override matches server default', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#aaa' } }));
|
||||
api.init({ theme: { accent: '#aaa' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
|
||||
});
|
||||
|
||||
test('returns true when override differs from server default', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
||||
api.init({ theme: { accent: '#aaa' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
|
||||
});
|
||||
|
||||
test('returns false for key not in overrides', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
||||
api.init({ theme: { accent: '#aaa', border: '#ccc' } });
|
||||
assert.strictEqual(api.isOverridden('theme', 'border'), false);
|
||||
});
|
||||
|
||||
test('returns true when server has no default for overridden key', () => {
|
||||
const { api, ls } = loadCustomizer();
|
||||
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
||||
api.init({});
|
||||
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
+405
-20
@@ -85,7 +85,7 @@ async function run() {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('meshcore-user-theme');
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
window.SITE_CONFIG = window.SITE_CONFIG || {};
|
||||
window.SITE_CONFIG.home = {
|
||||
heroTitle: 'Server Hero (E2E)',
|
||||
@@ -122,18 +122,18 @@ async function run() {
|
||||
const homeTab = page.locator('.cust-tab[data-tab="home"]');
|
||||
await homeTab.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await homeTab.click();
|
||||
const heroInput = page.locator('#cust-heroTitle');
|
||||
const heroInput = page.locator('[data-cv2-field="home.heroTitle"]');
|
||||
if (await heroInput.count() === 0) {
|
||||
console.log(' ⏭️ #cust-heroTitle not found — TODO: requires running server');
|
||||
console.log(' ⏭️ home.heroTitle input not found — TODO: requires running server');
|
||||
return;
|
||||
}
|
||||
await heroInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await heroInput.fill(editedHero);
|
||||
await page.waitForTimeout(700); // autoSave debounce is 500ms
|
||||
await page.waitForTimeout(700); // debounce is 300ms, allow margin
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
const persistedHero = await page.evaluate(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}');
|
||||
const saved = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
return saved && saved.home ? saved.home.heroTitle : '';
|
||||
} catch {
|
||||
return '';
|
||||
@@ -365,13 +365,12 @@ async function run() {
|
||||
await test('Packets page loads with filter', async () => {
|
||||
// Ensure desktop viewport and broad time window so fixture timestamps are included.
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
// Set time window BEFORE packets.js IIFE re-executes (525600 min ≈ 1 year)
|
||||
// Set time window BEFORE packets.js IIFE re-executes (525600 min ≈ 1 year).
|
||||
// Navigate to the packets URL then reload — avoids about:blank cross-origin issues
|
||||
// that can prevent the SPA from fully initializing within the timeout.
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
|
||||
// Navigate away so next goto is a full page load (not a same-document hash change).
|
||||
// This guarantees scripts re-execute and packets.js IIFE reads the new localStorage.
|
||||
await page.goto('about:blank');
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'load' });
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('table tbody tr', { timeout: 15000 });
|
||||
const rowsBefore = await page.$$('table tbody tr');
|
||||
assert(rowsBefore.length > 0, 'No packets visible');
|
||||
@@ -387,11 +386,8 @@ async function run() {
|
||||
});
|
||||
|
||||
await test('Packets initial fetch honors persisted time window', async () => {
|
||||
// Navigate to base first to get same-origin context for localStorage
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
// Set persisted time window to 60 min and reload so the IIFE reads it
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '60'));
|
||||
// Navigate away so next goto is a full page load
|
||||
await page.goto('about:blank');
|
||||
|
||||
const packetsRequestPromise = page.waitForRequest((req) => {
|
||||
try {
|
||||
@@ -402,8 +398,8 @@ async function run() {
|
||||
}
|
||||
}, { timeout: 10000 });
|
||||
|
||||
// Full navigation from about:blank — scripts re-execute, IIFE reads localStorage
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'load' });
|
||||
// Full reload on the packets page — scripts re-execute, IIFE reads localStorage
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 10000 });
|
||||
const timeWindowValue = await page.$eval('#fTimeWindow', (el) => el.value);
|
||||
assert(timeWindowValue === '60', `Expected time window dropdown to restore 60, got ${timeWindowValue}`);
|
||||
@@ -428,10 +424,8 @@ async function run() {
|
||||
// Test: Packets groupByHash toggle changes view
|
||||
await test('Packets groupByHash toggle works', async () => {
|
||||
// Restore wide time window — previous test set it to 60 min which excludes fixture data
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
|
||||
await page.goto('about:blank');
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'load' });
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('table tbody tr', { timeout: 15000 });
|
||||
const groupBtn = await page.$('#fGroup');
|
||||
assert(groupBtn, 'Group by hash button (#fGroup) not found');
|
||||
@@ -1021,6 +1015,397 @@ async function run() {
|
||||
assert(hexDump, 'Hex dump should be visible after selecting a packet');
|
||||
});
|
||||
|
||||
// --- Group: Customizer v2 E2E tests ---
|
||||
|
||||
await test('Customizer v2: setOverride persists and applies CSS', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
// Clear any existing overrides
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
// Set an override via the API
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
window._customizerV2.setOverride('theme', 'accent', '#ff0000');
|
||||
// Wait for debounce
|
||||
return new Promise(resolve => setTimeout(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const cssVal = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
|
||||
resolve({ stored, cssVal });
|
||||
}, 500));
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.stored.theme && result.stored.theme.accent === '#ff0000',
|
||||
'Override not persisted to localStorage');
|
||||
assert(result.cssVal === '#ff0000',
|
||||
`CSS variable --accent expected #ff0000 but got "${result.cssVal}"`);
|
||||
// Cleanup
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: clearOverride resets to server default', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
// Get the server default accent
|
||||
window._customizerV2.setOverride('theme', 'accent', '#ff0000');
|
||||
return new Promise(resolve => setTimeout(() => {
|
||||
window._customizerV2.clearOverride('theme', 'accent');
|
||||
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const hasAccent = stored.theme && stored.theme.hasOwnProperty('accent');
|
||||
resolve({ hasAccent });
|
||||
}, 500));
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(!result.hasAccent, 'accent should be removed from overrides after clearOverride');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: full reset clears all overrides', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' }, nodeColors: { repeater: '#00ff00' } }));
|
||||
// Simulate full reset
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
const stored = localStorage.getItem('cs-theme-overrides');
|
||||
return { stored };
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.stored === null, 'cs-theme-overrides should be null after full reset');
|
||||
});
|
||||
|
||||
await test('Customizer v2: export produces valid JSON', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
// Set some overrides
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#123456' } }));
|
||||
const delta = window._customizerV2.readOverrides();
|
||||
const json = JSON.stringify(delta, null, 2);
|
||||
try { JSON.parse(json); return { valid: true, hasAccent: delta.theme && delta.theme.accent === '#123456' }; }
|
||||
catch { return { valid: false }; }
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.valid, 'Exported JSON must be valid');
|
||||
assert(result.hasAccent, 'Exported JSON must contain the stored override');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: import applies overrides', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
const importData = { theme: { accent: '#abcdef' }, nodeColors: { repeater: '#112233' } };
|
||||
const validation = window._customizerV2.validateShape(importData);
|
||||
if (!validation.valid) return { error: 'Validation failed: ' + validation.errors.join(', ') };
|
||||
window._customizerV2.writeOverrides(importData);
|
||||
const stored = window._customizerV2.readOverrides();
|
||||
return { accent: stored.theme && stored.theme.accent, repeater: stored.nodeColors && stored.nodeColors.repeater };
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.accent === '#abcdef', 'Imported accent should be #abcdef');
|
||||
assert(result.repeater === '#112233', 'Imported repeater should be #112233');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: migration from legacy keys', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
// Clear new key so migration can run
|
||||
localStorage.removeItem('cs-theme-overrides');
|
||||
// Set legacy keys
|
||||
localStorage.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#aabb01' }, branding: { siteName: 'LegacyName' } }));
|
||||
localStorage.setItem('meshcore-timestamp-mode', 'absolute');
|
||||
localStorage.setItem('meshcore-heatmap-opacity', '0.5');
|
||||
// Run migration
|
||||
const migrated = window._customizerV2.migrateOldKeys();
|
||||
const stored = window._customizerV2.readOverrides();
|
||||
const legacyGone = localStorage.getItem('meshcore-user-theme') === null &&
|
||||
localStorage.getItem('meshcore-timestamp-mode') === null &&
|
||||
localStorage.getItem('meshcore-heatmap-opacity') === null;
|
||||
return {
|
||||
migrated: !!migrated,
|
||||
accent: stored.theme && stored.theme.accent,
|
||||
siteName: stored.branding && stored.branding.siteName,
|
||||
tsMode: stored.timestamps && stored.timestamps.defaultMode,
|
||||
opacity: stored.heatmapOpacity,
|
||||
legacyGone
|
||||
};
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.migrated, 'migrateOldKeys should return non-null');
|
||||
assert(result.accent === '#aabb01', 'Theme accent should be migrated');
|
||||
assert(result.siteName === 'LegacyName', 'Branding should be migrated');
|
||||
assert(result.tsMode === 'absolute', 'Timestamp mode should be migrated');
|
||||
assert(result.opacity === 0.5, 'Heatmap opacity should be migrated');
|
||||
assert(result.legacyGone, 'Legacy keys should be removed after migration');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: browser-local banner visible', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
// Open customizer
|
||||
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSel);
|
||||
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
|
||||
await btn.click();
|
||||
await page.waitForSelector('.cv2-local-banner', { timeout: 5000 });
|
||||
const bannerText = await page.$eval('.cv2-local-banner', el => el.textContent);
|
||||
assert(bannerText.includes('browser only'), `Banner should mention "browser only" but got "${bannerText}"`);
|
||||
});
|
||||
|
||||
await test('Customizer v2: auto-save status indicator', async () => {
|
||||
// Panel should already be open from previous test
|
||||
const statusEl = await page.$('#cv2-save-status');
|
||||
if (!statusEl) { console.log(' ⏭️ Save status element not found'); return; }
|
||||
const statusText = await page.$eval('#cv2-save-status', el => el.textContent);
|
||||
assert(statusText.includes('saved') || statusText.includes('Saving'),
|
||||
`Status should show save state but got "${statusText}"`);
|
||||
});
|
||||
|
||||
await test('Customizer v2: override indicator appears and disappears', async () => {
|
||||
// Set override BEFORE page load so _renderTheme sees it during init
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
// Force light mode so theme tab renders 'theme' section (not 'themeDark')
|
||||
localStorage.setItem('meshcore-theme', 'light');
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
|
||||
});
|
||||
// Reload so customizer v2 initializes with the override in place
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
// Ensure light mode is active (CI headless may default to dark)
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
|
||||
const result = await page.evaluate(() => {
|
||||
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
|
||||
return { ok: true };
|
||||
});
|
||||
assert(!result.error, result.error || '');
|
||||
// Open customizer and check for override dot
|
||||
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSel);
|
||||
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
|
||||
await btn.click();
|
||||
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
|
||||
// Click theme tab
|
||||
const themeTab = await page.$('.cust-tab[data-tab="theme"]');
|
||||
if (themeTab) await themeTab.click();
|
||||
await page.waitForTimeout(200);
|
||||
// Check for override dot
|
||||
const dots = await page.$$('.cv2-override-dot');
|
||||
assert(dots.length > 0, 'Override dot should be visible when overrides exist');
|
||||
// Clear overrides and reload to verify dots disappear
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
const btn2 = await page.$(toggleSel);
|
||||
if (btn2) await btn2.click();
|
||||
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
|
||||
const themeTab2 = await page.$('.cust-tab[data-tab="theme"]');
|
||||
if (themeTab2) await themeTab2.click();
|
||||
await page.waitForTimeout(200);
|
||||
const dotsAfter = await page.$$('.cv2-override-dot');
|
||||
assert(dotsAfter.length === 0, 'Override dots should disappear after clearing overrides');
|
||||
});
|
||||
|
||||
await test('Customizer v2: presets apply through standard pipeline', async () => {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
|
||||
const btn = await page.$(toggleSel);
|
||||
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
|
||||
await btn.click();
|
||||
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
|
||||
// Click theme tab
|
||||
const themeTab = await page.$('.cust-tab[data-tab="theme"]');
|
||||
if (themeTab) await themeTab.click();
|
||||
await page.waitForTimeout(200);
|
||||
// Click ocean preset
|
||||
const oceanBtn = await page.$('.cust-preset-btn[data-preset="ocean"]');
|
||||
if (!oceanBtn) { console.log(' ⏭️ Ocean preset button not found'); return; }
|
||||
await oceanBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
const result = await page.evaluate(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
|
||||
const cssAccent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
|
||||
return { hasTheme: !!stored.theme, cssAccent };
|
||||
});
|
||||
assert(result.hasTheme, 'Preset should write theme to localStorage');
|
||||
assert(result.cssAccent.length > 0, 'CSS accent should be set after preset');
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
await test('Customizer v2: page load applies overrides from localStorage', async () => {
|
||||
// Set overrides BEFORE navigating
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ee1122' } }));
|
||||
});
|
||||
// Reload to trigger init with overrides
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
|
||||
await page.waitForTimeout(500); // allow pipeline to run
|
||||
const cssAccent = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim()
|
||||
);
|
||||
assert(cssAccent === '#ee1122', `Page load should apply override accent #ee1122 but got "${cssAccent}"`);
|
||||
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
|
||||
});
|
||||
|
||||
// --- Group: Show Neighbors (#484 fix) ---
|
||||
|
||||
await test('Show Neighbors populates neighborPubkeys from affinity API', async () => {
|
||||
const testPubkey = 'aabbccdd11223344556677889900aabbccddeeff00112233445566778899001122';
|
||||
const neighborPubkey1 = '1111111111111111111111111111111111111111111111111111111111111111';
|
||||
const neighborPubkey2 = '2222222222222222222222222222222222222222222222222222222222222222';
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: testPubkey,
|
||||
neighbors: [
|
||||
{ pubkey: neighborPubkey1, prefix: '11', name: 'Neighbor-1', role: 'repeater', count: 50, score: 0.9, ambiguous: false },
|
||||
{ pubkey: neighborPubkey2, prefix: '22', name: 'Neighbor-2', role: 'companion', count: 20, score: 0.7, ambiguous: false }
|
||||
],
|
||||
total_observations: 70
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const result = await page.evaluate(async (args) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no _mapSelectRefNode' };
|
||||
await window._mapSelectRefNode(args.pk, 'TestNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, { pk: testPubkey });
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.neighbors.includes(neighborPubkey1), 'Should contain neighbor1');
|
||||
assert(result.neighbors.includes(neighborPubkey2), 'Should contain neighbor2');
|
||||
assert(result.neighbors.length === 2, `Expected 2 neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors resolves correct node on hash collision via affinity API', async () => {
|
||||
const nodeA = 'c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4';
|
||||
const nodeB = 'c0f1a2b3000000000000000000000000000000000000000000000000000000ff';
|
||||
const neighborR1 = 'r1aaaaaa000000000000000000000000000000000000000000000000000000aa';
|
||||
const neighborR2 = 'r2bbbbbb000000000000000000000000000000000000000000000000000000bb';
|
||||
const neighborR4 = 'r4dddddd000000000000000000000000000000000000000000000000000000dd';
|
||||
|
||||
await page.route(`**/api/nodes/${nodeA}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeA,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR1, prefix: 'R1', name: 'Repeater-R1', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
|
||||
{ pubkey: neighborR2, prefix: 'R2', name: 'Repeater-R2', role: 'repeater', count: 80, score: 0.85, ambiguous: false }
|
||||
],
|
||||
total_observations: 180
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${nodeB}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeB,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR4, prefix: 'R4', name: 'Repeater-R4', role: 'repeater', count: 60, score: 0.75, ambiguous: false }
|
||||
],
|
||||
total_observations: 60
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Select Node A — should get R1, R2 but NOT R4
|
||||
const resultA = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeA');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeA);
|
||||
assert(resultA.includes(neighborR1), 'Node A should have R1');
|
||||
assert(resultA.includes(neighborR2), 'Node A should have R2');
|
||||
assert(!resultA.includes(neighborR4), 'Node A should NOT have R4');
|
||||
|
||||
// Select Node B — should get R4 but NOT R1, R2
|
||||
const resultB = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeB');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeB);
|
||||
assert(resultB.includes(neighborR4), 'Node B should have R4');
|
||||
assert(!resultB.includes(neighborR1), 'Node B should NOT have R1');
|
||||
assert(!resultB.includes(neighborR2), 'Node B should NOT have R2');
|
||||
|
||||
await page.unroute(`**/api/nodes/${nodeA}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${nodeB}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors falls back to path walking when affinity API returns empty', async () => {
|
||||
const testPubkey = 'fallbacktest0000000000000000000000000000000000000000000000000000';
|
||||
const hopBefore = 'aaaa000000000000000000000000000000000000000000000000000000000000';
|
||||
const hopAfter = 'bbbb000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ node: testPubkey, neighbors: [], total_observations: 0 })
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/paths*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
paths: [{
|
||||
hops: [
|
||||
{ pubkey: hopBefore, name: 'HopBefore' },
|
||||
{ pubkey: testPubkey, name: 'Self' },
|
||||
{ pubkey: hopAfter, name: 'HopAfter' }
|
||||
]
|
||||
}]
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const result = await page.evaluate(async (pk) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no-function' };
|
||||
await window._mapSelectRefNode(pk, 'FallbackNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, testPubkey);
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(result.neighbors.includes(hopBefore), 'Fallback should find hopBefore');
|
||||
assert(result.neighbors.includes(hopAfter), 'Fallback should find hopAfter');
|
||||
assert(result.neighbors.length === 2, `Expected 2 fallback neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/paths*`);
|
||||
});
|
||||
|
||||
// Extract frontend coverage if instrumented server is running
|
||||
try {
|
||||
const coverage = await page.evaluate(() => window.__coverage__);
|
||||
|
||||
+1978
-345
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
/* Unit tests for live.js animation system — verifies rAF migration and concurrency cap */
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
const src = fs.readFileSync('public/live.js', 'utf8');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
console.log('\n=== Animation interval elimination ===');
|
||||
|
||||
test('pulseNode does not use setInterval', () => {
|
||||
// Extract pulseNode function body
|
||||
const pulseStart = src.indexOf('function pulseNode(');
|
||||
const nextFn = src.indexOf('\n function ', pulseStart + 1);
|
||||
const body = src.substring(pulseStart, nextFn);
|
||||
assert.ok(!body.includes('setInterval'), 'pulseNode still uses setInterval');
|
||||
assert.ok(body.includes('requestAnimationFrame'), 'pulseNode should use requestAnimationFrame');
|
||||
});
|
||||
|
||||
test('drawAnimatedLine does not use setInterval', () => {
|
||||
const drawStart = src.indexOf('function drawAnimatedLine(');
|
||||
const nextFn = src.indexOf('\n function ', drawStart + 1);
|
||||
const body = src.substring(drawStart, nextFn);
|
||||
assert.ok(!body.includes('setInterval'), 'drawAnimatedLine still uses setInterval');
|
||||
assert.ok(body.includes('requestAnimationFrame'), 'drawAnimatedLine should use requestAnimationFrame');
|
||||
});
|
||||
|
||||
test('ghost hop pulse does not use setInterval', () => {
|
||||
// Ghost pulse is inside animatePath
|
||||
const animStart = src.indexOf('function animatePath(');
|
||||
const animEnd = src.indexOf('\n function ', animStart + 1);
|
||||
const body = src.substring(animStart, animEnd);
|
||||
assert.ok(!body.includes('setInterval'), 'animatePath still uses setInterval');
|
||||
});
|
||||
|
||||
console.log('\n=== Concurrency cap ===');
|
||||
|
||||
test('MAX_CONCURRENT_ANIMS is defined', () => {
|
||||
assert.ok(src.includes('MAX_CONCURRENT_ANIMS'), 'MAX_CONCURRENT_ANIMS constant not found');
|
||||
});
|
||||
|
||||
test('MAX_CONCURRENT_ANIMS is set to 20', () => {
|
||||
const match = src.match(/MAX_CONCURRENT_ANIMS\s*=\s*(\d+)/);
|
||||
assert.ok(match, 'Could not parse MAX_CONCURRENT_ANIMS value');
|
||||
assert.strictEqual(parseInt(match[1]), 20);
|
||||
});
|
||||
|
||||
test('animatePath checks MAX_CONCURRENT_ANIMS before proceeding', () => {
|
||||
const animStart = src.indexOf('function animatePath(');
|
||||
// Check that within the first 200 chars of the function, we check the cap
|
||||
const snippet = src.substring(animStart, animStart + 300);
|
||||
assert.ok(snippet.includes('activeAnims >= MAX_CONCURRENT_ANIMS'), 'animatePath should check activeAnims against cap');
|
||||
});
|
||||
|
||||
console.log('\n=== Safety: no stale setInterval in animation functions ===');
|
||||
|
||||
test('no setInterval remains in animation hot path', () => {
|
||||
// The only acceptable setIntervals are the UI ones (timeline, clock, prune, rate counter)
|
||||
// Count total setInterval occurrences
|
||||
const matches = src.match(/setInterval\(/g) || [];
|
||||
// Count known OK ones: _timelineRefreshInterval, _lcdClockInterval, _pruneInterval, _rateCounterInterval
|
||||
const okPatterns = ['_timelineRefreshInterval', '_lcdClockInterval', '_pruneInterval', '_rateCounterInterval'];
|
||||
let okCount = 0;
|
||||
for (const p of okPatterns) {
|
||||
if (src.includes(p + ' = setInterval') || src.includes(p + '= setInterval')) okCount++;
|
||||
}
|
||||
// Allow some non-animation setIntervals (the 4 UI ones above)
|
||||
assert.ok(matches.length <= okCount + 1,
|
||||
`Found ${matches.length} setInterval calls, expected at most ${okCount + 1} (non-animation). Some animation setIntervals may remain.`);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
|
||||
/* === Null-guard coverage for rAF callbacks === */
|
||||
const src2 = fs.readFileSync('public/live.js', 'utf8');
|
||||
let p2 = 0, f2 = 0;
|
||||
function test2(name, fn) {
|
||||
try { fn(); p2++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { f2++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
console.log('\n=== Null guards on rAF animation callbacks ===');
|
||||
|
||||
test2('animatePath tick() has null guard', () => {
|
||||
// tick is inside animatePath, after "function tick(now)"
|
||||
const tickStart = src2.indexOf('function tick(now)');
|
||||
const tickBody = src2.substring(tickStart, tickStart + 200);
|
||||
assert.ok(tickBody.includes('!animLayer || !pathsLayer'), 'tick() missing animLayer/pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('animatePath fadeOut() has null guard', () => {
|
||||
const fadeOutStart = src2.indexOf('function fadeOut(now)');
|
||||
const fadeOutBody = src2.substring(fadeOutStart, fadeOutStart + 200);
|
||||
assert.ok(fadeOutBody.includes('!animLayer || !pathsLayer'), 'fadeOut() missing animLayer/pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('drawAnimatedLine animateLine() has null guard', () => {
|
||||
const lineStart = src2.indexOf('function animateLine(now)');
|
||||
const lineBody = src2.substring(lineStart, lineStart + 200);
|
||||
assert.ok(lineBody.includes('!animLayer || !pathsLayer'), 'animateLine() missing animLayer/pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('drawAnimatedLine animateFade() has null guard', () => {
|
||||
const fadeStart = src2.indexOf('function animateFade(now)');
|
||||
const fadeBody = src2.substring(fadeStart, fadeStart + 200);
|
||||
assert.ok(fadeBody.includes('!pathsLayer'), 'animateFade() missing pathsLayer null guard');
|
||||
});
|
||||
|
||||
test2('pulseNode animatePulse() has null guard', () => {
|
||||
const pulseStart = src2.indexOf('function animatePulse(now)');
|
||||
const pulseBody = src2.substring(pulseStart, pulseStart + 200);
|
||||
assert.ok(pulseBody.includes('!animLayer'), 'animatePulse() missing animLayer null guard');
|
||||
});
|
||||
|
||||
test2('ghostPulse has null guard', () => {
|
||||
const ghostStart = src2.indexOf('function ghostPulse(now)');
|
||||
const ghostBody = src2.substring(ghostStart, ghostStart + 200);
|
||||
assert.ok(ghostBody.includes('!animLayer'), 'ghostPulse() missing animLayer null guard');
|
||||
});
|
||||
|
||||
console.log(`\n${p2} passed, ${f2} failed\n`);
|
||||
if (f2 > 0) process.exit(1);
|
||||
+853
@@ -0,0 +1,853 @@
|
||||
/* Unit tests for live.js functions (tested via VM sandbox)
|
||||
* Part of #344 — live.js coverage
|
||||
*/
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
const pendingTests = [];
|
||||
function test(name, fn) {
|
||||
try {
|
||||
const out = fn();
|
||||
if (out && typeof out.then === 'function') {
|
||||
pendingTests.push(
|
||||
out.then(() => { passed++; console.log(` ✅ ${name}`); })
|
||||
.catch((e) => { failed++; console.log(` ❌ ${name}: ${e.message}`); })
|
||||
);
|
||||
return;
|
||||
}
|
||||
passed++; console.log(` ✅ ${name}`);
|
||||
} catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
// --- Browser-like sandbox ---
|
||||
function makeSandbox() {
|
||||
const ctx = {
|
||||
window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: (tag) => ({
|
||||
tagName: tag, id: '', textContent: '', innerHTML: '', style: {},
|
||||
classList: { add() {}, remove() {}, contains() { return false; } },
|
||||
setAttribute() {}, getAttribute() { return null; },
|
||||
addEventListener() {}, focus() {},
|
||||
getContext: () => ({
|
||||
clearRect() {}, fillRect() {}, beginPath() {}, arc() {}, fill() {},
|
||||
scale() {}, fillStyle: '', font: '', fillText() {},
|
||||
}),
|
||||
offsetWidth: 200, offsetHeight: 40, width: 0, height: 0,
|
||||
}),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
createElementNS: () => ({
|
||||
tagName: 'svg', id: '', textContent: '', innerHTML: '', style: {},
|
||||
setAttribute() {}, getAttribute() { return null; },
|
||||
}),
|
||||
documentElement: { getAttribute: () => null, setAttribute: () => {} },
|
||||
body: { appendChild: () => {}, removeChild: () => {}, contains: () => false },
|
||||
hidden: false,
|
||||
},
|
||||
console,
|
||||
Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
|
||||
Error, TypeError, Map, Set, Promise, URLSearchParams,
|
||||
parseInt, parseFloat, isNaN, isFinite,
|
||||
encodeURIComponent, decodeURIComponent,
|
||||
setTimeout: () => 0, clearTimeout: () => {},
|
||||
setInterval: () => 0, clearInterval: () => {},
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
||||
performance: { now: () => Date.now() },
|
||||
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
||||
cancelAnimationFrame: () => {},
|
||||
localStorage: (() => {
|
||||
const store = {};
|
||||
return {
|
||||
getItem: k => store[k] !== undefined ? store[k] : null,
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: k => { delete store[k]; },
|
||||
};
|
||||
})(),
|
||||
location: { hash: '', protocol: 'https:', host: 'localhost' },
|
||||
CustomEvent: class CustomEvent {},
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
|
||||
navigator: {},
|
||||
visualViewport: null,
|
||||
MutationObserver: function() { this.observe = () => {}; this.disconnect = () => {}; },
|
||||
WebSocket: function() { this.close = () => {}; },
|
||||
IATA_COORDS_GEO: {},
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadInCtx(ctx, file) {
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
|
||||
function makeLeafletMock() {
|
||||
return {
|
||||
circleMarker: () => {
|
||||
const m = {
|
||||
addTo() { return m; }, bindTooltip() { return m; }, on() { return m; },
|
||||
setRadius() {}, setStyle() {}, setLatLng() {},
|
||||
getLatLng() { return { lat: 0, lng: 0 }; },
|
||||
_baseColor: '', _baseSize: 5, _glowMarker: null, remove() {},
|
||||
};
|
||||
return m;
|
||||
},
|
||||
polyline: () => { const p = { addTo() { return p; }, setStyle() {}, remove() {} }; return p; },
|
||||
polygon: () => { const p = { addTo() { return p; }, remove() {} }; return p; },
|
||||
map: () => {
|
||||
const m = {
|
||||
setView() { return m; }, addLayer() { return m; }, on() { return m; },
|
||||
getZoom() { return 11; }, getCenter() { return { lat: 37, lng: -122 }; },
|
||||
getBounds() { return { contains: () => true }; }, fitBounds() { return m; },
|
||||
invalidateSize() {}, remove() {}, hasLayer() { return false; }, removeLayer() {},
|
||||
};
|
||||
return m;
|
||||
},
|
||||
layerGroup: () => {
|
||||
const g = {
|
||||
addTo() { return g; }, addLayer() {}, removeLayer() {},
|
||||
clearLayers() {}, hasLayer() { return true; }, eachLayer() {},
|
||||
};
|
||||
return g;
|
||||
},
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
control: { attribution: () => ({ addTo() {} }) },
|
||||
DomUtil: { addClass() {}, removeClass() {} },
|
||||
};
|
||||
}
|
||||
|
||||
function addLiveGlobals(ctx) {
|
||||
ctx.L = makeLeafletMock();
|
||||
ctx.registerPage = () => {};
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.connectWS = () => {};
|
||||
ctx.api = () => Promise.resolve([]);
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
ctx.getFavorites = () => [];
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.HopResolver = { init() {}, resolve: () => ({}), ready: () => false };
|
||||
ctx.MeshAudio = null;
|
||||
ctx.RegionFilter = { init() {}, getSelected: () => null, onRegionChange: () => {} };
|
||||
}
|
||||
|
||||
function makeLiveSandbox({ withAppJs = false } = {}) {
|
||||
const ctx = makeSandbox();
|
||||
addLiveGlobals(ctx);
|
||||
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
if (withAppJs) loadInCtx(ctx, 'public/app.js');
|
||||
try { loadInCtx(ctx, 'public/live.js'); } catch (e) {
|
||||
console.error('live.js load error:', e.message);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ===== dbPacketToLive =====
|
||||
console.log('\n=== live.js: dbPacketToLive ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const dbPacketToLive = ctx.window._liveDbPacketToLive;
|
||||
assert.ok(dbPacketToLive, '_liveDbPacketToLive must be exposed');
|
||||
|
||||
test('converts basic DB packet to live format', () => {
|
||||
const pkt = {
|
||||
id: 42, hash: 'abc123',
|
||||
raw_hex: 'deadbeef',
|
||||
path_json: '["hop1","hop2"]',
|
||||
decoded_json: '{"type":"GRP_TXT","text":"hello"}',
|
||||
timestamp: '2024-06-15T12:00:00Z',
|
||||
snr: 7.5, rssi: -85, observer_name: 'ObsA',
|
||||
};
|
||||
const result = dbPacketToLive(pkt);
|
||||
assert.strictEqual(result.id, 42);
|
||||
assert.strictEqual(result.hash, 'abc123');
|
||||
assert.strictEqual(result.raw, 'deadbeef');
|
||||
assert.strictEqual(result.snr, 7.5);
|
||||
assert.strictEqual(result.rssi, -85);
|
||||
assert.strictEqual(result.observer, 'ObsA');
|
||||
assert.strictEqual(result.decoded.header.payloadTypeName, 'GRP_TXT');
|
||||
assert.strictEqual(result.decoded.payload.text, 'hello');
|
||||
assert.deepStrictEqual(result.decoded.path.hops, ['hop1', 'hop2']);
|
||||
assert.strictEqual(result._ts, new Date('2024-06-15T12:00:00Z').getTime());
|
||||
});
|
||||
|
||||
test('handles null decoded_json', () => {
|
||||
const pkt = { id: 1, hash: 'x', decoded_json: null, path_json: null, timestamp: '2024-01-01T00:00:00Z' };
|
||||
const result = dbPacketToLive(pkt);
|
||||
assert.strictEqual(result.decoded.header.payloadTypeName, 'UNKNOWN');
|
||||
assert.deepStrictEqual(result.decoded.path.hops, []);
|
||||
});
|
||||
|
||||
test('uses payload_type_name as fallback', () => {
|
||||
const pkt = { id: 2, hash: 'y', decoded_json: '{}', path_json: '[]', timestamp: '2024-01-01T00:00:00Z', payload_type_name: 'ADVERT' };
|
||||
const result = dbPacketToLive(pkt);
|
||||
assert.strictEqual(result.decoded.header.payloadTypeName, 'ADVERT');
|
||||
});
|
||||
|
||||
test('uses created_at as timestamp fallback', () => {
|
||||
const pkt = { id: 3, hash: 'z', decoded_json: '{}', path_json: '[]', created_at: '2024-03-01T06:00:00Z' };
|
||||
const result = dbPacketToLive(pkt);
|
||||
assert.strictEqual(result._ts, new Date('2024-03-01T06:00:00Z').getTime());
|
||||
});
|
||||
}
|
||||
|
||||
// ===== expandToBufferEntries =====
|
||||
console.log('\n=== live.js: expandToBufferEntries ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const expand = ctx.window._liveExpandToBufferEntries;
|
||||
assert.ok(expand, '_liveExpandToBufferEntries must be exposed');
|
||||
|
||||
test('single packet without observations returns one entry', () => {
|
||||
const pkts = [{
|
||||
id: 1, hash: 'h1', timestamp: '2024-06-15T12:00:00Z',
|
||||
decoded_json: '{"type":"GRP_TXT"}', path_json: '[]',
|
||||
}];
|
||||
const entries = expand(pkts);
|
||||
assert.strictEqual(entries.length, 1);
|
||||
assert.strictEqual(entries[0].pkt.id, 1);
|
||||
assert.strictEqual(entries[0].ts, new Date('2024-06-15T12:00:00Z').getTime());
|
||||
});
|
||||
|
||||
test('packet with observations expands to one entry per observation', () => {
|
||||
const pkts = [{
|
||||
id: 10, hash: 'h10', timestamp: '2024-06-15T12:00:00Z',
|
||||
decoded_json: '{"type":"ADVERT"}', path_json: '[]', raw_hex: 'ff',
|
||||
observations: [
|
||||
{ timestamp: '2024-06-15T12:00:01Z', snr: 5, observer_name: 'O1' },
|
||||
{ timestamp: '2024-06-15T12:00:02Z', snr: 8, observer_name: 'O2' },
|
||||
{ timestamp: '2024-06-15T12:00:03Z', snr: 3, observer_name: 'O3' },
|
||||
],
|
||||
}];
|
||||
const entries = expand(pkts);
|
||||
assert.strictEqual(entries.length, 3);
|
||||
assert.strictEqual(entries[0].pkt.observer, 'O1');
|
||||
assert.strictEqual(entries[1].pkt.observer, 'O2');
|
||||
assert.strictEqual(entries[2].pkt.observer, 'O3');
|
||||
// All should share the same hash
|
||||
assert.strictEqual(entries[0].pkt.hash, 'h10');
|
||||
assert.strictEqual(entries[2].pkt.hash, 'h10');
|
||||
// Entries should be in chronological order
|
||||
assert.ok(entries[0].ts < entries[1].ts, 'entry 0 should be before entry 1');
|
||||
assert.ok(entries[1].ts < entries[2].ts, 'entry 1 should be before entry 2');
|
||||
});
|
||||
|
||||
test('empty observations array treated as no observations', () => {
|
||||
const pkts = [{
|
||||
id: 5, hash: 'h5', timestamp: '2024-01-01T00:00:00Z',
|
||||
decoded_json: '{}', path_json: '[]', observations: [],
|
||||
}];
|
||||
const entries = expand(pkts);
|
||||
assert.strictEqual(entries.length, 1);
|
||||
});
|
||||
|
||||
test('multiple packets expand independently', () => {
|
||||
const pkts = [
|
||||
{ id: 1, hash: 'h1', timestamp: '2024-01-01T00:00:00Z', decoded_json: '{}', path_json: '[]' },
|
||||
{
|
||||
id: 2, hash: 'h2', timestamp: '2024-01-01T00:00:00Z', decoded_json: '{}', path_json: '[]', raw_hex: 'aa',
|
||||
observations: [
|
||||
{ timestamp: '2024-01-01T00:00:01Z', observer_name: 'X' },
|
||||
{ timestamp: '2024-01-01T00:00:02Z', observer_name: 'Y' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const entries = expand(pkts);
|
||||
assert.strictEqual(entries.length, 3);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SEG_MAP (7-segment display) =====
|
||||
console.log('\n=== live.js: SEG_MAP ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const SEG_MAP = ctx.window._liveSEG_MAP;
|
||||
assert.ok(SEG_MAP, '_liveSEG_MAP must be exposed');
|
||||
|
||||
test('all digits 0-9 are mapped', () => {
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
assert.ok(SEG_MAP[String(i)] !== undefined, `digit ${i} must be in SEG_MAP`);
|
||||
assert.ok(SEG_MAP[String(i)] > 0, `digit ${i} must have non-zero segments`);
|
||||
}
|
||||
});
|
||||
|
||||
test('digit 8 lights all 7 segments and no others', () => {
|
||||
// 0x7F = 0b01111111 — all 7 segment bits on, MSB (colon) off
|
||||
const val = SEG_MAP['8'];
|
||||
assert.strictEqual(val & 0x7F, 0x7F, 'all 7 segment bits should be set');
|
||||
assert.strictEqual(val & 0x80, 0, 'colon bit should not be set for a digit');
|
||||
});
|
||||
|
||||
test('colon only sets the MSB (dot/colon indicator)', () => {
|
||||
const val = SEG_MAP[':'];
|
||||
assert.strictEqual(val & 0x80, 0x80, 'MSB (colon bit) should be set');
|
||||
assert.strictEqual(val & 0x7F, 0, 'no segment bits should be set for colon');
|
||||
});
|
||||
|
||||
test('space lights no segments', () => {
|
||||
assert.strictEqual(SEG_MAP[' '], 0x00, 'space should have no bits set');
|
||||
});
|
||||
|
||||
test('digit 1 lights fewer segments than digit 8', () => {
|
||||
// Behavioral: 1 has fewer segments lit than 8
|
||||
const ones = (n) => { let c = 0; while (n) { c += n & 1; n >>= 1; } return c; };
|
||||
assert.ok(ones(SEG_MAP['1']) < ones(SEG_MAP['8']),
|
||||
'digit 1 should have fewer segment bits than digit 8');
|
||||
});
|
||||
|
||||
test('VCR mode letters are mapped with non-zero segments', () => {
|
||||
for (const ch of ['P', 'A', 'U', 'S', 'E', 'L', 'I', 'V']) {
|
||||
assert.ok(SEG_MAP[ch] !== undefined, `${ch} must be in SEG_MAP`);
|
||||
assert.ok(SEG_MAP[ch] > 0, `${ch} must have non-zero segments`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== VCR state machine =====
|
||||
console.log('\n=== live.js: VCR state machine ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const VCR = ctx.window._liveVCR;
|
||||
const vcrSetMode = ctx.window._liveVcrSetMode;
|
||||
const vcrPause = ctx.window._liveVcrPause;
|
||||
const vcrSpeedCycle = ctx.window._liveVcrSpeedCycle;
|
||||
assert.ok(VCR, '_liveVCR must be exposed');
|
||||
|
||||
test('VCR initial mode is LIVE', () => {
|
||||
assert.strictEqual(VCR().mode, 'LIVE');
|
||||
});
|
||||
|
||||
test('vcrSetMode changes mode', () => {
|
||||
vcrSetMode('PAUSED');
|
||||
assert.strictEqual(VCR().mode, 'PAUSED');
|
||||
assert.ok(VCR().frozenNow != null, 'frozenNow should be set when not LIVE');
|
||||
});
|
||||
|
||||
test('vcrSetMode LIVE clears frozenNow', () => {
|
||||
vcrSetMode('LIVE');
|
||||
assert.strictEqual(VCR().mode, 'LIVE');
|
||||
assert.strictEqual(VCR().frozenNow, null);
|
||||
});
|
||||
|
||||
test('vcrPause stops replay and sets PAUSED', () => {
|
||||
vcrSetMode('LIVE');
|
||||
vcrPause();
|
||||
assert.strictEqual(VCR().mode, 'PAUSED');
|
||||
assert.strictEqual(VCR().missedCount, 0);
|
||||
});
|
||||
|
||||
test('vcrPause is idempotent', () => {
|
||||
vcrPause();
|
||||
const frozen1 = VCR().frozenNow;
|
||||
assert.strictEqual(VCR().mode, 'PAUSED', 'mode should be PAUSED after first call');
|
||||
vcrPause();
|
||||
assert.strictEqual(VCR().frozenNow, frozen1);
|
||||
assert.strictEqual(VCR().mode, 'PAUSED', 'mode should stay PAUSED after second call');
|
||||
});
|
||||
|
||||
test('vcrSpeedCycle cycles through 1,2,4,8', () => {
|
||||
vcrSetMode('LIVE');
|
||||
VCR().speed = 1;
|
||||
vcrSpeedCycle();
|
||||
assert.strictEqual(VCR().speed, 2);
|
||||
vcrSpeedCycle();
|
||||
assert.strictEqual(VCR().speed, 4);
|
||||
vcrSpeedCycle();
|
||||
assert.strictEqual(VCR().speed, 8);
|
||||
vcrSpeedCycle();
|
||||
assert.strictEqual(VCR().speed, 1); // wraps around
|
||||
});
|
||||
|
||||
const vcrResumeLive = ctx.window._liveVcrResumeLive;
|
||||
assert.ok(vcrResumeLive, '_liveVcrResumeLive must be exposed');
|
||||
|
||||
test('vcrResumeLive transitions from PAUSED to LIVE', () => {
|
||||
vcrPause();
|
||||
assert.strictEqual(VCR().mode, 'PAUSED');
|
||||
assert.ok(VCR().frozenNow != null, 'frozenNow should be set when paused');
|
||||
vcrResumeLive();
|
||||
assert.strictEqual(VCR().mode, 'LIVE');
|
||||
assert.strictEqual(VCR().frozenNow, null, 'frozenNow should be cleared');
|
||||
assert.strictEqual(VCR().playhead, -1, 'playhead should reset to -1');
|
||||
assert.strictEqual(VCR().speed, 1, 'speed should reset to 1');
|
||||
assert.strictEqual(VCR().missedCount, 0, 'missedCount should be 0');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== getFavoritePubkeys =====
|
||||
console.log('\n=== live.js: getFavoritePubkeys ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const getFavPubkeys = ctx.window._liveGetFavoritePubkeys;
|
||||
assert.ok(getFavPubkeys, '_liveGetFavoritePubkeys must be exposed');
|
||||
|
||||
test('returns empty array when no favorites stored', () => {
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(Array.isArray(result));
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
test('reads from meshcore-favorites', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["pk1","pk2"]');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(result.includes('pk1'));
|
||||
assert.ok(result.includes('pk2'));
|
||||
});
|
||||
|
||||
test('reads from meshcore-my-nodes pubkeys', () => {
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', '[{"pubkey":"mynode1"},{"pubkey":"mynode2"}]');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(result.includes('mynode1'));
|
||||
assert.ok(result.includes('mynode2'));
|
||||
});
|
||||
|
||||
test('merges both sources', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["fav1"]');
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', '[{"pubkey":"mine1"}]');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(result.includes('fav1'));
|
||||
assert.ok(result.includes('mine1'));
|
||||
assert.strictEqual(result.length, 2);
|
||||
});
|
||||
|
||||
test('handles corrupt localStorage gracefully', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', 'not json');
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', '{bad}');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(Array.isArray(result));
|
||||
assert.strictEqual(result.length, 0, 'corrupt data should yield empty array');
|
||||
});
|
||||
|
||||
test('filters out falsy values', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["pk1",null,"",false,"pk2"]');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const result = getFavPubkeys();
|
||||
assert.ok(!result.includes(null));
|
||||
assert.ok(!result.includes(''));
|
||||
assert.strictEqual(result.length, 2);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== packetInvolvesFavorite =====
|
||||
console.log('\n=== live.js: packetInvolvesFavorite ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
// Clean localStorage to avoid leakage from prior test sections
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const involves = ctx.window._livePacketInvolvesFavorite;
|
||||
assert.ok(involves, '_livePacketInvolvesFavorite must be exposed');
|
||||
|
||||
test('returns false when no favorites', () => {
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const pkt = { decoded: { header: {}, payload: { pubKey: 'abc' } } };
|
||||
assert.strictEqual(involves(pkt), false);
|
||||
});
|
||||
|
||||
test('matches sender pubKey', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["sender123"]');
|
||||
const pkt = { decoded: { header: {}, payload: { pubKey: 'sender123' } } };
|
||||
assert.strictEqual(involves(pkt), true);
|
||||
});
|
||||
|
||||
test('matches hop prefix', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["abcdef1234567890"]');
|
||||
const pkt = { decoded: { header: {}, payload: {}, path: { hops: ['abcd'] } } };
|
||||
assert.strictEqual(involves(pkt), true);
|
||||
});
|
||||
|
||||
test('does not match unrelated hop', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["abcdef1234567890"]');
|
||||
const pkt = { decoded: { header: {}, payload: {}, path: { hops: ['ffff'] } } };
|
||||
assert.strictEqual(involves(pkt), false);
|
||||
});
|
||||
|
||||
test('handles missing decoded fields gracefully', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["xyz"]');
|
||||
const pkt = {};
|
||||
assert.strictEqual(involves(pkt), false);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== isNodeFavorited =====
|
||||
console.log('\n=== live.js: isNodeFavorited ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
// Clean localStorage to avoid leakage from prior test sections
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
const isFav = ctx.window._liveIsNodeFavorited;
|
||||
assert.ok(isFav, '_liveIsNodeFavorited must be exposed');
|
||||
|
||||
test('returns true when pubkey is in favorites', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["pk1","pk2"]');
|
||||
assert.strictEqual(isFav('pk1'), true);
|
||||
});
|
||||
|
||||
test('returns false when pubkey not in favorites', () => {
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["pk1"]');
|
||||
assert.strictEqual(isFav('pk99'), false);
|
||||
});
|
||||
|
||||
test('returns false with empty favorites', () => {
|
||||
ctx.localStorage.removeItem('meshcore-favorites');
|
||||
ctx.localStorage.removeItem('meshcore-my-nodes');
|
||||
assert.strictEqual(isFav('pk1'), false);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== formatLiveTimestampHtml =====
|
||||
console.log('\n=== live.js: formatLiveTimestampHtml ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox({ withAppJs: true });
|
||||
|
||||
const fmt = ctx.window._liveFormatLiveTimestampHtml;
|
||||
assert.ok(fmt, '_liveFormatLiveTimestampHtml must be exposed');
|
||||
|
||||
test('formats a recent ISO timestamp', () => {
|
||||
const iso = new Date(Date.now() - 30000).toISOString();
|
||||
const html = fmt(iso);
|
||||
assert.ok(html.includes('timestamp-text'), 'should contain timestamp-text span');
|
||||
assert.ok(html.includes('title='), 'should have tooltip');
|
||||
});
|
||||
|
||||
test('handles null input', () => {
|
||||
const html = fmt(null);
|
||||
assert.ok(typeof html === 'string');
|
||||
assert.ok(html.includes('—'), 'null input should render em-dash fallback');
|
||||
});
|
||||
|
||||
test('handles numeric timestamp', () => {
|
||||
const html = fmt(Date.now() - 60000);
|
||||
assert.ok(typeof html === 'string');
|
||||
assert.ok(html.includes('timestamp-text'), 'numeric timestamp should produce timestamp-text span');
|
||||
assert.ok(html.includes('title='), 'numeric timestamp should have tooltip');
|
||||
});
|
||||
|
||||
test('future timestamp shows warning icon', () => {
|
||||
const future = new Date(Date.now() + 120000).toISOString();
|
||||
const html = fmt(future);
|
||||
assert.ok(html.includes('timestamp-future-icon'), 'should show future warning');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== resolveHopPositions =====
|
||||
console.log('\n=== live.js: resolveHopPositions ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const resolve = ctx.window._liveResolveHopPositions;
|
||||
const nodeData = ctx.window._liveNodeData();
|
||||
const nodeMarkers = ctx.window._liveNodeMarkers();
|
||||
assert.ok(resolve, '_liveResolveHopPositions must be exposed');
|
||||
|
||||
test('returns empty array for empty hops', () => {
|
||||
const result = resolve([], {});
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
|
||||
test('returns sender position when payload has pubKey + coords', () => {
|
||||
const payload = { pubKey: 'sender1', name: 'Sender', lat: 37.5, lon: -122.0 };
|
||||
// No nodes in nodeData, so hops won't resolve
|
||||
const result = resolve([], payload);
|
||||
// With empty hops, the function still adds the sender as an anchor point.
|
||||
assert.ok(Array.isArray(result), 'should return an array');
|
||||
assert.strictEqual(result.length, 1, 'sender coords should produce one anchor position');
|
||||
assert.strictEqual(result[0].pos[0], 37.5, 'anchor should use sender lat');
|
||||
assert.strictEqual(result[0].pos[1], -122.0, 'anchor should use sender lon');
|
||||
assert.strictEqual(result[0].name, 'Sender', 'anchor should use sender name');
|
||||
assert.strictEqual(result[0].known, true, 'sender with coords should be marked as known');
|
||||
});
|
||||
|
||||
test('resolves known node from nodeData', () => {
|
||||
// Add a node to nodeData
|
||||
nodeData['nodeA_pubkey'] = { public_key: 'nodeA_pubkey', name: 'NodeA', lat: 37.3, lon: -122.0 };
|
||||
nodeData['nodeB_pubkey'] = { public_key: 'nodeB_pubkey', name: 'NodeB', lat: 38.0, lon: -121.0 };
|
||||
// Need HopResolver to resolve the hop prefix — set on both ctx and window
|
||||
const mockResolver = {
|
||||
init() {},
|
||||
ready() { return true; },
|
||||
resolve(hops) {
|
||||
const map = {};
|
||||
for (const h of hops) {
|
||||
if (h === 'nodeA') map[h] = { name: 'NodeA', pubkey: 'nodeA_pubkey' };
|
||||
else if (h === 'nodeB') map[h] = { name: 'NodeB', pubkey: 'nodeB_pubkey' };
|
||||
else map[h] = { name: null, pubkey: null };
|
||||
}
|
||||
return map;
|
||||
},
|
||||
};
|
||||
ctx.HopResolver = mockResolver;
|
||||
ctx.window.HopResolver = mockResolver;
|
||||
// Need at least 2 known nodes for ghost mode to not filter down
|
||||
const result = resolve(['nodeA', 'nodeB'], {});
|
||||
assert.ok(result.length >= 2, `expected >= 2 positions, got ${result.length}`);
|
||||
const foundA = result.find(r => r.key === 'nodeA_pubkey');
|
||||
assert.ok(foundA, 'should resolve nodeA to nodeA_pubkey');
|
||||
assert.strictEqual(foundA.pos[0], 37.3);
|
||||
assert.strictEqual(foundA.pos[1], -122.0);
|
||||
assert.strictEqual(foundA.known, true);
|
||||
delete nodeData['nodeA_pubkey'];
|
||||
delete nodeData['nodeB_pubkey'];
|
||||
});
|
||||
|
||||
test('ghost hops get interpolated positions between known nodes', () => {
|
||||
// Set up: two known nodes, one unknown hop between them
|
||||
nodeData['n1'] = { public_key: 'n1', name: 'N1', lat: 37.0, lon: -122.0 };
|
||||
nodeData['n2'] = { public_key: 'n2', name: 'N2', lat: 38.0, lon: -121.0 };
|
||||
const mockResolver = {
|
||||
init() {},
|
||||
ready() { return true; },
|
||||
resolve(hops) {
|
||||
const map = {};
|
||||
for (const h of hops) {
|
||||
if (h === 'h1') map[h] = { name: 'N1', pubkey: 'n1' };
|
||||
else if (h === 'h3') map[h] = { name: 'N2', pubkey: 'n2' };
|
||||
else map[h] = { name: null, pubkey: null };
|
||||
}
|
||||
return map;
|
||||
},
|
||||
};
|
||||
ctx.HopResolver = mockResolver;
|
||||
ctx.window.HopResolver = mockResolver;
|
||||
const result = resolve(['h1', 'h2', 'h3'], {});
|
||||
assert.ok(result.length >= 2, `should have at least 2 positions, got ${result.length}`);
|
||||
// Check that the ghost hop got an interpolated position
|
||||
const ghost = result.find(r => r.ghost);
|
||||
assert.ok(ghost, 'ghost hop should be present in resolved positions — if missing, interpolation logic changed');
|
||||
assert.ok(ghost.pos[0] > 37.0 && ghost.pos[0] < 38.0, 'ghost lat should be interpolated');
|
||||
assert.ok(ghost.pos[1] > -122.0 && ghost.pos[1] < -121.0, 'ghost lon should be interpolated');
|
||||
delete nodeData['n1'];
|
||||
delete nodeData['n2'];
|
||||
});
|
||||
}
|
||||
|
||||
// ===== bufferPacket and VCR buffer management =====
|
||||
console.log('\n=== live.js: bufferPacket / VCR buffer ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const bufferPacket = ctx.window._liveBufferPacket;
|
||||
const VCR = ctx.window._liveVCR;
|
||||
assert.ok(bufferPacket, '_liveBufferPacket must be exposed');
|
||||
|
||||
test('bufferPacket adds entry to VCR buffer', () => {
|
||||
const initialLen = VCR().buffer.length;
|
||||
const pkt = { hash: 'test1', decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: {} } };
|
||||
bufferPacket(pkt);
|
||||
assert.strictEqual(VCR().buffer.length, initialLen + 1);
|
||||
const last = VCR().buffer[VCR().buffer.length - 1];
|
||||
assert.strictEqual(last.pkt.hash, 'test1');
|
||||
assert.ok(last.ts > 0);
|
||||
});
|
||||
|
||||
test('bufferPacket sets _ts on packet', () => {
|
||||
const pkt = { hash: 'test2', decoded: { header: {}, payload: {} } };
|
||||
const before = Date.now();
|
||||
bufferPacket(pkt);
|
||||
const after = Date.now();
|
||||
assert.ok(pkt._ts >= before && pkt._ts <= after, `_ts should be between ${before} and ${after}, got ${pkt._ts}`);
|
||||
});
|
||||
|
||||
test('VCR buffer caps at ~2000 entries', () => {
|
||||
// Fill buffer past 2000
|
||||
VCR().buffer.length = 0;
|
||||
for (let i = 0; i < 2100; i++) {
|
||||
VCR().buffer.push({ ts: Date.now(), pkt: { hash: 'fill' + i } });
|
||||
}
|
||||
// Next bufferPacket triggers trim: 2100+1=2101 > 2000 → splice(0, 500) → 1601
|
||||
const pkt = { hash: 'overflow', decoded: { header: {}, payload: {} } };
|
||||
bufferPacket(pkt);
|
||||
assert.strictEqual(VCR().buffer.length, 1601, `buffer should be 2101 - 500 = 1601, got ${VCR().buffer.length}`);
|
||||
});
|
||||
|
||||
test('bufferPacket increments missedCount when PAUSED', () => {
|
||||
ctx.window._liveVcrSetMode('PAUSED');
|
||||
VCR().missedCount = 0;
|
||||
const pkt = { hash: 'missed1', decoded: { header: {}, payload: {} } };
|
||||
bufferPacket(pkt);
|
||||
assert.strictEqual(VCR().missedCount, 1);
|
||||
bufferPacket({ hash: 'missed2', decoded: { header: {}, payload: {} } });
|
||||
assert.strictEqual(VCR().missedCount, 2);
|
||||
ctx.window._liveVcrSetMode('LIVE');
|
||||
});
|
||||
|
||||
test('bufferPacket handles malformed packet without decoded field', () => {
|
||||
const before = VCR().buffer.length;
|
||||
// Packet with no decoded field at all — should not throw, and should still be buffered
|
||||
bufferPacket({ hash: 'malformed1' });
|
||||
assert.strictEqual(VCR().buffer.length, before + 1, 'malformed packet should still be added to buffer');
|
||||
});
|
||||
|
||||
test('bufferPacket handles packet with null decoded', () => {
|
||||
const before = VCR().buffer.length;
|
||||
bufferPacket({ hash: 'malformed2', decoded: null });
|
||||
assert.strictEqual(VCR().buffer.length, before + 1, 'packet with null decoded should still be added to buffer');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== VCR frozenNow behavior =====
|
||||
console.log('\n=== live.js: VCR frozenNow ===');
|
||||
{
|
||||
const ctx = makeLiveSandbox();
|
||||
const VCR = ctx.window._liveVCR;
|
||||
const setMode = ctx.window._liveVcrSetMode;
|
||||
|
||||
test('frozenNow is set on first non-LIVE mode', () => {
|
||||
setMode('LIVE');
|
||||
assert.strictEqual(VCR().frozenNow, null);
|
||||
setMode('PAUSED');
|
||||
const t1 = VCR().frozenNow;
|
||||
assert.ok(t1 > 0);
|
||||
// Should NOT change on subsequent non-LIVE mode changes
|
||||
setMode('REPLAY');
|
||||
assert.strictEqual(VCR().frozenNow, t1, 'frozenNow should not change if already set');
|
||||
});
|
||||
|
||||
test('frozenNow cleared on LIVE', () => {
|
||||
setMode('PAUSED');
|
||||
assert.ok(VCR().frozenNow != null);
|
||||
setMode('LIVE');
|
||||
assert.strictEqual(VCR().frozenNow, null);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Source-level checks for live.js safety guards =====
|
||||
// NOTE: These src.includes() checks are intentionally brittle — they verify that specific
|
||||
// safety guards exist in the source code TODAY. They will break on whitespace/rename refactors,
|
||||
// which is an acceptable tradeoff: a failing test forces the developer to verify the guard
|
||||
// still exists in its new form. For critical guards (animation limits, null checks), prefer
|
||||
// behavioral tests where feasible (see bufferPacket and VCR sections above).
|
||||
console.log('\n=== live.js: source-level safety checks ===');
|
||||
{
|
||||
const src = fs.readFileSync('public/live.js', 'utf8');
|
||||
|
||||
test('renderPacketTree null-checks packets array', () => {
|
||||
assert.ok(src.includes('if (!packets || !packets.length) return;'),
|
||||
'renderPacketTree must guard null/empty packets');
|
||||
});
|
||||
|
||||
test('animatePath guards MAX_CONCURRENT_ANIMS', () => {
|
||||
assert.ok(src.includes('if (activeAnims >= MAX_CONCURRENT_ANIMS) return;'),
|
||||
'animatePath must respect concurrent animation limit');
|
||||
});
|
||||
|
||||
test('animatePath guards null animLayer/pathsLayer', () => {
|
||||
assert.ok(src.includes('if (!animLayer || !pathsLayer) return;'),
|
||||
'animatePath must guard null layers');
|
||||
});
|
||||
|
||||
test('pulseNode guards null animLayer/nodesLayer', () => {
|
||||
assert.ok(src.includes('if (!animLayer || !nodesLayer) return;'),
|
||||
'pulseNode must guard null layers');
|
||||
});
|
||||
|
||||
test('nextHop guards null animLayer', () => {
|
||||
assert.ok(src.includes('if (!animLayer) return;'),
|
||||
'nextHop must guard null animLayer before drawing');
|
||||
});
|
||||
|
||||
test('VCR buffer trim adjusts playhead', () => {
|
||||
assert.ok(src.includes('VCR.playhead = Math.max(0, VCR.playhead - trimCount)'),
|
||||
'buffer trim must adjust playhead to prevent stale indices');
|
||||
});
|
||||
|
||||
test('tab hidden skips animations', () => {
|
||||
assert.ok(src.includes('if (_tabHidden)'),
|
||||
'bufferPacket should skip animation when tab is hidden');
|
||||
});
|
||||
|
||||
test('visibility change clears propagation buffer', () => {
|
||||
assert.ok(src.includes('propagationBuffer.clear()'),
|
||||
'tab restore should clear propagation buffer');
|
||||
});
|
||||
|
||||
test('connectWS has reconnect on close', () => {
|
||||
assert.ok(src.includes('ws.onclose = () => setTimeout(connectWS, WS_RECONNECT_MS)'),
|
||||
'WebSocket should auto-reconnect on close');
|
||||
});
|
||||
|
||||
test('addNodeMarker avoids duplicates', () => {
|
||||
assert.ok(src.includes('if (nodeMarkers[n.public_key]) return nodeMarkers[n.public_key]'),
|
||||
'addNodeMarker should return existing marker if already exists');
|
||||
});
|
||||
|
||||
test('matrix mode saves toggle to localStorage', () => {
|
||||
assert.ok(src.includes("localStorage.setItem('live-matrix-mode'"),
|
||||
'matrix toggle should persist to localStorage');
|
||||
});
|
||||
|
||||
test('matrix rain saves toggle to localStorage', () => {
|
||||
assert.ok(src.includes("localStorage.setItem('live-matrix-rain'"),
|
||||
'matrix rain toggle should persist to localStorage');
|
||||
});
|
||||
|
||||
test('realistic propagation saves toggle to localStorage', () => {
|
||||
assert.ok(src.includes("localStorage.setItem('live-realistic-propagation'"),
|
||||
'realistic propagation toggle should persist to localStorage');
|
||||
});
|
||||
|
||||
test('favorites filter saves toggle to localStorage', () => {
|
||||
assert.ok(src.includes("localStorage.setItem('live-favorites-only'"),
|
||||
'favorites filter toggle should persist to localStorage');
|
||||
});
|
||||
|
||||
test('ghost hops saves toggle to localStorage', () => {
|
||||
assert.ok(src.includes("localStorage.setItem('live-ghost-hops'"),
|
||||
'ghost hops toggle should persist to localStorage');
|
||||
});
|
||||
|
||||
test('clearNodeMarkers resets HopResolver', () => {
|
||||
assert.ok(src.includes('if (window.HopResolver) HopResolver.init([])'),
|
||||
'clearNodeMarkers should reset HopResolver');
|
||||
});
|
||||
|
||||
test('rescaleMarkers reads zoom from map', () => {
|
||||
assert.ok(src.includes('const zoom = map.getZoom()'),
|
||||
'rescaleMarkers should read current zoom level');
|
||||
});
|
||||
|
||||
test('startReplay pre-aggregates by hash', () => {
|
||||
assert.ok(src.includes('const hashGroups = new Map()'),
|
||||
'startReplay should group buffer entries by hash');
|
||||
});
|
||||
|
||||
test('orientation change retries resize with delays', () => {
|
||||
assert.ok(src.includes('[50, 200, 500, 1000, 2000].forEach'),
|
||||
'orientation change handler should retry resize at multiple intervals');
|
||||
});
|
||||
|
||||
test('VCR rewind deduplicates buffer entries by ID', () => {
|
||||
assert.ok(src.includes('const existingIds = new Set(VCR.buffer.map(b => b.pkt.id)'),
|
||||
'vcrRewind should dedup by packet ID');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
console.log(` live.js tests: ${passed} passed, ${failed} failed`);
|
||||
console.log(`${'═'.repeat(40)}\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}).catch((e) => {
|
||||
console.error('Failed waiting for async tests:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
+763
@@ -0,0 +1,763 @@
|
||||
/* Unit tests for packets.js functions (tested via VM sandbox) */
|
||||
'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 a browser-like sandbox with all deps packets.js needs
|
||||
function makeSandbox() {
|
||||
const registeredPages = {};
|
||||
const ctx = {
|
||||
window: {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
innerWidth: 1200,
|
||||
PacketFilter: null,
|
||||
},
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: (tag) => ({
|
||||
tagName: tag.toUpperCase(), id: '', textContent: '', innerHTML: '',
|
||||
className: '', style: {}, appendChild: () => {}, setAttribute: () => {},
|
||||
addEventListener: () => {}, querySelectorAll: () => [], querySelector: () => null,
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false },
|
||||
}),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
body: { appendChild: () => {} },
|
||||
},
|
||||
console,
|
||||
Date,
|
||||
Infinity,
|
||||
Math,
|
||||
Array,
|
||||
Object,
|
||||
String,
|
||||
Number,
|
||||
JSON,
|
||||
RegExp,
|
||||
Error,
|
||||
TypeError,
|
||||
RangeError,
|
||||
parseInt,
|
||||
parseFloat,
|
||||
isNaN,
|
||||
isFinite,
|
||||
encodeURIComponent,
|
||||
decodeURIComponent,
|
||||
setTimeout: () => {},
|
||||
clearTimeout: () => {},
|
||||
setInterval: () => {},
|
||||
clearInterval: () => {},
|
||||
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
|
||||
performance: { now: () => Date.now() },
|
||||
localStorage: (() => {
|
||||
const store = {};
|
||||
return {
|
||||
getItem: k => store[k] || null,
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: k => { delete store[k]; },
|
||||
};
|
||||
})(),
|
||||
location: { hash: '' },
|
||||
history: { replaceState: () => {} },
|
||||
CustomEvent: class CustomEvent {},
|
||||
Map,
|
||||
Set,
|
||||
Promise,
|
||||
URLSearchParams,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
||||
_registeredPages: registeredPages,
|
||||
// Stub global functions packets.js depends on
|
||||
registerPage: (name, handler) => { registeredPages[name] = handler; },
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadInCtx(ctx, file) {
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx, { filename: file });
|
||||
for (const k of Object.keys(ctx.window)) {
|
||||
ctx[k] = ctx.window[k];
|
||||
}
|
||||
}
|
||||
|
||||
function loadPacketsSandbox() {
|
||||
const ctx = makeSandbox();
|
||||
// Load dependencies first
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
// HopDisplay stub (simpler than loading real file which may have DOM deps)
|
||||
vm.runInContext(`
|
||||
window.HopDisplay = {
|
||||
renderHop: function(h, entry, opts) {
|
||||
if (entry && entry.name) return '<span class="hop-named">' + entry.name + '</span>';
|
||||
return '<span class="hop-hex">' + h + '</span>';
|
||||
},
|
||||
_showFromBtn: function() {}
|
||||
};
|
||||
`, ctx);
|
||||
loadInCtx(ctx, 'public/packets.js');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ===== TESTS =====
|
||||
|
||||
console.log('\n=== packets.js: typeName ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('typeName returns known type', () => {
|
||||
assert.strictEqual(api.typeName(0), 'Request');
|
||||
assert.strictEqual(api.typeName(4), 'Advert');
|
||||
assert.strictEqual(api.typeName(5), 'Channel Msg');
|
||||
});
|
||||
|
||||
test('typeName returns fallback for unknown', () => {
|
||||
assert.strictEqual(api.typeName(99), 'Type 99');
|
||||
assert.strictEqual(api.typeName(undefined), 'Type undefined');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: obsName ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('obsName returns dash for falsy id', () => {
|
||||
assert.strictEqual(api.obsName(null), '—');
|
||||
assert.strictEqual(api.obsName(''), '—');
|
||||
assert.strictEqual(api.obsName(undefined), '—');
|
||||
});
|
||||
|
||||
test('obsName returns id when not in observerMap', () => {
|
||||
assert.strictEqual(api.obsName('unknown-id'), 'unknown-id');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: kv ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('kv produces correct HTML', () => {
|
||||
const result = api.kv('Route', 'Direct');
|
||||
assert(result.includes('byop-key'));
|
||||
assert(result.includes('Route'));
|
||||
assert(result.includes('Direct'));
|
||||
assert(result.includes('byop-val'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: sectionRow / fieldRow ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('sectionRow produces section HTML', () => {
|
||||
const result = api.sectionRow('Header');
|
||||
assert(result.includes('section-row'));
|
||||
assert(result.includes('Header'));
|
||||
assert(result.includes('colspan="4"'));
|
||||
});
|
||||
|
||||
test('fieldRow produces field HTML', () => {
|
||||
const result = api.fieldRow(0, 'Header Byte', '0xFF', 'some desc');
|
||||
assert(result.includes('0'));
|
||||
assert(result.includes('Header Byte'));
|
||||
assert(result.includes('0xFF'));
|
||||
assert(result.includes('some desc'));
|
||||
assert(result.includes('mono'));
|
||||
});
|
||||
|
||||
test('fieldRow handles empty description', () => {
|
||||
const result = api.fieldRow(5, 'Test', 'val', '');
|
||||
assert(result.includes('text-muted'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: getDetailPreview ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('getDetailPreview returns empty for null/undefined', () => {
|
||||
assert.strictEqual(api.getDetailPreview(null), '');
|
||||
assert.strictEqual(api.getDetailPreview(undefined), '');
|
||||
});
|
||||
|
||||
test('getDetailPreview handles CHAN type', () => {
|
||||
const result = api.getDetailPreview({ type: 'CHAN', text: 'hello world', channel: 'general' });
|
||||
assert(result.includes('💬'));
|
||||
assert(result.includes('hello world'));
|
||||
assert(result.includes('chan-tag'));
|
||||
assert(result.includes('general'));
|
||||
});
|
||||
|
||||
test('getDetailPreview truncates long CHAN text', () => {
|
||||
const longText = 'x'.repeat(100);
|
||||
const result = api.getDetailPreview({ type: 'CHAN', text: longText });
|
||||
assert(result.includes('…'));
|
||||
assert(!result.includes('x'.repeat(100)));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT type', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'TestNode', pubKey: 'abc123',
|
||||
flags: { repeater: true }
|
||||
});
|
||||
assert(result.includes('📡'));
|
||||
assert(result.includes('TestNode'));
|
||||
assert(result.includes('hop-link'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT room', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'RoomNode', pubKey: 'abc',
|
||||
flags: { room: true }
|
||||
});
|
||||
assert(result.includes('🏠'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT sensor', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'Sensor1', pubKey: 'abc',
|
||||
flags: { sensor: true }
|
||||
});
|
||||
assert(result.includes('🌡'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT companion (default)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'Comp', pubKey: 'abc',
|
||||
flags: {}
|
||||
});
|
||||
assert(result.includes('📻'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_TXT with channelHash (no_key)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_TXT', channelHash: 0xAB, decryptionStatus: 'no_key'
|
||||
});
|
||||
assert(result.includes('🔒'));
|
||||
assert(result.includes('0xAB'));
|
||||
assert(result.includes('no key'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_TXT decryption_failed', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_TXT', channelHash: 5, decryptionStatus: 'decryption_failed'
|
||||
});
|
||||
assert(result.includes('decryption failed'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_TXT with channelHashHex', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_TXT', channelHash: 0xFF, channelHashHex: 'FF'
|
||||
});
|
||||
assert(result.includes('0xFF'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles TXT_MSG', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'TXT_MSG', srcHash: 'abcdef01', destHash: '12345678'
|
||||
});
|
||||
assert(result.includes('✉️'));
|
||||
assert(result.includes('abcdef01'));
|
||||
assert(result.includes('12345678'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles PATH', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'PATH', srcHash: 'aabb', destHash: 'ccdd'
|
||||
});
|
||||
assert(result.includes('🔀'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles REQ', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'REQ', srcHash: 'aa', destHash: 'bb'
|
||||
});
|
||||
assert(result.includes('🔒'));
|
||||
assert(result.includes('aa'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles RESPONSE', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'RESPONSE', srcHash: 'aa', destHash: 'bb'
|
||||
});
|
||||
assert(result.includes('🔒'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ANON_REQ', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ANON_REQ', destHash: 'dd'
|
||||
});
|
||||
assert(result.includes('anon'));
|
||||
assert(result.includes('dd'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles text fallback', () => {
|
||||
const result = api.getDetailPreview({ text: 'some message' });
|
||||
assert(result.includes('some message'));
|
||||
});
|
||||
|
||||
test('getDetailPreview truncates long text fallback', () => {
|
||||
const result = api.getDetailPreview({ text: 'z'.repeat(100) });
|
||||
assert(result.includes('…'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles public_key fallback', () => {
|
||||
const result = api.getDetailPreview({ public_key: 'abcdef1234567890abcdef' });
|
||||
assert(result.includes('📡'));
|
||||
assert(result.includes('abcdef1234567890'));
|
||||
});
|
||||
|
||||
test('getDetailPreview returns empty for empty decoded', () => {
|
||||
assert.strictEqual(api.getDetailPreview({}), '');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: getPathHopCount ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('getPathHopCount with valid path', () => {
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: '["a","b","c"]' }), 3);
|
||||
});
|
||||
|
||||
test('getPathHopCount with empty path', () => {
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: '[]' }), 0);
|
||||
});
|
||||
|
||||
test('getPathHopCount with null/missing', () => {
|
||||
assert.strictEqual(api.getPathHopCount({}), 0);
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: null }), 0);
|
||||
});
|
||||
|
||||
test('getPathHopCount with invalid JSON', () => {
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: 'not json' }), 0);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: sortGroupChildren ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('sortGroupChildren handles null/empty gracefully', () => {
|
||||
api.sortGroupChildren(null);
|
||||
api.sortGroupChildren({});
|
||||
api.sortGroupChildren({ _children: [] });
|
||||
// No throw
|
||||
});
|
||||
|
||||
test('sortGroupChildren default sort groups by observer earliest-first', () => {
|
||||
// Need to set obsSortMode — it reads from closure. Default is 'observer'.
|
||||
const group = {
|
||||
_children: [
|
||||
{ observer_name: 'B', timestamp: '2024-01-01T02:00:00Z' },
|
||||
{ observer_name: 'A', timestamp: '2024-01-01T01:00:00Z' },
|
||||
{ observer_name: 'B', timestamp: '2024-01-01T01:30:00Z' },
|
||||
]
|
||||
};
|
||||
api.sortGroupChildren(group);
|
||||
// A has earliest timestamp, should be first
|
||||
assert.strictEqual(group._children[0].observer_name, 'A');
|
||||
// Then B entries
|
||||
assert.strictEqual(group._children[1].observer_name, 'B');
|
||||
assert.strictEqual(group._children[2].observer_name, 'B');
|
||||
// B entries should be time-ascending within group
|
||||
assert(group._children[1].timestamp < group._children[2].timestamp);
|
||||
});
|
||||
|
||||
test('sortGroupChildren updates header from first child', () => {
|
||||
const group = {
|
||||
observer_id: 'old',
|
||||
_children: [
|
||||
{ observer_name: 'A', observer_id: 'new-id', timestamp: '2024-01-01T01:00:00Z', snr: 10, rssi: -50, path_json: '["x"]', direction: 'rx' },
|
||||
]
|
||||
};
|
||||
api.sortGroupChildren(group);
|
||||
assert.strictEqual(group.observer_id, 'new-id');
|
||||
assert.strictEqual(group.snr, 10);
|
||||
assert.strictEqual(group.rssi, -50);
|
||||
assert.strictEqual(group.path_json, '["x"]');
|
||||
assert.strictEqual(group.direction, 'rx');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: renderTimestampCell ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('renderTimestampCell produces HTML with timestamp-text', () => {
|
||||
const result = api.renderTimestampCell('2024-01-15T10:30:00Z');
|
||||
assert(result.includes('timestamp-text'));
|
||||
});
|
||||
|
||||
test('renderTimestampCell handles null gracefully', () => {
|
||||
const result = api.renderTimestampCell(null);
|
||||
// Should not throw, produces some output
|
||||
assert(typeof result === 'string');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: renderPath ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('renderPath returns dash for empty/null', () => {
|
||||
assert.strictEqual(api.renderPath(null, null), '—');
|
||||
assert.strictEqual(api.renderPath([], null), '—');
|
||||
});
|
||||
|
||||
test('renderPath renders hops with arrows', () => {
|
||||
const result = api.renderPath(['aa', 'bb'], null);
|
||||
assert(result.includes('arrow'));
|
||||
assert(result.includes('aa'));
|
||||
assert(result.includes('bb'));
|
||||
});
|
||||
|
||||
test('renderPath renders single hop without arrow', () => {
|
||||
const result = api.renderPath(['cc'], null);
|
||||
assert(result.includes('cc'));
|
||||
assert(!result.includes('arrow'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: renderDecodedPacket ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('renderDecodedPacket produces header section', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 4, payloadVersion: 1 },
|
||||
payload: { name: 'TestNode' },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aabbccdd';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('byop-decoded'));
|
||||
assert(result.includes('Header'));
|
||||
assert(result.includes('4 bytes'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders path hops', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 4 },
|
||||
payload: {},
|
||||
path: { hops: ['aa', 'bb'] }
|
||||
};
|
||||
const hex = 'aabbccdd';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('Path (2 hops)'));
|
||||
assert(result.includes('aa'));
|
||||
assert(result.includes('bb'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders payload fields', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 5 },
|
||||
payload: { channel: 'general', text: 'hello' },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aabb';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('channel'));
|
||||
assert(result.includes('general'));
|
||||
assert(result.includes('hello'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders nested objects as JSON', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 0 },
|
||||
payload: { flags: { repeater: true } },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aa';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('byop-pre'));
|
||||
assert(result.includes('repeater'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket skips null payload values', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 0 },
|
||||
payload: { a: null, b: undefined, c: 'visible' },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aa';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('visible'));
|
||||
// null/undefined values should be skipped
|
||||
const kvCount = (result.match(/byop-row/g) || []).length;
|
||||
// Only 'c' should appear in payload (a and b are null/undefined), plus header fields
|
||||
assert(kvCount >= 1);
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders raw hex', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 0 },
|
||||
payload: {},
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aabbcc';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('AA BB CC'));
|
||||
assert(result.includes('byop-hex'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: buildFieldTable ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('buildFieldTable produces table HTML', () => {
|
||||
const pkt = { raw_hex: 'c0400102', route_type: 1, payload_type: 4 };
|
||||
const decoded = { type: 'ADVERT', name: 'Node', pubKey: 'abc', flags: { type: 2, hasLocation: false, hasName: true, raw: 0x22 } };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('field-table'));
|
||||
assert(result.includes('Header'));
|
||||
assert(result.includes('Header Byte'));
|
||||
assert(result.includes('Path Length'));
|
||||
});
|
||||
|
||||
test('buildFieldTable handles transport codes (route_type 0)', () => {
|
||||
const pkt = { raw_hex: 'c0400102030405060708', route_type: 0, payload_type: 0 };
|
||||
const decoded = { destHash: 'aa', srcHash: 'bb', mac: 'cc', encryptedData: 'dd' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Transport Codes'));
|
||||
assert(result.includes('Next Hop'));
|
||||
assert(result.includes('Last Hop'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders path hops', () => {
|
||||
const pkt = { raw_hex: 'c042aabb', route_type: 1, payload_type: 0 };
|
||||
const decoded = { destHash: 'xx' };
|
||||
const result = api.buildFieldTable(pkt, decoded, ['aa', 'bb'], []);
|
||||
assert(result.includes('Path (2 hops)'));
|
||||
assert(result.includes('Hop 0'));
|
||||
assert(result.includes('Hop 1'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders ADVERT payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 4 };
|
||||
const decoded = {
|
||||
type: 'ADVERT', pubKey: 'abc123', timestamp: 1234567890,
|
||||
timestampISO: '2009-02-13T23:31:30Z', signature: 'sig',
|
||||
name: 'TestNode',
|
||||
flags: { type: 1, hasLocation: true, hasName: true, raw: 0x55 }
|
||||
};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Public Key'));
|
||||
assert(result.includes('Timestamp'));
|
||||
assert(result.includes('Signature'));
|
||||
assert(result.includes('App Flags'));
|
||||
assert(result.includes('Companion'));
|
||||
assert(result.includes('Latitude'));
|
||||
assert(result.includes('Node Name'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders GRP_TXT payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 5 };
|
||||
const decoded = { type: 'GRP_TXT', channelHash: 0xAB, mac: 'AABB', encryptedData: 'data', decryptionStatus: 'no_key' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Channel Hash'));
|
||||
assert(result.includes('MAC'));
|
||||
assert(result.includes('Encrypted Data'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders CHAN payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 5 };
|
||||
const decoded = { type: 'CHAN', channel: 'general', sender: 'Alice', sender_timestamp: '12:00' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Channel'));
|
||||
assert(result.includes('general'));
|
||||
assert(result.includes('Sender'));
|
||||
assert(result.includes('Sender Time'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders ACK payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 3 };
|
||||
const decoded = { type: 'ACK', ackChecksum: 'DEADBEEF' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Checksum'));
|
||||
assert(result.includes('DEADBEEF'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders destHash-based payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 2 };
|
||||
const decoded = { destHash: 'DD', srcHash: 'SS', mac: 'MM', encryptedData: 'EE' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Dest Hash'));
|
||||
assert(result.includes('Src Hash'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders raw fallback for unknown payload', () => {
|
||||
const pkt = { raw_hex: 'c040aabbccdd', route_type: 1, payload_type: 99 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Raw'));
|
||||
});
|
||||
|
||||
test('buildFieldTable hash_size calculation', () => {
|
||||
// Path byte 0xC0 → bits 7-6 = 3 → hash_size = 4
|
||||
const pkt = { raw_hex: '00C0', route_type: 1, payload_type: 0 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('hash_size=4'));
|
||||
});
|
||||
|
||||
test('buildFieldTable handles empty raw_hex', () => {
|
||||
const pkt = { raw_hex: '', route_type: 1, payload_type: 0 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('field-table'));
|
||||
assert(result.includes('0B') || result.includes('0 bytes') || result.includes('??'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: _getRowCount ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('_getRowCount returns 1 for ungrouped', () => {
|
||||
// _displayGrouped is internal, but when not grouped, should return 1
|
||||
// Since we can't easily control _displayGrouped, test the function behavior
|
||||
const result = api._getRowCount({ hash: 'abc', _children: [{ observer_id: '1' }] });
|
||||
// Default _displayGrouped depends on initialization, but the function should not throw
|
||||
assert(typeof result === 'number');
|
||||
assert(result >= 1);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: buildFlatRowHtml ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('buildFlatRowHtml produces table row', () => {
|
||||
const p = {
|
||||
id: 1, hash: 'abc123', timestamp: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aabb', payload_type: 4,
|
||||
route_type: 1, decoded_json: '{}', path_json: '[]'
|
||||
};
|
||||
const result = api.buildFlatRowHtml(p);
|
||||
assert(result.includes('<tr'));
|
||||
assert(result.includes('data-id="1"'));
|
||||
assert(result.includes('data-hash="abc123"'));
|
||||
});
|
||||
|
||||
test('buildFlatRowHtml calculates size from hex', () => {
|
||||
const p = {
|
||||
id: 2, hash: 'x', timestamp: '', observer_id: null,
|
||||
raw_hex: 'aabbccdd', payload_type: 0, route_type: 0,
|
||||
decoded_json: '{}', path_json: '[]'
|
||||
};
|
||||
const result = api.buildFlatRowHtml(p);
|
||||
assert(result.includes('4B')); // 8 hex chars = 4 bytes
|
||||
});
|
||||
|
||||
test('buildFlatRowHtml handles missing raw_hex', () => {
|
||||
const p = {
|
||||
id: 3, hash: 'y', timestamp: '', observer_id: null,
|
||||
raw_hex: null, payload_type: 0, route_type: 0,
|
||||
decoded_json: '{}', path_json: '[]'
|
||||
};
|
||||
const result = api.buildFlatRowHtml(p);
|
||||
assert(result.includes('0B'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: buildGroupRowHtml ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('buildGroupRowHtml renders single-count group', () => {
|
||||
const p = {
|
||||
hash: 'abc', count: 1, latest: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aabb', payload_type: 4,
|
||||
route_type: 1, decoded_json: '{}', path_json: '[]',
|
||||
observation_count: 1, observer_count: 1
|
||||
};
|
||||
const result = api.buildGroupRowHtml(p);
|
||||
assert(result.includes('<tr'));
|
||||
assert(result.includes('data-hash="abc"'));
|
||||
// Single count: no expand arrow, no group-header class
|
||||
assert(!result.includes('group-header'));
|
||||
});
|
||||
|
||||
test('buildGroupRowHtml renders multi-count group with expand arrow', () => {
|
||||
const p = {
|
||||
hash: 'xyz', count: 3, latest: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aabbcc', payload_type: 0,
|
||||
route_type: 0, decoded_json: '{}', path_json: '[]',
|
||||
observation_count: 3, observer_count: 2
|
||||
};
|
||||
const result = api.buildGroupRowHtml(p);
|
||||
assert(result.includes('group-header'));
|
||||
assert(result.includes('▶')); // collapsed arrow
|
||||
});
|
||||
|
||||
test('buildGroupRowHtml shows observation count badge', () => {
|
||||
const p = {
|
||||
hash: 'obs', count: 1, latest: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aa', payload_type: 0,
|
||||
route_type: 0, decoded_json: '{}', path_json: '[]',
|
||||
observation_count: 5, observer_count: 1
|
||||
};
|
||||
const result = api.buildGroupRowHtml(p);
|
||||
assert(result.includes('badge-obs'));
|
||||
assert(result.includes('👁'));
|
||||
assert(result.includes('5'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: page registration ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
// registerPage is defined in app.js and stores in its own `pages` closure.
|
||||
// We verify via the navigateTo mechanism or by checking the pages object isn't empty.
|
||||
// Since we can't easily access the closure, just verify the test API is exposed.
|
||||
test('_packetsTestAPI is exposed on window', () => {
|
||||
assert(ctx._packetsTestAPI);
|
||||
assert(typeof ctx._packetsTestAPI.typeName === 'function');
|
||||
assert(typeof ctx._packetsTestAPI.getDetailPreview === 'function');
|
||||
assert(typeof ctx._packetsTestAPI.sortGroupChildren === 'function');
|
||||
assert(typeof ctx._packetsTestAPI.buildFieldTable === 'function');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
console.log(`\n${'='.repeat(40)}`);
|
||||
console.log(`packets.js tests: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Show Neighbors E2E tests (#484 fix)
|
||||
* Tests that selectReferenceNode() uses the affinity API instead of client-side path walking.
|
||||
* Usage: CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:13590 node test-show-neighbors.js
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
||||
const results = [];
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
results.push({ name, pass: true });
|
||||
console.log(` ✅ ${name}`);
|
||||
} catch (err) {
|
||||
results.push({ name, pass: false, error: err.message });
|
||||
console.log(` ❌ ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, msg) {
|
||||
if (!condition) throw new Error(msg || 'Assertion failed');
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('Launching Chromium...');
|
||||
const launchOpts = { headless: true, args: ['--no-sandbox', '--disable-gpu'] };
|
||||
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
const page = await browser.newPage();
|
||||
|
||||
console.log(`\nRunning Show Neighbors tests against ${BASE}\n`);
|
||||
|
||||
await test('Show Neighbors calls affinity API and populates neighborPubkeys', async () => {
|
||||
const testPubkey = 'aabbccdd11223344556677889900aabbccddeeff00112233445566778899001122';
|
||||
const neighborPubkey1 = '1111111111111111111111111111111111111111111111111111111111111111';
|
||||
const neighborPubkey2 = '2222222222222222222222222222222222222222222222222222222222222222';
|
||||
|
||||
let apiCalled = false;
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
apiCalled = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: testPubkey,
|
||||
neighbors: [
|
||||
{ pubkey: neighborPubkey1, prefix: '11', name: 'Neighbor-1', role: 'repeater', count: 50, score: 0.9, ambiguous: false },
|
||||
{ pubkey: neighborPubkey2, prefix: '22', name: 'Neighbor-2', role: 'companion', count: 20, score: 0.7, ambiguous: false }
|
||||
],
|
||||
total_observations: 70
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const result = await page.evaluate(async (args) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no _mapSelectRefNode function' };
|
||||
if (typeof window._mapGetNeighborPubkeys !== 'function') return { error: 'no _mapGetNeighborPubkeys function' };
|
||||
await window._mapSelectRefNode(args.pk, 'TestNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, { pk: testPubkey });
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(apiCalled, 'The /neighbors API should have been called');
|
||||
assert(result.neighbors.includes(neighborPubkey1), `Should contain neighbor1, got: ${JSON.stringify(result.neighbors)}`);
|
||||
assert(result.neighbors.includes(neighborPubkey2), `Should contain neighbor2, got: ${JSON.stringify(result.neighbors)}`);
|
||||
assert(result.neighbors.length === 2, `Should have exactly 2 neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors resolves correct node on hash collision via affinity API', async () => {
|
||||
const nodeA = 'c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4';
|
||||
const nodeB = 'c0f1a2b3000000000000000000000000000000000000000000000000000000ff';
|
||||
const neighborR1 = 'r1aaaaaa000000000000000000000000000000000000000000000000000000aa';
|
||||
const neighborR2 = 'r2bbbbbb000000000000000000000000000000000000000000000000000000bb';
|
||||
const neighborR4 = 'r4dddddd000000000000000000000000000000000000000000000000000000dd';
|
||||
|
||||
await page.route(`**/api/nodes/${nodeA}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeA,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR1, prefix: 'R1', name: 'Repeater-R1', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
|
||||
{ pubkey: neighborR2, prefix: 'R2', name: 'Repeater-R2', role: 'repeater', count: 80, score: 0.85, ambiguous: false }
|
||||
],
|
||||
total_observations: 180
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${nodeB}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: nodeB,
|
||||
neighbors: [
|
||||
{ pubkey: neighborR4, prefix: 'R4', name: 'Repeater-R4', role: 'repeater', count: 60, score: 0.75, ambiguous: false }
|
||||
],
|
||||
total_observations: 60
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Select Node A — should get R1, R2 but NOT R4
|
||||
const resultA = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeA');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeA);
|
||||
assert(resultA.includes(neighborR1), 'Node A should have R1 as neighbor');
|
||||
assert(resultA.includes(neighborR2), 'Node A should have R2 as neighbor');
|
||||
assert(!resultA.includes(neighborR4), 'Node A should NOT have R4 (that belongs to Node B)');
|
||||
|
||||
// Select Node B — should get R4 but NOT R1, R2
|
||||
const resultB = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'NodeB');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, nodeB);
|
||||
assert(resultB.includes(neighborR4), 'Node B should have R4 as neighbor');
|
||||
assert(!resultB.includes(neighborR1), 'Node B should NOT have R1 (that belongs to Node A)');
|
||||
assert(!resultB.includes(neighborR2), 'Node B should NOT have R2 (that belongs to Node A)');
|
||||
|
||||
await page.unroute(`**/api/nodes/${nodeA}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${nodeB}/neighbors*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors falls back to path walking when affinity API returns empty', async () => {
|
||||
const testPubkey = 'fallbacktest0000000000000000000000000000000000000000000000000000';
|
||||
const hopBefore = 'aaaa000000000000000000000000000000000000000000000000000000000000';
|
||||
const hopAfter = 'bbbb000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
let neighborApiCalled = false;
|
||||
let pathsApiCalled = false;
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
neighborApiCalled = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ node: testPubkey, neighbors: [], total_observations: 0 })
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/paths*`, route => {
|
||||
pathsApiCalled = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
paths: [{
|
||||
hops: [
|
||||
{ pubkey: hopBefore, name: 'HopBefore' },
|
||||
{ pubkey: testPubkey, name: 'Self' },
|
||||
{ pubkey: hopAfter, name: 'HopAfter' }
|
||||
]
|
||||
}]
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const result = await page.evaluate(async (pk) => {
|
||||
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no-function' };
|
||||
await window._mapSelectRefNode(pk, 'FallbackNode');
|
||||
return { neighbors: window._mapGetNeighborPubkeys() };
|
||||
}, testPubkey);
|
||||
|
||||
assert(!result.error, result.error || '');
|
||||
assert(neighborApiCalled, 'Should try neighbor API first');
|
||||
assert(pathsApiCalled, 'Should fall back to paths API when neighbors empty');
|
||||
assert(result.neighbors.includes(hopBefore), 'Fallback should find hopBefore as neighbor');
|
||||
assert(result.neighbors.includes(hopAfter), 'Fallback should find hopAfter as neighbor');
|
||||
assert(result.neighbors.length === 2, `Fallback should find exactly 2 neighbors, got ${result.neighbors.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/paths*`);
|
||||
});
|
||||
|
||||
await test('Show Neighbors includes ambiguous candidates in neighborPubkeys', async () => {
|
||||
const testPubkey = 'ambigtest000000000000000000000000000000000000000000000000000000';
|
||||
const candidate1 = 'a3b4c500000000000000000000000000000000000000000000000000000000';
|
||||
const candidate2 = 'a3f0e100000000000000000000000000000000000000000000000000000000';
|
||||
const knownNeighbor = 'b7e8f9a000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
node: testPubkey,
|
||||
neighbors: [
|
||||
{ pubkey: knownNeighbor, prefix: 'B7', name: 'Known-Neighbor', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
|
||||
{ pubkey: null, prefix: 'A3', name: null, role: null, count: 12, score: 0.08, ambiguous: true,
|
||||
candidates: [
|
||||
{ pubkey: candidate1, name: 'Node-Alpha', role: 'companion' },
|
||||
{ pubkey: candidate2, name: 'Node-Beta', role: 'companion' }
|
||||
]
|
||||
}
|
||||
],
|
||||
total_observations: 112
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const result = await page.evaluate(async (pk) => {
|
||||
await window._mapSelectRefNode(pk, 'AmbigNode');
|
||||
return window._mapGetNeighborPubkeys();
|
||||
}, testPubkey);
|
||||
|
||||
// Should include the known neighbor AND both ambiguous candidates
|
||||
assert(result.includes(knownNeighbor), 'Should include known neighbor');
|
||||
assert(result.includes(candidate1), 'Should include ambiguous candidate 1');
|
||||
assert(result.includes(candidate2), 'Should include ambiguous candidate 2');
|
||||
assert(result.length === 3, `Should have 3 neighbors (1 known + 2 candidates), got ${result.length}`);
|
||||
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
const passed = results.filter(r => r.pass).length;
|
||||
const failed = results.filter(r => !r.pass).length;
|
||||
console.log(`\n${passed}/${results.length} tests passed${failed ? `, ${failed} failed` : ''}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user