mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 10:11:38 +00:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3c0da8a94 | |||
| 3778ba9c95 | |||
| 2fc68c4452 | |||
| 2fc5da33d3 | |||
| 5d8c52d2e5 | |||
| 016c820207 | |||
| 93f437f937 | |||
| 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 | |||
| e7232c0d29 | |||
| f7c182c5f7 | |||
| 2d8203ae17 | |||
| 4cdc554b40 | |||
| 81bf3b4b12 | |||
| ce6e8d5237 | |||
| 4898541bce | |||
| 38e5f02a00 | |||
| dc9d6ba8df | |||
| fe314be3a8 |
@@ -129,6 +129,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Free disk space
|
||||
run: |
|
||||
# Prune old runner diagnostic logs (can accumulate 50MB+)
|
||||
find ~/actions-runner/_diag/ -name '*.log' -mtime +3 -delete 2>/dev/null || true
|
||||
# Show available disk space
|
||||
df -h / | tail -1
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -239,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
|
||||
@@ -314,6 +327,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Clean up old Docker images
|
||||
if: always()
|
||||
run: |
|
||||
# Remove dangling images and images older than 24h (keeps current build)
|
||||
echo "--- Docker disk usage before cleanup ---"
|
||||
docker system df
|
||||
docker image prune -af --filter "until=24h" 2>/dev/null || true
|
||||
docker builder prune -f --keep-storage=1GB 2>/dev/null || true
|
||||
echo "--- Docker disk usage after cleanup ---"
|
||||
docker system df
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 5. Publish Badges & Summary (master only)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -9,6 +9,7 @@ ARG BUILD_TIME=unknown
|
||||
# Build server
|
||||
WORKDIR /build/server
|
||||
COPY cmd/server/go.mod cmd/server/go.sum ./
|
||||
COPY internal/geofilter/ ../../internal/geofilter/
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
|
||||
|
||||
@@ -80,7 +80,7 @@ No Go installation needed — everything builds inside the container.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Kpa-clawbot/CoreScope.git
|
||||
cd corescope
|
||||
cd CoreScope
|
||||
./manage.sh setup
|
||||
```
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/meshcore-analyzer/geofilter"
|
||||
)
|
||||
|
||||
// MQTTSource represents a single MQTT broker connection.
|
||||
@@ -34,8 +36,12 @@ type Config struct {
|
||||
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
||||
HashChannels []string `json:"hashChannels,omitempty"`
|
||||
Retention *RetentionConfig `json:"retention,omitempty"`
|
||||
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||
}
|
||||
|
||||
// GeoFilterConfig is an alias for the shared geofilter.Config type.
|
||||
type GeoFilterConfig = geofilter.Config
|
||||
|
||||
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
|
||||
type RetentionConfig struct {
|
||||
NodeDays int `json:"nodeDays"`
|
||||
|
||||
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 {
|
||||
@@ -1313,3 +1363,343 @@ func TestTelemetryMigrationAddsColumns(t *testing.T) {
|
||||
t.Errorf("migration node_telemetry_v1 should be recorded, count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bug #320: Observer metadata nested stats ---
|
||||
|
||||
func TestExtractObserverMetaNestedStats(t *testing.T) {
|
||||
// Real-world MQTT status payload: stats fields nested under "stats"
|
||||
msg := map[string]interface{}{
|
||||
"status": "online",
|
||||
"origin": "ObserverName",
|
||||
"model": "Heltec V3",
|
||||
"firmware_version": "v1.14.0-9f1a3ea",
|
||||
"stats": map[string]interface{}{
|
||||
"battery_mv": 4174.0,
|
||||
"uptime_secs": 80277.0,
|
||||
"noise_floor": -110.0,
|
||||
},
|
||||
}
|
||||
meta := extractObserverMeta(msg)
|
||||
if meta == nil {
|
||||
t.Fatal("expected non-nil meta")
|
||||
}
|
||||
if meta.Model == nil || *meta.Model != "Heltec V3" {
|
||||
t.Errorf("Model=%v, want Heltec V3", meta.Model)
|
||||
}
|
||||
if meta.Firmware == nil || *meta.Firmware != "v1.14.0-9f1a3ea" {
|
||||
t.Errorf("Firmware=%v, want v1.14.0-9f1a3ea", meta.Firmware)
|
||||
}
|
||||
if meta.BatteryMv == nil || *meta.BatteryMv != 4174 {
|
||||
t.Errorf("BatteryMv=%v, want 4174", meta.BatteryMv)
|
||||
}
|
||||
if meta.UptimeSecs == nil || *meta.UptimeSecs != 80277 {
|
||||
t.Errorf("UptimeSecs=%v, want 80277", meta.UptimeSecs)
|
||||
}
|
||||
if meta.NoiseFloor == nil || *meta.NoiseFloor != -110.0 {
|
||||
t.Errorf("NoiseFloor=%v, want -110", meta.NoiseFloor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractObserverMetaNestedStatsPrecedence(t *testing.T) {
|
||||
// If stats has a value AND top-level has a value, nested wins
|
||||
msg := map[string]interface{}{
|
||||
"battery_mv": 9999.0, // top-level (stale/wrong)
|
||||
"noise_floor": -120.0, // top-level (stale/wrong)
|
||||
"stats": map[string]interface{}{
|
||||
"battery_mv": 4174.0, // nested (correct)
|
||||
"noise_floor": -110.5, // nested (correct)
|
||||
},
|
||||
}
|
||||
meta := extractObserverMeta(msg)
|
||||
if meta == nil {
|
||||
t.Fatal("expected non-nil meta")
|
||||
}
|
||||
if meta.BatteryMv == nil || *meta.BatteryMv != 4174 {
|
||||
t.Errorf("BatteryMv=%v, want 4174 (nested should win over top-level)", meta.BatteryMv)
|
||||
}
|
||||
if meta.NoiseFloor == nil || *meta.NoiseFloor != -110.5 {
|
||||
t.Errorf("NoiseFloor=%v, want -110.5 (nested should win over top-level)", meta.NoiseFloor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractObserverMetaFlatFallback(t *testing.T) {
|
||||
// Backward compatibility: flat structure (no stats object) still works
|
||||
msg := map[string]interface{}{
|
||||
"battery_mv": 3500.0,
|
||||
"uptime_secs": 86400.0,
|
||||
"noise_floor": -115.5,
|
||||
}
|
||||
meta := extractObserverMeta(msg)
|
||||
if meta == nil {
|
||||
t.Fatal("expected non-nil meta for flat structure")
|
||||
}
|
||||
if meta.BatteryMv == nil || *meta.BatteryMv != 3500 {
|
||||
t.Errorf("BatteryMv=%v, want 3500", meta.BatteryMv)
|
||||
}
|
||||
if meta.UptimeSecs == nil || *meta.UptimeSecs != 86400 {
|
||||
t.Errorf("UptimeSecs=%v, want 86400", meta.UptimeSecs)
|
||||
}
|
||||
if meta.NoiseFloor == nil || *meta.NoiseFloor != -115.5 {
|
||||
t.Errorf("NoiseFloor=%v, want -115.5", meta.NoiseFloor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractObserverMetaEmptyStats(t *testing.T) {
|
||||
// Empty stats object should not crash, top-level fallback still applies
|
||||
msg := map[string]interface{}{
|
||||
"model": "T-Beam",
|
||||
"stats": map[string]interface{}{},
|
||||
}
|
||||
meta := extractObserverMeta(msg)
|
||||
if meta == nil {
|
||||
t.Fatal("expected non-nil meta (model is present)")
|
||||
}
|
||||
if meta.Model == nil || *meta.Model != "T-Beam" {
|
||||
t.Errorf("Model=%v, want T-Beam", meta.Model)
|
||||
}
|
||||
if meta.BatteryMv != nil {
|
||||
t.Errorf("BatteryMv should be nil, got %v", *meta.BatteryMv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractObserverMetaStatsNotAMap(t *testing.T) {
|
||||
// stats field is not a map (e.g., string) — should not crash, fall back to top-level
|
||||
msg := map[string]interface{}{
|
||||
"stats": "invalid",
|
||||
"battery_mv": 3700.0,
|
||||
}
|
||||
meta := extractObserverMeta(msg)
|
||||
if meta == nil {
|
||||
t.Fatal("expected non-nil meta")
|
||||
}
|
||||
if meta.BatteryMv == nil || *meta.BatteryMv != 3700 {
|
||||
t.Errorf("BatteryMv=%v, want 3700 (top-level fallback when stats is not a map)", meta.BatteryMv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractObserverMetaNoiseFloorFloat(t *testing.T) {
|
||||
// noise_floor migrated to REAL — verify float precision preserved
|
||||
msg := map[string]interface{}{
|
||||
"stats": map[string]interface{}{
|
||||
"noise_floor": -108.75,
|
||||
},
|
||||
}
|
||||
meta := extractObserverMeta(msg)
|
||||
if meta == nil {
|
||||
t.Fatal("expected non-nil meta")
|
||||
}
|
||||
if meta.NoiseFloor == nil || *meta.NoiseFloor != -108.75 {
|
||||
t.Errorf("NoiseFloor=%v, want -108.75", meta.NoiseFloor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractObserverMetaNestedNilSkipsTopLevel(t *testing.T) {
|
||||
// JSON {"stats": {"battery_mv": null}} decodes to nil value in the map.
|
||||
// Nested nil should suppress top-level fallback (nested wins semantics).
|
||||
msg := map[string]interface{}{
|
||||
"battery_mv": 3700.0,
|
||||
"stats": map[string]interface{}{
|
||||
"battery_mv": nil,
|
||||
},
|
||||
}
|
||||
meta := extractObserverMeta(msg)
|
||||
if meta != nil && meta.BatteryMv != nil {
|
||||
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 }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "github.com/meshcore-analyzer/geofilter"
|
||||
|
||||
// NodePassesGeoFilter returns true if the node should be kept.
|
||||
// Nodes with no GPS coordinates are always allowed.
|
||||
func NodePassesGeoFilter(lat, lon *float64, gf *GeoFilterConfig) bool {
|
||||
if gf == nil {
|
||||
return true
|
||||
}
|
||||
if lat == nil || lon == nil {
|
||||
return true
|
||||
}
|
||||
return geofilter.PassesFilter(*lat, *lon, gf)
|
||||
}
|
||||
@@ -4,9 +4,12 @@ go 1.22
|
||||
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
github.com/meshcore-analyzer/geofilter v0.0.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
+140
-32
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -136,7 +137,7 @@ func main() {
|
||||
// Capture source for closure
|
||||
src := source
|
||||
opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) {
|
||||
handleMessage(store, tag, src, m, channelKeys)
|
||||
handleMessage(store, tag, src, m, channelKeys, cfg.GeoFilter)
|
||||
})
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
@@ -165,12 +166,12 @@ 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.")
|
||||
}
|
||||
|
||||
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string) {
|
||||
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string, geoFilter *GeoFilterConfig) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("MQTT [%s] panic in handler: %v", tag, r)
|
||||
@@ -241,43 +242,75 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
if f, ok := toFloat64(v); ok {
|
||||
mqttMsg.SNR = &f
|
||||
}
|
||||
} else if v, ok := msg["snr"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
mqttMsg.SNR = &f
|
||||
}
|
||||
}
|
||||
if v, ok := msg["RSSI"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
mqttMsg.RSSI = &f
|
||||
}
|
||||
} else if v, ok := msg["rssi"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
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
|
||||
}
|
||||
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
|
||||
isNew, err := store.InsertTransmission(pktData)
|
||||
if err != nil {
|
||||
log.Printf("MQTT [%s] db insert error: %v", tag, err)
|
||||
}
|
||||
|
||||
// Process ADVERT → upsert node
|
||||
// For ADVERT packets with known coordinates, enforce geo_filter before
|
||||
// storing anything — drop the entire message if outside the area.
|
||||
if decoded.Header.PayloadTypeName == "ADVERT" && decoded.Payload.PubKey != "" {
|
||||
ok, reason := ValidateAdvert(&decoded.Payload)
|
||||
if ok {
|
||||
role := advertRole(decoded.Payload.Flags)
|
||||
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
|
||||
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
|
||||
}
|
||||
if isNew {
|
||||
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
|
||||
log.Printf("MQTT [%s] advert count error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
// Update telemetry if present in advert
|
||||
if decoded.Payload.BatteryMv != nil || decoded.Payload.TemperatureC != nil {
|
||||
if err := store.UpdateNodeTelemetry(decoded.Payload.PubKey, decoded.Payload.BatteryMv, decoded.Payload.TemperatureC); err != nil {
|
||||
log.Printf("MQTT [%s] node telemetry update error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !ok {
|
||||
log.Printf("MQTT [%s] skipping corrupted ADVERT: %s", tag, reason)
|
||||
return
|
||||
}
|
||||
if !NodePassesGeoFilter(decoded.Payload.Lat, decoded.Payload.Lon, geoFilter) {
|
||||
return
|
||||
}
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
|
||||
isNew, err := store.InsertTransmission(pktData)
|
||||
if err != nil {
|
||||
log.Printf("MQTT [%s] db insert error: %v", tag, err)
|
||||
}
|
||||
role := advertRole(decoded.Payload.Flags)
|
||||
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
|
||||
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
|
||||
}
|
||||
if isNew {
|
||||
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
|
||||
log.Printf("MQTT [%s] advert count error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
// Update telemetry if present in advert
|
||||
if decoded.Payload.BatteryMv != nil || decoded.Payload.TemperatureC != nil {
|
||||
if err := store.UpdateNodeTelemetry(decoded.Payload.PubKey, decoded.Payload.BatteryMv, decoded.Payload.TemperatureC); err != nil {
|
||||
log.Printf("MQTT [%s] node telemetry update error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-ADVERT packets: store normally (routing/channel messages from
|
||||
// in-area observers are relevant regardless of relay hop origin).
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
|
||||
if _, err := store.InsertTransmission(pktData); err != nil {
|
||||
log.Printf("MQTT [%s] db insert error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,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
|
||||
@@ -352,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,
|
||||
@@ -359,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
|
||||
@@ -410,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
|
||||
@@ -429,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,
|
||||
@@ -436,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
|
||||
@@ -465,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 {
|
||||
@@ -501,21 +592,25 @@ func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
|
||||
hasData = true
|
||||
}
|
||||
|
||||
if v, ok := msg["battery_mv"]; ok {
|
||||
// Stats fields may be nested under a "stats" object or at top level.
|
||||
// Try nested first, fall back to top-level for backward compatibility.
|
||||
stats, _ := msg["stats"].(map[string]interface{})
|
||||
|
||||
if v := nestedOrTopLevel(stats, msg, "battery_mv"); v != nil {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
iv := int(math.Round(f))
|
||||
meta.BatteryMv = &iv
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
if v, ok := msg["uptime_secs"]; ok {
|
||||
if v := nestedOrTopLevel(stats, msg, "uptime_secs"); v != nil {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
iv := int64(math.Round(f))
|
||||
meta.UptimeSecs = &iv
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
if v, ok := msg["noise_floor"]; ok {
|
||||
if v := nestedOrTopLevel(stats, msg, "noise_floor"); v != nil {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
meta.NoiseFloor = &f
|
||||
hasData = true
|
||||
@@ -528,6 +623,19 @@ func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
|
||||
return meta
|
||||
}
|
||||
|
||||
// nestedOrTopLevel looks up a key in the nested map first, then the top-level map.
|
||||
func nestedOrTopLevel(nested, toplevel map[string]interface{}, key string) interface{} {
|
||||
if nested != nil {
|
||||
if v, ok := nested[key]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if v, ok := toplevel[key]; ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
|
||||
+139
-56
@@ -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},
|
||||
@@ -124,7 +130,7 @@ func TestHandleMessageRawPacket(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"myobs"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -141,7 +147,7 @@ func TestHandleMessageRawPacketAdvert(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
// Should create a node from the ADVERT
|
||||
var count int
|
||||
@@ -163,7 +169,7 @@ func TestHandleMessageInvalidJSON(t *testing.T) {
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: []byte(`not json`)}
|
||||
|
||||
// Should not panic
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -177,13 +183,13 @@ func TestHandleMessageStatusTopic(t *testing.T) {
|
||||
source := MQTTSource{Name: "test"}
|
||||
msg := &mockMessage{
|
||||
topic: "meshcore/SJC/obs1/status",
|
||||
payload: []byte(`{"origin":"MyObserver","model":"L1","firmware_version":"v1.2.3","client_version":"2.4.1","radio":"SX1262"}`),
|
||||
payload: []byte(`{"origin":"MyObserver"}`),
|
||||
}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var name, iata, model, firmware, clientVersion, radio string
|
||||
err := store.db.QueryRow("SELECT name, iata, model, firmware, client_version, radio FROM observers WHERE id = 'obs1'").Scan(&name, &iata, &model, &firmware, &clientVersion, &radio)
|
||||
var name, iata string
|
||||
err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -193,39 +199,6 @@ func TestHandleMessageStatusTopic(t *testing.T) {
|
||||
if iata != "SJC" {
|
||||
t.Errorf("iata=%s, want SJC", iata)
|
||||
}
|
||||
if model != "L1" {
|
||||
t.Errorf("model=%s, want L1", model)
|
||||
}
|
||||
if firmware != "v1.2.3" {
|
||||
t.Errorf("firmware=%s, want v1.2.3", firmware)
|
||||
}
|
||||
if clientVersion != "2.4.1" {
|
||||
t.Errorf("client_version=%s, want 2.4.1", clientVersion)
|
||||
}
|
||||
if radio != "SX1262" {
|
||||
t.Errorf("radio=%s, want SX1262", radio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageStatusTopicMissingIdentityFields(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
msg := &mockMessage{
|
||||
topic: "meshcore/SJC/obs1/status",
|
||||
payload: []byte(`{"origin":"MyObserver","battery_mv":3500}`),
|
||||
}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
|
||||
var model, firmware, clientVersion, radio interface{}
|
||||
err := store.db.QueryRow("SELECT model, firmware, client_version, radio FROM observers WHERE id = 'obs1'").
|
||||
Scan(&model, &firmware, &clientVersion, &radio)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if model != nil || firmware != nil || clientVersion != nil || radio != nil {
|
||||
t.Errorf("identity fields should remain NULL when absent: model=%v firmware=%v client_version=%v radio=%v", model, firmware, clientVersion, radio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageSkipStatusTopics(t *testing.T) {
|
||||
@@ -234,11 +207,11 @@ func TestHandleMessageSkipStatusTopics(t *testing.T) {
|
||||
|
||||
// meshcore/status should be skipped
|
||||
msg1 := &mockMessage{topic: "meshcore/status", payload: []byte(`{"raw":"0A00"}`)}
|
||||
handleMessage(store, "test", source, msg1, nil)
|
||||
handleMessage(store, "test", source, msg1, nil, nil)
|
||||
|
||||
// meshcore/events/connection should be skipped
|
||||
msg2 := &mockMessage{topic: "meshcore/events/connection", payload: []byte(`{"raw":"0A00"}`)}
|
||||
handleMessage(store, "test", source, msg2, nil)
|
||||
handleMessage(store, "test", source, msg2, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -257,7 +230,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -270,7 +243,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
|
||||
topic: "meshcore/LAX/obs2/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg2, nil)
|
||||
handleMessage(store, "test", source, msg2, nil, nil)
|
||||
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
if count != 1 {
|
||||
@@ -288,7 +261,7 @@ func TestHandleMessageIATAFilterNoRegion(t *testing.T) {
|
||||
topic: "meshcore",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
// No region part → filter doesn't apply, message goes through
|
||||
// Actually the code checks len(parts) > 1 for IATA filter
|
||||
@@ -304,7 +277,7 @@ func TestHandleMessageNoRawHex(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"type":"companion","data":"something"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -322,7 +295,7 @@ func TestHandleMessageBadRawHex(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"ZZZZ"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -339,7 +312,7 @@ func TestHandleMessageWithSNRRSSIAsNumbers(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"RSSI":-95}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var snr, rssi *float64
|
||||
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
||||
@@ -358,7 +331,7 @@ func TestHandleMessageMinimalTopic(t *testing.T) {
|
||||
topic: "meshcore/SJC",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -379,7 +352,7 @@ func TestHandleMessageCorruptedAdvert(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
// Transmission should be inserted (even if advert is invalid)
|
||||
var count int
|
||||
@@ -405,7 +378,7 @@ func TestHandleMessageNoObserverID(t *testing.T) {
|
||||
topic: "packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `","origin":"obs1"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -427,7 +400,7 @@ func TestHandleMessageSNRNotFloat(t *testing.T) {
|
||||
// SNR as a string value — should not parse as float
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":"bad","RSSI":"bad"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -443,7 +416,7 @@ func TestHandleMessageOriginExtraction(t *testing.T) {
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
payload := []byte(`{"raw":"` + rawHex + `","origin":"MyOrigin"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
// Verify origin was extracted to observer name
|
||||
var name string
|
||||
@@ -466,7 +439,7 @@ func TestHandleMessagePanicRecovery(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should not panic — the defer/recover should catch it
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
}
|
||||
|
||||
func TestHandleMessageStatusOriginFallback(t *testing.T) {
|
||||
@@ -478,7 +451,7 @@ func TestHandleMessageStatusOriginFallback(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/status",
|
||||
payload: []byte(`{"type":"status"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var name string
|
||||
err := store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name)
|
||||
@@ -656,3 +629,113 @@ func TestLoadChannelKeysSkipExplicit(t *testing.T) {
|
||||
t.Errorf("#General = %q, want my_explicit_key", keys["#General"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bug #321: SNR/RSSI case-insensitive fallback ---
|
||||
|
||||
func TestHandleMessageWithLowercaseSNRRSSI(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
payload := []byte(`{"raw":"` + rawHex + `","snr":5.5,"rssi":-102}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var snr, rssi *float64
|
||||
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
||||
if snr == nil || *snr != 5.5 {
|
||||
t.Errorf("snr=%v, want 5.5 (lowercase key)", snr)
|
||||
}
|
||||
if rssi == nil || *rssi != -102 {
|
||||
t.Errorf("rssi=%v, want -102 (lowercase key)", rssi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageSNRRSSIUppercaseWins(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
// Both uppercase and lowercase present — uppercase should take precedence
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"snr":1.0,"RSSI":-95,"rssi":-50}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var snr, rssi *float64
|
||||
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
||||
if snr == nil || *snr != 7.2 {
|
||||
t.Errorf("snr=%v, want 7.2 (uppercase should take precedence)", snr)
|
||||
}
|
||||
if rssi == nil || *rssi != -95 {
|
||||
t.Errorf("rssi=%v, want -95 (uppercase should take precedence)", rssi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageNoSNRRSSI(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
payload := []byte(`{"raw":"` + rawHex + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var snr, rssi *float64
|
||||
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
||||
if snr != nil {
|
||||
t.Errorf("snr should be nil when not present, got %v", *snr)
|
||||
}
|
||||
if rssi != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-18
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/meshcore-analyzer/geofilter"
|
||||
)
|
||||
|
||||
// Config mirrors the Node.js config.json structure (read-only fields).
|
||||
@@ -61,15 +63,15 @@ type PacketStoreConfig struct {
|
||||
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
|
||||
}
|
||||
|
||||
type GeoFilterConfig struct {
|
||||
Polygon [][2]float64 `json:"polygon,omitempty"`
|
||||
BufferKm float64 `json:"bufferKm,omitempty"`
|
||||
LatMin *float64 `json:"latMin,omitempty"`
|
||||
LatMax *float64 `json:"latMax,omitempty"`
|
||||
LonMin *float64 `json:"lonMin,omitempty"`
|
||||
LonMax *float64 `json:"lonMax,omitempty"`
|
||||
// GeoFilterConfig is an alias for the shared geofilter.Config type.
|
||||
type GeoFilterConfig = geofilter.Config
|
||||
|
||||
type RetentionConfig struct {
|
||||
NodeDays int `json:"nodeDays"`
|
||||
PacketDays int `json:"packetDays"`
|
||||
}
|
||||
|
||||
|
||||
type TimestampConfig struct {
|
||||
DefaultMode string `json:"defaultMode"` // "ago" | "absolute"
|
||||
Timezone string `json:"timezone"` // "local" | "utc"
|
||||
@@ -78,10 +80,6 @@ type TimestampConfig struct {
|
||||
AllowCustomFormat bool `json:"allowCustomFormat"` // admin gate
|
||||
}
|
||||
|
||||
type RetentionConfig struct {
|
||||
NodeDays int `json:"nodeDays"`
|
||||
}
|
||||
|
||||
func defaultTimestampConfig() TimestampConfig {
|
||||
return TimestampConfig{
|
||||
DefaultMode: "ago",
|
||||
@@ -221,17 +219,11 @@ func (c *Config) ResolveDBPath(baseDir string) string {
|
||||
return filepath.Join(baseDir, "data", "meshcore.db")
|
||||
}
|
||||
|
||||
func (c *Config) PropagationBufferMs() int {
|
||||
if c.LiveMap.PropagationBufferMs > 0 {
|
||||
return c.LiveMap.PropagationBufferMs
|
||||
}
|
||||
return 5000
|
||||
}
|
||||
|
||||
func (c *Config) NormalizeTimestampConfig() {
|
||||
defaults := defaultTimestampConfig()
|
||||
if c.Timestamps == nil {
|
||||
log.Printf("[config] timestamps not configured — using defaults (ago/local/iso)")
|
||||
log.Printf("[config] timestamps not configured - using defaults (ago/local/iso)")
|
||||
c.Timestamps = &defaults
|
||||
return
|
||||
}
|
||||
@@ -273,3 +265,9 @@ func (c *Config) GetTimestampConfig() TimestampConfig {
|
||||
}
|
||||
return *c.Timestamps
|
||||
}
|
||||
func (c *Config) PropagationBufferMs() int {
|
||||
if c.LiveMap.PropagationBufferMs > 0 {
|
||||
return c.LiveMap.PropagationBufferMs
|
||||
}
|
||||
return 5000
|
||||
}
|
||||
|
||||
@@ -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 ")
|
||||
@@ -1621,3 +1654,39 @@ func nullInt(ni sql.NullInt64) interface{} {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneOldPackets deletes transmissions and their observations older than the
|
||||
// given number of days. Nodes and observers are never touched.
|
||||
// Returns the number of transmissions deleted.
|
||||
// Opens a separate read-write connection since the main connection is read-only.
|
||||
func (db *DB) PruneOldPackets(days int) (int64, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=10000", db.path)
|
||||
rw, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rw.SetMaxOpenConns(1)
|
||||
defer rw.Close()
|
||||
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete observations linked to old transmissions first (no CASCADE in SQLite)
|
||||
_, err = tx.Exec(`DELETE FROM observations WHERE transmission_id IN (
|
||||
SELECT id FROM transmissions WHERE first_seen < ?
|
||||
)`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM transmissions WHERE first_seen < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, tx.Commit()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeHeader_TransportFlood(t *testing.T) {
|
||||
// Route type 0 = TRANSPORT_FLOOD, payload type 5 = GRP_TXT, version 0
|
||||
// Header byte: (0 << 6) | (5 << 2) | 0 = 0x14
|
||||
h := decodeHeader(0x14)
|
||||
if h.RouteType != RouteTransportFlood {
|
||||
t.Errorf("expected RouteTransportFlood (0), got %d", h.RouteType)
|
||||
}
|
||||
if h.RouteTypeName != "TRANSPORT_FLOOD" {
|
||||
t.Errorf("expected TRANSPORT_FLOOD, got %s", h.RouteTypeName)
|
||||
}
|
||||
if h.PayloadType != PayloadGRP_TXT {
|
||||
t.Errorf("expected PayloadGRP_TXT (5), got %d", h.PayloadType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeHeader_TransportDirect(t *testing.T) {
|
||||
// Route type 3 = TRANSPORT_DIRECT, payload type 2 = TXT_MSG, version 0
|
||||
// Header byte: (0 << 6) | (2 << 2) | 3 = 0x0B
|
||||
h := decodeHeader(0x0B)
|
||||
if h.RouteType != RouteTransportDirect {
|
||||
t.Errorf("expected RouteTransportDirect (3), got %d", h.RouteType)
|
||||
}
|
||||
if h.RouteTypeName != "TRANSPORT_DIRECT" {
|
||||
t.Errorf("expected TRANSPORT_DIRECT, got %s", h.RouteTypeName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeHeader_Flood(t *testing.T) {
|
||||
// Route type 1 = FLOOD, payload type 4 = ADVERT
|
||||
// Header byte: (0 << 6) | (4 << 2) | 1 = 0x11
|
||||
h := decodeHeader(0x11)
|
||||
if h.RouteType != RouteFlood {
|
||||
t.Errorf("expected RouteFlood (1), got %d", h.RouteType)
|
||||
}
|
||||
if h.RouteTypeName != "FLOOD" {
|
||||
t.Errorf("expected FLOOD, got %s", h.RouteTypeName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransportRoute(t *testing.T) {
|
||||
if !isTransportRoute(RouteTransportFlood) {
|
||||
t.Error("expected RouteTransportFlood to be transport")
|
||||
}
|
||||
if !isTransportRoute(RouteTransportDirect) {
|
||||
t.Error("expected RouteTransportDirect to be transport")
|
||||
}
|
||||
if isTransportRoute(RouteFlood) {
|
||||
t.Error("expected RouteFlood to NOT be transport")
|
||||
}
|
||||
if isTransportRoute(RouteDirect) {
|
||||
t.Error("expected RouteDirect to NOT be transport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePacket_TransportFloodHasCodes(t *testing.T) {
|
||||
// Build a minimal TRANSPORT_FLOOD packet:
|
||||
// Header 0x14 (route=0/T_FLOOD, payload=5/GRP_TXT)
|
||||
// Transport codes: AABB CCDD (4 bytes)
|
||||
// Path byte: 0x00 (hashSize=1, hashCount=0)
|
||||
// Payload: at least some bytes for GRP_TXT
|
||||
hex := "14AABBCCDD00112233445566778899"
|
||||
pkt, err := DecodePacket(hex)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pkt.TransportCodes == nil {
|
||||
t.Fatal("expected transport codes to be present")
|
||||
}
|
||||
if pkt.TransportCodes.Code1 != "AABB" {
|
||||
t.Errorf("expected Code1=AABB, got %s", pkt.TransportCodes.Code1)
|
||||
}
|
||||
if pkt.TransportCodes.Code2 != "CCDD" {
|
||||
t.Errorf("expected Code2=CCDD, got %s", pkt.TransportCodes.Code2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePacket_FloodHasNoCodes(t *testing.T) {
|
||||
// Header 0x11 (route=1/FLOOD, payload=4/ADVERT)
|
||||
// Path byte: 0x00 (no hops)
|
||||
// Some payload bytes
|
||||
hex := "110011223344556677889900AABBCCDD"
|
||||
pkt, err := DecodePacket(hex)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pkt.TransportCodes != nil {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import "github.com/meshcore-analyzer/geofilter"
|
||||
|
||||
// NodePassesGeoFilter returns true if the node should be included in responses.
|
||||
// Nodes with no GPS coordinates are always allowed.
|
||||
// lat and lon are interface{} because they come from DB row maps.
|
||||
func NodePassesGeoFilter(lat, lon interface{}, gf *GeoFilterConfig) bool {
|
||||
if gf == nil {
|
||||
return true
|
||||
}
|
||||
latF, ok1 := toFloat64(lat)
|
||||
lonF, ok2 := toFloat64(lon)
|
||||
if !ok1 || !ok2 {
|
||||
return true
|
||||
}
|
||||
return geofilter.PassesFilter(latF, lonF, gf)
|
||||
}
|
||||
|
||||
func toFloat64(v interface{}) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
case nil:
|
||||
return 0, false
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -5,9 +5,12 @@ go 1.22
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/meshcore-analyzer/geofilter v0.0.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+75
-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
|
||||
@@ -171,6 +179,27 @@ func main() {
|
||||
stopEviction := store.StartEvictionTicker()
|
||||
defer stopEviction()
|
||||
|
||||
// Auto-prune old packets if retention.packetDays is configured
|
||||
if cfg.Retention != nil && cfg.Retention.PacketDays > 0 {
|
||||
days := cfg.Retention.PacketDays
|
||||
go func() {
|
||||
time.Sleep(1 * time.Minute)
|
||||
if n, err := database.PruneOldPackets(days); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
}
|
||||
for range time.Tick(24 * time.Hour) {
|
||||
if n, err := database.PruneOldPackets(days); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", days)
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
@@ -183,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)
|
||||
@@ -196,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,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")
|
||||
}
|
||||
}
|
||||
+108
-31
@@ -42,6 +42,7 @@ type Server struct {
|
||||
|
||||
// PerfStats tracks request performance.
|
||||
type PerfStats struct {
|
||||
mu sync.Mutex
|
||||
Requests int64
|
||||
TotalMs float64
|
||||
Endpoints map[string]*EndpointPerf
|
||||
@@ -109,6 +110,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
|
||||
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
|
||||
r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
|
||||
r.Handle("/api/admin/prune", s.requireAPIKey(http.HandlerFunc(s.handleAdminPrune))).Methods("POST")
|
||||
|
||||
// Packet endpoints
|
||||
r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET")
|
||||
@@ -135,6 +137,7 @@ 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")
|
||||
|
||||
@@ -160,10 +163,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 {
|
||||
@@ -173,6 +173,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)}
|
||||
}
|
||||
@@ -198,6 +203,7 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
|
||||
s.perfStats.SlowQueries = s.perfStats.SlowQueries[1:]
|
||||
}
|
||||
}
|
||||
s.perfStats.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -363,7 +369,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 {
|
||||
@@ -372,6 +379,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",
|
||||
@@ -401,9 +412,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,
|
||||
},
|
||||
})
|
||||
@@ -463,22 +474,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 {
|
||||
@@ -519,22 +558,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,
|
||||
@@ -558,7 +585,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})
|
||||
}
|
||||
|
||||
@@ -728,10 +761,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),
|
||||
})
|
||||
@@ -855,6 +889,16 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cfg.GeoFilter != nil {
|
||||
filtered := nodes[:0]
|
||||
for _, node := range nodes {
|
||||
if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
}
|
||||
total = len(filtered)
|
||||
nodes = filtered
|
||||
}
|
||||
writeJSON(w, NodeListResponse{Nodes: nodes, Total: total, Counts: counts})
|
||||
}
|
||||
|
||||
@@ -1190,6 +1234,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")
|
||||
@@ -1842,3 +1898,24 @@ func nullFloatVal(n sql.NullFloat64) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPrune(w http.ResponseWriter, r *http.Request) {
|
||||
days := 0
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
fmt.Sscanf(d, "%d", &days)
|
||||
}
|
||||
if days <= 0 && s.cfg.Retention != nil {
|
||||
days = s.cfg.Retention.PacketDays
|
||||
}
|
||||
if days <= 0 {
|
||||
writeError(w, 400, "days parameter required (or set retention.packetDays in config)")
|
||||
return
|
||||
}
|
||||
n, err := s.db.PruneOldPackets(days)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
writeJSON(w, map[string]interface{}{"deleted": n, "days": days})
|
||||
}
|
||||
|
||||
+931
-4
@@ -2086,8 +2086,8 @@ t.Error("expected inconsistent flag to be true for flip-flop pattern")
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoDominant(t *testing.T) {
|
||||
// A node that sends mostly 2-byte adverts but occasionally 1-byte (pathByte=0x00
|
||||
// on direct sends) should report HashSize=2, not 1.
|
||||
// A node with mostly 2-byte adverts and an occasional 1-byte advert; the
|
||||
// latest advert (2-byte) determines the reported hash size.
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
@@ -2103,7 +2103,7 @@ raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1 (direct send, n
|
||||
raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2
|
||||
|
||||
payloadType := 4
|
||||
// 1 packet with hashSize=1, 4 packets with hashSize=2
|
||||
// 1 packet with hashSize=1, 4 packets with hashSize=2 (latest is 2-byte)
|
||||
raws := []string{raw1byte, raw2byte, raw2byte, raw2byte, raw2byte}
|
||||
for i, raw := range raws {
|
||||
tx := &StoreTx{
|
||||
@@ -2124,10 +2124,312 @@ if ni == nil {
|
||||
t.Fatal("expected hash info for test node")
|
||||
}
|
||||
if ni.HashSize != 2 {
|
||||
t.Errorf("HashSize=%d, want 2 (dominant size should win over occasional 1-byte)", ni.HashSize)
|
||||
t.Errorf("HashSize=%d, want 2 (latest advert should determine hash size)", ni.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoLatestWins(t *testing.T) {
|
||||
// A node reconfigured from 1-byte to 2-byte hash should show 2-byte
|
||||
// even when it has many more historical 1-byte adverts (issue #303).
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'LatestWins', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"LatestWins","pubKey":"` + pk + `"}`
|
||||
raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1
|
||||
raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2
|
||||
|
||||
payloadType := 4
|
||||
// 4 historical 1-byte adverts, then 1 recent 2-byte advert (latest).
|
||||
// Mode would pick 1 (majority), but latest-wins should pick 2.
|
||||
raws := []string{raw1byte, raw1byte, raw1byte, raw1byte, raw2byte}
|
||||
for i, raw := range raws {
|
||||
tx := &StoreTx{
|
||||
ID: 7000 + i,
|
||||
RawHex: raw,
|
||||
Hash: "latest" + 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 (latest advert should win over historical mode)", ni.HashSize)
|
||||
}
|
||||
if len(ni.AllSizes) != 2 {
|
||||
t.Errorf("AllSizes count=%d, want 2", len(ni.AllSizes))
|
||||
}
|
||||
if !ni.AllSizes[1] || !ni.AllSizes[2] {
|
||||
t.Error("AllSizes should contain both 1 and 2")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'NoAdverts', 'repeater')", pk)
|
||||
|
||||
// Add a non-advert packet (payload_type=2 = TXT_MSG)
|
||||
payloadType := 2
|
||||
tx := &StoreTx{
|
||||
ID: 6000,
|
||||
RawHex: "0440aabb",
|
||||
Hash: "noadverts0",
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: `{"pubKey":"` + pk + `"}`,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[2] = append(store.byPayloadType[2], tx)
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
if ni := info[pk]; ni != nil {
|
||||
t.Errorf("expected nil hash info for node with no adverts, got HashSize=%d", ni.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAnalyticsZeroHopAdvert(t *testing.T) {
|
||||
// A zero-hop advert (pathByte=0x00, no relay path) should contribute to
|
||||
// distributionByRepeaters (per-node tracking) but NOT inflate total or
|
||||
// distribution (which only count relayed packets).
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
// Capture baseline from seed data (bypass cache via computeAnalyticsHashSizes)
|
||||
baseline := store.computeAnalyticsHashSizes("")
|
||||
baseTotal, _ := baseline["total"].(int)
|
||||
baseDist, _ := baseline["distribution"].(map[string]int)
|
||||
baseDist1 := baseDist["1"]
|
||||
|
||||
pk := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'ZeroHop', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"ZeroHop","pubKey":"` + pk + `"}`
|
||||
// header 0x05 → routeType=1 (FLOOD), pathByte=0x00 → hashSize=1
|
||||
raw := "05" + "00" + "aabb"
|
||||
payloadType := 4
|
||||
|
||||
tx := &StoreTx{
|
||||
ID: 8000,
|
||||
RawHex: raw,
|
||||
Hash: "zerohop0",
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
// No PathJSON → txGetParsedPath returns nil (zero hops)
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
|
||||
result := store.computeAnalyticsHashSizes("")
|
||||
|
||||
// distributionByRepeaters should include the zero-hop advert's node
|
||||
distByRepeaters, ok := result["distributionByRepeaters"].(map[string]int)
|
||||
if !ok {
|
||||
t.Fatal("distributionByRepeaters missing or wrong type")
|
||||
}
|
||||
if distByRepeaters["1"] < 1 {
|
||||
t.Errorf("distributionByRepeaters[\"1\"]=%d, want >=1 (zero-hop advert should be tracked per-node)", distByRepeaters["1"])
|
||||
}
|
||||
|
||||
// total and distribution must NOT have increased from the baseline
|
||||
total, _ := result["total"].(int)
|
||||
dist, _ := result["distribution"].(map[string]int)
|
||||
if total != baseTotal {
|
||||
t.Errorf("total=%d, want %d (zero-hop adverts must not inflate total)", total, baseTotal)
|
||||
}
|
||||
if dist["1"] != baseDist1 {
|
||||
t.Errorf("distribution[\"1\"]=%d, want %d (zero-hop adverts must not inflate distribution)", dist["1"], baseDist1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsHashSizeSameNameDifferentPubkey(t *testing.T) {
|
||||
// Two nodes named "SameName" with different pubkeys should be counted
|
||||
// separately in distributionByRepeaters (issue #303, byNode keying fix).
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk1 := "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeee1111"
|
||||
pk2 := "aaaa111122223333444455556666777788889999aaaabbbbccccddddeeee2222"
|
||||
|
||||
decoded1 := `{"name":"SameName","pubKey":"` + pk1 + `"}`
|
||||
decoded2 := `{"name":"SameName","pubKey":"` + pk2 + `"}`
|
||||
|
||||
raw2byte := "05" + "40" + "aabb" // header routeType=1 (FLOOD), pathByte=0x40 → hashSize=2
|
||||
payloadType := 4
|
||||
|
||||
for i, decoded := range []string{decoded1, decoded2} {
|
||||
tx := &StoreTx{
|
||||
ID: 6100 + i,
|
||||
RawHex: raw2byte,
|
||||
Hash: "samename" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
PathJSON: `["AABB"]`,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
}
|
||||
|
||||
result := store.GetAnalyticsHashSizes("")
|
||||
|
||||
distByRepeaters, ok := result["distributionByRepeaters"].(map[string]int)
|
||||
if !ok {
|
||||
t.Fatal("distributionByRepeaters missing or wrong type")
|
||||
}
|
||||
if distByRepeaters["2"] < 2 {
|
||||
t.Errorf("distributionByRepeaters[\"2\"]=%d, want >=2 (same-name nodes with different pubkeys should be counted separately)", distByRepeaters["2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsHashSizesNoNullArrays(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-sizes", nil)
|
||||
@@ -2218,3 +2520,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+664
-184
File diff suppressed because it is too large
Load Diff
+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
-1
@@ -3,7 +3,8 @@
|
||||
"apiKey": "your-secret-api-key-here",
|
||||
"retention": {
|
||||
"nodeDays": 7,
|
||||
"_comment": "Nodes not seen in this many days are moved to inactive_nodes table. Default 7."
|
||||
"packetDays": 30,
|
||||
"_comment": "nodeDays: nodes not seen in N days are moved to inactive_nodes (default 7). packetDays: transmissions+observations older than N days are deleted daily (0 = disabled)."
|
||||
},
|
||||
"https": {
|
||||
"cert": "/path/to/cert.pem",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
MATOMO_COMMIT="38c30f9"
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
echo "[deploy] Fetching latest from origin..."
|
||||
git fetch origin
|
||||
|
||||
echo "[deploy] Resetting to origin/master..."
|
||||
git reset --hard origin/master
|
||||
|
||||
echo "[deploy] Building Docker image..."
|
||||
docker build -t 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 \
|
||||
-v "$(pwd)/config.json:/app/config.json:ro" \
|
||||
-v meshcore-data:/app/data \
|
||||
meshcore-analyzer
|
||||
|
||||
echo "[deploy] Done. Live at https://analyzer.on8ar.eu"
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
echo "[staging] Fetching latest from origin..."
|
||||
git fetch origin
|
||||
|
||||
BRANCH="${1:-master}"
|
||||
echo "[staging] Checking out $BRANCH..."
|
||||
git reset --hard "origin/$BRANCH"
|
||||
|
||||
echo "[staging] Building Docker image..."
|
||||
docker build -t meshcore-analyzer-staging .
|
||||
|
||||
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 \
|
||||
-v "$(pwd)/config.json:/app/config.json:ro" \
|
||||
-v meshcore-staging-data:/app/data \
|
||||
meshcore-analyzer-staging
|
||||
|
||||
echo "[staging] Done. Live at https://staging.on8ar.eu"
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
// Package geofilter provides the shared geographic filter configuration and
|
||||
// geometry used by both the server and ingestor packages.
|
||||
package geofilter
|
||||
|
||||
import "math"
|
||||
|
||||
// Config defines the geographic filter polygon or bounding box.
|
||||
// Shared between the server and ingestor packages.
|
||||
type Config struct {
|
||||
Polygon [][2]float64 `json:"polygon,omitempty"`
|
||||
BufferKm float64 `json:"bufferKm,omitempty"`
|
||||
LatMin *float64 `json:"latMin,omitempty"`
|
||||
LatMax *float64 `json:"latMax,omitempty"`
|
||||
LonMin *float64 `json:"lonMin,omitempty"`
|
||||
LonMax *float64 `json:"lonMax,omitempty"`
|
||||
}
|
||||
|
||||
// PassesFilter returns true if the coordinates fall within the filter area.
|
||||
// Nodes with no GPS fix (0,0) are always allowed.
|
||||
func PassesFilter(lat, lon float64, gf *Config) bool {
|
||||
if gf == nil {
|
||||
return true
|
||||
}
|
||||
if lat == 0 && lon == 0 {
|
||||
return true
|
||||
}
|
||||
if len(gf.Polygon) >= 3 {
|
||||
if PointInPolygon(lat, lon, gf.Polygon) {
|
||||
return true
|
||||
}
|
||||
if gf.BufferKm > 0 {
|
||||
n := len(gf.Polygon)
|
||||
for i := 0; i < n; i++ {
|
||||
j := (i + 1) % n
|
||||
if DistToSegmentKm(lat, lon, gf.Polygon[i], gf.Polygon[j]) <= gf.BufferKm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Legacy bounding box fallback
|
||||
if gf.LatMin != nil && gf.LatMax != nil && gf.LonMin != nil && gf.LonMax != nil {
|
||||
return lat >= *gf.LatMin && lat <= *gf.LatMax && lon >= *gf.LonMin && lon <= *gf.LonMax
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// PointInPolygon uses the ray-casting algorithm.
|
||||
func PointInPolygon(lat, lon float64, polygon [][2]float64) bool {
|
||||
inside := false
|
||||
n := len(polygon)
|
||||
j := n - 1
|
||||
for i := 0; i < n; i++ {
|
||||
yi, xi := polygon[i][0], polygon[i][1]
|
||||
yj, xj := polygon[j][0], polygon[j][1]
|
||||
if (yi > lat) != (yj > lat) {
|
||||
if lon < (xj-xi)*(lat-yi)/(yj-yi)+xi {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
j = i
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
// DistToSegmentKm returns the approximate distance in km from point (lat,lon)
|
||||
// to line segment a→b using a flat-earth projection.
|
||||
func DistToSegmentKm(lat, lon float64, a, b [2]float64) float64 {
|
||||
lat1, lon1 := a[0], a[1]
|
||||
lat2, lon2 := b[0], b[1]
|
||||
cosLat := math.Cos((lat1+lat2) / 2.0 * math.Pi / 180.0)
|
||||
ax := (lon1 - lon) * 111.0 * cosLat
|
||||
ay := (lat1 - lat) * 111.0
|
||||
bx := (lon2 - lon) * 111.0 * cosLat
|
||||
by := (lat2 - lat) * 111.0
|
||||
abx, aby := bx-ax, by-ay
|
||||
abSq := abx*abx + aby*aby
|
||||
if abSq == 0 {
|
||||
return math.Sqrt(ax*ax + ay*ay)
|
||||
}
|
||||
t := math.Max(0, math.Min(1, -(ax*abx+ay*aby)/abSq))
|
||||
px := ax + t*abx
|
||||
py := ay + t*aby
|
||||
return math.Sqrt(px*px + py*py)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/meshcore-analyzer/geofilter
|
||||
|
||||
go 1.22
|
||||
@@ -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 });
|
||||
|
||||
+77
-3
@@ -9,6 +9,8 @@ const PAYLOAD_COLORS = { 0: 'req', 1: 'response', 2: 'txt-msg', 3: 'ack', 4: 'ad
|
||||
function routeTypeName(n) { return ROUTE_TYPES[n] || 'UNKNOWN'; }
|
||||
function payloadTypeName(n) { return PAYLOAD_TYPES[n] || 'UNKNOWN'; }
|
||||
function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; }
|
||||
function isTransportRoute(rt) { return rt === 0 || rt === 3; }
|
||||
function transportBadge(rt) { return isTransportRoute(rt) ? ' <span class="badge badge-transport" title="' + routeTypeName(rt) + '">T</span>' : ''; }
|
||||
|
||||
// --- Utilities ---
|
||||
const _apiPerf = { calls: 0, totalMs: 0, log: [], cacheHits: 0 };
|
||||
@@ -405,7 +407,24 @@ function registerPage(name, mod) { pages[name] = mod; }
|
||||
|
||||
let currentPage = null;
|
||||
|
||||
function closeNav() {
|
||||
document.querySelector('.nav-links')?.classList.remove('open');
|
||||
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() {
|
||||
closeNav();
|
||||
|
||||
const hash = location.hash.replace('#/', '') || 'packets';
|
||||
const route = hash.split('?')[0];
|
||||
|
||||
@@ -437,6 +456,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();
|
||||
@@ -531,10 +557,57 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// --- Hamburger Menu ---
|
||||
const hamburger = document.getElementById('hamburger');
|
||||
const navLinks = document.querySelector('.nav-links');
|
||||
hamburger.addEventListener('click', () => navLinks.classList.toggle('open'));
|
||||
// Close menu on nav link click
|
||||
hamburger.addEventListener('click', () => {
|
||||
const opening = !navLinks.classList.contains('open');
|
||||
navLinks.classList.toggle('open');
|
||||
document.body.classList.toggle('nav-open');
|
||||
hamburger.setAttribute('aria-expanded', String(opening));
|
||||
});
|
||||
navLinks.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', () => navLinks.classList.remove('open'));
|
||||
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') {
|
||||
if (navMoreMenu && navMoreMenu.classList.contains('open')) closeMoreMenu();
|
||||
if (navLinks.classList.contains('open')) closeNav();
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (navLinks.classList.contains('open') &&
|
||||
!navLinks.contains(e.target) &&
|
||||
!hamburger.contains(e.target)) {
|
||||
closeNav();
|
||||
}
|
||||
if (navMoreMenu && navMoreMenu.classList.contains('open') &&
|
||||
!navMoreMenu.contains(e.target) &&
|
||||
!navMoreBtn.contains(e.target)) {
|
||||
closeMoreMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Favorites dropdown ---
|
||||
@@ -734,6 +807,7 @@ 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)
|
||||
|
||||
+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`;
|
||||
|
||||
|
||||
+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
-36
@@ -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=1774937706">
|
||||
<link rel="stylesheet" href="home.css?v=1774937706">
|
||||
<link rel="stylesheet" href="live.css?v=1774937706">
|
||||
<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,33 +85,31 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774937706"></script>
|
||||
<script src="customize.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774937706"></script>
|
||||
<script src="hop-resolver.js?v=1774937706"></script>
|
||||
<script src="hop-display.js?v=1774937706"></script>
|
||||
<script src="app.js?v=1774937706"></script>
|
||||
<script src="home.js?v=1774937706"></script>
|
||||
<script src="packet-filter.js?v=1774937706"></script>
|
||||
<script src="packets.js?v=1774937706"></script>
|
||||
<script src="geo-filter-overlay.js?v=1774937706"></script>
|
||||
<script src="map.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774937706" onerror="console.error('Failed to load:', this.src)"></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="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>
|
||||
|
||||
|
||||
|
||||
|
||||
+176
-66
@@ -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)
|
||||
@@ -817,7 +832,48 @@
|
||||
});
|
||||
|
||||
// Geo filter overlay
|
||||
initGeoFilterOverlay(map, 'liveGeoFilterToggle', 'liveGeoFilterLabel').then(function (layer) { geoFilterLayer = layer; });
|
||||
(async function () {
|
||||
try {
|
||||
const gf = await api('/config/geo-filter', { ttl: 3600 });
|
||||
if (!gf || !gf.polygon || gf.polygon.length < 3) return;
|
||||
const geoColor = cssVar('--geo-filter-color') || '#3b82f6';
|
||||
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
|
||||
const innerPoly = L.polygon(latlngs, {
|
||||
color: geoColor, weight: 2, opacity: 0.8,
|
||||
fillColor: geoColor, fillOpacity: 0.08
|
||||
});
|
||||
const bufferPoly = gf.bufferKm > 0 ? (function () {
|
||||
let cLat = 0, cLon = 0;
|
||||
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
|
||||
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
|
||||
const cosLat = Math.cos(cLat * Math.PI / 180);
|
||||
const outer = gf.polygon.map(function (p) {
|
||||
const dLatM = (p[0] - cLat) * 111000;
|
||||
const dLonM = (p[1] - cLon) * 111000 * cosLat;
|
||||
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
|
||||
if (dist === 0) return [p[0], p[1]];
|
||||
const scale = (gf.bufferKm * 1000) / dist;
|
||||
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
|
||||
});
|
||||
return L.polygon(outer, {
|
||||
color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4',
|
||||
fillColor: geoColor, fillOpacity: 0.04
|
||||
});
|
||||
})() : null;
|
||||
geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
|
||||
const label = document.getElementById('liveGeoFilterLabel');
|
||||
if (label) label.style.display = '';
|
||||
const el = document.getElementById('liveGeoFilterToggle');
|
||||
if (el) {
|
||||
const saved = localStorage.getItem('meshcore-map-geo-filter');
|
||||
if (saved === 'true') { el.checked = true; geoFilterLayer.addTo(map); }
|
||||
el.addEventListener('change', function (e) {
|
||||
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
|
||||
if (e.target.checked) { geoFilterLayer.addTo(map); } else { map.removeLayer(geoFilterLayer); }
|
||||
});
|
||||
}
|
||||
} catch (e) { /* no geo filter configured */ }
|
||||
})();
|
||||
|
||||
const matrixToggle = document.getElementById('liveMatrixToggle');
|
||||
matrixToggle.checked = matrixMode;
|
||||
@@ -1019,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');
|
||||
});
|
||||
@@ -1033,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');
|
||||
});
|
||||
@@ -1395,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;
|
||||
}
|
||||
@@ -1540,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 {
|
||||
@@ -1664,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;
|
||||
}
|
||||
@@ -1701,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 || [];
|
||||
}
|
||||
@@ -1802,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;
|
||||
@@ -1809,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];
|
||||
@@ -1823,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);
|
||||
@@ -1872,20 +1954,30 @@
|
||||
}).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 (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;
|
||||
@@ -2198,43 +2290,61 @@
|
||||
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); }
|
||||
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 lastPt = currentCoords[currentCoords.length - 1];
|
||||
line.setLatLngs(currentCoords);
|
||||
contrail.setLatLngs(currentCoords);
|
||||
dot.setLatLng(lastPt);
|
||||
|
||||
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 });
|
||||
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);
|
||||
}
|
||||
}, 52);
|
||||
}, 800);
|
||||
requestAnimationFrame(animateFade);
|
||||
}, 800);
|
||||
|
||||
if (onComplete) onComplete();
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, 33);
|
||||
requestAnimationFrame(animateLine);
|
||||
}
|
||||
requestAnimationFrame(animateLine);
|
||||
}
|
||||
|
||||
function showHeatMap() {
|
||||
|
||||
+110
-4
@@ -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;
|
||||
@@ -95,7 +97,7 @@
|
||||
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
|
||||
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
|
||||
<label for="mcHashLabels"><input type="checkbox" id="mcHashLabels"> Hash prefix labels</label>
|
||||
<label for="mcGeoFilter" id="mcGeoFilterLabel" style="display:none"><input type="checkbox" id="mcGeoFilter"> Geo filter area</label>
|
||||
<label id="mcGeoFilterLabel" for="mcGeoFilter" style="display:none"><input type="checkbox" id="mcGeoFilter"> Mesh live area</label>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Status</legend>
|
||||
@@ -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');
|
||||
@@ -228,7 +244,49 @@
|
||||
});
|
||||
|
||||
// Geo filter overlay
|
||||
initGeoFilterOverlay(map, 'mcGeoFilter', 'mcGeoFilterLabel').then(function (layer) { geoFilterLayer = layer; });
|
||||
(async function () {
|
||||
try {
|
||||
const gf = await api('/config/geo-filter', { ttl: 3600 });
|
||||
if (!gf || !gf.polygon || gf.polygon.length < 3) return;
|
||||
const geoColor = getComputedStyle(document.documentElement).getPropertyValue('--geo-filter-color').trim() || '#3b82f6';
|
||||
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
|
||||
const innerPoly = L.polygon(latlngs, {
|
||||
color: geoColor, weight: 2, opacity: 0.8,
|
||||
fillColor: geoColor, fillOpacity: 0.08
|
||||
});
|
||||
// Approximate buffer zone — expand each vertex outward from centroid by bufferKm
|
||||
const bufferPoly = gf.bufferKm > 0 ? (function () {
|
||||
let cLat = 0, cLon = 0;
|
||||
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
|
||||
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
|
||||
const cosLat = Math.cos(cLat * Math.PI / 180);
|
||||
const outer = gf.polygon.map(function (p) {
|
||||
const dLatM = (p[0] - cLat) * 111000;
|
||||
const dLonM = (p[1] - cLon) * 111000 * cosLat;
|
||||
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
|
||||
if (dist === 0) return [p[0], p[1]];
|
||||
const scale = (gf.bufferKm * 1000) / dist;
|
||||
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
|
||||
});
|
||||
return L.polygon(outer, {
|
||||
color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4',
|
||||
fillColor: geoColor, fillOpacity: 0.04
|
||||
});
|
||||
})() : null;
|
||||
geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
|
||||
const label = document.getElementById('mcGeoFilterLabel');
|
||||
if (label) label.style.display = '';
|
||||
const el = document.getElementById('mcGeoFilter');
|
||||
if (el) {
|
||||
const saved = localStorage.getItem('meshcore-map-geo-filter');
|
||||
if (saved === 'true') { el.checked = true; geoFilterLayer.addTo(map); }
|
||||
el.addEventListener('change', function (e) {
|
||||
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
|
||||
if (e.target.checked) { geoFilterLayer.addTo(map); } else { map.removeLayer(geoFilterLayer); }
|
||||
});
|
||||
}
|
||||
} catch (e) { /* no geo filter configured */ }
|
||||
})();
|
||||
|
||||
// WS for live advert updates
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
@@ -604,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;
|
||||
});
|
||||
|
||||
@@ -682,6 +745,43 @@
|
||||
</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)}` : '—';
|
||||
@@ -707,7 +807,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>`;
|
||||
}
|
||||
|
||||
@@ -733,6 +836,9 @@
|
||||
routeLayer = null;
|
||||
if (heatLayer) { heatLayer = null; }
|
||||
geoFilterLayer = null;
|
||||
selectedReferenceNode = null;
|
||||
neighborPubkeys = null;
|
||||
delete window._mapSelectRefNode;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
+366
-138
@@ -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,19 +21,45 @@
|
||||
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}`; }
|
||||
const isMobile = window.innerWidth <= 1024;
|
||||
const PACKET_LIMIT = isMobile ? 1000 : 50000;
|
||||
let savedTimeWindowMin = Number(localStorage.getItem('meshcore-time-window'));
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin < 0) savedTimeWindowMin = 15;
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
|
||||
if (isMobile && savedTimeWindowMin > 180) savedTimeWindowMin = 15;
|
||||
let totalCount = 0;
|
||||
let expandedHashes = new Set();
|
||||
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) {
|
||||
@@ -243,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);
|
||||
@@ -298,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 {}
|
||||
@@ -310,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;
|
||||
@@ -321,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;
|
||||
@@ -337,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) {
|
||||
@@ -359,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 {
|
||||
@@ -379,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; }
|
||||
@@ -406,6 +458,7 @@
|
||||
hopNameCache = {};
|
||||
totalCount = 0;
|
||||
observers = [];
|
||||
observerMap = new Map();
|
||||
directPacketId = null;
|
||||
directPacketHash = null;
|
||||
groupByHash = true;
|
||||
@@ -417,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 {}
|
||||
}
|
||||
|
||||
@@ -429,7 +483,7 @@
|
||||
const since = new Date(Date.now() - windowMin * 60000).toISOString();
|
||||
params.set('since', since);
|
||||
}
|
||||
params.set('limit', '50000');
|
||||
params.set('limit', String(PACKET_LIMIT));
|
||||
const regionParam = RegionFilter.getRegionParam();
|
||||
if (regionParam) params.set('region', regionParam);
|
||||
if (filters.hash) params.set('hash', filters.hash);
|
||||
@@ -448,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
|
||||
@@ -467,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]);
|
||||
|
||||
@@ -476,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();
|
||||
@@ -568,10 +622,10 @@
|
||||
<option value="30">Last 30 min</option>
|
||||
<option value="60">Last 1 hour</option>
|
||||
<option value="180">Last 3 hours</option>
|
||||
<option value="360">Last 6 hours</option>
|
||||
<option value="720">Last 12 hours</option>
|
||||
<option value="1440">Last 24 hours</option>
|
||||
<option value="0">All time</option>
|
||||
<option value="360"${isMobile ? ' disabled title="Disabled on mobile to prevent browser crashes"' : ''}>Last 6 hours</option>
|
||||
<option value="720"${isMobile ? ' disabled title="Disabled on mobile to prevent browser crashes"' : ''}>Last 12 hours</option>
|
||||
<option value="1440"${isMobile ? ' disabled title="Disabled on mobile to prevent browser crashes"' : ''}>Last 24 hours</option>
|
||||
${isMobile ? '' : '<option value="0">All time</option>'}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
@@ -595,6 +649,7 @@
|
||||
<table class="data-table" id="pktTable">
|
||||
<thead><tr>
|
||||
<th scope="col"></th><th scope="col" class="col-region">Region</th><th scope="col" class="col-time">Time</th><th scope="col" class="col-hash">Hash</th><th scope="col" class="col-size">Size</th>
|
||||
<th scope="col" class="col-hashsize">HB</th>
|
||||
<th scope="col" class="col-type">Type</th><th scope="col" class="col-observer">Observer</th><th scope="col" class="col-path">Path</th><th scope="col" class="col-rpt">Rpt</th><th scope="col" class="col-details">Details</th>
|
||||
</tr></thead>
|
||||
<tbody id="pktBody"></tbody>
|
||||
@@ -662,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 ▾';
|
||||
@@ -751,7 +806,7 @@
|
||||
fTimeWindow.value = String(savedTimeWindowMin);
|
||||
fTimeWindow.addEventListener('change', () => {
|
||||
savedTimeWindowMin = Number(fTimeWindow.value);
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin < 0) savedTimeWindowMin = 15;
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
|
||||
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
|
||||
loadPackets();
|
||||
});
|
||||
@@ -783,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 {}
|
||||
@@ -796,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();
|
||||
@@ -814,8 +869,8 @@
|
||||
{ key: 'rpt', label: 'Rpt' },
|
||||
{ key: 'details', label: 'Details' },
|
||||
];
|
||||
const isMobile = window.innerWidth <= 640;
|
||||
const defaultHidden = isMobile ? ['region', 'hash', 'observer', 'path', 'rpt', 'size'] : ['region'];
|
||||
const isNarrow = window.innerWidth <= 640;
|
||||
const defaultHidden = isNarrow ? ['region', 'hash', 'observer', 'path', 'rpt', 'size'] : ['region'];
|
||||
let visibleCols;
|
||||
try {
|
||||
visibleCols = JSON.parse(localStorage.getItem('packets-visible-cols'));
|
||||
@@ -956,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);
|
||||
}
|
||||
}
|
||||
@@ -977,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;
|
||||
@@ -986,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') || '[]');
|
||||
@@ -994,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 = [];
|
||||
}
|
||||
@@ -1029,102 +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 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-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</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 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-type"><span class="badge badge-${typeClass}">${typeName}</span></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 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-type"><span class="badge badge-${typeClass}">${typeName}</span></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) {
|
||||
@@ -1208,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 {}
|
||||
@@ -1226,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);
|
||||
@@ -1405,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(),
|
||||
@@ -1483,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);
|
||||
@@ -1493,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;
|
||||
@@ -1501,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++) {
|
||||
@@ -1513,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));
|
||||
@@ -1563,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>`;
|
||||
@@ -1710,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) {
|
||||
@@ -1775,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);
|
||||
@@ -1783,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);
|
||||
@@ -1816,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;
|
||||
@@ -1825,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');
|
||||
|
||||
+59
-8
@@ -5,7 +5,9 @@
|
||||
--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;
|
||||
--status-yellow: #eab308;
|
||||
--status-red: #ef4444;
|
||||
@@ -127,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;
|
||||
@@ -297,6 +299,13 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
font-size: 10px; font-weight: 700; font-family: var(--mono);
|
||||
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
|
||||
}
|
||||
/* TODO: expose --transport-badge-bg/fg in customizer THEME_CSS_MAP (tracked in future milestone) */
|
||||
.badge-transport {
|
||||
display: inline-block; padding: 1px 5px; border-radius: 4px;
|
||||
font-size: 9px; font-weight: 700; font-family: var(--mono);
|
||||
background: var(--transport-badge-bg, #f59e0b20); color: var(--transport-badge-fg, #d97706);
|
||||
letter-spacing: .5px; vertical-align: middle;
|
||||
}
|
||||
.badge-obs {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 10px;
|
||||
font-size: 10px; font-weight: 600;
|
||||
@@ -366,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 {
|
||||
@@ -613,7 +626,10 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.node-detail { padding: 4px 0; }
|
||||
.node-detail-name { font-size: 20px; font-weight: 700; margin: 12px 0 4px; }
|
||||
.node-detail-role { margin-bottom: 12px; }
|
||||
.node-detail-section { margin-bottom: 16px; }
|
||||
.node-detail-section {
|
||||
background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 12px; margin-bottom: 8px;
|
||||
}
|
||||
.node-detail-section h4 {
|
||||
font-size: 12px; text-transform: uppercase; letter-spacing: .5px;
|
||||
color: var(--text-muted); margin-bottom: 8px; padding-bottom: 4px;
|
||||
@@ -826,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; }
|
||||
@@ -840,19 +872,37 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.map-controls { width: 180px; font-size: 12px; }
|
||||
}
|
||||
|
||||
/* === Responsive — Mobile (≤640px) === */
|
||||
@media (max-width: 640px) {
|
||||
/* Nav: hamburger + collapse */
|
||||
/* === 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: 99;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* === Responsive — Mobile (≤640px) === */
|
||||
@media (max-width: 640px) {
|
||||
.brand-text { display: none; }
|
||||
.nav-right { gap: 4px; }
|
||||
|
||||
@@ -1224,7 +1274,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
|
||||
/* Hide low-value columns on mobile */
|
||||
@media (max-width: 640px) {
|
||||
.col-region, .col-rpt, .col-size, .col-pubkey { display: none; }
|
||||
.col-region, .col-rpt, .col-size, .col-hashsize, .col-pubkey { display: none; }
|
||||
}
|
||||
|
||||
/* Clickable hop links */
|
||||
@@ -1370,6 +1420,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.hide-col-observer .col-observer,
|
||||
.hide-col-path .col-path,
|
||||
.hide-col-rpt .col-rpt,
|
||||
.hide-col-hashsize .col-hashsize,
|
||||
.hide-col-details .col-details { display: none; }
|
||||
|
||||
/* === Home page fixes === */
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Delete nodes from the database that fall outside the configured geo_filter polygon + bufferKm.
|
||||
Nodes with no GPS coordinates are always kept.
|
||||
|
||||
Usage:
|
||||
python3 prune-nodes-outside-geo-filter.py [db_path] [--config config.json] [--dry-run]
|
||||
|
||||
db_path Path to meshcore.db (default: /app/data/meshcore.db)
|
||||
--config PATH Path to config.json (default: /app/config.json)
|
||||
--dry-run Show what would be deleted without making any changes
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import math
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def point_in_polygon(lat, lon, polygon):
|
||||
"""Ray-casting algorithm."""
|
||||
inside = False
|
||||
n = len(polygon)
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
yi, xi = polygon[i] # lat, lon
|
||||
yj, xj = polygon[j]
|
||||
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def dist_to_segment_km(lat, lon, a, b):
|
||||
"""Approximate distance (km) from point to line segment, using flat-earth projection."""
|
||||
lat1, lon1 = a
|
||||
lat2, lon2 = b
|
||||
mid_lat = (lat1 + lat2) / 2.0
|
||||
cos_lat = math.cos(math.radians(mid_lat))
|
||||
km_per_deg_lat = 111.0
|
||||
km_per_deg_lon = 111.0 * cos_lat
|
||||
|
||||
# Translate so point is at origin
|
||||
ax = (lon1 - lon) * km_per_deg_lon
|
||||
ay = (lat1 - lat) * km_per_deg_lat
|
||||
bx = (lon2 - lon) * km_per_deg_lon
|
||||
by = (lat2 - lat) * km_per_deg_lat
|
||||
|
||||
abx, aby = bx - ax, by - ay
|
||||
ab_sq = abx * abx + aby * aby
|
||||
if ab_sq == 0:
|
||||
return math.sqrt(ax * ax + ay * ay)
|
||||
|
||||
t = max(0.0, min(1.0, -(ax * abx + ay * aby) / ab_sq))
|
||||
px = ax + t * abx
|
||||
py = ay + t * aby
|
||||
return math.sqrt(px * px + py * py)
|
||||
|
||||
|
||||
def node_passes_filter(lat, lon, polygon, buffer_km):
|
||||
"""Return True if the node should be kept."""
|
||||
if lat is None or lon is None:
|
||||
return True
|
||||
if lat == 0.0 and lon == 0.0:
|
||||
return True # no GPS fix
|
||||
if point_in_polygon(lat, lon, polygon):
|
||||
return True
|
||||
if buffer_km > 0:
|
||||
n = len(polygon)
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
if dist_to_segment_km(lat, lon, polygon[i], polygon[j]) <= buffer_km:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def load_geo_filter(config_path):
|
||||
"""Load polygon and bufferKm from config.json geo_filter section."""
|
||||
if not os.path.exists(config_path):
|
||||
print(f"ERROR: config not found at {config_path}")
|
||||
sys.exit(1)
|
||||
with open(config_path) as f:
|
||||
cfg = json.load(f)
|
||||
gf = cfg.get('geo_filter')
|
||||
if not gf:
|
||||
print("ERROR: no geo_filter section found in config.json")
|
||||
sys.exit(1)
|
||||
polygon = gf.get('polygon', [])
|
||||
if len(polygon) < 3:
|
||||
print("ERROR: geo_filter.polygon must have at least 3 points")
|
||||
sys.exit(1)
|
||||
buffer_km = gf.get('bufferKm', 0.0)
|
||||
print(f"Loaded geo_filter from {config_path}: {len(polygon)} points, bufferKm={buffer_km}")
|
||||
return polygon, buffer_km
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
dry_run = '--dry-run' in args
|
||||
args = [a for a in args if a != '--dry-run']
|
||||
|
||||
config_path = '/app/config.json'
|
||||
if '--config' in args:
|
||||
idx = args.index('--config')
|
||||
config_path = args[idx + 1]
|
||||
args = args[:idx] + args[idx + 2:]
|
||||
|
||||
db_path = args[0] if args else '/app/data/meshcore.db'
|
||||
|
||||
polygon, buffer_km = load_geo_filter(config_path)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"ERROR: database not found at {db_path}")
|
||||
sys.exit(1)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute('SELECT public_key, name, lat, lon FROM nodes ORDER BY name')
|
||||
nodes = cur.fetchall()
|
||||
|
||||
keep, remove = [], []
|
||||
for row in nodes:
|
||||
lat = row['lat']
|
||||
lon = row['lon']
|
||||
if node_passes_filter(lat, lon, polygon, buffer_km):
|
||||
keep.append(row)
|
||||
else:
|
||||
remove.append(row)
|
||||
|
||||
print(f"Total nodes in DB : {len(nodes)}")
|
||||
print(f"Nodes to keep : {len(keep)}")
|
||||
print(f"Nodes to delete : {len(remove)}")
|
||||
|
||||
if not remove:
|
||||
print("\nNothing to delete.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print("\nNodes that will be DELETED:")
|
||||
for row in remove:
|
||||
lat = row['lat'] or 0
|
||||
lon = row['lon'] or 0
|
||||
name = row['name'] or row['public_key'][:12]
|
||||
print(f" {name:<30} lat={lat:.4f} lon={lon:.4f}")
|
||||
|
||||
if dry_run:
|
||||
print("\n[dry-run] No changes made.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
confirm = input(f"\nDelete {len(remove)} nodes? Type 'yes' to confirm: ").strip()
|
||||
if confirm.lower() != 'yes':
|
||||
print("Aborted.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
pubkeys = [row['public_key'] for row in remove]
|
||||
cur.executemany('DELETE FROM nodes WHERE public_key = ?', [(pk,) for pk in pubkeys])
|
||||
conn.commit()
|
||||
print(f"\nDeleted {cur.rowcount if cur.rowcount >= 0 else len(pubkeys)} nodes.")
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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);
|
||||
+15
-6
@@ -363,8 +363,15 @@ async function run() {
|
||||
|
||||
// Test 4: Packets page loads with filter
|
||||
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 });
|
||||
// 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.waitForSelector('table tbody tr');
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
|
||||
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');
|
||||
// Use the specific filter input
|
||||
@@ -379,8 +386,7 @@ 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'));
|
||||
|
||||
const packetsRequestPromise = page.waitForRequest((req) => {
|
||||
@@ -392,8 +398,8 @@ async function run() {
|
||||
}
|
||||
}, { timeout: 10000 });
|
||||
|
||||
// Full reload to packets page — forces app to re-read localStorage
|
||||
await page.evaluate(() => { window.location.href = window.location.origin + '/#/packets'; window.location.reload(); });
|
||||
// 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}`);
|
||||
@@ -417,7 +423,10 @@ async function run() {
|
||||
|
||||
// Test: Packets groupByHash toggle changes view
|
||||
await test('Packets groupByHash toggle works', async () => {
|
||||
await page.waitForSelector('table tbody tr');
|
||||
// Restore wide time window — previous test set it to 60 min which excludes fixture data
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
|
||||
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');
|
||||
// Check initial state (default is grouped/active)
|
||||
|
||||
+2116
-147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
/* 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);
|
||||
+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,153 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoFilter Builder</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
|
||||
header { padding: 12px 16px; background: #0f0f23; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
header h1 { font-size: 1rem; font-weight: 600; color: #4a9eff; white-space: nowrap; }
|
||||
.controls { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
button { padding: 6px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; }
|
||||
#btnUndo { background: #333; color: #ccc; }
|
||||
#btnClear { background: #5a2020; color: #ffaaaa; }
|
||||
#btnUndo:hover { background: #444; }
|
||||
#btnClear:hover { background: #7a2020; }
|
||||
.hint { font-size: 0.8rem; color: #888; margin-left: auto; }
|
||||
#map { flex: 1; }
|
||||
#output-panel { background: #0f0f23; border-top: 1px solid #333; padding: 12px 16px; display: flex; gap: 12px; align-items: flex-start; }
|
||||
#output-panel label { font-size: 0.75rem; color: #888; white-space: nowrap; padding-top: 6px; }
|
||||
#output { flex: 1; background: #111; border: 1px solid #333; border-radius: 6px; padding: 10px 12px; font-family: monospace; font-size: 0.78rem; color: #7ec8e3; white-space: pre; overflow-x: auto; min-height: 54px; max-height: 140px; overflow-y: auto; cursor: text; }
|
||||
#output.empty { color: #555; font-style: italic; }
|
||||
#btnCopy { padding: 6px 14px; background: #1a4a7a; color: #7ec8e3; border-radius: 6px; border: none; cursor: pointer; font-size: 0.85rem; white-space: nowrap; align-self: flex-end; }
|
||||
#btnCopy:hover { background: #2a6aaa; }
|
||||
#btnCopy.copied { background: #1a6a3a; color: #7effa0; }
|
||||
#counter { font-size: 0.8rem; color: #888; padding-top: 6px; white-space: nowrap; }
|
||||
.bufferRow { display: flex; align-items: center; gap: 8px; }
|
||||
.bufferRow label { font-size: 0.85rem; color: #aaa; }
|
||||
.bufferRow input { width: 60px; padding: 5px 8px; background: #222; border: 1px solid #444; border-radius: 6px; color: #eee; font-size: 0.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>GeoFilter Builder</h1>
|
||||
<div class="controls">
|
||||
<button id="btnUndo">↩ Undo</button>
|
||||
<button id="btnClear">✕ Clear</button>
|
||||
</div>
|
||||
<div class="bufferRow">
|
||||
<label for="bufferKm">Buffer km:</label>
|
||||
<input type="number" id="bufferKm" value="20" min="0" max="500"/>
|
||||
</div>
|
||||
<span class="hint">Click on the map to add polygon points</span>
|
||||
</header>
|
||||
|
||||
<div id="map"></div>
|
||||
|
||||
<div id="output-panel">
|
||||
<label>config.json</label>
|
||||
<div id="output" class="empty">Add at least 3 points to generate config…</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;align-items:flex-end">
|
||||
<span id="counter">0 points</span>
|
||||
<button id="btnCopy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const map = L.map('map').setView([50.5, 4.4], 8);
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
let points = [];
|
||||
let markers = [];
|
||||
let polygon = null;
|
||||
let closingLine = null;
|
||||
|
||||
function latLonPair(latlng) {
|
||||
return [parseFloat(latlng.lat.toFixed(6)), parseFloat(latlng.lng.toFixed(6))];
|
||||
}
|
||||
|
||||
function render() {
|
||||
// Remove existing polygon and closing line
|
||||
if (polygon) { map.removeLayer(polygon); polygon = null; }
|
||||
if (closingLine) { map.removeLayer(closingLine); closingLine = null; }
|
||||
|
||||
if (points.length >= 3) {
|
||||
polygon = L.polygon(points, {
|
||||
color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12
|
||||
}).addTo(map);
|
||||
} else if (points.length === 2) {
|
||||
closingLine = L.polyline(points, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(map);
|
||||
}
|
||||
|
||||
updateOutput();
|
||||
}
|
||||
|
||||
function updateOutput() {
|
||||
const el = document.getElementById('output');
|
||||
const counter = document.getElementById('counter');
|
||||
counter.textContent = points.length + ' point' + (points.length !== 1 ? 's' : '');
|
||||
|
||||
if (points.length < 3) {
|
||||
el.textContent = 'Add at least 3 points to generate config…';
|
||||
el.classList.add('empty');
|
||||
return;
|
||||
}
|
||||
el.classList.remove('empty');
|
||||
|
||||
const bufferKm = parseFloat(document.getElementById('bufferKm').value) || 0;
|
||||
const config = { bufferKm, polygon: points };
|
||||
el.textContent = JSON.stringify({ geo_filter: config }, null, 2);
|
||||
}
|
||||
|
||||
map.on('click', function(e) {
|
||||
const pt = latLonPair(e.latlng);
|
||||
points.push(pt);
|
||||
|
||||
const idx = points.length;
|
||||
const marker = L.circleMarker(e.latlng, {
|
||||
radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9
|
||||
}).addTo(map).bindTooltip(String(idx), { permanent: true, direction: 'top', offset: [0, -8], className: 'pt-label' });
|
||||
markers.push(marker);
|
||||
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('btnUndo').addEventListener('click', function() {
|
||||
if (!points.length) return;
|
||||
points.pop();
|
||||
const m = markers.pop();
|
||||
if (m) map.removeLayer(m);
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('btnClear').addEventListener('click', function() {
|
||||
points = [];
|
||||
markers.forEach(m => map.removeLayer(m));
|
||||
markers = [];
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('bufferKm').addEventListener('input', updateOutput);
|
||||
|
||||
document.getElementById('btnCopy').addEventListener('click', function() {
|
||||
if (points.length < 3) return;
|
||||
const text = document.getElementById('output').textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.getElementById('btnCopy');
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user