mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 15:31:44 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c82172feb | |||
| 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:
|
||||
@@ -314,6 +321,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
|
||||
|
||||
@@ -51,6 +51,33 @@ 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.
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+35
-7
@@ -280,6 +280,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
|
||||
}
|
||||
|
||||
@@ -434,8 +445,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 +553,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 +617,8 @@ type PacketData struct {
|
||||
ObserverName string
|
||||
SNR *float64
|
||||
RSSI *float64
|
||||
Score *float64
|
||||
Direction *string
|
||||
Hash string
|
||||
RouteType int
|
||||
PayloadType int
|
||||
@@ -605,10 +629,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 +653,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,
|
||||
|
||||
@@ -1313,3 +1313,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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -1621,3 +1628,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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+50
-4
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -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)
|
||||
|
||||
@@ -109,6 +109,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 +136,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")
|
||||
|
||||
@@ -855,6 +857,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 +1202,17 @@ func (s *Server) handleAnalyticsHashSizes(w http.ResponseWriter, r *http.Request
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsHashCollisions(w http.ResponseWriter, r *http.Request) {
|
||||
if s.store != nil {
|
||||
writeJSON(w, s.store.GetAnalyticsHashCollisions())
|
||||
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 +1865,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})
|
||||
}
|
||||
|
||||
+813
-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,194 @@ 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 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 +2402,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 TestHashCollisionsRegionParamIgnored(t *testing.T) {
|
||||
// Issue #417: region param was accepted but ignored.
|
||||
// After fix, the endpoint should work without region and not cache per-region.
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
// Request without region
|
||||
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 (should be ignored, same result)
|
||||
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)
|
||||
}
|
||||
|
||||
// Both should return identical results
|
||||
if w1.Body.String() != w2.Body.String() {
|
||||
t.Error("responses differ with/without region param — region should be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+599
-184
File diff suppressed because it is too large
Load Diff
+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
|
||||
|
||||
@@ -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
|
||||
@@ -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 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
|
||||
|
||||
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": "3.2.0",
|
||||
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
+156
-293
@@ -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', { 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 {
|
||||
@@ -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 });
|
||||
|
||||
+76
-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 ---
|
||||
|
||||
+37
-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=1775078686">
|
||||
<link rel="stylesheet" href="home.css?v=1775078686">
|
||||
<link rel="stylesheet" href="live.css?v=1775078686">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -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,30 @@
|
||||
<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=1775078686"></script>
|
||||
<script src="customize.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1775078686"></script>
|
||||
<script src="hop-resolver.js?v=1775078686"></script>
|
||||
<script src="hop-display.js?v=1775078686"></script>
|
||||
<script src="app.js?v=1775078686"></script>
|
||||
<script src="home.js?v=1775078686"></script>
|
||||
<script src="packet-filter.js?v=1775078686"></script>
|
||||
<script src="packets.js?v=1775078686"></script>
|
||||
<script src="geo-filter-overlay.js?v=1775078686"></script>
|
||||
<script src="map.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1775078686" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
+47
-4
@@ -817,7 +817,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;
|
||||
@@ -1809,9 +1850,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];
|
||||
@@ -1824,11 +1867,11 @@
|
||||
}).addTo(animLayer);
|
||||
let pulseUp = true;
|
||||
const pulseTimer = setInterval(() => {
|
||||
if (!animLayer.hasLayer(ghost)) { clearInterval(pulseTimer); return; }
|
||||
if (!animLayer || !animLayer.hasLayer(ghost)) { clearInterval(pulseTimer); return; }
|
||||
ghost.setStyle({ fillOpacity: pulseUp ? 0.6 : 0.25, opacity: pulseUp ? 0.7 : 0.4 });
|
||||
pulseUp = !pulseUp;
|
||||
}, 600);
|
||||
setTimeout(() => { clearInterval(pulseTimer); if (animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost); }, 3000);
|
||||
setTimeout(() => { clearInterval(pulseTimer); if (animLayer && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost); }, 3000);
|
||||
}
|
||||
} else {
|
||||
pulseNode(hp.key, hp.pos, typeName);
|
||||
|
||||
+44
-2
@@ -95,7 +95,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>
|
||||
@@ -228,7 +228,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) {
|
||||
|
||||
+299
-108
@@ -24,8 +24,11 @@
|
||||
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 = {};
|
||||
@@ -34,6 +37,19 @@
|
||||
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
|
||||
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
|
||||
|
||||
// --- 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) {
|
||||
@@ -321,6 +337,13 @@
|
||||
|
||||
// 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;
|
||||
@@ -386,7 +409,9 @@
|
||||
packets = filtered.concat(packets);
|
||||
}
|
||||
totalCount += filtered.length;
|
||||
renderTableRows();
|
||||
// Debounce WS-triggered renders to avoid rapid full rebuilds
|
||||
clearTimeout(_wsRenderTimer);
|
||||
_wsRenderTimer = setTimeout(function () { renderTableRows(); }, 200);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -394,6 +419,14 @@
|
||||
function destroy() {
|
||||
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; }
|
||||
@@ -429,7 +462,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);
|
||||
@@ -568,10 +601,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 +628,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>
|
||||
@@ -751,7 +785,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();
|
||||
});
|
||||
@@ -814,8 +848,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'));
|
||||
@@ -977,6 +1011,234 @@
|
||||
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 ? (observers.find(o => o.id === headerObserverId)?.iata || '') : '';
|
||||
let groupPath = [];
|
||||
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
|
||||
const groupPathStr = renderPath(groupPath, headerObserverId);
|
||||
const groupTypeName = payloadTypeName(p.payload_type);
|
||||
const groupTypeClass = payloadTypeColor(p.payload_type);
|
||||
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const isSingle = p.count <= 1;
|
||||
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((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</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 ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
|
||||
let childPath = [];
|
||||
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
|
||||
const childPathStr = renderPath(childPath, c.observer_id);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-hashsize mono">${childHashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
|
||||
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(c.decoded_json || '{}'); } catch { return {}; } })())}</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// Build HTML for a single flat (ungrouped) packet row
|
||||
function buildFlatRowHtml(p) {
|
||||
let decoded, pathHops = [];
|
||||
try { decoded = JSON.parse(p.decoded_json || '{}'); } catch {}
|
||||
try { pathHops = JSON.parse(p.path_json || '[]') || []; } catch {}
|
||||
const region = p.observer_id ? (observers.find(o => o.id === p.observer_id)?.iata || '') : '';
|
||||
const typeName = payloadTypeName(p.payload_type);
|
||||
const typeClass = payloadTypeColor(p.payload_type);
|
||||
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const pathStr = renderPath(pathHops, p.observer_id);
|
||||
const detail = getDetailPreview(decoded);
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-hashsize mono">${hashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
|
||||
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${detail}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// 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;
|
||||
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 +1248,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 +1256,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 +1291,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) {
|
||||
@@ -1229,7 +1420,7 @@
|
||||
let decoded;
|
||||
try { decoded = JSON.parse(pkt.decoded_json); } catch { decoded = {}; }
|
||||
let pathHops;
|
||||
try { pathHops = JSON.parse(pkt.path_json || '[]'); } catch { pathHops = []; }
|
||||
try { pathHops = JSON.parse(pkt.path_json || '[]') || []; } catch { pathHops = []; }
|
||||
|
||||
// Resolve sender GPS — from packet directly, or from known node in DB
|
||||
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
|
||||
|
||||
+55
-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;
|
||||
@@ -613,7 +622,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 +838,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 +868,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 +1270,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 +1416,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()
|
||||
+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)
|
||||
|
||||
+374
-147
@@ -1431,6 +1431,31 @@ console.log('\n=== app.js: formatEngineBadge ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== APP.JS: isTransportRoute + transportBadge =====
|
||||
console.log('\n=== app.js: isTransportRoute + transportBadge ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
const isTransportRoute = ctx.isTransportRoute;
|
||||
const transportBadge = ctx.transportBadge;
|
||||
|
||||
test('isTransportRoute(0) is true (TRANSPORT_FLOOD)', () => assert.strictEqual(isTransportRoute(0), true));
|
||||
test('isTransportRoute(3) is true (TRANSPORT_DIRECT)', () => assert.strictEqual(isTransportRoute(3), true));
|
||||
test('isTransportRoute(1) is false (FLOOD)', () => assert.strictEqual(isTransportRoute(1), false));
|
||||
test('isTransportRoute(2) is false (DIRECT)', () => assert.strictEqual(isTransportRoute(2), false));
|
||||
test('isTransportRoute(null) is false', () => assert.strictEqual(isTransportRoute(null), false));
|
||||
test('isTransportRoute(undefined) is false', () => assert.strictEqual(isTransportRoute(undefined), false));
|
||||
|
||||
test('transportBadge(0) contains badge-transport class', () => {
|
||||
const html = transportBadge(0);
|
||||
assert.ok(html.includes('badge-transport'), 'should contain badge-transport class');
|
||||
assert.ok(html.includes('>T<'), 'should contain T label');
|
||||
assert.ok(html.includes('TRANSPORT_FLOOD'), 'should contain route type name in title');
|
||||
});
|
||||
test('transportBadge(1) returns empty string', () => assert.strictEqual(transportBadge(1), ''));
|
||||
}
|
||||
|
||||
// ===== APP.JS: formatVersionBadge =====
|
||||
console.log('\n=== app.js: formatVersionBadge ===');
|
||||
{
|
||||
@@ -1767,153 +1792,6 @@ console.log('\n=== analytics.js: sortChannels ===');
|
||||
});
|
||||
}
|
||||
|
||||
// === analytics.js: hash prefix helpers ===
|
||||
console.log('\n=== analytics.js: hash prefix helpers ===');
|
||||
{
|
||||
const ctx = (() => {
|
||||
const c = makeSandbox();
|
||||
c.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
||||
c.registerPage = () => {};
|
||||
c.api = () => Promise.resolve({});
|
||||
c.timeAgo = () => '—';
|
||||
c.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' };
|
||||
c.onWS = () => {};
|
||||
c.offWS = () => {};
|
||||
c.connectWS = () => {};
|
||||
c.invalidateApiCache = () => {};
|
||||
c.makeColumnsResizable = () => {};
|
||||
c.initTabBar = () => {};
|
||||
c.IATA_COORDS_GEO = {};
|
||||
loadInCtx(c, 'public/roles.js');
|
||||
loadInCtx(c, 'public/app.js');
|
||||
try { loadInCtx(c, 'public/analytics.js'); } catch (e) {
|
||||
for (const k of Object.keys(c.window)) c[k] = c.window[k];
|
||||
}
|
||||
return c;
|
||||
})();
|
||||
|
||||
const buildOne = ctx.window._analyticsBuildOneBytePrefixMap;
|
||||
const buildTwo = ctx.window._analyticsBuildTwoBytePrefixInfo;
|
||||
const buildHops = ctx.window._analyticsBuildCollisionHops;
|
||||
|
||||
const node = (pk, extra) => ({ public_key: pk, name: pk.slice(0, 4), ...(extra || {}) });
|
||||
|
||||
test('buildOneBytePrefixMap exports exist', () => assert.ok(buildOne, 'must be exported'));
|
||||
test('buildTwoBytePrefixInfo exports exist', () => assert.ok(buildTwo, 'must be exported'));
|
||||
test('buildCollisionHops exports exist', () => assert.ok(buildHops, 'must be exported'));
|
||||
|
||||
// --- 1-byte prefix map ---
|
||||
test('1-byte map has 256 keys', () => {
|
||||
const m = buildOne([]);
|
||||
assert.strictEqual(Object.keys(m).length, 256);
|
||||
});
|
||||
|
||||
test('1-byte map places node in correct bucket', () => {
|
||||
const n = node('AABBCC');
|
||||
const m = buildOne([n]);
|
||||
assert.strictEqual(m['AA'].length, 1);
|
||||
assert.strictEqual(m['AA'][0].public_key, 'AABBCC');
|
||||
assert.strictEqual(m['BB'].length, 0);
|
||||
});
|
||||
|
||||
test('1-byte map groups two nodes with same prefix', () => {
|
||||
const a = node('AA1111'), b = node('AA2222');
|
||||
const m = buildOne([a, b]);
|
||||
assert.strictEqual(m['AA'].length, 2);
|
||||
});
|
||||
|
||||
test('1-byte map is case-insensitive for node keys', () => {
|
||||
const n = node('aabbcc');
|
||||
const m = buildOne([n]);
|
||||
assert.strictEqual(m['AA'].length, 1);
|
||||
});
|
||||
|
||||
test('1-byte map: empty input yields all empty buckets', () => {
|
||||
const m = buildOne([]);
|
||||
assert.ok(Object.values(m).every(v => v.length === 0));
|
||||
});
|
||||
|
||||
// --- 2-byte prefix info ---
|
||||
test('2-byte info has 256 first-byte keys', () => {
|
||||
const info = buildTwo([]);
|
||||
assert.strictEqual(Object.keys(info).length, 256);
|
||||
});
|
||||
|
||||
test('2-byte info: no nodes → zero collisions', () => {
|
||||
const info = buildTwo([]);
|
||||
assert.ok(Object.values(info).every(e => e.collisionCount === 0));
|
||||
});
|
||||
|
||||
test('2-byte info: node placed in correct first-byte group', () => {
|
||||
const n = node('AABB1122');
|
||||
const info = buildTwo([n]);
|
||||
assert.strictEqual(info['AA'].groupNodes.length, 1);
|
||||
assert.strictEqual(info['BB'].groupNodes.length, 0);
|
||||
});
|
||||
|
||||
test('2-byte info: same 2-byte prefix = collision', () => {
|
||||
const a = node('AABB0001'), b = node('AABB0002');
|
||||
const info = buildTwo([a, b]);
|
||||
assert.strictEqual(info['AA'].collisionCount, 1);
|
||||
assert.strictEqual(info['AA'].maxCollision, 2);
|
||||
});
|
||||
|
||||
test('2-byte info: different 2-byte prefixes in same group = no collision', () => {
|
||||
const a = node('AA110001'), b = node('AA220002');
|
||||
const info = buildTwo([a, b]);
|
||||
assert.strictEqual(info['AA'].collisionCount, 0);
|
||||
assert.strictEqual(info['AA'].maxCollision, 0);
|
||||
});
|
||||
|
||||
test('2-byte info: twoByteMap built correctly', () => {
|
||||
const a = node('AABB0001'), b = node('AABB0002'), c = node('AACC0003');
|
||||
const info = buildTwo([a, b, c]);
|
||||
assert.strictEqual(Object.keys(info['AA'].twoByteMap).length, 2);
|
||||
assert.strictEqual(info['AA'].twoByteMap['AABB'].length, 2);
|
||||
assert.strictEqual(info['AA'].twoByteMap['AACC'].length, 1);
|
||||
});
|
||||
|
||||
// --- 3-byte stat summary (via buildCollisionHops) ---
|
||||
test('buildCollisionHops: no collisions returns empty array', () => {
|
||||
const nodes = [node('AA000001'), node('BB000002'), node('CC000003')];
|
||||
assert.deepStrictEqual(buildHops(nodes, 1), []);
|
||||
});
|
||||
|
||||
test('buildCollisionHops: detects 1-byte collision', () => {
|
||||
const nodes = [node('AA000001'), node('AA000002')];
|
||||
const hops = buildHops(nodes, 1);
|
||||
assert.strictEqual(hops.length, 1);
|
||||
assert.strictEqual(hops[0].hex, 'AA');
|
||||
assert.strictEqual(hops[0].count, 2);
|
||||
});
|
||||
|
||||
test('buildCollisionHops: detects 2-byte collision', () => {
|
||||
const nodes = [node('AABB0001'), node('AABB0002'), node('AACC0003')];
|
||||
const hops = buildHops(nodes, 2);
|
||||
assert.strictEqual(hops.length, 1);
|
||||
assert.strictEqual(hops[0].hex, 'AABB');
|
||||
assert.strictEqual(hops[0].count, 2);
|
||||
});
|
||||
|
||||
test('buildCollisionHops: detects 3-byte collision', () => {
|
||||
const nodes = [node('AABBCC0001'), node('AABBCC0002')];
|
||||
const hops = buildHops(nodes, 3);
|
||||
assert.strictEqual(hops.length, 1);
|
||||
assert.strictEqual(hops[0].hex, 'AABBCC');
|
||||
});
|
||||
|
||||
test('buildCollisionHops: size field set correctly', () => {
|
||||
const nodes = [node('AABB0001'), node('AABB0002')];
|
||||
const hops = buildHops(nodes, 2);
|
||||
assert.strictEqual(hops[0].size, 2);
|
||||
});
|
||||
|
||||
test('buildCollisionHops: empty input returns empty array', () => {
|
||||
assert.deepStrictEqual(buildHops([], 1), []);
|
||||
assert.deepStrictEqual(buildHops([], 2), []);
|
||||
assert.deepStrictEqual(buildHops([], 3), []);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== CUSTOMIZE.JS: initState merge behavior =====
|
||||
console.log('\n=== customize.js: initState merge behavior ===');
|
||||
@@ -2497,6 +2375,355 @@ console.log('\n=== channels.js: WS batch + region snapshot integration ===');
|
||||
assert.ok(historyCalls.includes('#/channels'), 'should route back to channels root');
|
||||
});
|
||||
}
|
||||
// ===== PACKETS.JS: savedTimeWindowMin default guard =====
|
||||
console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
|
||||
{
|
||||
async function captureInitialPacketsRequest(storageValue, innerWidth) {
|
||||
const ctx = makeSandbox();
|
||||
const apiCalls = [];
|
||||
if (storageValue !== undefined) ctx.localStorage.setItem('meshcore-time-window', storageValue);
|
||||
ctx.window.localStorage = ctx.localStorage;
|
||||
ctx.window.innerWidth = innerWidth;
|
||||
const dom = {
|
||||
pktRight: { addEventListener() {}, classList: { add() {}, remove() {}, contains() { return false; } }, innerHTML: '' },
|
||||
};
|
||||
ctx.document.getElementById = (id) => {
|
||||
if (id === 'fTimeWindow') return null;
|
||||
return dom[id] || null;
|
||||
};
|
||||
ctx.document.addEventListener = () => {};
|
||||
ctx.document.removeEventListener = () => {};
|
||||
ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } };
|
||||
ctx.window.addEventListener = () => {};
|
||||
ctx.window.removeEventListener = () => {};
|
||||
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } };
|
||||
ctx.CLIENT_TTL = { observers: 120000 };
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.registerPage = (name, handlers) => { if (name === 'packets') ctx._packetsHandlers = handlers; };
|
||||
ctx.api = (path) => {
|
||||
apiCalls.push(path);
|
||||
if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] });
|
||||
if (path.indexOf('/packets?') === 0) return Promise.reject(new Error('stop after request capture'));
|
||||
if (path.indexOf('/config/regions') === 0) return Promise.resolve({});
|
||||
return Promise.resolve({});
|
||||
};
|
||||
|
||||
loadInCtx(ctx, 'public/packets.js');
|
||||
assert.ok(ctx._packetsHandlers && typeof ctx._packetsHandlers.init === 'function',
|
||||
'packets page should register init handler');
|
||||
await ctx._packetsHandlers.init({ innerHTML: '' });
|
||||
|
||||
const firstPacketsCall = apiCalls.find(p => p.indexOf('/packets?') === 0);
|
||||
assert.ok(firstPacketsCall, 'packets API should be called during initial packets page load');
|
||||
const params = new URLSearchParams((firstPacketsCall.split('?')[1] || ''));
|
||||
return { firstPacketsCall, params };
|
||||
}
|
||||
|
||||
test('savedTimeWindowMin defaults to 15 when localStorage returns null', async () => {
|
||||
const r = await captureInitialPacketsRequest(undefined, 1366);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('savedTimeWindowMin defaults to 15 when localStorage returns "0"', async () => {
|
||||
const r = await captureInitialPacketsRequest('0', 1366);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('savedTimeWindowMin preserves valid value (60)', async () => {
|
||||
const r = await captureInitialPacketsRequest('60', 1366);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 45 && deltaMin < 75, `expected persisted ~60m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('savedTimeWindowMin defaults to 15 for negative value', async () => {
|
||||
const r = await captureInitialPacketsRequest('-5', 1366);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('savedTimeWindowMin defaults to 15 for NaN string', async () => {
|
||||
const r = await captureInitialPacketsRequest('abc', 1366);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('PACKET_LIMIT is 1000 on mobile', async () => {
|
||||
const r = await captureInitialPacketsRequest('15', 375);
|
||||
assert.strictEqual(r.params.get('limit'), '1000');
|
||||
});
|
||||
|
||||
test('PACKET_LIMIT is 50000 on desktop', async () => {
|
||||
const r = await captureInitialPacketsRequest('15', 1366);
|
||||
assert.strictEqual(r.params.get('limit'), '50000');
|
||||
});
|
||||
|
||||
test('mobile caps large time window to 15', async () => {
|
||||
const r = await captureInitialPacketsRequest('1440', 375);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected capped ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('mobile allows 180 min window', async () => {
|
||||
const r = await captureInitialPacketsRequest('180', 375);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 160 && deltaMin < 210, `expected ~180m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('mobile corrects desktop-persisted all-time value to 15 minutes', async () => {
|
||||
const r = await captureInitialPacketsRequest('0', 375);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'mobile should not keep all-time persisted value');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected capped ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
}
|
||||
// ===== My Nodes client-side filter (issue #381) =====
|
||||
{
|
||||
console.log('\n--- My Nodes client-side filter ---');
|
||||
|
||||
// Simulate the client-side filter logic from packets.js renderTableRows()
|
||||
function filterMyNodes(packets, allKeys) {
|
||||
if (!allKeys.length) return [];
|
||||
return packets.filter(p => {
|
||||
const dj = p.decoded_json || '';
|
||||
return allKeys.some(k => dj.includes(k));
|
||||
});
|
||||
}
|
||||
|
||||
const testPackets = [
|
||||
{ decoded_json: '{"pubKey":"abc123","name":"Node1"}' },
|
||||
{ decoded_json: '{"pubKey":"def456","name":"Node2"}' },
|
||||
{ decoded_json: '{"pubKey":"ghi789","name":"Node3","hops":["abc123"]}' },
|
||||
{ decoded_json: '' },
|
||||
{ decoded_json: null },
|
||||
];
|
||||
|
||||
test('filters packets matching a single pubkey', () => {
|
||||
const result = filterMyNodes(testPackets, ['abc123']);
|
||||
assert.strictEqual(result.length, 2, 'should match sender + hop');
|
||||
assert.ok(result[0].decoded_json.includes('abc123'));
|
||||
assert.ok(result[1].decoded_json.includes('abc123'));
|
||||
});
|
||||
|
||||
test('filters packets matching multiple pubkeys', () => {
|
||||
const result = filterMyNodes(testPackets, ['abc123', 'def456']);
|
||||
assert.strictEqual(result.length, 3);
|
||||
});
|
||||
|
||||
test('returns empty array for no matching keys', () => {
|
||||
const result = filterMyNodes(testPackets, ['zzz999']);
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
test('returns empty array when allKeys is empty', () => {
|
||||
const result = filterMyNodes(testPackets, []);
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
test('handles null/empty decoded_json gracefully', () => {
|
||||
const result = filterMyNodes(testPackets, ['abc123']);
|
||||
assert.strictEqual(result.length, 2);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Packets page: virtual scroll infrastructure =====
|
||||
{
|
||||
console.log('\nPackets page — virtual scroll:');
|
||||
const packetsSource = fs.readFileSync('public/packets.js', 'utf8');
|
||||
|
||||
// --- Behavioral tests using extracted logic ---
|
||||
|
||||
// Extract _cumulativeRowOffsets logic for testing
|
||||
function cumulativeRowOffsets(rowCounts) {
|
||||
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];
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
// Extract _getRowCount logic for testing (#424 — single source of truth)
|
||||
function getRowCount(p, grouped, expandedHashes, observerFilterSet) {
|
||||
if (!grouped) 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;
|
||||
}
|
||||
|
||||
test('cumulativeRowOffsets computes correct offsets for flat rows', () => {
|
||||
const counts = [1, 1, 1, 1, 1];
|
||||
const offsets = cumulativeRowOffsets(counts);
|
||||
assert.deepStrictEqual(offsets, [0, 1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('cumulativeRowOffsets handles expanded groups with multiple rows', () => {
|
||||
const counts = [1, 4, 1];
|
||||
const offsets = cumulativeRowOffsets(counts);
|
||||
assert.deepStrictEqual(offsets, [0, 1, 5, 6]);
|
||||
assert.strictEqual(offsets[offsets.length - 1], 6);
|
||||
});
|
||||
|
||||
test('total scroll height accounts for expanded group rows', () => {
|
||||
const VSCROLL_ROW_HEIGHT = 36;
|
||||
const counts = [1, 4, 1, 4, 1];
|
||||
const offsets = cumulativeRowOffsets(counts);
|
||||
const totalDomRows = offsets[offsets.length - 1];
|
||||
assert.strictEqual(totalDomRows, 11);
|
||||
assert.strictEqual(totalDomRows * VSCROLL_ROW_HEIGHT, 396);
|
||||
});
|
||||
|
||||
test('scroll height with all collapsed equals entries * row height', () => {
|
||||
const VSCROLL_ROW_HEIGHT = 36;
|
||||
const counts = [1, 1, 1, 1, 1];
|
||||
const offsets = cumulativeRowOffsets(counts);
|
||||
const totalDomRows = offsets[offsets.length - 1];
|
||||
assert.strictEqual(totalDomRows * VSCROLL_ROW_HEIGHT, 5 * VSCROLL_ROW_HEIGHT);
|
||||
});
|
||||
|
||||
// --- Behavioral tests for _getRowCount (#424, #428 — test logic, not source strings) ---
|
||||
|
||||
test('getRowCount returns 1 for flat (ungrouped) mode', () => {
|
||||
const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}] };
|
||||
assert.strictEqual(getRowCount(p, false, new Set(), null), 1);
|
||||
});
|
||||
|
||||
test('getRowCount returns 1 for collapsed group', () => {
|
||||
const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}] };
|
||||
assert.strictEqual(getRowCount(p, true, new Set(), null), 1);
|
||||
});
|
||||
|
||||
test('getRowCount returns 1+children for expanded group', () => {
|
||||
const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}, {observer_id: '3'}] };
|
||||
const expanded = new Set(['abc']);
|
||||
assert.strictEqual(getRowCount(p, true, expanded, null), 4);
|
||||
});
|
||||
|
||||
test('getRowCount filters children by observer set', () => {
|
||||
const p = { hash: 'abc', _children: [{observer_id: '1'}, {observer_id: '2'}, {observer_id: '3'}] };
|
||||
const expanded = new Set(['abc']);
|
||||
const obsFilter = new Set(['1', '3']);
|
||||
assert.strictEqual(getRowCount(p, true, expanded, obsFilter), 3);
|
||||
});
|
||||
|
||||
test('getRowCount returns 1 for expanded group with no _children', () => {
|
||||
const p = { hash: 'abc' };
|
||||
const expanded = new Set(['abc']);
|
||||
assert.strictEqual(getRowCount(p, true, expanded, null), 1);
|
||||
});
|
||||
|
||||
test('renderVisibleRows uses cumulative offsets not flat entry count', () => {
|
||||
assert.ok(packetsSource.includes('_cumulativeRowOffsets'),
|
||||
'renderVisibleRows should use cumulative row offsets');
|
||||
assert.ok(!packetsSource.includes('const totalRows = _displayPackets.length'),
|
||||
'should NOT use flat array length for total row count');
|
||||
});
|
||||
|
||||
test('renderVisibleRows skips DOM rebuild when range unchanged', () => {
|
||||
assert.ok(packetsSource.includes('startIdx === _lastVisibleStart && endIdx === _lastVisibleEnd'),
|
||||
'should skip rebuild when range is unchanged');
|
||||
});
|
||||
|
||||
test('lazy row generation — HTML built only for visible slice', () => {
|
||||
assert.ok(!packetsSource.includes('_lastRenderedRows'),
|
||||
'should NOT have pre-built row HTML cache');
|
||||
assert.ok(packetsSource.includes('_displayPackets.slice(startIdx, endIdx)'),
|
||||
'should slice display packets for visible range');
|
||||
assert.ok(packetsSource.includes('visibleSlice.map(p => builder(p))'),
|
||||
'should build HTML lazily per visible packet');
|
||||
});
|
||||
|
||||
test('observer filter Set is hoisted, not recreated per-packet', () => {
|
||||
assert.ok(packetsSource.includes('_observerFilterSet = filters.observer ? new Set(filters.observer.split'),
|
||||
'observer filter Set should be created once in renderTableRows');
|
||||
assert.ok(packetsSource.includes('_observerFilterSet.has(String(c.observer_id))'),
|
||||
'buildGroupRowHtml should use hoisted _observerFilterSet');
|
||||
});
|
||||
|
||||
test('buildFlatRowHtml has null-safe decoded_json', () => {
|
||||
const flatBuilderMatch = packetsSource.match(/function buildFlatRowHtml[\s\S]*?(?=\n function )/);
|
||||
assert.ok(flatBuilderMatch, 'buildFlatRowHtml should exist');
|
||||
assert.ok(flatBuilderMatch[0].includes("p.decoded_json || '{}'"),
|
||||
'buildFlatRowHtml should have null-safe decoded_json fallback');
|
||||
});
|
||||
|
||||
test('pathHops null guard in buildFlatRowHtml (issue #451)', () => {
|
||||
const flatBuilderMatch = packetsSource.match(/function buildFlatRowHtml[\s\S]*?(?=\n function )/);
|
||||
assert.ok(flatBuilderMatch, 'buildFlatRowHtml should exist');
|
||||
// The JSON.parse result must be coalesced with || [] to handle literal null from path_json
|
||||
assert.ok(flatBuilderMatch[0].includes("|| '[]') || []"),
|
||||
'buildFlatRowHtml should coalesce parsed path_json with || [] to guard against null');
|
||||
});
|
||||
|
||||
test('pathHops null guard in detail pane (issue #451)', () => {
|
||||
// The detail pane (selectPacket / showPacketDetail) also parses path_json
|
||||
const detailMatch = packetsSource.match(/let pathHops;\s*try \{[^}]+\} catch/);
|
||||
assert.ok(detailMatch, 'detail pane pathHops parsing should exist');
|
||||
assert.ok(detailMatch[0].includes("|| '[]') || []"),
|
||||
'detail pane should coalesce parsed path_json with || [] to guard against null');
|
||||
});
|
||||
|
||||
test('destroy cleans up virtual scroll state', () => {
|
||||
assert.ok(packetsSource.includes('detachVScrollListener'),
|
||||
'destroy should detach virtual scroll listener');
|
||||
assert.ok(packetsSource.includes("_displayPackets = []"),
|
||||
'destroy should reset display packets');
|
||||
assert.ok(packetsSource.includes("_rowCounts = []"),
|
||||
'destroy should reset row counts');
|
||||
assert.ok(packetsSource.includes("_lastVisibleStart = -1"),
|
||||
'destroy should reset visible start');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== live.js: nextHop null guards =====
|
||||
console.log('\n=== live.js: nextHop null guards ===');
|
||||
{
|
||||
const liveSource = fs.readFileSync('public/live.js', 'utf8');
|
||||
|
||||
test('nextHop guards animLayer null before use', () => {
|
||||
assert.ok(liveSource.includes('if (!animLayer) return;'),
|
||||
'nextHop must return early when animLayer is null (post-destroy)');
|
||||
});
|
||||
|
||||
test('nextHop setInterval guards animLayer null', () => {
|
||||
assert.ok(liveSource.includes('if (!animLayer || !animLayer.hasLayer(ghost))'),
|
||||
'setInterval in nextHop must guard animLayer null');
|
||||
});
|
||||
|
||||
test('nextHop setTimeout guards animLayer null', () => {
|
||||
assert.ok(liveSource.includes('if (animLayer && animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost)'),
|
||||
'setTimeout in nextHop must guard animLayer null');
|
||||
});
|
||||
|
||||
test('nextHop guards liveAnimCount element null', () => {
|
||||
assert.ok(liveSource.includes('const countEl = document.getElementById(\'liveAnimCount\')'),
|
||||
'nextHop must null-check liveAnimCount element');
|
||||
assert.ok(liveSource.includes('if (countEl) countEl.textContent = activeAnims'),
|
||||
'nextHop must conditionally update liveAnimCount');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
@@ -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