Compare commits

..

1 Commits

Author SHA1 Message Date
efiten 8c82172feb fix: null-guard animLayer and liveAnimCount in nextHop after destroy
Async timers (setInterval/setTimeout) started by animateHop() can fire
after destroy() has nulled animLayer and removed DOM elements. This
caused three console errors on the Live page when navigating away mid-
animation. Guards added at each async callback site.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 01:22:08 +00:00
24 changed files with 172 additions and 1194 deletions
-6
View File
@@ -246,12 +246,6 @@ 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
+8 -4
View File
@@ -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 __BUST__ placeholder (auto-replaced at server startup)
index.html — SPA shell, script/style tags with cache busters
test-fixtures/ — Real data SQLite fixture from staging (used for E2E tests)
scripts/ — Tooling (coverage collector, fixture capture, frontend instrumentation)
```
@@ -84,8 +84,12 @@ Every change that touches logic MUST have tests. For Go backend: `cd cmd/server
### 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 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.
### 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.
### 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.
@@ -347,7 +351,7 @@ One logical change per commit. Each commit is deployable. Each commit has its te
| Pitfall | Times it happened | Prevention |
|---------|-------------------|------------|
| Forgot cache busters | 7 | Now automatic — `__BUST__` replaced at server startup |
| Forgot cache busters | 7 | Always bump in same commit |
| 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 |
+3 -12
View File
@@ -36,9 +36,8 @@ type Store struct {
stmtUpsertNode *sql.Stmt
stmtIncrementAdvertCount *sql.Stmt
stmtUpsertObserver *sql.Stmt
stmtGetObserverRowid *sql.Stmt
stmtUpdateObserverLastSeen *sql.Stmt
stmtUpdateNodeTelemetry *sql.Stmt
stmtGetObserverRowid *sql.Stmt
stmtUpdateNodeTelemetry *sql.Stmt
}
// OpenStore opens or creates a SQLite DB at the given path, applying the
@@ -370,11 +369,6 @@ 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),
@@ -434,16 +428,13 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
s.Stats.DuplicateTransmissions.Add(1)
}
// Resolve observer_idx and update last_seen
// Resolve observer_idx
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)
}
}
-50
View File
@@ -516,56 +516,6 @@ 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 {
-105
View File
@@ -6,7 +6,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -327,84 +326,6 @@ 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) {
@@ -424,29 +345,3 @@ 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)
}
}
+2 -26
View File
@@ -11,9 +11,9 @@ import (
"os"
"os/exec"
"os/signal"
"sync"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -242,35 +242,11 @@ 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) {
// 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)
http.ServeFile(w, r, filepath.Join(root, "index.html"))
return
}
// Disable caching for JS/CSS/HTML
-95
View File
@@ -1,95 +0,0 @@
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")
}
}
+31 -63
View File
@@ -42,7 +42,6 @@ type Server struct {
// PerfStats tracks request performance.
type PerfStats struct {
mu sync.Mutex
Requests int64
TotalMs float64
Endpoints map[string]*EndpointPerf
@@ -163,7 +162,10 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
ms := float64(time.Since(start).Microseconds()) / 1000.0
// Normalize key outside lock (no shared state needed)
s.perfStats.Requests++
s.perfStats.TotalMs += ms
// Normalize key: prefer mux route template (like Node.js req.route.path)
key := r.URL.Path
if route := mux.CurrentRoute(r); route != nil {
if tmpl, err := route.GetPathTemplate(); err == nil {
@@ -173,11 +175,6 @@ 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)}
}
@@ -203,7 +200,6 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
s.perfStats.SlowQueries = s.perfStats.SlowQueries[1:]
}
}
s.perfStats.mu.Unlock()
})
}
@@ -369,8 +365,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
lastPauseMs = float64(m.PauseNs[(m.NumGC+255)%256]) / 1e6
}
// Build slow queries list (copy under lock)
s.perfStats.mu.Lock()
// Build slow queries list
recentSlow := make([]SlowQuery, 0)
sliceEnd := s.perfStats.SlowQueries
if len(sliceEnd) > 5 {
@@ -379,10 +374,6 @@ 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",
@@ -412,9 +403,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
EstimatedMB: pktEstMB,
},
Perf: HealthPerfStats{
TotalRequests: int(perfRequests),
AvgMs: safeAvg(perfTotalMs, float64(perfRequests)),
SlowQueries: perfSlowCount,
TotalRequests: int(s.perfStats.Requests),
AvgMs: safeAvg(s.perfStats.TotalMs, float64(s.perfStats.Requests)),
SlowQueries: len(s.perfStats.SlowQueries),
RecentSlow: recentSlow,
},
})
@@ -474,50 +465,22 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handlePerf(w http.ResponseWriter, r *http.Request) {
// 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
// Endpoint performance summary
type epEntry struct {
path string
data *EndpointStatsResp
}
var entries []epEntry
for _, snap := range epSnapshots {
sorted := sortedCopy(snap.recent)
for path, ep := range s.perfStats.Endpoints {
sorted := sortedCopy(ep.Recent)
d := &EndpointStatsResp{
Count: snap.count,
AvgMs: safeAvg(snap.totalMs, float64(snap.count)),
Count: ep.Count,
AvgMs: safeAvg(ep.TotalMs, float64(ep.Count)),
P50Ms: round(percentile(sorted, 0.5), 1),
P95Ms: round(percentile(sorted, 0.95), 1),
MaxMs: round(snap.maxMs, 1),
MaxMs: round(ep.MaxMs, 1),
}
entries = append(entries, epEntry{snap.path, d})
entries = append(entries, epEntry{path, d})
}
// Sort by total time spent (count * avg) descending, matching Node.js
sort.Slice(entries, func(i, j int) bool {
@@ -558,10 +521,22 @@ 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: totalRequests,
AvgMs: safeAvg(totalMs, float64(totalRequests)),
TotalRequests: s.perfStats.Requests,
AvgMs: safeAvg(s.perfStats.TotalMs, float64(s.perfStats.Requests)),
Endpoints: summary,
SlowQueries: slowQueries,
Cache: perfCS,
@@ -585,13 +560,7 @@ func (s *Server) handlePerf(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handlePerfReset(w http.ResponseWriter, r *http.Request) {
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()
s.perfStats = NewPerfStats()
writeJSON(w, OkResp{Ok: true})
}
@@ -1235,8 +1204,7 @@ 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))
writeJSON(w, s.store.GetAnalyticsHashCollisions())
return
}
writeJSON(w, map[string]interface{}{
+6 -6
View File
@@ -2680,9 +2680,9 @@ func TestHashCollisionsNoNullArrays(t *testing.T) {
}
}
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.
func TestHashCollisionsRegionParamIgnored(t *testing.T) {
// Issue #417: region param was accepted but ignored.
// After fix, the endpoint should work without region and not cache per-region.
_, router := setupTestServer(t)
// Request without region
@@ -2693,7 +2693,7 @@ func TestHashCollisionsRegionParam(t *testing.T) {
t.Fatalf("expected 200, got %d", w1.Code)
}
// Request with region param (no observers for this region, so falls back to global)
// Request with region param (should be ignored, same result)
req2 := httptest.NewRequest("GET", "/api/analytics/hash-collisions?region=us-west", nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
@@ -2701,9 +2701,9 @@ func TestHashCollisionsRegionParam(t *testing.T) {
t.Fatalf("expected 200, got %d", w2.Code)
}
// With no region observers configured, both should return identical results
// 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")
t.Error("responses differ with/without region param region should be ignored")
}
}
+9 -59
View File
@@ -80,7 +80,7 @@ type PacketStore struct {
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
collisionCache map[string]*cachedResult // cached hash-collisions result keyed by region ("" = global)
collisionCache *cachedResult // cached hash-collisions result (no region filtering)
chanCache map[string]*cachedResult // region → cached channels result
distCache map[string]*cachedResult // region → cached distance result
subpathCache map[string]*cachedResult // params → cached subpaths result
@@ -176,7 +176,6 @@ func NewPacketStore(db *DB, cfg *PacketStoreConfig) *PacketStore {
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),
@@ -697,7 +696,7 @@ func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
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.collisionCache = nil
s.chanCache = make(map[string]*cachedResult)
s.distCache = make(map[string]*cachedResult)
s.subpathCache = make(map[string]*cachedResult)
@@ -717,7 +716,7 @@ func (s *PacketStore) invalidateCachesFor(inv cacheInvalidation) {
}
if inv.hasNewTransmissions {
s.hashCache = make(map[string]*cachedResult)
s.collisionCache = make(map[string]*cachedResult)
s.collisionCache = nil
}
if inv.hasChannelData {
s.chanCache = make(map[string]*cachedResult)
@@ -4182,20 +4181,20 @@ type hashSizeNodeInfo struct {
// 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{} {
func (s *PacketStore) GetAnalyticsHashCollisions() map[string]interface{} {
s.cacheMu.Lock()
if cached, ok := s.collisionCache[region]; ok && time.Now().Before(cached.expiresAt) {
if s.collisionCache != nil && time.Now().Before(s.collisionCache.expiresAt) {
s.cacheHits++
s.cacheMu.Unlock()
return cached.data
return s.collisionCache.data
}
s.cacheMisses++
s.cacheMu.Unlock()
result := s.computeHashCollisions(region)
result := s.computeHashCollisions()
s.cacheMu.Lock()
s.collisionCache[region] = &cachedResult{data: result, expiresAt: time.Now().Add(s.collisionCacheTTL)}
s.collisionCache = &cachedResult{data: result, expiresAt: time.Now().Add(s.collisionCacheTTL)}
s.cacheMu.Unlock()
return result
@@ -4237,60 +4236,11 @@ type twoByteCellInfo struct {
CollisionCount int `json:"collision_count"`
}
func (s *PacketStore) computeHashCollisions(region string) map[string]interface{} {
func (s *PacketStore) computeHashCollisions() 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 {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "meshcore-analyzer",
"version": "3.3.0",
"version": "3.2.0",
"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": {
+4 -4
View File
@@ -148,7 +148,7 @@
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 }),
api('/analytics/hash-collisions', { ttl: CLIENT_TTL.analyticsRF }),
]);
_analyticsData = { hashData, rfData, topoData, chanData, collisionData };
renderTab(_currentTab);
@@ -1488,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 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)); })();
const dLat = (a.lat - b.lat) * 111;
const dLon = (a.lon - b.lon) * 85;
const km = Math.sqrt(dLat*dLat + dLon*dLon);
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>`);
-1
View File
@@ -807,7 +807,6 @@ window.addEventListener('DOMContentLoaded', () => {
// User's localStorage preferences take priority over server config
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
window._SITE_CONFIG_ORIGINAL_HOME = JSON.parse(JSON.stringify(window.SITE_CONFIG.home || {}));
mergeUserHomeConfig(window.SITE_CONFIG, userTheme);
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
+2 -5
View File
@@ -274,9 +274,6 @@
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' ||
@@ -662,7 +659,7 @@
});
el.innerHTML = sorted.map(ch => {
const name = ch.name || `Channel ${formatHashHex(ch.hash)}`;
const name = ch.name || `Channel ${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
@@ -691,7 +688,7 @@
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
renderChannelList();
const ch = channels.find(c => c.hash === hash);
const name = ch?.name || `Channel ${formatHashHex(hash)}`;
const name = ch?.name || `Channel ${hash}`;
const header = document.getElementById('chHeader');
header.querySelector('.ch-header-text').textContent = `${name}${ch?.messageCount || 0} messages`;
+8 -9
View File
@@ -450,8 +450,7 @@
function mergeSection(key) {
return Object.assign({}, DEFAULTS[key], cfg[key] || {}, local[key] || {});
}
var serverHome = window._SITE_CONFIG_ORIGINAL_HOME || cfg.home || {};
var mergedHome = Object.assign({}, DEFAULTS.home, serverHome, local.home || {});
var mergedHome = mergeSection('home');
var localTsMode = localStorage.getItem('meshcore-timestamp-mode');
var localTsTimezone = localStorage.getItem('meshcore-timestamp-timezone');
var localTsFormat = localStorage.getItem('meshcore-timestamp-format');
@@ -1203,19 +1202,19 @@
var tmp = state.home.steps[i];
state.home.steps[i] = state.home.steps[j];
state.home.steps[j] = tmp;
render(container); autoSave();
render(container);
});
});
container.querySelectorAll('[data-rm-step]').forEach(function (btn) {
btn.addEventListener('click', function () {
state.home.steps.splice(parseInt(btn.dataset.rmStep), 1);
render(container); autoSave();
render(container);
});
});
var addStepBtn = document.getElementById('addStep');
if (addStepBtn) addStepBtn.addEventListener('click', function () {
state.home.steps.push({ emoji: '📌', title: '', description: '' });
render(container); autoSave();
render(container);
});
// Checklist
@@ -1228,13 +1227,13 @@
container.querySelectorAll('[data-rm-check]').forEach(function (btn) {
btn.addEventListener('click', function () {
state.home.checklist.splice(parseInt(btn.dataset.rmCheck), 1);
render(container); autoSave();
render(container);
});
});
var addCheckBtn = document.getElementById('addCheck');
if (addCheckBtn) addCheckBtn.addEventListener('click', function () {
state.home.checklist.push({ question: '', answer: '' });
render(container); autoSave();
render(container);
});
// Footer links
@@ -1247,13 +1246,13 @@
container.querySelectorAll('[data-rm-link]').forEach(function (btn) {
btn.addEventListener('click', function () {
state.home.footerLinks.splice(parseInt(btn.dataset.rmLink), 1);
render(container); autoSave();
render(container);
});
});
var addLinkBtn = document.getElementById('addLink');
if (addLinkBtn) addLinkBtn.addEventListener('click', function () {
state.home.footerLinks.push({ label: '', url: '' });
render(container); autoSave();
render(container);
});
// Export copy
+1 -1
View File
@@ -203,5 +203,5 @@ window.HopResolver = (function() {
return nodesList.length > 0;
}
return { init: init, resolve: resolve, ready: ready, haversineKm: haversineKm };
return { init: init, resolve: resolve, ready: ready };
})();
+28 -28
View File
@@ -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=__BUST__">
<link rel="stylesheet" href="home.css?v=__BUST__">
<link rel="stylesheet" href="live.css?v=__BUST__">
<link rel="stylesheet" href="style.css?v=1775078686">
<link rel="stylesheet" href="home.css?v=1775078686">
<link rel="stylesheet" href="live.css?v=1775078686">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -85,30 +85,30 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=__BUST__"></script>
<script src="customize.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="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>
<script src="roles.js?v=1775078686"></script>
<script src="customize.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775078686"></script>
<script src="hop-resolver.js?v=1775078686"></script>
<script src="hop-display.js?v=1775078686"></script>
<script src="app.js?v=1775078686"></script>
<script src="home.js?v=1775078686"></script>
<script src="packet-filter.js?v=1775078686"></script>
<script src="packets.js?v=1775078686"></script>
<script src="geo-filter-overlay.js?v=1775078686"></script>
<script src="map.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+58 -102
View File
@@ -10,7 +10,6 @@
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';
@@ -369,17 +368,12 @@
}
}
function vcrFormatTime(tsMs) {
const d = new Date(tsMs);
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());
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());
}
function updateVCRLcd() {
@@ -1066,7 +1060,8 @@
const rect = timelineEl.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
const ts = Date.now() - VCR.timelineScope + pct * VCR.timelineScope;
timeTooltip.textContent = vcrFormatTime(ts);
const d = new Date(ts);
timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
timeTooltip.style.left = (e.clientX - rect.left) + 'px';
timeTooltip.classList.remove('hidden');
});
@@ -1079,7 +1074,8 @@
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;
timeTooltip.textContent = vcrFormatTime(ts);
const d = new Date(ts);
timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
timeTooltip.style.left = (touch.clientX - rect.left) + 'px';
timeTooltip.classList.remove('hidden');
});
@@ -1585,7 +1581,6 @@
window._livePruneStaleNodes = pruneStaleNodes;
window._liveNodeMarkers = function() { return nodeMarkers; };
window._liveNodeData = function() { return nodeData; };
window._vcrFormatTime = vcrFormatTime;
async function replayRecent() {
try {
@@ -1848,7 +1843,6 @@
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;
@@ -1872,22 +1866,12 @@
radius: 3, fillColor: '#94a3b8', fillOpacity: 0.35, color: '#94a3b8', weight: 1, opacity: 0.5
}).addTo(animLayer);
let pulseUp = true;
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);
const pulseTimer = setInterval(() => {
if (!animLayer || !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 && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost); }, 3000);
}
} else {
pulseNode(hp.key, hp.pos, typeName);
@@ -1931,30 +1915,20 @@
}).addTo(animLayer);
let r = 2, op = 0.9;
let lastPulse = performance.now();
const pulseStart = lastPulse;
function animatePulse(now) {
if (now - pulseStart > 2000) {
const iv = setInterval(() => {
r += 1.5; op -= 0.03;
if (op <= 0) {
clearInterval(iv);
try { animLayer.removeLayer(ring); } catch {}
return;
}
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);
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 baseColor = marker._baseColor || '#6b7280';
const baseSize = marker._baseSize || 6;
@@ -2267,61 +2241,43 @@
radius: 3.5, fillColor: '#fff', fillOpacity: 1, color: color, weight: 1.5
}).addTo(animLayer);
let lastStep = performance.now();
function animateLine(now) {
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 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); }
}
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) {
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);
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 });
}
requestAnimationFrame(animateFade);
}, 800);
}, 52);
}, 800);
if (onComplete) onComplete();
return;
}
if (onComplete) onComplete();
}
requestAnimationFrame(animateLine);
}
requestAnimationFrame(animateLine);
}, 33);
}
function showHeatMap() {
+2 -66
View File
@@ -10,8 +10,6 @@
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;
@@ -110,8 +108,6 @@
<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>
@@ -211,19 +207,7 @@
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;
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();
});
document.getElementById('mcNeighbors').addEventListener('change', e => { filters.neighbors = e.target.checked; renderMarkers(); });
// Hash Labels toggle
const hashLabelEl = document.getElementById('mcHashLabels');
@@ -662,11 +646,6 @@
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;
});
@@ -745,43 +724,6 @@
</div>`;
}
async function selectReferenceNode(pubkey, name) {
selectedReferenceNode = pubkey;
neighborPubkeys = new Set();
try {
const data = await api('/nodes/' + pubkey + '/paths');
const paths = data.paths || [];
for (const p of paths) {
const hops = p.hops || [];
// Find the reference node in the path; direct neighbors are adjacent hops
for (let 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);
}
}
// (Redundant block removed — the main loop above already handles first/last hops)
}
} catch (e) {
console.warn('Failed to fetch neighbor paths for', pubkey, '— neighbor filter may be incomplete:', 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
window._mapSelectRefNode = selectReferenceNode;
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)}` : '—';
@@ -807,10 +749,7 @@
<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>
${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 style="margin-top:8px;clear:both;"><a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node </a></div>
</div>`;
}
@@ -836,9 +775,6 @@
routeLayer = null;
if (heatLayer) { heatLayer = null; }
geoFilterLayer = null;
selectedReferenceNode = null;
neighborPubkeys = null;
delete window._mapSelectRefNode;
}
function toggleHeatmap(on) {
+2 -32
View File
@@ -228,39 +228,11 @@
loadNodes();
// Auto-refresh when ADVERT packets arrive via WebSocket (fixes #131)
wsHandler = debouncedOnWS(function (msgs) {
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) {
if (msgs.some(isAdvertMessage)) {
_allNodes = null;
invalidateApiCache('/nodes');
loadNodes(true);
}
loadNodes(true);
}, 5000);
}
@@ -957,6 +929,4 @@
// Test hooks
window._nodesIsAdvertMessage = isAdvertMessage;
window._nodesGetAllNodes = function() { return _allNodes; };
window._nodesSetAllNodes = function(n) { _allNodes = n; };
})();
+7 -9
View File
@@ -8,7 +8,7 @@
// Resolve observer_id to friendly name from loaded observers list
function obsName(id) {
if (!id) return '—';
const o = observerMap.get(id);
const o = observers.find(ob => ob.id === id);
if (!o) return id;
return o.iata ? `${o.name} (${o.iata})` : o.name;
}
@@ -21,7 +21,6 @@
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}`; }
@@ -350,7 +349,7 @@
if (filters.hash && p.hash !== filters.hash) return false;
if (RegionFilter.getRegionParam()) {
const selectedRegions = RegionFilter.getRegionParam().split(',');
const obs = observerMap.get(p.observer_id);
const obs = observers.find(o => o.id === p.observer_id);
if (!obs || !selectedRegions.includes(obs.iata)) return false;
}
if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false;
@@ -440,7 +439,6 @@
hopNameCache = {};
totalCount = 0;
observers = [];
observerMap = new Map();
directPacketId = null;
directPacketHash = null;
groupByHash = true;
@@ -452,7 +450,6 @@
try {
const data = await api('/observers', { ttl: CLIENT_TTL.observers });
observers = data.observers || [];
observerMap = new Map(observers.map(o => [o.id, o]));
} catch {}
}
@@ -699,7 +696,7 @@
obsTrigger.textContent = 'All Observers ▾';
} else if (selectedObservers.size === 1) {
const id = [...selectedObservers][0];
const o = observerMap.get(id) || observerMap.get(Number(id));
const o = observers.find(x => String(x.id) === id);
obsTrigger.textContent = (o ? (o.name || o.id) : id) + ' ▾';
} else {
obsTrigger.textContent = selectedObservers.size + ' Observers ▾';
@@ -1026,7 +1023,7 @@
headerPathJson = match.path_json;
}
}
const groupRegion = headerObserverId ? (observerMap.get(headerObserverId)?.iata || '') : '';
const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : '';
let groupPath = [];
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
const groupPathStr = renderPath(groupPath, headerObserverId);
@@ -1058,7 +1055,7 @@
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 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);
@@ -1084,7 +1081,7 @@
let decoded, pathHops = [];
try { decoded = JSON.parse(p.decoded_json || '{}'); } catch {}
try { pathHops = JSON.parse(p.path_json || '[]') || []; } catch {}
const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : '';
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;
@@ -1134,6 +1131,7 @@
}
_cumulativeOffsetsCache = offsets;
return offsets;
return offsets;
}
function renderVisibleRows() {
-123
View File
@@ -1,123 +0,0 @@
/**
* 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);
-309
View File
@@ -564,40 +564,6 @@ console.log('\n=== hop-resolver.js ===');
});
}
// ===== haversineKm exposed from HopResolver (issue #433) =====
console.log('\n=== haversineKm (hop-resolver.js) ===');
{
const ctx = makeSandbox();
ctx.IATA_COORDS_GEO = {};
loadInCtx(ctx, 'public/hop-resolver.js');
const HR = ctx.window.HopResolver;
test('haversineKm is exported', () => {
assert.strictEqual(typeof HR.haversineKm, 'function');
});
test('haversineKm same point = 0', () => {
assert.strictEqual(HR.haversineKm(37.0, -122.0, 37.0, -122.0), 0);
});
test('haversineKm SF to LA ~559km', () => {
// San Francisco (37.7749, -122.4194) to Los Angeles (34.0522, -118.2437)
const d = HR.haversineKm(37.7749, -122.4194, 34.0522, -118.2437);
assert.ok(d > 550 && d < 570, `Expected ~559km, got ${d}`);
});
test('haversineKm differs from old Euclidean approximation', () => {
// The old code used dLat*111, dLon*85 which is inaccurate at high latitudes
// Oslo (59.9, 10.7) to Stockholm (59.3, 18.0)
const haversine = HR.haversineKm(59.9, 10.7, 59.3, 18.0);
const dLat = (59.9 - 59.3) * 111;
const dLon = (10.7 - 18.0) * 85;
const euclidean = Math.sqrt(dLat*dLat + dLon*dLon);
// Haversine should give ~415km, Euclidean ~627km (wrong because dLon*85 is wrong at 60° latitude)
assert.ok(Math.abs(haversine - euclidean) > 50, `Expected significant difference, haversine=${haversine.toFixed(1)}, euclidean=${euclidean.toFixed(1)}`);
});
}
// ===== SNR/RSSI Number casting =====
{
// These test the pattern used in observer-detail.js, home.js, traces.js, live.js
@@ -1000,66 +966,6 @@ console.log('\n=== live.js: pruneStaleNodes ===');
});
}
// ===== live.js: vcrFormatTime respects UTC/local setting =====
console.log('\n=== live.js: vcrFormatTime UTC/local ===');
{
function makeLiveSandboxForVcr() {
const ctx = makeSandbox();
ctx.L = { map: () => ({ on: () => {}, setView: () => {}, addLayer: () => {}, remove: () => {} }), tileLayer: () => ({ addTo: () => {} }), layerGroup: () => ({ addTo: () => {}, clearLayers: () => {}, addLayer: () => {} }), circleMarker: () => ({ addTo: () => {}, remove: () => {}, setStyle: () => {}, getLatLng: () => ({}), on: () => {} }), Polyline: function() { return { addTo: () => {}, remove: () => {} }; }, Control: { extend: () => function() { return { addTo: () => {} }; } } };
ctx.Chart = function() { return { destroy: () => {}, update: () => {} }; };
ctx.navigator = {};
ctx.visualViewport = null;
ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} };
ctx.document.body = { appendChild: () => {}, removeChild: () => {}, contains: () => false };
ctx.document.querySelector = () => null;
ctx.document.querySelectorAll = () => [];
ctx.document.createElementNS = () => ctx.document.createElement();
ctx.cancelAnimationFrame = () => {};
ctx.IATA_COORDS_GEO = {};
loadInCtx(ctx, 'public/roles.js');
try { loadInCtx(ctx, 'public/live.js'); } catch (e) {
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
return ctx;
}
test('vcrFormatTime is exposed as window._vcrFormatTime', () => {
const ctx = makeLiveSandboxForVcr();
assert.strictEqual(typeof ctx.window._vcrFormatTime, 'function', '_vcrFormatTime must be exposed');
});
test('vcrFormatTime uses UTC hours when timezone is utc', () => {
const ctx = makeLiveSandboxForVcr();
const fn = ctx.window._vcrFormatTime;
assert.ok(fn, '_vcrFormatTime must be exposed');
// Force UTC mode
ctx.getTimestampTimezone = () => 'utc';
// Use a known timestamp: 2024-01-15 14:30:45 UTC = different local time in most zones
const tsMs = Date.UTC(2024, 0, 15, 14, 30, 45);
const result = fn(tsMs);
assert.strictEqual(result, '14:30:45', 'UTC mode must show UTC hours 14:30:45');
});
test('vcrFormatTime uses local hours when timezone is local', () => {
const ctx = makeLiveSandboxForVcr();
const fn = ctx.window._vcrFormatTime;
assert.ok(fn, '_vcrFormatTime must be exposed');
ctx.getTimestampTimezone = () => 'local';
const d = new Date(2024, 0, 15, 9, 5, 3); // local time
const expected = String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0') + ':' + String(d.getSeconds()).padStart(2,'0');
assert.strictEqual(fn(d.getTime()), expected, 'local mode must use local hours');
});
test('vcrFormatTime zero-pads single-digit hours, minutes, seconds', () => {
const ctx = makeLiveSandboxForVcr();
const fn = ctx.window._vcrFormatTime;
assert.ok(fn, '_vcrFormatTime must be exposed');
ctx.getTimestampTimezone = () => 'utc';
const tsMs = Date.UTC(2024, 0, 15, 3, 5, 7); // 03:05:07 UTC
assert.strictEqual(fn(tsMs), '03:05:07');
});
}
// ===== NODES.JS: isAdvertMessage + auto-update logic =====
console.log('\n=== nodes.js: isAdvertMessage ===');
{
@@ -1275,61 +1181,6 @@ console.log('\n=== nodes.js: WS handler runtime behavior ===');
assert.ok(env.getApiCalls() > 0, 'api called because _allNodes was reset to null');
});
test('ADVERT for known node upserts in-place without API fetch', () => {
const env = makeNodesWsSandbox();
// Pre-populate _allNodes with a known node
assert.ok(typeof env.ctx.window._nodesSetAllNodes === 'function', '_nodesSetAllNodes must be exposed');
env.ctx.window._nodesSetAllNodes([
{ public_key: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', name: 'OldName', role: 'repeater', lat: null, lon: null, last_seen: '2024-01-01T00:00:00Z' }
]);
env.resetCounters();
env.sendWS({
type: 'packet',
data: {
packet: { payload_type: 4, timestamp: '2024-06-01T12:00:00Z' },
decoded: {
header: { payloadTypeName: 'ADVERT' },
payload: { type: 'ADVERT', pubKey: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', name: 'NewName', lat: 50.85, lon: 4.35 }
}
}
});
env.fireTimers();
assert.strictEqual(env.getApiCalls(), 0, 'known node upsert must NOT trigger API fetch');
assert.strictEqual(env.getInvalidated().length, 0, 'no cache invalidation for known node upsert');
const nodes = env.ctx.window._nodesGetAllNodes();
assert.ok(nodes, '_nodesGetAllNodes must be exposed');
assert.strictEqual(nodes[0].name, 'NewName', 'name must be updated in place');
assert.strictEqual(nodes[0].lat, 50.85, 'lat must be updated in place');
assert.strictEqual(nodes[0].lon, 4.35, 'lon must be updated in place');
assert.strictEqual(nodes[0].last_seen, '2024-06-01T12:00:00Z', 'last_seen must be updated from packet timestamp');
});
test('ADVERT for unknown node falls back to full reload', () => {
const env = makeNodesWsSandbox();
env.ctx.window._nodesSetAllNodes([
{ public_key: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', name: 'ExistingNode', role: 'repeater' }
]);
env.resetCounters();
// Send ADVERT from a pubKey NOT in _allNodes
env.sendWS({
type: 'packet',
data: {
packet: { payload_type: 4 },
decoded: {
header: { payloadTypeName: 'ADVERT' },
payload: { type: 'ADVERT', pubKey: 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', name: 'BrandNewNode' }
}
}
});
env.fireTimers();
assert.ok(env.getApiCalls() > 0, 'unknown node must trigger full reload');
assert.ok(env.getInvalidated().includes('/nodes'), 'cache must be invalidated for unknown node');
});
test('scroll position and selection preserved during WS-triggered refresh', () => {
const env = makeNodesWsSandbox();
// Simulate scrolled panel state — WS handler should not touch scroll or rebuild panel
@@ -2109,43 +1960,6 @@ console.log('\n=== customize.js: initState merge behavior ===');
assert.strictEqual(state.theme.accent, '#abcdef');
assert.strictEqual(state.theme.navBg, '#fedcba');
});
test('initState uses _SITE_CONFIG_ORIGINAL_HOME to bypass contaminated SITE_CONFIG.home', () => {
// Simulates: app.js called mergeUserHomeConfig which mutated SITE_CONFIG.home.steps = []
// The original server steps must still be recoverable via _SITE_CONFIG_ORIGINAL_HOME
const ctx = makeSandbox();
ctx.setTimeout = function (fn) { fn(); return 1; };
ctx.clearTimeout = function () {};
// SITE_CONFIG.home is contaminated — steps wiped by mergeUserHomeConfig at page load
ctx.window.SITE_CONFIG = {
home: {
heroTitle: 'Server Hero',
steps: [] // contaminated — user had steps:[] in localStorage at page load
}
};
// app.js snapshots original before mutation
ctx.window._SITE_CONFIG_ORIGINAL_HOME = {
heroTitle: 'Server Hero',
steps: [{ emoji: '🧪', title: 'Original Step', description: 'from server' }]
};
const ex = loadCustomizeExports(ctx);
ex.initState();
const state = ex.getState();
assert.strictEqual(state.home.steps.length, 1, 'should restore from snapshot, not contaminated SITE_CONFIG');
assert.strictEqual(state.home.steps[0].title, 'Original Step');
});
test('initState uses DEFAULTS.home when no SITE_CONFIG and no snapshot', () => {
const ctx = makeSandbox();
ctx.setTimeout = function (fn) { fn(); return 1; };
ctx.clearTimeout = function () {};
// No SITE_CONFIG at all — pure DEFAULTS
const ex = loadCustomizeExports(ctx);
ex.initState();
const state = ex.getState();
assert.ok(state.home.steps.length > 0, 'should use DEFAULTS.home.steps when no server config');
assert.strictEqual(state.home.steps[0].title, 'Join the Bay Area MeshCore Discord');
});
}
// ===== APP.JS: home rehydration merge =====
@@ -2910,129 +2724,6 @@ console.log('\n=== live.js: nextHop null guards ===');
});
}
// === channels.js: formatHashHex (#465) ===
console.log('\n=== channels.js: formatHashHex (issue #465) ===');
{
const chSource = fs.readFileSync('public/channels.js', 'utf8');
test('formatHashHex exists in channels.js', () => {
assert.ok(chSource.includes('function formatHashHex('), 'formatHashHex function must exist');
});
test('channel fallback name uses formatHashHex', () => {
assert.ok(chSource.includes('formatHashHex(ch.hash)'), 'renderChannelList must format hash as hex');
assert.ok(chSource.includes('formatHashHex(hash)'), 'selectChannel must format hash as hex');
});
test('formatHashHex produces correct hex output', () => {
// Extract and evaluate the function
const match = chSource.match(/function formatHashHex\(hash\)\s*\{[^}]+\}/);
assert.ok(match, 'should extract formatHashHex');
const ctx = vm.createContext({});
vm.runInContext(match[0], ctx);
const fmt = vm.runInContext('formatHashHex', ctx);
assert.strictEqual(fmt(10), '0x0A');
assert.strictEqual(fmt(255), '0xFF');
assert.strictEqual(fmt(0), '0x00');
assert.strictEqual(fmt(1), '0x01');
assert.strictEqual(fmt('LongFast'), 'LongFast'); // string hash passes through
});
}
// ===== MAP NEIGHBOR FILTER LOGIC =====
{
console.log('\n--- Map neighbor filter logic ---');
// NOTE: applyNeighborFilter is a hand-written copy of the filter logic from
// public/map.js _renderMarkersInner. The real code is browser-only (depends on
// Leaflet, DOM, closure state) and cannot be imported directly in Node.
// If the filter logic in map.js changes, update this copy to match.
function applyNeighborFilter(nodes, filters, selectedReferenceNode, neighborPubkeys) {
return nodes.filter(n => {
if (!n.lat || !n.lon) return false;
if (!filters[n.role || 'companion']) return false;
if (filters.neighbors && selectedReferenceNode && neighborPubkeys) {
const pk = n.public_key;
if (pk !== selectedReferenceNode && !neighborPubkeys.has(pk)) return false;
}
return true;
});
}
const testNodes = [
{ public_key: 'aaa', lat: 1, lon: 1, role: 'repeater', name: 'NodeA' },
{ public_key: 'bbb', lat: 2, lon: 2, role: 'repeater', name: 'NodeB' },
{ public_key: 'ccc', lat: 3, lon: 3, role: 'companion', name: 'NodeC' },
{ public_key: 'ddd', lat: 4, lon: 4, role: 'repeater', name: 'NodeD' },
];
const baseFilters = { repeater: true, companion: true, room: true, sensor: true, neighbors: false };
test('neighbor filter off shows all nodes', () => {
const result = applyNeighborFilter(testNodes, baseFilters, null, null);
assert.strictEqual(result.length, 4);
});
test('neighbor filter on with no reference shows all nodes', () => {
const f = { ...baseFilters, neighbors: true };
const result = applyNeighborFilter(testNodes, f, null, null);
assert.strictEqual(result.length, 4);
});
test('neighbor filter on with reference and neighbors filters correctly', () => {
const f = { ...baseFilters, neighbors: true };
const neighborSet = new Set(['bbb', 'ccc']);
const result = applyNeighborFilter(testNodes, f, 'aaa', neighborSet);
assert.strictEqual(result.length, 3); // aaa (ref) + bbb + ccc (neighbors)
const pks = result.map(n => n.public_key);
assert.ok(pks.includes('aaa'), 'reference node should be included');
assert.ok(pks.includes('bbb'), 'neighbor bbb should be included');
assert.ok(pks.includes('ccc'), 'neighbor ccc should be included');
assert.ok(!pks.includes('ddd'), 'non-neighbor ddd should be excluded');
});
test('neighbor filter on with reference and empty neighbors shows only reference', () => {
const f = { ...baseFilters, neighbors: true };
const neighborSet = new Set();
const result = applyNeighborFilter(testNodes, f, 'aaa', neighborSet);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].public_key, 'aaa');
});
test('neighbor filter respects role filter', () => {
const f = { ...baseFilters, neighbors: true, companion: false };
const neighborSet = new Set(['bbb', 'ccc']);
const result = applyNeighborFilter(testNodes, f, 'aaa', neighborSet);
assert.strictEqual(result.length, 2); // aaa + bbb (ccc is companion, filtered out)
const pks = result.map(n => n.public_key);
assert.ok(!pks.includes('ccc'), 'companion ccc should be filtered by role');
});
// Test path parsing for neighbor extraction
test('neighbor extraction from paths data', () => {
const refPubkey = 'aaa';
const paths = [
{ hops: [{ pubkey: 'bbb' }, { pubkey: 'aaa' }, { pubkey: 'ccc' }] },
{ hops: [{ pubkey: 'aaa' }, { pubkey: 'ddd' }] },
{ hops: [{ pubkey: 'eee' }, { pubkey: 'aaa' }] },
];
const neighborSet = new Set();
for (const p of paths) {
const hops = p.hops || [];
for (let i = 0; i < hops.length; i++) {
if (hops[i].pubkey === refPubkey) {
if (i > 0 && hops[i - 1].pubkey) neighborSet.add(hops[i - 1].pubkey);
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborSet.add(hops[i + 1].pubkey);
}
}
}
assert.ok(neighborSet.has('bbb'), 'bbb is adjacent in path 1');
assert.ok(neighborSet.has('ccc'), 'ccc is adjacent in path 1');
assert.ok(neighborSet.has('ddd'), 'ddd is adjacent in path 2');
assert.ok(neighborSet.has('eee'), 'eee is adjacent in path 3');
assert.strictEqual(neighborSet.size, 4);
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);
-78
View File
@@ -1,78 +0,0 @@
/* 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`);
process.exit(failed > 0 ? 1 : 0);